Skip to content

Commit afeca17

Browse files
committed
feat: implement dynamic MCP tool name support with triple underscore separator
- Add helper functions to detect and parse dynamic MCP tool names - Normalize dynamic tools to standard 'use_mcp_tool' format - Use triple underscores (___) as separator to allow underscores in tool names - Update tool name generation in mcp_server.ts - Add comprehensive test coverage for dynamic tool parsing - Extract toolInputProps and convert to JSON string for MCP server compatibility
1 parent 3de06cb commit afeca17

File tree

12 files changed

+545
-42
lines changed

12 files changed

+545
-42
lines changed

.changeset/green-melons-retire.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"kilo-code": minor
3+
---
4+
5+
Add Native MCP Support for JSON Tool Calling

apps/kilocode-docs/docs/features/experimental/native-function-calling.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,16 @@ Because of these risks and considerations, this capability is experiment, and of
4141

4242
To enable and use native function calling, consider and perform the following:
4343

44-
1. Ensure you are using a provider that has been enabled in Kilo Code for this experiment. As of Oct 16, 2025, they include:
44+
1. Ensure you are using a provider that has been enabled in Kilo Code for this experiment. As of Oct 21, 2025, they include:
4545

4646
- OpenRouter
4747
- Kilo Code
4848
- LM Studio
4949
- OpenAI Compatible
50+
- Z.ai
51+
- Synthetic
52+
- X.ai
53+
- Chutes
5054

5155
By default, native function calling is _disabled_ for most models. Should you wish to try it, open the Advanced settings for a given provider profile that is included in the testing group.
5256

@@ -55,11 +59,13 @@ Change the Tool Calling Style to `JSON`, and save the profile.
5559
## Caveats
5660

5761
This feature is currently experimental and mostly intended for users interested in contributing to its development.
58-
It is so far only supported when using OpenRouter or Kilo Code providers. There are possible issues including, but not limited to:
5962

60-
- Missing tools
63+
There are possible issues including, but not limited to:
64+
65+
- ~~Missing tools~~: As of Oct 21, all tools are supported
6166
- Tools calls not updating the UI until they are complete
62-
- MCP servers not working
67+
- ~~MCP servers not working~~: As of Oct 21, MCPs are supported
6368
- Errors specific to certain inference providers
69+
- Not all inference providers use servers that are fully compatible with the OpenAI specification. As a result, behavior will vary, even with the same model across providers.
6470

6571
While nearly any provider can be configured via the OpenAI Compatible profile, testers should be aware that this is enabled purely for ease of testing and should be prepared to experience unexpected responses from providers that are not prepared to handle native function calls.

src/core/assistant-message/AssistantMessageParser.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { type ToolName, toolNames } from "@roo-code/types"
22
import { TextContent, ToolUse, ToolParamName, toolParamNames } from "../../shared/tools"
33
import { AssistantMessageContent } from "./parseAssistantMessage"
4-
import { NativeToolCall, parseDoubleEncodedParams } from "./kilocode/native-tool-call"
4+
import { extractMcpToolInfo, NativeToolCall, parseDoubleEncodedParams } from "./kilocode/native-tool-call"
55

