diff --git a/examples/basic-server-vanillajs-without-libs/.gitignore b/examples/basic-server-vanillajs-without-libs/.gitignore new file mode 100644 index 00000000..7310e736 --- /dev/null +++ b/examples/basic-server-vanillajs-without-libs/.gitignore @@ -0,0 +1 @@ +.wrangler \ No newline at end of file diff --git a/examples/basic-server-vanillajs-without-libs/package.json b/examples/basic-server-vanillajs-without-libs/package.json new file mode 100644 index 00000000..7622a36a --- /dev/null +++ b/examples/basic-server-vanillajs-without-libs/package.json @@ -0,0 +1,5 @@ +{ + "scripts": { + "dev": "bun run worker.js" + } +} diff --git a/examples/basic-server-vanillajs-without-libs/worker.js b/examples/basic-server-vanillajs-without-libs/worker.js new file mode 100644 index 00000000..448ae6d1 --- /dev/null +++ b/examples/basic-server-vanillajs-without-libs/worker.js @@ -0,0 +1,632 @@ +/** + * Single Cloudflare Worker that is: + * 1. A fully functional MCP server (Streamable HTTP transport) + * 2. Serves an embedded MCP App view (HTML) as a ui:// resource + * + * No libraries used — raw JSON-RPC 2.0 over HTTP + postMessage. + */ + +// ── The HTML view embedded as a string ───────────────────────────── +const VIEW_HTML = /*html*/ ` + + + + +Counter App + + + + +
+
+
+ + +
+
Connecting…
+ + + +`; + +// ── Constants ────────────────────────────────────────────────────── + +const MCP_PROTOCOL_VERSION = "2025-03-26"; +const UI_MIME_TYPE = "text/html;profile=mcp-app"; +const RESOURCE_URI = "ui://counter/view.html"; + +// ── Server state (in-memory, per-isolate) ────────────────────────── +// Note: In production you'd use Durable Objects for per-session state. +let counter = 0; + +// ── JSON-RPC helpers ─────────────────────────────────────────────── + +function jsonRpcResponse(id, result) { + return { jsonrpc: "2.0", id, result }; +} + +function jsonRpcError(id, code, message) { + return { jsonrpc: "2.0", id, error: { code, message } }; +} + +// ── MCP request handler ──────────────────────────────────────────── + +function handleMcpRequest(method, params, id) { + switch (method) { + // ─── Initialize ──────────────────────────────────────────── + case "initialize": + return jsonRpcResponse(id, { + protocolVersion: MCP_PROTOCOL_VERSION, + capabilities: { + tools: { listChanged: false }, + resources: { listChanged: false }, + }, + serverInfo: { + name: "counter-worker", + version: "1.0.0", + }, + }); + + // ─── Ping ────────────────────────────────────────────────── + case "ping": + return jsonRpcResponse(id, {}); + + // ─── Tools ───────────────────────────────────────────────── + case "tools/list": + return jsonRpcResponse(id, { + tools: [ + { + name: "counter", + description: + "Interactive counter app. Shows a UI with +/- buttons.", + inputSchema: { + type: "object", + properties: { + initialValue: { + type: "number", + description: "Starting count value", + }, + }, + }, + _meta: { + ui: { resourceUri: RESOURCE_URI }, + "ui/resourceUri": RESOURCE_URI, // legacy compat + }, + }, + { + name: "increment", + description: "Increment the counter by a given amount", + inputSchema: { + type: "object", + properties: { + amount: { + type: "number", + description: "Amount to add (negative to subtract)", + }, + }, + }, + _meta: { + ui: { + resourceUri: RESOURCE_URI, + visibility: ["app"], // hidden from model, only callable by the view + }, + "ui/resourceUri": RESOURCE_URI, + }, + }, + ], + }); + + case "tools/call": { + const { name, arguments: args } = params || {}; + + if (name === "counter") { + const initial = args?.initialValue ?? 0; + counter = Number(initial); + return jsonRpcResponse(id, { + content: [{ type: "text", text: JSON.stringify({ count: counter }) }], + }); + } + + if (name === "increment") { + const amount = Number(args?.amount ?? 1); + counter += amount; + return jsonRpcResponse(id, { + content: [{ type: "text", text: JSON.stringify({ count: counter }) }], + }); + } + + return jsonRpcError(id, -32602, `Unknown tool: ${name}`); + } + + // ─── Resources ───────────────────────────────────────────── + case "resources/list": + return jsonRpcResponse(id, { + resources: [ + { + uri: RESOURCE_URI, + name: "Counter View", + description: "Interactive counter UI", + mimeType: UI_MIME_TYPE, + }, + ], + }); + + case "resources/read": { + const uri = params?.uri; + if (uri === RESOURCE_URI) { + return jsonRpcResponse(id, { + contents: [ + { + uri: RESOURCE_URI, + mimeType: UI_MIME_TYPE, + text: VIEW_HTML, + _meta: { + ui: { + prefersBorder: true, + }, + }, + }, + ], + }); + } + return jsonRpcError(id, -32602, `Unknown resource: ${uri}`); + } + + case "resources/templates/list": + return jsonRpcResponse(id, { resourceTemplates: [] }); + + // ─── Prompts ─────────────────────────────────────────────── + case "prompts/list": + return jsonRpcResponse(id, { prompts: [] }); + + default: + return jsonRpcError(id, -32601, `Method not found: ${method}`); + } +} + +// ── HTTP handler ─────────────────────────────────────────────────── + +export default { + async fetch(request) { + const url = new URL(request.url); + + // ── CORS preflight ───────────────────────────────────────── + if (request.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + }); + } + + const corsHeaders = { + "Access-Control-Allow-Origin": "*", + }; + + // ── MCP endpoint (Streamable HTTP) ───────────────────────── + if (url.pathname === "/mcp" || url.pathname === "/") { + if (request.method === "GET") { + // SSE endpoint — for this simple example we just return + // a keep-alive stream. A real implementation would push + // server-initiated notifications here. + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + const encoder = new TextEncoder(); + + // Send a comment to keep connection alive + writer.write(encoder.encode(": connected\n\n")); + + // Keep alive every 30s + const interval = setInterval(() => { + writer.write(encoder.encode(": ping\n\n")).catch(() => { + clearInterval(interval); + }); + }, 30000); + + // Clean up when client disconnects + request.signal?.addEventListener("abort", () => { + clearInterval(interval); + writer.close().catch(() => {}); + }); + + return new Response(readable, { + headers: { + ...corsHeaders, + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } + + if (request.method === "POST") { + let body; + try { + body = await request.json(); + } catch { + return new Response( + JSON.stringify(jsonRpcError(null, -32700, "Parse error")), + { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }, + ); + } + + // Handle batch or single request + const isBatch = Array.isArray(body); + const messages = isBatch ? body : [body]; + const responses = []; + + for (const msg of messages) { + if (msg.jsonrpc !== "2.0") continue; + + // Notification (no id) — just acknowledge + if (msg.id === undefined || msg.id === null) { + // notifications/initialized, etc. — no response needed + continue; + } + + // Request + const result = handleMcpRequest(msg.method, msg.params, msg.id); + responses.push(result); + } + + // If all were notifications, return 204 + if (responses.length === 0) { + return new Response(null, { + status: 204, + headers: corsHeaders, + }); + } + + const responseBody = isBatch ? responses : responses[0]; + return new Response(JSON.stringify(responseBody), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + return new Response("Method not allowed", { + status: 405, + headers: corsHeaders, + }); + } + + // ── Fallback: 404 ────────────────────────────────────────── + return new Response("Not found. MCP endpoint is at /mcp", { + status: 404, + headers: corsHeaders, + }); + }, +}; diff --git a/examples/basic-server-vanillajs-without-libs/wrangler.json b/examples/basic-server-vanillajs-without-libs/wrangler.json new file mode 100644 index 00000000..38b4cdee --- /dev/null +++ b/examples/basic-server-vanillajs-without-libs/wrangler.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://unpkg.com/wrangler@latest/config-schema.json", + "name": "basic-server-vanillajs-without-libs", + "compatibility_date": "2026-02-24", + "main": "worker.js" +}