[SEP-2663] refactor!: remove 2025-11 experimental tasks (#2128)
This commit is contained in:
parent
600ba75f34
commit
c8d7401b46
73 changed files with 423 additions and 18280 deletions
|
|
@ -1,5 +0,0 @@
|
|||
---
|
||||
'@modelcontextprotocol/core': patch
|
||||
---
|
||||
|
||||
Fix `requestStream` to call `tasks/result` for failed tasks instead of yielding a hardcoded `ProtocolError`. When a task reaches the `failed` terminal status, the stream now retrieves and yields the actual stored result (matching the behavior for `completed` tasks), as required by the spec.
|
||||
6
.changeset/sep-2663-tasks-removal.md
Normal file
6
.changeset/sep-2663-tasks-removal.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
'@modelcontextprotocol/core': major
|
||||
'@modelcontextprotocol/server': major
|
||||
'@modelcontextprotocol/client': major
|
||||
---
|
||||
SEP-2663: remove 2025-11 experimental tasks (TaskManager, experimental.tasks.* accessors). Tasks are now Extensions Track.
|
||||
|
|
@ -104,9 +104,7 @@ The repo also ships “middleware” packages under `packages/middleware/` (e.g.
|
|||
|
||||
### Experimental Features
|
||||
|
||||
Located in `packages/*/src/experimental/`:
|
||||
|
||||
- **Tasks**: Long-running task support with polling/resumption (`packages/core/src/experimental/tasks/`)
|
||||
Located in `packages/*/src/experimental/`. Currently empty.
|
||||
|
||||
### Zod Schemas
|
||||
|
||||
|
|
@ -201,7 +199,6 @@ The `ctx` parameter in handlers provides a structured context:
|
|||
- `notify(notification)`: Send related notification back
|
||||
- `http?`: HTTP transport info (undefined for stdio)
|
||||
- `authInfo?`: Validated auth token info
|
||||
- `task?`: Task context (`{ id?, store, requestedTtl? }`) when task storage is configured
|
||||
|
||||
**`ServerContext`** extends `BaseContext.mcpReq` and `BaseContext.http?` via type intersection:
|
||||
|
||||
|
|
|
|||
|
|
@ -544,7 +544,7 @@ All requests have a 60-second default timeout. Pass a custom `timeout` in the op
|
|||
```ts source="../examples/client/src/clientGuide.examples.ts#errorHandling_timeout"
|
||||
try {
|
||||
const result = await client.callTool(
|
||||
{ name: 'slow-task', arguments: {} },
|
||||
{ name: 'slow-operation', arguments: {} },
|
||||
{ timeout: 120_000 } // 2 minutes instead of the default 60 seconds
|
||||
);
|
||||
console.log(result.content);
|
||||
|
|
@ -581,7 +581,7 @@ let lastToken: string | undefined;
|
|||
const result = await client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: { name: 'long-running-task', arguments: {} }
|
||||
params: { name: 'long-running-operation', arguments: {} }
|
||||
},
|
||||
{
|
||||
resumptionToken: lastToken,
|
||||
|
|
@ -596,18 +596,6 @@ console.log(result);
|
|||
|
||||
For an end-to-end example of server-initiated SSE disconnection and automatic client reconnection with event replay, see [`ssePollingClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/ssePollingClient.ts).
|
||||
|
||||
## Tasks (experimental)
|
||||
|
||||
> [!WARNING]
|
||||
> The tasks API is experimental and may change without notice.
|
||||
|
||||
Task-based execution enables "call-now, fetch-later" patterns for long-running operations (see [Tasks](https://modelcontextprotocol.io/specification/latest/basic/utilities/tasks) in the MCP specification). Instead of returning a result immediately, a tool creates a task that can be polled or resumed later. To use tasks:
|
||||
|
||||
- Call {@linkcode @modelcontextprotocol/client!experimental/tasks/client.ExperimentalClientTasks#callToolStream | client.experimental.tasks.callToolStream(...)} to start a tool call that may create a task and emit status updates over time.
|
||||
- Call {@linkcode @modelcontextprotocol/client!experimental/tasks/client.ExperimentalClientTasks#getTask | client.experimental.tasks.getTask(...)} and {@linkcode @modelcontextprotocol/client!experimental/tasks/client.ExperimentalClientTasks#getTaskResult | getTaskResult(...)} to check status and fetch results after reconnecting.
|
||||
|
||||
For a full runnable example, see [`simpleTaskInteractiveClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/client/src/simpleTaskInteractiveClient.ts).
|
||||
|
||||
## See also
|
||||
|
||||
- [`examples/client/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/client) — Full runnable client examples
|
||||
|
|
|
|||
|
|
@ -47,25 +47,25 @@ Replace all `@modelcontextprotocol/sdk/...` imports using this table.
|
|||
|
||||
### Server imports
|
||||
|
||||
| v1 import path | v2 package |
|
||||
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `@modelcontextprotocol/sdk/server/mcp.js` | `@modelcontextprotocol/server` |
|
||||
| `@modelcontextprotocol/sdk/server/index.js` | `@modelcontextprotocol/server` |
|
||||
| `@modelcontextprotocol/sdk/server/stdio.js` | `@modelcontextprotocol/server/stdio` |
|
||||
| `@modelcontextprotocol/sdk/server/streamableHttp.js` | `@modelcontextprotocol/node` (class renamed to `NodeStreamableHTTPServerTransport`) OR `@modelcontextprotocol/server` (web-standard `WebStandardStreamableHTTPServerTransport` for Cloudflare Workers, Deno, etc.) |
|
||||
| `@modelcontextprotocol/sdk/server/sse.js` | REMOVED (migrate to Streamable HTTP); legacy bridge: `@modelcontextprotocol/server-legacy/sse` |
|
||||
| v1 import path | v2 package |
|
||||
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `@modelcontextprotocol/sdk/server/mcp.js` | `@modelcontextprotocol/server` |
|
||||
| `@modelcontextprotocol/sdk/server/index.js` | `@modelcontextprotocol/server` |
|
||||
| `@modelcontextprotocol/sdk/server/stdio.js` | `@modelcontextprotocol/server/stdio` |
|
||||
| `@modelcontextprotocol/sdk/server/streamableHttp.js` | `@modelcontextprotocol/node` (class renamed to `NodeStreamableHTTPServerTransport`) OR `@modelcontextprotocol/server` (web-standard `WebStandardStreamableHTTPServerTransport` for Cloudflare Workers, Deno, etc.) |
|
||||
| `@modelcontextprotocol/sdk/server/sse.js` | REMOVED (migrate to Streamable HTTP); legacy bridge: `@modelcontextprotocol/server-legacy/sse` |
|
||||
| `@modelcontextprotocol/sdk/server/auth/*` | RS helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `OAuthTokenVerifier`) → `@modelcontextprotocol/express`; AS helpers (`mcpAuthRouter`, `OAuthServerProvider`, etc.) → `@modelcontextprotocol/server-legacy/auth` (deprecated); migrate AS to an external IdP/OAuth library |
|
||||
| `@modelcontextprotocol/sdk/server/middleware.js` | `@modelcontextprotocol/express` (signature changed, see section 8) |
|
||||
| `@modelcontextprotocol/sdk/server/middleware.js` | `@modelcontextprotocol/express` (signature changed, see section 8) |
|
||||
|
||||
### Types / shared imports
|
||||
|
||||
| v1 import path | v2 package |
|
||||
| ------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| `@modelcontextprotocol/sdk/types.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` |
|
||||
| `@modelcontextprotocol/sdk/shared/protocol.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` |
|
||||
| `@modelcontextprotocol/sdk/shared/transport.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` |
|
||||
| `@modelcontextprotocol/sdk/shared/uriTemplate.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` |
|
||||
| `@modelcontextprotocol/sdk/shared/auth.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` |
|
||||
| v1 import path | v2 package |
|
||||
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `@modelcontextprotocol/sdk/types.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` |
|
||||
| `@modelcontextprotocol/sdk/shared/protocol.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` |
|
||||
| `@modelcontextprotocol/sdk/shared/transport.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` |
|
||||
| `@modelcontextprotocol/sdk/shared/uriTemplate.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` |
|
||||
| `@modelcontextprotocol/sdk/shared/auth.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` |
|
||||
| `@modelcontextprotocol/sdk/shared/stdio.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` (`ReadBuffer`, `serializeMessage`, `deserializeMessage` are in the root barrel; the `./stdio` subpath only has the transport class) |
|
||||
|
||||
Notes:
|
||||
|
|
@ -109,20 +109,20 @@ Three error classes now exist:
|
|||
- **`SdkError`** (new): Local SDK errors that never cross the wire
|
||||
- **`SdkHttpError`** (extends `SdkError`): HTTP transport errors with typed `.status` and `.statusText` accessors
|
||||
|
||||
| Error scenario | v1 type | v2 type |
|
||||
| --------------------------------- | -------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| Request timeout | `McpError` with `ErrorCode.RequestTimeout` | `SdkError` with `SdkErrorCode.RequestTimeout` |
|
||||
| Connection closed | `McpError` with `ErrorCode.ConnectionClosed` | `SdkError` with `SdkErrorCode.ConnectionClosed` |
|
||||
| Capability not supported | `new Error(...)` | `SdkError` with `SdkErrorCode.CapabilityNotSupported` |
|
||||
| Not connected | `new Error('Not connected')` | `SdkError` with `SdkErrorCode.NotConnected` |
|
||||
| Invalid params (server response) | `McpError` with `ErrorCode.InvalidParams` | `ProtocolError` with `ProtocolErrorCode.InvalidParams` |
|
||||
| HTTP transport error | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttp*` |
|
||||
| Failed to open SSE stream | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToOpenStream` |
|
||||
| 401 after re-auth (circuit break) | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpAuthentication` |
|
||||
| 403 after upscoping | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpForbidden` |
|
||||
| Unexpected content type | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpUnexpectedContent` |
|
||||
| Error scenario | v1 type | v2 type |
|
||||
| --------------------------------- | -------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| Request timeout | `McpError` with `ErrorCode.RequestTimeout` | `SdkError` with `SdkErrorCode.RequestTimeout` |
|
||||
| Connection closed | `McpError` with `ErrorCode.ConnectionClosed` | `SdkError` with `SdkErrorCode.ConnectionClosed` |
|
||||
| Capability not supported | `new Error(...)` | `SdkError` with `SdkErrorCode.CapabilityNotSupported` |
|
||||
| Not connected | `new Error('Not connected')` | `SdkError` with `SdkErrorCode.NotConnected` |
|
||||
| Invalid params (server response) | `McpError` with `ErrorCode.InvalidParams` | `ProtocolError` with `ProtocolErrorCode.InvalidParams` |
|
||||
| HTTP transport error | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttp*` |
|
||||
| Failed to open SSE stream | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToOpenStream` |
|
||||
| 401 after re-auth (circuit break) | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpAuthentication` |
|
||||
| 403 after upscoping | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpForbidden` |
|
||||
| Unexpected content type | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpUnexpectedContent` |
|
||||
| Session termination failed | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToTerminateSession` |
|
||||
| Response result fails schema | `ZodError` (raw) | `SdkError` with `SdkErrorCode.InvalidResult` |
|
||||
| Response result fails schema | `ZodError` (raw) | `SdkError` with `SdkErrorCode.InvalidResult` |
|
||||
|
||||
New `SdkErrorCode` enum values:
|
||||
|
||||
|
|
@ -164,11 +164,11 @@ if (error instanceof StreamableHTTPError) {
|
|||
// v2
|
||||
import { SdkHttpError, SdkErrorCode } from '@modelcontextprotocol/client';
|
||||
if (error instanceof SdkHttpError) {
|
||||
console.log('HTTP status:', error.status); // number — typed accessor
|
||||
console.log('HTTP status:', error.status); // number — typed accessor
|
||||
console.log('Status text:', error.statusText); // string | undefined
|
||||
switch (error.code) {
|
||||
case SdkErrorCode.ClientHttpAuthentication: // 401 after re-auth
|
||||
case SdkErrorCode.ClientHttpForbidden: // 403 after upscoping
|
||||
case SdkErrorCode.ClientHttpAuthentication: // 401 after re-auth
|
||||
case SdkErrorCode.ClientHttpForbidden: // 403 after upscoping
|
||||
case SdkErrorCode.ClientHttpFailedToOpenStream:
|
||||
case SdkErrorCode.ClientHttpNotImplemented:
|
||||
break;
|
||||
|
|
@ -328,11 +328,13 @@ new URL(ctx.http?.req?.url).searchParams.get('debug')
|
|||
|
||||
### SSE server transport
|
||||
|
||||
`SSEServerTransport` removed entirely. Migrate to `NodeStreamableHTTPServerTransport` (from `@modelcontextprotocol/node`). Client-side `SSEClientTransport` still available for connecting to legacy servers. Legacy bridge: `import { SSEServerTransport } from '@modelcontextprotocol/server-legacy/sse'` (deprecated, frozen v1 copy).
|
||||
`SSEServerTransport` removed entirely. Migrate to `NodeStreamableHTTPServerTransport` (from `@modelcontextprotocol/node`). Client-side `SSEClientTransport` still available for connecting to legacy servers. Legacy bridge:
|
||||
`import { SSEServerTransport } from '@modelcontextprotocol/server-legacy/sse'` (deprecated, frozen v1 copy).
|
||||
|
||||
### Server-side auth
|
||||
|
||||
Resource Server helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`) are first-class in `@modelcontextprotocol/express`. Authorization Server helpers (`mcpAuthRouter`, `OAuthServerProvider`, `ProxyOAuthServerProvider`, `authenticateClient`, `allowedMethods`, etc.) are available from `@modelcontextprotocol/server-legacy/auth` (deprecated, frozen v1 copy). Migrate AS to an external IdP/OAuth library for production use. See `examples/server/src/` for demos.
|
||||
Resource Server helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`) are first-class in `@modelcontextprotocol/express`. Authorization Server helpers (`mcpAuthRouter`, `OAuthServerProvider`,
|
||||
`ProxyOAuthServerProvider`, `authenticateClient`, `allowedMethods`, etc.) are available from `@modelcontextprotocol/server-legacy/auth` (deprecated, frozen v1 copy). Migrate AS to an external IdP/OAuth library for production use. See `examples/server/src/` for demos.
|
||||
|
||||
### Host header validation (Express)
|
||||
|
||||
|
|
@ -415,23 +417,21 @@ Request/notification params remain fully typed. Remove unused schema imports aft
|
|||
|
||||
`RequestHandlerExtra` → structured context types with nested groups. Rename `extra` → `ctx` in all handler callbacks.
|
||||
|
||||
| v1 | v2 |
|
||||
| -------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `RequestHandlerExtra` | `ServerContext` (server) / `ClientContext` (client) / `BaseContext` (base) |
|
||||
| `extra` (param name) | `ctx` |
|
||||
| `extra.signal` | `ctx.mcpReq.signal` |
|
||||
| `extra.requestId` | `ctx.mcpReq.id` |
|
||||
| `extra._meta` | `ctx.mcpReq._meta` |
|
||||
| `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` |
|
||||
| `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` |
|
||||
| `extra.authInfo` | `ctx.http?.authInfo` |
|
||||
| `extra.sessionId` | `ctx.sessionId` |
|
||||
| `extra.requestInfo` | `ctx.http?.req` (standard Web `Request`, only `ServerContext`) |
|
||||
| `extra.closeSSEStream` | `ctx.http?.closeSSE` (only `ServerContext`) |
|
||||
| `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` (only `ServerContext`) |
|
||||
| `extra.taskStore` | `ctx.task?.store` |
|
||||
| `extra.taskId` | `ctx.task?.id` |
|
||||
| `extra.taskRequestedTtl` | `ctx.task?.requestedTtl` |
|
||||
| v1 | v2 |
|
||||
| ------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `RequestHandlerExtra` | `ServerContext` (server) / `ClientContext` (client) / `BaseContext` (base) |
|
||||
| `extra` (param name) | `ctx` |
|
||||
| `extra.signal` | `ctx.mcpReq.signal` |
|
||||
| `extra.requestId` | `ctx.mcpReq.id` |
|
||||
| `extra._meta` | `ctx.mcpReq._meta` |
|
||||
| `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` |
|
||||
| `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` |
|
||||
| `extra.authInfo` | `ctx.http?.authInfo` |
|
||||
| `extra.sessionId` | `ctx.sessionId` |
|
||||
| `extra.requestInfo` | `ctx.http?.req` (standard Web `Request`, only `ServerContext`) |
|
||||
| `extra.closeSSEStream` | `ctx.http?.closeSSE` (only `ServerContext`) |
|
||||
| `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` (only `ServerContext`) |
|
||||
| `extra.taskStore` / `taskId` / `taskRequestedTtl` | _removed; see §12_ |
|
||||
|
||||
`ServerContext` convenience methods (new in v2, no v1 equivalent):
|
||||
|
||||
|
|
@ -473,33 +473,33 @@ Remove unused schema imports: `CallToolResultSchema`, `CompatibilityCallToolResu
|
|||
|
||||
If a `*Schema` constant was used for **runtime validation** (not just as a `request()` argument), replace with `isSpecType` / `specTypeSchemas`:
|
||||
|
||||
| v1 pattern | v2 replacement |
|
||||
| -------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `CallToolResultSchema.safeParse(value).success` | `isSpecType.CallToolResult(value)` |
|
||||
| `<TypeName>Schema.safeParse(value).success` | `isSpecType.<TypeName>(value)` |
|
||||
| v1 pattern | v2 replacement |
|
||||
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| `CallToolResultSchema.safeParse(value).success` | `isSpecType.CallToolResult(value)` |
|
||||
| `<TypeName>Schema.safeParse(value).success` | `isSpecType.<TypeName>(value)` |
|
||||
| `<TypeName>Schema.parse(value)` | `specTypeSchemas.<TypeName>['~standard'].validate(value)` (returns a `Result` synchronously, not the value) |
|
||||
| Passing `<TypeName>Schema` as a validator argument | `specTypeSchemas.<TypeName>` (a `StandardSchemaV1Sync<In, Out>`) |
|
||||
| Passing `<TypeName>Schema` as a validator argument | `specTypeSchemas.<TypeName>` (a `StandardSchemaV1Sync<In, Out>`) |
|
||||
|
||||
`isCallToolResult(value)` still works, but `isSpecType` covers every spec type by name.
|
||||
|
||||
## 12. Experimental: `TaskCreationParams.ttl` no longer accepts `null`
|
||||
## 12. Experimental tasks interception removed
|
||||
|
||||
`TaskCreationParams.ttl` changed from `z.union([z.number(), z.null()]).optional()` to `z.number().optional()`. Per the MCP spec, `null` TTL (unlimited lifetime) is only valid in server responses (`Task.ttl`), not in client requests. Omit `ttl` to let the server decide.
|
||||
The 2025-11 task side-channel through `Protocol` is removed (was always `@experimental`). No mechanical migration; remove usages.
|
||||
|
||||
| v1 | v2 |
|
||||
| ---------------------- | ---------------------------------- |
|
||||
| `task: { ttl: null }` | `task: {}` (omit ttl) |
|
||||
| `task: { ttl: 60000 }` | `task: { ttl: 60000 }` (unchanged) |
|
||||
| Removed | Notes |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
|
||||
| `ProtocolOptions.tasks` | drop the option |
|
||||
| `protocol.taskManager` | gone |
|
||||
| `RequestOptions.task` / `.relatedTask`, `NotificationOptions.relatedTask` | drop the option |
|
||||
| `BaseContext.task` (`ctx.task?.*`) | gone |
|
||||
| `assertTaskCapability` / `assertTaskHandlerCapability` overrides | delete the override |
|
||||
| `*.experimental.tasks.*` accessors, `Experimental{Client,Server,McpServer}Tasks` | removed |
|
||||
| `requestStream` / `callToolStream` / `createMessageStream` / `elicitInputStream` | removed; no streaming variant |
|
||||
| `registerToolTask`, `ToolTaskHandler`, `TaskRequestHandler`, `CreateTaskRequestHandler` | removed |
|
||||
| `TaskMessageQueue`, `InMemoryTaskMessageQueue`, `BaseQueuedMessage`, `Queued*`, `CreateTaskServerContext`, `TaskServerContext`, `TaskToolExecution` | removed |
|
||||
| `ResponseMessage`, `BaseResponseMessage`, `ErrorMessage`, `AsyncGeneratorValue`, `TaskStatusMessage`, `TaskCreatedMessage`, `ResultMessage`, `takeResult`, `toArrayAsync` | removed |
|
||||
|
||||
Type changes in handler context:
|
||||
|
||||
| Type | v1 | v2 |
|
||||
| ------------------------------------------- | ----------------------------- | --------------------- |
|
||||
| `TaskContext.requestedTtl` | `number \| null \| undefined` | `number \| undefined` |
|
||||
| `CreateTaskServerContext.task.requestedTtl` | `number \| null \| undefined` | `number \| undefined` |
|
||||
| `TaskServerContext.task.requestedTtl` | `number \| null \| undefined` | `number \| undefined` |
|
||||
|
||||
> These task APIs are `@experimental` and may change without notice.
|
||||
`TaskStore` / `InMemoryTaskStore` / `CreateTaskOptions` / `isTerminal` (storage layer) and `TaskCreationParams` are also removed; they will return with the SEP-2663 server-directed plugin.
|
||||
|
||||
## 13. Client Behavioral Changes
|
||||
|
||||
|
|
@ -531,7 +531,8 @@ Validator behavior:
|
|||
|
||||
- Do not add validator imports for normal migrations.
|
||||
- Do not install `ajv`, `ajv-formats`, or `@cfworker/json-schema` for the default path; client/server bundle the runtime-selected defaults and the root entry point does not pull either dep in.
|
||||
- To customize the built-in backend (e.g. register custom AJV formats, change `@cfworker/json-schema` draft), import the named class from the package subpath: `@modelcontextprotocol/{client,server}/validators/ajv` for `AjvJsonSchemaValidator`, `@modelcontextprotocol/{client,server}/validators/cf-worker` for `CfWorkerJsonSchemaValidator`. Importing from a subpath means the corresponding peer dep must be in your `package.json`.
|
||||
- To customize the built-in backend (e.g. register custom AJV formats, change `@cfworker/json-schema` draft), import the named class from the package subpath: `@modelcontextprotocol/{client,server}/validators/ajv` for `AjvJsonSchemaValidator`,
|
||||
`@modelcontextprotocol/{client,server}/validators/cf-worker` for `CfWorkerJsonSchemaValidator`. Importing from a subpath means the corresponding peer dep must be in your `package.json`.
|
||||
- To replace validation entirely, pass `jsonSchemaValidator: myCustomValidator` with your own implementation of the `jsonSchemaValidator` interface.
|
||||
|
||||
## 15. Migration Steps (apply in this order)
|
||||
|
|
|
|||
|
|
@ -145,7 +145,8 @@ const transport = new StreamableHTTPClientTransport(new URL('http://localhost:30
|
|||
|
||||
Resource Server helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`) are first-class in `@modelcontextprotocol/express`.
|
||||
|
||||
Authorization Server helpers (`mcpAuthRouter`, `OAuthServerProvider`, `ProxyOAuthServerProvider`, `authenticateClient`, `allowedMethods`, etc.) have been removed from the core SDK; new code should use a dedicated IdP/OAuth library. See the [examples](../examples/server/src/) for a working demo with `better-auth`.
|
||||
Authorization Server helpers (`mcpAuthRouter`, `OAuthServerProvider`, `ProxyOAuthServerProvider`, `authenticateClient`, `allowedMethods`, etc.) have been removed from the core SDK; new code should use a dedicated IdP/OAuth library. See the [examples](../examples/server/src/) for
|
||||
a working demo with `better-auth`.
|
||||
|
||||
Note: `AuthInfo` has moved from `server/auth/types.ts` to the core types and is now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`.
|
||||
|
||||
|
|
@ -387,7 +388,11 @@ const AcmeSearch = z.object({
|
|||
params: z.object({ query: z.string(), limit: z.number().int() })
|
||||
});
|
||||
server.setRequestHandler(AcmeSearch, async request => {
|
||||
return { items: [/* ... */] };
|
||||
return {
|
||||
items: [
|
||||
/* ... */
|
||||
]
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
|
|
@ -398,7 +403,11 @@ const SearchParams = z.object({ query: z.string(), limit: z.number().int() });
|
|||
const SearchResult = z.object({ items: z.array(z.string()) });
|
||||
|
||||
server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, ctx) => {
|
||||
return { items: [/* ... */] };
|
||||
return {
|
||||
items: [
|
||||
/* ... */
|
||||
]
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
|
|
@ -437,8 +446,8 @@ Common method string replacements:
|
|||
|
||||
### `Protocol.request()`, `ctx.mcpReq.send()`, and `Client.callTool()` no longer require a schema parameter for spec methods
|
||||
|
||||
For **spec** methods, the public `Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` methods no longer require a Zod result schema argument. The SDK now resolves the correct result schema internally based on the method name. This means you no longer need to import result schemas
|
||||
like `CallToolResultSchema` or `ElicitResultSchema` when making spec-method requests.
|
||||
For **spec** methods, the public `Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` methods no longer require a Zod result schema argument. The SDK now resolves the correct result schema internally based on the method name. This means you no longer need to
|
||||
import result schemas like `CallToolResultSchema` or `ElicitResultSchema` when making spec-method requests.
|
||||
|
||||
**`client.request()` — Before (v1):**
|
||||
|
||||
|
|
@ -493,7 +502,7 @@ const result = await client.callTool({ name: 'my-tool', arguments: {} }, Compati
|
|||
const result = await client.callTool({ name: 'my-tool', arguments: {} });
|
||||
```
|
||||
|
||||
The return type is now inferred from the method name via `ResultTypeMap`. For example, `client.request({ method: 'tools/call', ... })` returns `Promise<CallToolResult | CreateTaskResult>`.
|
||||
The return type is now inferred from the method name via `ResultTypeMap`. For example, `client.request({ method: 'tools/call', ... })` returns `Promise<CallToolResult>`.
|
||||
|
||||
For **custom (non-spec)** methods, keep the result-schema argument — see [Sending custom-method requests](#sending-custom-method-requests). Only drop the schema when calling a spec method.
|
||||
|
||||
|
|
@ -518,7 +527,8 @@ import { specTypeSchemas } from '@modelcontextprotocol/client';
|
|||
const result = specTypeSchemas.CallToolResult['~standard'].validate(value);
|
||||
```
|
||||
|
||||
`isSpecType` and `specTypeSchemas` are keyed by `SpecTypeName` — a literal union of every named type in the MCP spec — so you get autocomplete and a compile error on typos. `specTypeSchemas.X` is a `StandardSchemaV1Sync<In, Out>` — `validate()` returns the result synchronously, so you can access `.issues` / `.value` without `await`. It composes with any Standard-Schema-aware library. The pre-existing `isCallToolResult(value)` guard still works.
|
||||
`isSpecType` and `specTypeSchemas` are keyed by `SpecTypeName` — a literal union of every named type in the MCP spec — so you get autocomplete and a compile error on typos. `specTypeSchemas.X` is a `StandardSchemaV1Sync<In, Out>` — `validate()` returns the result synchronously,
|
||||
so you can access `.issues` / `.value` without `await`. It composes with any Standard-Schema-aware library. The pre-existing `isCallToolResult(value)` guard still works.
|
||||
|
||||
### Client list methods return empty results for missing capabilities
|
||||
|
||||
|
|
@ -585,23 +595,21 @@ import { JSONRPCErrorResponse, ResourceTemplateReference, isJSONRPCErrorResponse
|
|||
|
||||
The `RequestHandlerExtra` type has been replaced with a structured context type hierarchy using nested groups:
|
||||
|
||||
| v1 | v2 |
|
||||
| ---------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `RequestHandlerExtra` (flat, all fields) | `ServerContext` (server handlers) or `ClientContext` (client handlers) |
|
||||
| `extra` parameter name | `ctx` parameter name |
|
||||
| `extra.signal` | `ctx.mcpReq.signal` |
|
||||
| `extra.requestId` | `ctx.mcpReq.id` |
|
||||
| `extra._meta` | `ctx.mcpReq._meta` |
|
||||
| `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` |
|
||||
| `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` |
|
||||
| `extra.authInfo` | `ctx.http?.authInfo` |
|
||||
| `extra.requestInfo` | `ctx.http?.req` (standard Web `Request`, only on `ServerContext`) |
|
||||
| `extra.closeSSEStream` | `ctx.http?.closeSSE` (only on `ServerContext`) |
|
||||
| `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` (only on `ServerContext`) |
|
||||
| `extra.sessionId` | `ctx.sessionId` |
|
||||
| `extra.taskStore` | `ctx.task?.store` |
|
||||
| `extra.taskId` | `ctx.task?.id` |
|
||||
| `extra.taskRequestedTtl` | `ctx.task?.requestedTtl` |
|
||||
| v1 | v2 |
|
||||
| ------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `RequestHandlerExtra` (flat, all fields) | `ServerContext` (server handlers) or `ClientContext` (client handlers) |
|
||||
| `extra` parameter name | `ctx` parameter name |
|
||||
| `extra.signal` | `ctx.mcpReq.signal` |
|
||||
| `extra.requestId` | `ctx.mcpReq.id` |
|
||||
| `extra._meta` | `ctx.mcpReq._meta` |
|
||||
| `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` |
|
||||
| `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` |
|
||||
| `extra.authInfo` | `ctx.http?.authInfo` |
|
||||
| `extra.requestInfo` | `ctx.http?.req` (standard Web `Request`, only on `ServerContext`) |
|
||||
| `extra.closeSSEStream` | `ctx.http?.closeSSE` (only on `ServerContext`) |
|
||||
| `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` (only on `ServerContext`) |
|
||||
| `extra.sessionId` | `ctx.sessionId` |
|
||||
| `extra.taskStore` / `taskId` / `taskRequestedTtl` | _removed — see "Experimental tasks interception removed" below_ |
|
||||
|
||||
**Before (v1):**
|
||||
|
||||
|
|
@ -619,17 +627,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|||
```typescript
|
||||
server.setRequestHandler('tools/call', async (request, ctx) => {
|
||||
const headers = ctx.http?.req?.headers; // standard Web Request object
|
||||
const taskStore = ctx.task?.store;
|
||||
await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'abc', progress: 50, total: 100 } });
|
||||
return { content: [{ type: 'text', text: 'result' }] };
|
||||
});
|
||||
```
|
||||
|
||||
Context fields are organized into 4 groups:
|
||||
Context fields are organized into 3 groups:
|
||||
|
||||
- **`mcpReq`** — request-level concerns: `id`, `method`, `_meta`, `signal`, `send()`, `notify()`, plus server-only `log()`, `elicitInput()`, and `requestSampling()`
|
||||
- **`http?`** — HTTP transport concerns (undefined for stdio): `authInfo`, plus server-only `req`, `closeSSE`, `closeStandaloneSSE`
|
||||
- **`task?`** — task lifecycle: `id`, `store`, `requestedTtl`
|
||||
- **`sessionId?`** — transport session identifier (top-level)
|
||||
|
||||
`BaseContext` is the common base type shared by both `ServerContext` and `ClientContext`. `ServerContext` extends each group with server-specific additions via type intersection.
|
||||
|
||||
|
|
@ -715,22 +722,22 @@ try {
|
|||
|
||||
The new `SdkErrorCode` enum contains string-valued codes for local SDK errors:
|
||||
|
||||
| Code | Description |
|
||||
| ------------------------------------------------- | ------------------------------------------- |
|
||||
| `SdkErrorCode.NotConnected` | Transport is not connected |
|
||||
| `SdkErrorCode.AlreadyConnected` | Transport is already connected |
|
||||
| `SdkErrorCode.NotInitialized` | Protocol is not initialized |
|
||||
| `SdkErrorCode.CapabilityNotSupported` | Required capability is not supported |
|
||||
| `SdkErrorCode.RequestTimeout` | Request timed out waiting for response |
|
||||
| `SdkErrorCode.ConnectionClosed` | Connection was closed |
|
||||
| `SdkErrorCode.SendFailed` | Failed to send message |
|
||||
| Code | Description |
|
||||
| ------------------------------------------------- | ---------------------------------------------- |
|
||||
| `SdkErrorCode.NotConnected` | Transport is not connected |
|
||||
| `SdkErrorCode.AlreadyConnected` | Transport is already connected |
|
||||
| `SdkErrorCode.NotInitialized` | Protocol is not initialized |
|
||||
| `SdkErrorCode.CapabilityNotSupported` | Required capability is not supported |
|
||||
| `SdkErrorCode.RequestTimeout` | Request timed out waiting for response |
|
||||
| `SdkErrorCode.ConnectionClosed` | Connection was closed |
|
||||
| `SdkErrorCode.SendFailed` | Failed to send message |
|
||||
| `SdkErrorCode.InvalidResult` | Response result failed local schema validation |
|
||||
| `SdkErrorCode.ClientHttpNotImplemented` | HTTP POST request failed |
|
||||
| `SdkErrorCode.ClientHttpAuthentication` | Server returned 401 after re-authentication |
|
||||
| `SdkErrorCode.ClientHttpForbidden` | Server returned 403 after trying upscoping |
|
||||
| `SdkErrorCode.ClientHttpUnexpectedContent` | Unexpected content type in HTTP response |
|
||||
| `SdkErrorCode.ClientHttpFailedToOpenStream` | Failed to open SSE stream |
|
||||
| `SdkErrorCode.ClientHttpFailedToTerminateSession` | Failed to terminate session |
|
||||
| `SdkErrorCode.ClientHttpNotImplemented` | HTTP POST request failed |
|
||||
| `SdkErrorCode.ClientHttpAuthentication` | Server returned 401 after re-authentication |
|
||||
| `SdkErrorCode.ClientHttpForbidden` | Server returned 403 after trying upscoping |
|
||||
| `SdkErrorCode.ClientHttpUnexpectedContent` | Unexpected content type in HTTP response |
|
||||
| `SdkErrorCode.ClientHttpFailedToOpenStream` | Failed to open SSE stream |
|
||||
| `SdkErrorCode.ClientHttpFailedToTerminateSession` | Failed to terminate session |
|
||||
|
||||
#### `StreamableHTTPError` removed
|
||||
|
||||
|
|
@ -759,7 +766,7 @@ try {
|
|||
await transport.send(message);
|
||||
} catch (error) {
|
||||
if (error instanceof SdkHttpError) {
|
||||
console.log('HTTP status:', error.status); // number — no cast needed
|
||||
console.log('HTTP status:', error.status); // number — no cast needed
|
||||
console.log('Status text:', error.statusText); // string | undefined
|
||||
switch (error.code) {
|
||||
case SdkErrorCode.ClientHttpAuthentication:
|
||||
|
|
@ -870,46 +877,24 @@ try {
|
|||
}
|
||||
```
|
||||
|
||||
### Experimental: `TaskCreationParams.ttl` no longer accepts `null`
|
||||
### Experimental tasks interception removed
|
||||
|
||||
The `ttl` field in `TaskCreationParams` (used when requesting the server to create a task) no longer accepts `null`. Per the MCP spec, `null` TTL (meaning unlimited lifetime) is only valid in server responses (`Task.ttl`), not in client requests. Clients should omit `ttl` to let
|
||||
the server decide the lifetime.
|
||||
The 2025-11 experimental tasks side-channel woven through `Protocol` has been removed in preparation for the SEP-2663 Tasks Extension. The following are gone with no in-place replacement:
|
||||
|
||||
This also narrows the type of `requestedTtl` in `TaskContext`, `CreateTaskServerContext`, and `TaskServerContext` from `number | null | undefined` to `number | undefined`.
|
||||
- `ProtocolOptions.tasks` (the `{ taskStore, taskMessageQueue }` constructor option)
|
||||
- `protocol.taskManager` getter, `Protocol#_bindTaskManager`
|
||||
- `RequestOptions.task` / `RequestOptions.relatedTask`, `NotificationOptions.relatedTask`
|
||||
- `BaseContext.task` (`ctx.task?.store` / `ctx.task?.id` / `ctx.task?.requestedTtl`)
|
||||
- abstract `assertTaskCapability` / `assertTaskHandlerCapability`
|
||||
- `client.experimental.tasks.*` / `server.experimental.tasks.*` / `mcpServer.experimental.tasks.*` accessors and the `Experimental{Client,Server,McpServer}Tasks` classes
|
||||
- streaming methods (`requestStream`, `callToolStream`, `createMessageStream`, `elicitInputStream`) and the `ResponseMessage` types they yielded (`BaseResponseMessage`, `ErrorMessage`, `AsyncGeneratorValue`)
|
||||
- `mcpServer.experimental.tasks.registerToolTask(...)`, `ToolTaskHandler`, `TaskRequestHandler`, `CreateTaskRequestHandler`
|
||||
- `TaskMessageQueue`, `InMemoryTaskMessageQueue`, `BaseQueuedMessage` and the `Queued*` message types, `CreateTaskServerContext`, `TaskServerContext`, `TaskToolExecution`
|
||||
- `examples/{client,server}/src/simpleTaskInteractive*.ts`
|
||||
|
||||
**Before (v1):**
|
||||
**Also removed:** the storage layer (`TaskStore`, `InMemoryTaskStore`, `CreateTaskOptions`, `isTerminal`) and `TaskCreationParams`. They will return as part of the SEP-2663 server-directed plugin in a follow-up.
|
||||
|
||||
```typescript
|
||||
// Requesting unlimited lifetime by passing null
|
||||
const result = await client.callTool({
|
||||
name: 'long-task',
|
||||
arguments: {},
|
||||
task: { ttl: null }
|
||||
});
|
||||
|
||||
// Handler context had number | null | undefined
|
||||
server.setRequestHandler('tools/call', async (request, ctx) => {
|
||||
const ttl: number | null | undefined = ctx.task?.requestedTtl;
|
||||
});
|
||||
```
|
||||
|
||||
**After (v2):**
|
||||
|
||||
```typescript
|
||||
// Omit ttl to let the server decide (server may return null for unlimited)
|
||||
const result = await client.callTool({
|
||||
name: 'long-task',
|
||||
arguments: {},
|
||||
task: {}
|
||||
});
|
||||
|
||||
// Handler context is now number | undefined
|
||||
server.setRequestHandler('tools/call', async (request, ctx) => {
|
||||
const ttl: number | undefined = ctx.task?.requestedTtl;
|
||||
});
|
||||
```
|
||||
|
||||
> **Note:** These task APIs are marked `@experimental` and may change without notice.
|
||||
There is no migration path for the removed surface; it was always `@experimental`. Task support is planned to return as an opt-in extension plugin per SEP-2663.
|
||||
|
||||
## Enhancements
|
||||
|
||||
|
|
@ -949,7 +934,8 @@ const server = new McpServer(
|
|||
);
|
||||
```
|
||||
|
||||
You do not need to install or import validator packages for the default behavior. The client and server packages bundle the validator backend selected by the runtime shim, so a normal `import { McpServer } from '@modelcontextprotocol/server'` does not pull `ajv` or `@cfworker/json-schema` into your bundle until you choose to customize.
|
||||
You do not need to install or import validator packages for the default behavior. The client and server packages bundle the validator backend selected by the runtime shim, so a normal `import { McpServer } from '@modelcontextprotocol/server'` does not pull `ajv` or
|
||||
`@cfworker/json-schema` into your bundle until you choose to customize.
|
||||
|
||||
If you want to customize the **built-in** backend (for example, pre-register schemas by `$id`, register custom AJV formats, or change the `@cfworker/json-schema` draft), import the named class from the explicit subpath and pass an instance through `jsonSchemaValidator`:
|
||||
|
||||
|
|
@ -984,7 +970,8 @@ const server = new McpServer(
|
|||
|
||||
(both subpaths are also available on `@modelcontextprotocol/client/validators/...`)
|
||||
|
||||
If you import from one of these subpaths in your own code, the corresponding peer dep (`ajv` + `ajv-formats`, or `@cfworker/json-schema`) needs to be installed in your `package.json`. The runtime shim continues to vendor a copy for the default code path, so you can use the subpath in some files and rely on the default in others.
|
||||
If you import from one of these subpaths in your own code, the corresponding peer dep (`ajv` + `ajv-formats`, or `@cfworker/json-schema`) needs to be installed in your `package.json`. The runtime shim continues to vendor a copy for the default code path, so you can use the
|
||||
subpath in some files and rely on the default in others.
|
||||
|
||||
To replace validation wholesale rather than customizing the built-in classes, implement the `jsonSchemaValidator` interface and pass your own implementation through the option above.
|
||||
|
||||
|
|
|
|||
|
|
@ -495,19 +495,6 @@ server.registerTool(
|
|||
);
|
||||
```
|
||||
|
||||
## Tasks (experimental)
|
||||
|
||||
> [!WARNING]
|
||||
> The tasks API is experimental and may change without notice.
|
||||
|
||||
Task-based execution enables "call-now, fetch-later" patterns for long-running operations (see [Tasks](https://modelcontextprotocol.io/specification/latest/basic/utilities/tasks) in the MCP specification). Instead of returning a result immediately, a tool creates a task that can be polled or resumed later. To use tasks:
|
||||
|
||||
- Provide a {@linkcode @modelcontextprotocol/server!index.TaskStore | TaskStore} implementation that persists task metadata and results (see {@linkcode @modelcontextprotocol/server!index.InMemoryTaskStore | InMemoryTaskStore} for reference).
|
||||
- Enable the `tasks` capability when constructing the server.
|
||||
- Register tools with {@linkcode @modelcontextprotocol/server!experimental/tasks/mcpServer.ExperimentalMcpServerTasks#registerToolTask | server.experimental.tasks.registerToolTask(...)}.
|
||||
|
||||
For a full runnable example, see [`simpleTaskInteractive.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleTaskInteractive.ts).
|
||||
|
||||
## Shutdown
|
||||
|
||||
For stateful multi-session HTTP servers, capture the `http.Server` from `app.listen()` so you can stop accepting connections, then close each session transport:
|
||||
|
|
|
|||
|
|
@ -24,18 +24,17 @@ Most clients expect a server to be running. Start one from [`../server/README.md
|
|||
|
||||
## Example index
|
||||
|
||||
| Scenario | Description | File |
|
||||
| --------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| Interactive Streamable HTTP client | CLI client that exercises tools/resources/prompts, notifications, elicitation, and tasks. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) |
|
||||
| Backwards-compatible client (Streamable HTTP → SSE) | Tries Streamable HTTP first, falls back to legacy SSE on 4xx responses. | [`src/streamableHttpWithSseFallbackClient.ts`](src/streamableHttpWithSseFallbackClient.ts) |
|
||||
| SSE polling client (legacy) | Polls a legacy HTTP+SSE server and demonstrates notification handling. | [`src/ssePollingClient.ts`](src/ssePollingClient.ts) |
|
||||
| Parallel tool calls | Runs multiple tool calls in parallel. | [`src/parallelToolCallsClient.ts`](src/parallelToolCallsClient.ts) |
|
||||
| Multiple clients in parallel | Connects multiple clients concurrently to the same server. | [`src/multipleClientsParallel.ts`](src/multipleClientsParallel.ts) |
|
||||
| OAuth client (interactive) | OAuth-enabled client (dynamic registration, auth flow). | [`src/simpleOAuthClient.ts`](src/simpleOAuthClient.ts) |
|
||||
| OAuth provider helper | Demonstrates reusable OAuth providers. | [`src/simpleOAuthClientProvider.ts`](src/simpleOAuthClientProvider.ts) |
|
||||
| Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) |
|
||||
| URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) |
|
||||
| Task interactive client | Demonstrates task-based execution + interactive server→client requests. | [`src/simpleTaskInteractiveClient.ts`](src/simpleTaskInteractiveClient.ts) |
|
||||
| Scenario | Description | File |
|
||||
| --------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| Interactive Streamable HTTP client | CLI client that exercises tools/resources/prompts, notifications, and elicitation. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) |
|
||||
| Backwards-compatible client (Streamable HTTP → SSE) | Tries Streamable HTTP first, falls back to legacy SSE on 4xx responses. | [`src/streamableHttpWithSseFallbackClient.ts`](src/streamableHttpWithSseFallbackClient.ts) |
|
||||
| SSE polling client (legacy) | Polls a legacy HTTP+SSE server and demonstrates notification handling. | [`src/ssePollingClient.ts`](src/ssePollingClient.ts) |
|
||||
| Parallel tool calls | Runs multiple tool calls in parallel. | [`src/parallelToolCallsClient.ts`](src/parallelToolCallsClient.ts) |
|
||||
| Multiple clients in parallel | Connects multiple clients concurrently to the same server. | [`src/multipleClientsParallel.ts`](src/multipleClientsParallel.ts) |
|
||||
| OAuth client (interactive) | OAuth-enabled client (dynamic registration, auth flow). | [`src/simpleOAuthClient.ts`](src/simpleOAuthClient.ts) |
|
||||
| OAuth provider helper | Demonstrates reusable OAuth providers. | [`src/simpleOAuthClientProvider.ts`](src/simpleOAuthClientProvider.ts) |
|
||||
| Client credentials (M2M) | Machine-to-machine OAuth client credentials example. | [`src/simpleClientCredentials.ts`](src/simpleClientCredentials.ts) |
|
||||
| URL elicitation client | Drives URL-mode elicitation flows (sensitive input in a browser). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) |
|
||||
|
||||
## URL elicitation example (server + client)
|
||||
|
||||
|
|
|
|||
|
|
@ -490,7 +490,7 @@ async function errorHandling_timeout(client: Client) {
|
|||
//#region errorHandling_timeout
|
||||
try {
|
||||
const result = await client.callTool(
|
||||
{ name: 'slow-task', arguments: {} },
|
||||
{ name: 'slow-operation', arguments: {} },
|
||||
{ timeout: 120_000 } // 2 minutes instead of the default 60 seconds
|
||||
);
|
||||
console.log(result.content);
|
||||
|
|
@ -530,7 +530,7 @@ async function resumptionToken_basic(client: Client) {
|
|||
const result = await client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: { name: 'long-running-task', arguments: {} }
|
||||
params: { name: 'long-running-operation', arguments: {} }
|
||||
},
|
||||
{
|
||||
resumptionToken: lastToken,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { createServer } from 'node:http';
|
|||
import { createInterface } from 'node:readline';
|
||||
import { URL } from 'node:url';
|
||||
|
||||
import type { CallToolResult, ListToolsRequest, OAuthClientMetadata } from '@modelcontextprotocol/client';
|
||||
import type { ListToolsRequest, OAuthClientMetadata } from '@modelcontextprotocol/client';
|
||||
import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client';
|
||||
import open from 'open';
|
||||
|
||||
|
|
@ -209,7 +209,7 @@ class InteractiveOAuthClient {
|
|||
console.log('Commands:');
|
||||
console.log(' list - List available tools');
|
||||
console.log(' call <tool_name> [args] - Call a tool');
|
||||
console.log(' stream <tool_name> [args] - Call a tool with streaming (shows task status)');
|
||||
console.log(' stream <tool_name> [args] - (disabled; returns when the SEP-2663 tasks extension lands)');
|
||||
console.log(' quit - Exit the client');
|
||||
console.log();
|
||||
|
||||
|
|
@ -232,7 +232,7 @@ class InteractiveOAuthClient {
|
|||
} else if (command.startsWith('stream ')) {
|
||||
await this.handleStreamTool(command);
|
||||
} else {
|
||||
console.log("❌ Unknown command. Try 'list', 'call <tool_name>', 'stream <tool_name>', or 'quit'");
|
||||
console.log("❌ Unknown command. Try 'list', 'call <tool_name>', or 'quit'");
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'SIGINT') {
|
||||
|
|
@ -358,62 +358,11 @@ class InteractiveOAuthClient {
|
|||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Using the experimental tasks API - WARNING: may change without notice
|
||||
console.log(`\n🔧 Streaming tool '${toolName}'...`);
|
||||
|
||||
const stream = this.client.experimental.tasks.callToolStream(
|
||||
{
|
||||
name: toolName,
|
||||
arguments: toolArgs
|
||||
},
|
||||
{
|
||||
task: {
|
||||
taskId: `task-${Date.now()}`,
|
||||
ttl: 60_000
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Iterate through all messages yielded by the generator
|
||||
for await (const message of stream) {
|
||||
switch (message.type) {
|
||||
case 'taskCreated': {
|
||||
console.log(`✓ Task created: ${message.task.taskId}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'taskStatus': {
|
||||
console.log(`⟳ Status: ${message.task.status}`);
|
||||
if (message.task.statusMessage) {
|
||||
console.log(` ${message.task.statusMessage}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'result': {
|
||||
console.log('✓ Completed!');
|
||||
const toolResult = message.result as CallToolResult;
|
||||
for (const content of toolResult.content) {
|
||||
if (content.type === 'text') {
|
||||
console.log(content.text);
|
||||
} else {
|
||||
console.log(content);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
console.log('✗ Error:');
|
||||
console.log(` ${message.error.message}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to stream tool '${toolName}':`, error);
|
||||
}
|
||||
// The streaming-tool demo (callToolStream) was removed with the 2025-11
|
||||
// experimental tasks (SEP-2663); it returns when the tasks extension lands.
|
||||
void toolName;
|
||||
void toolArgs;
|
||||
console.log('Streaming tool demo removed with the 2025-11 experimental tasks (SEP-2663); returns when the tasks extension lands.');
|
||||
}
|
||||
|
||||
close(): void {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { createInterface } from 'node:readline';
|
||||
|
||||
import type {
|
||||
CallToolResult,
|
||||
GetPromptRequest,
|
||||
ListPromptsRequest,
|
||||
ListResourcesRequest,
|
||||
|
|
@ -9,15 +8,7 @@ import type {
|
|||
ReadResourceRequest,
|
||||
ResourceLink
|
||||
} from '@modelcontextprotocol/client';
|
||||
import {
|
||||
Client,
|
||||
getDisplayName,
|
||||
InMemoryTaskStore,
|
||||
ProtocolError,
|
||||
ProtocolErrorCode,
|
||||
RELATED_TASK_META_KEY,
|
||||
StreamableHTTPClientTransport
|
||||
} from '@modelcontextprotocol/client';
|
||||
import { Client, getDisplayName, ProtocolError, ProtocolErrorCode, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import { Ajv } from 'ajv';
|
||||
|
||||
// Create readline interface for user input
|
||||
|
|
@ -56,11 +47,9 @@ function printHelp(): void {
|
|||
console.log(' reconnect - Reconnect to the server');
|
||||
console.log(' list-tools - List available tools');
|
||||
console.log(' call-tool <name> [args] - Call a tool with optional JSON arguments');
|
||||
console.log(' call-tool-task <name> [args] - Call a tool with task-based execution (example: call-tool-task delay {"duration":3000})');
|
||||
console.log(' greet [name] - Call the greet tool');
|
||||
console.log(' multi-greet [name] - Call the multi-greet tool with notifications');
|
||||
console.log(' collect-info [type] - Test form elicitation with collect-user-info tool (contact/preferences/feedback)');
|
||||
console.log(' collect-info-task [type] - Test bidirectional task support (server+client tasks) with elicitation');
|
||||
console.log(' start-notifications [interval] [count] - Start periodic notifications');
|
||||
console.log(' run-notifications-tool-with-resumability [interval] [count] - Run notification tool with resumability');
|
||||
console.log(' list-prompts - List available prompts');
|
||||
|
|
@ -135,12 +124,6 @@ function commandLoop(): void {
|
|||
await callCollectInfoTool(args[1] || 'contact');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'collect-info-task': {
|
||||
await callCollectInfoWithTask(args[1] || 'contact');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'start-notifications': {
|
||||
const interval = args[1] ? Number.parseInt(args[1], 10) : 2000;
|
||||
const count = args[2] ? Number.parseInt(args[2], 10) : 10;
|
||||
|
|
@ -154,25 +137,6 @@ function commandLoop(): void {
|
|||
await runNotificationsToolWithResumability(interval, count);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'call-tool-task': {
|
||||
if (args.length < 2) {
|
||||
console.log('Usage: call-tool-task <name> [args]');
|
||||
} else {
|
||||
const toolName = args[1]!;
|
||||
let toolArgs = {};
|
||||
if (args.length > 2) {
|
||||
try {
|
||||
toolArgs = JSON.parse(args.slice(2).join(' '));
|
||||
} catch {
|
||||
console.log('Invalid JSON arguments. Using empty args.');
|
||||
}
|
||||
}
|
||||
await callToolTask(toolName, toolArgs);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'list-prompts': {
|
||||
await listPrompts();
|
||||
break;
|
||||
|
|
@ -250,10 +214,7 @@ async function connect(url?: string): Promise<void> {
|
|||
console.log(`Connecting to ${serverUrl}...`);
|
||||
|
||||
try {
|
||||
// Create task store for client-side task support
|
||||
const clientTaskStore = new InMemoryTaskStore();
|
||||
|
||||
// Create a new client with form elicitation capability and task support
|
||||
// Create a new client with form elicitation capability
|
||||
client = new Client(
|
||||
{
|
||||
name: 'example-client',
|
||||
|
|
@ -263,14 +224,6 @@ async function connect(url?: string): Promise<void> {
|
|||
capabilities: {
|
||||
elicitation: {
|
||||
form: {}
|
||||
},
|
||||
tasks: {
|
||||
taskStore: clientTaskStore,
|
||||
requests: {
|
||||
elicitation: {
|
||||
create: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -279,33 +232,16 @@ async function connect(url?: string): Promise<void> {
|
|||
console.error('\u001B[31mClient error:', error, '\u001B[0m');
|
||||
};
|
||||
|
||||
// Set up elicitation request handler with proper validation and task support
|
||||
client.setRequestHandler('elicitation/create', async (request, extra) => {
|
||||
// Set up elicitation request handler with proper validation
|
||||
client.setRequestHandler('elicitation/create', async request => {
|
||||
if (request.params.mode !== 'form') {
|
||||
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unsupported elicitation mode: ${request.params.mode}`);
|
||||
}
|
||||
console.log('\n🔔 Elicitation (form) Request Received:');
|
||||
console.log(`Message: ${request.params.message}`);
|
||||
console.log(`Related Task: ${request.params._meta?.[RELATED_TASK_META_KEY]?.taskId}`);
|
||||
console.log(`Task Creation Requested: ${request.params.task ? 'yes' : 'no'}`);
|
||||
console.log('Requested Schema:');
|
||||
console.log(JSON.stringify(request.params.requestedSchema, null, 2));
|
||||
|
||||
// Helper to return result, optionally creating a task if requested
|
||||
const returnResult = async (result: {
|
||||
action: 'accept' | 'decline' | 'cancel';
|
||||
content?: Record<string, string | number | boolean | string[]>;
|
||||
}) => {
|
||||
if (request.params.task && extra.task?.store) {
|
||||
// Create a task and store the result
|
||||
const task = await extra.task.store.createTask({ ttl: extra.task.requestedTtl });
|
||||
await extra.task.store.storeTaskResult(task.taskId, 'completed', result);
|
||||
console.log(`📋 Created client-side task: ${task.taskId}`);
|
||||
return { task };
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const schema = request.params.requestedSchema;
|
||||
const properties = schema.properties;
|
||||
const required = schema.required || [];
|
||||
|
|
@ -439,7 +375,7 @@ async function connect(url?: string): Promise<void> {
|
|||
}
|
||||
|
||||
if (inputCancelled) {
|
||||
return returnResult({ action: 'cancel' });
|
||||
return { action: 'cancel' };
|
||||
}
|
||||
|
||||
// If we didn't complete all fields due to an error, try again
|
||||
|
|
@ -452,7 +388,7 @@ async function connect(url?: string): Promise<void> {
|
|||
continue;
|
||||
} else {
|
||||
console.log('Maximum attempts reached. Declining request.');
|
||||
return returnResult({ action: 'decline' });
|
||||
return { action: 'decline' };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -471,7 +407,7 @@ async function connect(url?: string): Promise<void> {
|
|||
continue;
|
||||
} else {
|
||||
console.log('Maximum attempts reached. Declining request.');
|
||||
return returnResult({ action: 'decline' });
|
||||
return { action: 'decline' };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -488,14 +424,14 @@ async function connect(url?: string): Promise<void> {
|
|||
switch (confirmAnswer) {
|
||||
case 'yes':
|
||||
case 'y': {
|
||||
return returnResult({
|
||||
return {
|
||||
action: 'accept',
|
||||
content
|
||||
});
|
||||
};
|
||||
}
|
||||
case 'cancel':
|
||||
case 'c': {
|
||||
return returnResult({ action: 'cancel' });
|
||||
return { action: 'cancel' };
|
||||
}
|
||||
case 'no':
|
||||
case 'n': {
|
||||
|
|
@ -503,7 +439,7 @@ async function connect(url?: string): Promise<void> {
|
|||
console.log('Please re-enter the information...');
|
||||
continue;
|
||||
} else {
|
||||
return returnResult({ action: 'decline' });
|
||||
return { action: 'decline' };
|
||||
}
|
||||
|
||||
break;
|
||||
|
|
@ -513,7 +449,7 @@ async function connect(url?: string): Promise<void> {
|
|||
}
|
||||
|
||||
console.log('Maximum attempts reached. Declining request.');
|
||||
return returnResult({ action: 'decline' });
|
||||
return { action: 'decline' };
|
||||
});
|
||||
|
||||
transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
|
||||
|
|
@ -716,12 +652,6 @@ async function callCollectInfoTool(infoType: string): Promise<void> {
|
|||
await callTool('collect-user-info', { infoType });
|
||||
}
|
||||
|
||||
async function callCollectInfoWithTask(infoType: string): Promise<void> {
|
||||
console.log(`\n🔄 Testing bidirectional task support with collect-user-info-task tool (${infoType})...`);
|
||||
console.log('This will create a task on the server, which will elicit input and create a task on the client.\n');
|
||||
await callToolTask('collect-user-info-task', { infoType });
|
||||
}
|
||||
|
||||
async function startNotifications(interval: number, count: number): Promise<void> {
|
||||
console.log(`Starting notification stream: interval=${interval}ms, count=${count || 'unlimited'}`);
|
||||
await callTool('start-notification-stream', { interval, count });
|
||||
|
|
@ -880,70 +810,6 @@ async function readResource(uri: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
async function callToolTask(name: string, args: Record<string, unknown>): Promise<void> {
|
||||
if (!client) {
|
||||
console.log('Not connected to server.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Calling tool '${name}' with task-based execution...`);
|
||||
console.log('Arguments:', args);
|
||||
|
||||
// Use task-based execution - call now, fetch later
|
||||
// Using the experimental tasks API - WARNING: may change without notice
|
||||
console.log('This will return immediately while processing continues in the background...');
|
||||
|
||||
try {
|
||||
// Call the tool with task metadata using streaming API
|
||||
const stream = client.experimental.tasks.callToolStream(
|
||||
{
|
||||
name,
|
||||
arguments: args
|
||||
},
|
||||
{
|
||||
task: {
|
||||
ttl: 60_000 // Keep results for 60 seconds
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log('Waiting for task completion...');
|
||||
|
||||
let lastStatus = '';
|
||||
for await (const message of stream) {
|
||||
switch (message.type) {
|
||||
case 'taskCreated': {
|
||||
console.log('Task created successfully with ID:', message.task.taskId);
|
||||
break;
|
||||
}
|
||||
case 'taskStatus': {
|
||||
if (lastStatus !== message.task.status) {
|
||||
console.log(` ${message.task.status}${message.task.statusMessage ? ` - ${message.task.statusMessage}` : ''}`);
|
||||
}
|
||||
lastStatus = message.task.status;
|
||||
break;
|
||||
}
|
||||
case 'result': {
|
||||
console.log('Task completed!');
|
||||
console.log('Tool result:');
|
||||
const toolResult = message.result as CallToolResult;
|
||||
for (const item of toolResult.content) {
|
||||
if (item.type === 'text') {
|
||||
console.log(` ${item.text}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
throw message.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Error with task-based execution: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
if (client && transport) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,204 +0,0 @@
|
|||
/**
|
||||
* Simple interactive task client demonstrating elicitation and sampling responses.
|
||||
*
|
||||
* This client connects to simpleTaskInteractive.ts server and demonstrates:
|
||||
* - Handling elicitation requests (y/n confirmation)
|
||||
* - Handling sampling requests (returns a hardcoded haiku)
|
||||
* - Using task-based tool execution with streaming
|
||||
*/
|
||||
|
||||
import { createInterface } from 'node:readline';
|
||||
|
||||
import type { CallToolResult, CreateMessageRequest, CreateMessageResult, TextContent } from '@modelcontextprotocol/client';
|
||||
import { Client, ProtocolError, ProtocolErrorCode, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
|
||||
// Create readline interface for user input
|
||||
const readline = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
function question(prompt: string): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
readline.question(prompt, answer => {
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getTextContent(result: { content: Array<{ type: string; text?: string }> }): string {
|
||||
const textContent = result.content.find((c): c is TextContent => c.type === 'text');
|
||||
return textContent?.text ?? '(no text)';
|
||||
}
|
||||
|
||||
async function elicitationCallback(params: {
|
||||
mode?: string;
|
||||
message: string;
|
||||
requestedSchema?: object;
|
||||
}): Promise<{ action: 'accept' | 'cancel' | 'decline'; content?: Record<string, string | number | boolean | string[]> }> {
|
||||
console.log(`\n[Elicitation] Server asks: ${params.message}`);
|
||||
|
||||
// Simple terminal prompt for y/n
|
||||
const response = await question('Your response (y/n): ');
|
||||
const confirmed = ['y', 'yes', 'true', '1'].includes(response.toLowerCase());
|
||||
|
||||
console.log(`[Elicitation] Responding with: confirm=${confirmed}`);
|
||||
return { action: 'accept', content: { confirm: confirmed } };
|
||||
}
|
||||
|
||||
async function samplingCallback(params: CreateMessageRequest['params']): Promise<CreateMessageResult> {
|
||||
// Get the prompt from the first message
|
||||
let prompt = 'unknown';
|
||||
if (params.messages && params.messages.length > 0) {
|
||||
const firstMessage = params.messages[0]!;
|
||||
const content = firstMessage.content;
|
||||
if (typeof content === 'object' && !Array.isArray(content) && content.type === 'text' && 'text' in content) {
|
||||
prompt = content.text;
|
||||
} else if (Array.isArray(content)) {
|
||||
const textPart = content.find(c => c.type === 'text' && 'text' in c);
|
||||
if (textPart && 'text' in textPart) {
|
||||
prompt = textPart.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n[Sampling] Server requests LLM completion for: ${prompt}`);
|
||||
|
||||
// Return a hardcoded haiku (in real use, call your LLM here)
|
||||
const haiku = `Cherry blossoms fall
|
||||
Softly on the quiet pond
|
||||
Spring whispers goodbye`;
|
||||
|
||||
console.log('[Sampling] Responding with haiku');
|
||||
return {
|
||||
model: 'mock-haiku-model',
|
||||
role: 'assistant',
|
||||
content: { type: 'text', text: haiku }
|
||||
};
|
||||
}
|
||||
|
||||
async function run(url: string): Promise<void> {
|
||||
console.log('Simple Task Interactive Client');
|
||||
console.log('==============================');
|
||||
console.log(`Connecting to ${url}...`);
|
||||
|
||||
// Create client with elicitation and sampling capabilities
|
||||
const client = new Client(
|
||||
{ name: 'simple-task-interactive-client', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {
|
||||
elicitation: { form: {} },
|
||||
sampling: {}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Set up elicitation request handler
|
||||
client.setRequestHandler('elicitation/create', async request => {
|
||||
if (request.params.mode && request.params.mode !== 'form') {
|
||||
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unsupported elicitation mode: ${request.params.mode}`);
|
||||
}
|
||||
return elicitationCallback(request.params);
|
||||
});
|
||||
|
||||
// Set up sampling request handler
|
||||
client.setRequestHandler('sampling/createMessage', async request => {
|
||||
return samplingCallback(request.params) as unknown as ReturnType<typeof samplingCallback>;
|
||||
});
|
||||
|
||||
// Connect to server
|
||||
const transport = new StreamableHTTPClientTransport(new URL(url));
|
||||
await client.connect(transport);
|
||||
console.log('Connected!\n');
|
||||
|
||||
// List tools
|
||||
const toolsResult = await client.listTools();
|
||||
console.log(`Available tools: ${toolsResult.tools.map(t => t.name).join(', ')}`);
|
||||
|
||||
// Demo 1: Elicitation (confirm_delete)
|
||||
console.log('\n--- Demo 1: Elicitation ---');
|
||||
console.log('Calling confirm_delete tool...');
|
||||
|
||||
const confirmStream = client.experimental.tasks.callToolStream(
|
||||
{ name: 'confirm_delete', arguments: { filename: 'important.txt' } },
|
||||
{ task: { ttl: 60_000 } }
|
||||
);
|
||||
|
||||
for await (const message of confirmStream) {
|
||||
switch (message.type) {
|
||||
case 'taskCreated': {
|
||||
console.log(`Task created: ${message.task.taskId}`);
|
||||
break;
|
||||
}
|
||||
case 'taskStatus': {
|
||||
console.log(`Task status: ${message.task.status}`);
|
||||
break;
|
||||
}
|
||||
case 'result': {
|
||||
const toolResult = message.result as CallToolResult;
|
||||
console.log(`Result: ${getTextContent(toolResult)}`);
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
console.error(`Error: ${message.error}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Demo 2: Sampling (write_haiku)
|
||||
console.log('\n--- Demo 2: Sampling ---');
|
||||
console.log('Calling write_haiku tool...');
|
||||
|
||||
const haikuStream = client.experimental.tasks.callToolStream(
|
||||
{ name: 'write_haiku', arguments: { topic: 'autumn leaves' } },
|
||||
{ task: { ttl: 60_000 } }
|
||||
);
|
||||
|
||||
for await (const message of haikuStream) {
|
||||
switch (message.type) {
|
||||
case 'taskCreated': {
|
||||
console.log(`Task created: ${message.task.taskId}`);
|
||||
break;
|
||||
}
|
||||
case 'taskStatus': {
|
||||
console.log(`Task status: ${message.task.status}`);
|
||||
break;
|
||||
}
|
||||
case 'result': {
|
||||
const toolResult = message.result as CallToolResult;
|
||||
console.log(`Result:\n${getTextContent(toolResult)}`);
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
console.error(`Error: ${message.error}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
console.log('\nDemo complete. Closing connection...');
|
||||
await transport.close();
|
||||
readline.close();
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
let url = 'http://localhost:8000/mcp';
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--url' && args[i + 1]) {
|
||||
url = args[i + 1]!;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Run the client
|
||||
try {
|
||||
await run(url);
|
||||
} catch (error) {
|
||||
console.error('Error running client:', error);
|
||||
// eslint-disable-next-line unicorn/no-process-exit
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
@ -65,16 +65,16 @@ async function main(): Promise<void> {
|
|||
console.log('[Client] Connected successfully');
|
||||
console.log('');
|
||||
|
||||
// Call the long-task tool
|
||||
console.log('[Client] Calling long-task tool...');
|
||||
console.log('[Client] Server will disconnect mid-task to demonstrate polling');
|
||||
// Call the long-operation tool
|
||||
console.log('[Client] Calling long-operation tool...');
|
||||
console.log('[Client] Server will disconnect mid-operation to demonstrate polling');
|
||||
console.log('');
|
||||
|
||||
const result = await client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'long-task',
|
||||
name: 'long-operation',
|
||||
arguments: {}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ pnpm tsx src/simpleStreamableHttp.ts
|
|||
|
||||
| Scenario | Description | File |
|
||||
| ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| Streamable HTTP server (stateful) | Feature-rich server with tools/resources/prompts, logging, tasks, sampling, and optional OAuth. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) |
|
||||
| Streamable HTTP server (stateful) | Feature-rich server with tools/resources/prompts, logging, sampling, and optional OAuth. | [`src/simpleStreamableHttp.ts`](src/simpleStreamableHttp.ts) |
|
||||
| Streamable HTTP server (stateless) | No session tracking; good for simple API-style servers. | [`src/simpleStatelessStreamableHttp.ts`](src/simpleStatelessStreamableHttp.ts) |
|
||||
| Resource-Server-only auth | Minimal OAuth RS using `mcpAuthMetadataRouter` + `requireBearerAuth` from `@modelcontextprotocol/express` (no better-auth). | [`src/resourceServerOnly.ts`](src/resourceServerOnly.ts) |
|
||||
| JSON response mode (no SSE) | Streamable HTTP with JSON-only responses and limited notifications. | [`src/jsonResponseStreamableHttp.ts`](src/jsonResponseStreamableHttp.ts) |
|
||||
|
|
@ -35,8 +35,7 @@ pnpm tsx src/simpleStreamableHttp.ts
|
|||
| Output schema server | Demonstrates tool output validation with structured output schemas. | [`src/mcpServerOutputSchema.ts`](src/mcpServerOutputSchema.ts) |
|
||||
| Form elicitation server | Collects **non-sensitive** user input via schema-driven forms. | [`src/elicitationFormExample.ts`](src/elicitationFormExample.ts) |
|
||||
| URL elicitation server | Secure browser-based flows for **sensitive** input (API keys, OAuth, payments). | [`src/elicitationUrlExample.ts`](src/elicitationUrlExample.ts) |
|
||||
| Sampling + tasks server | Demonstrates sampling and experimental task-based execution. | [`src/toolWithSampleServer.ts`](src/toolWithSampleServer.ts) |
|
||||
| Task interactive server | Task-based execution with interactive server→client requests. | [`src/simpleTaskInteractive.ts`](src/simpleTaskInteractive.ts) |
|
||||
| Sampling server | Demonstrates server-initiated sampling requests. | [`src/toolWithSampleServer.ts`](src/toolWithSampleServer.ts) |
|
||||
| Hono Streamable HTTP server | Streamable HTTP server built with Hono instead of Express. | [`src/honoWebStandardStreamableHttp.ts`](src/honoWebStandardStreamableHttp.ts) |
|
||||
| SSE polling demo server | Legacy SSE server intended for polling demos. | [`src/ssePollingExample.ts`](src/ssePollingExample.ts) |
|
||||
|
||||
|
|
|
|||
|
|
@ -1,181 +0,0 @@
|
|||
# Simple Task Interactive Example
|
||||
|
||||
This example demonstrates the MCP Tasks message queue pattern with interactive server-to-client requests (elicitation and sampling).
|
||||
|
||||
## Overview
|
||||
|
||||
The example consists of two components:
|
||||
|
||||
1. **Server** (`simpleTaskInteractive.ts`) - Exposes two task-based tools that require client interaction:
|
||||
- `confirm_delete` - Uses elicitation to ask the user for confirmation before "deleting" a file
|
||||
- `write_haiku` - Uses sampling to request an LLM to generate a haiku on a topic
|
||||
|
||||
2. **Client** (`simpleTaskInteractiveClient.ts`) - Connects to the server and handles:
|
||||
- Elicitation requests with simple y/n terminal prompts
|
||||
- Sampling requests with a mock haiku generator
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Task-Based Execution
|
||||
|
||||
Both tools use `execution.taskSupport: 'required'`, meaning they follow the "call-now, fetch-later" pattern:
|
||||
|
||||
1. Client calls tool with `task: { ttl: 60000 }` parameter
|
||||
2. Server creates a task and returns `CreateTaskResult` immediately
|
||||
3. Client polls via `tasks/result` to get the final result
|
||||
4. Server sends elicitation/sampling requests through the task message queue
|
||||
5. Client handles requests and returns responses
|
||||
6. Server completes the task with the final result
|
||||
|
||||
### Message Queue Pattern
|
||||
|
||||
When a tool needs to interact with the client (elicitation or sampling), it:
|
||||
|
||||
1. Updates task status to `input_required`
|
||||
2. Enqueues the request in the task message queue
|
||||
3. Waits for the response via a Resolver
|
||||
4. Updates task status back to `working`
|
||||
5. Continues processing
|
||||
|
||||
The `TaskResultHandler` dequeues messages when the client calls `tasks/result` and routes responses back to waiting Resolvers.
|
||||
|
||||
## Running the Example
|
||||
|
||||
### Start the Server
|
||||
|
||||
```bash
|
||||
# From anywhere in the SDK
|
||||
pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleTaskInteractive.ts
|
||||
|
||||
# Or with a custom port
|
||||
PORT=9000 pnpm --filter @modelcontextprotocol/examples-server exec tsx src/simpleTaskInteractive.ts
|
||||
```
|
||||
|
||||
Or, from within the `examples/server` package:
|
||||
|
||||
```bash
|
||||
cd examples/server
|
||||
pnpm tsx src/simpleTaskInteractive.ts
|
||||
|
||||
# Or with a custom port
|
||||
PORT=9000 pnpm tsx src/simpleTaskInteractive.ts
|
||||
```
|
||||
|
||||
The server will start on http://localhost:8000/mcp (or your custom port).
|
||||
|
||||
### Run the Client
|
||||
|
||||
```bash
|
||||
# From anywhere in the SDK
|
||||
pnpm --filter @modelcontextprotocol/examples-client exec tsx src/simpleTaskInteractiveClient.ts
|
||||
|
||||
# Or connect to a different server
|
||||
pnpm --filter @modelcontextprotocol/examples-client exec tsx src/simpleTaskInteractiveClient.ts --url http://localhost:9000/mcp
|
||||
```
|
||||
|
||||
Or, from within the `examples/client` package:
|
||||
|
||||
```bash
|
||||
cd examples/client
|
||||
pnpm tsx src/simpleTaskInteractiveClient.ts
|
||||
|
||||
# Or connect to a different server
|
||||
pnpm tsx src/simpleTaskInteractiveClient.ts --url http://localhost:9000/mcp
|
||||
```
|
||||
|
||||
## Expected Output
|
||||
|
||||
### Server Output
|
||||
|
||||
```
|
||||
Starting server on http://localhost:8000/mcp
|
||||
|
||||
Available tools:
|
||||
- confirm_delete: Demonstrates elicitation (asks user y/n)
|
||||
- write_haiku: Demonstrates sampling (requests LLM completion)
|
||||
|
||||
[Server] confirm_delete called, task created: task-abc123
|
||||
[Server] confirm_delete: asking about 'important.txt'
|
||||
[Server] Sending elicitation request to client...
|
||||
[Server] tasks/result called for task task-abc123
|
||||
[Server] Delivering queued request message for task task-abc123
|
||||
[Server] Received elicitation response: action=accept, content={"confirm":true}
|
||||
[Server] Completing task with result: Deleted 'important.txt'
|
||||
|
||||
[Server] write_haiku called, task created: task-def456
|
||||
[Server] write_haiku: topic 'autumn leaves'
|
||||
[Server] Sending sampling request to client...
|
||||
[Server] tasks/result called for task task-def456
|
||||
[Server] Delivering queued request message for task task-def456
|
||||
[Server] Received sampling response: Cherry blossoms fall...
|
||||
[Server] Completing task with haiku
|
||||
```
|
||||
|
||||
### Client Output
|
||||
|
||||
```
|
||||
Simple Task Interactive Client
|
||||
==============================
|
||||
Connecting to http://localhost:8000/mcp...
|
||||
Connected!
|
||||
|
||||
Available tools: confirm_delete, write_haiku
|
||||
|
||||
--- Demo 1: Elicitation ---
|
||||
Calling confirm_delete tool...
|
||||
Task created: task-abc123
|
||||
Task status: working
|
||||
|
||||
[Elicitation] Server asks: Are you sure you want to delete 'important.txt'?
|
||||
Your response (y/n): y
|
||||
[Elicitation] Responding with: confirm=true
|
||||
Task status: input_required
|
||||
Task status: completed
|
||||
Result: Deleted 'important.txt'
|
||||
|
||||
--- Demo 2: Sampling ---
|
||||
Calling write_haiku tool...
|
||||
Task created: task-def456
|
||||
Task status: working
|
||||
|
||||
[Sampling] Server requests LLM completion for: Write a haiku about autumn leaves
|
||||
[Sampling] Responding with haiku
|
||||
Task status: input_required
|
||||
Task status: completed
|
||||
Result:
|
||||
Haiku:
|
||||
Cherry blossoms fall
|
||||
Softly on the quiet pond
|
||||
Spring whispers goodbye
|
||||
|
||||
Demo complete. Closing connection...
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Server Components
|
||||
|
||||
- **Resolver**: Promise-like class for passing results between async operations
|
||||
- **TaskMessageQueueWithResolvers**: Extended message queue that tracks pending requests with their Resolvers
|
||||
- **TaskStoreWithNotifications**: Extended task store with notification support for status changes
|
||||
- **TaskResultHandler**: Handles `tasks/result` requests by dequeuing messages and routing responses
|
||||
- **TaskSession**: Wraps the server to enqueue requests during task execution
|
||||
|
||||
### Client Capabilities
|
||||
|
||||
The client declares these capabilities during initialization:
|
||||
|
||||
```typescript
|
||||
capabilities: {
|
||||
elicitation: { form: {} },
|
||||
sampling: {}
|
||||
}
|
||||
```
|
||||
|
||||
This tells the server that the client can handle both form-based elicitation and sampling requests.
|
||||
|
||||
## Related Files
|
||||
|
||||
- `packages/core/src/experimental/tasks/interfaces.ts` - Core task interfaces (TaskStore, TaskMessageQueue)
|
||||
- `packages/core/src/experimental/tasks/stores/in-memory.ts` - In-memory task store implementation
|
||||
- `packages/core/src/types/types.ts` - Task-related types (Task, CreateTaskResult, GetTaskRequestSchema, etc.)
|
||||
|
|
@ -5,13 +5,12 @@ import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBeare
|
|||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import type {
|
||||
CallToolResult,
|
||||
ElicitResult,
|
||||
GetPromptResult,
|
||||
PrimitiveSchemaDefinition,
|
||||
ReadResourceResult,
|
||||
ResourceLink
|
||||
} from '@modelcontextprotocol/server';
|
||||
import { InMemoryTaskMessageQueue, InMemoryTaskStore, isInitializeRequest, McpServer } from '@modelcontextprotocol/server';
|
||||
import { isInitializeRequest, McpServer } from '@modelcontextprotocol/server';
|
||||
import cors from 'cors';
|
||||
import type { Request, Response } from 'express';
|
||||
import * as z from 'zod/v4';
|
||||
|
|
@ -22,9 +21,6 @@ import { InMemoryEventStore } from './inMemoryEventStore.js';
|
|||
const useOAuth = process.argv.includes('--oauth');
|
||||
const dangerousLoggingEnabled = process.argv.includes('--dangerous-logging-enabled');
|
||||
|
||||
// Create shared task store for demonstration
|
||||
const taskStore = new InMemoryTaskStore();
|
||||
|
||||
// Create an MCP server with implementation details
|
||||
const getServer = () => {
|
||||
const server = new McpServer(
|
||||
|
|
@ -36,12 +32,7 @@ const getServer = () => {
|
|||
},
|
||||
{
|
||||
capabilities: {
|
||||
logging: {},
|
||||
tasks: {
|
||||
requests: { tools: { call: {} } },
|
||||
taskStore,
|
||||
taskMessageQueue: new InMemoryTaskMessageQueue()
|
||||
}
|
||||
logging: {}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
@ -439,160 +430,6 @@ const getServer = () => {
|
|||
}
|
||||
);
|
||||
|
||||
// Register a long-running tool that demonstrates task execution
|
||||
// Using the experimental tasks API - WARNING: may change without notice
|
||||
server.experimental.tasks.registerToolTask(
|
||||
'delay',
|
||||
{
|
||||
title: 'Delay',
|
||||
description: 'A simple tool that delays for a specified duration, useful for testing task execution',
|
||||
inputSchema: z.object({
|
||||
duration: z.number().describe('Duration in milliseconds').default(5000)
|
||||
})
|
||||
},
|
||||
{
|
||||
async createTask({ duration }, ctx) {
|
||||
// Create the task
|
||||
const task = await ctx.task.store.createTask({
|
||||
ttl: ctx.task.requestedTtl
|
||||
});
|
||||
|
||||
// Simulate out-of-band work
|
||||
(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, duration));
|
||||
await ctx.task.store.storeTaskResult(task.taskId, 'completed', {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Completed ${duration}ms delay`
|
||||
}
|
||||
]
|
||||
});
|
||||
})();
|
||||
|
||||
// Return CreateTaskResult with the created task
|
||||
return {
|
||||
task
|
||||
};
|
||||
},
|
||||
async getTask(_args, ctx) {
|
||||
return await ctx.task.store.getTask(ctx.task.id);
|
||||
},
|
||||
async getTaskResult(_args, ctx) {
|
||||
const result = await ctx.task.store.getTaskResult(ctx.task.id);
|
||||
return result as CallToolResult;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Register a tool that demonstrates bidirectional task support:
|
||||
// Server creates a task, then elicits input from client using elicitInputStream
|
||||
// Using the experimental tasks API - WARNING: may change without notice
|
||||
server.experimental.tasks.registerToolTask(
|
||||
'collect-user-info-task',
|
||||
{
|
||||
title: 'Collect Info with Task',
|
||||
description: 'Collects user info via elicitation with task support using elicitInputStream',
|
||||
inputSchema: z.object({
|
||||
infoType: z.enum(['contact', 'preferences']).describe('Type of information to collect').default('contact')
|
||||
})
|
||||
},
|
||||
{
|
||||
async createTask({ infoType }, ctx) {
|
||||
// Create the server-side task
|
||||
const task = await ctx.task.store.createTask({
|
||||
ttl: ctx.task.requestedTtl
|
||||
});
|
||||
|
||||
// Perform async work that makes a nested elicitation request using elicitInputStream
|
||||
(async () => {
|
||||
try {
|
||||
const message = infoType === 'contact' ? 'Please provide your contact information' : 'Please set your preferences';
|
||||
|
||||
// Define schemas with proper typing for PrimitiveSchemaDefinition
|
||||
const contactSchema: {
|
||||
type: 'object';
|
||||
properties: Record<string, PrimitiveSchemaDefinition>;
|
||||
required: string[];
|
||||
} = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', title: 'Full Name', description: 'Your full name' },
|
||||
email: { type: 'string', title: 'Email', description: 'Your email address' }
|
||||
},
|
||||
required: ['name', 'email']
|
||||
};
|
||||
|
||||
const preferencesSchema: {
|
||||
type: 'object';
|
||||
properties: Record<string, PrimitiveSchemaDefinition>;
|
||||
required: string[];
|
||||
} = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
theme: { type: 'string', title: 'Theme', enum: ['light', 'dark', 'auto'] },
|
||||
notifications: { type: 'boolean', title: 'Enable Notifications', default: true }
|
||||
},
|
||||
required: ['theme']
|
||||
};
|
||||
|
||||
const requestedSchema = infoType === 'contact' ? contactSchema : preferencesSchema;
|
||||
|
||||
// Use elicitInputStream to elicit input from client
|
||||
// This demonstrates the streaming elicitation API
|
||||
// Access via server.server to get the underlying Server instance
|
||||
const stream = server.server.experimental.tasks.elicitInputStream({
|
||||
mode: 'form',
|
||||
message,
|
||||
requestedSchema
|
||||
});
|
||||
|
||||
let elicitResult: ElicitResult | undefined;
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === 'result') {
|
||||
elicitResult = msg.result as ElicitResult;
|
||||
} else if (msg.type === 'error') {
|
||||
throw msg.error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!elicitResult) {
|
||||
throw new Error('No result received from elicitation');
|
||||
}
|
||||
|
||||
let resultText: string;
|
||||
if (elicitResult.action === 'accept') {
|
||||
resultText = `Collected ${infoType} info: ${JSON.stringify(elicitResult.content, null, 2)}`;
|
||||
} else if (elicitResult.action === 'decline') {
|
||||
resultText = `User declined to provide ${infoType} information`;
|
||||
} else {
|
||||
resultText = 'User cancelled the request';
|
||||
}
|
||||
|
||||
await taskStore.storeTaskResult(task.taskId, 'completed', {
|
||||
content: [{ type: 'text', text: resultText }]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in collect-user-info-task:', error);
|
||||
await taskStore.storeTaskResult(task.taskId, 'failed', {
|
||||
content: [{ type: 'text', text: `Error: ${error}` }],
|
||||
isError: true
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return { task };
|
||||
},
|
||||
async getTask(_args, ctx) {
|
||||
return await ctx.task.store.getTask(ctx.task.id);
|
||||
},
|
||||
async getTaskResult(_args, ctx) {
|
||||
const result = await ctx.task.store.getTaskResult(ctx.task.id);
|
||||
return result as CallToolResult;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,758 +0,0 @@
|
|||
/**
|
||||
* Simple interactive task server demonstrating elicitation and sampling.
|
||||
*
|
||||
* This server demonstrates the task message queue pattern from the MCP Tasks spec:
|
||||
* - confirm_delete: Uses elicitation to ask the user for confirmation
|
||||
* - write_haiku: Uses sampling to request an LLM to generate content
|
||||
*
|
||||
* Both tools use the "call-now, fetch-later" pattern where the initial call
|
||||
* creates a task, and the result is fetched via tasks/result endpoint.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { createMcpExpressApp } from '@modelcontextprotocol/express';
|
||||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import type {
|
||||
CallToolResult,
|
||||
CreateMessageRequest,
|
||||
CreateMessageResult,
|
||||
CreateTaskOptions,
|
||||
CreateTaskResult,
|
||||
ElicitRequestFormParams,
|
||||
ElicitResult,
|
||||
GetTaskPayloadResult,
|
||||
GetTaskResult,
|
||||
JSONRPCRequest,
|
||||
PrimitiveSchemaDefinition,
|
||||
QueuedMessage,
|
||||
QueuedRequest,
|
||||
RequestId,
|
||||
Result,
|
||||
SamplingMessage,
|
||||
Task,
|
||||
TaskMessageQueue,
|
||||
TextContent,
|
||||
Tool
|
||||
} from '@modelcontextprotocol/server';
|
||||
import { InMemoryTaskStore, isTerminal, RELATED_TASK_META_KEY, Server } from '@modelcontextprotocol/server';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
// ============================================================================
|
||||
// Resolver - Promise-like for passing results between async operations
|
||||
// ============================================================================
|
||||
|
||||
class Resolver<T> {
|
||||
private _resolve!: (value: T) => void;
|
||||
private _reject!: (error: Error) => void;
|
||||
private _promise: Promise<T>;
|
||||
private _done = false;
|
||||
|
||||
constructor() {
|
||||
this._promise = new Promise<T>((resolve, reject) => {
|
||||
this._resolve = resolve;
|
||||
this._reject = reject;
|
||||
});
|
||||
}
|
||||
|
||||
setResult(value: T): void {
|
||||
if (this._done) return;
|
||||
this._done = true;
|
||||
this._resolve(value);
|
||||
}
|
||||
|
||||
setException(error: Error): void {
|
||||
if (this._done) return;
|
||||
this._done = true;
|
||||
this._reject(error);
|
||||
}
|
||||
|
||||
wait(): Promise<T> {
|
||||
return this._promise;
|
||||
}
|
||||
|
||||
done(): boolean {
|
||||
return this._done;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Extended message queue with resolver support and wait functionality
|
||||
// ============================================================================
|
||||
|
||||
interface QueuedRequestWithResolver extends QueuedRequest {
|
||||
resolver?: Resolver<Record<string, unknown>>;
|
||||
originalRequestId?: RequestId;
|
||||
}
|
||||
|
||||
type QueuedMessageWithResolver = QueuedRequestWithResolver | QueuedMessage;
|
||||
|
||||
class TaskMessageQueueWithResolvers implements TaskMessageQueue {
|
||||
private queues = new Map<string, QueuedMessageWithResolver[]>();
|
||||
private waitResolvers = new Map<string, (() => void)[]>();
|
||||
|
||||
private getQueue(taskId: string): QueuedMessageWithResolver[] {
|
||||
let queue = this.queues.get(taskId);
|
||||
if (!queue) {
|
||||
queue = [];
|
||||
this.queues.set(taskId, queue);
|
||||
}
|
||||
return queue;
|
||||
}
|
||||
|
||||
async enqueue(taskId: string, message: QueuedMessage, _sessionId?: string, maxSize?: number): Promise<void> {
|
||||
const queue = this.getQueue(taskId);
|
||||
if (maxSize !== undefined && queue.length >= maxSize) {
|
||||
throw new Error(`Task message queue overflow: queue size (${queue.length}) exceeds maximum (${maxSize})`);
|
||||
}
|
||||
queue.push(message);
|
||||
// Notify any waiters
|
||||
this.notifyWaiters(taskId);
|
||||
}
|
||||
|
||||
async enqueueWithResolver(
|
||||
taskId: string,
|
||||
message: JSONRPCRequest,
|
||||
resolver: Resolver<Record<string, unknown>>,
|
||||
originalRequestId: RequestId
|
||||
): Promise<void> {
|
||||
const queue = this.getQueue(taskId);
|
||||
const queuedMessage: QueuedRequestWithResolver = {
|
||||
type: 'request',
|
||||
message,
|
||||
timestamp: Date.now(),
|
||||
resolver,
|
||||
originalRequestId
|
||||
};
|
||||
queue.push(queuedMessage);
|
||||
this.notifyWaiters(taskId);
|
||||
}
|
||||
|
||||
async dequeue(taskId: string, _sessionId?: string): Promise<QueuedMessageWithResolver | undefined> {
|
||||
const queue = this.getQueue(taskId);
|
||||
return queue.shift();
|
||||
}
|
||||
|
||||
async dequeueAll(taskId: string, _sessionId?: string): Promise<QueuedMessageWithResolver[]> {
|
||||
const queue = this.queues.get(taskId) ?? [];
|
||||
this.queues.delete(taskId);
|
||||
return queue;
|
||||
}
|
||||
|
||||
async waitForMessage(taskId: string): Promise<void> {
|
||||
// Check if there are already messages
|
||||
const queue = this.getQueue(taskId);
|
||||
if (queue.length > 0) return;
|
||||
|
||||
// Wait for a message to be added
|
||||
return new Promise<void>(resolve => {
|
||||
let waiters = this.waitResolvers.get(taskId);
|
||||
if (!waiters) {
|
||||
waiters = [];
|
||||
this.waitResolvers.set(taskId, waiters);
|
||||
}
|
||||
waiters.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
private notifyWaiters(taskId: string): void {
|
||||
const waiters = this.waitResolvers.get(taskId);
|
||||
if (waiters) {
|
||||
this.waitResolvers.delete(taskId);
|
||||
for (const resolve of waiters) {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.queues.clear();
|
||||
this.waitResolvers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Extended task store with wait functionality
|
||||
// ============================================================================
|
||||
|
||||
class TaskStoreWithNotifications extends InMemoryTaskStore {
|
||||
private updateResolvers = new Map<string, (() => void)[]>();
|
||||
|
||||
override async updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string, sessionId?: string): Promise<void> {
|
||||
await super.updateTaskStatus(taskId, status, statusMessage, sessionId);
|
||||
this.notifyUpdate(taskId);
|
||||
}
|
||||
|
||||
override async storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result, sessionId?: string): Promise<void> {
|
||||
await super.storeTaskResult(taskId, status, result, sessionId);
|
||||
this.notifyUpdate(taskId);
|
||||
}
|
||||
|
||||
async waitForUpdate(taskId: string): Promise<void> {
|
||||
return new Promise<void>(resolve => {
|
||||
let waiters = this.updateResolvers.get(taskId);
|
||||
if (!waiters) {
|
||||
waiters = [];
|
||||
this.updateResolvers.set(taskId, waiters);
|
||||
}
|
||||
waiters.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
private notifyUpdate(taskId: string): void {
|
||||
const waiters = this.updateResolvers.get(taskId);
|
||||
if (waiters) {
|
||||
this.updateResolvers.delete(taskId);
|
||||
for (const resolve of waiters) {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Task Result Handler - delivers queued messages and routes responses
|
||||
// ============================================================================
|
||||
|
||||
class TaskResultHandler {
|
||||
private pendingRequests = new Map<RequestId, Resolver<Record<string, unknown>>>();
|
||||
|
||||
constructor(
|
||||
private store: TaskStoreWithNotifications,
|
||||
private queue: TaskMessageQueueWithResolvers
|
||||
) {}
|
||||
|
||||
async handle(taskId: string, server: Server, _sessionId: string): Promise<Result> {
|
||||
while (true) {
|
||||
// Get fresh task state
|
||||
const task = await this.store.getTask(taskId);
|
||||
if (!task) {
|
||||
throw new Error(`Task not found: ${taskId}`);
|
||||
}
|
||||
|
||||
// Dequeue and send all pending messages
|
||||
await this.deliverQueuedMessages(taskId, server, _sessionId);
|
||||
|
||||
// If task is terminal, return result
|
||||
if (isTerminal(task.status)) {
|
||||
const result = await this.store.getTaskResult(taskId);
|
||||
// Add related-task metadata per spec
|
||||
return {
|
||||
...result,
|
||||
_meta: {
|
||||
...result._meta,
|
||||
[RELATED_TASK_META_KEY]: { taskId }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Wait for task update or new message
|
||||
await this.waitForUpdate(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
private async deliverQueuedMessages(taskId: string, server: Server, _sessionId: string): Promise<void> {
|
||||
while (true) {
|
||||
const message = await this.queue.dequeue(taskId);
|
||||
if (!message) break;
|
||||
|
||||
console.log(`[Server] Delivering queued ${message.type} message for task ${taskId}`);
|
||||
|
||||
if (message.type === 'request') {
|
||||
const reqMessage = message as QueuedRequestWithResolver;
|
||||
// Send the request via the server
|
||||
// Store the resolver so we can route the response back
|
||||
if (reqMessage.resolver && reqMessage.originalRequestId) {
|
||||
this.pendingRequests.set(reqMessage.originalRequestId, reqMessage.resolver);
|
||||
}
|
||||
|
||||
// Send the message - for elicitation/sampling, we use the server's methods
|
||||
// But since we're in tasks/result context, we need to send via transport
|
||||
// This is simplified - in production you'd use proper message routing
|
||||
try {
|
||||
const request = reqMessage.message;
|
||||
let response: ElicitResult | CreateMessageResult;
|
||||
|
||||
if (request.method === 'elicitation/create') {
|
||||
// Send elicitation request to client
|
||||
const params = request.params as ElicitRequestFormParams;
|
||||
response = await server.elicitInput(params);
|
||||
} else if (request.method === 'sampling/createMessage') {
|
||||
// Send sampling request to client
|
||||
const params = request.params as CreateMessageRequest['params'];
|
||||
response = await server.createMessage(params);
|
||||
} else {
|
||||
throw new Error(`Unknown request method: ${request.method}`);
|
||||
}
|
||||
|
||||
// Route response back to resolver
|
||||
if (reqMessage.resolver) {
|
||||
reqMessage.resolver.setResult(response as unknown as Record<string, unknown>);
|
||||
}
|
||||
} catch (error) {
|
||||
if (reqMessage.resolver) {
|
||||
reqMessage.resolver.setException(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// For notifications, we'd send them too but this example focuses on requests
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForUpdate(taskId: string): Promise<void> {
|
||||
// Race between store update and queue message
|
||||
await Promise.race([this.store.waitForUpdate(taskId), this.queue.waitForMessage(taskId)]);
|
||||
}
|
||||
|
||||
routeResponse(requestId: RequestId, response: Record<string, unknown>): boolean {
|
||||
const resolver = this.pendingRequests.get(requestId);
|
||||
if (resolver && !resolver.done()) {
|
||||
this.pendingRequests.delete(requestId);
|
||||
resolver.setResult(response);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
routeError(requestId: RequestId, error: Error): boolean {
|
||||
const resolver = this.pendingRequests.get(requestId);
|
||||
if (resolver && !resolver.done()) {
|
||||
this.pendingRequests.delete(requestId);
|
||||
resolver.setException(error);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Task Session - wraps server to enqueue requests during task execution
|
||||
// ============================================================================
|
||||
|
||||
class TaskSession {
|
||||
private requestCounter = 0;
|
||||
|
||||
constructor(
|
||||
private server: Server,
|
||||
private taskId: string,
|
||||
private store: TaskStoreWithNotifications,
|
||||
private queue: TaskMessageQueueWithResolvers
|
||||
) {}
|
||||
|
||||
private nextRequestId(): string {
|
||||
return `task-${this.taskId}-${++this.requestCounter}`;
|
||||
}
|
||||
|
||||
async elicit(
|
||||
message: string,
|
||||
requestedSchema: {
|
||||
type: 'object';
|
||||
properties: Record<string, PrimitiveSchemaDefinition>;
|
||||
required?: string[];
|
||||
}
|
||||
): Promise<{ action: string; content?: Record<string, unknown> }> {
|
||||
// Update task status to input_required
|
||||
await this.store.updateTaskStatus(this.taskId, 'input_required');
|
||||
|
||||
const requestId = this.nextRequestId();
|
||||
|
||||
// Build the elicitation request with related-task metadata
|
||||
const params: ElicitRequestFormParams = {
|
||||
message,
|
||||
requestedSchema,
|
||||
mode: 'form',
|
||||
_meta: {
|
||||
[RELATED_TASK_META_KEY]: { taskId: this.taskId }
|
||||
}
|
||||
};
|
||||
|
||||
const jsonrpcRequest: JSONRPCRequest = {
|
||||
jsonrpc: '2.0',
|
||||
id: requestId,
|
||||
method: 'elicitation/create',
|
||||
params
|
||||
};
|
||||
|
||||
// Create resolver to wait for response
|
||||
const resolver = new Resolver<Record<string, unknown>>();
|
||||
|
||||
// Enqueue the request
|
||||
await this.queue.enqueueWithResolver(this.taskId, jsonrpcRequest, resolver, requestId);
|
||||
|
||||
try {
|
||||
// Wait for response
|
||||
const response = await resolver.wait();
|
||||
|
||||
// Update status back to working
|
||||
await this.store.updateTaskStatus(this.taskId, 'working');
|
||||
|
||||
return response as { action: string; content?: Record<string, unknown> };
|
||||
} catch (error) {
|
||||
await this.store.updateTaskStatus(this.taskId, 'working');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createMessage(
|
||||
messages: SamplingMessage[],
|
||||
maxTokens: number
|
||||
): Promise<{ role: string; content: TextContent | { type: string } }> {
|
||||
// Update task status to input_required
|
||||
await this.store.updateTaskStatus(this.taskId, 'input_required');
|
||||
|
||||
const requestId = this.nextRequestId();
|
||||
|
||||
// Build the sampling request with related-task metadata
|
||||
const params = {
|
||||
messages,
|
||||
maxTokens,
|
||||
_meta: {
|
||||
[RELATED_TASK_META_KEY]: { taskId: this.taskId }
|
||||
}
|
||||
};
|
||||
|
||||
const jsonrpcRequest: JSONRPCRequest = {
|
||||
jsonrpc: '2.0',
|
||||
id: requestId,
|
||||
method: 'sampling/createMessage',
|
||||
params
|
||||
};
|
||||
|
||||
// Create resolver to wait for response
|
||||
const resolver = new Resolver<Record<string, unknown>>();
|
||||
|
||||
// Enqueue the request
|
||||
await this.queue.enqueueWithResolver(this.taskId, jsonrpcRequest, resolver, requestId);
|
||||
|
||||
try {
|
||||
// Wait for response
|
||||
const response = await resolver.wait();
|
||||
|
||||
// Update status back to working
|
||||
await this.store.updateTaskStatus(this.taskId, 'working');
|
||||
|
||||
return response as { role: string; content: TextContent | { type: string } };
|
||||
} catch (error) {
|
||||
await this.store.updateTaskStatus(this.taskId, 'working');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Server Setup
|
||||
// ============================================================================
|
||||
|
||||
const PORT = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 8000;
|
||||
|
||||
// Create shared stores
|
||||
const taskStore = new TaskStoreWithNotifications();
|
||||
const messageQueue = new TaskMessageQueueWithResolvers();
|
||||
const taskResultHandler = new TaskResultHandler(taskStore, messageQueue);
|
||||
|
||||
// Track active task executions
|
||||
const activeTaskExecutions = new Map<
|
||||
string,
|
||||
{
|
||||
promise: Promise<void>;
|
||||
server: Server;
|
||||
sessionId: string;
|
||||
}
|
||||
>();
|
||||
|
||||
// Create the server
|
||||
const createServer = (): Server => {
|
||||
const server = new Server(
|
||||
{ name: 'simple-task-interactive', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
tasks: {
|
||||
requests: {
|
||||
tools: { call: {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Register tools
|
||||
server.setRequestHandler('tools/list', async (): Promise<{ tools: Tool[] }> => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: 'confirm_delete',
|
||||
description: 'Asks for confirmation before deleting (demonstrates elicitation)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
filename: { type: 'string' }
|
||||
}
|
||||
},
|
||||
execution: { taskSupport: 'required' }
|
||||
},
|
||||
{
|
||||
name: 'write_haiku',
|
||||
description: 'Asks LLM to write a haiku (demonstrates sampling)',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
topic: { type: 'string' }
|
||||
}
|
||||
},
|
||||
execution: { taskSupport: 'required' }
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
// Handle tool calls
|
||||
server.setRequestHandler('tools/call', async (request, ctx): Promise<CallToolResult | CreateTaskResult> => {
|
||||
const { name, arguments: args } = request.params;
|
||||
const taskParams = (request.params._meta?.task || request.params.task) as { ttl?: number; pollInterval?: number } | undefined;
|
||||
|
||||
// Validate task mode - these tools require tasks
|
||||
if (!taskParams) {
|
||||
throw new Error(`Tool ${name} requires task mode`);
|
||||
}
|
||||
|
||||
// Create task
|
||||
const taskOptions: CreateTaskOptions = {
|
||||
ttl: taskParams.ttl,
|
||||
pollInterval: taskParams.pollInterval ?? 1000
|
||||
};
|
||||
|
||||
const task = await taskStore.createTask(taskOptions, ctx.mcpReq.id, request, ctx.sessionId);
|
||||
|
||||
console.log(`\n[Server] ${name} called, task created: ${task.taskId}`);
|
||||
|
||||
// Start background task execution
|
||||
const taskExecution = (async () => {
|
||||
try {
|
||||
const taskSession = new TaskSession(server, task.taskId, taskStore, messageQueue);
|
||||
|
||||
if (name === 'confirm_delete') {
|
||||
const filename = args?.filename ?? 'unknown.txt';
|
||||
console.log(`[Server] confirm_delete: asking about '${filename}'`);
|
||||
|
||||
console.log('[Server] Sending elicitation request to client...');
|
||||
const result = await taskSession.elicit(`Are you sure you want to delete '${filename}'?`, {
|
||||
type: 'object',
|
||||
properties: {
|
||||
confirm: { type: 'boolean' }
|
||||
},
|
||||
required: ['confirm']
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[Server] Received elicitation response: action=${result.action}, content=${JSON.stringify(result.content)}`
|
||||
);
|
||||
|
||||
let text: string;
|
||||
if (result.action === 'accept' && result.content) {
|
||||
const confirmed = result.content.confirm;
|
||||
text = confirmed ? `Deleted '${filename}'` : 'Deletion cancelled';
|
||||
} else {
|
||||
text = 'Deletion cancelled';
|
||||
}
|
||||
|
||||
console.log(`[Server] Completing task with result: ${text}`);
|
||||
await taskStore.storeTaskResult(task.taskId, 'completed', {
|
||||
content: [{ type: 'text', text }]
|
||||
});
|
||||
} else if (name === 'write_haiku') {
|
||||
const topic = args?.topic ?? 'nature';
|
||||
console.log(`[Server] write_haiku: topic '${topic}'`);
|
||||
|
||||
console.log('[Server] Sending sampling request to client...');
|
||||
const result = await taskSession.createMessage(
|
||||
[
|
||||
{
|
||||
role: 'user',
|
||||
content: { type: 'text', text: `Write a haiku about ${topic}` }
|
||||
}
|
||||
],
|
||||
50
|
||||
);
|
||||
|
||||
let haiku = 'No response';
|
||||
if (result.content && 'text' in result.content) {
|
||||
haiku = (result.content as TextContent).text;
|
||||
}
|
||||
|
||||
console.log(`[Server] Received sampling response: ${haiku.slice(0, 50)}...`);
|
||||
console.log('[Server] Completing task with haiku');
|
||||
await taskStore.storeTaskResult(task.taskId, 'completed', {
|
||||
content: [{ type: 'text', text: `Haiku:\n${haiku}` }]
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Server] Task ${task.taskId} failed:`, error);
|
||||
await taskStore.storeTaskResult(task.taskId, 'failed', {
|
||||
content: [{ type: 'text', text: `Error: ${error}` }],
|
||||
isError: true
|
||||
});
|
||||
} finally {
|
||||
activeTaskExecutions.delete(task.taskId);
|
||||
}
|
||||
})();
|
||||
|
||||
activeTaskExecutions.set(task.taskId, {
|
||||
promise: taskExecution,
|
||||
server,
|
||||
sessionId: ctx.sessionId ?? ''
|
||||
});
|
||||
|
||||
return { task };
|
||||
});
|
||||
|
||||
// Handle tasks/get
|
||||
server.setRequestHandler('tasks/get', async (request): Promise<GetTaskResult> => {
|
||||
const { taskId } = request.params;
|
||||
const task = await taskStore.getTask(taskId);
|
||||
if (!task) {
|
||||
throw new Error(`Task ${taskId} not found`);
|
||||
}
|
||||
return task;
|
||||
});
|
||||
|
||||
// Handle tasks/result
|
||||
server.setRequestHandler('tasks/result', async (request, ctx): Promise<GetTaskPayloadResult> => {
|
||||
const { taskId } = request.params;
|
||||
console.log(`[Server] tasks/result called for task ${taskId}`);
|
||||
return taskResultHandler.handle(taskId, server, ctx.sessionId ?? '');
|
||||
});
|
||||
|
||||
return server;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Express App Setup
|
||||
// ============================================================================
|
||||
|
||||
const app = createMcpExpressApp();
|
||||
|
||||
// Map to store transports by session ID
|
||||
const transports: { [sessionId: string]: NodeStreamableHTTPServerTransport } = {};
|
||||
|
||||
// Helper to check if request is initialize
|
||||
const isInitializeRequest = (body: unknown): boolean => {
|
||||
return typeof body === 'object' && body !== null && 'method' in body && (body as { method: string }).method === 'initialize';
|
||||
};
|
||||
|
||||
// MCP POST endpoint
|
||||
app.post('/mcp', async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
|
||||
try {
|
||||
let transport: NodeStreamableHTTPServerTransport;
|
||||
|
||||
if (sessionId && transports[sessionId]) {
|
||||
transport = transports[sessionId];
|
||||
} else if (!sessionId && isInitializeRequest(req.body)) {
|
||||
transport = new NodeStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: sid => {
|
||||
console.log(`Session initialized: ${sid}`);
|
||||
transports[sid] = transport;
|
||||
}
|
||||
});
|
||||
|
||||
transport.onclose = () => {
|
||||
const sid = transport.sessionId;
|
||||
if (sid && transports[sid]) {
|
||||
console.log(`Transport closed for session ${sid}`);
|
||||
delete transports[sid];
|
||||
}
|
||||
};
|
||||
|
||||
const server = createServer();
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
return;
|
||||
} else if (sessionId) {
|
||||
res.status(404).json({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32_001, message: 'Session not found' },
|
||||
id: null
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
res.status(400).json({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32_000, message: 'Bad Request: Session ID required' },
|
||||
id: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (error) {
|
||||
console.error('Error handling MCP request:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32_603, message: 'Internal server error' },
|
||||
id: null
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle GET requests for SSE streams
|
||||
app.get('/mcp', async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (!sessionId) {
|
||||
res.status(400).send('Missing session ID');
|
||||
return;
|
||||
}
|
||||
if (!transports[sessionId]) {
|
||||
res.status(404).send('Session not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const transport = transports[sessionId];
|
||||
await transport.handleRequest(req, res);
|
||||
});
|
||||
|
||||
// Handle DELETE requests for session termination
|
||||
app.delete('/mcp', async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (!sessionId) {
|
||||
res.status(400).send('Missing session ID');
|
||||
return;
|
||||
}
|
||||
if (!transports[sessionId]) {
|
||||
res.status(404).send('Session not found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Session termination request: ${sessionId}`);
|
||||
const transport = transports[sessionId];
|
||||
await transport.handleRequest(req, res);
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Starting server on http://localhost:${PORT}/mcp`);
|
||||
console.log('\nAvailable tools:');
|
||||
console.log(' - confirm_delete: Demonstrates elicitation (asks user y/n)');
|
||||
console.log(' - write_haiku: Demonstrates sampling (requests LLM completion)');
|
||||
});
|
||||
|
||||
// Handle shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\nShutting down server...');
|
||||
for (const sessionId of Object.keys(transports)) {
|
||||
try {
|
||||
await transports[sessionId]!.close();
|
||||
delete transports[sessionId];
|
||||
} catch (error) {
|
||||
console.error(`Error closing session ${sessionId}:`, error);
|
||||
}
|
||||
}
|
||||
taskStore.cleanup();
|
||||
messageQueue.cleanup();
|
||||
console.log('Server shutdown complete');
|
||||
process.exit(0);
|
||||
});
|
||||
|
|
@ -37,14 +37,14 @@ const getServer = () => {
|
|||
|
||||
// Register a long-running tool that demonstrates server-initiated disconnect
|
||||
server.registerTool(
|
||||
'long-task',
|
||||
'long-operation',
|
||||
{
|
||||
description: 'A long-running task that sends progress updates. Server will disconnect mid-task to demonstrate polling.'
|
||||
description: 'A long-running operation that sends progress updates. Server will disconnect mid-stream to demonstrate polling.'
|
||||
},
|
||||
async (ctx): Promise<CallToolResult> => {
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
console.log(`[${ctx.sessionId}] Starting long-task...`);
|
||||
console.log(`[${ctx.sessionId}] Starting long-operation...`);
|
||||
|
||||
// Send first progress notification
|
||||
await ctx.mcpReq.log('info', 'Progress: 25% - Starting work...');
|
||||
|
|
@ -70,13 +70,13 @@ const getServer = () => {
|
|||
await sleep(500);
|
||||
await ctx.mcpReq.log('info', 'Progress: 100% - Complete!');
|
||||
|
||||
console.log(`[${ctx.sessionId}] Task complete`);
|
||||
console.log(`[${ctx.sessionId}] Operation complete`);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Long task completed successfully!'
|
||||
text: 'Long operation completed successfully!'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -131,5 +131,5 @@ app.listen(PORT, () => {
|
|||
console.log('- retryInterval: 2000ms (client waits 2s before reconnecting)');
|
||||
console.log('- eventStore: InMemoryEventStore (events are persisted for replay)');
|
||||
console.log('');
|
||||
console.log('Try calling the "long-task" tool to see server-initiated disconnect in action.');
|
||||
console.log('Try calling the "long-operation" tool to see server-initiated disconnect in action.');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,24 +29,19 @@ import type {
|
|||
Result,
|
||||
ServerCapabilities,
|
||||
SubscribeRequest,
|
||||
TaskManagerOptions,
|
||||
Tool,
|
||||
Transport,
|
||||
UnsubscribeRequest
|
||||
} from '@modelcontextprotocol/core';
|
||||
import {
|
||||
assertClientRequestTaskCapability,
|
||||
assertToolsCallTaskCapability,
|
||||
CallToolResultSchema,
|
||||
CompleteResultSchema,
|
||||
CreateMessageRequestSchema,
|
||||
CreateMessageResultSchema,
|
||||
CreateMessageResultWithToolsSchema,
|
||||
CreateTaskResultSchema,
|
||||
ElicitRequestSchema,
|
||||
ElicitResultSchema,
|
||||
EmptyResultSchema,
|
||||
extractTaskManagerOptions,
|
||||
GetPromptResultSchema,
|
||||
InitializeResultSchema,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
|
|
@ -65,8 +60,6 @@ import {
|
|||
SdkErrorCode
|
||||
} from '@modelcontextprotocol/core';
|
||||
|
||||
import { ExperimentalClientTasks } from '../experimental/tasks/client.js';
|
||||
|
||||
/**
|
||||
* Elicitation default application helper. Applies defaults to the `data` based on the `schema`.
|
||||
*
|
||||
|
|
@ -141,19 +134,11 @@ export function getSupportedElicitationModes(capabilities: ClientCapabilities['e
|
|||
return { supportsFormMode, supportsUrlMode };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended tasks capability that includes runtime configuration (store, messageQueue).
|
||||
* The runtime-only fields are stripped before advertising capabilities to servers.
|
||||
*/
|
||||
export type ClientTasksCapabilityWithRuntime = NonNullable<ClientCapabilities['tasks']> & TaskManagerOptions;
|
||||
|
||||
export type ClientOptions = ProtocolOptions & {
|
||||
/**
|
||||
* Capabilities to advertise as being supported by this client.
|
||||
*/
|
||||
capabilities?: Omit<ClientCapabilities, 'tasks'> & {
|
||||
tasks?: ClientTasksCapabilityWithRuntime;
|
||||
};
|
||||
capabilities?: ClientCapabilities;
|
||||
|
||||
/**
|
||||
* JSON Schema validator for tool output validation.
|
||||
|
|
@ -230,9 +215,6 @@ export class Client extends Protocol<ClientContext> {
|
|||
private _instructions?: string;
|
||||
private _jsonSchemaValidator: jsonSchemaValidator;
|
||||
private _cachedToolOutputValidators: Map<string, JsonSchemaValidator<unknown>> = new Map();
|
||||
private _cachedKnownTaskTools: Set<string> = new Set();
|
||||
private _cachedRequiredTaskTools: Set<string> = new Set();
|
||||
private _experimental?: { tasks: ExperimentalClientTasks };
|
||||
private _listChangedDebounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
||||
private _pendingListChangedConfig?: ListChangedHandlers;
|
||||
private _enforceStrictCapabilities: boolean;
|
||||
|
|
@ -244,22 +226,11 @@ export class Client extends Protocol<ClientContext> {
|
|||
private _clientInfo: Implementation,
|
||||
options?: ClientOptions
|
||||
) {
|
||||
super({
|
||||
...options,
|
||||
tasks: extractTaskManagerOptions(options?.capabilities?.tasks)
|
||||
});
|
||||
super(options);
|
||||
this._capabilities = options?.capabilities ? { ...options.capabilities } : {};
|
||||
this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator();
|
||||
this._enforceStrictCapabilities = options?.enforceStrictCapabilities ?? false;
|
||||
|
||||
// Strip runtime-only fields from advertised capabilities
|
||||
if (options?.capabilities?.tasks) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { taskStore, taskMessageQueue, defaultTaskPollInterval, maxTaskQueueSize, ...wireCapabilities } =
|
||||
options.capabilities.tasks;
|
||||
this._capabilities.tasks = wireCapabilities;
|
||||
}
|
||||
|
||||
// Store list changed config for setup after connection (when we know server capabilities)
|
||||
if (options?.listChanged) {
|
||||
this._pendingListChangedConfig = options.listChanged;
|
||||
|
|
@ -299,22 +270,6 @@ export class Client extends Protocol<ClientContext> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Access experimental features.
|
||||
*
|
||||
* WARNING: These APIs are experimental and may change without notice.
|
||||
*
|
||||
* @experimental
|
||||
*/
|
||||
get experimental(): { tasks: ExperimentalClientTasks } {
|
||||
if (!this._experimental) {
|
||||
this._experimental = {
|
||||
tasks: new ExperimentalClientTasks(this)
|
||||
};
|
||||
}
|
||||
return this._experimental;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers new capabilities. This can only be called before connecting to a transport.
|
||||
*
|
||||
|
|
@ -360,20 +315,6 @@ export class Client extends Protocol<ClientContext> {
|
|||
|
||||
const result = await handler(request, ctx);
|
||||
|
||||
// When task creation is requested, validate and return CreateTaskResult
|
||||
if (params.task) {
|
||||
const taskValidationResult = parseSchema(CreateTaskResultSchema, result);
|
||||
if (!taskValidationResult.success) {
|
||||
const errorMessage =
|
||||
taskValidationResult.error instanceof Error
|
||||
? taskValidationResult.error.message
|
||||
: String(taskValidationResult.error);
|
||||
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`);
|
||||
}
|
||||
return taskValidationResult.data;
|
||||
}
|
||||
|
||||
// For non-task requests, validate against ElicitResultSchema
|
||||
const validationResult = parseSchema(ElicitResultSchema, result);
|
||||
if (!validationResult.success) {
|
||||
// Type guard: if success is false, error is guaranteed to exist
|
||||
|
|
@ -416,20 +357,6 @@ export class Client extends Protocol<ClientContext> {
|
|||
|
||||
const result = await handler(request, ctx);
|
||||
|
||||
// When task creation is requested, validate and return CreateTaskResult
|
||||
if (params.task) {
|
||||
const taskValidationResult = parseSchema(CreateTaskResultSchema, result);
|
||||
if (!taskValidationResult.success) {
|
||||
const errorMessage =
|
||||
taskValidationResult.error instanceof Error
|
||||
? taskValidationResult.error.message
|
||||
: String(taskValidationResult.error);
|
||||
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid task creation result: ${errorMessage}`);
|
||||
}
|
||||
return taskValidationResult.data;
|
||||
}
|
||||
|
||||
// For non-task requests, validate against appropriate schema based on tools presence
|
||||
const hasTools = params.tools || params.toolChoice;
|
||||
const resultSchema = hasTools ? CreateMessageResultWithToolsSchema : CreateMessageResultSchema;
|
||||
const validationResult = parseSchema(resultSchema, result);
|
||||
|
|
@ -701,14 +628,6 @@ export class Client extends Protocol<ClientContext> {
|
|||
}
|
||||
}
|
||||
|
||||
protected assertTaskCapability(method: string): void {
|
||||
assertToolsCallTaskCapability(this._serverCapabilities?.tasks?.requests, method, 'Server');
|
||||
}
|
||||
|
||||
protected assertTaskHandlerCapability(method: string): void {
|
||||
assertClientRequestTaskCapability(this._capabilities?.tasks?.requests, method, 'Client');
|
||||
}
|
||||
|
||||
async ping(options?: RequestOptions) {
|
||||
return this._requestWithSchema({ method: 'ping' }, EmptyResultSchema, options);
|
||||
}
|
||||
|
|
@ -828,8 +747,6 @@ export class Client extends Protocol<ClientContext> {
|
|||
* a problem), and thrown {@linkcode ProtocolError} for protocol-level failures or {@linkcode SdkError} for
|
||||
* SDK-level issues (timeouts, missing capabilities).
|
||||
*
|
||||
* For task-based execution with streaming behavior, use {@linkcode ExperimentalClientTasks.callToolStream | client.experimental.tasks.callToolStream()} instead.
|
||||
*
|
||||
* @example Basic usage
|
||||
* ```ts source="./client.examples.ts#Client_callTool_basic"
|
||||
* const result = await client.callTool({
|
||||
|
|
@ -860,14 +777,6 @@ export class Client extends Protocol<ClientContext> {
|
|||
* ```
|
||||
*/
|
||||
async callTool(params: CallToolRequest['params'], options?: RequestOptions) {
|
||||
// Guard: required-task tools need experimental API
|
||||
if (this.isToolTaskRequired(params.name)) {
|
||||
throw new ProtocolError(
|
||||
ProtocolErrorCode.InvalidRequest,
|
||||
`Tool "${params.name}" requires task-based execution. Use client.experimental.tasks.callToolStream() instead.`
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this._requestWithSchema({ method: 'tools/call', params }, CallToolResultSchema, options);
|
||||
|
||||
// Check if the tool has an outputSchema
|
||||
|
|
@ -908,30 +817,12 @@ export class Client extends Protocol<ClientContext> {
|
|||
return result;
|
||||
}
|
||||
|
||||
private isToolTask(toolName: string): boolean {
|
||||
if (!this._serverCapabilities?.tasks?.requests?.tools?.call) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this._cachedKnownTaskTools.has(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool requires task-based execution.
|
||||
* Unlike {@linkcode isToolTask} which includes `'optional'` tools, this only checks for `'required'`.
|
||||
*/
|
||||
private isToolTaskRequired(toolName: string): boolean {
|
||||
return this._cachedRequiredTaskTools.has(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache validators for tool output schemas.
|
||||
* Called after {@linkcode listTools | listTools()} to pre-compile validators for better performance.
|
||||
*/
|
||||
private cacheToolMetadata(tools: Tool[]): void {
|
||||
this._cachedToolOutputValidators.clear();
|
||||
this._cachedKnownTaskTools.clear();
|
||||
this._cachedRequiredTaskTools.clear();
|
||||
|
||||
for (const tool of tools) {
|
||||
// If the tool has an outputSchema, create and cache the validator
|
||||
|
|
@ -939,15 +830,6 @@ export class Client extends Protocol<ClientContext> {
|
|||
const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema as JsonSchemaType);
|
||||
this._cachedToolOutputValidators.set(tool.name, toolValidator);
|
||||
}
|
||||
|
||||
// If the tool supports task-based execution, cache that information
|
||||
const taskSupport = tool.execution?.taskSupport;
|
||||
if (taskSupport === 'required' || taskSupport === 'optional') {
|
||||
this._cachedKnownTaskTools.add(tool.name);
|
||||
}
|
||||
if (taskSupport === 'required') {
|
||||
this._cachedRequiredTaskTools.add(tool.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ declare const platformBackgroundTask: {
|
|||
};
|
||||
|
||||
/**
|
||||
* Example: Using a platform background-task API to schedule reconnections.
|
||||
* Example: Using a platform background-scheduler API to schedule reconnections.
|
||||
*/
|
||||
function ReconnectionScheduler_basicUsage() {
|
||||
//#region ReconnectionScheduler_basicUsage
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue