From 60a011fb98a63f93ec52c5ad6359f1e4646f1951 Mon Sep 17 00:00:00 2001 From: guro Date: Tue, 20 Jan 2026 18:50:45 +0000 Subject: [PATCH 1/2] fix: enable trust proxy for correct rate limiting behind reverse proxy Without this, rate limiting uses the proxy's IP instead of actual client IPs, causing all requests through Cloudflare to be rate limited together. --- src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/index.ts b/src/index.ts index 67b950c..b5b79cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,10 @@ async function main() { const app = express(); + // Trust proxy headers (X-Forwarded-For, etc.) when behind reverse proxy (Cloudflare, etc.) + // This is required for rate limiting to work correctly with real client IPs + app.set('trust proxy', true); + // Basic middleware // Intentionally permissive CORS for public MCP reference server // This allows any MCP client to test against this reference implementation From 5941b544ffc94690a689d7d6f207234cf359e751 Mon Sep 17 00:00:00 2001 From: guro Date: Mon, 16 Feb 2026 22:16:55 +0000 Subject: [PATCH 2/2] feat: add MCP tasks support to example server Add task-based execution support for the longRunningOperation tool, demonstrating the MCP Tasks protocol. Changes include: - Add InMemoryTaskStore and InMemoryTaskMessageQueue - Declare tasks capability with list, cancel, and tool call support - Extract runLongOperation helper for background task execution - Add task status updates and cancellation checking during long operations - Update SDK to 1.26.0 for tasks support --- package-lock.json | 61 ++++--- package.json | 2 +- src/modules/mcp/services/mcp.ts | 281 ++++++++++++++++++++++---------- 3 files changed, 234 insertions(+), 110 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2eb1646..7fd7683 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "1.24.2", + "@modelcontextprotocol/sdk": "1.26.0", "@modelcontextprotocol/server-budget-allocator": "^1.0.1", "@modelcontextprotocol/server-cohort-heatmap": "^1.0.1", "@modelcontextprotocol/server-customer-segmentation": "^1.0.1", @@ -1137,6 +1137,18 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1670,11 +1682,12 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.24.2", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.24.2.tgz", - "integrity": "sha512-hS/kzSfchqzvUeJUsdiDHi84/kNhLIZaZ6coGQVwbYIelOBbcAwUohUfaQTLa1MvFOK/jbTnGFzraHSFwB7pjQ==", + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", "license": "MIT", "dependencies": { + "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -1682,13 +1695,15 @@ "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "jose": "^6.1.1", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.0" + "zod-to-json-schema": "^3.25.1" }, "engines": { "node": ">=18" @@ -1808,21 +1823,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -8838,6 +8838,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.11.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", + "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -9795,6 +9804,12 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", diff --git a/package.json b/package.json index c995855..4c1a2df 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ }, "dependencies": { "@modelcontextprotocol/ext-apps": "^1.0.1", - "@modelcontextprotocol/sdk": "1.24.2", + "@modelcontextprotocol/sdk": "1.26.0", "@modelcontextprotocol/server-budget-allocator": "^1.0.1", "@modelcontextprotocol/server-cohort-heatmap": "^1.0.1", "@modelcontextprotocol/server-customer-segmentation": "^1.0.1", diff --git a/src/modules/mcp/services/mcp.ts b/src/modules/mcp/services/mcp.ts index ec74fe4..7b8707e 100644 --- a/src/modules/mcp/services/mcp.ts +++ b/src/modules/mcp/services/mcp.ts @@ -20,6 +20,11 @@ import { Tool, UnsubscribeRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; +import { + InMemoryTaskStore, + InMemoryTaskMessageQueue, +} from "@modelcontextprotocol/sdk/experimental"; +import type { RequestTaskStore } from "@modelcontextprotocol/sdk/shared/protocol.js"; import { z } from "zod/v4"; type ToolInput = Tool["inputSchema"]; @@ -109,7 +114,60 @@ interface McpServerWrapper { cleanup: () => void; } +/** + * Runs a long operation in the background, updating task status as it progresses. + * Extracted from the inline longRunningOperation handler to support task-augmented execution. + */ +async function runLongOperation( + taskId: string, + duration: number, + steps: number, + taskStore: RequestTaskStore, + progressToken: string | number | undefined, + server: Server, +): Promise { + const stepDuration = duration / steps; + + for (let i = 1; i <= steps; i++) { + await new Promise((resolve) => setTimeout(resolve, stepDuration * 1000)); + + // Check if task was cancelled + const currentTask = await taskStore.getTask(taskId); + if (currentTask.status === "cancelled") { + return; + } + + // Update task status with human-readable message + await taskStore.updateTaskStatus( + taskId, + "working", + `Processing step ${i}/${steps}...`, + ); + + // Also send progress notification (existing behavior) + if (progressToken !== undefined) { + await server.notification({ + method: "notifications/progress", + params: { progress: i, total: steps, progressToken }, + }); + } + } + + // Store final result + await taskStore.storeTaskResult(taskId, "completed", { + content: [ + { + type: "text", + text: `Long running operation completed. Duration: ${duration} seconds, Steps: ${steps}.`, + }, + ], + }); +} + export const createMcpServer = (): McpServerWrapper => { + const taskStore = new InMemoryTaskStore(); + const taskMessageQueue = new InMemoryTaskMessageQueue(); + const server = new Server( { name: "example-servers/feature-reference", @@ -122,8 +180,17 @@ export const createMcpServer = (): McpServerWrapper => { tools: {}, logging: {}, completions: {}, + tasks: { + list: {}, + cancel: {}, + requests: { + tools: { call: {} }, + }, + }, }, - } + taskStore, + taskMessageQueue, + }, ); const subscriptions: Set = new Set(); @@ -166,13 +233,12 @@ export const createMcpServer = (): McpServerWrapper => { server.notification(message); }, 20000); - // Set up update interval for stderr messages const stdErrUpdateInterval = setInterval(() => { const shortTimestamp = new Date().toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - second: '2-digit' + hour: "2-digit", + minute: "2-digit", + second: "2-digit", }); server.notification({ method: "notifications/stderr", @@ -184,7 +250,7 @@ export const createMcpServer = (): McpServerWrapper => { const requestSampling = async ( context: string, uri: string, - maxTokens: number = 100 + maxTokens: number = 100, ) => { const request: CreateMessageRequest = { method: "sampling/createMessage", @@ -293,7 +359,10 @@ export const createMcpServer = (): McpServerWrapper => { // MCP Apps UI resources if (uri === HELLO_WORLD_APP_URI) { const distDir = path.join(import.meta.dirname, "../../../apps"); - const html = await fs.readFile(path.join(distDir, "mcp-app.html"), "utf-8"); + const html = await fs.readFile( + path.join(distDir, "mcp-app.html"), + "utf-8", + ); return { contents: [ { @@ -410,7 +479,7 @@ export const createMcpServer = (): McpServerWrapper => { const resourceId = parseInt(args?.resourceId as string, 10); if (isNaN(resourceId) || resourceId < 1 || resourceId > 100) { throw new Error( - `Invalid resourceId: ${args?.resourceId}. Must be a number between 1 and 100.` + `Invalid resourceId: ${args?.resourceId}. Must be a number between 1 and 100.`, ); } @@ -457,11 +526,13 @@ export const createMcpServer = (): McpServerWrapper => { description: "Demonstrates a long running operation with progress updates", inputSchema: toJsonSchema(LongRunningOperationSchema), + execution: { taskSupport: "required" }, }, { name: ToolName.SAMPLE_LLM, description: "Samples from an LLM using MCP's sampling feature", inputSchema: toJsonSchema(SampleLLMSchema), + execution: { taskSupport: "optional" }, }, { name: ToolName.GET_TINY_IMAGE, @@ -484,7 +555,8 @@ export const createMcpServer = (): McpServerWrapper => { name: ToolName.ELICIT_INPUTS, description: "Elicitation test tool that demonstrates how to request user input with various field types", - inputSchema: { type: "object" , properties: {} }, + inputSchema: { type: "object", properties: {} }, + execution: { taskSupport: "optional" }, }, { name: ToolName.MCP_APPS_HELLO_WORLD, @@ -524,12 +596,35 @@ export const createMcpServer = (): McpServerWrapper => { if (name === ToolName.LONG_RUNNING_OPERATION) { const validatedArgs = LongRunningOperationSchema.parse(args); const { duration, steps } = validatedArgs; - const stepDuration = duration / steps; const progressToken = request.params._meta?.progressToken; + // Task-augmented: create task and run in background + if (extra.taskStore) { + const task = await extra.taskStore.createTask({ + ttl: (duration + 30) * 1000, // TTL = duration + 30s buffer + pollInterval: Math.max(1000, (duration / steps) * 1000), // poll once per step + }); + + // Run in background (don't await) + runLongOperation( + task.taskId, + duration, + steps, + extra.taskStore, + progressToken, + server, + ).catch((err) => { + extra.taskStore!.updateTaskStatus(task.taskId, "failed", String(err)); + }); + + return { task }; + } + + // Non-task fallback: run synchronously (existing behavior) + const stepDuration = duration / steps; for (let i = 1; i < steps + 1; i++) { await new Promise((resolve) => - setTimeout(resolve, stepDuration * 1000) + setTimeout(resolve, stepDuration * 1000), ); if (progressToken !== undefined) { @@ -561,11 +656,16 @@ export const createMcpServer = (): McpServerWrapper => { const result = await requestSampling( prompt, ToolName.SAMPLE_LLM, - maxTokens + maxTokens, ); - const contentArray = Array.isArray(result.content) ? result.content : [result.content]; + const contentArray = Array.isArray(result.content) + ? result.content + : [result.content]; const firstContent = contentArray[0]; - const textContent = firstContent && "text" in firstContent ? firstContent.text : JSON.stringify(result.content); + const textContent = + firstContent && "text" in firstContent + ? firstContent.text + : JSON.stringify(result.content); return { content: [ { type: "text", text: `LLM sampling result: ${textContent}` }, @@ -675,78 +775,82 @@ export const createMcpServer = (): McpServerWrapper => { } if (name === ToolName.ELICIT_INPUTS) { - const result = await extra.sendRequest({ - method: 'elicitation/create', - params: { - message: "Please provide inputs for the following fields:", - requestedSchema: { - type: "object", - properties: { - name: { - title: "Full Name", - type: "string", - description: "Your full, legal name", - }, - check: { - title: "Agree to terms", - type: "boolean", - description: "A boolean check", - }, - color: { - title: "Favorite Color", - type: "string", - description: "Favorite color (open text)", - default: "blue", - }, - email: { - title: "Email Address", - type: "string", - format: "email", - description: - "Your email address (will be verified, and never shared with anyone else)", - }, - homepage: { - type: "string", - format: "uri", - description: "Homepage / personal site", - }, - birthdate: { - title: "Birthdate", - type: "string", - format: "date", - description: - "Your date of birth (will never be shared with anyone else)", - }, - integer: { - title: "Favorite Integer", - type: "integer", - description: - "Your favorite integer (do not give us your phone number, pin, or other sensitive info)", - minimum: 1, - maximum: 100, - default: 42, - }, - number: { - title: "Favorite Number", - type: "number", - description: "Favorite number (there are no wrong answers)", - minimum: 0, - maximum: 1000, - default: 3.14, - }, - petType: { - title: "Pet type", - type: "string", - enum: ["cats", "dogs", "birds", "fish", "reptiles"], - enumNames: ["Cats", "Dogs", "Birds", "Fish", "Reptiles"], - default: "dogs", - description: "Your favorite pet type", + const result = await extra.sendRequest( + { + method: "elicitation/create", + params: { + message: "Please provide inputs for the following fields:", + requestedSchema: { + type: "object", + properties: { + name: { + title: "Full Name", + type: "string", + description: "Your full, legal name", + }, + check: { + title: "Agree to terms", + type: "boolean", + description: "A boolean check", + }, + color: { + title: "Favorite Color", + type: "string", + description: "Favorite color (open text)", + default: "blue", + }, + email: { + title: "Email Address", + type: "string", + format: "email", + description: + "Your email address (will be verified, and never shared with anyone else)", + }, + homepage: { + type: "string", + format: "uri", + description: "Homepage / personal site", + }, + birthdate: { + title: "Birthdate", + type: "string", + format: "date", + description: + "Your date of birth (will never be shared with anyone else)", + }, + integer: { + title: "Favorite Integer", + type: "integer", + description: + "Your favorite integer (do not give us your phone number, pin, or other sensitive info)", + minimum: 1, + maximum: 100, + default: 42, + }, + number: { + title: "Favorite Number", + type: "number", + description: "Favorite number (there are no wrong answers)", + minimum: 0, + maximum: 1000, + default: 3.14, + }, + petType: { + title: "Pet type", + type: "string", + enum: ["cats", "dogs", "birds", "fish", "reptiles"], + enumNames: ["Cats", "Dogs", "Birds", "Fish", "Reptiles"], + default: "dogs", + description: "Your favorite pet type", + }, }, + required: ["name"], }, - required: ["name"], }, - } - }, ElicitResultSchema, {timeout: 10 * 60 * 1000 /* 10 minutes */}); + }, + ElicitResultSchema, + { timeout: 10 * 60 * 1000 /* 10 minutes */ }, + ); return { content: [ @@ -769,7 +873,11 @@ export const createMcpServer = (): McpServerWrapper => { structuredContent: { greeting: "Hello from MCP Apps!", timestamp: new Date().toISOString(), - features: ["interactive UI", "bidirectional communication", "theme support"], + features: [ + "interactive UI", + "bidirectional communication", + "theme support", + ], stats: { version: "1.0.0", requestCount: Math.floor(Math.random() * 1000), @@ -794,7 +902,7 @@ export const createMcpServer = (): McpServerWrapper => { // Filter resource IDs that start with the input value const values = EXAMPLE_COMPLETIONS.resourceId.filter((id) => - id.startsWith(argument.value) + id.startsWith(argument.value), ); return { completion: { values, hasMore: false, total: values.length } }; } @@ -806,7 +914,7 @@ export const createMcpServer = (): McpServerWrapper => { if (!completions) return { completion: { values: [] } }; const values = completions.filter((value) => - value.startsWith(argument.value) + value.startsWith(argument.value), ); return { completion: { values, hasMore: false, total: values.length } }; } @@ -835,6 +943,7 @@ export const createMcpServer = (): McpServerWrapper => { if (subsUpdateInterval) clearInterval(subsUpdateInterval); if (logsUpdateInterval) clearInterval(logsUpdateInterval); if (stdErrUpdateInterval) clearInterval(stdErrUpdateInterval); + taskStore.cleanup(); }; return { server, cleanup };