v2 - RFC Extract Tasks out of protocol.ts into TaskManager (#1673)
Co-authored-by: Felix Weinberger <fweinberger@anthropic.com>
This commit is contained in:
parent
7d9c72eee5
commit
462c3fc47d
19 changed files with 2122 additions and 1942 deletions
10
.changeset/extract-task-manager.md
Normal file
10
.changeset/extract-task-manager.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
"@modelcontextprotocol/core": minor
|
||||
"@modelcontextprotocol/client": minor
|
||||
"@modelcontextprotocol/server": minor
|
||||
---
|
||||
|
||||
refactor: extract task orchestration from Protocol into TaskManager
|
||||
|
||||
**Breaking changes:**
|
||||
- `taskStore`, `taskMessageQueue`, `defaultTaskPollInterval`, and `maxTaskQueueSize` moved from `ProtocolOptions` to `capabilities.tasks` on `ClientOptions`/`ServerOptions`
|
||||
|
|
@ -265,14 +265,14 @@ async function connect(url?: string): Promise<void> {
|
|||
form: {}
|
||||
},
|
||||
tasks: {
|
||||
taskStore: clientTaskStore,
|
||||
requests: {
|
||||
elicitation: {
|
||||
create: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
client.onerror = error => {
|
||||
|
|
|
|||
|
|
@ -41,9 +41,14 @@ const getServer = () => {
|
|||
websiteUrl: 'https://github.com/modelcontextprotocol/typescript-sdk'
|
||||
},
|
||||
{
|
||||
capabilities: { logging: {}, tasks: { requests: { tools: { call: {} } } } },
|
||||
taskStore, // Enable task support
|
||||
taskMessageQueue: new InMemoryTaskMessageQueue()
|
||||
capabilities: {
|
||||
logging: {},
|
||||
tasks: {
|
||||
requests: { tools: { call: {} } },
|
||||
taskStore,
|
||||
taskMessageQueue: new InMemoryTaskMessageQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import type {
|
|||
ResultTypeMap,
|
||||
ServerCapabilities,
|
||||
SubscribeRequest,
|
||||
TaskManagerOptions,
|
||||
Tool,
|
||||
Transport,
|
||||
UnsubscribeRequest
|
||||
|
|
@ -46,6 +47,7 @@ import {
|
|||
ElicitRequestSchema,
|
||||
ElicitResultSchema,
|
||||
EmptyResultSchema,
|
||||
extractTaskManagerOptions,
|
||||
GetPromptResultSchema,
|
||||
InitializeResultSchema,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
|
|
@ -140,11 +142,19 @@ 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?: ClientCapabilities;
|
||||
capabilities?: Omit<ClientCapabilities, 'tasks'> & {
|
||||
tasks?: ClientTasksCapabilityWithRuntime;
|
||||
};
|
||||
|
||||
/**
|
||||
* JSON Schema validator for tool output validation.
|
||||
|
|
@ -213,11 +223,22 @@ export class Client extends Protocol<ClientContext> {
|
|||
private _clientInfo: Implementation,
|
||||
options?: ClientOptions
|
||||
) {
|
||||
super(options);
|
||||
this._capabilities = options?.capabilities ?? {};
|
||||
super({
|
||||
...options,
|
||||
tasks: extractTaskManagerOptions(options?.capabilities?.tasks)
|
||||
});
|
||||
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;
|
||||
|
|
@ -650,12 +671,6 @@ export class Client extends Protocol<ClientContext> {
|
|||
}
|
||||
|
||||
protected assertRequestHandlerCapability(method: string): void {
|
||||
// Task handlers are registered in Protocol constructor before _capabilities is initialized
|
||||
// Skip capability check for task methods during initialization
|
||||
if (!this._capabilities) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (method) {
|
||||
case 'sampling/createMessage': {
|
||||
if (!this._capabilities.sampling) {
|
||||
|
|
@ -687,19 +702,6 @@ export class Client extends Protocol<ClientContext> {
|
|||
break;
|
||||
}
|
||||
|
||||
case 'tasks/get':
|
||||
case 'tasks/list':
|
||||
case 'tasks/result':
|
||||
case 'tasks/cancel': {
|
||||
if (!this._capabilities.tasks) {
|
||||
throw new SdkError(
|
||||
SdkErrorCode.CapabilityNotSupported,
|
||||
`Client does not support tasks capability (required for ${method})`
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ping': {
|
||||
// No specific capability required for ping
|
||||
break;
|
||||
|
|
@ -712,16 +714,9 @@ export class Client extends Protocol<ClientContext> {
|
|||
}
|
||||
|
||||
protected assertTaskHandlerCapability(method: string): void {
|
||||
// Task handlers are registered in Protocol constructor before _capabilities is initialized
|
||||
// Skip capability check for task methods during initialization
|
||||
if (!this._capabilities) {
|
||||
return;
|
||||
}
|
||||
|
||||
assertClientRequestTaskCapability(this._capabilities.tasks?.requests, method, 'Client');
|
||||
assertClientRequestTaskCapability(this._capabilities?.tasks?.requests, method, 'Client');
|
||||
}
|
||||
|
||||
/** Sends a ping to the server to check connectivity. */
|
||||
async ping(options?: RequestOptions) {
|
||||
return this._requestWithSchema({ method: 'ping' }, EmptyResultSchema, options);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import type {
|
||||
AnyObjectSchema,
|
||||
CallToolRequest,
|
||||
CallToolResult,
|
||||
CancelTaskResult,
|
||||
|
|
@ -13,12 +14,19 @@ import type {
|
|||
GetTaskPayloadResult,
|
||||
GetTaskResult,
|
||||
ListTasksResult,
|
||||
Request,
|
||||
RequestMethod,
|
||||
RequestOptions,
|
||||
ResponseMessage,
|
||||
ResultTypeMap
|
||||
} from '@modelcontextprotocol/core';
|
||||
import { GetTaskPayloadResultSchema, ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/core';
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
getResultSchema,
|
||||
GetTaskPayloadResultSchema,
|
||||
ProtocolError,
|
||||
ProtocolErrorCode
|
||||
} from '@modelcontextprotocol/core';
|
||||
|
||||
import type { Client } from '../../client/client.js';
|
||||
|
||||
|
|
@ -27,10 +35,6 @@ import type { Client } from '../../client/client.js';
|
|||
* @internal
|
||||
*/
|
||||
interface ClientInternal {
|
||||
requestStream<M extends RequestMethod>(
|
||||
request: { method: M; params?: Record<string, unknown> },
|
||||
options?: RequestOptions
|
||||
): AsyncGenerator<ResponseMessage<ResultTypeMap[M]>, void, void>;
|
||||
isToolTask(toolName: string): boolean;
|
||||
getToolOutputValidator(toolName: string): ((data: unknown) => { valid: boolean; errorMessage?: string }) | undefined;
|
||||
}
|
||||
|
|
@ -49,6 +53,10 @@ interface ClientInternal {
|
|||
export class ExperimentalClientTasks {
|
||||
constructor(private readonly _client: Client) {}
|
||||
|
||||
private get _module() {
|
||||
return this._client.taskManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls a tool and returns an AsyncGenerator that yields response messages.
|
||||
* The generator is guaranteed to end with either a `'result'` or `'error'` message.
|
||||
|
|
@ -103,7 +111,7 @@ export class ExperimentalClientTasks {
|
|||
task: options?.task ?? (clientInternal.isToolTask(params.name) ? {} : undefined)
|
||||
};
|
||||
|
||||
const stream = clientInternal.requestStream({ method: 'tools/call', params }, optionsWithTask);
|
||||
const stream = this._module.requestStream({ method: 'tools/call', params }, CallToolResultSchema, optionsWithTask);
|
||||
|
||||
// Get the validator for this tool (if it has an output schema)
|
||||
const validator = clientInternal.getToolOutputValidator(params.name);
|
||||
|
|
@ -175,9 +183,7 @@ export class ExperimentalClientTasks {
|
|||
* @experimental
|
||||
*/
|
||||
async getTask(taskId: string, options?: RequestOptions): Promise<GetTaskResult> {
|
||||
// Delegate to the client's underlying Protocol method
|
||||
type ClientWithGetTask = { getTask(params: { taskId: string }, options?: RequestOptions): Promise<GetTaskResult> };
|
||||
return (this._client as unknown as ClientWithGetTask).getTask({ taskId }, options);
|
||||
return this._module.getTask({ taskId }, options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -191,15 +197,7 @@ export class ExperimentalClientTasks {
|
|||
* @experimental
|
||||
*/
|
||||
async getTaskResult(taskId: string, options?: RequestOptions): Promise<GetTaskPayloadResult> {
|
||||
return (
|
||||
this._client as unknown as {
|
||||
getTaskResult: (
|
||||
params: { taskId: string },
|
||||
resultSchema: typeof GetTaskPayloadResultSchema,
|
||||
options?: RequestOptions
|
||||
) => Promise<GetTaskPayloadResult>;
|
||||
}
|
||||
).getTaskResult({ taskId }, GetTaskPayloadResultSchema, options);
|
||||
return this._module.getTaskResult({ taskId }, GetTaskPayloadResultSchema, options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -212,12 +210,7 @@ export class ExperimentalClientTasks {
|
|||
* @experimental
|
||||
*/
|
||||
async listTasks(cursor?: string, options?: RequestOptions): Promise<ListTasksResult> {
|
||||
// Delegate to the client's underlying Protocol method
|
||||
return (
|
||||
this._client as unknown as {
|
||||
listTasks: (params?: { cursor?: string }, options?: RequestOptions) => Promise<ListTasksResult>;
|
||||
}
|
||||
).listTasks(cursor ? { cursor } : undefined, options);
|
||||
return this._module.listTasks(cursor ? { cursor } : undefined, options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -229,12 +222,7 @@ export class ExperimentalClientTasks {
|
|||
* @experimental
|
||||
*/
|
||||
async cancelTask(taskId: string, options?: RequestOptions): Promise<CancelTaskResult> {
|
||||
// Delegate to the client's underlying Protocol method
|
||||
return (
|
||||
this._client as unknown as {
|
||||
cancelTask: (params: { taskId: string }, options?: RequestOptions) => Promise<CancelTaskResult>;
|
||||
}
|
||||
).cancelTask({ taskId }, options);
|
||||
return this._module.cancelTask({ taskId }, options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -279,7 +267,11 @@ export class ExperimentalClientTasks {
|
|||
request: { method: M; params?: Record<string, unknown> },
|
||||
options?: RequestOptions
|
||||
): AsyncGenerator<ResponseMessage<ResultTypeMap[M]>, void, void> {
|
||||
// Delegate to the client's underlying Protocol method
|
||||
return (this._client as unknown as ClientInternal).requestStream(request, options);
|
||||
const resultSchema = getResultSchema(request.method) as unknown as AnyObjectSchema;
|
||||
return this._module.requestStream(request as Request, resultSchema, options) as AsyncGenerator<
|
||||
ResponseMessage<ResultTypeMap[M]>,
|
||||
void,
|
||||
void
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ interface TaskRequestsCapability {
|
|||
|
||||
/**
|
||||
* Asserts that task creation is supported for `tools/call`.
|
||||
* Used by {@linkcode @modelcontextprotocol/client!client/client.Client.assertTaskCapability | Client.assertTaskCapability} and {@linkcode @modelcontextprotocol/server!server/server.Server.assertTaskHandlerCapability | Server.assertTaskHandlerCapability}.
|
||||
* Used to implement the `assertTaskCapability` or `assertTaskHandlerCapability` abstract methods on Protocol.
|
||||
*
|
||||
* @param requests - The task requests capability object
|
||||
* @param method - The method being checked
|
||||
|
|
@ -52,7 +52,7 @@ export function assertToolsCallTaskCapability(
|
|||
|
||||
/**
|
||||
* Asserts that task creation is supported for `sampling/createMessage` or `elicitation/create`.
|
||||
* Used by {@linkcode @modelcontextprotocol/server!server/server.Server.assertTaskCapability | Server.assertTaskCapability} and {@linkcode @modelcontextprotocol/client!client/client.Client.assertTaskHandlerCapability | Client.assertTaskHandlerCapability}.
|
||||
* Used to implement the `assertTaskCapability` or `assertTaskHandlerCapability` abstract methods on Protocol.
|
||||
*
|
||||
* @param requests - The task requests capability object
|
||||
* @param method - The method being checked
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
* WARNING: These APIs are experimental and may change without notice.
|
||||
*/
|
||||
|
||||
import type { RequestTaskStore, ServerContext } from '../../shared/protocol.js';
|
||||
import type { ServerContext } from '../../shared/protocol.js';
|
||||
import type { RequestTaskStore } from '../../shared/taskManager.js';
|
||||
import type {
|
||||
JSONRPCErrorResponse,
|
||||
JSONRPCNotification,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ export * from './shared/metadataUtils.js';
|
|||
export * from './shared/protocol.js';
|
||||
export * from './shared/responseMessage.js';
|
||||
export * from './shared/stdio.js';
|
||||
export type { RequestTaskStore, TaskContext, TaskManagerOptions, TaskRequestOptions } from './shared/taskManager.js';
|
||||
export { extractTaskManagerOptions, NullTaskManager, TaskManager } from './shared/taskManager.js';
|
||||
export * from './shared/toolNameValidation.js';
|
||||
export * from './shared/transport.js';
|
||||
export * from './shared/uriTemplate.js';
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
900
packages/core/src/shared/taskManager.ts
Normal file
900
packages/core/src/shared/taskManager.ts
Normal file
|
|
@ -0,0 +1,900 @@
|
|||
import type { CreateTaskOptions, QueuedMessage, TaskMessageQueue, TaskStore } from '../experimental/tasks/interfaces.js';
|
||||
import { isTerminal } from '../experimental/tasks/interfaces.js';
|
||||
import type {
|
||||
GetTaskPayloadRequest,
|
||||
GetTaskRequest,
|
||||
GetTaskResult,
|
||||
JSONRPCErrorResponse,
|
||||
JSONRPCNotification,
|
||||
JSONRPCRequest,
|
||||
JSONRPCResponse,
|
||||
JSONRPCResultResponse,
|
||||
Notification,
|
||||
Request,
|
||||
RequestId,
|
||||
Result,
|
||||
Task,
|
||||
TaskCreationParams,
|
||||
TaskStatusNotification
|
||||
} from '../types/types.js';
|
||||
import {
|
||||
CancelTaskResultSchema,
|
||||
CreateTaskResultSchema,
|
||||
GetTaskResultSchema,
|
||||
isJSONRPCErrorResponse,
|
||||
isJSONRPCRequest,
|
||||
isJSONRPCResultResponse,
|
||||
isTaskAugmentedRequestParams,
|
||||
ListTasksResultSchema,
|
||||
ProtocolError,
|
||||
ProtocolErrorCode,
|
||||
RELATED_TASK_META_KEY,
|
||||
TaskStatusNotificationSchema
|
||||
} from '../types/types.js';
|
||||
import type { AnyObjectSchema, AnySchema, SchemaOutput } from '../util/schema.js';
|
||||
import type { BaseContext, NotificationOptions, RequestOptions } from './protocol.js';
|
||||
import type { ResponseMessage } from './responseMessage.js';
|
||||
|
||||
/**
|
||||
* Host interface for TaskManager to call back into Protocol. @internal
|
||||
*/
|
||||
export interface TaskManagerHost {
|
||||
request<T extends AnySchema>(request: Request, resultSchema: T, options?: RequestOptions): Promise<SchemaOutput<T>>;
|
||||
notification(notification: Notification, options?: NotificationOptions): Promise<void>;
|
||||
reportError(error: Error): void;
|
||||
removeProgressHandler(token: number): void;
|
||||
registerHandler(method: string, handler: (request: JSONRPCRequest, ctx: BaseContext) => Promise<Result>): void;
|
||||
sendOnResponseStream(message: JSONRPCNotification | JSONRPCRequest, relatedRequestId: RequestId): Promise<void>;
|
||||
enforceStrictCapabilities: boolean;
|
||||
assertTaskCapability(method: string): void;
|
||||
assertTaskHandlerCapability(method: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context provided to TaskManager when processing an inbound request.
|
||||
* @internal
|
||||
*/
|
||||
export interface InboundContext {
|
||||
sessionId?: string;
|
||||
sendNotification: (notification: Notification, options?: NotificationOptions) => Promise<void>;
|
||||
sendRequest: <U extends AnySchema>(request: Request, resultSchema: U, options?: RequestOptions) => Promise<SchemaOutput<U>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned by TaskManager after processing an inbound request.
|
||||
* @internal
|
||||
*/
|
||||
export interface InboundResult {
|
||||
taskContext?: BaseContext['task'];
|
||||
sendNotification: (notification: Notification) => Promise<void>;
|
||||
sendRequest: <U extends AnySchema>(
|
||||
request: Request,
|
||||
resultSchema: U,
|
||||
options?: Omit<RequestOptions, 'relatedTask'>
|
||||
) => Promise<SchemaOutput<U>>;
|
||||
routeResponse: (message: JSONRPCResponse | JSONRPCErrorResponse) => Promise<boolean>;
|
||||
hasTaskCreationParams: boolean;
|
||||
/**
|
||||
* Optional validation to run inside the async handler chain (before the request handler).
|
||||
* Throwing here produces a proper JSON-RPC error response, matching the behavior of
|
||||
* capability checks on main.
|
||||
*/
|
||||
validateInbound?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options that can be given per request.
|
||||
*/
|
||||
// relatedTask is excluded as the SDK controls if this is sent according to if the source is a task.
|
||||
export type TaskRequestOptions = Omit<RequestOptions, 'relatedTask'>;
|
||||
|
||||
/**
|
||||
* Request-scoped TaskStore interface.
|
||||
*/
|
||||
export interface RequestTaskStore {
|
||||
/**
|
||||
* Creates a new task with the given creation parameters.
|
||||
* The implementation generates a unique taskId and createdAt timestamp.
|
||||
*
|
||||
* @param taskParams - The task creation parameters from the request
|
||||
* @returns The created task object
|
||||
*/
|
||||
createTask(taskParams: CreateTaskOptions): Promise<Task>;
|
||||
|
||||
/**
|
||||
* Gets the current status of a task.
|
||||
*
|
||||
* @param taskId - The task identifier
|
||||
* @returns The task object
|
||||
* @throws If the task does not exist
|
||||
*/
|
||||
getTask(taskId: string): Promise<Task>;
|
||||
|
||||
/**
|
||||
* Stores the result of a task and sets its final status.
|
||||
*
|
||||
* @param taskId - The task identifier
|
||||
* @param status - The final status: 'completed' for success, 'failed' for errors
|
||||
* @param result - The result to store
|
||||
*/
|
||||
storeTaskResult(taskId: string, status: 'completed' | 'failed', result: Result): Promise<void>;
|
||||
|
||||
/**
|
||||
* Retrieves the stored result of a task.
|
||||
*
|
||||
* @param taskId - The task identifier
|
||||
* @returns The stored result
|
||||
*/
|
||||
getTaskResult(taskId: string): Promise<Result>;
|
||||
|
||||
/**
|
||||
* Updates a task's status (e.g., to 'cancelled', 'failed', 'completed').
|
||||
*
|
||||
* @param taskId - The task identifier
|
||||
* @param status - The new status
|
||||
* @param statusMessage - Optional diagnostic message for failed tasks or other status information
|
||||
*/
|
||||
updateTaskStatus(taskId: string, status: Task['status'], statusMessage?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Lists tasks, optionally starting from a pagination cursor.
|
||||
*
|
||||
* @param cursor - Optional cursor for pagination
|
||||
* @returns An object containing the tasks array and an optional nextCursor
|
||||
*/
|
||||
listTasks(cursor?: string): Promise<{ tasks: Task[]; nextCursor?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task context provided to request handlers when task storage is configured.
|
||||
*/
|
||||
export type TaskContext = {
|
||||
id?: string;
|
||||
store: RequestTaskStore;
|
||||
requestedTtl?: number | null;
|
||||
};
|
||||
|
||||
export type TaskManagerOptions = {
|
||||
/**
|
||||
* Task storage implementation. Required for handling incoming task requests (server-side).
|
||||
* Not required for sending task requests (client-side outbound API).
|
||||
*/
|
||||
taskStore?: TaskStore;
|
||||
/**
|
||||
* Optional task message queue implementation for managing server-initiated messages
|
||||
* that will be delivered through the tasks/result response stream.
|
||||
*/
|
||||
taskMessageQueue?: TaskMessageQueue;
|
||||
/**
|
||||
* Default polling interval (in milliseconds) for task status checks when no pollInterval
|
||||
* is provided by the server. Defaults to 1000ms if not specified.
|
||||
*/
|
||||
defaultTaskPollInterval?: number;
|
||||
/**
|
||||
* Maximum number of messages that can be queued per task for side-channel delivery.
|
||||
* If undefined, the queue size is unbounded.
|
||||
*/
|
||||
maxTaskQueueSize?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts {@linkcode TaskManagerOptions} from a capability object that mixes in runtime fields.
|
||||
* Returns `undefined` when no task capability is configured.
|
||||
*/
|
||||
export function extractTaskManagerOptions(tasksCapability: TaskManagerOptions | undefined): TaskManagerOptions | undefined {
|
||||
if (!tasksCapability) return undefined;
|
||||
const { taskStore, taskMessageQueue, defaultTaskPollInterval, maxTaskQueueSize } = tasksCapability;
|
||||
return { taskStore, taskMessageQueue, defaultTaskPollInterval, maxTaskQueueSize };
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages task orchestration: state, message queuing, and polling.
|
||||
* Capability checking is delegated to the Protocol host.
|
||||
* @internal
|
||||
*/
|
||||
export class TaskManager {
|
||||
private _taskStore?: TaskStore;
|
||||
private _taskMessageQueue?: TaskMessageQueue;
|
||||
private _taskProgressTokens: Map<string, number> = new Map();
|
||||
private _requestResolvers: Map<RequestId, (response: JSONRPCResultResponse | Error) => void> = new Map();
|
||||
private _options: TaskManagerOptions;
|
||||
private _host?: TaskManagerHost;
|
||||
|
||||
constructor(options: TaskManagerOptions) {
|
||||
this._options = options;
|
||||
this._taskStore = options.taskStore;
|
||||
this._taskMessageQueue = options.taskMessageQueue;
|
||||
}
|
||||
|
||||
bind(host: TaskManagerHost): void {
|
||||
this._host = host;
|
||||
|
||||
if (this._taskStore) {
|
||||
host.registerHandler('tasks/get', async (request, ctx) => {
|
||||
const params = request.params as { taskId: string };
|
||||
const task = await this.handleGetTask(params.taskId, ctx.sessionId);
|
||||
// Per spec: tasks/get responses SHALL NOT include related-task metadata
|
||||
// as the taskId parameter is the source of truth
|
||||
return {
|
||||
...task
|
||||
} as Result;
|
||||
});
|
||||
|
||||
host.registerHandler('tasks/result', async (request, ctx) => {
|
||||
const params = request.params as { taskId: string };
|
||||
return await this.handleGetTaskPayload(params.taskId, ctx.sessionId, ctx.mcpReq.signal, async message => {
|
||||
// Send the message on the response stream by passing the relatedRequestId
|
||||
// This tells the transport to write the message to the tasks/result response stream
|
||||
await host.sendOnResponseStream(message, ctx.mcpReq.id);
|
||||
});
|
||||
});
|
||||
|
||||
host.registerHandler('tasks/list', async (request, ctx) => {
|
||||
const params = request.params as { cursor?: string } | undefined;
|
||||
return (await this.handleListTasks(params?.cursor, ctx.sessionId)) as Result;
|
||||
});
|
||||
|
||||
host.registerHandler('tasks/cancel', async (request, ctx) => {
|
||||
const params = request.params as { taskId: string };
|
||||
return await this.handleCancelTask(params.taskId, ctx.sessionId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected get _requireHost(): TaskManagerHost {
|
||||
if (!this._host) {
|
||||
throw new ProtocolError(ProtocolErrorCode.InternalError, 'TaskManager is not bound to a Protocol host — call bind() first');
|
||||
}
|
||||
return this._host;
|
||||
}
|
||||
|
||||
get taskStore(): TaskStore | undefined {
|
||||
return this._taskStore;
|
||||
}
|
||||
|
||||
private get _requireTaskStore(): TaskStore {
|
||||
if (!this._taskStore) {
|
||||
throw new ProtocolError(ProtocolErrorCode.InternalError, 'TaskStore is not configured');
|
||||
}
|
||||
return this._taskStore;
|
||||
}
|
||||
|
||||
get taskMessageQueue(): TaskMessageQueue | undefined {
|
||||
return this._taskMessageQueue;
|
||||
}
|
||||
|
||||
// -- Public API (client-facing) --
|
||||
async *requestStream<T extends AnyObjectSchema>(
|
||||
request: Request,
|
||||
resultSchema: T,
|
||||
options?: RequestOptions
|
||||
): AsyncGenerator<ResponseMessage<SchemaOutput<T>>, void, void> {
|
||||
const host = this._requireHost;
|
||||
const { task } = options ?? {};
|
||||
|
||||
if (!task) {
|
||||
try {
|
||||
const result = await host.request(request, resultSchema, options);
|
||||
yield { type: 'result', result };
|
||||
} catch (error) {
|
||||
yield {
|
||||
type: 'error',
|
||||
error: error instanceof Error ? error : new Error(String(error))
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let taskId: string | undefined;
|
||||
try {
|
||||
const createResult = await host.request(request, CreateTaskResultSchema, options);
|
||||
|
||||
if (createResult.task) {
|
||||
taskId = createResult.task.taskId;
|
||||
yield { type: 'taskCreated', task: createResult.task };
|
||||
} else {
|
||||
throw new ProtocolError(ProtocolErrorCode.InternalError, 'Task creation did not return a task');
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const task = await this.getTask({ taskId }, options);
|
||||
yield { type: 'taskStatus', task };
|
||||
|
||||
if (isTerminal(task.status)) {
|
||||
switch (task.status) {
|
||||
case 'completed': {
|
||||
const result = await this.getTaskResult({ taskId }, resultSchema, options);
|
||||
yield { type: 'result', result };
|
||||
break;
|
||||
}
|
||||
case 'failed': {
|
||||
yield { type: 'error', error: new ProtocolError(ProtocolErrorCode.InternalError, `Task ${taskId} failed`) };
|
||||
break;
|
||||
}
|
||||
case 'cancelled': {
|
||||
yield {
|
||||
type: 'error',
|
||||
error: new ProtocolError(ProtocolErrorCode.InternalError, `Task ${taskId} was cancelled`)
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (task.status === 'input_required') {
|
||||
const result = await this.getTaskResult({ taskId }, resultSchema, options);
|
||||
yield { type: 'result', result };
|
||||
return;
|
||||
}
|
||||
|
||||
const pollInterval = task.pollInterval ?? this._options.defaultTaskPollInterval ?? 1000;
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||||
options?.signal?.throwIfAborted();
|
||||
}
|
||||
} catch (error) {
|
||||
yield {
|
||||
type: 'error',
|
||||
error: error instanceof Error ? error : new Error(String(error))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getTask(params: GetTaskRequest['params'], options?: RequestOptions): Promise<GetTaskResult> {
|
||||
return this._requireHost.request({ method: 'tasks/get', params }, GetTaskResultSchema, options);
|
||||
}
|
||||
|
||||
async getTaskResult<T extends AnySchema>(
|
||||
params: GetTaskPayloadRequest['params'],
|
||||
resultSchema: T,
|
||||
options?: RequestOptions
|
||||
): Promise<SchemaOutput<T>> {
|
||||
return this._requireHost.request({ method: 'tasks/result', params }, resultSchema, options);
|
||||
}
|
||||
|
||||
async listTasks(params?: { cursor?: string }, options?: RequestOptions): Promise<SchemaOutput<typeof ListTasksResultSchema>> {
|
||||
return this._requireHost.request({ method: 'tasks/list', params }, ListTasksResultSchema, options);
|
||||
}
|
||||
|
||||
async cancelTask(params: { taskId: string }, options?: RequestOptions): Promise<SchemaOutput<typeof CancelTaskResultSchema>> {
|
||||
return this._requireHost.request({ method: 'tasks/cancel', params }, CancelTaskResultSchema, options);
|
||||
}
|
||||
|
||||
// -- Handler bodies (delegated from Protocol's registered handlers) --
|
||||
|
||||
private async handleGetTask(taskId: string, sessionId?: string): Promise<Task> {
|
||||
const task = await this._requireTaskStore.getTask(taskId, sessionId);
|
||||
if (!task) {
|
||||
throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Failed to retrieve task: Task not found');
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
private async handleGetTaskPayload(
|
||||
taskId: string,
|
||||
sessionId: string | undefined,
|
||||
signal: AbortSignal,
|
||||
sendOnResponseStream: (message: JSONRPCNotification | JSONRPCRequest) => Promise<void>
|
||||
): Promise<Result> {
|
||||
const handleTaskResult = async (): Promise<Result> => {
|
||||
if (this._taskMessageQueue) {
|
||||
let queuedMessage: QueuedMessage | undefined;
|
||||
while ((queuedMessage = await this._taskMessageQueue.dequeue(taskId, sessionId))) {
|
||||
if (queuedMessage.type === 'response' || queuedMessage.type === 'error') {
|
||||
const message = queuedMessage.message;
|
||||
const requestId = message.id;
|
||||
const resolver = this._requestResolvers.get(requestId as RequestId);
|
||||
|
||||
if (resolver) {
|
||||
this._requestResolvers.delete(requestId as RequestId);
|
||||
if (queuedMessage.type === 'response') {
|
||||
resolver(message as JSONRPCResultResponse);
|
||||
} else {
|
||||
const errorMessage = message as JSONRPCErrorResponse;
|
||||
resolver(new ProtocolError(errorMessage.error.code, errorMessage.error.message, errorMessage.error.data));
|
||||
}
|
||||
} else {
|
||||
const messageType = queuedMessage.type === 'response' ? 'Response' : 'Error';
|
||||
this._host?.reportError(new Error(`${messageType} handler missing for request ${requestId}`));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
await sendOnResponseStream(queuedMessage.message as JSONRPCNotification | JSONRPCRequest);
|
||||
}
|
||||
}
|
||||
|
||||
const task = await this._requireTaskStore.getTask(taskId, sessionId);
|
||||
if (!task) {
|
||||
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Task not found: ${taskId}`);
|
||||
}
|
||||
|
||||
if (!isTerminal(task.status)) {
|
||||
await this._waitForTaskUpdate(task.pollInterval, signal);
|
||||
return await handleTaskResult();
|
||||
}
|
||||
|
||||
const result = await this._requireTaskStore.getTaskResult(taskId, sessionId);
|
||||
await this._clearTaskQueue(taskId);
|
||||
|
||||
return {
|
||||
...result,
|
||||
_meta: {
|
||||
...result._meta,
|
||||
[RELATED_TASK_META_KEY]: { taskId }
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return await handleTaskResult();
|
||||
}
|
||||
|
||||
private async handleListTasks(
|
||||
cursor: string | undefined,
|
||||
sessionId?: string
|
||||
): Promise<{ tasks: Task[]; nextCursor?: string; _meta: Record<string, unknown> }> {
|
||||
try {
|
||||
const { tasks, nextCursor } = await this._requireTaskStore.listTasks(cursor, sessionId);
|
||||
return { tasks, nextCursor, _meta: {} };
|
||||
} catch (error) {
|
||||
throw new ProtocolError(
|
||||
ProtocolErrorCode.InvalidParams,
|
||||
`Failed to list tasks: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCancelTask(taskId: string, sessionId?: string): Promise<Result> {
|
||||
try {
|
||||
const task = await this._requireTaskStore.getTask(taskId, sessionId);
|
||||
if (!task) {
|
||||
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Task not found: ${taskId}`);
|
||||
}
|
||||
|
||||
if (isTerminal(task.status)) {
|
||||
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Cannot cancel task in terminal status: ${task.status}`);
|
||||
}
|
||||
|
||||
await this._requireTaskStore.updateTaskStatus(taskId, 'cancelled', 'Client cancelled task execution.', sessionId);
|
||||
await this._clearTaskQueue(taskId);
|
||||
|
||||
const cancelledTask = await this._requireTaskStore.getTask(taskId, sessionId);
|
||||
if (!cancelledTask) {
|
||||
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Task not found after cancellation: ${taskId}`);
|
||||
}
|
||||
|
||||
return { _meta: {}, ...cancelledTask };
|
||||
} catch (error) {
|
||||
if (error instanceof ProtocolError) throw error;
|
||||
throw new ProtocolError(
|
||||
ProtocolErrorCode.InvalidRequest,
|
||||
`Failed to cancel task: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Internal delegation methods --
|
||||
|
||||
private prepareOutboundRequest(
|
||||
jsonrpcRequest: JSONRPCRequest,
|
||||
options: RequestOptions | undefined,
|
||||
messageId: number,
|
||||
responseHandler: (response: JSONRPCResultResponse | Error) => void,
|
||||
onError: (error: unknown) => void
|
||||
): boolean {
|
||||
const { task, relatedTask } = options ?? {};
|
||||
|
||||
if (task) {
|
||||
jsonrpcRequest.params = {
|
||||
...jsonrpcRequest.params,
|
||||
task: task
|
||||
};
|
||||
}
|
||||
|
||||
if (relatedTask) {
|
||||
jsonrpcRequest.params = {
|
||||
...jsonrpcRequest.params,
|
||||
_meta: {
|
||||
...jsonrpcRequest.params?._meta,
|
||||
[RELATED_TASK_META_KEY]: relatedTask
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const relatedTaskId = relatedTask?.taskId;
|
||||
if (relatedTaskId) {
|
||||
this._requestResolvers.set(messageId, responseHandler);
|
||||
|
||||
this._enqueueTaskMessage(relatedTaskId, {
|
||||
type: 'request',
|
||||
message: jsonrpcRequest,
|
||||
timestamp: Date.now()
|
||||
}).catch(error => {
|
||||
onError(error);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private extractInboundTaskContext(
|
||||
request: JSONRPCRequest,
|
||||
sessionId?: string
|
||||
): {
|
||||
relatedTaskId?: string;
|
||||
taskCreationParams?: TaskCreationParams;
|
||||
taskContext?: TaskContext;
|
||||
} {
|
||||
const relatedTaskId = (request.params?._meta as Record<string, { taskId?: string }> | undefined)?.[RELATED_TASK_META_KEY]?.taskId;
|
||||
const taskCreationParams = isTaskAugmentedRequestParams(request.params) ? request.params.task : undefined;
|
||||
|
||||
// Provide task context whenever a task store is configured,
|
||||
// not just for task-related requests — tools need ctx.task.store
|
||||
let taskContext: TaskContext | undefined;
|
||||
if (this._taskStore) {
|
||||
const store = this.createRequestTaskStore(request, sessionId);
|
||||
taskContext = {
|
||||
id: relatedTaskId,
|
||||
store,
|
||||
requestedTtl: taskCreationParams?.ttl
|
||||
};
|
||||
}
|
||||
|
||||
if (!relatedTaskId && !taskCreationParams && !taskContext) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
relatedTaskId,
|
||||
taskCreationParams,
|
||||
taskContext
|
||||
};
|
||||
}
|
||||
|
||||
private wrapSendNotification(
|
||||
relatedTaskId: string,
|
||||
originalSendNotification: (notification: Notification, options?: NotificationOptions) => Promise<void>
|
||||
): (notification: Notification) => Promise<void> {
|
||||
return async (notification: Notification) => {
|
||||
const notificationOptions: NotificationOptions = { relatedTask: { taskId: relatedTaskId } };
|
||||
await originalSendNotification(notification, notificationOptions);
|
||||
};
|
||||
}
|
||||
|
||||
private wrapSendRequest(
|
||||
relatedTaskId: string,
|
||||
taskStore: RequestTaskStore | undefined,
|
||||
originalSendRequest: <V extends AnySchema>(request: Request, resultSchema: V, options?: RequestOptions) => Promise<SchemaOutput<V>>
|
||||
): <V extends AnySchema>(request: Request, resultSchema: V, options?: TaskRequestOptions) => Promise<SchemaOutput<V>> {
|
||||
return async <V extends AnySchema>(request: Request, resultSchema: V, options?: TaskRequestOptions) => {
|
||||
const requestOptions: RequestOptions = { ...options };
|
||||
if (relatedTaskId && !requestOptions.relatedTask) {
|
||||
requestOptions.relatedTask = { taskId: relatedTaskId };
|
||||
}
|
||||
|
||||
const effectiveTaskId = requestOptions.relatedTask?.taskId ?? relatedTaskId;
|
||||
if (effectiveTaskId && taskStore) {
|
||||
await taskStore.updateTaskStatus(effectiveTaskId, 'input_required');
|
||||
}
|
||||
|
||||
return await originalSendRequest(request, resultSchema, requestOptions);
|
||||
};
|
||||
}
|
||||
|
||||
private handleResponse(response: JSONRPCResponse | JSONRPCErrorResponse): boolean {
|
||||
const messageId = Number(response.id);
|
||||
const resolver = this._requestResolvers.get(messageId);
|
||||
if (resolver) {
|
||||
this._requestResolvers.delete(messageId);
|
||||
if (isJSONRPCResultResponse(response)) {
|
||||
resolver(response);
|
||||
} else {
|
||||
resolver(new ProtocolError(response.error.code, response.error.message, response.error.data));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private shouldPreserveProgressHandler(response: JSONRPCResponse | JSONRPCErrorResponse, messageId: number): boolean {
|
||||
if (isJSONRPCResultResponse(response) && response.result && typeof response.result === 'object') {
|
||||
const result = response.result as Record<string, unknown>;
|
||||
if (result.task && typeof result.task === 'object') {
|
||||
const task = result.task as Record<string, unknown>;
|
||||
if (typeof task.taskId === 'string') {
|
||||
this._taskProgressTokens.set(task.taskId, messageId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async routeNotification(notification: Notification, options?: NotificationOptions): Promise<boolean> {
|
||||
const relatedTaskId = options?.relatedTask?.taskId;
|
||||
if (!relatedTaskId) return false;
|
||||
|
||||
const jsonrpcNotification: JSONRPCNotification = {
|
||||
...notification,
|
||||
jsonrpc: '2.0',
|
||||
params: {
|
||||
...notification.params,
|
||||
_meta: {
|
||||
...notification.params?._meta,
|
||||
[RELATED_TASK_META_KEY]: options!.relatedTask
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await this._enqueueTaskMessage(relatedTaskId, {
|
||||
type: 'notification',
|
||||
message: jsonrpcNotification,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async routeResponse(
|
||||
relatedTaskId: string | undefined,
|
||||
message: JSONRPCResponse | JSONRPCErrorResponse,
|
||||
sessionId?: string
|
||||
): Promise<boolean> {
|
||||
if (!relatedTaskId || !this._taskMessageQueue) return false;
|
||||
|
||||
await (isJSONRPCErrorResponse(message)
|
||||
? this._enqueueTaskMessage(relatedTaskId, { type: 'error', message, timestamp: Date.now() }, sessionId)
|
||||
: this._enqueueTaskMessage(
|
||||
relatedTaskId,
|
||||
{ type: 'response', message: message as JSONRPCResultResponse, timestamp: Date.now() },
|
||||
sessionId
|
||||
));
|
||||
return true;
|
||||
}
|
||||
|
||||
private createRequestTaskStore(request?: JSONRPCRequest, sessionId?: string): RequestTaskStore {
|
||||
const taskStore = this._requireTaskStore;
|
||||
const host = this._host;
|
||||
|
||||
return {
|
||||
createTask: async taskParams => {
|
||||
if (!request) throw new Error('No request provided');
|
||||
return await taskStore.createTask(taskParams, request.id, { method: request.method, params: request.params }, sessionId);
|
||||
},
|
||||
getTask: async taskId => {
|
||||
const task = await taskStore.getTask(taskId, sessionId);
|
||||
if (!task) throw new ProtocolError(ProtocolErrorCode.InvalidParams, 'Failed to retrieve task: Task not found');
|
||||
return task;
|
||||
},
|
||||
storeTaskResult: async (taskId, status, result) => {
|
||||
await taskStore.storeTaskResult(taskId, status, result, sessionId);
|
||||
const task = await taskStore.getTask(taskId, sessionId);
|
||||
if (task) {
|
||||
const notification: TaskStatusNotification = TaskStatusNotificationSchema.parse({
|
||||
method: 'notifications/tasks/status',
|
||||
params: task
|
||||
});
|
||||
await host?.notification(notification as Notification);
|
||||
if (isTerminal(task.status)) {
|
||||
this._cleanupTaskProgressHandler(taskId);
|
||||
}
|
||||
}
|
||||
},
|
||||
getTaskResult: taskId => taskStore.getTaskResult(taskId, sessionId),
|
||||
updateTaskStatus: async (taskId, status, statusMessage) => {
|
||||
const task = await taskStore.getTask(taskId, sessionId);
|
||||
if (!task) {
|
||||
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Task "${taskId}" not found - it may have been cleaned up`);
|
||||
}
|
||||
if (isTerminal(task.status)) {
|
||||
throw new ProtocolError(
|
||||
ProtocolErrorCode.InvalidParams,
|
||||
`Cannot update task "${taskId}" from terminal status "${task.status}" to "${status}". Terminal states (completed, failed, cancelled) cannot transition to other states.`
|
||||
);
|
||||
}
|
||||
await taskStore.updateTaskStatus(taskId, status, statusMessage, sessionId);
|
||||
const updatedTask = await taskStore.getTask(taskId, sessionId);
|
||||
if (updatedTask) {
|
||||
const notification: TaskStatusNotification = TaskStatusNotificationSchema.parse({
|
||||
method: 'notifications/tasks/status',
|
||||
params: updatedTask
|
||||
});
|
||||
await host?.notification(notification as Notification);
|
||||
if (isTerminal(updatedTask.status)) {
|
||||
this._cleanupTaskProgressHandler(taskId);
|
||||
}
|
||||
}
|
||||
},
|
||||
listTasks: cursor => taskStore.listTasks(cursor, sessionId)
|
||||
};
|
||||
}
|
||||
|
||||
// -- Lifecycle methods (called by Protocol directly) --
|
||||
|
||||
processInboundRequest(request: JSONRPCRequest, ctx: InboundContext): InboundResult {
|
||||
const taskInfo = this.extractInboundTaskContext(request, ctx.sessionId);
|
||||
const relatedTaskId = taskInfo?.relatedTaskId;
|
||||
|
||||
const sendNotification = relatedTaskId
|
||||
? this.wrapSendNotification(relatedTaskId, ctx.sendNotification)
|
||||
: (notification: Notification) => ctx.sendNotification(notification);
|
||||
|
||||
const sendRequest = relatedTaskId
|
||||
? this.wrapSendRequest(relatedTaskId, taskInfo?.taskContext?.store, ctx.sendRequest)
|
||||
: taskInfo?.taskContext
|
||||
? this.wrapSendRequest('', taskInfo.taskContext.store, ctx.sendRequest)
|
||||
: ctx.sendRequest;
|
||||
|
||||
const hasTaskCreationParams = !!taskInfo?.taskCreationParams;
|
||||
|
||||
return {
|
||||
taskContext: taskInfo?.taskContext,
|
||||
sendNotification,
|
||||
sendRequest,
|
||||
routeResponse: async (message: JSONRPCResponse | JSONRPCErrorResponse) => {
|
||||
if (relatedTaskId) {
|
||||
return this.routeResponse(relatedTaskId, message, ctx.sessionId);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
hasTaskCreationParams,
|
||||
// Deferred validation: runs inside the async handler chain so errors
|
||||
// produce proper JSON-RPC error responses (matching main's behavior).
|
||||
validateInbound: hasTaskCreationParams ? () => this._requireHost.assertTaskHandlerCapability(request.method) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
processOutboundRequest(
|
||||
jsonrpcRequest: JSONRPCRequest,
|
||||
options: RequestOptions | undefined,
|
||||
messageId: number,
|
||||
responseHandler: (response: JSONRPCResultResponse | Error) => void,
|
||||
onError: (error: unknown) => void
|
||||
): { queued: boolean } {
|
||||
// Check task capability when sending a task-augmented request (matches main's enforceStrictCapabilities gate)
|
||||
if (this._requireHost.enforceStrictCapabilities && options?.task) {
|
||||
this._requireHost.assertTaskCapability(jsonrpcRequest.method);
|
||||
}
|
||||
|
||||
const queued = this.prepareOutboundRequest(jsonrpcRequest, options, messageId, responseHandler, onError);
|
||||
return { queued };
|
||||
}
|
||||
|
||||
processInboundResponse(
|
||||
response: JSONRPCResponse | JSONRPCErrorResponse,
|
||||
messageId: number
|
||||
): { consumed: boolean; preserveProgress: boolean } {
|
||||
const consumed = this.handleResponse(response);
|
||||
if (consumed) {
|
||||
return { consumed: true, preserveProgress: false };
|
||||
}
|
||||
const preserveProgress = this.shouldPreserveProgressHandler(response, messageId);
|
||||
return { consumed: false, preserveProgress };
|
||||
}
|
||||
|
||||
async processOutboundNotification(
|
||||
notification: Notification,
|
||||
options?: NotificationOptions
|
||||
): Promise<{ queued: boolean; jsonrpcNotification?: JSONRPCNotification }> {
|
||||
// Try queuing first
|
||||
const queued = await this.routeNotification(notification, options);
|
||||
if (queued) return { queued: true };
|
||||
|
||||
// Build JSONRPC notification with optional relatedTask metadata
|
||||
let jsonrpcNotification: JSONRPCNotification = { ...notification, jsonrpc: '2.0' };
|
||||
if (options?.relatedTask) {
|
||||
jsonrpcNotification = {
|
||||
...jsonrpcNotification,
|
||||
params: {
|
||||
...jsonrpcNotification.params,
|
||||
_meta: {
|
||||
...jsonrpcNotification.params?._meta,
|
||||
[RELATED_TASK_META_KEY]: options.relatedTask
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
return { queued: false, jsonrpcNotification };
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this._taskProgressTokens.clear();
|
||||
}
|
||||
|
||||
// -- Private helpers --
|
||||
|
||||
private async _enqueueTaskMessage(taskId: string, message: QueuedMessage, sessionId?: string): Promise<void> {
|
||||
if (!this._taskStore || !this._taskMessageQueue) {
|
||||
throw new Error('Cannot enqueue task message: taskStore and taskMessageQueue are not configured');
|
||||
}
|
||||
await this._taskMessageQueue.enqueue(taskId, message, sessionId, this._options.maxTaskQueueSize);
|
||||
}
|
||||
|
||||
private async _clearTaskQueue(taskId: string, sessionId?: string): Promise<void> {
|
||||
if (this._taskMessageQueue) {
|
||||
const messages = await this._taskMessageQueue.dequeueAll(taskId, sessionId);
|
||||
for (const message of messages) {
|
||||
if (message.type === 'request' && isJSONRPCRequest(message.message)) {
|
||||
const requestId = message.message.id as RequestId;
|
||||
const resolver = this._requestResolvers.get(requestId);
|
||||
if (resolver) {
|
||||
resolver(new ProtocolError(ProtocolErrorCode.InternalError, 'Task cancelled or completed'));
|
||||
this._requestResolvers.delete(requestId);
|
||||
} else {
|
||||
this._host?.reportError(new Error(`Resolver missing for request ${requestId} during task ${taskId} cleanup`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _waitForTaskUpdate(pollInterval: number | undefined, signal: AbortSignal): Promise<void> {
|
||||
const interval = pollInterval ?? this._options.defaultTaskPollInterval ?? 1000;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Request cancelled'));
|
||||
return;
|
||||
}
|
||||
const timeoutId = setTimeout(resolve, interval);
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(new ProtocolError(ProtocolErrorCode.InvalidRequest, 'Request cancelled'));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private _cleanupTaskProgressHandler(taskId: string): void {
|
||||
const progressToken = this._taskProgressTokens.get(taskId);
|
||||
if (progressToken !== undefined) {
|
||||
this._host?.removeProgressHandler(progressToken);
|
||||
this._taskProgressTokens.delete(taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op TaskManager used when tasks capability is not configured.
|
||||
* Provides passthrough implementations for the hot paths, avoiding
|
||||
* unnecessary task extraction logic on every request.
|
||||
*/
|
||||
export class NullTaskManager extends TaskManager {
|
||||
constructor() {
|
||||
super({});
|
||||
}
|
||||
|
||||
override processInboundRequest(request: JSONRPCRequest, ctx: InboundContext): InboundResult {
|
||||
const hasTaskCreationParams = isTaskAugmentedRequestParams(request.params) && !!request.params.task;
|
||||
return {
|
||||
taskContext: undefined,
|
||||
sendNotification: (notification: Notification) => ctx.sendNotification(notification),
|
||||
sendRequest: ctx.sendRequest,
|
||||
routeResponse: async () => false,
|
||||
hasTaskCreationParams,
|
||||
validateInbound: hasTaskCreationParams ? () => this._requireHost.assertTaskHandlerCapability(request.method) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
// processOutboundRequest is inherited - it handles task/relatedTask augmentation
|
||||
// and only queues if relatedTask is set (which won't happen without a task store)
|
||||
|
||||
// processInboundResponse is inherited - it checks _requestResolvers (empty for NullTaskManager)
|
||||
// and _taskProgressTokens (empty for NullTaskManager)
|
||||
|
||||
override async processOutboundNotification(
|
||||
notification: Notification,
|
||||
_options?: NotificationOptions
|
||||
): Promise<{ queued: boolean; jsonrpcNotification?: JSONRPCNotification }> {
|
||||
return { queued: false, jsonrpcNotification: { ...notification, jsonrpc: '2.0' } };
|
||||
}
|
||||
|
||||
override onClose(): void {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -39,10 +39,10 @@ describe('Protocol transport handling bug', () => {
|
|||
protected assertNotificationCapability(): void {}
|
||||
protected assertRequestHandlerCapability(): void {}
|
||||
protected assertTaskCapability(): void {}
|
||||
protected assertTaskHandlerCapability(): void {}
|
||||
protected buildContext(ctx: BaseContext): BaseContext {
|
||||
return ctx;
|
||||
}
|
||||
protected assertTaskHandlerCapability(): void {}
|
||||
})();
|
||||
|
||||
transportA = new MockTransport('A');
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import type {
|
||||
AnyObjectSchema,
|
||||
CancelTaskResult,
|
||||
CreateMessageRequestParams,
|
||||
CreateMessageResult,
|
||||
|
|
@ -15,12 +16,13 @@ import type {
|
|||
GetTaskPayloadResult,
|
||||
GetTaskResult,
|
||||
ListTasksResult,
|
||||
Request,
|
||||
RequestMethod,
|
||||
RequestOptions,
|
||||
ResponseMessage,
|
||||
ResultTypeMap
|
||||
} from '@modelcontextprotocol/core';
|
||||
import { GetTaskPayloadResultSchema } from '@modelcontextprotocol/core';
|
||||
import { getResultSchema, GetTaskPayloadResultSchema } from '@modelcontextprotocol/core';
|
||||
|
||||
import type { Server } from '../../server/server.js';
|
||||
|
||||
|
|
@ -39,6 +41,10 @@ import type { Server } from '../../server/server.js';
|
|||
export class ExperimentalServerTasks {
|
||||
constructor(private readonly _server: Server) {}
|
||||
|
||||
private get _module() {
|
||||
return this._server.taskManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a request and returns an AsyncGenerator that yields response messages.
|
||||
* The generator is guaranteed to end with either a `'result'` or `'error'` message.
|
||||
|
|
@ -56,14 +62,12 @@ export class ExperimentalServerTasks {
|
|||
request: { method: M; params?: Record<string, unknown> },
|
||||
options?: RequestOptions
|
||||
): AsyncGenerator<ResponseMessage<ResultTypeMap[M]>, void, void> {
|
||||
// Delegate to the server's underlying Protocol method
|
||||
type ServerWithRequestStream = {
|
||||
requestStream<N extends RequestMethod>(
|
||||
request: { method: N; params?: Record<string, unknown> },
|
||||
options?: RequestOptions
|
||||
): AsyncGenerator<ResponseMessage<ResultTypeMap[N]>, void, void>;
|
||||
};
|
||||
return (this._server as unknown as ServerWithRequestStream).requestStream(request, options);
|
||||
const resultSchema = getResultSchema(request.method) as unknown as AnyObjectSchema;
|
||||
return this._module.requestStream(request as Request, resultSchema, options) as AsyncGenerator<
|
||||
ResponseMessage<ResultTypeMap[M]>,
|
||||
void,
|
||||
void
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -250,8 +254,7 @@ export class ExperimentalServerTasks {
|
|||
* @experimental
|
||||
*/
|
||||
async getTask(taskId: string, options?: RequestOptions): Promise<GetTaskResult> {
|
||||
type ServerWithGetTask = { getTask(params: { taskId: string }, options?: RequestOptions): Promise<GetTaskResult> };
|
||||
return (this._server as unknown as ServerWithGetTask).getTask({ taskId }, options);
|
||||
return this._module.getTask({ taskId }, options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -265,15 +268,7 @@ export class ExperimentalServerTasks {
|
|||
* @experimental
|
||||
*/
|
||||
async getTaskResult(taskId: string, options?: RequestOptions): Promise<GetTaskPayloadResult> {
|
||||
return (
|
||||
this._server as unknown as {
|
||||
getTaskResult: (
|
||||
params: { taskId: string },
|
||||
resultSchema: typeof GetTaskPayloadResultSchema,
|
||||
options?: RequestOptions
|
||||
) => Promise<GetTaskPayloadResult>;
|
||||
}
|
||||
).getTaskResult({ taskId }, GetTaskPayloadResultSchema, options);
|
||||
return this._module.getTaskResult({ taskId }, GetTaskPayloadResultSchema, options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -286,11 +281,7 @@ export class ExperimentalServerTasks {
|
|||
* @experimental
|
||||
*/
|
||||
async listTasks(cursor?: string, options?: RequestOptions): Promise<ListTasksResult> {
|
||||
return (
|
||||
this._server as unknown as {
|
||||
listTasks: (params?: { cursor?: string }, options?: RequestOptions) => Promise<ListTasksResult>;
|
||||
}
|
||||
).listTasks(cursor ? { cursor } : undefined, options);
|
||||
return this._module.listTasks(cursor ? { cursor } : undefined, options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -302,10 +293,6 @@ export class ExperimentalServerTasks {
|
|||
* @experimental
|
||||
*/
|
||||
async cancelTask(taskId: string, options?: RequestOptions): Promise<CancelTaskResult> {
|
||||
return (
|
||||
this._server as unknown as {
|
||||
cancelTask: (params: { taskId: string }, options?: RequestOptions) => Promise<CancelTaskResult>;
|
||||
}
|
||||
).cancelTask({ taskId }, options);
|
||||
return this._module.cancelTask({ taskId }, options);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import type {
|
|||
ServerCapabilities,
|
||||
ServerContext,
|
||||
ServerResult,
|
||||
TaskManagerOptions,
|
||||
ToolResultContent,
|
||||
ToolUseContent
|
||||
} from '@modelcontextprotocol/core';
|
||||
|
|
@ -42,6 +43,7 @@ import {
|
|||
CreateTaskResultSchema,
|
||||
ElicitResultSchema,
|
||||
EmptyResultSchema,
|
||||
extractTaskManagerOptions,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
ListRootsResultSchema,
|
||||
LoggingLevelSchema,
|
||||
|
|
@ -57,11 +59,19 @@ import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'
|
|||
|
||||
import { ExperimentalServerTasks } from '../experimental/tasks/server.js';
|
||||
|
||||
/**
|
||||
* Extended tasks capability that includes runtime configuration (store, messageQueue).
|
||||
* The runtime-only fields are stripped before advertising capabilities to clients.
|
||||
*/
|
||||
export type ServerTasksCapabilityWithRuntime = NonNullable<ServerCapabilities['tasks']> & TaskManagerOptions;
|
||||
|
||||
export type ServerOptions = ProtocolOptions & {
|
||||
/**
|
||||
* Capabilities to advertise as being supported by this server.
|
||||
*/
|
||||
capabilities?: ServerCapabilities;
|
||||
capabilities?: Omit<ServerCapabilities, 'tasks'> & {
|
||||
tasks?: ServerTasksCapabilityWithRuntime;
|
||||
};
|
||||
|
||||
/**
|
||||
* Optional instructions describing how to use the server and its features.
|
||||
|
|
@ -106,11 +116,22 @@ export class Server extends Protocol<ServerContext> {
|
|||
private _serverInfo: Implementation,
|
||||
options?: ServerOptions
|
||||
) {
|
||||
super(options);
|
||||
this._capabilities = options?.capabilities ?? {};
|
||||
super({
|
||||
...options,
|
||||
tasks: extractTaskManagerOptions(options?.capabilities?.tasks)
|
||||
});
|
||||
this._capabilities = options?.capabilities ? { ...options.capabilities } : {};
|
||||
this._instructions = options?.instructions;
|
||||
this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator();
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
this.setRequestHandler('initialize', request => this._oninitialize(request));
|
||||
this.setNotificationHandler('notifications/initialized', () => this.oninitialized?.());
|
||||
|
||||
|
|
@ -347,12 +368,6 @@ export class Server extends Protocol<ServerContext> {
|
|||
}
|
||||
|
||||
protected assertRequestHandlerCapability(method: string): void {
|
||||
// Task handlers are registered in Protocol constructor before _capabilities is initialized
|
||||
// Skip capability check for task methods during initialization
|
||||
if (!this._capabilities) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (method) {
|
||||
case 'completion/complete': {
|
||||
if (!this._capabilities.completions) {
|
||||
|
|
@ -393,19 +408,6 @@ export class Server extends Protocol<ServerContext> {
|
|||
break;
|
||||
}
|
||||
|
||||
case 'tasks/get':
|
||||
case 'tasks/list':
|
||||
case 'tasks/result':
|
||||
case 'tasks/cancel': {
|
||||
if (!this._capabilities.tasks) {
|
||||
throw new SdkError(
|
||||
SdkErrorCode.CapabilityNotSupported,
|
||||
`Server does not support tasks capability (required for ${method})`
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ping':
|
||||
case 'initialize': {
|
||||
// No specific capability required for these methods
|
||||
|
|
@ -419,13 +421,7 @@ export class Server extends Protocol<ServerContext> {
|
|||
}
|
||||
|
||||
protected assertTaskHandlerCapability(method: string): void {
|
||||
// Task handlers are registered in Protocol constructor before _capabilities is initialized
|
||||
// Skip capability check for task methods during initialization
|
||||
if (!this._capabilities) {
|
||||
return;
|
||||
}
|
||||
|
||||
assertToolsCallTaskCapability(this._capabilities.tasks?.requests, method, 'Server');
|
||||
assertToolsCallTaskCapability(this._capabilities?.tasks?.requests, method, 'Server');
|
||||
}
|
||||
|
||||
private async _oninitialize(request: InitializeRequest): Promise<InitializeResult> {
|
||||
|
|
|
|||
|
|
@ -2258,10 +2258,13 @@ describe('outputSchema validation', () => {
|
|||
throw new Error('Unknown tool');
|
||||
});
|
||||
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{ capabilities: { tasks: { requests: { tools: { call: {} } } } } }
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
|
|
@ -2302,10 +2305,11 @@ describe('Task-based execution', () => {
|
|||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore: serverTaskStore
|
||||
}
|
||||
},
|
||||
taskStore: serverTaskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -2342,10 +2346,13 @@ describe('Task-based execution', () => {
|
|||
}
|
||||
);
|
||||
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{ capabilities: { tasks: { requests: { tools: { call: {} } } } } }
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
|
|
@ -2381,10 +2388,11 @@ describe('Task-based execution', () => {
|
|||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore: serverTaskStore
|
||||
}
|
||||
},
|
||||
taskStore: serverTaskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -2421,10 +2429,13 @@ describe('Task-based execution', () => {
|
|||
}
|
||||
);
|
||||
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{ capabilities: { tasks: { requests: { tools: { call: {} } } } } }
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
|
|
@ -2461,10 +2472,11 @@ describe('Task-based execution', () => {
|
|||
call: {},
|
||||
list: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore: serverTaskStore
|
||||
}
|
||||
},
|
||||
taskStore: serverTaskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -2501,10 +2513,13 @@ describe('Task-based execution', () => {
|
|||
}
|
||||
);
|
||||
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{ capabilities: { tasks: { requests: { tools: { call: {} } } } } }
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
|
|
@ -2545,10 +2560,11 @@ describe('Task-based execution', () => {
|
|||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore: serverTaskStore
|
||||
}
|
||||
},
|
||||
taskStore: serverTaskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -2585,10 +2601,13 @@ describe('Task-based execution', () => {
|
|||
}
|
||||
);
|
||||
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{ capabilities: { tasks: { requests: { tools: { call: {} } } } } }
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
|
|
@ -2652,10 +2671,11 @@ describe('Task-based execution', () => {
|
|||
elicitation: {
|
||||
create: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
},
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -2744,10 +2764,11 @@ describe('Task-based execution', () => {
|
|||
elicitation: {
|
||||
create: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
},
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -2835,10 +2856,11 @@ describe('Task-based execution', () => {
|
|||
elicitation: {
|
||||
create: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
},
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -2925,10 +2947,11 @@ describe('Task-based execution', () => {
|
|||
elicitation: {
|
||||
create: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
},
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -3027,10 +3050,11 @@ describe('Task-based execution', () => {
|
|||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore: serverTaskStore
|
||||
}
|
||||
},
|
||||
taskStore: serverTaskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -3152,10 +3176,11 @@ describe('Task-based execution', () => {
|
|||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore: serverTaskStore
|
||||
}
|
||||
},
|
||||
taskStore: serverTaskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -3199,10 +3224,11 @@ describe('Task-based execution', () => {
|
|||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore: serverTaskStore
|
||||
}
|
||||
},
|
||||
taskStore: serverTaskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -3246,10 +3272,11 @@ describe('Task-based execution', () => {
|
|||
elicitation: {
|
||||
create: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
},
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -3300,10 +3327,11 @@ test('should respect server task capabilities', async () => {
|
|||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore: serverTaskStore
|
||||
}
|
||||
},
|
||||
taskStore: serverTaskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -3346,7 +3374,16 @@ test('should respect server task capabilities', async () => {
|
|||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
enforceStrictCapabilities: true
|
||||
enforceStrictCapabilities: true,
|
||||
capabilities: {
|
||||
tasks: {
|
||||
requests: {
|
||||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -3419,7 +3456,7 @@ test('should expose requestStream() method for streaming responses', async () =>
|
|||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {}
|
||||
capabilities: { tasks: { requests: { tools: { call: {} } } } }
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -3481,7 +3518,7 @@ test('should expose callToolStream() method for streaming tool calls', async ()
|
|||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {}
|
||||
capabilities: { tasks: { requests: { tools: { call: {} } } } }
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -3559,7 +3596,7 @@ test('should validate structured output in callToolStream()', async () => {
|
|||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {}
|
||||
capabilities: { tasks: { requests: { tools: { call: {} } } } }
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -3637,7 +3674,7 @@ test('callToolStream() should yield error when structuredContent does not match
|
|||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {}
|
||||
capabilities: { tasks: { requests: { tools: { call: {} } } } }
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -3710,7 +3747,7 @@ test('callToolStream() should yield error when tool with outputSchema returns no
|
|||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {}
|
||||
capabilities: { tasks: { requests: { tools: { call: {} } } } }
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -3775,7 +3812,7 @@ test('callToolStream() should handle tools without outputSchema normally', async
|
|||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {}
|
||||
capabilities: { tasks: { requests: { tools: { call: {} } } } }
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -3870,7 +3907,7 @@ test('callToolStream() should handle complex JSON schema validation', async () =
|
|||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {}
|
||||
capabilities: { tasks: { requests: { tools: { call: {} } } } }
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -3949,7 +3986,7 @@ test('callToolStream() should yield error with additional properties when not al
|
|||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {}
|
||||
capabilities: { tasks: { requests: { tools: { call: {} } } } }
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -4023,7 +4060,7 @@ test('callToolStream() should not validate structuredContent when isError is tru
|
|||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {}
|
||||
capabilities: { tasks: { requests: { tools: { call: {} } } } }
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -50,11 +50,11 @@ export async function createInMemoryTaskEnvironment(options?: {
|
|||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
taskStore,
|
||||
taskMessageQueue: new InMemoryTaskMessageQueue()
|
||||
}
|
||||
},
|
||||
taskStore,
|
||||
taskMessageQueue: new InMemoryTaskMessageQueue()
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1875,182 +1875,195 @@ describe('createMessageStream', () => {
|
|||
}).toThrow('tool_result blocks are not matching any tool_use from the previous message');
|
||||
});
|
||||
|
||||
describe('terminal message guarantees', () => {
|
||||
test('should yield exactly one terminal message for successful request', async () => {
|
||||
const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} });
|
||||
const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } });
|
||||
describe('with tasks', () => {
|
||||
let server: Server;
|
||||
let client: Client;
|
||||
let clientTransport: ReturnType<typeof InMemoryTransport.createLinkedPair>[0];
|
||||
let serverTransport: ReturnType<typeof InMemoryTransport.createLinkedPair>[1];
|
||||
|
||||
client.setRequestHandler('sampling/createMessage', async () => ({
|
||||
role: 'assistant',
|
||||
content: { type: 'text', text: 'Response' },
|
||||
model: 'test-model'
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const stream = server.experimental.tasks.createMessageStream({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }],
|
||||
maxTokens: 100
|
||||
});
|
||||
|
||||
const allMessages = await toArrayAsync(stream);
|
||||
|
||||
expect(allMessages.length).toBe(1);
|
||||
expect(allMessages[0].type).toBe('result');
|
||||
|
||||
const taskMessages = allMessages.filter(m => m.type === 'taskCreated' || m.type === 'taskStatus');
|
||||
expect(taskMessages.length).toBe(0);
|
||||
});
|
||||
|
||||
test('should yield error as terminal message when client returns error', async () => {
|
||||
const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} });
|
||||
const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } });
|
||||
|
||||
client.setRequestHandler('sampling/createMessage', async () => {
|
||||
throw new Error('Simulated client error');
|
||||
});
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const stream = server.experimental.tasks.createMessageStream({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }],
|
||||
maxTokens: 100
|
||||
});
|
||||
|
||||
const allMessages = await toArrayAsync(stream);
|
||||
|
||||
expect(allMessages.length).toBe(1);
|
||||
expect(allMessages[0].type).toBe('error');
|
||||
});
|
||||
|
||||
test('should yield exactly one terminal message with result', async () => {
|
||||
const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} });
|
||||
const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } });
|
||||
|
||||
client.setRequestHandler('sampling/createMessage', () => ({
|
||||
model: 'test-model',
|
||||
role: 'assistant' as const,
|
||||
content: { type: 'text' as const, text: 'Response' }
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const stream = server.experimental.tasks.createMessageStream({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'Message' } }],
|
||||
maxTokens: 100
|
||||
});
|
||||
|
||||
const messages = await toArrayAsync(stream);
|
||||
const terminalMessages = messages.filter(m => m.type === 'result' || m.type === 'error');
|
||||
|
||||
expect(terminalMessages.length).toBe(1);
|
||||
|
||||
const lastMessage = messages.at(-1);
|
||||
expect(lastMessage.type === 'result' || lastMessage.type === 'error').toBe(true);
|
||||
|
||||
if (lastMessage.type === 'result') {
|
||||
expect((lastMessage.result as CreateMessageResult).content).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-task request minimality', () => {
|
||||
test('should yield only result message for non-task request', async () => {
|
||||
const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} });
|
||||
const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } });
|
||||
|
||||
client.setRequestHandler('sampling/createMessage', () => ({
|
||||
model: 'test-model',
|
||||
role: 'assistant' as const,
|
||||
content: { type: 'text' as const, text: 'Response' }
|
||||
}));
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const stream = server.experimental.tasks.createMessageStream({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'Message' } }],
|
||||
maxTokens: 100
|
||||
});
|
||||
|
||||
const messages = await toArrayAsync(stream);
|
||||
|
||||
const taskMessages = messages.filter(m => m.type === 'taskCreated' || m.type === 'taskStatus');
|
||||
expect(taskMessages.length).toBe(0);
|
||||
|
||||
const resultMessages = messages.filter(m => m.type === 'result');
|
||||
expect(resultMessages.length).toBe(1);
|
||||
|
||||
expect(messages.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('task-augmented request handling', () => {
|
||||
test('should yield taskCreated and result for task-augmented request', async () => {
|
||||
const clientTaskStore = new InMemoryTaskStore();
|
||||
const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} });
|
||||
const client = new Client(
|
||||
{ name: 'test client', version: '1.0' },
|
||||
beforeEach(async () => {
|
||||
server = new Server(
|
||||
{ name: 'test server', version: '1.0' },
|
||||
{
|
||||
capabilities: {
|
||||
sampling: {},
|
||||
tasks: {
|
||||
requests: {
|
||||
sampling: { createMessage: {} }
|
||||
}
|
||||
taskStore: new InMemoryTaskStore()
|
||||
}
|
||||
},
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
client.setRequestHandler('sampling/createMessage', async (request, extra) => {
|
||||
const result = {
|
||||
model: 'test-model',
|
||||
role: 'assistant' as const,
|
||||
content: { type: 'text' as const, text: 'Task response' }
|
||||
};
|
||||
client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } });
|
||||
|
||||
if (request.params.task && extra.task?.store) {
|
||||
const task = await extra.task.store.createTask({ ttl: extra.task.requestedTtl });
|
||||
await extra.task.store.storeTaskResult(task.taskId, 'completed', result);
|
||||
return { task };
|
||||
}
|
||||
return result;
|
||||
[clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close().catch(() => {});
|
||||
await client.close().catch(() => {});
|
||||
});
|
||||
|
||||
describe('terminal message guarantees', () => {
|
||||
test('should yield exactly one terminal message for successful request', async () => {
|
||||
client.setRequestHandler('sampling/createMessage', async () => ({
|
||||
role: 'assistant',
|
||||
content: { type: 'text', text: 'Response' },
|
||||
model: 'test-model'
|
||||
}));
|
||||
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const stream = server.experimental.tasks.createMessageStream({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }],
|
||||
maxTokens: 100
|
||||
});
|
||||
|
||||
const allMessages = await toArrayAsync(stream);
|
||||
|
||||
expect(allMessages.length).toBe(1);
|
||||
expect(allMessages[0].type).toBe('result');
|
||||
|
||||
const taskMessages = allMessages.filter(m => m.type === 'taskCreated' || m.type === 'taskStatus');
|
||||
expect(taskMessages.length).toBe(0);
|
||||
});
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
test('should yield error as terminal message when client returns error', async () => {
|
||||
client.setRequestHandler('sampling/createMessage', async () => {
|
||||
throw new Error('Simulated client error');
|
||||
});
|
||||
|
||||
const stream = server.experimental.tasks.createMessageStream(
|
||||
{
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'Task-augmented message' } }],
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const stream = server.experimental.tasks.createMessageStream({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }],
|
||||
maxTokens: 100
|
||||
},
|
||||
{ task: { ttl: 60_000 } }
|
||||
);
|
||||
});
|
||||
|
||||
const messages = await toArrayAsync(stream);
|
||||
const allMessages = await toArrayAsync(stream);
|
||||
|
||||
// Should have taskCreated and result
|
||||
expect(messages.length).toBeGreaterThanOrEqual(2);
|
||||
expect(allMessages.length).toBe(1);
|
||||
expect(allMessages[0].type).toBe('error');
|
||||
});
|
||||
|
||||
// First message should be taskCreated
|
||||
expect(messages[0].type).toBe('taskCreated');
|
||||
const taskCreated = messages[0] as { type: 'taskCreated'; task: Task };
|
||||
expect(taskCreated.task.taskId).toBeDefined();
|
||||
test('should yield exactly one terminal message with result', async () => {
|
||||
client.setRequestHandler('sampling/createMessage', () => ({
|
||||
model: 'test-model',
|
||||
role: 'assistant' as const,
|
||||
content: { type: 'text' as const, text: 'Response' }
|
||||
}));
|
||||
|
||||
// Last message should be result
|
||||
const lastMessage = messages.at(-1);
|
||||
expect(lastMessage.type).toBe('result');
|
||||
if (lastMessage.type === 'result') {
|
||||
expect((lastMessage.result as CreateMessageResult).model).toBe('test-model');
|
||||
}
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
clientTaskStore.cleanup();
|
||||
const stream = server.experimental.tasks.createMessageStream({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'Message' } }],
|
||||
maxTokens: 100
|
||||
});
|
||||
|
||||
const messages = await toArrayAsync(stream);
|
||||
const terminalMessages = messages.filter(m => m.type === 'result' || m.type === 'error');
|
||||
|
||||
expect(terminalMessages.length).toBe(1);
|
||||
|
||||
const lastMessage = messages.at(-1);
|
||||
expect(lastMessage.type === 'result' || lastMessage.type === 'error').toBe(true);
|
||||
|
||||
if (lastMessage.type === 'result') {
|
||||
expect((lastMessage.result as CreateMessageResult).content).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-task request minimality', () => {
|
||||
test('should yield only result message for non-task request', async () => {
|
||||
client.setRequestHandler('sampling/createMessage', () => ({
|
||||
model: 'test-model',
|
||||
role: 'assistant' as const,
|
||||
content: { type: 'text' as const, text: 'Response' }
|
||||
}));
|
||||
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const stream = server.experimental.tasks.createMessageStream({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'Message' } }],
|
||||
maxTokens: 100
|
||||
});
|
||||
|
||||
const messages = await toArrayAsync(stream);
|
||||
|
||||
const taskMessages = messages.filter(m => m.type === 'taskCreated' || m.type === 'taskStatus');
|
||||
expect(taskMessages.length).toBe(0);
|
||||
|
||||
const resultMessages = messages.filter(m => m.type === 'result');
|
||||
expect(resultMessages.length).toBe(1);
|
||||
|
||||
expect(messages.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('task-augmented request handling', () => {
|
||||
test('should yield taskCreated and result for task-augmented request', async () => {
|
||||
const clientTaskStore = new InMemoryTaskStore();
|
||||
const taskClient = new Client(
|
||||
{ name: 'test client', version: '1.0' },
|
||||
{
|
||||
capabilities: {
|
||||
sampling: {},
|
||||
tasks: {
|
||||
taskStore: clientTaskStore,
|
||||
requests: {
|
||||
sampling: { createMessage: {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
taskClient.setRequestHandler('sampling/createMessage', async (request, extra) => {
|
||||
const result = {
|
||||
model: 'test-model',
|
||||
role: 'assistant' as const,
|
||||
content: { type: 'text' as const, text: 'Task response' }
|
||||
};
|
||||
|
||||
if (request.params.task && extra.task?.store) {
|
||||
const task = await extra.task.store.createTask({ ttl: extra.task.requestedTtl });
|
||||
await extra.task.store.storeTaskResult(task.taskId, 'completed', result);
|
||||
return { task };
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const [taskClientTransport, taskServerTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([taskClient.connect(taskClientTransport), server.connect(taskServerTransport)]);
|
||||
|
||||
const stream = server.experimental.tasks.createMessageStream(
|
||||
{
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'Task-augmented message' } }],
|
||||
maxTokens: 100
|
||||
},
|
||||
{ task: { ttl: 60_000 } }
|
||||
);
|
||||
|
||||
const messages = await toArrayAsync(stream);
|
||||
|
||||
// Should have taskCreated and result
|
||||
expect(messages.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// First message should be taskCreated
|
||||
expect(messages[0].type).toBe('taskCreated');
|
||||
const taskCreated = messages[0] as { type: 'taskCreated'; task: Task };
|
||||
expect(taskCreated.task.taskId).toBeDefined();
|
||||
|
||||
// Last message should be result
|
||||
const lastMessage = messages.at(-1);
|
||||
expect(lastMessage.type).toBe('result');
|
||||
if (lastMessage.type === 'result') {
|
||||
expect((lastMessage.result as CreateMessageResult).model).toBe('test-model');
|
||||
}
|
||||
|
||||
clientTaskStore.cleanup();
|
||||
await taskClient.close().catch(() => {});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -2362,10 +2375,11 @@ describe('Task-based execution', () => {
|
|||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore
|
||||
}
|
||||
},
|
||||
taskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -2542,10 +2556,11 @@ describe('Task-based execution', () => {
|
|||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore
|
||||
}
|
||||
},
|
||||
taskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -2719,10 +2734,11 @@ describe('Task-based execution', () => {
|
|||
elicitation: {
|
||||
create: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
},
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -2746,10 +2762,23 @@ describe('Task-based execution', () => {
|
|||
return result;
|
||||
});
|
||||
|
||||
const server = new Server({
|
||||
name: 'test-server',
|
||||
version: '1.0.0'
|
||||
});
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'test-server',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tasks: {
|
||||
requests: {
|
||||
elicitation: {
|
||||
create: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
|
|
@ -2799,10 +2828,11 @@ describe('Task-based execution', () => {
|
|||
elicitation: {
|
||||
create: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
},
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -2826,10 +2856,21 @@ describe('Task-based execution', () => {
|
|||
return result;
|
||||
});
|
||||
|
||||
const server = new Server({
|
||||
name: 'test-server',
|
||||
version: '1.0.0'
|
||||
});
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'test-server',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tasks: {
|
||||
requests: {
|
||||
elicitation: { create: {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
|
|
@ -2877,10 +2918,11 @@ describe('Task-based execution', () => {
|
|||
elicitation: {
|
||||
create: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
},
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -2904,10 +2946,21 @@ describe('Task-based execution', () => {
|
|||
return result;
|
||||
});
|
||||
|
||||
const server = new Server({
|
||||
name: 'test-server',
|
||||
version: '1.0.0'
|
||||
});
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'test-server',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tasks: {
|
||||
requests: {
|
||||
elicitation: { create: {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
|
|
@ -2957,10 +3010,11 @@ describe('Task-based execution', () => {
|
|||
elicitation: {
|
||||
create: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
},
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -3059,10 +3113,11 @@ describe('Task-based execution', () => {
|
|||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore
|
||||
}
|
||||
},
|
||||
taskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -3198,10 +3253,11 @@ describe('Task-based execution', () => {
|
|||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore
|
||||
}
|
||||
},
|
||||
taskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -3245,10 +3301,11 @@ describe('Task-based execution', () => {
|
|||
elicitation: {
|
||||
create: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
},
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -3302,10 +3359,11 @@ test('should respect client task capabilities', async () => {
|
|||
elicitation: {
|
||||
create: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
},
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -3414,7 +3472,16 @@ describe('elicitInputStream', () => {
|
|||
let serverTransport: ReturnType<typeof InMemoryTransport.createLinkedPair>[1];
|
||||
|
||||
beforeEach(async () => {
|
||||
server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} });
|
||||
server = new Server(
|
||||
{ name: 'test server', version: '1.0' },
|
||||
{
|
||||
capabilities: {
|
||||
tasks: {
|
||||
taskStore: new InMemoryTaskStore()
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
client = new Client(
|
||||
{ name: 'test client', version: '1.0' },
|
||||
|
|
@ -3643,12 +3710,12 @@ describe('elicitInputStream', () => {
|
|||
capabilities: {
|
||||
elicitation: { form: {} },
|
||||
tasks: {
|
||||
taskStore: clientTaskStore,
|
||||
requests: {
|
||||
elicitation: { create: {} }
|
||||
}
|
||||
}
|
||||
},
|
||||
taskStore: clientTaskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -2035,10 +2035,11 @@ describe('Zod v4', () => {
|
|||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore
|
||||
}
|
||||
},
|
||||
taskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -2104,10 +2105,11 @@ describe('Zod v4', () => {
|
|||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore
|
||||
}
|
||||
},
|
||||
taskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -6367,10 +6369,11 @@ describe('Zod v4', () => {
|
|||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore
|
||||
}
|
||||
},
|
||||
taskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -6469,10 +6472,11 @@ describe('Zod v4', () => {
|
|||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore
|
||||
}
|
||||
},
|
||||
taskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -6574,10 +6578,11 @@ describe('Zod v4', () => {
|
|||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore
|
||||
}
|
||||
},
|
||||
taskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -6694,10 +6699,11 @@ describe('Zod v4', () => {
|
|||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore
|
||||
}
|
||||
},
|
||||
taskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -6797,10 +6803,11 @@ describe('Zod v4', () => {
|
|||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore
|
||||
}
|
||||
},
|
||||
taskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -6895,10 +6902,11 @@ describe('Zod v4', () => {
|
|||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore
|
||||
}
|
||||
},
|
||||
taskStore
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -39,11 +39,11 @@ describe('Task Lifecycle Integration Tests', () => {
|
|||
}
|
||||
},
|
||||
list: {},
|
||||
cancel: {}
|
||||
cancel: {},
|
||||
taskStore,
|
||||
taskMessageQueue: new InMemoryTaskMessageQueue()
|
||||
}
|
||||
},
|
||||
taskStore,
|
||||
taskMessageQueue: new InMemoryTaskMessageQueue()
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -300,10 +300,15 @@ describe('Task Lifecycle Integration Tests', () => {
|
|||
|
||||
describe('Task Cancellation', () => {
|
||||
it('should cancel a working task and return the cancelled task', async () => {
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: { tasks: {} }
|
||||
}
|
||||
);
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client.connect(transport);
|
||||
|
|
@ -346,10 +351,15 @@ describe('Task Lifecycle Integration Tests', () => {
|
|||
});
|
||||
|
||||
it('should reject cancellation of completed task with error code -32602', async () => {
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: { tasks: {} }
|
||||
}
|
||||
);
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client.connect(transport);
|
||||
|
|
@ -734,10 +744,15 @@ describe('Task Lifecycle Integration Tests', () => {
|
|||
|
||||
describe('Error Handling', () => {
|
||||
it('should return error code -32602 for non-existent task in tasks/get', async () => {
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: { tasks: {} }
|
||||
}
|
||||
);
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client.connect(transport);
|
||||
|
|
@ -754,10 +769,15 @@ describe('Task Lifecycle Integration Tests', () => {
|
|||
});
|
||||
|
||||
it('should return error code -32602 for non-existent task in tasks/cancel', async () => {
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: { tasks: {} }
|
||||
}
|
||||
);
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client.connect(transport);
|
||||
|
|
@ -1491,7 +1511,8 @@ describe('Task Lifecycle Integration Tests', () => {
|
|||
},
|
||||
{
|
||||
capabilities: {
|
||||
elicitation: {}
|
||||
elicitation: {},
|
||||
tasks: {}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue