Skip to content
Merged
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Changelog

## 0.2.1

- 适配 MCP Python SDK 2.x:低层 `Server` 改为构造参数 `on_list_tools` / `on_call_tool`,返回 `ListToolsResult` / `CallToolResult`,工具列表变更通知改走 `ctx.session.send_tool_list_changed()`。
- 依赖改为 `mcp>=2.2,<3`,避免再解析到已删除 `Server.list_tools` 的版本,也避免下一个大版本无上限装崩。
- `__version__` 与包版本对齐为 0.2.1。

## 0.2.0

- 增加 Link 模式认证(`--link`):远程客户端通过授权链接与授权码完成登录,不依赖本机浏览器回调。
17 changes: 6 additions & 11 deletions claude.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Uno MCP Stdio - Claude 项目指南

当前版本 **0.2.1**。运行时依赖 `mcp>=2.2,<3`。低层 Server 用 `on_list_tools` / `on_call_tool`(mcp 2.x 已删除装饰器 `list_tools`)。仓库没有发布 workflow,合并不会自动发到 PyPI。

## 项目概述

`uno-mcp-stdio` 是 Uno MCP Gateway 的本地 stdio 代理客户端。它解决了不支持 OAuth 认证的 MCP 客户端(如 Manus、Cherry Studio)无法连接需要认证的 MCP 服务器的问题。
Expand Down Expand Up @@ -57,19 +59,12 @@ uno-mcp-stdio/

### 1. stdio_server.py - MCP Server 实现

使用 MCP Python SDK 实现 stdio 传输的 server:

```python
class UnoStdioServer:
# 处理 tools/list - 代理到 gateway 获取工具列表
# 处理 tools/call - 代理到 gateway 执行工具
# 处理 uno_auth_required - 启动 OAuth 认证流程
```
使用 MCP Python SDK 2.x 低层 `Server`(`on_list_tools` / `on_call_tool`)实现 stdio server,代理到远程 gateway。本地工具名是 `uno_auth`(登录、退出、状态;Link 模式带 `code`)。

关键点:
- 如果未认证,`tools/list` 返回一个 `uno_auth_required` 工具
- 用户调用该工具触发 OAuth 认证流程
- 认证成功后,重新调用 `tools/list` 获取真实工具列表
- `tools/list` 始终在列表开头放 `uno_auth`,其余工具来自 gateway
- 未登录时 `tools/call` 除 `uno_auth` 外返回需要认证
- 认证成功后通过 `ctx.session.send_tool_list_changed()` 通知客户端刷新

### 2. token_manager.py - Token 管理

Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uno-mcp-stdio"
version = "0.1.9"
version = "0.2.1"
description = "Uno MCP Stdio Client - Local stdio proxy for Uno MCP Gateway with OAuth authentication"
readme = "README.md"
requires-python = ">=3.11"
Expand All @@ -21,7 +21,7 @@ classifiers = [
]

