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
49 changes: 48 additions & 1 deletion packages/kosong/src/kosong/chat_provider/kimi.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
)
from kosong.tooling import Tool
from kosong.utils.jsonschema import JsonDict, ensure_property_types
from kosong.utils.typing import JsonType

if TYPE_CHECKING:

Expand Down Expand Up @@ -383,11 +384,57 @@ def _convert_tool(tool: Tool) -> ChatCompletionToolParam:
function = converted["function"]
parameters = function.get("parameters")
if isinstance(parameters, dict):
normalized = ensure_property_types(cast(JsonDict, parameters))
normalized = copy.deepcopy(cast(JsonDict, parameters))
if isinstance(normalized.get("properties"), dict):
normalized.setdefault("type", "object")
normalized = _normalize_object_any_of(normalized)
normalized = ensure_property_types(normalized)
function["parameters"] = cast(dict[str, object], normalized)
return converted


def _normalize_object_any_of(schema: JsonDict) -> JsonDict:
"""Distribute a simple object schema into ``anyOf`` required branches.

Moonshot rejects ``type`` beside ``anyOf``. MCP tools commonly use this
shape solely to require one of several object properties. Distributing the
shared object constraints into those branches preserves the schema's
meaning while satisfying Moonshot's stricter dialect.
"""
branches = schema.get("anyOf")
if not (
schema.get("type") == "object"
and isinstance(schema.get("properties"), dict)
and isinstance(branches, list)
and branches
and all(
isinstance(branch, dict)
and set(branch).issubset({"required"})
and isinstance(branch.get("required", []), list)
for branch in branches
)
):
return schema

common = copy.deepcopy(schema)
common.pop("anyOf")
common_required = common.pop("required", [])
if not isinstance(common_required, list):
return schema

distributed: list[JsonType] = []
for branch in branches:
assert isinstance(branch, dict)
branch_required = branch.get("required", [])
assert isinstance(branch_required, list)
branch_schema = copy.deepcopy(common)
required = list(dict.fromkeys([*common_required, *branch_required]))
if required:
branch_schema["required"] = required
distributed.append(branch_schema)
return {"anyOf": distributed}


class KimiStreamedMessage:
"""The streamed message of the Kimi chat provider."""

Expand Down
52 changes: 52 additions & 0 deletions packages/kosong/tests/test_kimi_tool_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,55 @@ def test_convert_tool_passes_through_already_typed_schema() -> None:
"properties": {"msg": {"type": "string"}},
"required": ["msg"],
}


def test_convert_tool_distributes_object_constraints_into_any_of() -> None:
tool = Tool(
name="api_impact",
description="Find an API route or file.",
parameters={
"type": "object",
"properties": {
"route": {"type": "string"},
"file": {"type": "string"},
},
"required": [],
"anyOf": [{"required": ["route"]}, {"required": ["file"]}],
},
)

parameters = _convert_tool(tool)["function"].get("parameters")

assert parameters == {
"anyOf": [
{
"type": "object",
"properties": {
"route": {"type": "string"},
"file": {"type": "string"},
},
"required": ["route"],
},
{
"type": "object",
"properties": {
"route": {"type": "string"},
"file": {"type": "string"},
},
"required": ["file"],
},
]
}


def test_convert_tool_adds_missing_root_object_type() -> None:
tool = Tool(
name="echo",
description="Echo input.",
parameters={"properties": {"message": {"type": "string"}}},
)

assert _convert_tool(tool)["function"].get("parameters") == {
"type": "object",
"properties": {"message": {"type": "string"}},
}
32 changes: 31 additions & 1 deletion src/kimi_cli/soul/toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
import importlib
import inspect
import json
import re
import time
from contextvars import ContextVar
from dataclasses import dataclass
from datetime import timedelta
from hashlib import sha256
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, cast, overload

Expand Down Expand Up @@ -61,6 +63,20 @@

_current_session_id: ContextVar[str] = ContextVar("_current_session_id", default="")

_MOONSHOT_TOOL_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$")


def _safe_mcp_tool_name(server_name: str, tool_name: str) -> str:
"""Return a stable Moonshot-compatible alias for an MCP tool name."""
if len(tool_name) <= 64 and _MOONSHOT_TOOL_NAME_RE.fullmatch(tool_name):
return tool_name

