From c94ec52364087354446a3cadfad81c869ae5fd5e Mon Sep 17 00:00:00 2001 From: Bryce M <52695653+bmage8923@users.noreply.github.com> Date: Mon, 30 Mar 2026 09:05:38 -0600 Subject: [PATCH] feat: Add MCP Worker and setup documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the mcp-worker/ Cloudflare Worker that exposes daemon.md content as a queryable MCP endpoint (13 tools over JSON-RPC), plus docs/mcp-setup.md with full deployment and Claude Code integration instructions. Implements MCP Streamable HTTP spec: - GET → 405 (no server-push SSE) - Notifications (no id) → 202 Accepted - All tools include inputSchema (required by MCP clients) - initialize handshake handled before tools/list Replaces the README "documented separately" placeholder with a link to the new docs. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 15 +-- docs/mcp-setup.md | 156 ++++++++++++++++++++++++++ mcp-worker/package.json | 12 ++ mcp-worker/src/index.ts | 230 +++++++++++++++++++++++++++++++++++++++ mcp-worker/tsconfig.json | 12 ++ mcp-worker/wrangler.toml | 12 ++ 6 files changed, 427 insertions(+), 10 deletions(-) create mode 100644 docs/mcp-setup.md create mode 100644 mcp-worker/package.json create mode 100644 mcp-worker/src/index.ts create mode 100644 mcp-worker/tsconfig.json create mode 100644 mcp-worker/wrangler.toml diff --git a/README.md b/README.md index bade219..c1651cd 100644 --- a/README.md +++ b/README.md @@ -111,17 +111,12 @@ The website's dashboard fetches data from the MCP server at `mcp.daemon.danielmi If you want the full experience with a queryable MCP endpoint: -1. The MCP server is a separate Cloudflare Worker that: - - Parses your `daemon.md` file - - Stores the data in Cloudflare KV - - Serves it via JSON-RPC (MCP protocol) +The `mcp-worker/` directory contains a Cloudflare Worker that: +- Fetches `daemon.md` from your deployed Pages site (5-minute edge cache) +- Parses all sections and exposes them as 13 MCP tools +- Serves JSON-RPC over HTTPS — no KV or additional infrastructure needed -2. You'll need to: - - Create a Cloudflare Worker for your MCP endpoint - - Set up a KV namespace for data storage - - Update the dashboard component to point to your MCP URL - -3. The MCP server code and setup instructions will be documented separately. +Full deployment instructions, including Claude Code integration: **[docs/mcp-setup.md](docs/mcp-setup.md)** **Note**: The static site works without the MCP component—you just won't have the live API functionality until you set up your own MCP server. diff --git a/docs/mcp-setup.md b/docs/mcp-setup.md new file mode 100644 index 0000000..2608a55 --- /dev/null +++ b/docs/mcp-setup.md @@ -0,0 +1,156 @@ +# MCP Server Setup + +This guide walks you through deploying the Daemon MCP Worker — a Cloudflare Worker that exposes your `daemon.md` data as a queryable [Model Context Protocol](https://modelcontextprotocol.io) endpoint. + +Once deployed, AI assistants (Claude, Cursor, etc.) can query your daemon directly. + +## Prerequisites + +- A deployed Daemon Pages site (your `daemon.md` is publicly accessible at `https://your-daemon-domain.com/daemon.md`) +- A [Cloudflare account](https://cloudflare.com) (free tier works) +- [Bun](https://bun.sh) installed +- Wrangler authenticated: `bunx wrangler login` + +## 1. Update the daemon URL + +In `mcp-worker/src/index.ts`, update the first line to point to your deployed Pages site: + +```typescript +const DAEMON_MD_URL = 'https://your-daemon-domain.com/daemon.md'; +``` + +## 2. Configure the Worker + +Edit `mcp-worker/wrangler.toml`: + +```toml +name = "yourname-daemon-mcp" # unique Worker name in your Cloudflare account +main = "src/index.ts" +compatibility_date = "2025-08-20" +workers_dev = true + +# Optional: add a custom domain (e.g. mcp.daemon.yourdomain.com) +# See step 4 for custom domain setup. +# routes = [{ pattern = "mcp.daemon.yourdomain.com", custom_domain = true }] + +[observability] +enabled = false +``` + +> **Important:** If you add a `routes` field, it must appear **before** any `[section]` headers in the file. TOML parses keys as part of the nearest preceding section — a `routes` field inside `[observability]` will be silently ignored and your custom domain will not be registered. + +## 3. Deploy + +```bash +cd mcp-worker +bun install +bun run deploy +``` + +Wrangler will print your Worker URL: + +``` +Deployed daemon-mcp triggers + https://yourname-daemon-mcp.your-account.workers.dev +``` + +Your MCP endpoint is now live at that URL. Test it: + +```bash +curl -s -X POST https://yourname-daemon-mcp.your-account.workers.dev \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' | python3 -m json.tool +``` + +You should see a list of 13 tools, each with an `inputSchema` field. + +## 4. Custom domain (optional) + +To use a custom subdomain like `mcp.daemon.yourdomain.com`: + +1. Make sure `yourdomain.com` is managed by Cloudflare (nameservers pointed at Cloudflare). +2. Uncomment and update the `routes` line in `wrangler.toml` **before** the `[observability]` section: + +```toml +routes = [{ pattern = "mcp.daemon.yourdomain.com", custom_domain = true }] + +[observability] +enabled = false +``` + +3. Run `bun run deploy` again. Cloudflare will provision the DNS record and TLS certificate automatically. + +DNS propagation to upstream resolvers can take up to 24 hours. You can verify it's live on Cloudflare's resolver immediately: + +```bash +dig @1.1.1.1 mcp.daemon.yourdomain.com +``` + +## 5. Connect to Claude Code + +### Project-scoped (recommended) + +Add to `~/.claude.json` in your project root. This makes the MCP server available only when working in this project: + +```json +{ + "mcpServers": { + "daemon": { + "type": "http", + "url": "https://yourname-daemon-mcp.your-account.workers.dev" + } + } +} +``` + +### Global (all Claude Code sessions) + +Add to `~/.claude/settings.json` to make it available everywhere: + +```json +{ + "mcpServers": { + "daemon": { + "type": "http", + "url": "https://yourname-daemon-mcp.your-account.workers.dev" + } + } +} +``` + +After adding either config, run `/mcp` in Claude Code to verify the connection. You should see the server listed as connected with 13 tools available. + +## How it works + +The Worker: + +1. Receives JSON-RPC requests from MCP clients +2. Fetches `daemon.md` from your Pages site (cached at the edge for 5 minutes) +3. Parses the section-based format and routes tool calls to the appropriate section +4. Returns results as MCP `text` content + +The Worker implements the [MCP Streamable HTTP transport](https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/transports/#streamable-http): + +- `POST /` — handles all JSON-RPC requests +- `GET /` — returns `405 Method Not Allowed` (server-push SSE not supported) +- `OPTIONS /` — CORS preflight +- Requests without an `id` (notifications) return `202 Accepted` with no body +- All tool definitions include `inputSchema` — required by MCP clients for validation + +## Available tools + +| Tool | Description | +|------|-------------| +| `get_about` | Basic information | +| `get_mission` | Mission statement | +| `get_telos` | Full TELOS framework (raw text) | +| `get_telos_structured` | TELOS as structured JSON with sub-bullets | +| `get_current_location` | Current location | +| `get_favorite_books` | Reading list | +| `get_currently_reading` | Current fiction/hobby reading | +| `get_favorite_movies` | Movie recommendations | +| `get_favorite_podcasts` | Podcast recommendations | +| `get_preferences` | Work style and preferences | +| `get_predictions` | Predictions about the future | +| `get_all` | Everything as structured JSON | +| `get_section` | Any section by name (e.g. `ABOUT`, `TELOS`) | diff --git a/mcp-worker/package.json b/mcp-worker/package.json new file mode 100644 index 0000000..8548e9d --- /dev/null +++ b/mcp-worker/package.json @@ -0,0 +1,12 @@ +{ + "name": "daemon-mcp", + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.0.0", + "wrangler": "^4.0.0" + } +} diff --git a/mcp-worker/src/index.ts b/mcp-worker/src/index.ts new file mode 100644 index 0000000..58a0f62 --- /dev/null +++ b/mcp-worker/src/index.ts @@ -0,0 +1,230 @@ +// Update this URL to point to your deployed Pages site's daemon.md +const DAEMON_MD_URL = 'https://daemon.danielmiessler.com/daemon.md'; + +const EMPTY_SCHEMA = { type: 'object', properties: {} } as const; + +const TOOLS = [ + { name: 'get_about', description: 'Get basic information about this person', inputSchema: EMPTY_SCHEMA }, + { name: 'get_mission', description: 'Get current mission statement', inputSchema: EMPTY_SCHEMA }, + { name: 'get_telos', description: 'Get complete TELOS framework', inputSchema: EMPTY_SCHEMA }, + { name: 'get_current_location', description: 'Get current location', inputSchema: EMPTY_SCHEMA }, + { name: 'get_favorite_books', description: 'Get recommended reading list', inputSchema: EMPTY_SCHEMA }, + { name: 'get_favorite_movies', description: 'Get movie recommendations', inputSchema: EMPTY_SCHEMA }, + { name: 'get_favorite_podcasts',description: 'Get podcast recommendations', inputSchema: EMPTY_SCHEMA }, + { name: 'get_preferences', description: 'Get work style and preferences', inputSchema: EMPTY_SCHEMA }, + { name: 'get_predictions', description: 'Get predictions about the future', inputSchema: EMPTY_SCHEMA }, + { name: 'get_currently_reading',description: 'Get current fiction/hobby reading', inputSchema: EMPTY_SCHEMA }, + { name: 'get_telos_structured', description: 'Get TELOS as structured JSON with sub-bullets per entry', inputSchema: EMPTY_SCHEMA }, + { name: 'get_all', description: 'Get all available data as structured JSON', inputSchema: EMPTY_SCHEMA }, + { + name: 'get_section', + description: 'Get a specific section by name', + inputSchema: { + type: 'object', + properties: { + section: { type: 'string', description: 'Section name (e.g. ABOUT, MISSION, TELOS)' }, + }, + required: ['section'], + }, + }, +]; + +async function fetchDaemonMd(ctx: ExecutionContext): Promise { + const cacheKey = new Request(DAEMON_MD_URL); + const cache = caches.default; + + const cached = await cache.match(cacheKey); + if (cached) return cached.text(); + + const response = await fetch(DAEMON_MD_URL); + if (!response.ok) throw new Error(`Failed to fetch daemon.md: ${response.status}`); + const text = await response.text(); + + ctx.waitUntil( + cache.put(cacheKey, new Response(text, { + headers: { 'Cache-Control': 'max-age=300', 'Content-Type': 'text/plain' } + })) + ); + + return text; +} + +function parseSections(content: string): Record { + const sections: Record = {}; + let currentSection: string | null = null; + const currentContent: string[] = []; + + for (const line of content.split('\n')) { + if (line.trim().startsWith('#')) continue; // skip comments + + const match = line.match(/^\[([A-Z_]+)\]$/); + if (match) { + if (currentSection) sections[currentSection] = currentContent.join('\n').trim(); + currentSection = match[1]; + currentContent.length = 0; + } else if (currentSection) { + currentContent.push(line); + } + } + + if (currentSection) sections[currentSection] = currentContent.join('\n').trim(); + return sections; +} + +function parseList(text: string): string[] { + return text + .split('\n') + .filter(line => line.trim().startsWith('-')) + .map(line => line.replace(/^-\s*/, '').trim()) + .filter(Boolean); +} + +// Extract P1/M1/G1-style items from the TELOS section for dashboard rendering +function parseTelosItems(text: string): string[] { + return text + .split('\n') + .filter(line => line.trim().match(/^-\s+[PMG]\d+:/)) + .map(line => line.replace(/^-\s+/, '').trim()) + .filter(Boolean); +} + +interface TelosEntry { id: string; title: string; bullets: string[] } +interface TelosStructured { problems: TelosEntry[]; missions: TelosEntry[]; goals: TelosEntry[] } + +function parseTelosStructured(text: string): TelosStructured { + const problems: TelosEntry[] = []; + const missions: TelosEntry[] = []; + const goals: TelosEntry[] = []; + let current: TelosEntry | null = null; + + for (const line of text.split('\n')) { + const top = line.match(/^-\s+([PMG])(\d+):\s*(.+)/); + if (top) { + const [, type, num, title] = top; + current = { id: `${type}${num}`, title: title.trim(), bullets: [] }; + if (type === 'P') problems.push(current); + else if (type === 'M') missions.push(current); + else if (type === 'G') goals.push(current); + continue; + } + const sub = line.match(/^\s{2,}-\s+(.+)/); + if (sub && current) current.bullets.push(sub[1].trim()); + } + + return { problems, missions, goals }; +} + +function buildDaemonData(sections: Record) { + return { + about: sections['ABOUT'], + mission: sections['MISSION'], + telos: sections['TELOS'] ? parseTelosItems(sections['TELOS']) : undefined, + current_location: sections['CURRENT_LOCATION'], + favorite_books: sections['FAVORITE_BOOKS'] ? parseList(sections['FAVORITE_BOOKS']) : undefined, + favorite_movies: sections['FAVORITE_MOVIES'] ? parseList(sections['FAVORITE_MOVIES']) : undefined, + favorite_podcasts: sections['FAVORITE_PODCASTS'] ? parseList(sections['FAVORITE_PODCASTS']) : undefined, + preferences: sections['PREFERENCES'] ? parseList(sections['PREFERENCES']) : undefined, + daily_routine: sections['DAILY_ROUTINE'] ? parseList(sections['DAILY_ROUTINE']) : undefined, + predictions: sections['PREDICTIONS'] ? parseList(sections['PREDICTIONS']) : undefined, + currently_reading: sections['CURRENTLY_READING'] ? parseList(sections['CURRENTLY_READING']) : undefined, + last_updated: new Date().toISOString(), + }; +} + +function callTool(name: string, sections: Record, args?: Record): string { + switch (name) { + case 'get_about': return sections['ABOUT'] ?? 'Not available'; + case 'get_mission': return sections['MISSION'] ?? 'Not available'; + case 'get_telos': return sections['TELOS'] ?? 'Not available'; + case 'get_current_location': return sections['CURRENT_LOCATION'] ?? 'Not available'; + case 'get_favorite_books': return sections['FAVORITE_BOOKS'] ?? 'Not available'; + case 'get_favorite_movies': return sections['FAVORITE_MOVIES'] ?? 'Not available'; + case 'get_favorite_podcasts':return sections['FAVORITE_PODCASTS'] ?? 'Not available'; + case 'get_preferences': return sections['PREFERENCES'] ?? 'Not available'; + case 'get_predictions': return sections['PREDICTIONS'] ?? 'Not available'; + case 'get_currently_reading':return sections['CURRENTLY_READING'] ?? 'Not available'; + case 'get_telos_structured': return JSON.stringify(parseTelosStructured(sections['TELOS'] ?? '')); + case 'get_all': return JSON.stringify(buildDaemonData(sections)); + case 'get_section': { + const key = args?.section?.toUpperCase(); + return key && sections[key] ? sections[key] : 'Section not found'; + } + default: return 'Tool not found'; + } +} + +const CORS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Accept, MCP-Session-Id, MCP-Protocol-Version, Last-Event-ID', +}; + +function jsonRpcOk(id: unknown, result: unknown): Response { + return new Response(JSON.stringify({ jsonrpc: '2.0', result, id }), { + headers: { 'Content-Type': 'application/json', ...CORS }, + }); +} + +function jsonRpcErr(id: unknown, code: number, message: string): Response { + return new Response(JSON.stringify({ jsonrpc: '2.0', error: { code, message }, id }), { + status: 400, + headers: { 'Content-Type': 'application/json', ...CORS }, + }); +} + +export default { + async fetch(request: Request, _env: unknown, ctx: ExecutionContext): Promise { + if (request.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: CORS }); + } + + // MCP Streamable HTTP spec: GET is not supported for server-push-less implementations + if (request.method === 'GET') { + return new Response('Method Not Allowed', { status: 405, headers: CORS }); + } + + if (request.method !== 'POST') { + return new Response('Method Not Allowed', { status: 405, headers: CORS }); + } + + let body: { method?: string; params?: { name?: string; arguments?: Record }; id?: unknown }; + try { + body = await request.json(); + } catch { + return jsonRpcErr(null, -32700, 'Parse error'); + } + + const { method, params, id } = body; + + // Notifications and responses have no id — return 202 Accepted per spec + if (id === undefined || id === null) { + return new Response(null, { status: 202, headers: CORS }); + } + + if (method === 'initialize') { + return jsonRpcOk(id, { + protocolVersion: '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { name: 'daemon-mcp', version: '1.0.0' }, + }); + } + + if (method === 'tools/list') { + return jsonRpcOk(id, { tools: TOOLS }); + } + + if (method === 'tools/call') { + try { + const content = await fetchDaemonMd(ctx); + const sections = parseSections(content); + const result = callTool(params?.name ?? '', sections, params?.arguments); + return jsonRpcOk(id, { content: [{ type: 'text', text: result }] }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Internal error'; + return jsonRpcErr(id, -32603, message); + } + } + + return jsonRpcErr(id, -32601, 'Method not found'); + }, +}; diff --git a/mcp-worker/tsconfig.json b/mcp-worker/tsconfig.json new file mode 100644 index 0000000..168765e --- /dev/null +++ b/mcp-worker/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true + }, + "include": ["src/**/*"] +} diff --git a/mcp-worker/wrangler.toml b/mcp-worker/wrangler.toml new file mode 100644 index 0000000..ec1d4d3 --- /dev/null +++ b/mcp-worker/wrangler.toml @@ -0,0 +1,12 @@ +name = "daemon-mcp" # rename to something like "yourname-daemon-mcp" +main = "src/index.ts" +compatibility_date = "2025-08-20" +workers_dev = true + +# Uncomment and update to add a custom domain (e.g. mcp.daemon.yourdomain.com) +# routes must appear BEFORE any [section] headers — TOML parses them as part of +# the section otherwise and wrangler will silently ignore them. +# routes = [{ pattern = "mcp.daemon.yourdomain.com", custom_domain = true }] + +[observability] +enabled = false