Skip to content

Commit 3e9f737

Browse files
committed
Return task data in cancellation result
1 parent 6bb2444 commit 3e9f737

File tree

5 files changed

+90
-41
lines changed

5 files changed

+90
-41
lines changed

src/integration-tests/taskLifecycle.test.ts

Lines changed: 62 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
CreateTaskResultSchema,
1111
ElicitRequestSchema,
1212
ElicitResultSchema,
13+
ErrorCode,
14+
McpError,
1315
RELATED_TASK_META_KEY,
1416
TaskSchema
1517
} from '../types.js';
@@ -316,7 +318,7 @@ describe('Task Lifecycle Integration Tests', () => {
316318
});
317319

318320
describe('Task Cancellation', () => {
319-
it('should cancel a working task', async () => {
321+
it('should cancel a working task and return the cancelled task', async () => {
320322
const client = new Client({
321323
name: 'test-client',
322324
version: '1.0.0'
@@ -348,17 +350,24 @@ describe('Task Lifecycle Integration Tests', () => {
348350
let task = await taskStore.getTask(taskId);
349351
expect(task?.status).toBe('working');
350352

351-
// Cancel the task
352-
await taskStore.updateTaskStatus(taskId, 'cancelled');
353+
// Cancel the task via client.cancelTask - per spec, returns Result & Task
354+
const cancelResult = await client.cancelTask({ taskId });
353355

354-
// Verify task is cancelled
356+
// Verify the cancel response includes the cancelled task (per MCP spec CancelTaskResult is Result & Task)
357+
expect(cancelResult.taskId).toBe(taskId);
358+
expect(cancelResult.status).toBe('cancelled');
359+
expect(cancelResult.createdAt).toBeDefined();
360+
expect(cancelResult.lastUpdatedAt).toBeDefined();
361+
expect(cancelResult.ttl).toBeDefined();
362+
363+
// Verify task is cancelled in store as well
355364
task = await taskStore.getTask(taskId);
356365
expect(task?.status).toBe('cancelled');
357366

358367
await transport.close();
359368
});
360369

361-
it('should reject cancellation of completed task', async () => {
370+
it('should reject cancellation of completed task with error code -32602', async () => {
362371
const client = new Client({
363372
name: 'test-client',
364373
version: '1.0.0'
@@ -393,8 +402,13 @@ describe('Task Lifecycle Integration Tests', () => {
393402
const task = await taskStore.getTask(taskId);
394403
expect(task?.status).toBe('completed');
395404

396-
// Try to cancel (should fail)
397-
await expect(taskStore.updateTaskStatus(taskId, 'cancelled')).rejects.toThrow();
405+
// Try to cancel via tasks/cancel request (should fail with -32602)
406+
await expect(client.cancelTask({ taskId })).rejects.toSatisfy((error: McpError) => {
407+
expect(error).toBeInstanceOf(McpError);
408+
expect(error.code).toBe(ErrorCode.InvalidParams);
409+
expect(error.message).toContain('Cannot cancel task in terminal status');
410+
return true;
411+
});
398412

399413
await transport.close();
400414
});
@@ -775,7 +789,7 @@ describe('Task Lifecycle Integration Tests', () => {
775789
});
776790

777791
describe('Error Handling', () => {
778-
it('should return null for non-existent task', async () => {
792+
it('should return error code -32602 for non-existent task in tasks/get', async () => {
779793
const client = new Client({
780794
name: 'test-client',
781795
version: '1.0.0'
@@ -784,14 +798,18 @@ describe('Task Lifecycle Integration Tests', () => {
784798
const transport = new StreamableHTTPClientTransport(baseUrl);
785799
await client.connect(transport);
786800

787-
// Try to get non-existent task
788-
const task = await taskStore.getTask('non-existent');
789-
expect(task).toBeNull();
801+
// Try to get non-existent task via tasks/get request
802+
await expect(client.getTask({ taskId: 'non-existent-task-id' })).rejects.toSatisfy((error: McpError) => {
803+
expect(error).toBeInstanceOf(McpError);
804+
expect(error.code).toBe(ErrorCode.InvalidParams);
805+
expect(error.message).toContain('Task not found');
806+
return true;
807+
});
790808

791809
await transport.close();
792810
});
793811

794-
it('should return error for invalid task operation', async () => {
812+
it('should return error code -32602 for non-existent task in tasks/cancel', async () => {
795813
const client = new Client({
796814
name: 'test-client',
797815
version: '1.0.0'
@@ -800,30 +818,41 @@ describe('Task Lifecycle Integration Tests', () => {
800818
const transport = new StreamableHTTPClientTransport(baseUrl);
801819
await client.connect(transport);
802820

803-
// Create and complete a task
804-
const createResult = await client.request(
805-
{
806-
method: 'tools/call',
807-
params: {
808-
name: 'long-task',
809-
arguments: {
810-
duration: 100
811-
},
812-
task: {
813-
ttl: 60000
814-
}
815-
}
816-
},
817-
CreateTaskResultSchema
818-
);
821+
// Try to cancel non-existent task via tasks/cancel request
822+
await expect(client.cancelTask({ taskId: 'non-existent-task-id' })).rejects.toSatisfy((error: McpError) => {
823+
expect(error).toBeInstanceOf(McpError);
824+
expect(error.code).toBe(ErrorCode.InvalidParams);
825+
expect(error.message).toContain('Task not found');
826+
return true;
827+
});
819828

820-
const taskId = createResult.task.taskId;
829+
await transport.close();
830+
});
821831

822-
// Wait for completion
823-
await new Promise(resolve => setTimeout(resolve, 200));
832+
it('should return error code -32602 for non-existent task in tasks/result', async () => {
833+
const client = new Client({
834+
name: 'test-client',
835+
version: '1.0.0'
836+
});
824837

825-
// Try to cancel completed task (should fail)
826-
await expect(taskStore.updateTaskStatus(taskId, 'cancelled')).rejects.toThrow();
838+
const transport = new StreamableHTTPClientTransport(baseUrl);
839+
await client.connect(transport);
840+
841+
// Try to get result of non-existent task via tasks/result request
842+
await expect(
843+
client.request(
844+
{
845+
method: 'tasks/result',
846+
params: { taskId: 'non-existent-task-id' }
847+
},
848+
CallToolResultSchema
849+
)
850+
).rejects.toSatisfy((error: McpError) => {
851+
expect(error).toBeInstanceOf(McpError);
852+
expect(error.code).toBe(ErrorCode.InvalidParams);
853+
expect(error.message).toContain('Task not found');
854+
return true;
855+
});
827856

828857
await transport.close();
829858
});

src/shared/protocol.test.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1715,13 +1715,18 @@ describe('Task-based execution', () => {
17151715

17161716
const deleteTaskPromise = protocol.cancelTask({ taskId: 'task-to-delete' });
17171717

1718-
// Simulate server response
1718+
// Simulate server response - per MCP spec, CancelTaskResult is Result & Task
17191719
setTimeout(() => {
17201720
transport.onmessage?.({
17211721
jsonrpc: '2.0',
17221722
id: sendSpy.mock.calls[0][0].id,
17231723
result: {
1724-
_meta: {}
1724+
_meta: {},
1725+
taskId: 'task-to-delete',
1726+
status: 'cancelled',
1727+
ttl: 60000,
1728+
createdAt: new Date().toISOString(),
1729+
lastUpdatedAt: new Date().toISOString()
17251730
}
17261731
});
17271732
}, 0);
@@ -1738,6 +1743,8 @@ describe('Task-based execution', () => {
17381743
expect.any(Object)
17391744
);
17401745
expect(result._meta).toBeDefined();
1746+
expect(result.taskId).toBe('task-to-delete');
1747+
expect(result.status).toBe('cancelled');
17411748
});
17421749
});
17431750

src/shared/protocol.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -507,9 +507,16 @@ export abstract class Protocol<SendRequestT extends Request, SendNotificationT e
507507

508508
this._clearTaskQueue(request.params.taskId);
509509

510+
const cancelledTask = await this._taskStore!.getTask(request.params.taskId, extra.sessionId);
511+
if (!cancelledTask) {
512+
// Task was deleted during cancellation (e.g., cleanup happened)
513+
throw new McpError(ErrorCode.InvalidParams, `Task not found after cancellation: ${request.params.taskId}`);
514+
}
515+
510516
return {
511-
_meta: {}
512-
} as SendResultT;
517+
_meta: {},
518+
...cancelledTask
519+
} as unknown as SendResultT;
513520
} catch (error) {
514521
// Re-throw McpError as-is
515522
if (error instanceof McpError) {

src/shared/task-listing.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { InMemoryTransport } from '../inMemory.js';
33
import { Client } from '../client/index.js';
44
import { Server } from '../server/index.js';
55
import { InMemoryTaskStore, InMemoryTaskMessageQueue } from '../examples/shared/inMemoryTaskStore.js';
6+
import { ErrorCode, McpError } from '../types.js';
67

78
describe('Task Listing with Pagination', () => {
89
let client: Client;
@@ -125,14 +126,19 @@ describe('Task Listing with Pagination', () => {
125126
expect(result.tasks).toHaveLength(2);
126127
});
127128

128-
it('should return error for invalid cursor', async () => {
129+
it('should return error code -32602 for invalid cursor', async () => {
129130
await taskStore.createTask({}, 1, {
130131
method: 'tools/call',
131132
params: { name: 'test-tool' }
132133
});
133134

134-
// Try to use an invalid cursor
135-
await expect(client.listTasks({ cursor: 'invalid-cursor' })).rejects.toThrow();
135+
// Try to use an invalid cursor - should return -32602 (Invalid params) per MCP spec
136+
await expect(client.listTasks({ cursor: 'invalid-cursor' })).rejects.toSatisfy((error: McpError) => {
137+
expect(error).toBeInstanceOf(McpError);
138+
expect(error.code).toBe(ErrorCode.InvalidParams);
139+
expect(error.message).toContain('Invalid cursor');
140+
return true;
141+
});
136142
});
137143

138144
it('should ensure tasks accessible via tasks/get are also accessible via tasks/list', async () => {

src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -735,7 +735,7 @@ export const CancelTaskRequestSchema = RequestSchema.extend({
735735
/**
736736
* The response to a tasks/cancel request.
737737
*/
738-
export const CancelTaskResultSchema = ResultSchema;
738+
export const CancelTaskResultSchema = ResultSchema.merge(TaskSchema);
739739

740740
/* Resources */
741741
/**

0 commit comments

Comments
 (0)