initial upl
This commit is contained in:
Executable
+205
@@ -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()
|
||||
Reference in New Issue
Block a user