-
Notifications
You must be signed in to change notification settings - Fork 798
feat(mcp): add --scope option for OAuth and fix upstream auth flow issues #1625
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,8 @@ | |
|
|
||
| import typer | ||
|
|
||
| from kimi_cli.oauth import create_oauth | ||
|
|
||
| cli = typer.Typer(help="Manage MCP server configurations.") | ||
|
|
||
|
|
||
|
|
@@ -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 | ||
|
|
@@ -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() | ||
|
|
@@ -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: | ||
|
|
@@ -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"] = {} | ||
|
|
@@ -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: | ||
|
|
@@ -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) | ||
| transport = transport_cls(url, headers=headers, auth=auth) | ||
|
|
||
| client = fastmcp.Client(transport) | ||
|
Comment on lines
+308
to
+311
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| try: | ||
| async with client: | ||
| tools = await client.list_tools() | ||
|
|
||
| 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) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
|
||
| scopes = getattr(server_config, "scopes", None) | ||
| transport_cls = ( | ||
| SSETransport | ||
| if server_config.transport == "sse" | ||
| else StreamableHttpTransport | ||
|
Comment on lines
+380
to
+383
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In the scoped OAuth branch, transport selection is reduced to 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=[] | ||
| ) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
transportis unsetThis new auth path hardcodes
httpwhen the config omitstransport, but FastMCP's canonical config supports leavingtransportempty and inferring it from the URL (notably/sseendpoints). For OAuth servers loaded from external/canonical MCP configs that rely on URL-based inference,kimi mcp authwill now chooseStreamableHttpTransportinstead ofSSETransport, causing authorization/connect failures that did not happen in the previousClient({"mcpServers": ...})flow.Useful? React with 👍 / 👎.