Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/green-melons-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---

Add Native MCP Support for JSON Tool Calling
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,16 @@ Because of these risks and considerations, this capability is experiment, and of

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

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

- OpenRouter
- Kilo Code
- LM Studio
- OpenAI Compatible
- Z.ai
- Synthetic
- X.ai
- Chutes

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.

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

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

- Missing tools
There are possible issues including, but not limited to:

- ~~Missing tools~~: As of Oct 21, all tools are supported
- Tools calls not updating the UI until they are complete
- MCP servers not working
- ~~MCP servers not working~~: As of Oct 21, MCPs are supported
- Errors specific to certain inference providers
- 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.

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.
43 changes: 38 additions & 5 deletions src/core/assistant-message/AssistantMessageParser.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type ToolName, toolNames } from "@roo-code/types"
import { TextContent, ToolUse, ToolParamName, toolParamNames } from "../../shared/tools"
import { AssistantMessageContent } from "./parseAssistantMessage"
import { NativeToolCall, parseDoubleEncodedParams } from "./kilocode/native-tool-call"
import { extractMcpToolInfo, NativeToolCall, parseDoubleEncodedParams } from "./kilocode/native-tool-call"

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

// Validate that this is a recognized tool name
if (!toolNames.includes(toolName as ToolName)) {
// Check if it's a dynamic MCP tool or a recognized static tool name
const mcpToolInfo = extractMcpToolInfo(toolName)
const isValidTool = mcpToolInfo !== null || toolNames.includes(toolName as ToolName)

if (!isValidTool) {
console.warn("[AssistantMessageParser] Unknown tool name in native call:", toolName)
continue
}
Expand Down Expand Up @@ -175,17 +178,46 @@ export class AssistantMessageParser {
// Tool call is complete - convert it to ToolUse format
if (isComplete) {
const toolName = accumulatedCall.function!.name

// Finalize any current text content before adding tool use
if (this.currentTextContent) {
this.currentTextContent.partial = false
this.currentTextContent = undefined
}

// Normalize dynamic MCP tool names to "use_mcp_tool"
// Dynamic tools have format: use_mcp_tool_{serverName}_{toolName}
const mcpToolInfo = extractMcpToolInfo(toolName)
let normalizedToolName: ToolName
let normalizedParams = parsedArgs

if (mcpToolInfo) {
// Dynamic MCP tool - normalize to "use_mcp_tool"
// Tool name format: use_mcp_tool___{serverName}___{toolName}
normalizedToolName = "use_mcp_tool"

// Extract toolInputProps and convert to JSON string for the arguments parameter
// The model provides: { server_name, tool_name, toolInputProps: {...actual args...} }
// We need: { server_name, tool_name, arguments: "{...actual args as JSON string...}" }
const toolInputProps = (parsedArgs as any).toolInputProps || {}
const argumentsJson = JSON.stringify(toolInputProps)

// Add server_name, tool_name, and arguments to params
normalizedParams = {
server_name: parsedArgs.server_name || mcpToolInfo.serverName,
tool_name: parsedArgs.tool_name || mcpToolInfo.toolName,
arguments: argumentsJson,
}
} else {
// Standard tool
normalizedToolName = toolName as ToolName
}

// Create a ToolUse block from the native tool call
const toolUse: ToolUse = {
type: "tool_use",
name: toolName as ToolName,
params: parsedArgs,
name: normalizedToolName,
params: normalizedParams,
partial: false, // Now complete after accumulation
}

Expand All @@ -198,6 +230,7 @@ export class AssistantMessageParser {
}
}
}

// kilocode_change end

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
// npx vitest src/core/assistant-message/kilocode/__tests__/AssistantMessageParser.spec.ts

import { AssistantMessageParser } from "../../AssistantMessageParser"
import { ToolUse } from "../../../../shared/tools"

describe("AssistantMessageParser (streaming)", () => {
let parser: AssistantMessageParser

beforeEach(() => {
parser = new AssistantMessageParser()
})
describe("AssistantMessageParser (native tool calls)", () => {
let parser: AssistantMessageParser

beforeEach(() => {
parser = new AssistantMessageParser()
})

describe("dynamic MCP tool name handling", () => {
it("should normalize dynamic MCP tool names to use_mcp_tool", () => {
const toolCalls = [
{
id: "call_123",
type: "function" as const,
function: {
name: "use_mcp_tool___context7___get-library-docs",
arguments: JSON.stringify({
toolInputProps: {
context7CompatibleLibraryID: "/vercel/next.js",
topic: "routing",
},
}),
},
},
]

parser.processNativeToolCalls(toolCalls)
const blocks = parser.getContentBlocks()

expect(blocks).toHaveLength(1)
const toolUse = blocks[0] as ToolUse
expect(toolUse.type).toBe("tool_use")
expect(toolUse.name).toBe("use_mcp_tool")
expect(toolUse.params.server_name).toBe("context7")
expect(toolUse.params.tool_name).toBe("get-library-docs")
// Verify arguments contains the toolInputProps as a JSON string
expect(toolUse.params.arguments).toBeDefined()
const parsedArgs = JSON.parse(toolUse.params.arguments!)
expect(parsedArgs.context7CompatibleLibraryID).toBe("/vercel/next.js")
expect(parsedArgs.topic).toBe("routing")
expect(toolUse.partial).toBe(false)
})

it("should handle dynamic MCP tool names with underscores in tool name", () => {
const toolCalls = [
{
id: "call_456",
type: "function" as const,
function: {
name: "use_mcp_tool___myserver___get_user_data",
arguments: JSON.stringify({
toolInputProps: {
userId: "123",
},
}),
},
},
]

parser.processNativeToolCalls(toolCalls)
const blocks = parser.getContentBlocks()

expect(blocks).toHaveLength(1)
const toolUse = blocks[0] as ToolUse
expect(toolUse.name).toBe("use_mcp_tool")
expect(toolUse.params.server_name).toBe("myserver")
expect(toolUse.params.tool_name).toBe("get_user_data")
// Verify arguments contains the toolInputProps as a JSON string
const parsedArgs = JSON.parse(toolUse.params.arguments!)
expect(parsedArgs.userId).toBe("123")
})

it("should reject malformed dynamic MCP tool names (no triple underscore separator)", () => {
const toolCalls = [
{
id: "call_789",
type: "function" as const,
function: {
name: "use_mcp_tool___notripleunderscoreseparator",
arguments: JSON.stringify({}),
},
},
]

// Mock console.warn to verify it's called
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})

parser.processNativeToolCalls(toolCalls)
const blocks = parser.getContentBlocks()

expect(blocks).toHaveLength(0)
expect(warnSpy).toHaveBeenCalledWith(
"[AssistantMessageParser] Unknown tool name in native call:",
"use_mcp_tool___notripleunderscoreseparator",
)

warnSpy.mockRestore()
})

it("should handle streaming dynamic MCP tool calls", () => {
// First delta: function name
const delta1 = [
{
id: "call_stream",
index: 0,
type: "function" as const,
function: {
name: "use_mcp_tool___weather___get_forecast",
arguments: "",
},
},
]

// Second delta: partial arguments (name can be empty string during streaming)
const delta2 = [
{
index: 0,
type: "function" as const,
function: {
name: "",
arguments: '{"toolInputProps": {"city": "San',
},
},
]

// Third delta: complete arguments
const delta3 = [
{
index: 0,
type: "function" as const,
function: {
name: "",
arguments: ' Francisco"}}',
},
},
]

parser.processNativeToolCalls(delta1)
let blocks = parser.getContentBlocks()
expect(blocks).toHaveLength(0) // Not complete yet

parser.processNativeToolCalls(delta2)
blocks = parser.getContentBlocks()
expect(blocks).toHaveLength(0) // Still not complete

parser.processNativeToolCalls(delta3)
blocks = parser.getContentBlocks()

expect(blocks).toHaveLength(1)
const toolUse = blocks[0] as ToolUse
expect(toolUse.name).toBe("use_mcp_tool")
expect(toolUse.params.server_name).toBe("weather")
expect(toolUse.params.tool_name).toBe("get_forecast")
// Verify arguments contains the toolInputProps as a JSON string
const parsedArgs = JSON.parse(toolUse.params.arguments!)
expect(parsedArgs.city).toBe("San Francisco")
})

it("should preserve existing server_name and tool_name in params if present", () => {
const toolCalls = [
{
id: "call_preserve",
type: "function" as const,
function: {
name: "use_mcp_tool___server1___tool1",
arguments: JSON.stringify({
server_name: "custom_server",
tool_name: "custom_tool",
toolInputProps: {
data: "test",
},
}),
},
},
]

parser.processNativeToolCalls(toolCalls)
const blocks = parser.getContentBlocks()

expect(blocks).toHaveLength(1)
const toolUse = blocks[0] as ToolUse
expect(toolUse.name).toBe("use_mcp_tool")
// Should preserve the params from arguments, not override with extracted values
expect(toolUse.params.server_name).toBe("custom_server")
expect(toolUse.params.tool_name).toBe("custom_tool")
// Verify arguments contains the toolInputProps as a JSON string
const parsedArgs = JSON.parse(toolUse.params.arguments!)
expect(parsedArgs.data).toBe("test")
})
})

describe("standard tool names", () => {
it("should handle standard tool names without modification", () => {
const toolCalls = [
{
id: "call_standard",
type: "function" as const,
function: {
name: "read_file",
arguments: JSON.stringify({
path: "src/file.ts",
}),
},
},
]

parser.processNativeToolCalls(toolCalls)
const blocks = parser.getContentBlocks()

expect(blocks).toHaveLength(1)
const toolUse = blocks[0] as ToolUse
expect(toolUse.name).toBe("read_file")
expect(toolUse.params.path).toBe("src/file.ts")
})
})
})
})
Loading