diff --git a/apps/website/content/docs/core-concepts/meta.json b/apps/website/content/docs/core-concepts/meta.json index b4908e58f..8067d0535 100644 --- a/apps/website/content/docs/core-concepts/meta.json +++ b/apps/website/content/docs/core-concepts/meta.json @@ -4,6 +4,7 @@ "tools", "prompts", "resources", + "notifications", "middlewares", "css", "external-clients" diff --git a/apps/website/content/docs/core-concepts/notifications.mdx b/apps/website/content/docs/core-concepts/notifications.mdx new file mode 100644 index 000000000..55d4881aa --- /dev/null +++ b/apps/website/content/docs/core-concepts/notifications.mdx @@ -0,0 +1,113 @@ +--- +title: "Notifications" +metadataTitle: "Notifications | xmcp Documentation" +publishedAt: "2026-03-19" +summary: "Learn how to handle MCP client notifications in your xmcp application." +description: "Notifications are one-way messages in the MCP protocol. They allow clients to inform servers about lifecycle events, cancellations, progress updates, and more, without expecting a response." +--- + +In the MCP protocol, notifications are **one-way messages**. Unlike requests, they don't expect a response. Clients send notifications to servers to inform them about events such as: + +- The client has finished initializing and is ready. +- A previously issued request has been cancelled. +- Progress has been made on a long-running operation. +- The client's filesystem roots have changed. +- A task's status has been updated. + +Notifications are fire-and-forget: the server receives them, acts on them if needed, and never sends a reply. + +## Client-to-server notifications + +These are all the notifications a server can receive from a client, as defined in the [MCP specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/index#notifications): + +| Key | MCP Method | Params | Description | +| ------------------ | ------------------------------------- | ------------------------------------------------------------- | --------------------------------------------- | +| `initialized` | `notifications/initialized` | _none_ | Client completed the initialization handshake | +| `cancelled` | `notifications/cancelled` | `requestId?`, `reason?` | Client cancelled an in-flight request | +| `progress` | `notifications/progress` | `progressToken`, `progress`, `total?`, `message?` | Progress update on a long-running request | +| `rootsListChanged` | `notifications/roots/list_changed` | _none_ | Client's root URIs changed | +| `taskStatus` | `notifications/tasks/status` | `taskId`, `status`, `statusMessage?`, `createdAt`, `lastUpdatedAt`, `ttl`, `pollInterval?` | Task status changed | + +## Handling notifications in xmcp + +Create a `src/notifications.ts` file that exports a `defineNotifications()` call. xmcp discovers this file at build time, following the same single-file pattern used by `src/middleware.ts`. + +```typescript title="src/notifications.ts" +import { defineNotifications } from "xmcp"; + +export default defineNotifications({ + initialized: async () => { + console.log("Client session initialized"); + }, + rootsListChanged: async () => { + console.log("Client roots changed, refreshing workspace context"); + }, +}); +``` + +xmcp maps the short keys to full MCP method names automatically (e.g. `cancelled` to `notifications/cancelled`). All keys are optional, only subscribe to what you need. + +## Handler signature + +Notifications **without params** (`initialized`, `rootsListChanged`) take no arguments. Notifications **with params** receive them fully typed: + +```typescript title="src/notifications.ts" +import { defineNotifications } from "xmcp"; + +export default defineNotifications({ + initialized: async () => { + console.log("Client ready"); + }, + + cancelled: async (params) => { + // params.requestId and params.reason are fully typed + console.log(`Request ${params.requestId} was cancelled: ${params.reason}`); + }, + + progress: async (params) => { + const pct = params.total + ? ` (${Math.round((params.progress / params.total) * 100)}%)` + : ""; + console.log(`Progress${pct}: ${params.message ?? "no message"}`); + }, + + taskStatus: async (params) => { + console.log(`Task ${params.taskId}: ${params.status}`); + }, +}); +``` + +## Custom notification methods + +Beyond the standard MCP notifications, you can handle any custom method by using its full method string as the key. Custom methods have `params` typed as `Record`: + +```typescript title="src/notifications.ts" +import { defineNotifications } from "xmcp"; + +export default defineNotifications({ + initialized: async () => { + console.log("Client ready"); + }, + "custom/my-event": async (params) => { + console.log("Custom event received:", params); + }, +}); +``` + +## SDK handler preservation + +For `cancelled` and `progress`, the MCP SDK registers internal handlers that manage request abortion and progress tracking. Your handlers run **alongside** them and never replace the SDK's built-in behavior. + + + If a future SDK update changes its internal handler structure, xmcp will throw + a clear error at startup rather than silently losing cancellation or progress + tracking. + + +## Error handling + +Handler errors are caught and logged automatically. A failing handler does not affect server stability or other handlers: + +``` +[xmcp] Notification handler error for "notifications/cancelled": Error: something went wrong +``` diff --git a/apps/website/content/docs/getting-started/project-structure.mdx b/apps/website/content/docs/getting-started/project-structure.mdx index d2dce6c28..f155a4d16 100644 --- a/apps/website/content/docs/getting-started/project-structure.mdx +++ b/apps/website/content/docs/getting-started/project-structure.mdx @@ -13,20 +13,21 @@ A basic project structure is as follows: ``` my-project/ ├── src/ -│ ├── middleware.ts # Middleware for http request/response processing -│ └── tools/ # Tool files are auto-discovered here +│ ├── middleware.ts # Middleware for http request/response processing +│ ├── notifications.ts # Notification handlers setup file +│ └── tools/ # Tool files are auto-discovered here │ ├── greet.ts │ ├── search.ts │ └── prompts/ # Prompt files are auto-discovered here │ ├── review-code.ts │ ├── team-greeting.ts -│ └── resources/ # Resource files are auto-discovered here +│ └── resources/ # Resource files are auto-discovered here │ ├── (config)/app.ts │ ├── (users)/[userId]/profile.ts -├── dist/ # Built output (generated) +├── dist/ # Built output (generated) ├── package.json ├── tsconfig.json -└── xmcp.config.ts # Configuration file for xmcp +└── xmcp.config.ts # Configuration file for xmcp ``` ## Top-level files @@ -41,7 +42,7 @@ There are the three top-level files that are required for your project: The `src/` directory houses your project's implementation. xmcp follows a declarative, file-system based approach—simply create a file in the appropriate directory, and it will be automatically discovered and registered. -The optional `middleware.ts` file at the root of `src/` processes HTTP requests and responses. You can customize the location of `tools/`, `prompts/`, and `resources/` directories in your `xmcp.config.ts` file. See the [custom directories](../../configuration/custom-directories) documentation for details. +The optional `middleware.ts` and `notifications.ts` files at the root of `src/` handle HTTP middleware and MCP notification events respectively. You can customize the location of `tools/`, `prompts/`, and `resources/` directories in your `xmcp.config.ts` file. See the [custom directories](../../configuration/custom-directories) documentation for details. + { + console.log("Client initialized"); + }, + cancelled: async (params) => { + console.log("Cancelled:", params.requestId); + }, + progress: async (params) => { + console.log("Progress:", params.progress); + }, + rootsListChanged: async () => { + console.log("Roots changed"); + }, + taskStatus: async (params) => { + console.log(`Task ${params.taskId}: ${params.status}`); + }, +}); +``` + +xmcp discovers this file at build time (similar to `src/middleware.ts`) and registers the handlers on the MCP server at runtime. Keys are mapped to full MCP method names automatically. + +### Handler signature + +```ts +defineNotifications({ + // Paramless, no arguments needed + initialized: async () => { ... }, + + // With params, fully typed + cancelled: async (params) => { + params.requestId // string | number | undefined + params.reason // string | undefined + }, + + // Custom methods, params is Record + "custom/my-event": async (params) => { ... }, +}); +``` + +- **Known keys** get fully typed `params` and autocomplete. +- **Custom methods** (any string) are also supported. `params` is typed as `Record`. +- **Error isolation**: if a handler throws, it's caught and logged without affecting the server. + +### SDK handler preservation + +For `cancelled` and `progress`, the MCP SDK already has internal handlers (for aborting requests and tracking progress). Your handlers run **alongside** them and don't replace the SDK's built-in behavior. + +## Project structure + +``` +src/ +├── notifications.ts # All notification handlers in one place +└── tools/ + └── ping.ts # Simple ping tool +``` + +## Running the example + +### 1. Start the server + +```bash +pnpm dev +``` + +### 2. Send test notifications + +In a second terminal: + +```bash +pnpm run test:notifications +``` + +If the server started on a different port (check the server terminal output), set the URL: + +```bash +SERVER_URL=http://localhost:3002/mcp pnpm run test:notifications +``` + +### 3. Check the server terminal + +You should see output like: + +``` +[notification] Client session initialized +[notification] Roots list changed +[notification] Request req-42 cancelled: User clicked cancel +[notification] Progress (75%): Almost done +[notification] Task task-1: completed +``` diff --git a/examples/notifications/package.json b/examples/notifications/package.json new file mode 100644 index 000000000..e4c8127fb --- /dev/null +++ b/examples/notifications/package.json @@ -0,0 +1,19 @@ +{ + "name": "notifications", + "description": "Learn how to subscribe to MCP client notifications using xmcp", + "keywords": [ + "notifications", + "subscribe", + "events" + ], + "scripts": { + "build": "xmcp build", + "dev": "xmcp dev", + "start": "node dist/http.js", + "test:notifications": "node --experimental-strip-types test-notifications.ts" + }, + "dependencies": { + "xmcp": "workspace:*", + "zod": "^4.0.10" + } +} diff --git a/examples/notifications/src/analytics.ts b/examples/notifications/src/analytics.ts new file mode 100644 index 000000000..041f7c771 --- /dev/null +++ b/examples/notifications/src/analytics.ts @@ -0,0 +1,11 @@ +const eventCounts = new Map(); + +export function track(name: string, properties?: Record) { + const count = (eventCounts.get(name) ?? 0) + 1; + eventCounts.set(name, count); + console.log( + `[analytics] "${name}" (count=${count})`, + properties ?? {} + ); + return count; +} diff --git a/examples/notifications/src/notifications.ts b/examples/notifications/src/notifications.ts new file mode 100644 index 000000000..0161b3c3b --- /dev/null +++ b/examples/notifications/src/notifications.ts @@ -0,0 +1,38 @@ +import { defineNotifications } from "xmcp"; +import { track } from "./analytics"; + +export default defineNotifications({ + initialized: async () => { + console.log("[notification] Client session initialized"); + }, + rootsListChanged: async () => { + console.log("[notification] Roots list changed"); + }, + cancelled: async (params) => { + console.log( + `[notification] Request ${params.requestId} cancelled${params.reason ? `: ${params.reason}` : ""}` + ); + }, + progress: async (params) => { + const pct = + params.total != null + ? ` (${Math.round((params.progress / params.total) * 100)}%)` + : ""; + console.log( + `[notification] Progress${pct}: ${params.message ?? "no message"}` + ); + }, + taskStatus: async (params) => { + console.log( + `[notification] Task ${params.taskId}: ${params.status}${params.statusMessage ? ` - ${params.statusMessage}` : ""}` + ); + }, + + // Custom notification: track an app-level analytics event. + // Shares the same tracker as tool invocations, so both paths + // contribute to one running count per event name. + "app/analytics-event": async (params) => { + const name = typeof params.name === "string" ? params.name : "unknown"; + track(name, params.properties as Record | undefined); + }, +}); diff --git a/examples/notifications/src/tools/ping.ts b/examples/notifications/src/tools/ping.ts new file mode 100644 index 000000000..135ac5ae2 --- /dev/null +++ b/examples/notifications/src/tools/ping.ts @@ -0,0 +1,12 @@ +import { type ToolMetadata } from "xmcp"; +import { track } from "../analytics"; + +export const metadata: ToolMetadata = { + name: "ping", + description: "Simple ping tool to verify the server is running", +}; + +export default function ping() { + track("tool_invoked", { tool: "ping" }); + return "pong"; +} diff --git a/examples/notifications/test-notifications.ts b/examples/notifications/test-notifications.ts new file mode 100644 index 000000000..246f18d4e --- /dev/null +++ b/examples/notifications/test-notifications.ts @@ -0,0 +1,172 @@ +/** + * Test client that sends MCP notifications to a running xmcp server. + * + * Usage: + * 1. Start the server: pnpm dev + * 2. In another terminal: pnpm run test:notifications + * 3. Watch the server terminal for handler output. + * + * Adjust SERVER_URL if your server starts on a different port. + */ + +const SERVER_URL = process.env.SERVER_URL ?? "http://localhost:3001/mcp"; + +let sessionId: string | undefined; + +function buildHeaders(): Record { + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }; + if (sessionId) headers["mcp-session-id"] = sessionId; + return headers; +} + +function captureSessionId(res: Response) { + const sid = res.headers.get("mcp-session-id"); + if (sid && !sessionId) sessionId = sid; +} + +async function parseResponse(res: Response) { + const ct = res.headers.get("content-type") ?? ""; + if (ct.includes("text/event-stream")) { + const text = await res.text(); + const dataLine = text + .split("\n") + .find((line) => line.startsWith("data:")); + return dataLine ? JSON.parse(dataLine.slice(5).trim()) : null; + } + if (ct.includes("application/json")) { + return res.json(); + } + return null; +} + +async function sendJsonRpc( + method: string, + params?: Record, + id?: number +) { + const body: Record = { + jsonrpc: "2.0", + method, + }; + if (params) body.params = params; + if (id !== undefined) body.id = id; + + const res = await fetch(SERVER_URL, { + method: "POST", + headers: buildHeaders(), + body: JSON.stringify(body), + }); + captureSessionId(res); + + if (id !== undefined) { + return parseResponse(res); + } + return { status: res.status }; +} + +async function sendBatch(messages: Record[]) { + const res = await fetch(SERVER_URL, { + method: "POST", + headers: buildHeaders(), + body: JSON.stringify(messages), + }); + captureSessionId(res); + return parseResponse(res); +} + +async function main() { + console.log("Testing xmcp Notification Subscriptions"); + console.log(`Server: ${SERVER_URL}\n`); + + // 1. Initialize session + console.log("1. Initializing MCP session..."); + const initResult = await sendJsonRpc( + "initialize", + { + protocolVersion: "2025-03-26", + capabilities: { roots: { listChanged: true } }, + clientInfo: { name: "notification-test-client", version: "1.0.0" }, + }, + 1 + ); + console.log( + ` Server: ${initResult.result?.serverInfo?.name ?? "connected"}` + ); + + // Send initialized notification + await sendJsonRpc("notifications/initialized"); + console.log(" Expected: [notification] Client session initialized\n"); + + // 2. roots/list_changed (batched with a request to share the session) + console.log("2. Sending notifications/roots/list_changed..."); + await sendBatch([ + { jsonrpc: "2.0", method: "notifications/roots/list_changed" }, + { jsonrpc: "2.0", method: "tools/list", id: 2 }, + ]); + console.log(" Expected: [notification] Roots list changed\n"); + + // 3. notifications/cancelled + console.log("3. Sending notifications/cancelled..."); + await sendJsonRpc("notifications/cancelled", { + requestId: "req-42", + reason: "User clicked cancel", + }); + console.log( + " Expected: [notification] Request req-42 cancelled: User clicked cancel\n" + ); + + // 4. notifications/progress + console.log("4. Sending notifications/progress..."); + await sendJsonRpc("notifications/progress", { + progressToken: "token-abc", + progress: 75, + total: 100, + message: "Almost done", + }); + console.log(" Expected: [notification] Progress (75%): Almost done\n"); + + // 5. notifications/tasks/status + console.log("5. Sending notifications/tasks/status..."); + await sendJsonRpc("notifications/tasks/status", { + taskId: "task-1", + status: "completed", + statusMessage: "All items processed", + createdAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString(), + ttl: null, + }); + console.log( + " Expected: [notification] Task task-1: completed - All items processed\n" + ); + + // 6. Call the ping tool — the tool itself calls track("tool_invoked", ...) + console.log("6. Calling ping tool twice (each invocation tracks itself)..."); + await sendJsonRpc( + "tools/call", + { name: "ping", arguments: {} }, + 10 + ); + await sendJsonRpc( + "tools/call", + { name: "ping", arguments: {} }, + 11 + ); + console.log( + ' Expected: [analytics] "tool_invoked" (count=1, count=2) from tool runs\n' + ); + + // 7. Fire the same event externally — counter keeps climbing on the shared tracker + console.log("7. Sending app/analytics-event to extend the same counter..."); + await sendJsonRpc("app/analytics-event", { + name: "tool_invoked", + properties: { source: "external-client" }, + }); + console.log(' Expected: [analytics] "tool_invoked" (count=3)\n'); + + console.log("All notifications sent. Check the server terminal for output."); +} + +main().catch(console.error); diff --git a/examples/notifications/tsconfig.json b/examples/notifications/tsconfig.json new file mode 100644 index 000000000..369a04ea4 --- /dev/null +++ b/examples/notifications/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["xmcp-env.d.ts", "src/**/*.ts"] +} diff --git a/examples/notifications/xmcp.config.ts b/examples/notifications/xmcp.config.ts new file mode 100644 index 000000000..9eba99a2e --- /dev/null +++ b/examples/notifications/xmcp.config.ts @@ -0,0 +1,14 @@ +import { XmcpConfig } from "xmcp"; + +const config: XmcpConfig = { + http: true, + paths: { + prompts: false, + resources: false, + }, + typescript: { + skipTypeCheck: true, + }, +}; + +export default config; diff --git a/packages/xmcp/src/compiler/compiler-context.ts b/packages/xmcp/src/compiler/compiler-context.ts index 4aef399b5..e1ca3a2ad 100644 --- a/packages/xmcp/src/compiler/compiler-context.ts +++ b/packages/xmcp/src/compiler/compiler-context.ts @@ -20,6 +20,8 @@ interface CompilerContext { promptPaths: Set; /** The paths to the resources. */ resourcePaths: Set; + /** Whether the notifications setup file is present. */ + hasNotifications: boolean; /** Whether the middleware is enabled. */ hasMiddleware: boolean; /** The parsed config. */ @@ -36,7 +38,7 @@ export const compilerContext = createContext({ export const compilerContextProvider = async ( initialValue: Omit< CompilerContext, - "toolPaths" | "promptPaths" | "resourcePaths" | "hasMiddleware" + "toolPaths" | "promptPaths" | "resourcePaths" | "hasNotifications" | "hasMiddleware" >, callback: () => void ) => { @@ -46,6 +48,7 @@ export const compilerContextProvider = async ( toolPaths: new Set(), promptPaths: new Set(), resourcePaths: new Set(), + hasNotifications: false, hasMiddleware: false, }, () => Promise.resolve(callback()) diff --git a/packages/xmcp/src/compiler/generate-import-code.ts b/packages/xmcp/src/compiler/generate-import-code.ts index f3abcfeef..725bec627 100644 --- a/packages/xmcp/src/compiler/generate-import-code.ts +++ b/packages/xmcp/src/compiler/generate-import-code.ts @@ -13,6 +13,7 @@ export function generateImportCode(): string { toolPaths, promptPaths, resourcePaths, + hasNotifications, hasMiddleware, clientBundles, platforms, @@ -27,6 +28,7 @@ export function generateImportCode(): string { toolPaths, promptPaths, resourcePaths, + hasNotifications, hasMiddleware, clientBundles ); @@ -36,6 +38,7 @@ export function generateImportCode(): string { toolPaths, promptPaths, resourcePaths, + hasNotifications, hasMiddleware, clientBundles ); @@ -49,6 +52,7 @@ function generateStaticImportCode( toolPaths: Set, promptPaths: Set, resourcePaths: Set, + hasNotifications: boolean, hasMiddleware: boolean, clientBundles?: Map ): string { @@ -82,6 +86,12 @@ function generateStaticImportCode( resourcesEntries.push(`"${path}": () => Promise.resolve(${identifier}),`); }); + let notificationsCode = ""; + if (hasNotifications) { + staticImports.push(`import * as _notifications from "../src/notifications.ts";`); + notificationsCode = `export const notifications = () => Promise.resolve(_notifications);`; + } + let middlewareCode = ""; if (hasMiddleware) { staticImports.push(`import * as _middleware from "../src/middleware.ts";`); @@ -114,6 +124,7 @@ export const clientBundles = { ${clientBundlesEntries} }; +${notificationsCode} ${middlewareCode} `; } @@ -126,6 +137,7 @@ function generateDynamicImportCode( toolPaths: Set, promptPaths: Set, resourcePaths: Set, + hasNotifications: boolean, hasMiddleware: boolean, clientBundles?: Map ): string { @@ -153,6 +165,10 @@ function generateDynamicImportCode( }) .join("\n"); + const importNotificationsCode = hasNotifications + ? `export const notifications = () => import("../src/notifications.ts");` + : ""; + const importMiddlewareCode = hasMiddleware ? `export const middleware = () => import("../src/middleware.ts");` : ""; @@ -182,6 +198,7 @@ export const clientBundles = { ${clientBundlesEntries} }; +${importNotificationsCode} ${importMiddlewareCode} `; } diff --git a/packages/xmcp/src/compiler/get-bundler-config/index.ts b/packages/xmcp/src/compiler/get-bundler-config/index.ts index 68315e6a2..1a4c763aa 100644 --- a/packages/xmcp/src/compiler/get-bundler-config/index.ts +++ b/packages/xmcp/src/compiler/get-bundler-config/index.ts @@ -238,6 +238,10 @@ export function getRspackConfig( path.resolve(processFolder, ".xmcp/import-map.js"), "resources", ], + INJECTED_NOTIFICATIONS: [ + path.resolve(processFolder, ".xmcp/import-map.js"), + "notifications", + ], INJECTED_MIDDLEWARE: [ path.resolve(processFolder, ".xmcp/import-map.js"), "middleware", diff --git a/packages/xmcp/src/compiler/index.ts b/packages/xmcp/src/compiler/index.ts index 4d1f7abce..c90d8039f 100644 --- a/packages/xmcp/src/compiler/index.ts +++ b/packages/xmcp/src/compiler/index.ts @@ -176,6 +176,26 @@ export async function compile({ onBuild }: CompileOptions = {}) { }); } + // handle notifications (single-file pattern, like middleware) + watcher.watch("./src/notifications.ts", { + onAdd: async () => { + compilerContext.setContext({ + hasNotifications: true, + }); + if (compilerStarted) { + await generateCode(); + } + }, + onUnlink: async () => { + compilerContext.setContext({ + hasNotifications: false, + }); + if (compilerStarted) { + await generateCode(); + } + }, + }); + // if adapter is not enabled, handle middleware if (!xmcpConfig.experimental?.adapter) { // handle middleware @@ -230,6 +250,7 @@ export async function compile({ onBuild }: CompileOptions = {}) { reactToolsCount, promptsCount: promptPaths.size, resourcesCount: resourcePaths.size, + hasNotifications: compilerContext.getContext().hasNotifications, transport: xmcpConfig.http ? TransportType.HTTP : TransportType.STDIO, adapter: xmcpConfig.experimental?.adapter ? (xmcpConfig.experimental.adapter as AdapterType) diff --git a/packages/xmcp/src/define-notifications.ts b/packages/xmcp/src/define-notifications.ts new file mode 100644 index 000000000..b500161c6 --- /dev/null +++ b/packages/xmcp/src/define-notifications.ts @@ -0,0 +1,42 @@ +import type { + NotificationKey, + NotificationHandler, + NotificationMethodMap, + NotificationsConfig, + CustomNotificationHandler, +} from "./types/notification"; + +const METHOD_PREFIX: NotificationMethodMap = { + initialized: "notifications/initialized", + cancelled: "notifications/cancelled", + progress: "notifications/progress", + rootsListChanged: "notifications/roots/list_changed", + taskStatus: "notifications/tasks/status", +}; + +// Resolves the handler type: known keys get typed params, custom strings get Record +type ResolveHandler = K extends NotificationKey + ? NotificationHandler + : CustomNotificationHandler; + +type HandlersInput = { + [M in K]: ResolveHandler; +}; + +export function defineNotifications< + K extends NotificationKey | (string & {}), +>(handlers: HandlersInput): NotificationsConfig { + const mapped: Record = {}; + for (const [key, handler] of Object.entries(handlers)) { + if (!handler) continue; + // Known keys get mapped to full MCP method strings, custom keys pass through + const fullMethod = + METHOD_PREFIX[key as keyof NotificationMethodMap] ?? key; + mapped[fullMethod] = handler; + } + + return { + __isNotificationsConfig: true, + handlers: mapped, + }; +} diff --git a/packages/xmcp/src/index.ts b/packages/xmcp/src/index.ts index ec7a907d4..174223497 100644 --- a/packages/xmcp/src/index.ts +++ b/packages/xmcp/src/index.ts @@ -18,6 +18,14 @@ export type { McpClientInfo } from "./types/client-info"; export type { PromptMetadata } from "./types/prompt"; export type { ResourceMetadata } from "./types/resource"; export type { UIMetadata } from "./types/ui-meta"; +export type { + NotificationKey, + NotificationHandler, + CustomNotificationHandler, + NotificationsConfig, + NotificationParams, +} from "./types/notification"; +export { defineNotifications } from "./define-notifications"; export type { XmcpConfigInputSchema as XmcpConfig } from "./compiler/config"; import "./types/declarations"; diff --git a/packages/xmcp/src/runtime/adapters/nextjs/handler/server-lifecycle.ts b/packages/xmcp/src/runtime/adapters/nextjs/handler/server-lifecycle.ts index cbce19890..c269b67c8 100644 --- a/packages/xmcp/src/runtime/adapters/nextjs/handler/server-lifecycle.ts +++ b/packages/xmcp/src/runtime/adapters/nextjs/handler/server-lifecycle.ts @@ -7,6 +7,7 @@ import { loadPrompts, loadResources, loadTools, + loadNotificationsConfig, } from "@/runtime/utils/server"; export interface ServerLifecycle { @@ -40,16 +41,25 @@ export async function initializeMcpServer(): Promise { const toolModulesPromise = loadTools(); const promptModulesPromise = loadPrompts(); const resourceModulesPromise = loadResources(); - const [toolModules, promptModules, resourceModules] = await Promise.all([ - toolModulesPromise, - promptModulesPromise, - resourceModulesPromise, - ]); + const notificationsConfigPromise = loadNotificationsConfig(); + const [toolModules, promptModules, resourceModules, notificationsConfig] = + await Promise.all([ + toolModulesPromise, + promptModulesPromise, + resourceModulesPromise, + notificationsConfigPromise, + ]); const { instructions, ...serverInfo } = INJECTED_CONFIG; const server = new McpServer(serverInfo, { instructions }); - await configureServer(server, toolModules, promptModules, resourceModules); + await configureServer( + server, + toolModules, + promptModules, + resourceModules, + notificationsConfig + ); return server; } diff --git a/packages/xmcp/src/runtime/utils/notifications.ts b/packages/xmcp/src/runtime/utils/notifications.ts new file mode 100644 index 000000000..8437cf54d --- /dev/null +++ b/packages/xmcp/src/runtime/utils/notifications.ts @@ -0,0 +1,97 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp"; +import { + CancelledNotificationSchema, + ProgressNotificationSchema, + InitializedNotificationSchema, + RootsListChangedNotificationSchema, + TaskStatusNotificationSchema, + NotificationSchema, +} from "@modelcontextprotocol/sdk/types"; +import { z } from "zod"; +import type { NotificationsConfig } from "@/types/notification"; + +// Map known methods → SDK Zod schemas +const KNOWN_SCHEMAS: Record> = { + "notifications/cancelled": CancelledNotificationSchema, + "notifications/progress": ProgressNotificationSchema, + "notifications/initialized": InitializedNotificationSchema, + "notifications/roots/list_changed": RootsListChangedNotificationSchema, + "notifications/tasks/status": TaskStatusNotificationSchema, +}; + +// Methods where SDK has internal handlers that must be preserved +const SDK_INTERNAL_METHODS = new Set([ + "notifications/cancelled", + "notifications/progress", +]); + +function createGenericSchema(method: string) { + return NotificationSchema.extend({ + method: z.literal(method), + }); +} + +export function addNotificationsToServer( + server: McpServer, + config: NotificationsConfig | undefined +): void { + if (!config || !config.__isNotificationsConfig) return; + + const lowLevelServer = server.server; + + for (const [method, handler] of Object.entries(config.handlers)) { + if (!handler) continue; + + const schema = KNOWN_SCHEMAS[method] ?? createGenericSchema(method); + + // For SDK internal methods, preserve the existing handler via wrap approach. + // Uses the SDK's private _notificationHandlers Map — if the SDK renames this + // field, we throw at startup rather than silently losing cancellation/progress. + let existingHandler: + | ((notification: any) => void | Promise) + | undefined; + if (SDK_INTERNAL_METHODS.has(method)) { + const handlersMap = (lowLevelServer as any)._notificationHandlers; + if (!handlersMap || typeof handlersMap.get !== "function") { + throw new Error( + `[xmcp] SDK internal structure changed: _notificationHandlers is missing. ` + + `Cannot safely register "${method}" without breaking SDK behavior. ` + + `Please update xmcp to a version compatible with this SDK.` + ); + } + existingHandler = handlersMap.get(method); + if (!existingHandler) { + throw new Error( + `[xmcp] SDK internal handler for "${method}" not found. ` + + `Registering a user handler would overwrite SDK behavior for request cancellation/progress tracking. ` + + `Please update xmcp to a version compatible with this SDK.` + ); + } + } + + lowLevelServer.setNotificationHandler(schema, async (notification) => { + // 1. Run SDK internal handler first (if preserved) + if (existingHandler) { + try { + await existingHandler(notification); + } catch (err) { + console.error( + `[xmcp] SDK notification handler error for "${method}":`, + err + ); + } + } + + // 2. Run user handler with error isolation + const params = notification.params ?? {}; + try { + await handler(params as any); + } catch (err) { + console.error( + `[xmcp] Notification handler error for "${method}":`, + err + ); + } + }); + } +} diff --git a/packages/xmcp/src/runtime/utils/server.ts b/packages/xmcp/src/runtime/utils/server.ts index 0cb362705..33363add2 100644 --- a/packages/xmcp/src/runtime/utils/server.ts +++ b/packages/xmcp/src/runtime/utils/server.ts @@ -9,6 +9,7 @@ import { UserPromptHandler } from "./transformers/prompt"; import { UserResourceHandler } from "./transformers/resource"; import { ZodRawShape } from "zod/v3"; import { addResourcesToServer } from "./resources"; +import { addNotificationsToServer } from "./notifications"; import { ResourceMetadata } from "@/types/resource"; import { uIResourceRegistry } from "./ext-apps-registry"; import { loadPromptModules, reportPromptLoadIssues } from "./prompt-loader"; @@ -17,6 +18,7 @@ import { reportResourceLoadIssues, } from "./resource-loader"; import { loadToolModules, reportToolLoadIssues } from "./tool-loader"; +import type { NotificationsConfig } from "@/types/notification"; export type ToolFile = { metadata: ToolMetadata; @@ -52,7 +54,13 @@ export const injectedResources = INJECTED_RESOURCES as Record< () => Promise >; -export const INJECTED_CONFIG = SERVER_INFO as Implementation & { instructions?: string }; +const injectedNotifications = INJECTED_NOTIFICATIONS as + | (() => Promise<{ default?: NotificationsConfig }>) + | undefined; + +export const INJECTED_CONFIG = SERVER_INFO as Implementation & { + instructions?: string; +}; /* Loads all modules and injects them into the server */ // would be better as a class and use dependency injection perhaps @@ -60,13 +68,15 @@ export async function configureServer( server: McpServer, toolModules: Map, promptModules: Map, - resourceModules: Map + resourceModules: Map, + notificationsConfig: NotificationsConfig | undefined ): Promise { uIResourceRegistry.clear(); addToolsToServer(server, toolModules); addPromptsToServer(server, promptModules); addResourcesToServer(server, resourceModules); + addNotificationsToServer(server, notificationsConfig); return server; } @@ -92,16 +102,33 @@ export async function loadResources() { return resourceModules; } +export async function loadNotificationsConfig(): Promise< + NotificationsConfig | undefined +> { + if (!injectedNotifications) return undefined; + const mod = await injectedNotifications(); + return mod?.default; +} + export async function createServer() { const { instructions, ...serverInfo } = INJECTED_CONFIG; const server = new McpServer(serverInfo, { instructions }); const toolModulesPromise = loadTools(); const promptModulesPromise = loadPrompts(); const resourceModulesPromise = loadResources(); - const [toolModules, promptModules, resourceModules] = await Promise.all([ - toolModulesPromise, - promptModulesPromise, - resourceModulesPromise, - ]); - return configureServer(server, toolModules, promptModules, resourceModules); + const notificationsConfigPromise = loadNotificationsConfig(); + const [toolModules, promptModules, resourceModules, notificationsConfig] = + await Promise.all([ + toolModulesPromise, + promptModulesPromise, + resourceModulesPromise, + notificationsConfigPromise, + ]); + return configureServer( + server, + toolModules, + promptModules, + resourceModules, + notificationsConfig + ); } diff --git a/packages/xmcp/src/types/injected.d.ts b/packages/xmcp/src/types/injected.d.ts index 281fe0a71..617f30987 100644 --- a/packages/xmcp/src/types/injected.d.ts +++ b/packages/xmcp/src/types/injected.d.ts @@ -15,6 +15,12 @@ declare const INJECTED_RESOURCES: Record< () => Promise >; +declare const INJECTED_NOTIFICATIONS: + | (() => Promise<{ + default?: import("./notification").NotificationsConfig; + }>) + | undefined; + declare const INJECTED_MIDDLEWARE: | (() => Promise<{ default?: diff --git a/packages/xmcp/src/types/notification.ts b/packages/xmcp/src/types/notification.ts new file mode 100644 index 000000000..6ca803c2c --- /dev/null +++ b/packages/xmcp/src/types/notification.ts @@ -0,0 +1,63 @@ +// Known keys users write in defineNotifications() +export type NotificationKey = + | "initialized" + | "cancelled" + | "progress" + | "rootsListChanged" + | "taskStatus"; + +// Full MCP method strings (used internally for SDK registration) +export type NotificationMethodMap = { + initialized: "notifications/initialized"; + cancelled: "notifications/cancelled"; + progress: "notifications/progress"; + rootsListChanged: "notifications/roots/list_changed"; + taskStatus: "notifications/tasks/status"; +}; + +// Task status values from the MCP spec +export type TaskStatus = + | "working" + | "input_required" + | "completed" + | "failed" + | "cancelled"; + +// Typed params for each known key +export type NotificationParams = { + initialized: Record; + cancelled: { requestId?: string | number; reason?: string }; + progress: { + progressToken: string | number; + progress: number; + total?: number; + message?: string; + }; + rootsListChanged: Record; + taskStatus: { + taskId: string; + status: TaskStatus; + statusMessage?: string; + createdAt: string; + lastUpdatedAt: string; + ttl: number | null; + pollInterval?: number; + }; +}; + +// Handler type for known keys — paramless notifications don't require params arg +export type NotificationHandler = + NotificationParams[K] extends Record + ? () => void | Promise + : (params: NotificationParams[K]) => void | Promise; + +// Handler type for custom notification methods +export type CustomNotificationHandler = ( + params: Record +) => void | Promise; + +// Return type of defineNotifications() +export interface NotificationsConfig { + __isNotificationsConfig: true; + handlers: Record void | Promise>; +} diff --git a/packages/xmcp/src/utils/path-validation.ts b/packages/xmcp/src/utils/path-validation.ts index 8a1731f3c..4ce53c9c8 100644 --- a/packages/xmcp/src/utils/path-validation.ts +++ b/packages/xmcp/src/utils/path-validation.ts @@ -5,7 +5,7 @@ import path from "path"; // for further addition of resources, prompts, etc, we can add more path types // used for the message error to the user -type PathType = "tools" | "prompts" | "resources"; +type PathType = "tools" | "prompts" | "resources" | "notifications"; export function isValidPath( pathStr: string | boolean | null | undefined, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71b1ae65b..c8851827a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -674,6 +674,15 @@ importers: specifier: ^4.0.10 version: 4.1.13 + examples/notifications: + dependencies: + xmcp: + specifier: workspace:* + version: link:../../packages/xmcp + zod: + specifier: ^4.0.10 + version: 4.1.13 + examples/polar-http: dependencies: '@xmcp-dev/polar':