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: 45 additions & 4 deletions src/kimi_cli/acp/server.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
from __future__ import annotations

import asyncio
import json
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Any, NamedTuple

import acp
from fastmcp.mcp_config import MCPConfig
from kaos.path import KaosPath
from pydantic import ValidationError

from kimi_cli.acp.kaos import ACPKaos
from kimi_cli.acp.mcp import acp_mcp_servers_to_mcp_config
Expand All @@ -27,6 +30,31 @@
from kimi_cli.utils.logging import logger


def _load_global_mcp_config() -> MCPConfig | None:
"""Load the globally-configured MCP servers shared with interactive mode.

These live in ``<share>/mcp.json`` (managed by ``kimi mcp add`` or edited by
hand) and are loaded by interactive ``kimi``, but were previously ignored by
the ``kimi acp`` server. Returns ``None`` when the file is absent or cannot
be parsed — a broken global config must not abort ACP session creation.
"""
from kimi_cli.cli.mcp import get_global_mcp_config_file

mcp_file = get_global_mcp_config_file()
if not mcp_file.exists():
return None
try:
data = json.loads(mcp_file.read_text(encoding="utf-8"))
return MCPConfig.model_validate(data)
except (OSError, json.JSONDecodeError, ValidationError) as exc:
logger.warning(
"Ignoring unreadable global MCP config {file}: {error}",
file=mcp_file,
error=exc,
)
return None


class ACPServer:
def __init__(self) -> None:
self.client_capabilities: acp.schema.ClientCapabilities | None = None
Expand Down Expand Up @@ -147,6 +175,21 @@ def _check_auth(self) -> None:
logger.warning("Authentication required, {reason}", reason=reason)
raise acp.RequestError.auth_required({"authMethods": auth_methods_data})

def _build_mcp_configs(self, mcp_servers: list[MCPServer] | None) -> list[MCPConfig]:
"""Assemble the MCP configs for a session.

Merges the globally-configured MCP servers (shared with interactive
``kimi``) with any servers the ACP client supplied in the request, so
``kimi acp`` exposes the same MCP tools as interactive mode. Servers
provided by the client take precedence on name conflicts.
"""
configs: list[MCPConfig] = []
global_config = _load_global_mcp_config()
if global_config is not None:
configs.append(global_config)
configs.append(acp_mcp_servers_to_mcp_config(mcp_servers or []))
return configs

async def new_session(
self, cwd: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any
) -> acp.NewSessionResponse:
Expand All @@ -159,10 +202,9 @@ async def new_session(

session = await Session.create(KaosPath.unsafe_from_local_path(Path(cwd)))

mcp_config = acp_mcp_servers_to_mcp_config(mcp_servers or [])
cli_instance = await KimiCLI.create(
session,
mcp_configs=[mcp_config],
mcp_configs=self._build_mcp_configs(mcp_servers),
ui_mode="acp",
)
config = cli_instance.soul.runtime.config
Expand Down Expand Up @@ -229,10 +271,9 @@ async def _setup_session(
)
raise acp.RequestError.invalid_params({"session_id": "Session not found"})

mcp_config = acp_mcp_servers_to_mcp_config(mcp_servers or [])
cli_instance = await KimiCLI.create(
session,
mcp_configs=[mcp_config],
mcp_configs=self._build_mcp_configs(mcp_servers),
resumed=True, # _setup_session loads existing sessions
ui_mode="acp",
)
Expand Down
52 changes: 52 additions & 0 deletions tests/acp/test_server_mcp_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Unit tests for ACPServer MCP config assembly.

Regression coverage for the ``kimi acp`` server ignoring the globally-configured
MCP servers (``<share>/mcp.json``) that interactive ``kimi`` loads.
"""

from __future__ import annotations

import json
from pathlib import Path

from kimi_cli.acp.server import ACPServer, _load_global_mcp_config


def _write_global_config(mcp_file: Path, servers: dict) -> None:
mcp_file.write_text(json.dumps({"mcpServers": servers}), encoding="utf-8")


def test_load_global_mcp_config_missing_file(tmp_path: Path, monkeypatch) -> None:
missing = tmp_path / "mcp.json"
monkeypatch.setattr("kimi_cli.cli.mcp.get_global_mcp_config_file", lambda: missing)
assert _load_global_mcp_config() is None


def test_load_global_mcp_config_malformed_is_ignored(tmp_path: Path, monkeypatch) -> None:
mcp_file = tmp_path / "mcp.json"
mcp_file.write_text("{ not valid json", encoding="utf-8")
monkeypatch.setattr("kimi_cli.cli.mcp.get_global_mcp_config_file", lambda: mcp_file)
# A broken global config must not raise; it is skipped.
assert _load_global_mcp_config() is None


def test_build_mcp_configs_includes_global_servers(tmp_path: Path, monkeypatch) -> None:
mcp_file = tmp_path / "mcp.json"
_write_global_config(mcp_file, {"fs-test": {"command": "python", "args": ["-c", "pass"]}})
monkeypatch.setattr("kimi_cli.cli.mcp.get_global_mcp_config_file", lambda: mcp_file)

configs = ACPServer()._build_mcp_configs(None)

server_names = {name for config in configs for name in config.mcpServers}
assert "fs-test" in server_names


def test_build_mcp_configs_without_global_config(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(
"kimi_cli.cli.mcp.get_global_mcp_config_file",
lambda: tmp_path / "does-not-exist.json",
)
# No global config on disk and no client-provided servers -> no MCP servers.
configs = ACPServer()._build_mcp_configs(None)
server_names = {name for config in configs for name in config.mcpServers}
assert server_names == set()
Loading