From f8fb87b0f9749626437d9986895e26e315a9a4ed Mon Sep 17 00:00:00 2001 From: Ariel Marti Date: Sat, 28 Mar 2026 23:07:48 +0700 Subject: [PATCH 1/2] feat(mcp): add --scope option for OAuth and fix upstream auth flow issues Add --scope/-s repeatable option to `kimi mcp add` for OAuth servers that require specific scopes. Validated to require --auth oauth and http transport, following the existing --header/--auth guard pattern. Refactor `kimi mcp auth` to use manual transport+OAuth construction so scopes are forwarded. Add create_oauth() helper with _PatchedOAuthClient that works around three upstream fastmcp/MCP SDK issues: URL path stripping breaking RFC 8707 resource matching, redirect_handler pre-flight GET misinterpreting 400 responses, and token exchange rejecting HTTP 201. Show configured scopes in `kimi mcp list` output and pass scopes through to OAuth during runtime MCP tool loading in toolset.py. --- src/kimi_cli/cli/mcp.py | 69 ++++++++- src/kimi_cli/soul/toolset.py | 29 +++- tests/core/test_mcp_cli.py | 268 +++++++++++++++++++++++++++++++++++ 3 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 tests/core/test_mcp_cli.py diff --git a/src/kimi_cli/cli/mcp.py b/src/kimi_cli/cli/mcp.py index 835774eb3..8805102b9 100644 --- a/src/kimi_cli/cli/mcp.py +++ b/src/kimi_cli/cli/mcp.py @@ -7,6 +7,42 @@ cli = typer.Typer(help="Manage MCP server configurations.") +def create_oauth(mcp_url: str, scopes: list[str] | None = None) -> Any: + """Create an OAuth client with workarounds for upstream fastmcp/MCP SDK bugs. + + Works around three issues: + - fastmcp strips the URL path, breaking RFC 8707 resource matching + - fastmcp's redirect_handler pre-flight GET misinterprets 400 responses + - MCP SDK rejects HTTP 201 on token exchange (e.g. Supabase) + """ + import webbrowser + + import httpx + from fastmcp.client.auth.oauth import OAuth + + class _PatchedOAuthClient(OAuth): + """OAuth client with workarounds for upstream fastmcp/MCP SDK issues. + + FIXME: Remove once upstream fixes land in fastmcp and mcp SDK. + """ + + def __init__(self, url: str, **kwargs: Any) -> None: + super().__init__(url, **kwargs) + self.context.server_url = url + + async def redirect_handler(self, authorization_url: str) -> None: + # Skip pre-flight GET that misinterprets 400 as "client not found" + webbrowser.open(authorization_url) + + async def _handle_token_response(self, response: httpx.Response) -> None: + # Accept 200 and 201 for token exchange (Supabase returns 201) + if response.status_code in (200, 201): + response.status_code = 200 + await super()._handle_token_response(response) + + return _PatchedOAuthClient(mcp_url, scopes=scopes) + + def get_global_mcp_config_file() -> Path: """Get the global MCP config file path.""" from kimi_cli.share import get_share_dir @@ -91,6 +127,10 @@ def _parse_key_value_pairs( # Add streamable HTTP server with OAuth authorization:\n kimi mcp add --transport http --auth oauth linear https://mcp.linear.app/mcp\n \n + # Add OAuth server with specific scopes (e.g., Supabase):\n + kimi mcp add --transport http --auth oauth supabase https://mcp.supabase.com/mcp \\\n + --scope "organizations:read" --scope "projects:read" --scope "database:read"\n + \n # Add stdio server:\n kimi mcp add --transport stdio chrome-devtools -- npx chrome-devtools-mcp@latest """.strip(), # noqa: E501 @@ -139,6 +179,14 @@ def mcp_add( help="Authorization type (e.g., 'oauth').", ), ] = None, + scope: Annotated[ + list[str] | None, + typer.Option( + "--scope", + "-s", + help="OAuth scope to request. Can be specified multiple times.", + ), + ] = None, ): """Add an MCP server.""" config = _load_mcp_config() @@ -161,6 +209,9 @@ def mcp_add( if auth: typer.echo("--auth is only valid for http transport.", err=True) raise typer.Exit(code=1) + if scope: + typer.echo("--scope is only valid for http transport.", err=True) + raise typer.Exit(code=1) command, *command_args = server_args server_config: dict[str, Any] = {"command": command, "args": command_args} if env: @@ -185,6 +236,11 @@ def mcp_add( ) if auth: server_config["auth"] = auth + if scope: + if auth != "oauth": + typer.echo("--scope is only valid with --auth oauth.", err=True) + raise typer.Exit(code=1) + server_config["scopes"] = scope if "mcpServers" not in config: config["mcpServers"] = {} @@ -247,6 +303,8 @@ def mcp_list(): if transport == "streamable-http": transport = "http" line = f"{name} ({transport}): {server['url']}" + if server.get("scopes") and server.get("auth") == "oauth": + line += f" [scopes: {', '.join(server['scopes'])}]" if server.get("auth") == "oauth" and not _has_oauth_tokens(server["url"]): line += " [authorization required - run: kimi mcp auth " + name + "]" else: @@ -271,11 +329,20 @@ def mcp_auth( async def _auth() -> None: import fastmcp + from fastmcp.client.transports import SSETransport, StreamableHttpTransport typer.echo(f"Authorizing with '{name}'...") typer.echo("A browser window will open for authorization.") - client = fastmcp.Client({"mcpServers": {name: server}}) + url = server["url"] + scopes = server.get("scopes") + headers = server.get("headers", {}) + transport_type = server.get("transport", "http") + transport_cls = SSETransport if transport_type == "sse" else StreamableHttpTransport + auth = create_oauth(url, scopes=scopes) + transport = transport_cls(url, headers=headers, auth=auth) + + client = fastmcp.Client(transport) try: async with client: tools = await client.list_tools() diff --git a/src/kimi_cli/soul/toolset.py b/src/kimi_cli/soul/toolset.py index 718bcde65..b44ecaa6b 100644 --- a/src/kimi_cli/soul/toolset.py +++ b/src/kimi_cli/soul/toolset.py @@ -372,7 +372,34 @@ async def _connect(): if isinstance(server_config, RemoteMCPServer) and server_config.auth == "oauth": oauth_servers[server_name] = server_config.url - client = fastmcp.Client(MCPConfig(mcpServers={server_name: server_config})) + scopes = ( + getattr(server_config, "scopes", None) + if isinstance(server_config, RemoteMCPServer) + else None + ) + if ( + isinstance(server_config, RemoteMCPServer) + and server_config.auth == "oauth" + and scopes + ): + from fastmcp.client.transports import SSETransport, StreamableHttpTransport + + from kimi_cli.cli.mcp import create_oauth + + transport_cls = ( + SSETransport + if server_config.transport == "sse" + else StreamableHttpTransport + ) + transport = transport_cls( + server_config.url, + headers=server_config.headers, + auth=create_oauth(server_config.url, scopes=scopes), + ) + client = fastmcp.Client(transport) + else: + client = fastmcp.Client(MCPConfig(mcpServers={server_name: server_config})) + self._mcp_servers[server_name] = MCPServerInfo( status="pending", client=client, tools=[] ) diff --git a/tests/core/test_mcp_cli.py b/tests/core/test_mcp_cli.py new file mode 100644 index 000000000..7f5cfda2a --- /dev/null +++ b/tests/core/test_mcp_cli.py @@ -0,0 +1,268 @@ +"""Tests for kimi mcp add/auth --scope feature.""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import Any +from unittest.mock import AsyncMock, patch + +from fastmcp.client.auth.oauth import OAuth +from typer.testing import CliRunner + +from kimi_cli.cli.mcp import cli + +_runner = CliRunner() + +_EXAMPLE_URL = "https://mcp.example.com/mcp" + + +@contextmanager +def _patch_mcp_config(initial: dict[str, Any] | None = None): + """Patch load/save so tests never touch the real config file. + + Yields a list that captures whatever was passed to _save_mcp_config. + """ + saved: list[dict[str, Any]] = [] + with patch.multiple( + "kimi_cli.cli.mcp", + _load_mcp_config=lambda: initial or {"mcpServers": {}}, + _save_mcp_config=lambda config: saved.append(config), + ): + yield saved + + +def _make_oauth_server(**overrides: Any) -> dict[str, Any]: + base: dict[str, Any] = {"url": _EXAMPLE_URL, "transport": "http", "auth": "oauth"} + base.update(overrides) + return base + + +def _mock_fastmcp_client(): + """Return a mock fastmcp.Client that supports `async with`.""" + mock_instance = AsyncMock() + mock_instance.__aenter__ = AsyncMock(return_value=mock_instance) + mock_instance.__aexit__ = AsyncMock(return_value=False) + mock_instance.list_tools = AsyncMock(return_value=[]) + return mock_instance + + +# --- mcp add: --scope rejected for stdio transport --- + + +def test_add_scope_rejected_for_stdio() -> None: + with _patch_mcp_config(): + result = _runner.invoke( + cli, + ["add", "-t", "stdio", "-s", "read", "myserver", "--", "npx", "srv"], + ) + assert result.exit_code != 0 + assert "--scope is only valid for http transport" in result.output + + +def test_add_stdio_header_checked_before_scope() -> None: + with _patch_mcp_config(): + result = _runner.invoke( + cli, + ["add", "-t", "stdio", "-H", "X-Key:val", "-s", "read", "myserver", "--", "npx", "srv"], + ) + assert result.exit_code != 0 + assert "--header is only valid for http transport" in result.output + + +# --- mcp add: --scope requires --auth oauth --- + + +def test_add_scope_rejected_without_auth() -> None: + with _patch_mcp_config(): + result = _runner.invoke( + cli, + ["add", "-t", "http", "-s", "read", "myserver", _EXAMPLE_URL], + ) + assert result.exit_code != 0 + assert "--scope is only valid with --auth oauth" in result.output + + +def test_add_scope_rejected_with_non_oauth_auth() -> None: + with _patch_mcp_config(): + result = _runner.invoke( + cli, + ["add", "-t", "http", "-a", "basic", "-s", "read", "myserver", _EXAMPLE_URL], + ) + assert result.exit_code != 0 + assert "--scope is only valid with --auth oauth" in result.output + + +def test_add_scope_accepted_with_auth_oauth() -> None: + with _patch_mcp_config(): + result = _runner.invoke( + cli, + [ + "add", + "-t", + "http", + "-a", + "oauth", + "-s", + "read", + "-s", + "write", + "myserver", + _EXAMPLE_URL, + ], + ) + assert result.exit_code == 0 + assert "Added MCP server 'myserver'" in result.output + + +def test_add_no_scope_still_works() -> None: + with _patch_mcp_config(): + result = _runner.invoke( + cli, + ["add", "-t", "http", "-a", "oauth", "myserver", _EXAMPLE_URL], + ) + assert result.exit_code == 0 + assert "Added MCP server 'myserver'" in result.output + + +# --- mcp add: config persistence --- + + +def test_add_scopes_stored_as_list() -> None: + with _patch_mcp_config() as saved: + result = _runner.invoke( + cli, + [ + "add", + "-t", + "http", + "-a", + "oauth", + "-s", + "organizations:read", + "-s", + "projects:read", + "supabase", + "https://mcp.supabase.com/mcp", + ], + ) + assert result.exit_code == 0 + server = saved[0]["mcpServers"]["supabase"] + assert server["scopes"] == ["organizations:read", "projects:read"] + assert server["auth"] == "oauth" + + +def test_add_no_scopes_key_when_omitted() -> None: + with _patch_mcp_config() as saved: + result = _runner.invoke( + cli, + ["add", "-t", "http", "-a", "oauth", "linear", "https://mcp.linear.app/mcp"], + ) + assert result.exit_code == 0 + server = saved[0]["mcpServers"]["linear"] + assert "scopes" not in server + + +# --- mcp auth: transport + OAuth construction --- + + +@patch("kimi_cli.cli.mcp._get_mcp_server") +@patch("kimi_cli.cli.mcp._load_mcp_config") +def test_auth_with_scopes_creates_oauth_with_scopes(mock_load: Any, mock_get: Any) -> None: + mock_get.return_value = _make_oauth_server(scopes=["read", "write"]) + + with ( + patch("fastmcp.Client") as mock_client, + patch("fastmcp.client.transports.StreamableHttpTransport") as mock_transport, + ): + mock_client.return_value = _mock_fastmcp_client() + result = _runner.invoke(cli, ["auth", "supabase"]) + + assert result.exit_code == 0 + mock_transport.assert_called_once() + call_args = mock_transport.call_args + # Transport called with (url, headers=..., auth=...) + assert call_args[0][0] == _EXAMPLE_URL # positional arg: url + assert call_args[1].get("headers") == {} + oauth_arg = call_args[1].get("auth") + assert isinstance(oauth_arg, OAuth) + assert oauth_arg.context.client_metadata.scope == "read write" + assert oauth_arg.context.server_url == _EXAMPLE_URL + + +@patch("kimi_cli.cli.mcp._get_mcp_server") +@patch("kimi_cli.cli.mcp._load_mcp_config") +def test_auth_without_scopes_creates_oauth_with_empty_scope(mock_load: Any, mock_get: Any) -> None: + mock_get.return_value = _make_oauth_server() + + with ( + patch("fastmcp.Client") as mock_client, + patch("fastmcp.client.transports.StreamableHttpTransport") as mock_transport, + ): + mock_client.return_value = _mock_fastmcp_client() + result = _runner.invoke(cli, ["auth", "linear"]) + + assert result.exit_code == 0 + mock_transport.assert_called_once() + call_args = mock_transport.call_args + oauth_arg = call_args[1].get("auth") + assert isinstance(oauth_arg, OAuth) + assert oauth_arg.context.client_metadata.scope == "" + assert oauth_arg.context.server_url == _EXAMPLE_URL + + +@patch("kimi_cli.cli.mcp._get_mcp_server") +@patch("kimi_cli.cli.mcp._load_mcp_config") +def test_auth_sse_transport_uses_sse_class(mock_load: Any, mock_get: Any) -> None: + mock_get.return_value = _make_oauth_server(transport="sse", scopes=["read"]) + + with ( + patch("fastmcp.Client") as mock_client, + patch("fastmcp.client.transports.SSETransport") as mock_sse, + ): + mock_client.return_value = _mock_fastmcp_client() + result = _runner.invoke(cli, ["auth", "myserver"]) + + assert result.exit_code == 0 + mock_sse.assert_called_once() + call_args = mock_sse.call_args + oauth_arg = call_args[1].get("auth") + assert isinstance(oauth_arg, OAuth) + assert oauth_arg.context.client_metadata.scope == "read" + + +@patch("kimi_cli.cli.mcp._get_mcp_server") +@patch("kimi_cli.cli.mcp._load_mcp_config") +def test_auth_passes_headers_from_config(mock_load: Any, mock_get: Any) -> None: + mock_get.return_value = _make_oauth_server(headers={"X-Api-Key": "secret"}) + + with ( + patch("fastmcp.Client") as mock_client, + patch("fastmcp.client.transports.StreamableHttpTransport") as mock_transport, + ): + mock_client.return_value = _mock_fastmcp_client() + result = _runner.invoke(cli, ["auth", "myserver"]) + + assert result.exit_code == 0 + mock_transport.assert_called_once() + call_args = mock_transport.call_args + assert call_args[1].get("headers") == {"X-Api-Key": "secret"} + + +@patch("kimi_cli.cli.mcp._get_mcp_server") +@patch("kimi_cli.cli.mcp._load_mcp_config") +def test_auth_fixes_server_url_to_full_mcp_url(mock_load: Any, mock_get: Any) -> None: + """Verify _OAuth patches context.server_url to include the full path (RFC 8707 fix).""" + mock_get.return_value = _make_oauth_server(scopes=["read"]) + + with ( + patch("fastmcp.Client") as mock_client, + patch("fastmcp.client.transports.StreamableHttpTransport") as mock_transport, + ): + mock_client.return_value = _mock_fastmcp_client() + result = _runner.invoke(cli, ["auth", "myserver"]) + + assert result.exit_code == 0 + call_args = mock_transport.call_args + oauth_arg = call_args[1].get("auth") + # fastmcp's OAuth.__init__ strips URL path; our patched client preserves it. + assert oauth_arg.context.server_url == _EXAMPLE_URL From af9cf48b1969d48c84caedfa0f9282c68d5601b2 Mon Sep 17 00:00:00 2001 From: Ariel Marti Date: Sun, 29 Mar 2026 00:52:39 +0700 Subject: [PATCH 2/2] refactor(mcp): extract oauth module and address bot feedback (#1625) - Extract create_oauth() and _PatchedOAuthClient to kimi_cli/oauth.py - Fix layering violation: soul/toolset.py imports from kimi_cli.oauth instead of cli.mcp - Remove scopes gate: OAuth workarounds now apply to ALL OAuth servers, not just those with explicit scopes - Fix return type: create_oauth() now returns OAuth instead of Any - Add E2E tests for OAuth scopes support --- src/kimi_cli/cli/mcp.py | 38 +------- src/kimi_cli/oauth.py | 42 +++++++++ src/kimi_cli/soul/toolset.py | 13 +-- tests_e2e/test_mcp_oauth_scopes.py | 135 +++++++++++++++++++++++++++++ 4 files changed, 181 insertions(+), 47 deletions(-) create mode 100644 src/kimi_cli/oauth.py create mode 100644 tests_e2e/test_mcp_oauth_scopes.py diff --git a/src/kimi_cli/cli/mcp.py b/src/kimi_cli/cli/mcp.py index 8805102b9..320a67ba2 100644 --- a/src/kimi_cli/cli/mcp.py +++ b/src/kimi_cli/cli/mcp.py @@ -4,43 +4,9 @@ import typer -cli = typer.Typer(help="Manage MCP server configurations.") - - -def create_oauth(mcp_url: str, scopes: list[str] | None = None) -> Any: - """Create an OAuth client with workarounds for upstream fastmcp/MCP SDK bugs. - - Works around three issues: - - fastmcp strips the URL path, breaking RFC 8707 resource matching - - fastmcp's redirect_handler pre-flight GET misinterprets 400 responses - - MCP SDK rejects HTTP 201 on token exchange (e.g. Supabase) - """ - import webbrowser - - import httpx - from fastmcp.client.auth.oauth import OAuth - - class _PatchedOAuthClient(OAuth): - """OAuth client with workarounds for upstream fastmcp/MCP SDK issues. +from kimi_cli.oauth import create_oauth - FIXME: Remove once upstream fixes land in fastmcp and mcp SDK. - """ - - def __init__(self, url: str, **kwargs: Any) -> None: - super().__init__(url, **kwargs) - self.context.server_url = url - - async def redirect_handler(self, authorization_url: str) -> None: - # Skip pre-flight GET that misinterprets 400 as "client not found" - webbrowser.open(authorization_url) - - async def _handle_token_response(self, response: httpx.Response) -> None: - # Accept 200 and 201 for token exchange (Supabase returns 201) - if response.status_code in (200, 201): - response.status_code = 200 - await super()._handle_token_response(response) - - return _PatchedOAuthClient(mcp_url, scopes=scopes) +cli = typer.Typer(help="Manage MCP server configurations.") def get_global_mcp_config_file() -> Path: diff --git a/src/kimi_cli/oauth.py b/src/kimi_cli/oauth.py new file mode 100644 index 000000000..44c1b88b9 --- /dev/null +++ b/src/kimi_cli/oauth.py @@ -0,0 +1,42 @@ +"""OAuth client utilities with workarounds for upstream fastmcp/MCP SDK issues.""" + +from __future__ import annotations + +from typing import Any + +from fastmcp.client.auth.oauth import OAuth + + +def create_oauth(mcp_url: str, scopes: list[str] | None = None) -> OAuth: + """Create an OAuth client with workarounds for upstream fastmcp/MCP SDK bugs. + + Works around three issues: + - fastmcp strips the URL path, breaking RFC 8707 resource matching + - fastmcp's redirect_handler pre-flight GET misinterprets 400 responses + - MCP SDK rejects HTTP 201 on token exchange (e.g. Supabase) + """ + import webbrowser + + import httpx + + class _PatchedOAuthClient(OAuth): + """OAuth client with workarounds for upstream fastmcp/MCP SDK issues. + + FIXME: Remove once upstream fixes land in fastmcp and mcp SDK. + """ + + def __init__(self, url: str, **kwargs: Any) -> None: + super().__init__(url, **kwargs) + self.context.server_url = url + + async def redirect_handler(self, authorization_url: str) -> None: + # Skip pre-flight GET that misinterprets 400 as "client not found" + webbrowser.open(authorization_url) + + async def _handle_token_response(self, response: httpx.Response) -> None: + # Accept 201 for token exchange (Supabase returns this) + if response.status_code == 201: + response.status_code = 200 + await super()._handle_token_response(response) + + return _PatchedOAuthClient(mcp_url, scopes=scopes) diff --git a/src/kimi_cli/soul/toolset.py b/src/kimi_cli/soul/toolset.py index b44ecaa6b..d9150439f 100644 --- a/src/kimi_cli/soul/toolset.py +++ b/src/kimi_cli/soul/toolset.py @@ -372,20 +372,11 @@ async def _connect(): if isinstance(server_config, RemoteMCPServer) and server_config.auth == "oauth": oauth_servers[server_name] = server_config.url - scopes = ( - getattr(server_config, "scopes", None) - if isinstance(server_config, RemoteMCPServer) - else None - ) - if ( - isinstance(server_config, RemoteMCPServer) - and server_config.auth == "oauth" - and scopes - ): from fastmcp.client.transports import SSETransport, StreamableHttpTransport - from kimi_cli.cli.mcp import create_oauth + from kimi_cli.oauth import create_oauth + scopes = getattr(server_config, "scopes", None) transport_cls = ( SSETransport if server_config.transport == "sse" diff --git a/tests_e2e/test_mcp_oauth_scopes.py b/tests_e2e/test_mcp_oauth_scopes.py new file mode 100644 index 000000000..d5366d0d2 --- /dev/null +++ b/tests_e2e/test_mcp_oauth_scopes.py @@ -0,0 +1,135 @@ +"""Test OAuth scopes support in MCP commands.""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +from inline_snapshot import snapshot + +from tests_e2e.wire_helpers import ( + base_command, + make_env, + make_home_dir, + normalize_value, + repo_root, + share_dir, +) + + +def _normalize_cli_output(text: str) -> str: + normalized = text + normalized = normalize_value(normalized) + normalized = normalized.replace("kimi-agent mcp", " mcp") + normalized = normalized.replace("kimi mcp", " mcp") + return normalized + + +def _run_cli(args: list[str], env: dict[str, str]) -> subprocess.CompletedProcess[str]: + cmd = base_command() + args + return subprocess.run( + cmd, + cwd=repo_root(), + env=env, + text=True, + encoding="utf-8", + errors="replace", + capture_output=True, + timeout=30, + ) + + +def _mcp_config_path(home_dir: Path) -> Path: + return share_dir(home_dir) / "mcp.json" + + +def _load_mcp_config(home_dir: Path) -> dict[str, object]: + config_path = _mcp_config_path(home_dir) + assert config_path.exists() + data = json.loads(config_path.read_text(encoding="utf-8")) + normalized = normalize_value(data) + assert isinstance(normalized, dict) + return normalized + + +def test_mcp_add_oauth_with_scopes(tmp_path: Path) -> None: + """Test that --scope option is stored in config.""" + home_dir = make_home_dir(tmp_path) + env = make_env(home_dir) + + # Add server with multiple scopes + add = _run_cli( + [ + "mcp", + "add", + "--transport", + "http", + "--auth", + "oauth", + "--scope", + "organizations:read", + "--scope", + "projects:read", + "--scope", + "database:write", + "supabase", + "https://mcp.supabase.com/mcp", + ], + env, + ) + + assert add.returncode == 0, _normalize_cli_output(add.stderr) + assert _normalize_cli_output(add.stdout) == snapshot( + "Added MCP server 'supabase' to /.kimi/mcp.json.\n" + ) + + # Verify config has scopes + config = _load_mcp_config(home_dir) + assert config == snapshot( + { + "mcpServers": { + "supabase": { + "auth": "oauth", + "scopes": ["organizations:read", "projects:read", "database:write"], + "transport": "http", + "url": "https://mcp.supabase.com/mcp", + } + } + } + ) + + +def test_mcp_list_shows_scopes(tmp_path: Path) -> None: + """Test that mcp list shows scopes.""" + home_dir = make_home_dir(tmp_path) + env = make_env(home_dir) + + # Add server with scopes + _run_cli( + [ + "mcp", + "add", + "--transport", + "http", + "--auth", + "oauth", + "--scope", + "read", + "--scope", + "write", + "test", + "https://example.com/mcp", + ], + env, + ) + + # List should show scopes + listed = _run_cli(["mcp", "list"], env) + assert listed.returncode == 0, _normalize_cli_output(listed.stderr) + assert _normalize_cli_output(listed.stdout) == snapshot( + """\ +MCP config file: /.kimi/mcp.json + test (http): https://example.com/mcp [scopes: read, write] [authorization required - run: mcp auth test] +""" + )