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
35 changes: 34 additions & 1 deletion src/kimi_cli/cli/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import typer

from kimi_cli.oauth import create_oauth

cli = typer.Typer(help="Manage MCP server configurations.")


Expand Down Expand Up @@ -91,6 +93,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
Expand Down Expand Up @@ -139,6 +145,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()
Expand All @@ -161,6 +175,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:
Expand All @@ -185,6 +202,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"] = {}
Expand Down Expand Up @@ -247,6 +269,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:
Expand All @@ -271,11 +295,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)
Comment on lines +306 to +308
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve transport inference when transport is unset

This new auth path hardcodes http when the config omits transport, but FastMCP's canonical config supports leaving transport empty and inferring it from the URL (notably /sse endpoints). For OAuth servers loaded from external/canonical MCP configs that rely on URL-based inference, kimi mcp auth will now choose StreamableHttpTransport instead of SSETransport, causing authorization/connect failures that did not happen in the previous Client({"mcpServers": ...}) flow.

Useful? React with 👍 / 👎.

transport = transport_cls(url, headers=headers, auth=auth)

client = fastmcp.Client(transport)
Comment on lines +308 to +311
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route mcp test through patched OAuth client

mcp_auth() now builds OAuth with create_oauth() (full-path RFC8707/resource behavior), but mcp_test() still uses the legacy fastmcp.Client({"mcpServers": ...}) path. When a server has no cached token yet (or needs refresh), mcp test can drop back to the unpatched OAuth flow and fail on providers that need path-aware resource/scope handling, even though mcp auth succeeded for the same server. Reusing the same patched transport/auth construction for mcp_test would keep these commands consistent.

Useful? React with 👍 / 👎.

try:
async with client:
tools = await client.list_tools()
Expand Down
42 changes: 42 additions & 0 deletions src/kimi_cli/oauth.py
Original file line number Diff line number Diff line change
@@ -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)
20 changes: 19 additions & 1 deletion src/kimi_cli/soul/toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,25 @@ 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}))
from fastmcp.client.transports import SSETransport, StreamableHttpTransport

from kimi_cli.oauth import create_oauth

Comment on lines +375 to +378
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toolset now imports create_oauth from kimi_cli.cli.mcp. This introduces a runtime dependency on the CLI module (and its Typer app construction) from the core soul runtime. Consider moving create_oauth into a non-CLI/shared module (e.g. kimi_cli/mcp/oauth.py) and importing it from both CLI and runtime to keep layering clean and avoid accidental CLI-side imports/side effects in non-CLI contexts.

Copilot uses AI. Check for mistakes.
scopes = getattr(server_config, "scopes", None)
transport_cls = (
SSETransport
if server_config.transport == "sse"
else StreamableHttpTransport
Comment on lines +380 to +383
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep URL-based transport inference in scoped OAuth loading

In the scoped OAuth branch, transport selection is reduced to transport == "sse" else HTTP, which skips FastMCP's normal inference for configs where transport is omitted. That means OAuth servers with scopes and URLs that imply SSE can be loaded with the wrong transport at runtime, while the old MCPConfig(...)->Client(...) path would infer correctly.

Useful? React with 👍 / 👎.

)
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=[]
)
Expand Down
Loading