stem = re.sub(r"[^A-Za-z0-9_-]", "_", tool_name)
if not stem or not stem[0].isalpha() or not stem[0].isascii():
stem = f"m_{stem}"
digest = sha256(f"{server_name}\0{tool_name}".encode()).hexdigest()[:8]
return f"{stem[:55]}_{digest}"


def set_session_id(sid: str) -> None:
_current_session_id.set(sid)
Expand Down Expand Up @@ -251,6 +267,10 @@ def set_hook_engine(self, engine: HookEngine) -> None:
self._hook_engine = engine

def add(self, tool: ToolType) -> None:
if tool.name in self._tool_dict and isinstance(tool, MCPTool):
raise ValueError(
f"MCP tool name conflict: runtime name `{tool.name}` is already registered"
)
self._tool_dict[tool.name] = tool

def hide(self, tool_name: str) -> bool:
Expand Down Expand Up @@ -796,6 +816,16 @@ async def _connect_server(
MCPTool(server_name, tool, client, runtime=runtime)
)

registered_names = set(self._tool_dict)
pending_names: set[str] = set()
for tool in server_info.tools:
if tool.name in registered_names or tool.name in pending_names:
raise ValueError(
f"MCP tool name conflict: runtime name `{tool.name}` "
"is already registered"
)
pending_names.add(tool.name)

for tool in server_info.tools:
self.add(tool)

Expand Down Expand Up @@ -915,7 +945,7 @@ def __init__(
**kwargs: Any,
):
super().__init__(
name=mcp_tool.name,
name=_safe_mcp_tool_name(server_name, mcp_tool.name),
description=(
f"This is an MCP (Model Context Protocol) tool from MCP server `{server_name}`.\n\n"
f"{mcp_tool.description or 'No description provided.'}"
Expand Down
76 changes: 76 additions & 0 deletions tests/core/test_mcp_tool_names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from types import SimpleNamespace
from typing import Any, cast

import pytest
from mcp.types import Tool

from kimi_cli.soul.toolset import KimiToolset, MCPTool, _safe_mcp_tool_name


def test_safe_mcp_tool_name_preserves_compatible_name() -> None:
assert _safe_mcp_tool_name("magic", "component_builder") == "component_builder"


def test_safe_mcp_tool_name_rewrites_invalid_name_stably() -> None:
first = _safe_mcp_tool_name("magic", "21st.magic component builder")
second = _safe_mcp_tool_name("magic", "21st.magic component builder")

assert first == second
assert first.startswith("m_21st_magic_component_builder_")
assert len(first) <= 64


def test_mcp_tool_keeps_original_name_for_server_routing() -> None:
upstream = Tool(
name="21st_magic_component_builder",
description="Build a component.",
inputSchema={"type": "object", "properties": {}},
)
runtime = SimpleNamespace(
config=SimpleNamespace(
mcp=SimpleNamespace(client=SimpleNamespace(tool_call_timeout_ms=60_000))
)
)

tool = MCPTool("magic", upstream, cast(Any, object()), runtime=cast(Any, runtime))

assert tool.name.startswith("m_21st_magic_component_builder_")
assert tool._mcp_tool.name == "21st_magic_component_builder"


def test_mcp_alias_collision_is_rejected_instead_of_silent_overwrite() -> None:
server_name = "magic"
invalid_name = "21st.magic component builder"
generated_alias = _safe_mcp_tool_name(server_name, invalid_name)
runtime = SimpleNamespace(
config=SimpleNamespace(
mcp=SimpleNamespace(client=SimpleNamespace(tool_call_timeout_ms=60_000))
)
)
client = cast(Any, object())
invalid_tool = MCPTool(
server_name,
Tool(name=invalid_name, description="invalid original", inputSchema={"type": "object"}),
client,
runtime=cast(Any, runtime),
)
valid_tool = MCPTool(
server_name,
Tool(
name=generated_alias,
description="valid original",
inputSchema={"type": "object"},
),
client,
runtime=cast(Any, runtime),
)

assert invalid_tool.name == valid_tool.name

toolset = KimiToolset()
toolset.add(invalid_tool)

with pytest.raises(ValueError, match="MCP tool name conflict"):
toolset.add(valid_tool)

assert toolset.find(generated_alias) is invalid_tool