From 59b3e8fc9d33922670456d2beef1b27ae7e8abf7 Mon Sep 17 00:00:00 2001 From: 0xKoller Date: Thu, 19 Mar 2026 16:46:14 -0300 Subject: [PATCH 1/5] Notificacions --- .../docs/configuration/custom-directories.mdx | 4 +- .../content/docs/core-concepts/meta.json | 1 + .../docs/core-concepts/notifications.mdx | 102 +++++++++++ .../getting-started/project-structure.mdx | 10 +- examples/notifications/README.md | 107 ++++++++++++ examples/notifications/package.json | 19 ++ .../src/notifications/on-cancel.ts | 13 ++ .../src/notifications/on-initialized.ts | 9 + .../src/notifications/on-progress.ts | 15 ++ .../src/notifications/roots-changed.ts | 11 ++ examples/notifications/src/tools/ping.ts | 10 ++ examples/notifications/test-notifications.ts | 111 ++++++++++++ examples/notifications/tsconfig.json | 11 ++ examples/notifications/xmcp.config.ts | 14 ++ .../xmcp/src/compiler/compiler-context.ts | 5 +- .../xmcp/src/compiler/config/schemas/paths.ts | 2 + packages/xmcp/src/compiler/config/utils.ts | 12 ++ .../xmcp/src/compiler/generate-import-code.ts | 30 ++++ .../src/compiler/get-bundler-config/index.ts | 4 + packages/xmcp/src/compiler/index.ts | 26 ++- packages/xmcp/src/index.ts | 7 + .../nextjs/handler/server-lifecycle.ts | 6 +- .../http/stateless-streamable-http.ts | 7 + .../xmcp/src/runtime/utils/notifications.ts | 123 +++++++++++++ packages/xmcp/src/runtime/utils/server.ts | 44 ++++- packages/xmcp/src/subscribe.ts | 16 ++ packages/xmcp/src/types/notification.ts | 50 ++++++ packages/xmcp/src/utils/path-validation.ts | 2 +- pnpm-lock.yaml | 162 ++++++++++-------- 29 files changed, 848 insertions(+), 85 deletions(-) create mode 100644 apps/website/content/docs/core-concepts/notifications.mdx create mode 100644 examples/notifications/README.md create mode 100644 examples/notifications/package.json create mode 100644 examples/notifications/src/notifications/on-cancel.ts create mode 100644 examples/notifications/src/notifications/on-initialized.ts create mode 100644 examples/notifications/src/notifications/on-progress.ts create mode 100644 examples/notifications/src/notifications/roots-changed.ts create mode 100644 examples/notifications/src/tools/ping.ts create mode 100644 examples/notifications/test-notifications.ts create mode 100644 examples/notifications/tsconfig.json create mode 100644 examples/notifications/xmcp.config.ts create mode 100644 packages/xmcp/src/runtime/utils/notifications.ts create mode 100644 packages/xmcp/src/subscribe.ts create mode 100644 packages/xmcp/src/types/notification.ts diff --git a/apps/website/content/docs/configuration/custom-directories.mdx b/apps/website/content/docs/configuration/custom-directories.mdx index 4360d7d01..c98b54907 100644 --- a/apps/website/content/docs/configuration/custom-directories.mdx +++ b/apps/website/content/docs/configuration/custom-directories.mdx @@ -6,7 +6,7 @@ summary: "Learn how to customize the directories for tools, prompts, and resourc description: "Customize where xmcp looks for tools, prompts, and resources." --- -Customize where `xmcp` looks for tools, prompts, and resources by configuring the `paths` option. If not specified, these are the defaults: +Customize where `xmcp` looks for tools, prompts, resources, and notifications by configuring the `paths` option. If not specified, these are the defaults: ```typescript title="xmcp.config.ts" const config: XmcpConfig = { @@ -14,6 +14,7 @@ const config: XmcpConfig = { tools: "src/tools", prompts: "src/prompts", resources: "src/resources", + notifications: "src/notifications", }, }; @@ -30,6 +31,7 @@ const config: XmcpConfig = { tools: "src/tools", prompts: false, // Prompts directory disabled resources: "src/resources", + notifications: false, // Notifications directory disabled }, }; 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..46b37d375 --- /dev/null +++ b/apps/website/content/docs/core-concepts/notifications.mdx @@ -0,0 +1,102 @@ +--- +title: "Notifications" +metadataTitle: "Notifications | xmcp Documentation" +publishedAt: "2026-03-19" +summary: "Learn how to subscribe to MCP client notifications in your xmcp application." +description: "Notifications are one-way messages from clients to servers. xmcp provides a file-based convention for subscribing to these events using the subscribe() function." +--- + +By default, `xmcp` detects files under the `/src/notifications/` directory and registers them as notification handlers, but you can specify a [custom directory](/docs/configuration/custom-directories) if you prefer. + +A notification file exports a single default using the `subscribe()` function: + +```typescript title="src/notifications/roots-changed.ts" +import { subscribe } from "xmcp"; + +export default subscribe("notifications/roots/list_changed", async (params, extra) => { + console.log("Client roots changed — refreshing workspace context"); +}); +``` + +## What are MCP notifications? + +In the MCP protocol, notifications are **one-way messages** that don't expect a response. Clients send notifications to servers to signal events like session initialization, request cancellation, or workspace changes. + +## Available notification methods + +These are the standard MCP client-to-server notification methods: + +| Method | Params | Description | +| ------------------------------------ | --------------------------------------------------- | --------------------------------------------------- | +| `notifications/initialized` | _none_ | Client completed the initialization handshake | +| `notifications/roots/list_changed` | _none_ | Client's root URIs changed | +| `notifications/cancelled` | `requestId`, `reason?` | Client cancelled an in-flight request | +| `notifications/progress` | `progressToken`, `progress`, `total?`, `message?` | Client sent a progress update | + +Custom notification methods are also supported — `params` will be typed as `Record`. + +## Handler signature + +The handler receives two arguments: + +- **`params`**: The notification parameters, fully typed for known methods. +- **`extra`**: Context object with `signal` (AbortSignal). + +```typescript title="src/notifications/on-cancel.ts" +import { subscribe } from "xmcp"; + +export default subscribe("notifications/cancelled", async (params, extra) => { + // params.requestId and params.reason are fully typed + console.log(`Request ${params.requestId} was cancelled: ${params.reason}`); +}); +``` + +## Multiple handlers + +Multiple files can subscribe to the same notification method. All handlers run **concurrently** and are **isolated** — if one throws, the others still execute. + +```typescript title="src/notifications/log-cancel.ts" +import { subscribe } from "xmcp"; + +export default subscribe("notifications/cancelled", async (params) => { + console.log(`Cancelled: ${params.requestId}`); +}); +``` + +```typescript title="src/notifications/metrics-cancel.ts" +import { subscribe } from "xmcp"; + +export default subscribe("notifications/cancelled", async (params) => { + // Track cancellation metrics + metrics.increment("request.cancelled"); +}); +``` + +Both handlers fire when a cancellation notification arrives. + +## SDK handler preservation + +For `notifications/cancelled` and `notifications/progress`, the MCP SDK has built-in internal handlers that manage request abortion and progress tracking. Your handlers run **alongside** them — they never replace the SDK's built-in behavior. + +## Custom notification methods + +You can subscribe to any notification method string, not just the standard ones. Custom methods have `params` typed as `Record`: + +```typescript title="src/notifications/custom-event.ts" +import { subscribe } from "xmcp"; + +export default subscribe("custom/my-event", async (params) => { + // params is Record + console.log("Custom event received:", params); +}); +``` + +## Error handling + +Handler errors are logged automatically and don't affect other handlers or server stability: + +``` +[xmcp] Notification handler error for "notifications/cancelled": Error: something went wrong +``` + +Invalid notification files (missing or malformed `subscribe()` export) produce a warning but don't prevent the server from starting. diff --git a/apps/website/content/docs/getting-started/project-structure.mdx b/apps/website/content/docs/getting-started/project-structure.mdx index d2dce6c28..3408807d2 100644 --- a/apps/website/content/docs/getting-started/project-structure.mdx +++ b/apps/website/content/docs/getting-started/project-structure.mdx @@ -23,6 +23,9 @@ my-project/ │ └── resources/ # Resource files are auto-discovered here │ ├── (config)/app.ts │ ├── (users)/[userId]/profile.ts +│ └── notifications/ # Notification handlers are auto-discovered here +│ ├── roots-changed.ts +│ ├── on-cancel.ts ├── dist/ # Built output (generated) ├── package.json ├── tsconfig.json @@ -41,7 +44,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` file at the root of `src/` processes HTTP requests and responses. You can customize the location of `tools/`, `prompts/`, `resources/`, and `notifications/` directories in your `xmcp.config.ts` file. See the [custom directories](../../configuration/custom-directories) documentation for details. + { + console.log("Roots list changed!"); +}); +``` + +xmcp discovers these files at build time (just like tools, prompts, and resources) and registers the handlers on the MCP server at runtime. + +### Handler signature + +```ts +subscribe(method, async (params, extra) => { + // params — typed based on the notification method + // extra — { signal: AbortSignal } +}); +``` + +- **Known methods** (`notifications/cancelled`, `notifications/progress`, etc.) get fully typed `params`. +- **Custom methods** are also supported — `params` is typed as `Record`. +- **Multiple files** can subscribe to the same notification. All handlers run concurrently. +- **Error isolation** — if one handler throws, others still execute. + +### SDK handler preservation + +For `notifications/cancelled` and `notifications/progress`, the MCP SDK already has internal handlers (for aborting requests and tracking progress). Your handlers run **alongside** them — they don't replace the SDK's built-in behavior. + +## Project structure + +``` +src/ +├── notifications/ +│ ├── roots-changed.ts # Reacts to root URI changes +│ ├── on-cancel.ts # Logs cancelled requests +│ ├── on-progress.ts # Logs progress updates +│ └── on-initialized.ts # Logs session initialization +└── 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 — workspace context may need updating +[notification] Request req-42 was cancelled: User clicked cancel +[notification] Progress (75%): Almost done +``` + +## Configuration + +The notifications directory path is configurable in `xmcp.config.ts`: + +```ts +const config: XmcpConfig = { + paths: { + notifications: "src/notifications", // default + // notifications: "src/events", // custom path + // notifications: false, // disable + }, +}; +``` 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/notifications/on-cancel.ts b/examples/notifications/src/notifications/on-cancel.ts new file mode 100644 index 000000000..b60a87871 --- /dev/null +++ b/examples/notifications/src/notifications/on-cancel.ts @@ -0,0 +1,13 @@ +import { subscribe } from "xmcp"; + +/** + * Fires when the client cancels an in-flight request. + * The SDK already handles aborting the request internally — + * this handler runs alongside it (additive) for logging or cleanup. + */ +export default subscribe("notifications/cancelled", async (params) => { + console.log( + `[notification] Request ${params.requestId} was cancelled` + + (params.reason ? `: ${params.reason}` : "") + ); +}); diff --git a/examples/notifications/src/notifications/on-initialized.ts b/examples/notifications/src/notifications/on-initialized.ts new file mode 100644 index 000000000..13014aca0 --- /dev/null +++ b/examples/notifications/src/notifications/on-initialized.ts @@ -0,0 +1,9 @@ +import { subscribe } from "xmcp"; + +/** + * Fires when the client completes the initialization handshake. + * Use this for one-time setup that requires a live session. + */ +export default subscribe("notifications/initialized", async () => { + console.log("[notification] Client session initialized"); +}); diff --git a/examples/notifications/src/notifications/on-progress.ts b/examples/notifications/src/notifications/on-progress.ts new file mode 100644 index 000000000..59501beec --- /dev/null +++ b/examples/notifications/src/notifications/on-progress.ts @@ -0,0 +1,15 @@ +import { subscribe } from "xmcp"; + +/** + * Fires when the client sends a progress update. + * The SDK already tracks progress internally — + * this handler runs alongside it (additive) for logging or custom metrics. + */ +export default subscribe("notifications/progress", async (params) => { + const pct = params.total + ? ` (${Math.round((params.progress / params.total) * 100)}%)` + : ""; + console.log( + `[notification] Progress${pct}: ${params.message ?? "no message"}` + ); +}); diff --git a/examples/notifications/src/notifications/roots-changed.ts b/examples/notifications/src/notifications/roots-changed.ts new file mode 100644 index 000000000..0d85df2a3 --- /dev/null +++ b/examples/notifications/src/notifications/roots-changed.ts @@ -0,0 +1,11 @@ +import { subscribe } from "xmcp"; + +/** + * Fires when the client's list of root URIs changes. + * Use this to update workspace context, refresh file watchers, etc. + */ +export default subscribe("notifications/roots/list_changed", async () => { + console.log( + "[notification] Roots list changed — workspace context may need updating" + ); +}); diff --git a/examples/notifications/src/tools/ping.ts b/examples/notifications/src/tools/ping.ts new file mode 100644 index 000000000..8f556c7be --- /dev/null +++ b/examples/notifications/src/tools/ping.ts @@ -0,0 +1,10 @@ +import { type ToolMetadata } from "xmcp"; + +export const metadata: ToolMetadata = { + name: "ping", + description: "Simple ping tool to verify the server is running", +}; + +export default function ping() { + return "pong"; +} diff --git a/examples/notifications/test-notifications.ts b/examples/notifications/test-notifications.ts new file mode 100644 index 000000000..113fb9997 --- /dev/null +++ b/examples/notifications/test-notifications.ts @@ -0,0 +1,111 @@ +/** + * 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"; + +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: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(body), + }); + + if (id !== undefined) { + return res.json(); + } + return { status: res.status }; +} + +async function sendBatch(messages: Record[]) { + const res = await fetch(SERVER_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(messages), + }); + return res.json(); +} + +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(" Done.\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 — workspace context may need updating\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 was 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" + ); + + 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..8979f1ab4 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; + /** The paths to the notifications. */ + notificationPaths: Set; /** 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" | "notificationPaths" | "hasMiddleware" >, callback: () => void ) => { @@ -46,6 +48,7 @@ export const compilerContextProvider = async ( toolPaths: new Set(), promptPaths: new Set(), resourcePaths: new Set(), + notificationPaths: new Set(), hasMiddleware: false, }, () => Promise.resolve(callback()) diff --git a/packages/xmcp/src/compiler/config/schemas/paths.ts b/packages/xmcp/src/compiler/config/schemas/paths.ts index fe3687b86..e3b62029c 100644 --- a/packages/xmcp/src/compiler/config/schemas/paths.ts +++ b/packages/xmcp/src/compiler/config/schemas/paths.ts @@ -8,6 +8,7 @@ const pathsConfigBaseSchema = z.object({ tools: z.union([z.boolean(), z.string()]).default(true), prompts: z.union([z.boolean(), z.string()]).default(true), resources: z.union([z.boolean(), z.string()]).default(true), + notifications: z.union([z.boolean(), z.string()]).default(true), }); // Input schema - all fields optional for partial configs @@ -32,4 +33,5 @@ export const DEFAULT_PATHS = { tools: "src/tools", prompts: "src/prompts", resources: "src/resources", + notifications: "src/notifications", } as const; diff --git a/packages/xmcp/src/compiler/config/utils.ts b/packages/xmcp/src/compiler/config/utils.ts index 5a4972482..07b82620f 100644 --- a/packages/xmcp/src/compiler/config/utils.ts +++ b/packages/xmcp/src/compiler/config/utils.ts @@ -56,6 +56,7 @@ export type ResolvedPathsConfig = { tools: string | null; prompts: string | null; resources: string | null; + notifications: string | null; }; export function getResolvedPathsConfig( @@ -67,6 +68,7 @@ export function getResolvedPathsConfig( tools: DEFAULT_PATHS.tools, prompts: DEFAULT_PATHS.prompts, resources: DEFAULT_PATHS.resources, + notifications: DEFAULT_PATHS.notifications, }; } @@ -74,6 +76,7 @@ export function getResolvedPathsConfig( tools: DEFAULT_PATHS.tools, prompts: DEFAULT_PATHS.prompts, resources: DEFAULT_PATHS.resources, + notifications: DEFAULT_PATHS.notifications, }; // Handle tools path @@ -99,6 +102,15 @@ export function getResolvedPathsConfig( resolvedPaths.resources = userPaths.resources; } + // Handle notifications path + if (typeof userPaths.notifications === "boolean") { + resolvedPaths.notifications = userPaths.notifications + ? DEFAULT_PATHS.notifications + : null; + } else if (typeof userPaths.notifications === "string") { + resolvedPaths.notifications = userPaths.notifications; + } + return resolvedPaths; } diff --git a/packages/xmcp/src/compiler/generate-import-code.ts b/packages/xmcp/src/compiler/generate-import-code.ts index f3abcfeef..e519b4034 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, + notificationPaths, hasMiddleware, clientBundles, platforms, @@ -27,6 +28,7 @@ export function generateImportCode(): string { toolPaths, promptPaths, resourcePaths, + notificationPaths, hasMiddleware, clientBundles ); @@ -36,6 +38,7 @@ export function generateImportCode(): string { toolPaths, promptPaths, resourcePaths, + notificationPaths, hasMiddleware, clientBundles ); @@ -49,6 +52,7 @@ function generateStaticImportCode( toolPaths: Set, promptPaths: Set, resourcePaths: Set, + notificationPaths: Set, hasMiddleware: boolean, clientBundles?: Map ): string { @@ -57,6 +61,7 @@ function generateStaticImportCode( const toolsEntries: string[] = []; const promptsEntries: string[] = []; const resourcesEntries: string[] = []; + const notificationsEntries: string[] = []; Array.from(toolPaths).forEach((p) => { const path = p.replace(/\\/g, "/"); @@ -82,6 +87,14 @@ function generateStaticImportCode( resourcesEntries.push(`"${path}": () => Promise.resolve(${identifier}),`); }); + Array.from(notificationPaths).forEach((p) => { + const path = p.replace(/\\/g, "/"); + const relativePath = `../${path}`; + const identifier = pathToIdentifier(path); + staticImports.push(`import * as ${identifier} from "${relativePath}";`); + notificationsEntries.push(`"${path}": () => Promise.resolve(${identifier}),`); + }); + let middlewareCode = ""; if (hasMiddleware) { staticImports.push(`import * as _middleware from "../src/middleware.ts";`); @@ -110,6 +123,10 @@ export const resources = { ${resourcesEntries.join("\n")} }; +export const notifications = { +${notificationsEntries.join("\n")} +}; + export const clientBundles = { ${clientBundlesEntries} }; @@ -126,6 +143,7 @@ function generateDynamicImportCode( toolPaths: Set, promptPaths: Set, resourcePaths: Set, + notificationPaths: Set, hasMiddleware: boolean, clientBundles?: Map ): string { @@ -153,6 +171,14 @@ function generateDynamicImportCode( }) .join("\n"); + const importNotificationsCode = Array.from(notificationPaths) + .map((p) => { + const path = p.replace(/\\/g, "/"); + const relativePath = `../${path}`; + return `"${path}": () => import("${relativePath}"),`; + }) + .join("\n"); + const importMiddlewareCode = hasMiddleware ? `export const middleware = () => import("../src/middleware.ts");` : ""; @@ -178,6 +204,10 @@ export const resources = { ${importResourcesCode} }; +export const notifications = { +${importNotificationsCode} +}; + export const clientBundles = { ${clientBundlesEntries} }; 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 750991bfa..5437bb290 100644 --- a/packages/xmcp/src/compiler/index.ts +++ b/packages/xmcp/src/compiler/index.ts @@ -48,7 +48,7 @@ export interface CompileOptions { } export async function compile({ onBuild }: CompileOptions = {}) { - const { mode, toolPaths, promptPaths, resourcePaths, platforms } = + const { mode, toolPaths, promptPaths, resourcePaths, notificationPaths, platforms } = compilerContext.getContext(); const startTime = Date.now(); let compilerStarted = false; @@ -166,6 +166,29 @@ export async function compile({ onBuild }: CompileOptions = {}) { }); } + // handle notifications + let notificationsPath = isValidPath( + getResolvedPathsConfig(xmcpConfig).notifications, + "notifications" + ); + + if (notificationsPath) { + watcher.watch(`${notificationsPath}/**/*.{ts,tsx}`, { + onAdd: async (filePath) => { + addWatchedPath(notificationPaths, filePath); + if (compilerStarted) { + await generateCode(); + } + }, + onUnlink: async (filePath) => { + removeWatchedPath(notificationPaths, filePath); + if (compilerStarted) { + await generateCode(); + } + }, + }); + } + // if adapter is not enabled, handle middleware if (!xmcpConfig.experimental?.adapter) { // handle middleware @@ -220,6 +243,7 @@ export async function compile({ onBuild }: CompileOptions = {}) { reactToolsCount, promptsCount: promptPaths.size, resourcesCount: resourcePaths.size, + notificationsCount: notificationPaths.size, transport: xmcpConfig.http ? TransportType.HTTP : TransportType.STDIO, adapter: xmcpConfig.experimental?.adapter ? (xmcpConfig.experimental.adapter as AdapterType) diff --git a/packages/xmcp/src/index.ts b/packages/xmcp/src/index.ts index 2b28e5b30..48cb797d4 100644 --- a/packages/xmcp/src/index.ts +++ b/packages/xmcp/src/index.ts @@ -12,6 +12,13 @@ export type { export type { PromptMetadata } from "./types/prompt"; export type { ResourceMetadata } from "./types/resource"; export type { UIMetadata } from "./types/ui-meta"; +export type { + NotificationMethod, + NotificationHandler, + NotificationSubscription, + NotificationExtra, +} from "./types/notification"; +export { subscribe } from "./subscribe"; 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 9e4fc8468..9d5113b88 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, + loadNotifications, } from "@/runtime/utils/server"; export interface ServerLifecycle { @@ -40,12 +41,13 @@ export async function initializeMcpServer(): Promise { const [toolPromises, toolModules] = loadTools(); const [promptPromises, promptModules] = loadPrompts(); const [resourcePromises, resourceModules] = loadResources(); + const [notificationPromises, notificationModules] = loadNotifications(); - await Promise.all([...toolPromises, ...promptPromises, ...resourcePromises]); + await Promise.all([...toolPromises, ...promptPromises, ...resourcePromises, ...notificationPromises]); const server = new McpServer(INJECTED_CONFIG); - await configureServer(server, toolModules, promptModules, resourceModules); + await configureServer(server, toolModules, promptModules, resourceModules, notificationModules); return server; } diff --git a/packages/xmcp/src/runtime/transports/http/stateless-streamable-http.ts b/packages/xmcp/src/runtime/transports/http/stateless-streamable-http.ts index 2f7fd778d..10f4a60f0 100644 --- a/packages/xmcp/src/runtime/transports/http/stateless-streamable-http.ts +++ b/packages/xmcp/src/runtime/transports/http/stateless-streamable-http.ts @@ -213,6 +213,13 @@ export class StatelessHttpServerTransport extends BaseHttpServerTransport { if (!hasRequests) { // Handle notifications (no response expected) + // Still dispatch to the server so notification handlers can fire + const authInfo: AuthInfo | undefined = req.auth; + for (const message of messages) { + if (this.onmessage) { + this.onmessage(message, { authInfo }); + } + } res.writeHead(202).end(); return; } diff --git a/packages/xmcp/src/runtime/utils/notifications.ts b/packages/xmcp/src/runtime/utils/notifications.ts new file mode 100644 index 000000000..ffe4d47c0 --- /dev/null +++ b/packages/xmcp/src/runtime/utils/notifications.ts @@ -0,0 +1,123 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp"; +import { + CancelledNotificationSchema, + ProgressNotificationSchema, + InitializedNotificationSchema, + RootsListChangedNotificationSchema, + NotificationSchema, +} from "@modelcontextprotocol/sdk/types"; +import { z } from "zod"; +import type { + NotificationSubscription, + NotificationExtra, +} from "@/types/notification"; + +export type NotificationFile = { + default: NotificationSubscription; +}; + +// Map known methods → SDK Zod schemas +const KNOWN_SCHEMAS: Record> = { + "notifications/cancelled": CancelledNotificationSchema, + "notifications/progress": ProgressNotificationSchema, + "notifications/initialized": InitializedNotificationSchema, + "notifications/roots/list_changed": RootsListChangedNotificationSchema, +}; + +// 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, + notificationModules: Map +): void { + // Group user handlers by method + const handlersByMethod = new Map< + string, + Array + >(); + + notificationModules.forEach((mod, path) => { + const subscription = mod.default; + if (!subscription || !subscription.__isNotificationSubscription) { + console.warn( + `[xmcp] Invalid notification subscription at ${path}. Expected default export from subscribe().` + ); + return; + } + + const { method, handler } = subscription; + if (!handlersByMethod.has(method)) { + handlersByMethod.set(method, []); + } + handlersByMethod.get(method)!.push(handler); + }); + + const lowLevelServer = server.server; + + handlersByMethod.forEach((handlers, method) => { + const schema = KNOWN_SCHEMAS[method] ?? createGenericSchema(method); + + // For SDK internal methods, preserve the existing handler via wrap approach. + // The SDK's Protocol stores handlers in _notificationHandlers Map keyed by method string. + // We read the existing handler before overwriting to chain it with user handlers. + let existingHandler: + | ((notification: any) => void | Promise) + | undefined; + if (SDK_INTERNAL_METHODS.has(method)) { + existingHandler = (lowLevelServer as any)._notificationHandlers?.get( + method + ); + if (!existingHandler) { + console.warn( + `[xmcp] Could not find SDK internal handler for "${method}". ` + + `SDK request cancellation/progress tracking may not work correctly.` + ); + } + } + + 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. Build extra context + const extra: NotificationExtra = { + signal: new AbortController().signal, + }; + + // 3. Run all user handlers concurrently with isolation + const params = notification.params ?? {}; + const results = await Promise.allSettled( + handlers.map((h) => Promise.resolve(h(params as any, extra))) + ); + + // 4. Log any handler errors + results.forEach((result) => { + if (result.status === "rejected") { + console.error( + `[xmcp] Notification handler error for "${method}":`, + result.reason + ); + } + }); + }); + }); +} diff --git a/packages/xmcp/src/runtime/utils/server.ts b/packages/xmcp/src/runtime/utils/server.ts index fb54f0d99..c4cb8842a 100644 --- a/packages/xmcp/src/runtime/utils/server.ts +++ b/packages/xmcp/src/runtime/utils/server.ts @@ -9,6 +9,10 @@ import { UserPromptHandler } from "./transformers/prompt"; import { UserResourceHandler } from "./transformers/resource"; import { ZodRawShape } from "zod/v3"; import { addResourcesToServer } from "./resources"; +import { + addNotificationsToServer, + NotificationFile, +} from "./notifications"; import { ResourceMetadata } from "@/types/resource"; import { uIResourceRegistry } from "./ext-apps-registry"; @@ -49,6 +53,12 @@ export const injectedResources = INJECTED_RESOURCES as Record< () => Promise >; +// @ts-expect-error: injected by compiler +export const injectedNotifications = INJECTED_NOTIFICATIONS as Record< + string, + () => Promise +>; + // @ts-expect-error: injected by compiler export const INJECTED_CONFIG = SERVER_INFO as Implementation; @@ -58,13 +68,15 @@ export async function configureServer( server: McpServer, toolModules: Map, promptModules: Map, - resourceModules: Map + resourceModules: Map, + notificationModules: Map ): Promise { uIResourceRegistry.clear(); addToolsToServer(server, toolModules); addPromptsToServer(server, promptModules); addResourcesToServer(server, resourceModules); + addNotificationsToServer(server, notificationModules); return server; } @@ -104,13 +116,35 @@ export function loadResources() { return [resourcePromises, resourceModules] as const; } +export function loadNotifications() { + const notificationModules = new Map(); + + const notificationPromises = Object.keys(injectedNotifications).map((path) => + injectedNotifications[path]().then((notificationModule) => { + notificationModules.set(path, notificationModule); + }) + ); + + return [notificationPromises, notificationModules] as const; +} + export async function createServer() { const server = new McpServer(INJECTED_CONFIG); const [toolPromises, toolModules] = loadTools(); const [promptPromises, promptModules] = loadPrompts(); const [resourcePromises, resourceModules] = loadResources(); - await Promise.all(toolPromises); - await Promise.all(promptPromises); - await Promise.all(resourcePromises); - return configureServer(server, toolModules, promptModules, resourceModules); + const [notificationPromises, notificationModules] = loadNotifications(); + await Promise.all([ + ...toolPromises, + ...promptPromises, + ...resourcePromises, + ...notificationPromises, + ]); + return configureServer( + server, + toolModules, + promptModules, + resourceModules, + notificationModules + ); } diff --git a/packages/xmcp/src/subscribe.ts b/packages/xmcp/src/subscribe.ts new file mode 100644 index 000000000..259a2e32a --- /dev/null +++ b/packages/xmcp/src/subscribe.ts @@ -0,0 +1,16 @@ +import type { + NotificationMethod, + NotificationHandler, + NotificationSubscription, +} from "./types/notification"; + +export function subscribe( + method: M, + handler: NotificationHandler +): NotificationSubscription { + return { + __isNotificationSubscription: true, + method, + handler, + }; +} diff --git a/packages/xmcp/src/types/notification.ts b/packages/xmcp/src/types/notification.ts new file mode 100644 index 000000000..acc95355a --- /dev/null +++ b/packages/xmcp/src/types/notification.ts @@ -0,0 +1,50 @@ +// Known MCP client→server notification methods +export type KnownNotificationMethod = + | "notifications/cancelled" + | "notifications/progress" + | "notifications/initialized" + | "notifications/roots/list_changed"; + +// Open type: known methods + arbitrary strings +export type NotificationMethod = KnownNotificationMethod | (string & {}); + +// Typed params for known methods +export type KnownNotificationParams = { + "notifications/cancelled": { requestId: string | number; reason?: string }; + "notifications/progress": { + progressToken: string | number; + progress: number; + total?: number; + message?: string; + }; + "notifications/initialized": Record; + "notifications/roots/list_changed": Record; +}; + +// Resolve params: known → typed, unknown → Record +export type NotificationParamsFor = + M extends keyof KnownNotificationParams + ? KnownNotificationParams[M] + : Record; + +// Extra context available at notification time. +// Unlike tool handlers, notification handlers don't have access to +// sendNotification/sendRequest/authInfo since notifications are +// dispatched outside the request-response lifecycle. +export interface NotificationExtra { + /** An abort signal for the notification handler */ + signal: AbortSignal; +} + +// Handler callback type +export type NotificationHandler = ( + params: NotificationParamsFor, + extra: NotificationExtra +) => void | Promise; + +// Return type of subscribe() +export interface NotificationSubscription { + __isNotificationSubscription: true; + method: M; + handler: NotificationHandler; +} 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 2dc84658e..8937fd814 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,10 +124,10 @@ importers: version: 0.178.1 '@vercel/analytics': specifier: ^1.6.1 - version: 1.6.1(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 1.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) '@vercel/speed-insights': specifier: ^1.3.1 - version: 1.3.1(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 1.3.1(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) ai: specifier: ^5.0.108 version: 5.0.108(zod@4.1.13) @@ -145,13 +145,13 @@ importers: version: 12.23.25(react-dom@19.2.4(react@19.2.4))(react@19.2.4) fumadocs-core: specifier: ^15.8.5 - version: 15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) fumadocs-mdx: specifier: ^12.0.3 - version: 12.0.3(fumadocs-core@15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4))(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 12.0.3(fumadocs-core@15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4))(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) fumadocs-ui: specifier: ^15.8.5 - version: 15.8.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(tailwindcss@4.1.17) + version: 15.8.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(tailwindcss@4.1.17) gray-matter: specifier: ^4.0.3 version: 4.0.3 @@ -223,7 +223,7 @@ importers: version: 5.0.0 xmcp: specifier: latest - version: 0.6.2(@swc/helpers@0.5.15)(postcss@8.5.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.104.1)(zod@4.1.13) + version: 0.6.5(@swc/helpers@0.5.15)(postcss@8.5.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.104.1)(zod@4.1.13) zod: specifier: ^4.1.13 version: 4.1.13 @@ -287,7 +287,7 @@ importers: dependencies: next: specifier: 16.1.7 - version: 16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: specifier: ^19.2.3 version: 19.2.3 @@ -364,10 +364,10 @@ importers: dependencies: better-auth: specifier: ^1.4.6 - version: 1.4.6(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.4.6(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next: specifier: 16.1.7 - version: 16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) pg: specifier: ^8.16.3 version: 8.16.3 @@ -652,6 +652,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': @@ -894,7 +903,7 @@ importers: version: 5.1.0(esbuild@0.27.1) xmcp: specifier: latest - version: 0.6.2(@swc/helpers@0.5.15)(postcss@8.5.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.104.1(esbuild@0.27.1))(zod@4.1.13) + version: 0.6.5(@swc/helpers@0.5.15)(postcss@8.5.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.104.1(esbuild@0.27.1))(zod@4.1.13) zod: specifier: ^4.0.10 version: 4.1.13 @@ -6181,6 +6190,7 @@ packages: glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.0: @@ -6189,7 +6199,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} @@ -9094,6 +9104,19 @@ packages: react-dom: optional: true + xmcp@0.6.5: + resolution: {integrity: sha512-0bqADWeaKYLGMrijUKpiLbx1UsgLKYdVyLZwgZ/98aNm8HBAGksNMbpcBsyoXE8lMAlzyJb4v6y2kBCWicEa2g==} + hasBin: true + peerDependencies: + react: '>=19.0.0' + react-dom: '>=19.0.0' + zod: ^3.25.76 || ^4.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + xtend@2.2.0: resolution: {integrity: sha512-SLt5uylT+4aoXxXuwtQp5ZnMMzhDb1Xkg4pEqc00WUJCQifPfV9Ub1VrNhp9kXkrjZD2I2Hl8WnjP37jzZLPZw==} engines: {node: '>=0.4'} @@ -12265,16 +12288,16 @@ snapshots: '@use-gesture/core': 10.3.1 react: 19.2.4 - '@vercel/analytics@1.6.1(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': + '@vercel/analytics@1.6.1(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': optionalDependencies: - next: 16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 '@vercel/oidc@3.0.5': {} - '@vercel/speed-insights@1.3.1(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': + '@vercel/speed-insights@1.3.1(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': optionalDependencies: - next: 16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.19.2)(lightningcss@1.30.2)(terser@5.44.1))': @@ -12697,7 +12720,7 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - better-auth@1.4.6(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + better-auth@1.4.6(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@better-auth/core': 1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.6(@better-auth/core@1.4.6(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.5(zod@4.1.13))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.0)) @@ -12713,7 +12736,7 @@ snapshots: nanostores: 1.1.0 zod: 4.1.13 optionalDependencies: - next: 16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + next: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -13504,8 +13527,8 @@ snapshots: '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.1(jiti@2.6.1)) @@ -13554,21 +13577,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3 - eslint: 9.39.1(jiti@2.6.1) - get-tsconfig: 4.13.0 - is-bun-module: 2.0.0 - stable-hash: 0.0.5 - tinyglobby: 0.2.15 - unrs-resolver: 1.11.1 - optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -13584,28 +13592,18 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.1(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - - eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): - dependencies: - debug: 3.2.7 - optionalDependencies: eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -13616,7 +13614,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -13645,7 +13643,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -14125,7 +14123,7 @@ snapshots: fsevents@2.3.3: optional: true - fumadocs-core@15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): + fumadocs-core@15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): dependencies: '@formatjs/intl-localematcher': 0.6.2 '@orama/orama': 3.1.16 @@ -14148,21 +14146,21 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 lucide-react: 0.544.0(react@19.2.4) - next: 16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) react-router: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) transitivePeerDependencies: - supports-color - fumadocs-mdx@12.0.3(fumadocs-core@15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4))(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): + fumadocs-mdx@12.0.3(fumadocs-core@15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4))(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): dependencies: '@mdx-js/mdx': 3.1.1 '@standard-schema/spec': 1.0.0 chokidar: 4.0.3 esbuild: 0.25.12 estree-util-value-to-estree: 3.5.0 - fumadocs-core: 15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + fumadocs-core: 15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) js-yaml: 4.1.1 lru-cache: 11.2.4 mdast-util-to-markdown: 2.1.2 @@ -14175,12 +14173,12 @@ snapshots: unist-util-visit: 5.0.0 zod: 4.1.13 optionalDependencies: - next: 16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 transitivePeerDependencies: - supports-color - fumadocs-ui@15.8.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(tailwindcss@4.1.17): + fumadocs-ui@15.8.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(tailwindcss@4.1.17): dependencies: '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -14193,7 +14191,7 @@ snapshots: '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: 0.7.1 - fumadocs-core: 15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + fumadocs-core: 15.8.5(@types/react@19.2.14)(lucide-react@0.544.0(react@19.2.4))(next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) lodash.merge: 4.6.2 next-themes: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) postcss-selector-parser: 7.1.1 @@ -14204,7 +14202,7 @@ snapshots: tailwind-merge: 3.4.0 optionalDependencies: '@types/react': 19.2.14 - next: 16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tailwindcss: 4.1.17 transitivePeerDependencies: - '@mixedbread/sdk' @@ -15794,7 +15792,7 @@ snapshots: postcss: 8.4.31 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.3) + styled-jsx: 5.1.6(react@19.2.3) optionalDependencies: '@next/swc-darwin-arm64': 15.5.13 '@next/swc-darwin-x64': 15.5.13 @@ -15810,16 +15808,16 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.1.7 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.8 caniuse-lite: 1.0.30001767 postcss: 8.4.31 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.4) optionalDependencies: '@next/swc-darwin-arm64': 16.1.7 '@next/swc-darwin-x64': 16.1.7 @@ -15835,16 +15833,16 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.1.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.1.7(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@next/env': 16.1.7 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.8 caniuse-lite: 1.0.30001767 postcss: 8.4.31 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.4) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + styled-jsx: 5.1.6(react@19.2.3) optionalDependencies: '@next/swc-darwin-arm64': 16.1.7 '@next/swc-darwin-x64': 16.1.7 @@ -17171,19 +17169,17 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.3): + styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.4): dependencies: client-only: 0.0.1 - react: 19.2.3 + react: 19.2.4 optionalDependencies: '@babel/core': 7.28.5 - styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.4): + styled-jsx@5.1.6(react@19.2.3): dependencies: client-only: 0.0.1 - react: 19.2.4 - optionalDependencies: - '@babel/core': 7.28.5 + react: 19.2.3 sucrase@3.35.1: dependencies: @@ -17975,10 +17971,29 @@ snapshots: - supports-color - webpack - xmcp@0.6.2(@swc/helpers@0.5.15)(postcss@8.5.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.104.1(esbuild@0.27.1))(zod@4.1.13): + xmcp@0.6.2(@swc/helpers@0.5.15)(postcss@8.5.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.104.1)(zod@4.1.13): dependencies: '@modelcontextprotocol/sdk': 1.26.0(zod@4.1.13) '@rspack/core': 1.6.7(@swc/helpers@0.5.15) + postcss-loader: 8.2.0(@rspack/core@1.6.7(@swc/helpers@0.5.15))(postcss@8.5.6)(typescript@5.9.3)(webpack@5.104.1) + ts-checker-rspack-plugin: 1.2.1(@rspack/core@1.6.7(@swc/helpers@0.5.15))(typescript@5.9.3) + typescript: 5.9.3 + zod: 4.1.13 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@cfworker/json-schema' + - '@swc/helpers' + - postcss + - supports-color + - webpack + + xmcp@0.6.5(@swc/helpers@0.5.15)(postcss@8.5.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.104.1(esbuild@0.27.1))(zod@4.1.13): + dependencies: + '@modelcontextprotocol/sdk': 1.26.0(zod@4.1.13) + '@rspack/core': 1.6.7(@swc/helpers@0.5.15) + jose: 6.1.3 postcss-loader: 8.2.0(@rspack/core@1.6.7(@swc/helpers@0.5.15))(postcss@8.5.6)(typescript@5.9.3)(webpack@5.104.1(esbuild@0.27.1)) ts-checker-rspack-plugin: 1.2.1(@rspack/core@1.6.7(@swc/helpers@0.5.15))(typescript@5.9.3) typescript: 5.9.3 @@ -17993,10 +18008,11 @@ snapshots: - supports-color - webpack - xmcp@0.6.2(@swc/helpers@0.5.15)(postcss@8.5.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.104.1)(zod@4.1.13): + xmcp@0.6.5(@swc/helpers@0.5.15)(postcss@8.5.6)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(webpack@5.104.1)(zod@4.1.13): dependencies: '@modelcontextprotocol/sdk': 1.26.0(zod@4.1.13) '@rspack/core': 1.6.7(@swc/helpers@0.5.15) + jose: 6.1.3 postcss-loader: 8.2.0(@rspack/core@1.6.7(@swc/helpers@0.5.15))(postcss@8.5.6)(typescript@5.9.3)(webpack@5.104.1) ts-checker-rspack-plugin: 1.2.1(@rspack/core@1.6.7(@swc/helpers@0.5.15))(typescript@5.9.3) typescript: 5.9.3 From d632f46878082dd47f48e224f71c34582fdd39d0 Mon Sep 17 00:00:00 2001 From: 0xKoller Date: Mon, 30 Mar 2026 10:17:43 -0300 Subject: [PATCH 2/5] Update pnpm-lock.yaml --- pnpm-lock.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31403d190..3febc3db5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -652,6 +652,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': From c0936468957e337f33d711e20379412c4e4bb2ea Mon Sep 17 00:00:00 2001 From: 0xKoller Date: Mon, 30 Mar 2026 12:42:00 -0300 Subject: [PATCH 3/5] Asks and fixes --- .../docs/configuration/custom-directories.mdx | 4 +- .../docs/core-concepts/notifications.mdx | 135 ++++++++++-------- .../getting-started/project-structure.mdx | 16 +-- examples/notifications/README.md | 94 ++++++------ examples/notifications/src/notifications.ts | 34 +++++ .../src/notifications/on-cancel.ts | 13 -- .../src/notifications/on-initialized.ts | 9 -- .../src/notifications/on-progress.ts | 15 -- .../src/notifications/roots-changed.ts | 11 -- .../xmcp/src/compiler/compiler-context.ts | 8 +- .../xmcp/src/compiler/config/schemas/paths.ts | 2 - packages/xmcp/src/compiler/config/utils.ts | 12 -- .../xmcp/src/compiler/generate-import-code.ts | 43 ++---- packages/xmcp/src/compiler/index.ts | 45 +++--- packages/xmcp/src/define-notifications.ts | 42 ++++++ packages/xmcp/src/index.ts | 9 +- .../nextjs/handler/server-lifecycle.ts | 9 +- .../xmcp/src/runtime/utils/notifications.ts | 92 +++++------- packages/xmcp/src/runtime/utils/server.ts | 42 +++--- packages/xmcp/src/subscribe.ts | 16 --- packages/xmcp/src/types/injected.d.ts | 6 + packages/xmcp/src/types/notification.ts | 87 ++++++----- 22 files changed, 363 insertions(+), 381 deletions(-) create mode 100644 examples/notifications/src/notifications.ts delete mode 100644 examples/notifications/src/notifications/on-cancel.ts delete mode 100644 examples/notifications/src/notifications/on-initialized.ts delete mode 100644 examples/notifications/src/notifications/on-progress.ts delete mode 100644 examples/notifications/src/notifications/roots-changed.ts create mode 100644 packages/xmcp/src/define-notifications.ts delete mode 100644 packages/xmcp/src/subscribe.ts diff --git a/apps/website/content/docs/configuration/custom-directories.mdx b/apps/website/content/docs/configuration/custom-directories.mdx index c98b54907..4360d7d01 100644 --- a/apps/website/content/docs/configuration/custom-directories.mdx +++ b/apps/website/content/docs/configuration/custom-directories.mdx @@ -6,7 +6,7 @@ summary: "Learn how to customize the directories for tools, prompts, and resourc description: "Customize where xmcp looks for tools, prompts, and resources." --- -Customize where `xmcp` looks for tools, prompts, resources, and notifications by configuring the `paths` option. If not specified, these are the defaults: +Customize where `xmcp` looks for tools, prompts, and resources by configuring the `paths` option. If not specified, these are the defaults: ```typescript title="xmcp.config.ts" const config: XmcpConfig = { @@ -14,7 +14,6 @@ const config: XmcpConfig = { tools: "src/tools", prompts: "src/prompts", resources: "src/resources", - notifications: "src/notifications", }, }; @@ -31,7 +30,6 @@ const config: XmcpConfig = { tools: "src/tools", prompts: false, // Prompts directory disabled resources: "src/resources", - notifications: false, // Notifications directory disabled }, }; diff --git a/apps/website/content/docs/core-concepts/notifications.mdx b/apps/website/content/docs/core-concepts/notifications.mdx index 46b37d375..55d4881aa 100644 --- a/apps/website/content/docs/core-concepts/notifications.mdx +++ b/apps/website/content/docs/core-concepts/notifications.mdx @@ -2,101 +2,112 @@ title: "Notifications" metadataTitle: "Notifications | xmcp Documentation" publishedAt: "2026-03-19" -summary: "Learn how to subscribe to MCP client notifications in your xmcp application." -description: "Notifications are one-way messages from clients to servers. xmcp provides a file-based convention for subscribing to these events using the subscribe() function." +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." --- -By default, `xmcp` detects files under the `/src/notifications/` directory and registers them as notification handlers, but you can specify a [custom directory](/docs/configuration/custom-directories) if you prefer. +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: -A notification file exports a single default using the `subscribe()` function: +- 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. -```typescript title="src/notifications/roots-changed.ts" -import { subscribe } from "xmcp"; +Notifications are fire-and-forget: the server receives them, acts on them if needed, and never sends a reply. -export default subscribe("notifications/roots/list_changed", async (params, extra) => { - console.log("Client roots changed — refreshing workspace context"); -}); -``` - -## What are MCP notifications? +## Client-to-server notifications -In the MCP protocol, notifications are **one-way messages** that don't expect a response. Clients send notifications to servers to signal events like session initialization, request cancellation, or workspace changes. +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): -## Available notification methods +| 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 | -These are the standard MCP client-to-server notification methods: +## Handling notifications in xmcp -| Method | Params | Description | -| ------------------------------------ | --------------------------------------------------- | --------------------------------------------------- | -| `notifications/initialized` | _none_ | Client completed the initialization handshake | -| `notifications/roots/list_changed` | _none_ | Client's root URIs changed | -| `notifications/cancelled` | `requestId`, `reason?` | Client cancelled an in-flight request | -| `notifications/progress` | `progressToken`, `progress`, `total?`, `message?` | Client sent a progress update | +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`. -Custom notification methods are also supported — `params` will be typed as `Record`. +```typescript title="src/notifications.ts" +import { defineNotifications } from "xmcp"; -## Handler signature +export default defineNotifications({ + initialized: async () => { + console.log("Client session initialized"); + }, + rootsListChanged: async () => { + console.log("Client roots changed, refreshing workspace context"); + }, +}); +``` -The handler receives two arguments: +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. -- **`params`**: The notification parameters, fully typed for known methods. -- **`extra`**: Context object with `signal` (AbortSignal). +## Handler signature -```typescript title="src/notifications/on-cancel.ts" -import { subscribe } from "xmcp"; +Notifications **without params** (`initialized`, `rootsListChanged`) take no arguments. Notifications **with params** receive them fully typed: -export default subscribe("notifications/cancelled", async (params, extra) => { - // params.requestId and params.reason are fully typed - console.log(`Request ${params.requestId} was cancelled: ${params.reason}`); -}); -``` +```typescript title="src/notifications.ts" +import { defineNotifications } from "xmcp"; -## Multiple handlers +export default defineNotifications({ + initialized: async () => { + console.log("Client ready"); + }, -Multiple files can subscribe to the same notification method. All handlers run **concurrently** and are **isolated** — if one throws, the others still execute. + cancelled: async (params) => { + // params.requestId and params.reason are fully typed + console.log(`Request ${params.requestId} was cancelled: ${params.reason}`); + }, -```typescript title="src/notifications/log-cancel.ts" -import { subscribe } from "xmcp"; + progress: async (params) => { + const pct = params.total + ? ` (${Math.round((params.progress / params.total) * 100)}%)` + : ""; + console.log(`Progress${pct}: ${params.message ?? "no message"}`); + }, -export default subscribe("notifications/cancelled", async (params) => { - console.log(`Cancelled: ${params.requestId}`); + taskStatus: async (params) => { + console.log(`Task ${params.taskId}: ${params.status}`); + }, }); ``` -```typescript title="src/notifications/metrics-cancel.ts" -import { subscribe } from "xmcp"; +## 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 subscribe("notifications/cancelled", async (params) => { - // Track cancellation metrics - metrics.increment("request.cancelled"); +export default defineNotifications({ + initialized: async () => { + console.log("Client ready"); + }, + "custom/my-event": async (params) => { + console.log("Custom event received:", params); + }, }); ``` -Both handlers fire when a cancellation notification arrives. - ## SDK handler preservation -For `notifications/cancelled` and `notifications/progress`, the MCP SDK has built-in internal handlers that manage request abortion and progress tracking. Your handlers run **alongside** them — they never replace the SDK's built-in behavior. - -## Custom notification methods - -You can subscribe to any notification method string, not just the standard ones. Custom methods have `params` typed as `Record`: +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. -```typescript title="src/notifications/custom-event.ts" -import { subscribe } from "xmcp"; - -export default subscribe("custom/my-event", async (params) => { - // params is Record - console.log("Custom event received:", params); -}); -``` + + 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 logged automatically and don't affect other handlers or server stability: +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 ``` - -Invalid notification files (missing or malformed `subscribe()` export) produce a warning but don't prevent the server from starting. diff --git a/apps/website/content/docs/getting-started/project-structure.mdx b/apps/website/content/docs/getting-started/project-structure.mdx index 3408807d2..f155a4d16 100644 --- a/apps/website/content/docs/getting-started/project-structure.mdx +++ b/apps/website/content/docs/getting-started/project-structure.mdx @@ -13,23 +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 -│ └── notifications/ # Notification handlers are auto-discovered here -│ ├── roots-changed.ts -│ ├── on-cancel.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 @@ -44,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/`, `resources/`, and `notifications/` 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("Roots list changed!"); +export default defineNotifications({ + initialized: async () => { + 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 these files at build time (just like tools, prompts, and resources) and registers the handlers on the MCP server at runtime. +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 -subscribe(method, async (params, extra) => { - // params — typed based on the notification method - // extra — { signal: AbortSignal } +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 methods** (`notifications/cancelled`, `notifications/progress`, etc.) get fully typed `params`. -- **Custom methods** are also supported — `params` is typed as `Record`. -- **Multiple files** can subscribe to the same notification. All handlers run concurrently. -- **Error isolation** — if one handler throws, others still execute. +- **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 `notifications/cancelled` and `notifications/progress`, the MCP SDK already has internal handlers (for aborting requests and tracking progress). Your handlers run **alongside** them — they don't replace the SDK's built-in behavior. +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/ -│ ├── roots-changed.ts # Reacts to root URI changes -│ ├── on-cancel.ts # Logs cancelled requests -│ ├── on-progress.ts # Logs progress updates -│ └── on-initialized.ts # Logs session initialization +├── notifications.ts # All notification handlers in one place └── tools/ - └── ping.ts # Simple ping tool + └── ping.ts # Simple ping tool ``` ## Running the example @@ -87,21 +106,8 @@ You should see output like: ``` [notification] Client session initialized -[notification] Roots list changed — workspace context may need updating -[notification] Request req-42 was cancelled: User clicked cancel +[notification] Roots list changed +[notification] Request req-42 cancelled: User clicked cancel [notification] Progress (75%): Almost done -``` - -## Configuration - -The notifications directory path is configurable in `xmcp.config.ts`: - -```ts -const config: XmcpConfig = { - paths: { - notifications: "src/notifications", // default - // notifications: "src/events", // custom path - // notifications: false, // disable - }, -}; +[notification] Task task-1: completed ``` diff --git a/examples/notifications/src/notifications.ts b/examples/notifications/src/notifications.ts new file mode 100644 index 000000000..3a5e8e3be --- /dev/null +++ b/examples/notifications/src/notifications.ts @@ -0,0 +1,34 @@ +import { defineNotifications } from "xmcp"; + +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 methods are also supported + "custom/my-event": async (params) => { + console.log("[notification] Custom event received:", params); + }, +}); diff --git a/examples/notifications/src/notifications/on-cancel.ts b/examples/notifications/src/notifications/on-cancel.ts deleted file mode 100644 index b60a87871..000000000 --- a/examples/notifications/src/notifications/on-cancel.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { subscribe } from "xmcp"; - -/** - * Fires when the client cancels an in-flight request. - * The SDK already handles aborting the request internally — - * this handler runs alongside it (additive) for logging or cleanup. - */ -export default subscribe("notifications/cancelled", async (params) => { - console.log( - `[notification] Request ${params.requestId} was cancelled` + - (params.reason ? `: ${params.reason}` : "") - ); -}); diff --git a/examples/notifications/src/notifications/on-initialized.ts b/examples/notifications/src/notifications/on-initialized.ts deleted file mode 100644 index 13014aca0..000000000 --- a/examples/notifications/src/notifications/on-initialized.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { subscribe } from "xmcp"; - -/** - * Fires when the client completes the initialization handshake. - * Use this for one-time setup that requires a live session. - */ -export default subscribe("notifications/initialized", async () => { - console.log("[notification] Client session initialized"); -}); diff --git a/examples/notifications/src/notifications/on-progress.ts b/examples/notifications/src/notifications/on-progress.ts deleted file mode 100644 index 59501beec..000000000 --- a/examples/notifications/src/notifications/on-progress.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { subscribe } from "xmcp"; - -/** - * Fires when the client sends a progress update. - * The SDK already tracks progress internally — - * this handler runs alongside it (additive) for logging or custom metrics. - */ -export default subscribe("notifications/progress", async (params) => { - const pct = params.total - ? ` (${Math.round((params.progress / params.total) * 100)}%)` - : ""; - console.log( - `[notification] Progress${pct}: ${params.message ?? "no message"}` - ); -}); diff --git a/examples/notifications/src/notifications/roots-changed.ts b/examples/notifications/src/notifications/roots-changed.ts deleted file mode 100644 index 0d85df2a3..000000000 --- a/examples/notifications/src/notifications/roots-changed.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { subscribe } from "xmcp"; - -/** - * Fires when the client's list of root URIs changes. - * Use this to update workspace context, refresh file watchers, etc. - */ -export default subscribe("notifications/roots/list_changed", async () => { - console.log( - "[notification] Roots list changed — workspace context may need updating" - ); -}); diff --git a/packages/xmcp/src/compiler/compiler-context.ts b/packages/xmcp/src/compiler/compiler-context.ts index 8979f1ab4..e1ca3a2ad 100644 --- a/packages/xmcp/src/compiler/compiler-context.ts +++ b/packages/xmcp/src/compiler/compiler-context.ts @@ -20,8 +20,8 @@ interface CompilerContext { promptPaths: Set; /** The paths to the resources. */ resourcePaths: Set; - /** The paths to the notifications. */ - notificationPaths: Set; + /** Whether the notifications setup file is present. */ + hasNotifications: boolean; /** Whether the middleware is enabled. */ hasMiddleware: boolean; /** The parsed config. */ @@ -38,7 +38,7 @@ export const compilerContext = createContext({ export const compilerContextProvider = async ( initialValue: Omit< CompilerContext, - "toolPaths" | "promptPaths" | "resourcePaths" | "notificationPaths" | "hasMiddleware" + "toolPaths" | "promptPaths" | "resourcePaths" | "hasNotifications" | "hasMiddleware" >, callback: () => void ) => { @@ -48,7 +48,7 @@ export const compilerContextProvider = async ( toolPaths: new Set(), promptPaths: new Set(), resourcePaths: new Set(), - notificationPaths: new Set(), + hasNotifications: false, hasMiddleware: false, }, () => Promise.resolve(callback()) diff --git a/packages/xmcp/src/compiler/config/schemas/paths.ts b/packages/xmcp/src/compiler/config/schemas/paths.ts index e3b62029c..fe3687b86 100644 --- a/packages/xmcp/src/compiler/config/schemas/paths.ts +++ b/packages/xmcp/src/compiler/config/schemas/paths.ts @@ -8,7 +8,6 @@ const pathsConfigBaseSchema = z.object({ tools: z.union([z.boolean(), z.string()]).default(true), prompts: z.union([z.boolean(), z.string()]).default(true), resources: z.union([z.boolean(), z.string()]).default(true), - notifications: z.union([z.boolean(), z.string()]).default(true), }); // Input schema - all fields optional for partial configs @@ -33,5 +32,4 @@ export const DEFAULT_PATHS = { tools: "src/tools", prompts: "src/prompts", resources: "src/resources", - notifications: "src/notifications", } as const; diff --git a/packages/xmcp/src/compiler/config/utils.ts b/packages/xmcp/src/compiler/config/utils.ts index 07b82620f..5a4972482 100644 --- a/packages/xmcp/src/compiler/config/utils.ts +++ b/packages/xmcp/src/compiler/config/utils.ts @@ -56,7 +56,6 @@ export type ResolvedPathsConfig = { tools: string | null; prompts: string | null; resources: string | null; - notifications: string | null; }; export function getResolvedPathsConfig( @@ -68,7 +67,6 @@ export function getResolvedPathsConfig( tools: DEFAULT_PATHS.tools, prompts: DEFAULT_PATHS.prompts, resources: DEFAULT_PATHS.resources, - notifications: DEFAULT_PATHS.notifications, }; } @@ -76,7 +74,6 @@ export function getResolvedPathsConfig( tools: DEFAULT_PATHS.tools, prompts: DEFAULT_PATHS.prompts, resources: DEFAULT_PATHS.resources, - notifications: DEFAULT_PATHS.notifications, }; // Handle tools path @@ -102,15 +99,6 @@ export function getResolvedPathsConfig( resolvedPaths.resources = userPaths.resources; } - // Handle notifications path - if (typeof userPaths.notifications === "boolean") { - resolvedPaths.notifications = userPaths.notifications - ? DEFAULT_PATHS.notifications - : null; - } else if (typeof userPaths.notifications === "string") { - resolvedPaths.notifications = userPaths.notifications; - } - return resolvedPaths; } diff --git a/packages/xmcp/src/compiler/generate-import-code.ts b/packages/xmcp/src/compiler/generate-import-code.ts index e519b4034..725bec627 100644 --- a/packages/xmcp/src/compiler/generate-import-code.ts +++ b/packages/xmcp/src/compiler/generate-import-code.ts @@ -13,7 +13,7 @@ export function generateImportCode(): string { toolPaths, promptPaths, resourcePaths, - notificationPaths, + hasNotifications, hasMiddleware, clientBundles, platforms, @@ -28,7 +28,7 @@ export function generateImportCode(): string { toolPaths, promptPaths, resourcePaths, - notificationPaths, + hasNotifications, hasMiddleware, clientBundles ); @@ -38,7 +38,7 @@ export function generateImportCode(): string { toolPaths, promptPaths, resourcePaths, - notificationPaths, + hasNotifications, hasMiddleware, clientBundles ); @@ -52,7 +52,7 @@ function generateStaticImportCode( toolPaths: Set, promptPaths: Set, resourcePaths: Set, - notificationPaths: Set, + hasNotifications: boolean, hasMiddleware: boolean, clientBundles?: Map ): string { @@ -61,7 +61,6 @@ function generateStaticImportCode( const toolsEntries: string[] = []; const promptsEntries: string[] = []; const resourcesEntries: string[] = []; - const notificationsEntries: string[] = []; Array.from(toolPaths).forEach((p) => { const path = p.replace(/\\/g, "/"); @@ -87,13 +86,11 @@ function generateStaticImportCode( resourcesEntries.push(`"${path}": () => Promise.resolve(${identifier}),`); }); - Array.from(notificationPaths).forEach((p) => { - const path = p.replace(/\\/g, "/"); - const relativePath = `../${path}`; - const identifier = pathToIdentifier(path); - staticImports.push(`import * as ${identifier} from "${relativePath}";`); - notificationsEntries.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) { @@ -123,14 +120,11 @@ export const resources = { ${resourcesEntries.join("\n")} }; -export const notifications = { -${notificationsEntries.join("\n")} -}; - export const clientBundles = { ${clientBundlesEntries} }; +${notificationsCode} ${middlewareCode} `; } @@ -143,7 +137,7 @@ function generateDynamicImportCode( toolPaths: Set, promptPaths: Set, resourcePaths: Set, - notificationPaths: Set, + hasNotifications: boolean, hasMiddleware: boolean, clientBundles?: Map ): string { @@ -171,13 +165,9 @@ function generateDynamicImportCode( }) .join("\n"); - const importNotificationsCode = Array.from(notificationPaths) - .map((p) => { - const path = p.replace(/\\/g, "/"); - const relativePath = `../${path}`; - return `"${path}": () => import("${relativePath}"),`; - }) - .join("\n"); + const importNotificationsCode = hasNotifications + ? `export const notifications = () => import("../src/notifications.ts");` + : ""; const importMiddlewareCode = hasMiddleware ? `export const middleware = () => import("../src/middleware.ts");` @@ -204,14 +194,11 @@ export const resources = { ${importResourcesCode} }; -export const notifications = { -${importNotificationsCode} -}; - export const clientBundles = { ${clientBundlesEntries} }; +${importNotificationsCode} ${importMiddlewareCode} `; } diff --git a/packages/xmcp/src/compiler/index.ts b/packages/xmcp/src/compiler/index.ts index 5437bb290..15bf98ed0 100644 --- a/packages/xmcp/src/compiler/index.ts +++ b/packages/xmcp/src/compiler/index.ts @@ -48,7 +48,7 @@ export interface CompileOptions { } export async function compile({ onBuild }: CompileOptions = {}) { - const { mode, toolPaths, promptPaths, resourcePaths, notificationPaths, platforms } = + const { mode, toolPaths, promptPaths, resourcePaths, platforms } = compilerContext.getContext(); const startTime = Date.now(); let compilerStarted = false; @@ -166,28 +166,25 @@ export async function compile({ onBuild }: CompileOptions = {}) { }); } - // handle notifications - let notificationsPath = isValidPath( - getResolvedPathsConfig(xmcpConfig).notifications, - "notifications" - ); - - if (notificationsPath) { - watcher.watch(`${notificationsPath}/**/*.{ts,tsx}`, { - onAdd: async (filePath) => { - addWatchedPath(notificationPaths, filePath); - if (compilerStarted) { - await generateCode(); - } - }, - onUnlink: async (filePath) => { - removeWatchedPath(notificationPaths, filePath); - if (compilerStarted) { - await generateCode(); - } - }, - }); - } + // 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) { @@ -243,7 +240,7 @@ export async function compile({ onBuild }: CompileOptions = {}) { reactToolsCount, promptsCount: promptPaths.size, resourcesCount: resourcePaths.size, - notificationsCount: notificationPaths.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 48cb797d4..390d8f7d7 100644 --- a/packages/xmcp/src/index.ts +++ b/packages/xmcp/src/index.ts @@ -13,12 +13,13 @@ export type { PromptMetadata } from "./types/prompt"; export type { ResourceMetadata } from "./types/resource"; export type { UIMetadata } from "./types/ui-meta"; export type { - NotificationMethod, + NotificationKey, NotificationHandler, - NotificationSubscription, - NotificationExtra, + CustomNotificationHandler, + NotificationsConfig, + NotificationParams, } from "./types/notification"; -export { subscribe } from "./subscribe"; +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 d00ead726..4aba5f404 100644 --- a/packages/xmcp/src/runtime/adapters/nextjs/handler/server-lifecycle.ts +++ b/packages/xmcp/src/runtime/adapters/nextjs/handler/server-lifecycle.ts @@ -7,7 +7,7 @@ import { loadPrompts, loadResources, loadTools, - loadNotifications, + loadNotificationsConfig, } from "@/runtime/utils/server"; export interface ServerLifecycle { @@ -41,19 +41,20 @@ export async function initializeMcpServer(): Promise { const toolModulesPromise = loadTools(); const [promptPromises, promptModules] = loadPrompts(); const [resourcePromises, resourceModules] = loadResources(); - const [notificationPromises, notificationModules] = loadNotifications(); + const notificationsConfigPromise = loadNotificationsConfig(); await Promise.all([ toolModulesPromise, ...promptPromises, ...resourcePromises, - ...notificationPromises, + notificationsConfigPromise, ]); const toolModules = await toolModulesPromise; + const notificationsConfig = await notificationsConfigPromise; const server = new McpServer(INJECTED_CONFIG); - await configureServer(server, toolModules, promptModules, resourceModules, notificationModules); + 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 index ffe4d47c0..8437cf54d 100644 --- a/packages/xmcp/src/runtime/utils/notifications.ts +++ b/packages/xmcp/src/runtime/utils/notifications.ts @@ -4,17 +4,11 @@ import { ProgressNotificationSchema, InitializedNotificationSchema, RootsListChangedNotificationSchema, + TaskStatusNotificationSchema, NotificationSchema, } from "@modelcontextprotocol/sdk/types"; import { z } from "zod"; -import type { - NotificationSubscription, - NotificationExtra, -} from "@/types/notification"; - -export type NotificationFile = { - default: NotificationSubscription; -}; +import type { NotificationsConfig } from "@/types/notification"; // Map known methods → SDK Zod schemas const KNOWN_SCHEMAS: Record> = { @@ -22,6 +16,7 @@ const KNOWN_SCHEMAS: Record> = { "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 @@ -38,49 +33,38 @@ function createGenericSchema(method: string) { export function addNotificationsToServer( server: McpServer, - notificationModules: Map + config: NotificationsConfig | undefined ): void { - // Group user handlers by method - const handlersByMethod = new Map< - string, - Array - >(); - - notificationModules.forEach((mod, path) => { - const subscription = mod.default; - if (!subscription || !subscription.__isNotificationSubscription) { - console.warn( - `[xmcp] Invalid notification subscription at ${path}. Expected default export from subscribe().` - ); - return; - } - - const { method, handler } = subscription; - if (!handlersByMethod.has(method)) { - handlersByMethod.set(method, []); - } - handlersByMethod.get(method)!.push(handler); - }); + if (!config || !config.__isNotificationsConfig) return; const lowLevelServer = server.server; - handlersByMethod.forEach((handlers, method) => { + 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. - // The SDK's Protocol stores handlers in _notificationHandlers Map keyed by method string. - // We read the existing handler before overwriting to chain it with user handlers. + // 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)) { - existingHandler = (lowLevelServer as any)._notificationHandlers?.get( - 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) { - console.warn( - `[xmcp] Could not find SDK internal handler for "${method}". ` + - `SDK request cancellation/progress tracking may not work correctly.` + 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.` ); } } @@ -98,26 +82,16 @@ export function addNotificationsToServer( } } - // 2. Build extra context - const extra: NotificationExtra = { - signal: new AbortController().signal, - }; - - // 3. Run all user handlers concurrently with isolation + // 2. Run user handler with error isolation const params = notification.params ?? {}; - const results = await Promise.allSettled( - handlers.map((h) => Promise.resolve(h(params as any, extra))) - ); - - // 4. Log any handler errors - results.forEach((result) => { - if (result.status === "rejected") { - console.error( - `[xmcp] Notification handler error for "${method}":`, - result.reason - ); - } - }); + 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 ff052ee52..1dec0fdb1 100644 --- a/packages/xmcp/src/runtime/utils/server.ts +++ b/packages/xmcp/src/runtime/utils/server.ts @@ -9,13 +9,11 @@ import { UserPromptHandler } from "./transformers/prompt"; import { UserResourceHandler } from "./transformers/resource"; import { ZodRawShape } from "zod/v3"; import { addResourcesToServer } from "./resources"; -import { - addNotificationsToServer, - NotificationFile, -} from "./notifications"; +import { addNotificationsToServer } from "./notifications"; import { ResourceMetadata } from "@/types/resource"; import { uIResourceRegistry } from "./ext-apps-registry"; import { loadToolModules, reportToolLoadIssues } from "./tool-loader"; +import type { NotificationsConfig } from "@/types/notification"; export type ToolFile = { metadata: ToolMetadata; @@ -51,13 +49,10 @@ export const injectedResources = INJECTED_RESOURCES as Record< () => Promise >; -// @ts-expect-error: injected by compiler -export const injectedNotifications = INJECTED_NOTIFICATIONS as Record< - string, - () => Promise ->; +const injectedNotifications = INJECTED_NOTIFICATIONS as + | (() => Promise<{ default?: NotificationsConfig }>) + | undefined; -// @ts-expect-error: injected by compiler export const INJECTED_CONFIG = SERVER_INFO as Implementation; /* Loads all modules and injects them into the server */ @@ -67,14 +62,14 @@ export async function configureServer( toolModules: Map, promptModules: Map, resourceModules: Map, - notificationModules: Map + notificationsConfig: NotificationsConfig | undefined ): Promise { uIResourceRegistry.clear(); addToolsToServer(server, toolModules); addPromptsToServer(server, promptModules); addResourcesToServer(server, resourceModules); - addNotificationsToServer(server, notificationModules); + addNotificationsToServer(server, notificationsConfig); return server; } @@ -108,16 +103,12 @@ export function loadResources() { return [resourcePromises, resourceModules] as const; } -export function loadNotifications() { - const notificationModules = new Map(); - - const notificationPromises = Object.keys(injectedNotifications).map((path) => - injectedNotifications[path]().then((notificationModule) => { - notificationModules.set(path, notificationModule); - }) - ); - - return [notificationPromises, notificationModules] as const; +export async function loadNotificationsConfig(): Promise< + NotificationsConfig | undefined +> { + if (!injectedNotifications) return undefined; + const mod = await injectedNotifications(); + return mod?.default; } export async function createServer() { @@ -125,19 +116,20 @@ export async function createServer() { const toolModulesPromise = loadTools(); const [promptPromises, promptModules] = loadPrompts(); const [resourcePromises, resourceModules] = loadResources(); - const [notificationPromises, notificationModules] = loadNotifications(); + const notificationsConfigPromise = loadNotificationsConfig(); await Promise.all([ toolModulesPromise, ...promptPromises, ...resourcePromises, - ...notificationPromises, + notificationsConfigPromise, ]); const toolModules = await toolModulesPromise; + const notificationsConfig = await notificationsConfigPromise; return configureServer( server, toolModules, promptModules, resourceModules, - notificationModules + notificationsConfig ); } diff --git a/packages/xmcp/src/subscribe.ts b/packages/xmcp/src/subscribe.ts deleted file mode 100644 index 259a2e32a..000000000 --- a/packages/xmcp/src/subscribe.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { - NotificationMethod, - NotificationHandler, - NotificationSubscription, -} from "./types/notification"; - -export function subscribe( - method: M, - handler: NotificationHandler -): NotificationSubscription { - return { - __isNotificationSubscription: true, - method, - handler, - }; -} 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 index acc95355a..6ca803c2c 100644 --- a/packages/xmcp/src/types/notification.ts +++ b/packages/xmcp/src/types/notification.ts @@ -1,50 +1,63 @@ -// Known MCP client→server notification methods -export type KnownNotificationMethod = - | "notifications/cancelled" - | "notifications/progress" - | "notifications/initialized" - | "notifications/roots/list_changed"; +// Known keys users write in defineNotifications() +export type NotificationKey = + | "initialized" + | "cancelled" + | "progress" + | "rootsListChanged" + | "taskStatus"; -// Open type: known methods + arbitrary strings -export type NotificationMethod = KnownNotificationMethod | (string & {}); +// 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 known methods -export type KnownNotificationParams = { - "notifications/cancelled": { requestId: string | number; reason?: string }; - "notifications/progress": { +// 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; }; - "notifications/initialized": Record; - "notifications/roots/list_changed": Record; + rootsListChanged: Record; + taskStatus: { + taskId: string; + status: TaskStatus; + statusMessage?: string; + createdAt: string; + lastUpdatedAt: string; + ttl: number | null; + pollInterval?: number; + }; }; -// Resolve params: known → typed, unknown → Record -export type NotificationParamsFor = - M extends keyof KnownNotificationParams - ? KnownNotificationParams[M] - : Record; - -// Extra context available at notification time. -// Unlike tool handlers, notification handlers don't have access to -// sendNotification/sendRequest/authInfo since notifications are -// dispatched outside the request-response lifecycle. -export interface NotificationExtra { - /** An abort signal for the notification handler */ - signal: AbortSignal; -} +// 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 callback type -export type NotificationHandler = ( - params: NotificationParamsFor, - extra: NotificationExtra +// Handler type for custom notification methods +export type CustomNotificationHandler = ( + params: Record ) => void | Promise; -// Return type of subscribe() -export interface NotificationSubscription { - __isNotificationSubscription: true; - method: M; - handler: NotificationHandler; +// Return type of defineNotifications() +export interface NotificationsConfig { + __isNotificationsConfig: true; + handlers: Record void | Promise>; } From 367cde1efb6dbef801f6688a61048cfca86e4d97 Mon Sep 17 00:00:00 2001 From: 0xKoller Date: Mon, 30 Mar 2026 13:31:17 -0300 Subject: [PATCH 4/5] Update test-notifications.ts --- examples/notifications/test-notifications.ts | 31 ++++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/examples/notifications/test-notifications.ts b/examples/notifications/test-notifications.ts index 113fb9997..d453bc949 100644 --- a/examples/notifications/test-notifications.ts +++ b/examples/notifications/test-notifications.ts @@ -71,7 +71,7 @@ async function main() { // Send initialized notification await sendJsonRpc("notifications/initialized"); - console.log(" Done.\n"); + 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..."); @@ -79,9 +79,7 @@ async function main() { { jsonrpc: "2.0", method: "notifications/roots/list_changed" }, { jsonrpc: "2.0", method: "tools/list", id: 2 }, ]); - console.log( - " Expected: [notification] Roots list changed — workspace context may need updating\n" - ); + console.log(" Expected: [notification] Roots list changed\n"); // 3. notifications/cancelled console.log("3. Sending notifications/cancelled..."); @@ -90,7 +88,7 @@ async function main() { reason: "User clicked cancel", }); console.log( - " Expected: [notification] Request req-42 was cancelled: User clicked cancel\n" + " Expected: [notification] Request req-42 cancelled: User clicked cancel\n" ); // 4. notifications/progress @@ -101,8 +99,29 @@ async function main() { 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. Custom notification + console.log("6. Sending custom/my-event..."); + await sendJsonRpc("custom/my-event", { + foo: "bar", + }); console.log( - " Expected: [notification] Progress (75%): Almost done\n" + ' Expected: [notification] Custom event received: { foo: "bar" }\n' ); console.log("All notifications sent. Check the server terminal for output."); From 8cfc142c602ea4dcdd2fa3f4fc417a78560ba5be Mon Sep 17 00:00:00 2001 From: 0xKoller Date: Mon, 20 Apr 2026 10:07:28 -0300 Subject: [PATCH 5/5] custom notification example --- examples/notifications/src/analytics.ts | 11 +++ examples/notifications/src/notifications.ts | 10 ++- examples/notifications/src/tools/ping.ts | 2 + examples/notifications/test-notifications.ts | 74 +++++++++++++++----- 4 files changed, 78 insertions(+), 19 deletions(-) create mode 100644 examples/notifications/src/analytics.ts 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 index 3a5e8e3be..0161b3c3b 100644 --- a/examples/notifications/src/notifications.ts +++ b/examples/notifications/src/notifications.ts @@ -1,4 +1,5 @@ import { defineNotifications } from "xmcp"; +import { track } from "./analytics"; export default defineNotifications({ initialized: async () => { @@ -27,8 +28,11 @@ export default defineNotifications({ ); }, - // Custom notification methods are also supported - "custom/my-event": async (params) => { - console.log("[notification] Custom event received:", params); + // 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 index 8f556c7be..135ac5ae2 100644 --- a/examples/notifications/src/tools/ping.ts +++ b/examples/notifications/src/tools/ping.ts @@ -1,4 +1,5 @@ import { type ToolMetadata } from "xmcp"; +import { track } from "../analytics"; export const metadata: ToolMetadata = { name: "ping", @@ -6,5 +7,6 @@ export const metadata: ToolMetadata = { }; 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 index d453bc949..246f18d4e 100644 --- a/examples/notifications/test-notifications.ts +++ b/examples/notifications/test-notifications.ts @@ -11,6 +11,37 @@ 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, @@ -25,15 +56,13 @@ async function sendJsonRpc( const res = await fetch(SERVER_URL, { method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, + headers: buildHeaders(), body: JSON.stringify(body), }); + captureSessionId(res); if (id !== undefined) { - return res.json(); + return parseResponse(res); } return { status: res.status }; } @@ -41,13 +70,11 @@ async function sendJsonRpc( async function sendBatch(messages: Record[]) { const res = await fetch(SERVER_URL, { method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, + headers: buildHeaders(), body: JSON.stringify(messages), }); - return res.json(); + captureSessionId(res); + return parseResponse(res); } async function main() { @@ -115,15 +142,30 @@ async function main() { " Expected: [notification] Task task-1: completed - All items processed\n" ); - // 6. Custom notification - console.log("6. Sending custom/my-event..."); - await sendJsonRpc("custom/my-event", { - foo: "bar", - }); + // 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: [notification] Custom event received: { foo: "bar" }\n' + ' 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."); }