"""mcp-server-tutorial-python.py — a dependency-free MCP server over stdio, in plain Python. What it does: speaks the Model Context Protocol (JSON-RPC 2.0, one message per line on stdin/stdout, logs on stderr only) and exposes two tools: word_count(text) -> counts words, lines, and characters in a string read_lines(path, start, count) -> returns lines from a file under the server's root directory It answers `initialize` and `notifications/initialized` for hosts on the 2025-06-18 revision, `tools/list`, `tools/call`, and `ping`, and returns JSON-RPC errors for anything else. The stdio transport rules it follows: messages are newline-delimited, nothing that is not a protocol message is written to stdout, and diagnostics go to stderr. Inputs: MCP_ROOT environment variable (directory read_lines may read; default: the current directory). Run under a host, for example: claude mcp add --transport stdio notes -- python mcp-server-tutorial-python.py or exercise it by hand: printf '%s\\n' '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | python mcp-server-tutorial-python.py Python 3.13, standard library only. """ from __future__ import annotations import json import os import sys ROOT = os.path.abspath(os.environ.get("MCP_ROOT", ".")) PROTOCOL = "2025-06-18" TOOLS = [ { "name": "word_count", "description": "Count words, lines, and characters in a piece of text.", "inputSchema": { "type": "object", "properties": {"text": {"type": "string", "description": "The text to measure"}}, "required": ["text"], }, }, { "name": "read_lines", "description": "Read up to `count` lines of a text file under the server root, starting at 1-based line `start`.", "inputSchema": { "type": "object", "properties": { "path": {"type": "string", "description": "Path relative to the server root"}, "start": {"type": "integer", "description": "First line to return (1-based)", "default": 1}, "count": {"type": "integer", "description": "How many lines (max 200)", "default": 50}, }, "required": ["path"], }, }, ] def log(msg: str) -> None: print(f"[mcp-server] {msg}", file=sys.stderr, flush=True) def tool_word_count(args: dict) -> str: text = str(args["text"]) return json.dumps({"words": len(text.split()), "lines": text.count("\n") + (1 if text else 0), "characters": len(text)}) def tool_read_lines(args: dict) -> str: full = os.path.abspath(os.path.join(ROOT, str(args["path"]))) if not full.startswith(ROOT + os.sep) and full != ROOT: raise ValueError(f"path escapes the server root: {args['path']}") start = max(1, int(args.get("start", 1))) count = min(200, max(1, int(args.get("count", 50)))) with open(full, encoding="utf-8", errors="replace") as f: lines = f.read().splitlines() chunk = lines[start - 1:start - 1 + count] return "\n".join(f"{start + i}: {line}" for i, line in enumerate(chunk)) or "(no lines in that range)" HANDLERS = {"word_count": tool_word_count, "read_lines": tool_read_lines} def error(id_, code: int, message: str) -> dict: return {"jsonrpc": "2.0", "id": id_, "error": {"code": code, "message": message}} def handle(msg: dict) -> dict | None: method, id_, params = msg.get("method"), msg.get("id"), msg.get("params") or {} if method == "initialize": return {"jsonrpc": "2.0", "id": id_, "result": { "protocolVersion": PROTOCOL, "capabilities": {"tools": {"listChanged": False}}, "serverInfo": {"name": "notes-tools", "version": "0.1.0"}}} if method == "notifications/initialized": log("client initialized") return None # notifications get no response if method == "ping": return {"jsonrpc": "2.0", "id": id_, "result": {}} if method == "tools/list": return {"jsonrpc": "2.0", "id": id_, "result": {"tools": TOOLS}} if method == "tools/call": name, args = params.get("name"), params.get("arguments") or {} if name not in HANDLERS: return error(id_, -32602, f"Unknown tool: {name}") try: text = HANDLERS[name](args) return {"jsonrpc": "2.0", "id": id_, "result": {"content": [{"type": "text", "text": text}], "isError": False}} except Exception as e: # noqa: BLE001 - tool failures are results, not protocol errors return {"jsonrpc": "2.0", "id": id_, "result": {"content": [{"type": "text", "text": f"{type(e).__name__}: {e}"}], "isError": True}} if id_ is None: return None # unknown notification: ignore return error(id_, -32601, f"Method not found: {method}") def main() -> None: log(f"serving {len(TOOLS)} tools, root={ROOT}") for line in sys.stdin: line = line.strip() if not line: continue try: msg = json.loads(line) except json.JSONDecodeError: sys.stdout.write(json.dumps(error(None, -32700, "Parse error")) + "\n") sys.stdout.flush() continue reply = handle(msg) if reply is not None: sys.stdout.write(json.dumps(reply) + "\n") sys.stdout.flush() if __name__ == "__main__": main()