Skip to content

Commit 89db8c6

Browse files
refactor(examples): split customMethod example into server/client pair, drop ext-apps demo
1 parent 98d7742 commit 89db8c6

10 files changed

Lines changed: 211 additions & 264 deletions

File tree

docs/migration.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -432,9 +432,7 @@ class App extends Client {
432432
Custom handlers share the same dispatch path as standard handlers — context, cancellation, task delivery, and error wrapping all apply. Passing a `{ params, result }` schema bundle to `sendCustomRequest` (or `{ params }` to `sendCustomNotification`) validates outbound params
433433
before sending and gives typed `params`; passing a bare result schema sends params unvalidated.
434434

435-
For larger sub-protocols where neither side is semantically an MCP client or server, prefer composition: hold a `Client` (or `Server`) instance, register custom handlers on it, and expose typed facade methods. See `examples/server/src/customMethodExtAppsExample.ts` for a worked
436-
example.
437-
435+
For larger sub-protocols where neither side is semantically an MCP client or server, prefer composition: hold a `Client` (or `Server`) instance, register custom handlers on it, and expose typed facade methods. See `examples/server/src/customMethodExample.ts` and `examples/client/src/customMethodExample.ts` for runnable examples.
438436

439437
### `Protocol.request()`, `ctx.mcpReq.send()`, and `Client.callTool()` no longer take a schema parameter
440438

examples/client/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Most clients expect a server to be running. Start one from [`../server/README.md
3636
| Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) |
3737
| URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) |
3838
| Task interactive client | Demonstrates task-based execution + interactive server→client requests. | [`src/simpleTaskInteractiveClient.ts`](src/simpleTaskInteractiveClient.ts) |
39+
| Custom (non-standard) methods client | Sends `acme/*` custom requests and handles custom server notifications. | [`src/customMethodExample.ts`](src/customMethodExample.ts) |
3940

4041
## URL elicitation example (server + client)
4142

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Run with: pnpm tsx src/customMethodExample.ts
2+
//
3+
// Demonstrates sending custom (non-standard) requests and receiving custom
4+
// notifications from the server.
5+
//
6+
// The Protocol class exposes sendCustomRequest / setCustomNotificationHandler for
7+
// vendor-specific methods that are not part of the MCP spec. The schema-bundle
8+
// overload of sendCustomRequest gives typed params with pre-send validation.
9+
//
10+
// Pair with: examples/server/src/customMethodExample.ts (start the server first).
11+
12+
import { Client, ProtocolError, ProtocolErrorCode, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
13+
import { z } from 'zod';
14+
15+
const SearchParamsSchema = z.object({
16+
query: z.string(),
17+
limit: z.number().int().positive().optional()
18+
});
19+
20+
const SearchResultSchema = z.object({
21+
results: z.array(z.object({ id: z.string(), title: z.string() })),
22+
total: z.number()
23+
});
24+
25+
const AnalyticsResultSchema = z.object({ recorded: z.boolean() });
26+
27+
const StatusUpdateParamsSchema = z.object({
28+
status: z.enum(['idle', 'busy', 'error']),
29+
detail: z.string().optional()
30+
});
31+
32+
const serverUrl = process.argv[2] ?? 'http://localhost:3000/mcp';
33+
34+
async function main(): Promise<void> {
35+
const client = new Client({ name: 'custom-method-client', version: '1.0.0' });
36+
37+
// Register handler for custom server→client notifications before connecting.
38+
client.setCustomNotificationHandler('acme/statusUpdate', StatusUpdateParamsSchema, params => {
39+
console.log(`[client] acme/statusUpdate status=${params.status} detail=${params.detail ?? '<none>'}`);
40+
});
41+
42+
const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
43+
await client.connect(transport);
44+
console.log(`[client] connected to ${serverUrl}`);
45+
46+
// Schema-bundle overload: typed params + pre-send validation, typed result.
47+
const searchResult = await client.sendCustomRequest(
48+
'acme/search',
49+
{ query: 'widgets', limit: 5 },
50+
{ params: SearchParamsSchema, result: SearchResultSchema }
51+
);
52+
console.log(`[client] acme/search → ${searchResult.total} results, first: "${searchResult.results[0]?.title}"`);
53+
54+
// Loose overload: bare result schema, untyped params.
55+
const analyticsResult = await client.sendCustomRequest('acme/analytics', { event: 'page_view' }, AnalyticsResultSchema);
56+
console.log(`[client] acme/analytics → recorded=${analyticsResult.recorded}`);
57+
58+
// Pre-send validation: schema-bundle overload rejects bad params before the round-trip.
59+
try {
60+
await client.sendCustomRequest(
61+
'acme/search',
62+
{ query: 'widgets', limit: 'five' } as unknown as z.output<typeof SearchParamsSchema>,
63+
{ params: SearchParamsSchema, result: SearchResultSchema }
64+
);
65+
console.error('[client] expected validation error but request succeeded');
66+
} catch (error) {
67+
const code = error instanceof ProtocolError && error.code === ProtocolErrorCode.InvalidParams ? 'InvalidParams' : 'unknown';
68+
console.log(`[client] pre-send validation error (expected, ${code}): ${(error as Error).message}`);
69+
}
70+
71+
await transport.close();
72+
}
73+
74+
try {
75+
await main();
76+
} catch (error) {
77+
console.error('[client] error:', error);
78+
// eslint-disable-next-line unicorn/no-process-exit
79+
process.exit(1);
80+
}

examples/server/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ pnpm tsx src/simpleStreamableHttp.ts
3838
| Task interactive server | Task-based execution with interactive server→client requests. | [`src/simpleTaskInteractive.ts`](src/simpleTaskInteractive.ts) |
3939
| Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) |
4040
| SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) |
41+
| Custom (non-standard) methods server | Registers `acme/*` custom request handlers and sends custom notifications. | [`src/customMethodExample.ts`](src/customMethodExample.ts) |
4142

4243
## OAuth demo flags (Streamable HTTP server)
4344

examples/server/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
},
3434
"dependencies": {
3535
"@hono/node-server": "catalog:runtimeServerOnly",
36-
"@modelcontextprotocol/client": "workspace:^",
3736
"@modelcontextprotocol/examples-shared": "workspace:^",
3837
"@modelcontextprotocol/express": "workspace:^",
3938
"@modelcontextprotocol/hono": "workspace:^",
Lines changed: 81 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,42 @@
1-
#!/usr/bin/env node
2-
/**
3-
* Demonstrates custom (non-standard) request and notification methods.
4-
*
5-
* The Protocol class exposes setCustomRequestHandler / setCustomNotificationHandler /
6-
* sendCustomRequest / sendCustomNotification for vendor-specific methods that are not
7-
* part of the MCP spec. Params and results are validated against user-provided Zod
8-
* schemas, and handlers receive the same context (cancellation, task support,
9-
* bidirectional send/notify) as standard handlers.
10-
*/
11-
12-
import { Client } from '@modelcontextprotocol/client';
13-
import { InMemoryTransport, Server } from '@modelcontextprotocol/server';
1+
// Run with: pnpm tsx src/customMethodExample.ts
2+
//
3+
// Demonstrates registering handlers for custom (non-standard) request methods
4+
// and sending custom notifications back to the client.
5+
//
6+
// The Protocol class exposes setCustomRequestHandler / sendCustomNotification for
7+
// vendor-specific methods that are not part of the MCP spec. Params are validated
8+
// against user-provided Zod schemas, and handlers receive the same context
9+
// (cancellation, bidirectional send/notify) as standard handlers.
10+
//
11+
// Pair with: examples/client/src/customMethodExample.ts
12+
13+
import { randomUUID } from 'node:crypto';
14+
15+
import { createMcpExpressApp } from '@modelcontextprotocol/express';
16+
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
17+
import { isInitializeRequest, Server } from '@modelcontextprotocol/server';
18+
import type { Request, Response } from 'express';
1419
import { z } from 'zod';
1520

1621
const SearchParamsSchema = z.object({
1722
query: z.string(),
1823
limit: z.number().int().positive().optional()
1924
});
2025

21-
const SearchResultSchema = z.object({
22-
results: z.array(z.object({ id: z.string(), title: z.string() })),
23-
total: z.number()
24-
});
25-
2626
const AnalyticsParamsSchema = z.object({
2727
event: z.string(),
2828
properties: z.record(z.string(), z.unknown()).optional()
2929
});
3030

31-
const AnalyticsResultSchema = z.object({ recorded: z.boolean() });
32-
33-
const StatusUpdateParamsSchema = z.object({
34-
status: z.enum(['idle', 'busy', 'error']),
35-
detail: z.string().optional()
36-
});
37-
38-
async function main() {
31+
const getServer = () => {
3932
const server = new Server({ name: 'custom-method-server', version: '1.0.0' }, { capabilities: {} });
40-
const client = new Client({ name: 'custom-method-client', version: '1.0.0' }, { capabilities: {} });
4133

4234
server.setCustomRequestHandler('acme/search', SearchParamsSchema, async (params, ctx) => {
4335
console.log(`[server] acme/search query="${params.query}" limit=${params.limit ?? 'unset'} (req ${ctx.mcpReq.id})`);
36+
37+
// Send a custom server→client notification on the same SSE stream as this response.
38+
await server.sendCustomNotification('acme/statusUpdate', { status: 'busy', detail: `searching "${params.query}"` });
39+
4440
return {
4541
results: [
4642
{ id: 'r1', title: `Result for "${params.query}"` },
@@ -55,31 +51,68 @@ async function main() {
5551
return { recorded: true };
5652
});
5753

58-
client.setCustomNotificationHandler('acme/statusUpdate', StatusUpdateParamsSchema, params => {
59-
console.log(`[client] acme/statusUpdate status=${params.status} detail=${params.detail ?? '<none>'}`);
60-
});
61-
62-
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
63-
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
54+
return server;
55+
};
6456

65-
const searchResult = await client.sendCustomRequest('acme/search', { query: 'widgets', limit: 5 }, SearchResultSchema);
66-
console.log(`[client] received ${searchResult.total} results, first: "${searchResult.results[0]?.title}"`);
57+
const PORT = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000;
58+
const app = createMcpExpressApp();
59+
const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {};
6760

68-
const analyticsResult = await client.sendCustomRequest('acme/analytics', { event: 'page_view' }, AnalyticsResultSchema);
69-
console.log(`[client] analytics recorded=${analyticsResult.recorded}`);
70-
71-
await server.sendCustomNotification('acme/statusUpdate', { status: 'busy', detail: 'indexing' });
72-
73-
// Validation error: wrong param type (limit must be a number)
61+
app.post('/mcp', async (req: Request, res: Response) => {
62+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
7463
try {
75-
await client.sendCustomRequest('acme/search', { query: 'widgets', limit: 'five' }, SearchResultSchema);
76-
console.error('[client] expected validation error but request succeeded');
64+
let transport: NodeStreamableHTTPServerTransport;
65+
if (sessionId && transports[sessionId]) {
66+
transport = transports[sessionId];
67+
} else if (!sessionId && isInitializeRequest(req.body)) {
68+
transport = new NodeStreamableHTTPServerTransport({
69+
sessionIdGenerator: () => randomUUID(),
70+
onsessioninitialized: sid => {
71+
transports[sid] = transport;
72+
}
73+
});
74+
transport.onclose = () => {
75+
const sid = transport.sessionId;
76+
if (sid) delete transports[sid];
77+
};
78+
const server = getServer();
79+
await server.connect(transport);
80+
} else {
81+
res.status(400).json({ jsonrpc: '2.0', error: { code: -32_000, message: 'No valid session ID' }, id: null });
82+
return;
83+
}
84+
await transport.handleRequest(req, res, req.body);
7785
} catch (error) {
78-
console.log(`[client] validation error (expected): ${(error as Error).message}`);
86+
console.error('Error handling MCP request:', error);
87+
if (!res.headersSent) {
88+
res.status(500).json({ jsonrpc: '2.0', error: { code: -32_603, message: 'Internal server error' }, id: null });
89+
}
90+
}
91+
});
92+
93+
const handleSessionRequest = async (req: Request, res: Response) => {
94+
const sessionId = req.headers['mcp-session-id'] as string | undefined;
95+
if (!sessionId || !transports[sessionId]) {
96+
res.status(400).send('Invalid or missing session ID');
97+
return;
7998
}
99+
await transports[sessionId].handleRequest(req, res);
100+
};
80101

81-
await client.close();
82-
await server.close();
83-
}
102+
app.get('/mcp', handleSessionRequest);
103+
app.delete('/mcp', handleSessionRequest);
84104

85-
await main();
105+
app.listen(PORT, error => {
106+
if (error) {
107+
console.error('Failed to start server:', error);
108+
// eslint-disable-next-line unicorn/no-process-exit
109+
process.exit(1);
110+
}
111+
console.log(`Custom-method example server listening on http://localhost:${PORT}/mcp`);
112+
console.log('Custom methods: acme/search, acme/analytics');
113+
});
114+
115+
process.on('SIGINT', async () => {
116+
for (const sid in transports) await transports[sid]!.close();
117+
process.exit(0);
118+
});

0 commit comments

Comments
 (0)