Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions strands-ts/src/mcp/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,20 @@ describe('MCP Integration', () => {
expect(sdkClientMock.connect).not.toHaveBeenCalled()
})

it('stops waiting for the connection when callTool is aborted without tasksConfig', async () => {
sdkClientMock.connect.mockReturnValue(new Promise<void>(() => {}))
const tool = new McpTool({ name: 'calc', description: '', inputSchema: {}, client })
const controller = new AbortController()
const reason = new Error('caller aborted during connect')

const pending = client.callTool(tool, {}, { signal: controller.signal })
const rejection = expect(pending).rejects.toBe(reason)
controller.abort(reason)

await rejection
expect(sdkClientMock.callTool).not.toHaveBeenCalled()
})

it('converts SDK tool specs to McpTool instances', async () => {
const longName = 'a'.repeat(65)
const outputSchema = {
Expand Down
63 changes: 63 additions & 0 deletions strands-ts/src/mcp/__tests__/task-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,69 @@ describe('McpClient legacy task execution', () => {
expect(server.requests('tasks/result')).toHaveLength(0)
})

it('fetches task metadata for a tool invoked before any listTools call', async () => {
const { client, server, tool } = await createHarness({
era: 'legacy',
capabilities: { tools: {}, tasks: { requests: { tools: { call: {} } }, cancel: {} } },
})
server.handle('tools/list', () => ({
tools: [{ name: 'task_tool', inputSchema: { type: 'object' }, execution: { taskSupport: 'required' } }],
}))
server.handle('tools/call', () => ({ task: legacyTask('completed') }))
server.handle('tasks/result', () => ({ content: [{ type: 'text', text: 'unlisted tool result' }] }))

await expect(client.callTool(tool, {})).resolves.toEqual({
content: [{ type: 'text', text: 'unlisted tool result' }],
})
expect(server.requests('tools/list')).toHaveLength(1)
expect(requestParams(server.requests('tools/call')[0]!)).toMatchObject({ task: {} })
})

it('lists tools once even when the listing is empty or the tool is unknown', async () => {
const { client, server, tool } = await createHarness({
era: 'legacy',
capabilities: { tools: {}, tasks: { requests: { tools: { call: {} } }, cancel: {} } },
})
server.handle('tools/list', () => ({ tools: [] }))
server.handle('tools/call', () => ({ content: [{ type: 'text', text: 'plain' }] }))

await client.callTool(tool, {})
await client.callTool(tool, {})
await client.callTool(tool, {})

expect(server.requests('tools/list')).toHaveLength(1)
expect(server.requests('tools/call')).toHaveLength(3)
})

it('skips the metadata fetch when the server does not advertise legacy tasks', async () => {
const { client, server, tool } = await createHarness({
era: 'legacy',
capabilities: { tools: {} },
})
server.handle('tools/call', () => ({ content: [{ type: 'text', text: 'plain' }] }))

await expect(client.callTool(tool, {})).resolves.toEqual({ content: [{ type: 'text', text: 'plain' }] })
expect(server.requests('tools/list')).toHaveLength(0)
})

it('surfaces an abort during the metadata fetch instead of calling the tool', async () => {
const { client, server, tool } = await createHarness({
era: 'legacy',
capabilities: { tools: {}, tasks: { requests: { tools: { call: {} } }, cancel: {} } },
})
server.handle('tools/list', () => NO_RESPONSE)
const controller = new AbortController()
const reason = new Error('aborted during metadata fetch')

const pending = client.callTool(tool, {}, { signal: controller.signal })
const rejection = expect(pending).rejects.toBe(reason)
await vi.waitFor(() => expect(server.requests('tools/list')).toHaveLength(1))
controller.abort(reason)

await rejection
expect(server.requests('tools/call')).toHaveLength(0)
})

it('waits for queued input on tasks/result beyond the request timeout without progress', async () => {
vi.useFakeTimers()
const { client, server, tool } = await legacyHarness({ requestTimeout: 100, pollTimeout: 5_000 })
Expand Down
32 changes: 31 additions & 1 deletion strands-ts/src/mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,8 @@ export class McpClient {
/** Server-side name of each listed tool, which differs from `tool.name` when a prefix is set. */
private _serverToolNames = new WeakMap<McpTool, string>()
private _serverToolDefinitions = new Map<string, McpServerToolDefinition>()
/** Whether tools/list ran for this client, independent of how many tools it returned. */
private _serverToolsListed = false
private _registeredToolNames = new Set<string>()
private _onToolsChanged: ((oldTools: string[], newTools: McpTool[]) => void) | undefined
private _refreshingTools = false
Expand Down Expand Up @@ -633,6 +635,7 @@ export class McpClient {
}

this._serverToolDefinitions = toolDefinitions
this._serverToolsListed = true

// Per-call overrides are transient, so they must not become the baseline that a later
// tools-changed refresh reports as the previously registered names.
Expand Down Expand Up @@ -843,11 +846,22 @@ export class McpClient {
operation?: TaskOperation,
completeLegacyTask = false
): Promise<McpToolCallOutcome> {
await this.connect(false, operation ? { signal: operation.signal } : undefined)
const connectSignal = operation?.signal ?? options.signal
await this.connect(false, connectSignal ? { signal: connectSignal } : undefined)
if (this._state === 'failed') throw new Error('MCP server failed to connect. Call connect(true) to retry.')

const params = this._prepareToolCall(tool, args)

// Task support is read from tools/list metadata, so a tool invoked before any listing
// fetches it once.
if (
completeLegacyTask &&
this._client.getNegotiatedProtocolVersion() === '2025-11-25' &&
this._client.getServerCapabilities()?.tasks?.requests?.tools?.call !== undefined
) {
await this._ensureServerToolDefinitions(operation!)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion
Issue: _ensureServerToolDefinitions(operation!) relies on the non-null assertion holding because the gating condition includes completeLegacyTask, which today is only true on the task path where operation is always supplied. This invariant is implicit and could silently break if a future caller sets completeLegacyTask without an operation.
Suggestion: Consider making operation a required parameter when completeLegacyTask is set, or add a brief comment at this call site documenting why operation is guaranteed present here, so the ! is self-explanatory.

}

// The upstream codec rejects extension result types before custom result schemas run.
if (completeLegacyTask && this._supportsLegacyTask(params.name)) {
const outputSchema = compileToolOutputSchema(
Expand Down Expand Up @@ -1107,6 +1121,22 @@ export class McpClient {
}
}

private async _ensureServerToolDefinitions(operation: TaskOperation): Promise<void> {
if (this._serverToolsListed) return
this._serverToolsListed = true
try {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important
Issue: _serverToolsListed is set to true before listTools resolves. On an aborted/timed-out fetch, the catch re-throws (good), but the flag stays true and _serverToolDefinitions was never populated. A subsequent callTool with a fresh signal then skips the fetch (if (this._serverToolsListed) return), sends a plain tools/call with no task: {}, and a taskSupport: "required" tool fails with the confusing server error this PR set out to prevent. A genuine listing failure logging a warning and falling back is intentional per the description, but an abort/timeout permanently poisons the once-per-client flag so retries can never recover.
Suggestion: Only latch the flag on outcomes you intend to be permanent — e.g. set _serverToolsListed = true after a successful fetch and inside the non-abort branch of catch, leaving it false when the fetch is aborted/times out so a retry can re-attempt.

const result = await this._client.listTools(undefined, {
timeout: remainingTime(operation.deadline, this._tasksConfig!.requestTimeoutMs),
signal: operation.signal,
})
this._serverToolDefinitions = new Map(result.tools.map((tool) => [tool.name, toMcpServerToolDefinition(tool)]))
} catch (error) {
// A cancelled or expired operation must surface as such, not as a listing failure.
if (operation.signal.aborted) throw error
logger.warn(`error=<${error}> | failed to list tools for legacy task support detection`)
}
}

private async _callLegacyTask(params: CallToolRequest['params'], operation: TaskOperation): Promise<CallToolResult> {
const requestOptions = (): CallToolRequestOptions => ({
signal: operation.signal,
Expand Down
Loading