66
/**
77
* Parser for assistant messages. Maintains state between chunks
@@ -118,8 +118,11 @@ export class AssistantMessageParser {
118118
if (toolCall.function?.name) {
119119
const toolName = toolCall.function.name
120120

121-
// Validate that this is a recognized tool name
122-
if (!toolNames.includes(toolName as ToolName)) {
121+
// Check if it's a dynamic MCP tool or a recognized static tool name
122+
const mcpToolInfo = extractMcpToolInfo(toolName)
123+
const isValidTool = mcpToolInfo !== null || toolNames.includes(toolName as ToolName)
124+
125+
if (!isValidTool) {
123126
console.warn("[AssistantMessageParser] Unknown tool name in native call:", toolName)
124127
continue
125128
}
@@ -175,17 +178,46 @@ export class AssistantMessageParser {
175178
// Tool call is complete - convert it to ToolUse format
176179
if (isComplete) {
177180
const toolName = accumulatedCall.function!.name
181+
178182
// Finalize any current text content before adding tool use
179183
if (this.currentTextContent) {
180184
this.currentTextContent.partial = false
181185
this.currentTextContent = undefined
182186
}
183187

188+
// Normalize dynamic MCP tool names to "use_mcp_tool"
189+
// Dynamic tools have format: use_mcp_tool_{serverName}_{toolName}
190+
const mcpToolInfo = extractMcpToolInfo(toolName)
191+
let normalizedToolName: ToolName
192+
let normalizedParams = parsedArgs
193+
194+
if (mcpToolInfo) {
195+
// Dynamic MCP tool - normalize to "use_mcp_tool"
196+
// Tool name format: use_mcp_tool___{serverName}___{toolName}
197+
normalizedToolName = "use_mcp_tool"
198+
199+
// Extract toolInputProps and convert to JSON string for the arguments parameter
200+
// The model provides: { server_name, tool_name, toolInputProps: {...actual args...} }
201+
// We need: { server_name, tool_name, arguments: "{...actual args as JSON string...}" }
202+
const toolInputProps = (parsedArgs as any).toolInputProps || {}
203+
const argumentsJson = JSON.stringify(toolInputProps)
204+
205+
// Add server_name, tool_name, and arguments to params
206+
normalizedParams = {
207+
server_name: parsedArgs.server_name || mcpToolInfo.serverName,
208+
tool_name: parsedArgs.tool_name || mcpToolInfo.toolName,
209+
arguments: argumentsJson,
210+
}
211+
} else {
212+
// Standard tool
213+
normalizedToolName = toolName as ToolName
214+
}
215+
184216
// Create a ToolUse block from the native tool call
185217
const toolUse: ToolUse = {
186218
type: "tool_use",
187-
name: toolName as ToolName,
188-
params: parsedArgs,
219+
name: normalizedToolName,
220+
params: normalizedParams,
189221
partial: false, // Now complete after accumulation
190222
}
191223

@@ -198,6 +230,7 @@ export class AssistantMessageParser {
198230
}
199231
}
200232
}
233+
201234
// kilocode_change end
202235

203236
/**
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// npx vitest src/core/assistant-message/kilocode/__tests__/AssistantMessageParser.spec.ts
2+
3+
import { AssistantMessageParser } from "../../AssistantMessageParser"
4+
import { ToolUse } from "../../../../shared/tools"
5+
6+
describe("AssistantMessageParser (streaming)", () => {
7+
let parser: AssistantMessageParser
8+
9+
beforeEach(() => {
10+
parser = new AssistantMessageParser()
11+
})
12+
describe("AssistantMessageParser (native tool calls)", () => {
13+
let parser: AssistantMessageParser
14+
15+
beforeEach(() => {
16+
parser = new AssistantMessageParser()
17+
})
18+
19+
describe("dynamic MCP tool name handling", () => {
20+
it("should normalize dynamic MCP tool names to use_mcp_tool", () => {
21+
const toolCalls = [
22+
{
23+
id: "call_123",
24+
type: "function" as const,
25+
function: {
26+
name: "use_mcp_tool___context7___get-library-docs",
27+
arguments: JSON.stringify({
28+
toolInputProps: {
29+
context7CompatibleLibraryID: "/vercel/next.js",
30+
topic: "routing",
31+
},
32+
}),
33+
},
34+
},
35+
]
36+
37+
parser.processNativeToolCalls(toolCalls)
38+
const blocks = parser.getContentBlocks()
39+
40+
expect(blocks).toHaveLength(1)
41+
const toolUse = blocks[0] as ToolUse
42+
expect(toolUse.type).toBe("tool_use")
43+
expect(toolUse.name).toBe("use_mcp_tool")
44+
expect(toolUse.params.server_name).toBe("context7")
45+
expect(toolUse.params.tool_name).toBe("get-library-docs")
46+
// Verify arguments contains the toolInputProps as a JSON string
47+
expect(toolUse.params.arguments).toBeDefined()
48+
const parsedArgs = JSON.parse(toolUse.params.arguments!)
49+
expect(parsedArgs.context7CompatibleLibraryID).toBe("/vercel/next.js")
50+
expect(parsedArgs.topic).toBe("routing")
51+
expect(toolUse.partial).toBe(false)
52+
})
53+
54+
it("should handle dynamic MCP tool names with underscores in tool name", () => {
55+
const toolCalls = [
56+
{
57+
id: "call_456",
58+
type: "function" as const,
59+
function: {
60+
name: "use_mcp_tool___myserver___get_user_data",
61+
arguments: JSON.stringify({
62+
toolInputProps: {
63+
userId: "123",
64+
},
65+
}),
66+
},
67+
},
68+
]
69+
70+
parser.processNativeToolCalls(toolCalls)
71+
const blocks = parser.getContentBlocks()
72+
73+
expect(blocks).toHaveLength(1)
74+
const toolUse = blocks[0] as ToolUse
75+
expect(toolUse.name).toBe("use_mcp_tool")
76+
expect(toolUse.params.server_name).toBe("myserver")
77+
expect(toolUse.params.tool_name).toBe("get_user_data")
78+
// Verify arguments contains the toolInputProps as a JSON string
79+
const parsedArgs = JSON.parse(toolUse.params.arguments!)
80+
expect(parsedArgs.userId).toBe("123")
81+
})
82+
83+
it("should reject malformed dynamic MCP tool names (no triple underscore separator)", () => {
84+
const toolCalls = [
85+
{
86+
id: "call_789",
87+
type: "function" as const,
88+
function: {
89+
name: "use_mcp_tool___notripleunderscoreseparator",
90+
arguments: JSON.stringify({}),
91+
},
92+
},
93+
]
94+
95+
// Mock console.warn to verify it's called
96+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
97+
98+
parser.processNativeToolCalls(toolCalls)
99+
const blocks = parser.getContentBlocks()
100+
101+
expect(blocks).toHaveLength(0)
102+
expect(warnSpy).toHaveBeenCalledWith(
103+
"[AssistantMessageParser] Unknown tool name in native call:",
104+
"use_mcp_tool___notripleunderscoreseparator",
105+
)
106+
107+
warnSpy.mockRestore()
108+
})
109+
110+
it("should handle streaming dynamic MCP tool calls", () => {
111+
// First delta: function name
112+
const delta1 = [
113+
{
114+
id: "call_stream",
115+
index: 0,
116+
type: "function" as const,
117+
function: {
118+
name: "use_mcp_tool___weather___get_forecast",
119+
arguments: "",
120+
},
121+
},
122+
]
123+
124+
// Second delta: partial arguments (name can be empty string during streaming)
125+
const delta2 = [
126+
{
127+
index: 0,
128+
type: "function" as const,
129+
function: {
130+
name: "",
131+
arguments: '{"toolInputProps": {"city": "San',
132+
},
133+
},
134+
]
135+
136+
// Third delta: complete arguments
137+
const delta3 = [
138+
{
139+
index: 0,
140+
type: "function" as const,
141+
function: {
142+
name: "",
143+
arguments: ' Francisco"}}',
144+
},
145+
},
146+
]
147+
148+
parser.processNativeToolCalls(delta1)
149+
let blocks = parser.getContentBlocks()
150+
expect(blocks).toHaveLength(0) // Not complete yet
151+
152+
parser.processNativeToolCalls(delta2)
153+
blocks = parser.getContentBlocks()
154+
expect(blocks).toHaveLength(0) // Still not complete
155+
156+
parser.processNativeToolCalls(delta3)
157+
blocks = parser.getContentBlocks()
158+
159+
expect(blocks).toHaveLength(1)
160+
const toolUse = blocks[0] as ToolUse
161+
expect(toolUse.name).toBe("use_mcp_tool")
162+
expect(toolUse.params.server_name).toBe("weather")
163+
expect(toolUse.params.tool_name).toBe("get_forecast")
164+
// Verify arguments contains the toolInputProps as a JSON string
165+
const parsedArgs = JSON.parse(toolUse.params.arguments!)
166+
expect(parsedArgs.city).toBe("San Francisco")
167+
})
168+
169+
it("should preserve existing server_name and tool_name in params if present", () => {
170+
const toolCalls = [
171+
{
172+
id: "call_preserve",
173+
type: "function" as const,
174+
function: {
175+
name: "use_mcp_tool___server1___tool1",
176+
arguments: JSON.stringify({
177+
server_name: "custom_server",
178+
tool_name: "custom_tool",
179+
toolInputProps: {
180+
data: "test",
181+
},
182+
}),
183+
},
184+
},
185+
]
186+
187+
parser.processNativeToolCalls(toolCalls)
188+
const blocks = parser.getContentBlocks()
189+
190+
expect(blocks).toHaveLength(1)
191+
const toolUse = blocks[0] as ToolUse
192+
expect(toolUse.name).toBe("use_mcp_tool")
193+
// Should preserve the params from arguments, not override with extracted values
194+
expect(toolUse.params.server_name).toBe("custom_server")
195+
expect(toolUse.params.tool_name).toBe("custom_tool")
196+
// Verify arguments contains the toolInputProps as a JSON string
197+
const parsedArgs = JSON.parse(toolUse.params.arguments!)
198+
expect(parsedArgs.data).toBe("test")
199+
})
200+
})
201+
202+
describe("standard tool names", () => {
203+
it("should handle standard tool names without modification", () => {
204+
const toolCalls = [
205+
{
206+
id: "call_standard",
207+
type: "function" as const,
208+
function: {
209+
name: "read_file",
210+
arguments: JSON.stringify({
211+
path: "src/file.ts",
212+
}),
213+
},
214+
},
215+
]
216+
217+
parser.processNativeToolCalls(toolCalls)
218+
const blocks = parser.getContentBlocks()
219+
220+
expect(blocks).toHaveLength(1)
221+
const toolUse = blocks[0] as ToolUse
222+
expect(toolUse.name).toBe("read_file")
223+
expect(toolUse.params.path).toBe("src/file.ts")
224+
})
225+
})
226+
})
227+
})

0 commit comments

Comments
 (0)