[SEP-2663] refactor!: remove 2025-11 experimental tasks (#2128)
This commit is contained in:
parent
600ba75f34
commit
c8d7401b46
7 changed files with 423 additions and 18280 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -1,144 +0,0 @@
|
|||
import type { Task } from '@modelcontextprotocol/core';
|
||||
import { isTerminal, TaskCreationParamsSchema } from '@modelcontextprotocol/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Task utility functions', () => {
|
||||
describe('isTerminal', () => {
|
||||
it('should return true for completed status', () => {
|
||||
expect(isTerminal('completed')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for failed status', () => {
|
||||
expect(isTerminal('failed')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for cancelled status', () => {
|
||||
expect(isTerminal('cancelled')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for working status', () => {
|
||||
expect(isTerminal('working')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for input_required status', () => {
|
||||
expect(isTerminal('input_required')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task Schema Validation', () => {
|
||||
it('should validate task with ttl field', () => {
|
||||
const createdAt = new Date().toISOString();
|
||||
const task: Task = {
|
||||
taskId: 'test-123',
|
||||
status: 'working',
|
||||
ttl: 60_000,
|
||||
createdAt,
|
||||
lastUpdatedAt: createdAt,
|
||||
pollInterval: 1000
|
||||
};
|
||||
|
||||
expect(task.ttl).toBe(60_000);
|
||||
expect(task.createdAt).toBeDefined();
|
||||
expect(typeof task.createdAt).toBe('string');
|
||||
});
|
||||
|
||||
it('should validate task with null ttl', () => {
|
||||
const createdAt = new Date().toISOString();
|
||||
const task: Task = {
|
||||
taskId: 'test-456',
|
||||
status: 'completed',
|
||||
ttl: null,
|
||||
createdAt,
|
||||
lastUpdatedAt: createdAt
|
||||
};
|
||||
|
||||
expect(task.ttl).toBeNull();
|
||||
});
|
||||
|
||||
it('should validate task with statusMessage field', () => {
|
||||
const createdAt = new Date().toISOString();
|
||||
const task: Task = {
|
||||
taskId: 'test-789',
|
||||
status: 'failed',
|
||||
ttl: null,
|
||||
createdAt,
|
||||
lastUpdatedAt: createdAt,
|
||||
statusMessage: 'Operation failed due to timeout'
|
||||
};
|
||||
|
||||
expect(task.statusMessage).toBe('Operation failed due to timeout');
|
||||
});
|
||||
|
||||
it('should validate task with createdAt in ISO 8601 format', () => {
|
||||
const now = new Date();
|
||||
const createdAt = now.toISOString();
|
||||
const task: Task = {
|
||||
taskId: 'test-iso',
|
||||
status: 'working',
|
||||
ttl: 30_000,
|
||||
createdAt,
|
||||
lastUpdatedAt: createdAt
|
||||
};
|
||||
|
||||
expect(task.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
expect(new Date(task.createdAt).getTime()).toBe(now.getTime());
|
||||
});
|
||||
|
||||
it('should validate task with lastUpdatedAt in ISO 8601 format', () => {
|
||||
const now = new Date();
|
||||
const createdAt = now.toISOString();
|
||||
const task: Task = {
|
||||
taskId: 'test-iso',
|
||||
status: 'working',
|
||||
ttl: 30_000,
|
||||
createdAt,
|
||||
lastUpdatedAt: createdAt
|
||||
};
|
||||
|
||||
expect(task.lastUpdatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
});
|
||||
|
||||
it('should validate all task statuses', () => {
|
||||
const statuses: Task['status'][] = ['working', 'input_required', 'completed', 'failed', 'cancelled'];
|
||||
|
||||
const createdAt = new Date().toISOString();
|
||||
for (const status of statuses) {
|
||||
const task: Task = {
|
||||
taskId: `test-${status}`,
|
||||
status,
|
||||
ttl: null,
|
||||
createdAt,
|
||||
lastUpdatedAt: createdAt
|
||||
};
|
||||
expect(task.status).toBe(status);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('TaskCreationParams Schema Validation', () => {
|
||||
it('should accept ttl as a number', () => {
|
||||
const result = TaskCreationParamsSchema.safeParse({ ttl: 60_000 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept missing ttl (optional)', () => {
|
||||
const result = TaskCreationParamsSchema.safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject null ttl (not allowed in request, only response)', () => {
|
||||
const result = TaskCreationParamsSchema.safeParse({ ttl: null });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept pollInterval as a number', () => {
|
||||
const result = TaskCreationParamsSchema.safeParse({ pollInterval: 1000 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept both ttl and pollInterval', () => {
|
||||
const result = TaskCreationParamsSchema.safeParse({ ttl: 60_000, pollInterval: 1000 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
import { ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/core';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createInMemoryTaskEnvironment } from '../../helpers/mcp.js';
|
||||
|
||||
describe('Task Listing with Pagination', () => {
|
||||
let client: Awaited<ReturnType<typeof createInMemoryTaskEnvironment>>['client'];
|
||||
let server: Awaited<ReturnType<typeof createInMemoryTaskEnvironment>>['server'];
|
||||
let taskStore: Awaited<ReturnType<typeof createInMemoryTaskEnvironment>>['taskStore'];
|
||||
|
||||
beforeEach(async () => {
|
||||
const env = await createInMemoryTaskEnvironment();
|
||||
client = env.client;
|
||||
server = env.server;
|
||||
taskStore = env.taskStore;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
taskStore.cleanup();
|
||||
await client.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('should return empty list when no tasks exist', async () => {
|
||||
const result = await client.experimental.tasks.listTasks();
|
||||
|
||||
expect(result.tasks).toEqual([]);
|
||||
expect(result.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return all tasks when less than page size', async () => {
|
||||
// Create 3 tasks
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await taskStore.createTask({}, i, {
|
||||
method: 'tools/call',
|
||||
params: { name: 'test-tool' }
|
||||
});
|
||||
}
|
||||
|
||||
const result = await client.experimental.tasks.listTasks();
|
||||
|
||||
expect(result.tasks).toHaveLength(3);
|
||||
expect(result.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should paginate when more than page size exists', async () => {
|
||||
// Create 15 tasks (page size is 10 in InMemoryTaskStore)
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await taskStore.createTask({}, i, {
|
||||
method: 'tools/call',
|
||||
params: { name: 'test-tool' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get first page
|
||||
const page1 = await client.experimental.tasks.listTasks();
|
||||
expect(page1.tasks).toHaveLength(10);
|
||||
expect(page1.nextCursor).toBeDefined();
|
||||
|
||||
// Get second page using cursor
|
||||
const page2 = await client.experimental.tasks.listTasks(page1.nextCursor);
|
||||
expect(page2.tasks).toHaveLength(5);
|
||||
expect(page2.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should treat cursor as opaque token', async () => {
|
||||
// Create 5 tasks
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await taskStore.createTask({}, i, {
|
||||
method: 'tools/call',
|
||||
params: { name: 'test-tool' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get all tasks to get a valid cursor
|
||||
const allTasks = taskStore.getAllTasks();
|
||||
const validCursor = allTasks[2]!.taskId;
|
||||
|
||||
// Use the cursor - should work even though we don't know its internal structure
|
||||
const result = await client.experimental.tasks.listTasks(validCursor);
|
||||
expect(result.tasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return error code -32602 for invalid cursor', async () => {
|
||||
await taskStore.createTask({}, 1, {
|
||||
method: 'tools/call',
|
||||
params: { name: 'test-tool' }
|
||||
});
|
||||
|
||||
// Try to use an invalid cursor - should return -32602 (Invalid params) per MCP spec
|
||||
await expect(client.experimental.tasks.listTasks('invalid-cursor')).rejects.toSatisfy((error: ProtocolError) => {
|
||||
expect(error).toBeInstanceOf(ProtocolError);
|
||||
expect(error.code).toBe(ProtocolErrorCode.InvalidParams);
|
||||
expect(error.message).toContain('Invalid cursor');
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it('should ensure tasks accessible via tasks/get are also accessible via tasks/list', async () => {
|
||||
// Create a task
|
||||
const task = await taskStore.createTask({}, 1, {
|
||||
method: 'tools/call',
|
||||
params: { name: 'test-tool' }
|
||||
});
|
||||
|
||||
// Verify it's accessible via tasks/get
|
||||
const getResult = await client.experimental.tasks.getTask(task.taskId);
|
||||
expect(getResult.taskId).toBe(task.taskId);
|
||||
|
||||
// Verify it's also accessible via tasks/list
|
||||
const listResult = await client.experimental.tasks.listTasks();
|
||||
expect(listResult.tasks).toHaveLength(1);
|
||||
expect(listResult.tasks[0]!.taskId).toBe(task.taskId);
|
||||
});
|
||||
|
||||
it('should not include related-task metadata in list response', async () => {
|
||||
// Create a task
|
||||
await taskStore.createTask({}, 1, {
|
||||
method: 'tools/call',
|
||||
params: { name: 'test-tool' }
|
||||
});
|
||||
|
||||
const result = await client.experimental.tasks.listTasks();
|
||||
|
||||
// The response should have _meta but not include related-task metadata
|
||||
expect(result._meta).toBeDefined();
|
||||
expect(result._meta?.['io.modelcontextprotocol/related-task']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import { Client } from '@modelcontextprotocol/client';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/core';
|
||||
import type { ClientCapabilities, ServerCapabilities } from '@modelcontextprotocol/server';
|
||||
import { InMemoryTaskMessageQueue, InMemoryTaskStore, Server } from '@modelcontextprotocol/server';
|
||||
|
||||
export interface InMemoryTaskEnvironment {
|
||||
client: Client;
|
||||
server: Server;
|
||||
taskStore: InMemoryTaskStore;
|
||||
clientTransport: InMemoryTransport;
|
||||
serverTransport: InMemoryTransport;
|
||||
}
|
||||
|
||||
export async function createInMemoryTaskEnvironment(options?: {
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
serverCapabilities?: ServerCapabilities;
|
||||
}): Promise<InMemoryTaskEnvironment> {
|
||||
const taskStore = new InMemoryTaskStore();
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: options?.clientCapabilities ?? {
|
||||
tasks: {
|
||||
list: {},
|
||||
requests: {
|
||||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'test-server',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: options?.serverCapabilities ?? {
|
||||
tasks: {
|
||||
list: {},
|
||||
requests: {
|
||||
tools: {
|
||||
call: {}
|
||||
}
|
||||
},
|
||||
taskStore,
|
||||
taskMessageQueue: new InMemoryTaskMessageQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
return {
|
||||
client,
|
||||
server,
|
||||
taskStore,
|
||||
clientTransport,
|
||||
serverTransport
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,33 +1,10 @@
|
|||
import { Client } from '@modelcontextprotocol/client';
|
||||
import type { CallToolResult, Notification, TextContent } from '@modelcontextprotocol/core';
|
||||
import {
|
||||
getDisplayName,
|
||||
InMemoryTaskStore,
|
||||
InMemoryTransport,
|
||||
ProtocolErrorCode,
|
||||
UriTemplate,
|
||||
UrlElicitationRequiredError
|
||||
} from '@modelcontextprotocol/core';
|
||||
import type { Notification, TextContent } from '@modelcontextprotocol/core';
|
||||
import { getDisplayName, InMemoryTransport, ProtocolErrorCode, UriTemplate, UrlElicitationRequiredError } from '@modelcontextprotocol/core';
|
||||
import { completable, McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
function createLatch() {
|
||||
let latch = false;
|
||||
const waitForLatch = async () => {
|
||||
while (!latch) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
releaseLatch: () => {
|
||||
latch = true;
|
||||
},
|
||||
waitForLatch
|
||||
};
|
||||
}
|
||||
|
||||
describe('Zod v4', () => {
|
||||
describe('McpServer', () => {
|
||||
/***
|
||||
|
|
@ -2019,146 +1996,6 @@ describe('Zod v4', () => {
|
|||
expect(result.tools[0]!._meta).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should include execution field in listTools response when tool has execution settings', async () => {
|
||||
const taskStore = new InMemoryTaskStore();
|
||||
|
||||
const mcpServer = new McpServer(
|
||||
{
|
||||
name: 'test server',
|
||||
version: '1.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
tasks: {
|
||||
requests: {
|
||||
tools: {
|
||||
call: {}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({
|
||||
name: 'test client',
|
||||
version: '1.0'
|
||||
});
|
||||
|
||||
// Register a tool with execution.taskSupport
|
||||
mcpServer.experimental.tasks.registerToolTask(
|
||||
'task-tool',
|
||||
{
|
||||
description: 'A tool with task support',
|
||||
inputSchema: z.object({ input: z.string() }),
|
||||
execution: {
|
||||
taskSupport: 'required'
|
||||
}
|
||||
},
|
||||
{
|
||||
createTask: async (_args, ctx) => {
|
||||
const task = await ctx.task.store.createTask({ ttl: 60_000 });
|
||||
return { task };
|
||||
},
|
||||
getTask: async (_args, ctx) => {
|
||||
const task = await ctx.task.store.getTask(ctx.task.id);
|
||||
if (!task) throw new Error('Task not found');
|
||||
return task;
|
||||
},
|
||||
getTaskResult: async (_args, ctx) => {
|
||||
return (await ctx.task.store.getTaskResult(ctx.task.id)) as CallToolResult;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
|
||||
|
||||
const result = await client.request({ method: 'tools/list' });
|
||||
|
||||
expect(result.tools).toHaveLength(1);
|
||||
expect(result.tools[0]!.name).toBe('task-tool');
|
||||
expect(result.tools[0]!.execution).toEqual({
|
||||
taskSupport: 'required'
|
||||
});
|
||||
|
||||
taskStore.cleanup();
|
||||
});
|
||||
|
||||
test('should include execution field with taskSupport optional in listTools response', async () => {
|
||||
const taskStore = new InMemoryTaskStore();
|
||||
|
||||
const mcpServer = new McpServer(
|
||||
{
|
||||
name: 'test server',
|
||||
version: '1.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
tasks: {
|
||||
requests: {
|
||||
tools: {
|
||||
call: {}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client({
|
||||
name: 'test client',
|
||||
version: '1.0'
|
||||
});
|
||||
|
||||
// Register a tool with execution.taskSupport optional
|
||||
mcpServer.experimental.tasks.registerToolTask(
|
||||
'optional-task-tool',
|
||||
{
|
||||
description: 'A tool with optional task support',
|
||||
inputSchema: z.object({ input: z.string() }),
|
||||
execution: {
|
||||
taskSupport: 'optional'
|
||||
}
|
||||
},
|
||||
{
|
||||
createTask: async (_args, ctx) => {
|
||||
const task = await ctx.task.store.createTask({ ttl: 60_000 });
|
||||
return { task };
|
||||
},
|
||||
getTask: async (_args, ctx) => {
|
||||
const task = await ctx.task.store.getTask(ctx.task.id);
|
||||
if (!task) throw new Error('Task not found');
|
||||
return task;
|
||||
},
|
||||
getTaskResult: async (_args, ctx) => {
|
||||
return (await ctx.task.store.getTaskResult(ctx.task.id)) as CallToolResult;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
|
||||
|
||||
const result = await client.request({ method: 'tools/list' });
|
||||
|
||||
expect(result.tools).toHaveLength(1);
|
||||
expect(result.tools[0]!.name).toBe('optional-task-tool');
|
||||
expect(result.tools[0]!.execution).toEqual({
|
||||
taskSupport: 'optional'
|
||||
});
|
||||
|
||||
taskStore.cleanup();
|
||||
});
|
||||
|
||||
test('should validate tool names according to SEP specification', () => {
|
||||
// Create a new server instance for this test
|
||||
const testServer = new McpServer({
|
||||
|
|
@ -6445,598 +6282,10 @@ describe('Zod v4', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Tool-level task hints with automatic polling wrapper', () => {
|
||||
test('should return error for tool with taskSupport "required" called without task augmentation', async () => {
|
||||
const taskStore = new InMemoryTaskStore();
|
||||
|
||||
const mcpServer = new McpServer(
|
||||
{
|
||||
name: 'test server',
|
||||
version: '1.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
tasks: {
|
||||
requests: {
|
||||
tools: {
|
||||
call: {}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'test client',
|
||||
version: '1.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tasks: {
|
||||
requests: {
|
||||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Register a task-based tool with taskSupport "required"
|
||||
mcpServer.experimental.tasks.registerToolTask(
|
||||
'long-running-task',
|
||||
{
|
||||
description: 'A long running task',
|
||||
inputSchema: z.object({
|
||||
input: z.string()
|
||||
}),
|
||||
execution: {
|
||||
taskSupport: 'required'
|
||||
}
|
||||
},
|
||||
{
|
||||
createTask: async ({ input }, ctx) => {
|
||||
const task = await ctx.task.store.createTask({ ttl: 60_000, pollInterval: 100 });
|
||||
|
||||
// Capture taskStore for use in setTimeout
|
||||
const store = ctx.task.store;
|
||||
|
||||
// Simulate async work
|
||||
setTimeout(async () => {
|
||||
await store.storeTaskResult(task.taskId, 'completed', {
|
||||
content: [{ type: 'text' as const, text: `Processed: ${input}` }]
|
||||
});
|
||||
}, 200);
|
||||
|
||||
return { task };
|
||||
},
|
||||
getTask: async (_args, ctx) => {
|
||||
const task = await ctx.task.store.getTask(ctx.task.id);
|
||||
if (!task) {
|
||||
throw new Error('Task not found');
|
||||
}
|
||||
return task;
|
||||
},
|
||||
getTaskResult: async (_input, ctx) => {
|
||||
const result = await ctx.task.store.getTaskResult(ctx.task.id);
|
||||
return result as CallToolResult;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
|
||||
|
||||
// Call the tool WITHOUT task augmentation - should return error
|
||||
const result = await client.callTool({
|
||||
name: 'long-running-task',
|
||||
arguments: { input: 'test data' }
|
||||
});
|
||||
|
||||
// Should receive error result
|
||||
expect(result.isError).toBe(true);
|
||||
const content = result.content as TextContent[];
|
||||
expect(content[0]!.text).toContain('requires task augmentation');
|
||||
|
||||
taskStore.cleanup();
|
||||
});
|
||||
|
||||
test('should automatically poll and return CallToolResult for tool with taskSupport "optional" called without task augmentation', async () => {
|
||||
const taskStore = new InMemoryTaskStore();
|
||||
const { releaseLatch, waitForLatch } = createLatch();
|
||||
|
||||
const mcpServer = new McpServer(
|
||||
{
|
||||
name: 'test server',
|
||||
version: '1.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
tasks: {
|
||||
requests: {
|
||||
tools: {
|
||||
call: {}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'test client',
|
||||
version: '1.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tasks: {
|
||||
requests: {
|
||||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Register a task-based tool with taskSupport "optional"
|
||||
mcpServer.experimental.tasks.registerToolTask(
|
||||
'optional-task',
|
||||
{
|
||||
description: 'An optional task',
|
||||
inputSchema: z.object({
|
||||
value: z.number()
|
||||
}),
|
||||
execution: {
|
||||
taskSupport: 'optional'
|
||||
}
|
||||
},
|
||||
{
|
||||
createTask: async ({ value }, ctx) => {
|
||||
const task = await ctx.task.store.createTask({ ttl: 60_000, pollInterval: 100 });
|
||||
|
||||
// Capture taskStore for use in setTimeout
|
||||
const store = ctx.task.store;
|
||||
|
||||
// Simulate async work
|
||||
setTimeout(async () => {
|
||||
await store.storeTaskResult(task.taskId, 'completed', {
|
||||
content: [{ type: 'text' as const, text: `Result: ${value * 2}` }]
|
||||
});
|
||||
releaseLatch();
|
||||
}, 150);
|
||||
|
||||
return { task };
|
||||
},
|
||||
getTask: async (_args, ctx) => {
|
||||
const task = await ctx.task.store.getTask(ctx.task.id);
|
||||
if (!task) {
|
||||
throw new Error('Task not found');
|
||||
}
|
||||
return task;
|
||||
},
|
||||
getTaskResult: async (_value, ctx) => {
|
||||
const result = await ctx.task.store.getTaskResult(ctx.task.id);
|
||||
return result as CallToolResult;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
|
||||
|
||||
// Call the tool WITHOUT task augmentation
|
||||
const result = await client.callTool({
|
||||
name: 'optional-task',
|
||||
arguments: { value: 21 }
|
||||
});
|
||||
|
||||
// Should receive CallToolResult directly, not CreateTaskResult
|
||||
expect(result).toHaveProperty('content');
|
||||
expect(result.content).toEqual([{ type: 'text' as const, text: 'Result: 42' }]);
|
||||
expect(result).not.toHaveProperty('task');
|
||||
|
||||
// Wait for async operations to complete
|
||||
await waitForLatch();
|
||||
taskStore.cleanup();
|
||||
});
|
||||
|
||||
test('should return CreateTaskResult when tool with taskSupport "required" is called WITH task augmentation', async () => {
|
||||
const taskStore = new InMemoryTaskStore();
|
||||
const { releaseLatch, waitForLatch } = createLatch();
|
||||
|
||||
const mcpServer = new McpServer(
|
||||
{
|
||||
name: 'test server',
|
||||
version: '1.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
tasks: {
|
||||
requests: {
|
||||
tools: {
|
||||
call: {}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'test client',
|
||||
version: '1.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tasks: {
|
||||
requests: {
|
||||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Register a task-based tool with taskSupport "required"
|
||||
mcpServer.experimental.tasks.registerToolTask(
|
||||
'task-tool',
|
||||
{
|
||||
description: 'A task tool',
|
||||
inputSchema: z.object({
|
||||
data: z.string()
|
||||
}),
|
||||
execution: {
|
||||
taskSupport: 'required'
|
||||
}
|
||||
},
|
||||
{
|
||||
createTask: async ({ data }, ctx) => {
|
||||
const task = await ctx.task.store.createTask({ ttl: 60_000, pollInterval: 100 });
|
||||
|
||||
// Capture taskStore for use in setTimeout
|
||||
const store = ctx.task.store;
|
||||
|
||||
// Simulate async work
|
||||
setTimeout(async () => {
|
||||
await store.storeTaskResult(task.taskId, 'completed', {
|
||||
content: [{ type: 'text' as const, text: `Completed: ${data}` }]
|
||||
});
|
||||
releaseLatch();
|
||||
}, 200);
|
||||
|
||||
return { task };
|
||||
},
|
||||
getTask: async (_args, ctx) => {
|
||||
const task = await ctx.task.store.getTask(ctx.task.id);
|
||||
if (!task) {
|
||||
throw new Error('Task not found');
|
||||
}
|
||||
return task;
|
||||
},
|
||||
getTaskResult: async (_data, ctx) => {
|
||||
const result = await ctx.task.store.getTaskResult(ctx.task.id);
|
||||
return result as CallToolResult;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
|
||||
|
||||
// Call the tool WITH task augmentation
|
||||
const result = await client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'task-tool',
|
||||
arguments: { data: 'test' },
|
||||
task: { ttl: 60_000 }
|
||||
}
|
||||
},
|
||||
z.object({
|
||||
task: z.object({
|
||||
taskId: z.string(),
|
||||
status: z.string(),
|
||||
ttl: z.union([z.number(), z.null()]),
|
||||
createdAt: z.string(),
|
||||
pollInterval: z.number().optional()
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
// Should receive CreateTaskResult with task field
|
||||
expect(result).toHaveProperty('task');
|
||||
expect(result.task).toHaveProperty('taskId');
|
||||
expect(result.task.status).toBe('working');
|
||||
|
||||
// Wait for async operations to complete
|
||||
await waitForLatch();
|
||||
taskStore.cleanup();
|
||||
});
|
||||
|
||||
test('should handle task failures during automatic polling', async () => {
|
||||
const taskStore = new InMemoryTaskStore();
|
||||
const { releaseLatch, waitForLatch } = createLatch();
|
||||
|
||||
const mcpServer = new McpServer(
|
||||
{
|
||||
name: 'test server',
|
||||
version: '1.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
tasks: {
|
||||
requests: {
|
||||
tools: {
|
||||
call: {}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'test client',
|
||||
version: '1.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tasks: {
|
||||
requests: {
|
||||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Register a task-based tool that fails
|
||||
mcpServer.experimental.tasks.registerToolTask(
|
||||
'failing-task',
|
||||
{
|
||||
description: 'A failing task',
|
||||
execution: {
|
||||
taskSupport: 'optional'
|
||||
}
|
||||
},
|
||||
{
|
||||
createTask: async ctx => {
|
||||
const task = await ctx.task.store.createTask({ ttl: 60_000, pollInterval: 100 });
|
||||
|
||||
// Capture taskStore for use in setTimeout
|
||||
const store = ctx.task.store;
|
||||
|
||||
// Simulate async failure
|
||||
setTimeout(async () => {
|
||||
await store.storeTaskResult(task.taskId, 'failed', {
|
||||
content: [{ type: 'text' as const, text: 'Error occurred' }],
|
||||
isError: true
|
||||
});
|
||||
releaseLatch();
|
||||
}, 150);
|
||||
|
||||
return { task };
|
||||
},
|
||||
getTask: async ctx => {
|
||||
const task = await ctx.task.store.getTask(ctx.task.id);
|
||||
if (!task) {
|
||||
throw new Error('Task not found');
|
||||
}
|
||||
return task;
|
||||
},
|
||||
getTaskResult: async ctx => {
|
||||
const result = await ctx.task.store.getTaskResult(ctx.task.id);
|
||||
return result as CallToolResult;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
|
||||
|
||||
// Call the tool WITHOUT task augmentation
|
||||
const result = await client.callTool({
|
||||
name: 'failing-task',
|
||||
arguments: {}
|
||||
});
|
||||
|
||||
// Should receive the error result
|
||||
expect(result).toHaveProperty('content');
|
||||
expect(result.content).toEqual([{ type: 'text' as const, text: 'Error occurred' }]);
|
||||
expect(result.isError).toBe(true);
|
||||
|
||||
// Wait for async operations to complete
|
||||
await waitForLatch();
|
||||
taskStore.cleanup();
|
||||
});
|
||||
|
||||
test('should handle task cancellation during automatic polling', async () => {
|
||||
const taskStore = new InMemoryTaskStore();
|
||||
const { releaseLatch, waitForLatch } = createLatch();
|
||||
|
||||
const mcpServer = new McpServer(
|
||||
{
|
||||
name: 'test server',
|
||||
version: '1.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
tasks: {
|
||||
requests: {
|
||||
tools: {
|
||||
call: {}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'test client',
|
||||
version: '1.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tasks: {
|
||||
requests: {
|
||||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Register a task-based tool that gets cancelled
|
||||
mcpServer.experimental.tasks.registerToolTask(
|
||||
'cancelled-task',
|
||||
{
|
||||
description: 'A task that gets cancelled',
|
||||
execution: {
|
||||
taskSupport: 'optional'
|
||||
}
|
||||
},
|
||||
{
|
||||
createTask: async ctx => {
|
||||
const task = await ctx.task.store.createTask({ ttl: 60_000, pollInterval: 100 });
|
||||
|
||||
// Capture taskStore for use in setTimeout
|
||||
const store = ctx.task.store;
|
||||
|
||||
// Simulate async cancellation
|
||||
setTimeout(async () => {
|
||||
await store.updateTaskStatus(task.taskId, 'cancelled', 'Task was cancelled');
|
||||
releaseLatch();
|
||||
}, 150);
|
||||
|
||||
return { task };
|
||||
},
|
||||
getTask: async ctx => {
|
||||
const task = await ctx.task.store.getTask(ctx.task.id);
|
||||
if (!task) {
|
||||
throw new Error('Task not found');
|
||||
}
|
||||
return task;
|
||||
},
|
||||
getTaskResult: async ctx => {
|
||||
const result = await ctx.task.store.getTaskResult(ctx.task.id);
|
||||
return result as CallToolResult;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
|
||||
|
||||
// Call the tool WITHOUT task augmentation
|
||||
const result = await client.callTool({
|
||||
name: 'cancelled-task',
|
||||
arguments: {}
|
||||
});
|
||||
|
||||
// Should receive an error since cancelled tasks don't have results
|
||||
expect(result).toHaveProperty('content');
|
||||
expect(result.content).toEqual([{ type: 'text' as const, text: expect.stringContaining('has no result stored') }]);
|
||||
|
||||
// Wait for async operations to complete
|
||||
await waitForLatch();
|
||||
taskStore.cleanup();
|
||||
});
|
||||
|
||||
test('should raise error when registerToolTask is called with taskSupport "forbidden"', () => {
|
||||
const taskStore = new InMemoryTaskStore();
|
||||
|
||||
const mcpServer = new McpServer(
|
||||
{
|
||||
name: 'test server',
|
||||
version: '1.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
tasks: {
|
||||
requests: {
|
||||
tools: {
|
||||
call: {}
|
||||
}
|
||||
},
|
||||
|
||||
taskStore
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Attempt to register a task-based tool with taskSupport "forbidden" (cast to bypass type checking)
|
||||
expect(() => {
|
||||
mcpServer.experimental.tasks.registerToolTask(
|
||||
'invalid-task',
|
||||
{
|
||||
description: 'A task with forbidden support',
|
||||
inputSchema: z.object({
|
||||
input: z.string()
|
||||
}),
|
||||
execution: {
|
||||
taskSupport: 'forbidden' as unknown as 'required'
|
||||
}
|
||||
},
|
||||
{
|
||||
createTask: async (_args, ctx) => {
|
||||
const task = await ctx.task.store.createTask({ ttl: 60_000, pollInterval: 100 });
|
||||
return { task };
|
||||
},
|
||||
getTask: async (_args, ctx) => {
|
||||
const task = await ctx.task.store.getTask(ctx.task.id);
|
||||
if (!task) {
|
||||
throw new Error('Task not found');
|
||||
}
|
||||
return task;
|
||||
},
|
||||
getTaskResult: async (_args, ctx) => {
|
||||
const result = await ctx.task.store.getTaskResult(ctx.task.id);
|
||||
return result as CallToolResult;
|
||||
}
|
||||
}
|
||||
);
|
||||
}).toThrow();
|
||||
|
||||
taskStore.cleanup();
|
||||
});
|
||||
});
|
||||
// SEP-2663: `taskSupport: 'required'` enforcement and the automatic-polling wrapper
|
||||
// depended on the client sending `params.task`. Under the server-directed model the
|
||||
// tool handler decides to return `{resultType:'task', task}`; there is no per-tool
|
||||
// augmentation to enforce and no wrapper. The deleted "should include execution field"
|
||||
// tests registered tools via `experimental.tasks.registerToolTask`, which is removed;
|
||||
// `Tool.execution` remains a spec field but no SDK registration path currently sets it.
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue