feat(tools): require cancellation signal on every invocation

This commit is contained in:
Tianyi Cui
2026-07-19 23:38:54 +08:00
parent a99750f341
commit e8b95c8754
77 changed files with 1129 additions and 446 deletions

View File

@@ -165,7 +165,7 @@ function createExecutor(
{ name: rawName, arguments: argsObj },
undefined,
{
...exec.signal ? { signal: exec.signal } : {},
signal: exec.signal,
timeout: opts.toolCallTimeoutMs,
},
)

View File

@@ -26,6 +26,8 @@ import { apply } from '@deepseek-ai/dsh-mcp-client/src/index.ts'
import { publicToolName } from '@deepseek-ai/dsh-mcp-client/src/tools.ts'
import type { Config } from '@deepseek-ai/dsh-mcp-client'
const testToolSignal = new AbortController().signal
const fixtureServerPath = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
// Resolve package-local .bin for pnpm-hoisted MCP server binaries.
@@ -119,6 +121,7 @@ describe('fixture server — controlled scenarios', () => {
it('executes the dotted tool via its normalized public name', async () => {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: publicToolName('fixture', 'admin.reset'), arguments: {},
})
expect(result.isError).toBe(false)
@@ -127,6 +130,7 @@ describe('fixture server — controlled scenarios', () => {
it('executes add(2, 3) → "5"', async () => {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__fixture__add', arguments: { a: 2, b: 3 },
})
expect(result.isError).toBe(false)
@@ -135,6 +139,7 @@ describe('fixture server — controlled scenarios', () => {
it('executes greet("World") → "Hello, World!"', async () => {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__fixture__greet', arguments: { name: 'World' },
})
expect(result.isError).toBe(false)
@@ -143,6 +148,7 @@ describe('fixture server — controlled scenarios', () => {
it('executes fail() → isError result', async () => {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__fixture__fail', arguments: {},
})
expect(result.isError).toBe(true)
@@ -151,6 +157,7 @@ describe('fixture server — controlled scenarios', () => {
it('executes image() → image placeholder', async () => {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__fixture__image', arguments: {},
})
expect(result.isError).toBe(false)
@@ -241,6 +248,7 @@ describe('server-everything — official test server', () => {
it('executes echo({ message: "hello" }) → "Echo: hello"', async () => {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__everything__echo', arguments: { message: 'hello' },
})
expect(result.isError).toBe(false)
@@ -249,6 +257,7 @@ describe('server-everything — official test server', () => {
it('executes get-sum({ a: 3, b: 7 }) → contains "10"', async () => {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__everything__get-sum', arguments: { a: 3, b: 7 },
})
expect(result.isError).toBe(false)
@@ -257,6 +266,7 @@ describe('server-everything — official test server', () => {
it('executes get-tiny-image → image placeholder', async () => {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__everything__get-tiny-image', arguments: {},
})
expect(result.isError).toBe(false)
@@ -306,6 +316,7 @@ describe('server-filesystem — real filesystem operations', () => {
// Write via MCP tool
const writeResult = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__filesystem__write_file', arguments: { path: filePath, content },
})
expect(writeResult.isError).toBe(false)
@@ -316,6 +327,7 @@ describe('server-filesystem — real filesystem operations', () => {
// Read back via MCP tool
const readResult = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__filesystem__read_file', arguments: { path: filePath },
})
expect(readResult.isError).toBe(false)
@@ -327,6 +339,7 @@ describe('server-filesystem — real filesystem operations', () => {
await writeFile(join(tempDir, 'listed.txt'), 'listed')
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__filesystem__list_directory', arguments: { path: tempDir },
})
expect(result.isError).toBe(false)
@@ -418,6 +431,7 @@ describe('streamable-http — in-process MCP server', () => {
it('executes ping() → "pong" over HTTP', async () => {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__web__ping', arguments: {},
})
expect(result.isError).toBe(false)
@@ -426,6 +440,7 @@ describe('streamable-http — in-process MCP server', () => {
it('executes shout({ message }) with args over HTTP', async () => {
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: nextCallId(), name: 'mcp__web__shout', arguments: { message: 'quiet' },
})
expect(result.isError).toBe(false)

View File

@@ -7,6 +7,8 @@ import { publicToolName, syncTools, type ToolBridgeOptions } from '@deepseek-ai/
import { createTransport } from '@deepseek-ai/dsh-mcp-client/src/transport.ts'
import type { Config } from '@deepseek-ai/dsh-mcp-client'
const testToolSignal = new AbortController().signal
// ---- Mock MCP Client ----
interface MockTool {
@@ -122,7 +124,7 @@ describe('syncTools', () => {
expect(ctx.tools.get('search')).toBeDefined()
expect(ctx.tools.get('mcp__srv__search')).toBeDefined()
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'search', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'search', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: 'native' })
})
@@ -217,7 +219,7 @@ describe('tool execution', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__echo', arguments: { msg: 'hi' } })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__echo', arguments: { msg: 'hi' } })
expect(result.isError).toBe(false)
expect(result.content).toEqual([{ type: 'text', text: 'hello world' }])
@@ -237,7 +239,7 @@ describe('tool execution', () => {
await syncTools(client as never, ctx, defaultOpts, new Map())
const publicName = publicToolName('srv', 'admin.reset')
const result = await ctx.tools.execute({ callId: CallId('c1'), name: publicName, arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: publicName, arguments: {} })
expect(result.isError).toBe(false)
expect(client.callTool).toHaveBeenCalledWith(
@@ -254,7 +256,7 @@ describe('tool execution', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__multi', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__multi', arguments: {} })
expect(result.content).toEqual([{ type: 'text', text: 'line1\nline2' }])
})
@@ -266,7 +268,7 @@ describe('tool execution', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__img', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__img', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: 'before\n[image: image/png, content discarded]' })
})
@@ -278,7 +280,7 @@ describe('tool execution', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__fail', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__fail', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]).toEqual({ type: 'text', text: 'Error: something went wrong' })
@@ -308,7 +310,7 @@ describe('tool execution', () => {
client.callTool.mockResolvedValue({ toolResult: { key: 'value' } })
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__legacy', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__legacy', arguments: {} })
expect(result.isError).toBe(false)
expect(result.content[0]).toEqual({ type: 'text', text: '{"key":"value"}' })
@@ -329,7 +331,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__audio_tool', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__audio_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[audio: audio/mp3, content discarded]' })
})
@@ -341,7 +343,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__res_tool', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__res_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' })
})
@@ -353,7 +355,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__link_tool', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__link_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[resource: content discarded]' })
})
@@ -365,7 +367,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__unknown_tool', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__unknown_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[unsupported content type: video]' })
})
@@ -377,7 +379,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__img2', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__img2', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[image: unknown, content discarded]' })
})
@@ -389,7 +391,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__audio_no_mime', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__audio_no_mime', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '[audio: unknown, content discarded]' })
})
@@ -401,7 +403,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__notext', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__notext', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no text content)' })
})
@@ -413,7 +415,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__empty_tool', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__empty_tool', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no text content)' })
})
@@ -426,7 +428,7 @@ describe('tool execution edge cases', () => {
client.callTool.mockResolvedValue({})
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__legacy2', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__legacy2', arguments: {} })
expect(result.content[0]).toEqual({ type: 'text', text: '(no output)' })
})
@@ -438,7 +440,7 @@ describe('tool execution edge cases', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__err_notext', arguments: {} })
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__err_notext', arguments: {} })
expect(result.isError).toBe(true)
expect(result.content[0]).toEqual({ type: 'text', text: 'Error: [image: image/png, content discarded]' })
@@ -575,7 +577,7 @@ describe('tool execution — non-object args fallback', () => {
await syncTools(client as never, ctx, defaultOpts, new Map())
// Simulate model emitting `null` as tool arguments (malformed).
await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__coerce', arguments: null })
await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__coerce', arguments: null })
expect(client.callTool).toHaveBeenCalledWith(
{ name: 'coerce', arguments: {} },
@@ -591,7 +593,7 @@ describe('tool execution — non-object args fallback', () => {
)
await syncTools(client as never, ctx, defaultOpts, new Map())
await ctx.tools.execute({ callId: CallId('c1'), name: 'mcp__srv__coerce2', arguments: 'bad' })
await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__coerce2', arguments: 'bad' })
expect(client.callTool).toHaveBeenCalledWith(
{ name: 'coerce2', arguments: {} },