dependencies = [
"mcp>=1.0.0",
"mcp>=2.2,<3",
"httpx>=0.27.0",
"pydantic>=2.5.0",
"pydantic-settings>=2.1.0",
Expand Down
2 changes: 1 addition & 1 deletion src/uno_mcp_stdio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@
为不支持 OAuth 认证的 MCP 客户端提供本地代理。
"""

__version__ = "0.1.3"
__version__ = "0.2.1"

186 changes: 186 additions & 0 deletions src/uno_mcp_stdio/auth/token_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,45 @@ def from_dict(cls, data: Dict[str, Any]) -> "ClientRegistration":
)


@dataclass
class PendingAuthSession:
"""
待完成的认证会话(用于 Link 模式)

Link 模式下,用户需要分两步完成认证:
1. 获取认证链接
2. 输入授权码

这个类存储第一步生成的 PKCE 参数,供第二步使用。
"""
code_verifier: str
code_challenge: str
state: str
redirect_uri: str
client_id: str
created_at: float # Unix timestamp

def is_expired(self, timeout_seconds: int = 600) -> bool:
"""检查会话是否过期(默认 10 分钟)"""
return time.time() > (self.created_at + timeout_seconds)

def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return asdict(self)

@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "PendingAuthSession":
"""从字典创建"""
return cls(
code_verifier=data["code_verifier"],
code_challenge=data["code_challenge"],
state=data["state"],
redirect_uri=data["redirect_uri"],
client_id=data["client_id"],
created_at=data["created_at"]
)


class TokenManager:
"""Token 管理器"""

Expand All @@ -101,6 +140,9 @@ def __init__(self):
self._oauth_metadata: Optional[OAuthMetadata] = None
self._client_registration: Optional[ClientRegistration] = None
self._client_registration_path = self._credentials_path.parent / "client.json"
# Link 模式相关
self._pending_session: Optional[PendingAuthSession] = None
self._pending_session_path = self._credentials_path.parent / "pending_session.json"

def _log(self, message: str):
"""输出日志到 stderr(避免干扰 stdio 通信)"""
Expand Down Expand Up @@ -251,6 +293,64 @@ async def ensure_client_registered(self, redirect_uri: str) -> Optional[str]:
self._log("动态注册失败,使用默认 client_id")
return settings.oauth_client_id

# ==================== Pending Session Management (Link Mode) ====================

def save_pending_session(self, session: PendingAuthSession):
"""
保存待完成的认证会话(Link 模式)

在用户获取认证链接后,保存 PKCE 参数,等待用户输入授权码。
"""
self._pending_session = session

self._pending_session_path.parent.mkdir(parents=True, exist_ok=True)

with open(self._pending_session_path, "w") as f:
json.dump(session.to_dict(), f, indent=2)

self._log(f"已保存 pending session: state={session.state[:8]}...")

def load_pending_session(self) -> Optional[PendingAuthSession]:
"""加载待完成的认证会话"""
if self._pending_session:
if not self._pending_session.is_expired():
return self._pending_session
else:
self._log("内存中的 pending session 已过期")
self._pending_session = None

if not self._pending_session_path.exists():
return None

try:
with open(self._pending_session_path, "r") as f:
data = json.load(f)
session = PendingAuthSession.from_dict(data)

if session.is_expired():
self._log("文件中的 pending session 已过期,清除")
self.clear_pending_session()
return None

self._pending_session = session
self._log(f"已加载 pending session: state={session.state[:8]}...")
return session
except Exception as e:
self._log(f"加载 pending session 失败: {e}")
return None

def clear_pending_session(self):
"""清除待完成的认证会话"""
self._pending_session = None
if self._pending_session_path.exists():
self._pending_session_path.unlink()
self._log("已清除 pending session")

def has_pending_session(self) -> bool:
"""检查是否有待完成的认证会话"""
session = self.load_pending_session()
return session is not None

# ==================== Credentials Management ====================

def load_credentials(self) -> Optional[Credentials]:
Expand Down Expand Up @@ -466,6 +566,92 @@ def open_auth_url(self, url: str) -> bool:
except Exception as e:
self._log(f"无法打开浏览器: {e}")
return False

# ==================== Link Mode Methods ====================

async def create_link_mode_session(self) -> Optional[Dict[str, str]]:
"""
创建 Link 模式认证会话

生成认证 URL 并保存 PKCE 参数,返回认证信息供用户使用。

Returns:
{
"auth_url": "认证链接",
"state": "会话标识(可选,用于验证)"
}
"""
# 生成 PKCE 参数
code_verifier, code_challenge = self.generate_pkce()
state = self.generate_state()

# 使用固定的回调 URL(MCPMarket 提供的授权码显示页面)
redirect_uri = settings.link_mode_callback_url

# 确保客户端已注册
client_id = await self.ensure_client_registered(redirect_uri)
if not client_id:
self._log("客户端注册失败")
return None

# 构建认证 URL
auth_url = await self.build_auth_url(redirect_uri, state, code_challenge, client_id)
if not auth_url:
self._log("构建认证 URL 失败")
return None

# 保存 pending session
session = PendingAuthSession(
code_verifier=code_verifier,
code_challenge=code_challenge,
state=state,
redirect_uri=redirect_uri,
client_id=client_id,
created_at=time.time()
)
self.save_pending_session(session)

self._log(f"Link 模式会话已创建: auth_url={auth_url[:50]}...")

return {
"auth_url": auth_url,
"state": state
}

async def complete_link_mode_auth(self, code: str) -> Optional[Credentials]:
"""
完成 Link 模式认证

使用用户提供的授权码交换 token。

Args:
code: 用户从认证页面获取的授权码

Returns:
认证成功返回 Credentials,失败返回 None
"""
# 加载 pending session
session = self.load_pending_session()
if not session:
self._log("没有待完成的认证会话")
return None

# 交换 token
credentials = await self.exchange_code_for_token(
code=code,
code_verifier=session.code_verifier,
redirect_uri=session.redirect_uri,
client_id=session.client_id
)

if credentials:
# 清除 pending session
self.clear_pending_session()
self._log("Link 模式认证成功")
return credentials
else:
self._log("Link 模式认证失败:token 交换失败")
return None


# 全局实例
Expand Down
12 changes: 12 additions & 0 deletions src/uno_mcp_stdio/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ class Settings(BaseSettings):
description="等待 OAuth 回调超时时间(秒)"
)

# Link 模式配置(用于远程服务器场景,如 Manus)
link_mode_callback_url: str = Field(
default="https://mcpmarket.cn/oauth/code-display",
description="Link 模式下的回调 URL,该页面会显示授权码供用户复制"
)

# 认证模式
auth_mode: str = Field(
default="auto",
description="认证模式: auto(自动检测), local(本地模式), link(链接模式)"
)

def get_credentials_path(self) -> Path:
"""获取 credentials 文件的完整路径"""
path = Path(self.credentials_path).expanduser()
Expand Down
Loading