Skip to content

Commit 7bb79eb

Browse files
refactor: replace RequestHandlerExtra with structured context types (#1467)
1 parent be5670f commit 7bb79eb

File tree

25 files changed

+1224
-770
lines changed

25 files changed

+1224
-770
lines changed

CLAUDE.md

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -144,37 +144,51 @@ When a request arrives from the remote side:
144144
2. **`Protocol.connect()`** routes to `_onrequest()`, `_onresponse()`, or `_onnotification()`
145145
3. **`Protocol._onrequest()`**:
146146
- Looks up handler in `_requestHandlers` map (keyed by method name)
147-
- Creates `RequestHandlerExtra` with `signal`, `sessionId`, `sendNotification`, `sendRequest`
147+
- Creates `BaseContext` with `signal`, `sessionId`, `sendNotification`, `sendRequest`, etc.
148+
- Calls `buildContext()` to let subclasses enrich the context (e.g., Server adds `requestInfo`)
148149
- Invokes handler, sends JSON-RPC response back via transport
149150
4. **Handler** was registered via `setRequestHandler('method', handler)`
150151

151152
### Handler Registration
152153

153154
```typescript
154155
// In Client (for server→client requests like sampling, elicitation)
155-
client.setRequestHandler('sampling/createMessage', async (request, extra) => {
156+
client.setRequestHandler('sampling/createMessage', async (request, ctx) => {
156157
// Handle sampling request from server
157158
return { role: "assistant", content: {...}, model: "..." };
158159
});
159160

160161
// In Server (for client→server requests like tools/call)
161-
server.setRequestHandler('tools/call', async (request, extra) => {
162+
server.setRequestHandler('tools/call', async (request, ctx) => {
162163
// Handle tool call from client
163164
return { content: [...] };
164165
});
165166
```
166167

167-
### Request Handler Extra
168+
### Request Handler Context
169+
170+
The `ctx` parameter in handlers provides a structured context:
171+
172+
**`BaseContext`** (common to both Server and Client), fields organized into nested groups:
173+
174+
- `sessionId?`: Transport session identifier
175+
- `mcpReq`: Request-level concerns
176+
- `id`: JSON-RPC message ID
177+
- `method`: Request method string (e.g., 'tools/call')
178+
- `_meta?`: Request metadata
179+
- `signal`: AbortSignal for cancellation
180+
- `send(request, schema, options?)`: Send related request (for bidirectional flows)
181+
- `notify(notification)`: Send related notification back
182+
- `http?`: HTTP transport info (undefined for stdio)
183+
- `authInfo?`: Validated auth token info
184+
- `task?`: Task context (`{ id?, store, requestedTtl? }`) when task storage is configured
185+
186+
**`ServerContext`** extends `BaseContext.mcpReq` and `BaseContext.http?` via type intersection:
168187

169-
The `extra` parameter in handlers (`RequestHandlerExtra`) provides:
188+
- `mcpReq` adds: `log(level, data, logger?)`, `elicitInput(params, options?)`, `requestSampling(params, options?)`
189+
- `http?` adds: `req?` (HTTP request info), `closeSSE?`, `closeStandaloneSSE?`
170190

171-
- `signal`: AbortSignal for cancellation
172-
- `sessionId`: Transport session identifier
173-
- `authInfo`: Validated auth token info (if authenticated)
174-
- `requestId`: JSON-RPC message ID
175-
- `sendNotification(notification)`: Send related notification back
176-
- `sendRequest(request, schema)`: Send related request (for bidirectional flows)
177-
- `taskStore`: Task storage interface (if tasks enabled)
191+
**`ClientContext`** is currently identical to `BaseContext`.
178192

179193
### Capability Checking
180194

docs/migration-SKILL.md

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ extra.requestInfo?.headers['mcp-session-id']
297297

298298
// v2: Headers object, .get() access
299299
headers: new Headers({ 'Authorization': 'Bearer token' })
300-
extra.requestInfo?.headers.get('mcp-session-id')
300+
ctx.http?.req?.headers.get('mcp-session-id')
301301
```
302302

303303
## 8. Removed Server Features
@@ -366,11 +366,41 @@ Schema to method string mapping:
366366

367367
Request/notification params remain fully typed. Remove unused schema imports after migration.
368368

369-
## 10. Client Behavioral Changes
369+
## 10. Request Handler Context Types
370+
371+
`RequestHandlerExtra` → structured context types with nested groups. Rename `extra``ctx` in all handler callbacks.
372+
373+
| v1 | v2 |
374+
|----|-----|
375+
| `RequestHandlerExtra` | `ServerContext` (server) / `ClientContext` (client) / `BaseContext` (base) |
376+
| `extra` (param name) | `ctx` |
377+
| `extra.signal` | `ctx.mcpReq.signal` |
378+
| `extra.requestId` | `ctx.mcpReq.id` |
379+
| `extra._meta` | `ctx.mcpReq._meta` |
380+
| `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` |
381+
| `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` |
382+
| `extra.authInfo` | `ctx.http?.authInfo` |
383+
| `extra.sessionId` | `ctx.sessionId` |
384+
| `extra.requestInfo` | `ctx.http?.req` (only `ServerContext`) |
385+
| `extra.closeSSEStream` | `ctx.http?.closeSSE` (only `ServerContext`) |
386+
| `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` (only `ServerContext`) |
387+
| `extra.taskStore` | `ctx.task?.store` |
388+
| `extra.taskId` | `ctx.task?.id` |
389+
| `extra.taskRequestedTtl` | `ctx.task?.requestedTtl` |
390+
391+
`ServerContext` convenience methods (new in v2, no v1 equivalent):
392+
393+
| Method | Description | Replaces |
394+
|--------|-------------|----------|
395+
| `ctx.mcpReq.log(level, data, logger?)` | Send log notification (respects client's level filter) | `server.sendLoggingMessage(...)` from within handler |
396+
| `ctx.mcpReq.elicitInput(params, options?)` | Elicit user input (form or URL) | `server.elicitInput(...)` from within handler |
397+
| `ctx.mcpReq.requestSampling(params, options?)` | Request LLM sampling from client | `server.createMessage(...)` from within handler |
398+
399+
## 11. Client Behavioral Changes
370400

371401
`Client.listPrompts()`, `listResources()`, `listResourceTemplates()`, `listTools()` now return empty results when the server lacks the corresponding capability (instead of sending the request). Set `enforceStrictCapabilities: true` in `ClientOptions` to throw an error instead.
372402

373-
## 11. Runtime-Specific JSON Schema Validators (Enhancement)
403+
## 12. Runtime-Specific JSON Schema Validators (Enhancement)
374404

375405
The SDK now auto-selects the appropriate JSON Schema validator based on runtime:
376406
- Node.js → `AjvJsonSchemaValidator` (no change from v1)
@@ -390,7 +420,7 @@ new McpServer({ name: 'server', version: '1.0.0' }, {});
390420

391421
Access validators via `_shims` export: `import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims';`
392422

393-
## 12. Migration Steps (apply in this order)
423+
## 13. Migration Steps (apply in this order)
394424

395425
1. Update `package.json`: `npm uninstall @modelcontextprotocol/sdk`, install the appropriate v2 packages
396426
2. Replace all imports from `@modelcontextprotocol/sdk/...` using the import mapping tables (sections 3-4), including `StreamableHTTPServerTransport``NodeStreamableHTTPServerTransport`

docs/migration.md

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ const transport = new StreamableHTTPClientTransport(url, {
156156
});
157157

158158
// Reading headers in a request handler
159-
const sessionId = extra.requestInfo?.headers.get('mcp-session-id');
159+
const sessionId = ctx.http?.req?.headers.get('mcp-session-id');
160160
```
161161

162162
### `McpServer.tool()`, `.prompt()`, `.resource()` removed
@@ -381,6 +381,83 @@ import { JSONRPCError, ResourceReference, isJSONRPCError } from '@modelcontextpr
381381
import { JSONRPCErrorResponse, ResourceTemplateReference, isJSONRPCErrorResponse } from '@modelcontextprotocol/core';
382382
```
383383

384+
### Request handler context types
385+
386+
The `RequestHandlerExtra` type has been replaced with a structured context type hierarchy using nested groups:
387+
388+
| v1 | v2 |
389+
|----|-----|
390+
| `RequestHandlerExtra` (flat, all fields) | `ServerContext` (server handlers) or `ClientContext` (client handlers) |
391+
| `extra` parameter name | `ctx` parameter name |
392+
| `extra.signal` | `ctx.mcpReq.signal` |
393+
| `extra.requestId` | `ctx.mcpReq.id` |
394+
| `extra._meta` | `ctx.mcpReq._meta` |
395+
| `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` |
396+
| `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` |
397+
| `extra.authInfo` | `ctx.http?.authInfo` |
398+
| `extra.requestInfo` | `ctx.http?.req` (only on `ServerContext`) |
399+
| `extra.closeSSEStream` | `ctx.http?.closeSSE` (only on `ServerContext`) |
400+
| `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` (only on `ServerContext`) |
401+
| `extra.sessionId` | `ctx.sessionId` |
402+
| `extra.taskStore` | `ctx.task?.store` |
403+
| `extra.taskId` | `ctx.task?.id` |
404+
| `extra.taskRequestedTtl` | `ctx.task?.requestedTtl` |
405+
406+
**Before (v1):**
407+
408+
```typescript
409+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
410+
const headers = extra.requestInfo?.headers;
411+
const taskStore = extra.taskStore;
412+
await extra.sendNotification({ method: 'notifications/progress', params: { progressToken: 'abc', progress: 50, total: 100 } });
413+
return { content: [{ type: 'text', text: 'result' }] };
414+
});
415+
```
416+
417+
**After (v2):**
418+
419+
```typescript
420+
server.setRequestHandler('tools/call', async (request, ctx) => {
421+
const headers = ctx.http?.req?.headers;
422+
const taskStore = ctx.task?.store;
423+
await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'abc', progress: 50, total: 100 } });
424+
return { content: [{ type: 'text', text: 'result' }] };
425+
});
426+
```
427+
428+
Context fields are organized into 4 groups:
429+
430+
- **`mcpReq`** — request-level concerns: `id`, `method`, `_meta`, `signal`, `send()`, `notify()`, plus server-only `log()`, `elicitInput()`, and `requestSampling()`
431+
- **`http?`** — HTTP transport concerns (undefined for stdio): `authInfo`, plus server-only `req`, `closeSSE`, `closeStandaloneSSE`
432+
- **`task?`** — task lifecycle: `id`, `store`, `requestedTtl`
433+
434+
`BaseContext` is the common base type shared by both `ServerContext` and `ClientContext`. `ServerContext` extends each group with server-specific additions via type intersection.
435+
436+
`ServerContext` also provides convenience methods for common server→client operations:
437+
438+
```typescript
439+
server.setRequestHandler('tools/call', async (request, ctx) => {
440+
// Send a log message (respects client's log level filter)
441+
await ctx.mcpReq.log('info', 'Processing tool call', 'my-logger');
442+
443+
// Request client to sample an LLM
444+
const samplingResult = await ctx.mcpReq.requestSampling({
445+
messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }],
446+
maxTokens: 100,
447+
});
448+
449+
// Elicit user input via a form
450+
const elicitResult = await ctx.mcpReq.elicitInput({
451+
message: 'Please provide details',
452+
requestedSchema: { type: 'object', properties: { name: { type: 'string' } } },
453+
});
454+
455+
return { content: [{ type: 'text', text: 'done' }] };
456+
});
457+
```
458+
459+
These replace the pattern of calling `server.sendLoggingMessage()`, `server.createMessage()`, and `server.elicitInput()` from within handlers.
460+
384461
### Error hierarchy refactoring
385462

386463
The SDK now distinguishes between two types of errors:

examples/server/src/elicitationUrlExample.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,12 @@ const getServer = () => {
4646
cartId: z.string().describe('The ID of the cart to confirm')
4747
})
4848
},
49-
async ({ cartId }, extra): Promise<CallToolResult> => {
49+
async ({ cartId }, ctx): Promise<CallToolResult> => {
5050
/*
5151
In a real world scenario, there would be some logic here to check if the user has the provided cartId.
5252
For the purposes of this example, we'll throw an error (-> elicits the client to open a URL to confirm payment)
5353
*/
54-
const sessionId = extra.sessionId;
54+
const sessionId = ctx.sessionId;
5555
if (!sessionId) {
5656
throw new Error('Expected a Session ID');
5757
}
@@ -79,15 +79,15 @@ const getServer = () => {
7979
param1: z.string().describe('First parameter')
8080
})
8181
},
82-
async (_, extra): Promise<CallToolResult> => {
82+
async (_, ctx): Promise<CallToolResult> => {
8383
/*
8484
In a real world scenario, there would be some logic here to check if we already have a valid access token for the user.
85-
Auth info (with a subject or `sub` claim) can be typically be found in `extra.authInfo`.
85+
Auth info (with a subject or `sub` claim) can be typically be found in `ctx.http?.authInfo`.
8686
If we do, we can just return the result of the tool call.
8787
If we don't, we can throw an ElicitationRequiredError to request the user to authenticate.
8888
For the purposes of this example, we'll throw an error (-> elicits the client to open a URL to authenticate).
8989
*/
90-
const sessionId = extra.sessionId;
90+
const sessionId = ctx.sessionId;
9191
if (!sessionId) {
9292
throw new Error('Expected a Session ID');
9393
}

examples/server/src/jsonResponseStreamableHttp.ts

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -51,36 +51,18 @@ const getServer = () => {
5151
name: z.string().describe('Name to greet')
5252
})
5353
},
54-
async ({ name }, extra): Promise<CallToolResult> => {
54+
async ({ name }, ctx): Promise<CallToolResult> => {
5555
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
5656

57-
await server.sendLoggingMessage(
58-
{
59-
level: 'debug',
60-
data: `Starting multi-greet for ${name}`
61-
},
62-
extra.sessionId
63-
);
57+
await ctx.mcpReq.log('debug', `Starting multi-greet for ${name}`);
6458

6559
await sleep(1000); // Wait 1 second before first greeting
6660

67-
await server.sendLoggingMessage(
68-
{
69-
level: 'info',
70-
data: `Sending first greeting to ${name}`
71-
},
72-
extra.sessionId
73-
);
61+
await ctx.mcpReq.log('info', `Sending first greeting to ${name}`);
7462

7563
await sleep(1000); // Wait another second before second greeting
7664

77-
await server.sendLoggingMessage(
78-
{
79-
level: 'info',
80-
data: `Sending second greeting to ${name}`
81-
},
82-
extra.sessionId
83-
);
65+
await ctx.mcpReq.log('info', `Sending second greeting to ${name}`);
8466

8567
return {
8668
content: [

examples/server/src/simpleStatelessStreamableHttp.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,20 +49,14 @@ const getServer = () => {
4949
count: z.number().describe('Number of notifications to send (0 for 100)').default(10)
5050
})
5151
},
52-
async ({ interval, count }, extra): Promise<CallToolResult> => {
52+
async ({ interval, count }, ctx): Promise<CallToolResult> => {
5353
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
5454
let counter = 0;
5555

5656
while (count === 0 || counter < count) {
5757
counter++;
5858
try {
59-
await server.sendLoggingMessage(
60-
{
61-
level: 'info',
62-
data: `Periodic notification #${counter} at ${new Date().toISOString()}`
63-
},
64-
extra.sessionId
65-
);
59+
await ctx.mcpReq.log('info', `Periodic notification #${counter} at ${new Date().toISOString()}`);
6660
} catch (error) {
6761
console.error('Error sending notification:', error);
6862
}

0 commit comments

Comments
 (0)