206 lines
7.7 KiB
Python
206 lines
7.7 KiB
Python
# 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()
|