initial upl

This commit is contained in:
Luxferre
2026-03-10 10:46:23 +02:00
commit 7e56c692af
5 changed files with 287 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
uv.lock
dist
__pycache__
+58
View File
@@ -0,0 +1,58 @@
# Cities MCP: a simple MCP server for managing Web pages in your Neocities sites
This is an stdio-only, Python-based MCP server that allows your coding agent to manage your Neocities web pages: list, sync, upload and do everything else that an official free-tier Neocities API allows. Additionally, it supports managing multiple accounts at once, thanks to the config format invented for the [Multicities](https://codeberg.org/luxferre/sh-goodies/src/branch/master/cities.sh) site manager.
**Disclaimer**: this, as well as Multicities, is a purely hobbyist project that is not affiliated with Neocities hosting in any possible way. It makes use of the official Neocities API that may at any moment become outdated or subject to additional restrictions without prior notice or updates to this package. Use at your own risk and discretion.
## Installation
You'll need `uv` as a prerequisite, although you can build the package using different methods.
First, clone the repo and run `uv build`:
```
git clone https://codeberg.org/luxferre/cities-mcp.git
cd cities-mcp
uv build
```
Then, configure your coding agent.
For Gemini CLI/Antigravity/Cursor/Claude etc, the configuration should look like this:
```
"mcpServers": {
//...
"neocities": {
"command": "uv",
"args": [
"run",
"--directory",
"/path/to/your/cities-mcp",
"cities-mcp"
]
},
//...
}
```
For OpenCode, it should look like this:
```
"mcp": {
//...
"neocities": {
"type": "local",
"enabled": true,
"command": ["uv", "run", "--directory", "/path/to/your/cities-mcp", "cities-mcp"]
},
//...
}
```
## Account configuration
The Neocities account config file format is fully compatible with the one used in Multicities. Just log in with either Multicities or directly via your coding agent using this server's `login` tool, and you're ready to go.
## Credits
Created by Luxferre in 2026, released into public domain with no warranties.
Executable
+205
View File
@@ -0,0 +1,205 @@
# Cities MCP: Neocities site management MCP server (stdio-only)
# Depends upon httpx and FastMCP libraries
# Created by Luxferre in 2026, released into public domain
import sys
import json
import hashlib
from pathlib import Path
try:
import httpx
from fastmcp import FastMCP
except ImportError:
print("Please install required dependencies: pip install fastmcp httpx", file=sys.stderr)
sys.exit(1)
mcp = FastMCP("neocities")
API_ROOT = "https://neocities.org/api"
CONFIG_FILE = Path.home() / ".multicities.json"
def get_auth_token(username: str) -> str:
"""Get auth token from config file for the given user"""
if not CONFIG_FILE.exists():
raise ValueError(f"Config file {CONFIG_FILE} not found. Please log in first.")
with open(CONFIG_FILE, "r") as f:
try:
config = json.load(f)
except json.JSONDecodeError:
config = {}
token = config.get(username)
if not token:
raise ValueError(f"No token found for user '{username}'. Please log in first.")
return token
def get_auth_headers(username: str) -> dict:
"""Generate auth headers for HTTP requests"""
return {"Authorization": f"Bearer {get_auth_token(username)}"}
@mcp.tool(description="Login to a Neocities account using username and password. This will fetch an API token and save it to ~/.multicities.json for future use.")
def login(username: str, password: str) -> str:
"""Login to Neocities to get an API token. Saves token to ~/.multicities.json"""
response = httpx.get(f"{API_ROOT}/key", auth=(username, password))
if response.status_code != 200:
return f"Login failed: HTTP {response.status_code} - {response.text}"
data = response.json()
if data.get("result") == "error":
return f"Login failed: {data.get('message')}"
token = data.get("api_key")
if not token:
return "Login failed: No api_key in response."
config: dict = {}
if CONFIG_FILE.exists():
with open(CONFIG_FILE, "r") as f:
try:
config = json.load(f)
except json.JSONDecodeError:
pass
config[username] = token
with open(CONFIG_FILE, "w") as f:
json.dump(config, f, indent=2)
return f"Login successful for {username}. Token saved."
@mcp.tool(description="Retrieve detailed information about a Neocities site, including views, hits, creation date, last update time, tags, and custom domain configuration.")
def info(username: str) -> str:
"""Get site info for the given username/sitename."""
headers = get_auth_headers(username)
response = httpx.get(f"{API_ROOT}/info", headers=headers)
if response.status_code != 200:
return f"Error {response.status_code}: {response.text}"
data = response.json()
if data.get("result") == "error":
return f"Error: {data.get('message')}"
info_dict = data.get("info", {})
output = []
output.append(f"Sitename: {info_dict.get('sitename')}")
output.append(f"Views: {info_dict.get('views')}")
output.append(f"Hits: {info_dict.get('hits')}")
output.append(f"Created at: {info_dict.get('created_at')}")
output.append(f"Last updated at: {info_dict.get('last_updated')}")
output.append(f"Custom domain: {info_dict.get('domain', 'None')}")
tags = info_dict.get('tags')
if not tags:
tags = []
# Ensure standard tags logic (like cities.sh '@csv' mapping doesn't crash)
tag_strs = [f'"{t}"' for t in tags]
output.append(f"Tags: {','.join(tag_strs)}")
return "\n".join(output)
@mcp.tool(description="List all files and directories on a Neocities site, including their types (file or dir), sizes in bytes, and relative paths.")
def list_files(username: str) -> str:
"""List site files for the given username/sitename."""
headers = get_auth_headers(username)
response = httpx.get(f"{API_ROOT}/list", headers=headers)
if response.status_code != 200:
return f"Error {response.status_code}: {response.text}"
data = response.json()
if data.get("result") == "error":
return f"Error: {data.get('message')}"
output = []
for item in data.get("files", []):
type_str = "dir" if item.get("is_directory") else "file"
size = item.get("size", 0)
path = item.get("path", "")
output.append(f"{type_str}\t{size}\t{path}")
return "\n".join(output)
@mcp.tool(description="Upload a single local file to a Neocities site at the specified path. If remote_path is not provided, the file's original name will be used.")
def upload(username: str, local_path: str, remote_path: str | None = None) -> str:
"""Upload a single file to Neocities."""
headers = get_auth_headers(username)
p = Path(local_path).resolve()
if not p.is_file():
return f"Error: Local file {local_path} not found."
if not remote_path:
remote_path = p.name
with open(p, "rb") as f:
files = {remote_path: f}
response = httpx.post(f"{API_ROOT}/upload", headers=headers, files=files)
if response.status_code != 200:
return f"Error {response.status_code}: {response.text}"
return f"Upload successful: {response.text}"
@mcp.tool(description="Delete a specific file or directory from a Neocities site given its remote path.")
def delete_file(username: str, remote_path: str) -> str:
"""Delete a remote file from Neocities."""
headers = get_auth_headers(username)
data = {"filenames[]": remote_path}
response = httpx.post(f"{API_ROOT}/delete", headers=headers, data=data)
if response.status_code != 200:
return f"Error {response.status_code}: {response.text}"
return f"Delete successful: {response.text}"
@mcp.tool(description="Synchronize a local directory with a Neocities site. It recursively hashes local files and only uploads those that have changed or are missing on the remote site, skipping hidden files and directories.")
def sync(username: str, local_dir: str) -> str:
"""Sync a local directory to Neocities. Skips hidden files and up-to-date files."""
headers = get_auth_headers(username)
base_dir = Path(local_dir).resolve()
if not base_dir.is_dir():
return f"Error: Directory {local_dir} not found."
# Get remote list
list_response = httpx.get(f"{API_ROOT}/list", headers=headers)
if list_response.status_code != 200:
return f"Failed to get remote list: {list_response.text}"
remote_files = {}
for item in list_response.json().get("files", []):
if not item.get("is_directory"):
remote_files[item["path"]] = item.get("sha1_hash")
results = []
# Iterate local files skipping hidden dots
for p in base_dir.rglob("*"):
if p.is_file():
# Check if any parent part or filename starts with '.'
if any(part.startswith('.') for part in p.relative_to(base_dir).parts):
continue
remote_path = p.relative_to(base_dir).as_posix()
# calculate sha1
sha1 = hashlib.sha1()
with open(p, "rb") as f:
while chunk := f.read(8192):
sha1.update(chunk)
local_hash = sha1.hexdigest()
prev_hash = remote_files.get(remote_path)
if prev_hash != local_hash:
results.append(f"Updating {remote_path} (prev: {prev_hash}, cur: {local_hash})")
with open(p, "rb") as f:
files = {remote_path: f}
upload_response = httpx.post(f"{API_ROOT}/upload", headers=headers, files=files)
if upload_response.status_code != 200:
results.append(f"Error uploading {remote_path}: {upload_response.text}")
else:
data = upload_response.json()
if data.get("result") == "error":
results.append(f"Error uploading {remote_path}: {data.get('message')}")
else:
results.append(f"Successfully uploaded {remote_path}")
results.append("All files synced!")
return "\n".join(results)
if __name__ == "__main__":
mcp.run()
+19
View File
@@ -0,0 +1,19 @@
[project]
name = "cities-mcp"
version = "0.1.0"
description = "A FastMCP server for managing Neocities sites"
dependencies = [
"fastmcp>=0.1.0",
"httpx>=0.27.0"
]
requires-python = ">=3.10"
[project.scripts]
cities-mcp = "cities_mcp:mcp.run"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["cities_mcp.py"]
+2
View File
@@ -0,0 +1,2 @@
fastmcp>=0.1.0
httpx>=0.27.0