Skip to content

feat: forced result tool #7

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jun 2, 2025
Merged
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
97 changes: 61 additions & 36 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ export interface McpxAnthropicStage {
}


function anthropicToolCallToMcpxToolCall(submessage: any): any {
Copy link
Contributor

Choose a reason for hiding this comment

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

oooh, I see – this is a normalization pass to unify the return type with mcpx-openai.

Copy link
Contributor Author

@evacchi evacchi May 30, 2025

Choose a reason for hiding this comment

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

yeah, technically to align it to the MCP CallToolRequest definition

return {
method: 'tools/call',
params: {
name: submessage.name,
arguments: submessage.input,
},
}
}

/**
* A Driver wrapping an Anthropic client and MCPX session.
*
Expand Down Expand Up @@ -122,23 +132,16 @@ export class Driver {
}


private async call(submessage: ToolUseBlock): Promise<ContentBlockParam> {
const { id, input, name } = submessage
private async call(convertedToolCall: any, toolCallId: string): Promise<ContentBlockParam> {
try {
const abortcontroller = new AbortController()
const result = await this.#session.handleCallTool(
{
method: 'tools/call',
params: {
name,
arguments: input as any,
},
},
convertedToolCall,
{ signal: abortcontroller.signal },
)

return {
tool_use_id: id,
tool_use_id: toolCallId,
type: 'tool_result',
content: Array.isArray(result.content)
? result.content.map(xs => {
Expand All @@ -149,15 +152,15 @@ export class Driver {
} catch (err: any) {
this.#logger.error(
{
tool_use_id: id,
tool_use_id: toolCallId,
name,
error: err.message,
stack: err.stack,
},
'tool use failed',
)
return {
tool_use_id: id,
tool_use_id: toolCallId,
type: 'tool_result',
content: err.toString(),
is_error: true,
Expand All @@ -169,11 +172,16 @@ export class Driver {
const { response, messages, index, status, toolCallIndex } = stage
switch (status) {
case 'pending': {
const tools = config.tools?.map(mcpxToolToAnthropic) || this.#tools
Copy link
Contributor

Choose a reason for hiding this comment

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

4️⃣ this would be the load-bearing change, right? (Assuming users could already pass tool_choice as a part of config?)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

that's right. I don't love the fact that we are abusing the anthropic-specific config to pass a foreign config key. We could expose a separate "mcpToolConfig" object for this specific purpose though

const tool_choice =
config.tool_choice ? { type: 'tool', name: config.tool_choice } : { type: 'auto' }

let response: Anthropic.Messages.Message
try {
response = await this.#anthropic.messages.create({
...config,
...(this.#tools.length ? { tools: this.#tools } : {}),
tools,
tool_choice,
messages,
}, requestOptions)
} catch (err: any) {
Expand Down Expand Up @@ -213,36 +221,60 @@ export class Driver {
return { response, messages, index: messageIdx, status: 'ready' }
}
case 'input_wait': {
const toolUseCount = stage.toolCallIndex!
const submessageIdx = stage.submessageIdx!
const inputMessage = messages[index-1]
const newMessage = messages[index]

this.#logger.info({ m:"message index", index, len: messages.length })

// when status == 'input_wait' it is always a tool call,
// newMessage.content is always a ContentBlockParam[]
const submessage = inputMessage.content[submessageIdx] as ToolUseBlock
const content = newMessage.content as ContentBlockParam[]
content.push(await this.call(submessage))
Copy link
Contributor

Choose a reason for hiding this comment

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

1️⃣ If I follow correctly, we moved the normalization of the tool call info from here...

const { tool, submessageIdx, toolCallIndex, toolCallLength, toolCallId } =
this.parseNextToolCall(stage)

content.push(await this.call(tool, toolCallId!))
Copy link
Contributor

Choose a reason for hiding this comment

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

3️⃣ so we can pass it directly here. But I don't see any other changes to the logic?


const nextTool = toolUseCount + 1
const nextSubmessage = submessageIdx + 1
if (nextSubmessage >= inputMessage.content.length) {
if (submessageIdx >= toolCallLength) {
return { response, messages, index, status: 'pending' }
} else {
return { response, messages, index, status: 'input_wait', toolCallIndex: nextTool, submessageIdx: nextSubmessage }
return { response, messages, index, status: 'input_wait', toolCallIndex, submessageIdx }
}

}
default:
throw new Error("Illegal status: " + status)
}
}

parseNextToolCall(stage: McpxAnthropicStage) {
const { status, messages, index } = stage
if (status !== 'input_wait') {
throw new Error("Cannot parse next tool call: invalid status " + status)
}
const toolUseCount = stage.toolCallIndex!
const submessageIdx = stage.submessageIdx!
const inputMessage = messages[index-1]

// when status == 'input_wait' it is always a tool call,
// newMessage.content is always a ContentBlockParam[]
const submessage = inputMessage.content[submessageIdx] as ToolUseBlock

const nextTool = toolUseCount + 1
const nextSubmessage = submessageIdx + 1

return {
tool: anthropicToolCallToMcpxToolCall(submessage),
toolCallIndex: nextTool,
toolCallLength: inputMessage.content.length,
submessageIdx: nextSubmessage,
toolCallId: submessage.id
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

2️⃣ ...to here


}

function mcpxToolToAnthropic(tool: any) {
return {
// So, you're saying you folks write a lot of Python, eh? Well, it certainly doesn't show.
input_schema: tool.inputSchema,
name: tool.name,
description: tool.description,
}
}

/** Create a driver using an Anthropic client and MCPX Session options. */
export default async function createDriver(opts: DriverOptions) {
const { anthropic, logger } = opts
Expand All @@ -264,14 +296,7 @@ export default async function createDriver(opts: DriverOptions) {
anthropic,
logger: logger || (session.logger as any) || pino({ level: 'silent' }),
session,
tools: mcpTools.map(tool => {
return {
// So, you're saying you folks write a lot of Python, eh? Well, it certainly doesn't show.
input_schema: tool.inputSchema,
name: tool.name,
description: tool.description,
}
})
tools: mcpTools.map(mcpxToolToAnthropic)
})
}

Expand Down