Implement MCP 2026-07-28 (#2286)
Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com> Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
e4227d135c
commit
1823aae891
4 changed files with 69131 additions and 9001 deletions
19
examples/elicitation/README.md
Normal file
19
examples/elicitation/README.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# elicitation
|
||||
|
||||
Server requests user input. One factory, both protocol eras: elicitation works on both eras with different APIs — push-style on 2025, `inputRequired` on 2026; the protocol carries it differently but the user experience is the same.
|
||||
|
||||
| Mode | 2025-era (`--legacy`, push-style) | 2026-07-28 (multi-round-trip) |
|
||||
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **form** (`register_user`) | `await ctx.mcpReq.elicitInput({ mode: 'form', requestedSchema })` — the server pushes an `elicitation/create` request and awaits the answer in-line | `return inputRequired({ inputRequests: { form: inputRequired.elicit(...) } })` — the client collects the form and retries the same handler with the response attached |
|
||||
| **url** (`link_account`) | `await ctx.mcpReq.elicitInput({ mode: 'url', url, elicitationId })` + `createElicitationCompletionNotifier(elicitationId)` for the out-of-band `notifications/elicitation/complete` | `return inputRequired({ inputRequests: { auth: inputRequired.elicitUrl(...) } })` — no `elicitationId` / complete notification on this era |
|
||||
| **url, throw** (`confirm_payment`) | `throw new UrlElicitationRequiredError([...])` — the wire `-32042`; the client catches the typed error and reads `.elicitations` | n/a — a throw on this era fails loudly with a steer to `inputRequired.elicitUrl(...)` |
|
||||
|
||||
`plan_trip` chains **two** form elicitations inside one tool call (destination → dates for that destination): two sequential `ctx.mcpReq.elicitInput` pushes on 2025, two `inputRequired` rounds with `requestState` carry-over on 2026. The `register_user` form schema includes an
|
||||
`enumNames` field (display labels for the `plan` enum). For the secure `requestState` round-trip pattern see [`../mrtr/`](../mrtr/README.md).
|
||||
|
||||
Runs all four transport/era legs: `server.ts` inlines a sessionful `NodeStreamableHTTPServerTransport` arm for 2025 traffic (the same `isLegacyRequest` composition `../legacy-routing/` shows by hand), so push server→client requests reach the client over either transport.
|
||||
|
||||
```bash
|
||||
pnpm --filter @mcp-examples/elicitation client # 2026-07-28 (inputRequired)
|
||||
pnpm --filter @mcp-examples/elicitation client -- --legacy # 2025 (push-style)
|
||||
```
|
||||
98
examples/elicitation/client.ts
Normal file
98
examples/elicitation/client.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* Auto-answers form and URL elicitations on either protocol era and asserts
|
||||
* the tool's text reflects the elicitation outcome.
|
||||
*
|
||||
* On the 2025-era leg (`--legacy`) the server pushes `elicitation/create`
|
||||
* requests and a `notifications/elicitation/complete` notification, and the
|
||||
* `confirm_payment` tool throws a typed `UrlElicitationRequiredError` the
|
||||
* client catches. On the 2026-07-28 leg the same `elicitation/create`
|
||||
* handler is dispatched by the auto-fulfilment engine for the embedded
|
||||
* `inputRequired` requests; there is no throw-style or complete-notification
|
||||
* surface on that era, so those assertions are gated to the legacy leg.
|
||||
*/
|
||||
import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared';
|
||||
import type { ElicitRequestURLParams, ElicitResult } from '@modelcontextprotocol/client';
|
||||
import { Client, StreamableHTTPClientTransport, UrlElicitationRequiredError } from '@modelcontextprotocol/client';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
|
||||
|
||||
const { transport, url, era } = parseExampleArgs();
|
||||
|
||||
const client = new Client(
|
||||
{ name: 'elicitation-example-client', version: '1.0.0' },
|
||||
{
|
||||
versionNegotiation: { mode: era === 'modern' ? 'auto' : 'legacy' },
|
||||
capabilities: { elicitation: { form: {}, url: {} } }
|
||||
}
|
||||
);
|
||||
|
||||
await (transport === 'stdio'
|
||||
? client.connect(new StdioClientTransport({ command: 'npx', args: ['-y', 'tsx', siblingPath(import.meta.url, 'server.ts')] }))
|
||||
: client.connect(new StreamableHTTPClientTransport(new URL(url))));
|
||||
|
||||
// URL-mode requests on the 2025 era carry an `elicitationId`; the client
|
||||
// waits for `notifications/elicitation/complete` with that id (the
|
||||
// out-of-band "the user finished the URL flow" signal) before answering.
|
||||
const completed = new Map<string, () => void>();
|
||||
client.setNotificationHandler('notifications/elicitation/complete', notification => {
|
||||
const id = (notification.params as { elicitationId: string }).elicitationId;
|
||||
completed.get(id)?.();
|
||||
});
|
||||
|
||||
let formAction: 'accept' | 'decline' = 'accept';
|
||||
client.setRequestHandler('elicitation/create', async (request): Promise<ElicitResult> => {
|
||||
const params = request.params as { mode?: 'form' | 'url'; requestedSchema?: { properties?: Record<string, unknown> } } & Partial<
|
||||
Pick<ElicitRequestURLParams, 'url' | 'elicitationId'>
|
||||
>;
|
||||
if (params.mode === 'url') {
|
||||
// A real client would open `params.url` in a browser here. On the
|
||||
// 2025 era it then waits for the matching complete notification
|
||||
// before resolving; on the 2026 era there is no elicitationId and
|
||||
// the client answers as soon as the user finishes.
|
||||
check.ok(params.url?.startsWith('https://example.com/'));
|
||||
if (params.elicitationId) {
|
||||
await new Promise<void>(resolve => completed.set(params.elicitationId as string, resolve));
|
||||
}
|
||||
return { action: 'accept' };
|
||||
}
|
||||
if (params.requestedSchema?.properties?.['destination']) {
|
||||
return { action: 'accept', content: { destination: 'Tokyo' } };
|
||||
}
|
||||
if (params.requestedSchema?.properties?.['departure']) {
|
||||
return { action: 'accept', content: { departure: '2026-09-01', nights: 7 } };
|
||||
}
|
||||
check.ok(params.requestedSchema?.properties?.['username'], 'elicitation should carry the requestedSchema');
|
||||
if (formAction === 'decline') return { action: 'decline' };
|
||||
return { action: 'accept', content: { username: 'alice', email: 'alice@example.com', plan: 'pro' } };
|
||||
});
|
||||
|
||||
// ---- Form mode (accept then decline) -------------------------------------
|
||||
const accepted = await client.callTool({ name: 'register_user' });
|
||||
check.match(accepted.content?.[0]?.type === 'text' ? accepted.content[0].text : '', /registered alice <alice@example.com> \(plan: pro\)/);
|
||||
|
||||
formAction = 'decline';
|
||||
const declined = await client.callTool({ name: 'register_user' });
|
||||
check.match(declined.content?.[0]?.type === 'text' ? declined.content[0].text : '', /registration decline/);
|
||||
|
||||
// ---- Multi-step form (two chained elicitations inside one tool call) -----
|
||||
const trip = await client.callTool({ name: 'plan_trip' });
|
||||
check.match(trip.content?.[0]?.type === 'text' ? trip.content[0].text : '', /trip planned: Tokyo on 2026-09-01 for 7 nights/);
|
||||
|
||||
// ---- URL mode (push-style on 2025, inputRequired.elicitUrl on 2026) ------
|
||||
const linked = await client.callTool({ name: 'link_account', arguments: { provider: 'github' } });
|
||||
check.match(linked.content?.[0]?.type === 'text' ? linked.content[0].text : '', /linked github/);
|
||||
|
||||
// ---- URL mode (throw-style — 2025-era only) ------------------------------
|
||||
if (era === 'legacy') {
|
||||
let caught: UrlElicitationRequiredError | undefined;
|
||||
try {
|
||||
await client.callTool({ name: 'confirm_payment', arguments: { cartId: 'cart-42' } });
|
||||
} catch (error) {
|
||||
check.ok(error instanceof UrlElicitationRequiredError, 'expected UrlElicitationRequiredError');
|
||||
caught = error as UrlElicitationRequiredError;
|
||||
}
|
||||
check.ok(caught, 'confirm_payment should throw UrlElicitationRequiredError on the 2025 era');
|
||||
check.equal(caught?.elicitations.length, 1);
|
||||
check.match(caught?.elicitations[0]?.url ?? '', /confirm-payment\?cart=cart-42/);
|
||||
}
|
||||
|
||||
await client.close();
|
||||
23
examples/elicitation/package.json
Normal file
23
examples/elicitation/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "@mcp-examples/elicitation",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"server": "tsx server.ts",
|
||||
"client": "tsx client.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mcp-examples/shared": "workspace:*",
|
||||
"@modelcontextprotocol/client": "workspace:*",
|
||||
"@modelcontextprotocol/node": "workspace:*",
|
||||
"@modelcontextprotocol/server": "workspace:*",
|
||||
"zod": "catalog:runtimeShared"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "catalog:devTools"
|
||||
},
|
||||
"example": {
|
||||
"era": "dual",
|
||||
"//": "2025-era push-style runs over the sessionful legacy arm inlined in server.ts; 2026-07-28 inputRequired runs over the per-request modern arm. All four transport/era legs."
|
||||
}
|
||||
}
|
||||
302
examples/elicitation/server.ts
Normal file
302
examples/elicitation/server.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
/**
|
||||
* Elicitation — server requests user input. One factory, both protocol eras.
|
||||
*
|
||||
* The same tools serve both eras with different APIs: on a 2025-era
|
||||
* connection (`--legacy`, the `initialize` handshake) the server uses the
|
||||
* push-style server→client request flow — `ctx.mcpReq.elicitInput(...)` for
|
||||
* form and URL mode, `UrlElicitationRequiredError` for the throw-style URL
|
||||
* signal, and `createElicitationCompletionNotifier` for the out-of-band
|
||||
* `notifications/elicitation/complete`. On a 2026-07-28 connection there is
|
||||
* no server→client request channel: the same tools instead **return**
|
||||
* `inputRequired(...)` (multi-round-trip) and the client retries with the
|
||||
* collected responses. The protocol carries the request differently; the user
|
||||
* experience is the same.
|
||||
*
|
||||
* One binary, either transport (selected from argv). On HTTP the 2025-era arm
|
||||
* is **sessionful** (`NodeStreamableHTTPServerTransport`): push-style
|
||||
* `elicitation/create` needs the `initialize`-declared client capabilities and
|
||||
* the bidirectional SSE stream of a session, neither of which the per-request
|
||||
* stateless legacy fallback can provide.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { createServer } from 'node:http';
|
||||
|
||||
import { parseExampleArgs } from '@mcp-examples/shared';
|
||||
import { NodeStreamableHTTPServerTransport, toNodeHandler } from '@modelcontextprotocol/node';
|
||||
import type {
|
||||
CallToolResult,
|
||||
ElicitRequestFormParams,
|
||||
ElicitRequestURLParams,
|
||||
InputRequiredResult,
|
||||
McpRequestContext
|
||||
} from '@modelcontextprotocol/server';
|
||||
import {
|
||||
acceptedContent,
|
||||
createMcpHandler,
|
||||
inputRequired,
|
||||
isInitializeRequest,
|
||||
isLegacyRequest,
|
||||
McpServer,
|
||||
UrlElicitationRequiredError
|
||||
} from '@modelcontextprotocol/server';
|
||||
import { serveStdio } from '@modelcontextprotocol/server/stdio';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
// The form schema (with `enumNames` display labels for the enum field).
|
||||
const REGISTRATION_SCHEMA: ElicitRequestFormParams['requestedSchema'] = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
username: { type: 'string', title: 'Username', minLength: 3, maxLength: 20 },
|
||||
email: { type: 'string', title: 'Email', format: 'email' },
|
||||
plan: {
|
||||
type: 'string',
|
||||
title: 'Plan',
|
||||
enum: ['free', 'pro', 'team'],
|
||||
enumNames: ['Free tier', 'Pro', 'Team']
|
||||
}
|
||||
},
|
||||
required: ['username', 'email']
|
||||
};
|
||||
|
||||
type Registration = { username: string; email: string; plan?: string };
|
||||
|
||||
function buildServer(reqCtx: McpRequestContext): McpServer {
|
||||
const server = new McpServer({ name: 'elicitation-example', version: '1.0.0' });
|
||||
|
||||
// ---- Form-mode elicitation -----------------------------------------------
|
||||
server.registerTool(
|
||||
'register_user',
|
||||
{ description: 'Register a new user account by collecting their information' },
|
||||
async (ctx): Promise<CallToolResult | InputRequiredResult> => {
|
||||
if (reqCtx.era === 'legacy') {
|
||||
// 2025-era: push a server→client `elicitation/create` request and
|
||||
// await the user's answer in-line.
|
||||
const result = await ctx.mcpReq.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Please provide your registration information:',
|
||||
requestedSchema: REGISTRATION_SCHEMA
|
||||
});
|
||||
if (result.action !== 'accept' || !result.content) {
|
||||
return { content: [{ type: 'text', text: `registration ${result.action}` }] };
|
||||
}
|
||||
const { username, email, plan } = result.content as Registration;
|
||||
return { content: [{ type: 'text', text: `registered ${username} <${email}> (plan: ${plan ?? 'free'})` }] };
|
||||
}
|
||||
// 2026-07-28: return inputRequired — the client collects the form
|
||||
// and retries this same handler with the response attached.
|
||||
const response = ctx.mcpReq.inputResponses?.['form'] as { action?: string } | undefined;
|
||||
if (!response) {
|
||||
return inputRequired({
|
||||
inputRequests: {
|
||||
form: inputRequired.elicit({
|
||||
message: 'Please provide your registration information:',
|
||||
requestedSchema: REGISTRATION_SCHEMA
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
const form = acceptedContent<Registration>(ctx.mcpReq.inputResponses, 'form');
|
||||
if (!form) {
|
||||
return { content: [{ type: 'text', text: `registration ${response.action}` }] };
|
||||
}
|
||||
return { content: [{ type: 'text', text: `registered ${form.username} <${form.email}> (plan: ${form.plan ?? 'free'})` }] };
|
||||
}
|
||||
);
|
||||
|
||||
// ---- Multi-step / chained form elicitation (two sequential prompts) ------
|
||||
server.registerTool(
|
||||
'plan_trip',
|
||||
{ description: 'Plan a trip by collecting a destination and then dates for that destination' },
|
||||
async (ctx): Promise<CallToolResult | InputRequiredResult> => {
|
||||
const DEST: ElicitRequestFormParams['requestedSchema'] = {
|
||||
type: 'object',
|
||||
properties: { destination: { type: 'string', title: 'Destination' } },
|
||||
required: ['destination']
|
||||
};
|
||||
const datesFor = (dest: string): ElicitRequestFormParams['requestedSchema'] => ({
|
||||
type: 'object',
|
||||
properties: {
|
||||
departure: { type: 'string', title: `Departure date for ${dest}`, format: 'date' },
|
||||
nights: { type: 'integer', title: 'Nights', minimum: 1, maximum: 30 }
|
||||
},
|
||||
required: ['departure', 'nights']
|
||||
});
|
||||
if (reqCtx.era === 'legacy') {
|
||||
// 2025-era: two sequential `elicitation/create` pushes inside one tool call.
|
||||
const step1 = await ctx.mcpReq.elicitInput({ mode: 'form', message: 'Where to?', requestedSchema: DEST });
|
||||
if (step1.action !== 'accept' || !step1.content) {
|
||||
return { content: [{ type: 'text', text: `trip ${step1.action}` }] };
|
||||
}
|
||||
const dest = step1.content.destination as string;
|
||||
const step2 = await ctx.mcpReq.elicitInput({ mode: 'form', message: 'When?', requestedSchema: datesFor(dest) });
|
||||
if (step2.action !== 'accept' || !step2.content) {
|
||||
return { content: [{ type: 'text', text: `trip ${step2.action}` }] };
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: `trip planned: ${dest} on ${step2.content.departure} for ${step2.content.nights} nights` }
|
||||
]
|
||||
};
|
||||
}
|
||||
// 2026-07-28: two `inputRequired` rounds — the second carries the
|
||||
// first answer back via `requestState` (an opaque server-minted
|
||||
// string) so the chain survives the stateless retry. See ../mrtr/
|
||||
// for integrity-protecting `requestState` in production.
|
||||
const dates = acceptedContent<{ departure: string; nights: number }>(ctx.mcpReq.inputResponses, 'dates');
|
||||
const destination =
|
||||
ctx.mcpReq.requestState ?? acceptedContent<{ destination: string }>(ctx.mcpReq.inputResponses, 'dest')?.destination;
|
||||
if (!destination) {
|
||||
return inputRequired({ inputRequests: { dest: inputRequired.elicit({ message: 'Where to?', requestedSchema: DEST }) } });
|
||||
}
|
||||
if (!dates) {
|
||||
return inputRequired({
|
||||
requestState: destination,
|
||||
inputRequests: { dates: inputRequired.elicit({ message: 'When?', requestedSchema: datesFor(destination) }) }
|
||||
});
|
||||
}
|
||||
return { content: [{ type: 'text', text: `trip planned: ${destination} on ${dates.departure} for ${dates.nights} nights` }] };
|
||||
}
|
||||
);
|
||||
|
||||
// ---- URL-mode elicitation (push style + completion notification) ---------
|
||||
server.registerTool(
|
||||
'link_account',
|
||||
{
|
||||
description: 'Link a third-party account by opening a sign-in URL',
|
||||
inputSchema: z.object({ provider: z.string() })
|
||||
},
|
||||
async ({ provider }, ctx): Promise<CallToolResult | InputRequiredResult> => {
|
||||
if (reqCtx.era === 'legacy') {
|
||||
// 2025-era push style: send `elicitation/create` (mode: 'url')
|
||||
// and, in parallel, simulate the out-of-band callback that
|
||||
// fires when the user finishes the URL flow by sending
|
||||
// `notifications/elicitation/complete` for the same id. The
|
||||
// client waits for that notification before answering accept.
|
||||
const elicitationId = randomUUID();
|
||||
// Tie the completion notification to the in-flight request so on
|
||||
// sessionful HTTP it travels over this POST's SSE response stream
|
||||
// (rather than the standalone GET stream).
|
||||
const notifyComplete = server.server.createElicitationCompletionNotifier(elicitationId, {
|
||||
relatedRequestId: ctx.mcpReq.id
|
||||
});
|
||||
setTimeout(() => void notifyComplete().catch(error => console.error('[server] complete notify failed:', error)), 50);
|
||||
const params: ElicitRequestURLParams = {
|
||||
mode: 'url',
|
||||
message: `Sign in to ${provider} to link your account`,
|
||||
url: `https://example.com/oauth/${encodeURIComponent(provider)}/authorize`,
|
||||
elicitationId
|
||||
};
|
||||
const result = await ctx.mcpReq.elicitInput(params);
|
||||
return { content: [{ type: 'text', text: result.action === 'accept' ? `linked ${provider}` : `link ${result.action}` }] };
|
||||
}
|
||||
// 2026-07-28: URL elicitation rides the multi-round-trip flow. No
|
||||
// elicitationId / complete notification — correlation is the
|
||||
// server's own state across retries.
|
||||
const auth = ctx.mcpReq.inputResponses?.['auth'] as { action?: string } | undefined;
|
||||
if (auth?.action !== 'accept') {
|
||||
return inputRequired({
|
||||
inputRequests: {
|
||||
auth: inputRequired.elicitUrl({
|
||||
message: `Sign in to ${provider} to link your account`,
|
||||
url: `https://example.com/oauth/${encodeURIComponent(provider)}/authorize`
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
return { content: [{ type: 'text', text: `linked ${provider}` }] };
|
||||
}
|
||||
);
|
||||
|
||||
// ---- URL-mode elicitation (throw style, 2025-era only) -------------------
|
||||
// The error-style signal: the tool THROWS `UrlElicitationRequiredError`
|
||||
// (wire `-32042`); the client catches it as a typed error and reads
|
||||
// `.elicitations`. There is no 2026-07-28 equivalent — a throw on that era
|
||||
// fails loudly with a steer to `inputRequired.elicitUrl(...)`.
|
||||
server.registerTool(
|
||||
'confirm_payment',
|
||||
{
|
||||
description: 'Confirm a payment via a browser flow (2025-era throw-style URL elicitation)',
|
||||
inputSchema: z.object({ cartId: z.string() })
|
||||
},
|
||||
async ({ cartId }): Promise<CallToolResult> => {
|
||||
throw new UrlElicitationRequiredError([
|
||||
{
|
||||
mode: 'url',
|
||||
message: 'Open the link to confirm payment',
|
||||
url: `https://example.com/confirm-payment?cart=${encodeURIComponent(cartId)}`,
|
||||
elicitationId: randomUUID()
|
||||
}
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
const { transport, port } = parseExampleArgs();
|
||||
|
||||
if (transport === 'stdio') {
|
||||
void serveStdio(buildServer);
|
||||
console.error('[server] serving over stdio');
|
||||
} else {
|
||||
// --- modern (2026-07-28): per-request, strict so the sessionful arm owns ALL legacy traffic ---
|
||||
const modern = toNodeHandler(createMcpHandler(buildServer, { legacy: 'reject' }));
|
||||
|
||||
// --- legacy (2025): sessionful Streamable HTTP — push-style elicitation
|
||||
// requires the session (client capabilities + bidirectional SSE stream) ---
|
||||
const sessions = new Map<string, NodeStreamableHTTPServerTransport>();
|
||||
const handleLegacy = async (req: IncomingMessage, res: ServerResponse, body: unknown): Promise<void> => {
|
||||
const sid = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (sid && sessions.has(sid)) {
|
||||
await sessions.get(sid)!.handleRequest(req, res, body);
|
||||
} else if (!sid && isInitializeRequest(body)) {
|
||||
const t = new NodeStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: id => {
|
||||
sessions.set(id, t);
|
||||
}
|
||||
});
|
||||
t.onclose = () => t.sessionId && sessions.delete(t.sessionId);
|
||||
await buildServer({ era: 'legacy' } as McpRequestContext).connect(t);
|
||||
await t.handleRequest(req, res, body);
|
||||
} else {
|
||||
res.writeHead(sid ? 404 : 400, { 'content-type': 'application/json' }).end(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
error: sid
|
||||
? { code: -32_001, message: 'Session not found' }
|
||||
: { code: -32_000, message: 'Bad Request: Session ID required' },
|
||||
id: null
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
createServer((req, res) => {
|
||||
void (async () => {
|
||||
// Read the body once for the predicate and pass it forward.
|
||||
let body: unknown;
|
||||
if (req.method === 'POST') {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) chunks.push(chunk as Buffer);
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
try {
|
||||
body = raw ? JSON.parse(raw) : undefined;
|
||||
} catch {
|
||||
body = undefined;
|
||||
}
|
||||
}
|
||||
const probe = new globalThis.Request(`http://localhost${req.url ?? '/'}`, {
|
||||
method: req.method,
|
||||
headers: req.headers as Record<string, string>
|
||||
});
|
||||
await ((await isLegacyRequest(probe, body)) ? handleLegacy(req, res, body) : modern(req, res, body));
|
||||
})().catch(error => {
|
||||
console.error('[server] request error:', error instanceof Error ? error.message : error);
|
||||
if (!res.headersSent) res.writeHead(500).end();
|
||||
});
|
||||
}).listen(port, () => {
|
||||
console.error(`[server] listening on http://127.0.0.1:${port}/mcp`);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue