feat(mcp): 完善独立 MCP 服务器配置中心与连接生命周期管理 #13
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 本文件用于团队开发期间快速配置环境和启动项目,不是正式的项目 README。
|
> 本文件用于团队开发期间快速配置环境和启动项目,不是正式的项目 README。
|
||||||
|
|
||||||
> 当前基线:2026-09-03。第一阶段 Web 联调前后端已经完成;第二阶段已完成 Workspace 去 Mock、Agent Trace 持久化与 SSE 恢复、stdio MCP Bridge、隔离 Plugin Host、Plugin Command/Settings,以及独立 MCP Server 配置中心 C.1/P0。Streamable HTTP MCP、真实音频、Provider 协议增强、Benchmark、导出、主题包、Trace 可视化、Mermaid 与函数图像仍在后续开发;Tauri Host、Stronghold、原生多 Vault 文件系统和 Sync Server 尚未接入。
|
> 当前基线:2026-09-03。第一阶段 Web 联调前后端已经完成;第二阶段已完成 Workspace 去 Mock、Agent Trace 持久化与 SSE 恢复、stdio MCP Bridge、隔离 Plugin Host、Plugin Command/Settings,以及独立 MCP Server 配置中心 C.1(stdio、Streamable HTTP 与旧 SSE 兼容)。真实音频、Provider 协议增强、Benchmark、导出、主题包、Trace 可视化、Mermaid 与函数图像仍在后续开发;Tauri Host、Stronghold、原生多 Vault 文件系统和 Sync Server 尚未接入。
|
||||||
|
|
||||||
## 当前目录
|
## 当前目录
|
||||||
|
|
||||||
|
|||||||
+42
-17
@@ -494,22 +494,29 @@ class McpServerTransport(str, Enum):
|
|||||||
sse = "sse"
|
sse = "sse"
|
||||||
|
|
||||||
|
|
||||||
class McpServerCreateRequest(Contract):
|
class McpServerConfig(Contract):
|
||||||
name: str = Field(min_length=1, max_length=80)
|
name: str = Field(min_length=1, max_length=80)
|
||||||
transport: McpServerTransport = McpServerTransport.stdio
|
transport: McpServerTransport = McpServerTransport.stdio
|
||||||
command: str = Field(min_length=1, max_length=1024)
|
command: str | None = Field(default=None, max_length=1024)
|
||||||
args: list[str] = Field(default_factory=list, max_length=64)
|
args: list[str] = Field(default_factory=list, max_length=64)
|
||||||
|
url: str | None = Field(default=None, max_length=4096)
|
||||||
|
headers: dict[str, str] = Field(default_factory=dict)
|
||||||
environment: dict[str, str] = Field(default_factory=dict)
|
environment: dict[str, str] = Field(default_factory=dict)
|
||||||
secret_environment_keys: list[str] = Field(default_factory=list)
|
secret_environment_keys: list[str] = Field(default_factory=list)
|
||||||
|
secret_header_keys: list[str] = Field(default_factory=list)
|
||||||
permissions: list[str] = Field(default_factory=list)
|
permissions: list[str] = Field(default_factory=list)
|
||||||
startup_timeout_seconds: float = Field(default=15, ge=1, le=120)
|
startup_timeout_seconds: float = Field(default=15, ge=1, le=120)
|
||||||
tool_timeout_seconds: float = Field(default=30, ge=1, le=300)
|
tool_timeout_seconds: float = Field(default=30, ge=1, le=300)
|
||||||
|
|
||||||
|
|
||||||
class McpServerUpdateRequest(McpServerCreateRequest):
|
class McpServerCreateRequest(McpServerConfig):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class McpServerUpdateRequest(McpServerConfig):
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
class McpServerSecretWriteRequest(Contract):
|
class McpServerSecretWriteRequest(Contract):
|
||||||
secret: SecretStr = Field(min_length=1, max_length=32768)
|
secret: SecretStr = Field(min_length=1, max_length=32768)
|
||||||
|
|
||||||
@@ -523,21 +530,8 @@ class McpServerTrustRequest(Contract):
|
|||||||
command_digest: str = Field(min_length=64, max_length=64)
|
command_digest: str = Field(min_length=64, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
class McpServer(Contract):
|
class McpServerStatus(Contract):
|
||||||
server_id: str
|
|
||||||
name: str
|
|
||||||
transport: McpServerTransport
|
|
||||||
command: str
|
|
||||||
args: list[str] = Field(default_factory=list)
|
|
||||||
environment: dict[str, str] = Field(default_factory=dict)
|
|
||||||
secret_environment: dict[str, bool] = Field(default_factory=dict)
|
|
||||||
permissions: list[str] = Field(default_factory=list)
|
|
||||||
startup_timeout_seconds: float
|
|
||||||
tool_timeout_seconds: float
|
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
trusted: bool = False
|
|
||||||
command_digest: str
|
|
||||||
command_summary: str
|
|
||||||
status: PluginHostState = PluginHostState.stopped
|
status: PluginHostState = PluginHostState.stopped
|
||||||
tools_count: int = 0
|
tools_count: int = 0
|
||||||
protocol_version: str | None = None
|
protocol_version: str | None = None
|
||||||
@@ -548,10 +542,41 @@ class McpServer(Contract):
|
|||||||
last_test_succeeded: bool | None = None
|
last_test_succeeded: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class McpServer(McpServerStatus):
|
||||||
|
server_id: str
|
||||||
|
version: int
|
||||||
|
name: str
|
||||||
|
transport: McpServerTransport
|
||||||
|
command: str | None = None
|
||||||
|
args: list[str] = Field(default_factory=list)
|
||||||
|
url: str | None = None
|
||||||
|
headers: dict[str, str] = Field(default_factory=dict)
|
||||||
|
environment: dict[str, str] = Field(default_factory=dict)
|
||||||
|
permissions: list[str] = Field(default_factory=list)
|
||||||
|
startup_timeout_seconds: float
|
||||||
|
tool_timeout_seconds: float
|
||||||
|
secret_environment: dict[str, bool] = Field(default_factory=dict)
|
||||||
|
secret_headers: dict[str, bool] = Field(default_factory=dict)
|
||||||
|
trusted: bool = False
|
||||||
|
command_digest: str
|
||||||
|
command_summary: str
|
||||||
|
|
||||||
|
|
||||||
class McpServerListResponse(Contract):
|
class McpServerListResponse(Contract):
|
||||||
items: list[McpServer] = Field(default_factory=list)
|
items: list[McpServer] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class McpToolSummary(Contract):
|
||||||
|
name: str
|
||||||
|
remote_name: str
|
||||||
|
description: str
|
||||||
|
permission: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class McpToolSummaryListResponse(Contract):
|
||||||
|
items: list[McpToolSummary] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class PluginCommandLocation(str, Enum):
|
class PluginCommandLocation(str, Enum):
|
||||||
command_palette = "command_palette"
|
command_palette = "command_palette"
|
||||||
context_menu = "context_menu"
|
context_menu = "context_menu"
|
||||||
|
|||||||
+678
-35
@@ -10,14 +10,18 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
from typing import Any, Protocol
|
||||||
|
from urllib.parse import urljoin, urlsplit
|
||||||
|
|
||||||
|
import httpx
|
||||||
from jsonschema import Draft202012Validator
|
from jsonschema import Draft202012Validator
|
||||||
from jsonschema.exceptions import SchemaError
|
from jsonschema.exceptions import SchemaError
|
||||||
|
|
||||||
@@ -99,7 +103,12 @@ class McpStdioClient:
|
|||||||
return
|
return
|
||||||
# TODO(extension-security): 社区 Plugin 开放前迁移到 Tauri/Rust Host 的
|
# TODO(extension-security): 社区 Plugin 开放前迁移到 Tauri/Rust Host 的
|
||||||
# 平台级沙箱启动器;uvx 只隔离 Python 依赖,不能替代系统权限限制。
|
# 平台级沙箱启动器;uvx 只隔离 Python 依赖,不能替代系统权限限制。
|
||||||
creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0
|
creation_flags = (
|
||||||
|
getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||||
|
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||||
|
if os.name == "nt"
|
||||||
|
else 0
|
||||||
|
)
|
||||||
environment = _subprocess_environment()
|
environment = _subprocess_environment()
|
||||||
environment.update(self.environment)
|
environment.update(self.environment)
|
||||||
environment.setdefault("PYTHONUNBUFFERED", "1")
|
environment.setdefault("PYTHONUNBUFFERED", "1")
|
||||||
@@ -117,6 +126,7 @@ class McpStdioClient:
|
|||||||
shell=False,
|
shell=False,
|
||||||
env=environment,
|
env=environment,
|
||||||
creationflags=creation_flags,
|
creationflags=creation_flags,
|
||||||
|
start_new_session=os.name != "nt",
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise McpBridgeError(
|
raise McpBridgeError(
|
||||||
@@ -183,7 +193,9 @@ class McpStdioClient:
|
|||||||
except queue.Empty as exc:
|
except queue.Empty as exc:
|
||||||
self.cancel(request_id, "Request timed out.")
|
self.cancel(request_id, "Request timed out.")
|
||||||
self.abandon(request_id)
|
self.abandon(request_id)
|
||||||
raise McpBridgeError(timeout_code, "MCP request timed out.", status_code=504) from exc
|
raise McpBridgeError(
|
||||||
|
timeout_code, "MCP request timed out.", status_code=504
|
||||||
|
) from exc
|
||||||
if isinstance(response, BaseException):
|
if isinstance(response, BaseException):
|
||||||
raise response
|
raise response
|
||||||
if "error" in response:
|
if "error" in response:
|
||||||
@@ -216,9 +228,7 @@ class McpStdioClient:
|
|||||||
except McpBridgeError:
|
except McpBridgeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def abandon(
|
def abandon(self, request_id: int, wake_error: BaseException | None = None) -> None:
|
||||||
self, request_id: int, wake_error: BaseException | None = None
|
|
||||||
) -> None:
|
|
||||||
with self._pending_lock:
|
with self._pending_lock:
|
||||||
pending = self._pending.pop(request_id, None)
|
pending = self._pending.pop(request_id, None)
|
||||||
# asyncio.to_thread 被取消时不会停止底层线程;主动唤醒 Queue,避免线程
|
# asyncio.to_thread 被取消时不会停止底层线程;主动唤醒 Queue,避免线程
|
||||||
@@ -243,15 +253,17 @@ class McpStdioClient:
|
|||||||
try:
|
try:
|
||||||
process.wait(timeout=2)
|
process.wait(timeout=2)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
process.terminate()
|
_terminate_process_tree(process)
|
||||||
try:
|
try:
|
||||||
process.wait(timeout=2)
|
process.wait(timeout=2)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
process.kill()
|
_kill_process_tree(process)
|
||||||
process.wait(timeout=2)
|
process.wait(timeout=2)
|
||||||
finally:
|
finally:
|
||||||
self._fail_pending(
|
self._fail_pending(
|
||||||
McpBridgeError("PLUGIN_HOST_UNAVAILABLE", "MCP host stopped.", status_code=503)
|
McpBridgeError(
|
||||||
|
"PLUGIN_HOST_UNAVAILABLE", "MCP host stopped.", status_code=503
|
||||||
|
)
|
||||||
)
|
)
|
||||||
self.process = None
|
self.process = None
|
||||||
|
|
||||||
@@ -313,14 +325,17 @@ class McpStdioClient:
|
|||||||
{
|
{
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"id": message["id"],
|
"id": message["id"],
|
||||||
"error": {"code": -32601, "message": "Method not supported."},
|
"error": {
|
||||||
|
"code": -32601,
|
||||||
|
"message": "Method not supported.",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except (McpBridgeError, OSError, ValueError) as exc:
|
except (McpBridgeError, OSError, ValueError) as exc:
|
||||||
failure = f"MCP stdout closed unexpectedly: {type(exc).__name__}."
|
failure = f"MCP stdout closed unexpectedly: {type(exc).__name__}."
|
||||||
finally:
|
finally:
|
||||||
if failure and process.poll() is None:
|
if failure and process.poll() is None:
|
||||||
process.terminate()
|
_terminate_process_tree(process)
|
||||||
exit_code = process.poll()
|
exit_code = process.poll()
|
||||||
if exit_code is None:
|
if exit_code is None:
|
||||||
try:
|
try:
|
||||||
@@ -328,7 +343,9 @@ class McpStdioClient:
|
|||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
exit_code = None
|
exit_code = None
|
||||||
if not self._stopping:
|
if not self._stopping:
|
||||||
message = failure or f"MCP host exited unexpectedly with code {exit_code}."
|
message = (
|
||||||
|
failure or f"MCP host exited unexpectedly with code {exit_code}."
|
||||||
|
)
|
||||||
error = McpBridgeError(
|
error = McpBridgeError(
|
||||||
"PLUGIN_HOST_UNAVAILABLE", message, status_code=503
|
"PLUGIN_HOST_UNAVAILABLE", message, status_code=503
|
||||||
)
|
)
|
||||||
@@ -363,13 +380,472 @@ class McpStdioClient:
|
|||||||
item.response.put(error)
|
item.response.put(error)
|
||||||
|
|
||||||
|
|
||||||
|
class McpHttpClient:
|
||||||
|
"""MCP Streamable HTTP client supporting JSON and SSE POST responses."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
headers: dict[str, str],
|
||||||
|
on_seen: Callable[[], None],
|
||||||
|
on_broken: Callable[[str], None],
|
||||||
|
on_tools_changed: Callable[[], None],
|
||||||
|
) -> None:
|
||||||
|
self.url = url
|
||||||
|
self.headers = headers
|
||||||
|
self.on_seen = on_seen
|
||||||
|
self.on_broken = on_broken
|
||||||
|
self.on_tools_changed = on_tools_changed
|
||||||
|
self._client = httpx.Client(follow_redirects=False, timeout=30)
|
||||||
|
self._pending_lock = threading.Lock()
|
||||||
|
self._pending: dict[int, _PendingRequest] = {}
|
||||||
|
self._next_id = 1
|
||||||
|
self._session_id: str | None = None
|
||||||
|
self._protocol_version: str | None = None
|
||||||
|
self._stopping = False
|
||||||
|
self._stream_started = False
|
||||||
|
self._last_event_id: str | None = None
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def set_protocol_version(self, version: str) -> None:
|
||||||
|
self._protocol_version = version
|
||||||
|
|
||||||
|
def start_event_stream(self) -> None:
|
||||||
|
if self._stream_started:
|
||||||
|
return
|
||||||
|
self._stream_started = True
|
||||||
|
threading.Thread(target=self._event_stream_loop, daemon=True).start()
|
||||||
|
|
||||||
|
def request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
params: dict[str, Any],
|
||||||
|
*,
|
||||||
|
timeout: float,
|
||||||
|
timeout_code: str,
|
||||||
|
response_error_code: str = "MCP_TOOL_CALL_FAILED",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
request_id, pending = self.begin_request(method, params)
|
||||||
|
return self.wait_response(
|
||||||
|
request_id,
|
||||||
|
pending,
|
||||||
|
timeout=timeout,
|
||||||
|
timeout_code=timeout_code,
|
||||||
|
response_error_code=response_error_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
def begin_request(
|
||||||
|
self, method: str, params: dict[str, Any]
|
||||||
|
) -> tuple[int, _PendingRequest]:
|
||||||
|
with self._pending_lock:
|
||||||
|
request_id = self._next_id
|
||||||
|
self._next_id += 1
|
||||||
|
pending = _PendingRequest(response=queue.Queue(maxsize=1))
|
||||||
|
self._pending[request_id] = pending
|
||||||
|
message = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"method": method,
|
||||||
|
"params": params,
|
||||||
|
}
|
||||||
|
threading.Thread(
|
||||||
|
target=self._dispatch_request,
|
||||||
|
args=(request_id, message),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
return request_id, pending
|
||||||
|
|
||||||
|
def wait_response(
|
||||||
|
self,
|
||||||
|
request_id: int,
|
||||||
|
pending: _PendingRequest,
|
||||||
|
*,
|
||||||
|
timeout: float,
|
||||||
|
timeout_code: str,
|
||||||
|
response_error_code: str = "MCP_TOOL_CALL_FAILED",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
response = pending.response.get(timeout=timeout)
|
||||||
|
except queue.Empty as exc:
|
||||||
|
self.cancel(request_id, "Request timed out.")
|
||||||
|
self.abandon(request_id)
|
||||||
|
raise McpBridgeError(
|
||||||
|
timeout_code, "MCP request timed out.", status_code=504
|
||||||
|
) from exc
|
||||||
|
if isinstance(response, BaseException):
|
||||||
|
raise response
|
||||||
|
if "error" in response:
|
||||||
|
error = response.get("error")
|
||||||
|
message = (
|
||||||
|
str(error.get("message", "MCP JSON-RPC error."))
|
||||||
|
if isinstance(error, dict)
|
||||||
|
else "MCP JSON-RPC error."
|
||||||
|
)
|
||||||
|
raise McpBridgeError(response_error_code, message)
|
||||||
|
result = response.get("result")
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
raise McpBridgeError(
|
||||||
|
response_error_code, "MCP response result must be an object."
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
|
||||||
|
message: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
|
||||||
|
if params is not None:
|
||||||
|
message["params"] = params
|
||||||
|
self._post_notification(message)
|
||||||
|
|
||||||
|
def cancel(self, request_id: int, reason: str = "Cancelled by host.") -> None:
|
||||||
|
def send() -> None:
|
||||||
|
try:
|
||||||
|
self.notify(
|
||||||
|
"notifications/cancelled",
|
||||||
|
{"requestId": request_id, "reason": reason},
|
||||||
|
)
|
||||||
|
except McpBridgeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
threading.Thread(target=send, daemon=True).start()
|
||||||
|
|
||||||
|
def abandon(self, request_id: int, wake_error: BaseException | None = None) -> None:
|
||||||
|
with self._pending_lock:
|
||||||
|
pending = self._pending.pop(request_id, None)
|
||||||
|
if pending is not None and wake_error is not None:
|
||||||
|
try:
|
||||||
|
pending.response.put_nowait(wake_error)
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stopping = True
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._session_id:
|
||||||
|
try:
|
||||||
|
request = self._client.build_request(
|
||||||
|
"DELETE", self.url, headers=self._request_headers()
|
||||||
|
)
|
||||||
|
response = self._client.send(request, stream=True)
|
||||||
|
response.close()
|
||||||
|
except httpx.HTTPError:
|
||||||
|
pass
|
||||||
|
self._client.close()
|
||||||
|
self._fail_pending(
|
||||||
|
McpBridgeError(
|
||||||
|
"PLUGIN_HOST_UNAVAILABLE", "MCP HTTP client stopped.", status_code=503
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _dispatch_request(self, request_id: int, message: dict[str, Any]) -> None:
|
||||||
|
try:
|
||||||
|
response = self._post(message, timeout=None)
|
||||||
|
try:
|
||||||
|
self._capture_session(response)
|
||||||
|
content_type = response.headers.get("content-type", "").lower()
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise McpBridgeError(
|
||||||
|
"MCP_HTTP_REQUEST_FAILED",
|
||||||
|
f"MCP HTTP server returned status {response.status_code}.",
|
||||||
|
status_code=502,
|
||||||
|
)
|
||||||
|
if "application/json" in content_type:
|
||||||
|
payload = _bounded_json_response(response)
|
||||||
|
self._deliver(payload)
|
||||||
|
elif "text/event-stream" in content_type:
|
||||||
|
delivered = False
|
||||||
|
for _event, _event_id, data in _iter_sse(response):
|
||||||
|
payload = _json_rpc_message(data)
|
||||||
|
self._handle_message(payload)
|
||||||
|
if payload.get("id") == request_id:
|
||||||
|
delivered = True
|
||||||
|
break
|
||||||
|
if not delivered:
|
||||||
|
raise McpBridgeError(
|
||||||
|
"MCP_HTTP_RESPONSE_INVALID",
|
||||||
|
"MCP SSE response ended before the matching JSON-RPC response.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise McpBridgeError(
|
||||||
|
"MCP_HTTP_RESPONSE_INVALID",
|
||||||
|
"MCP HTTP response has an unsupported Content-Type.",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
response.close()
|
||||||
|
except (McpBridgeError, httpx.HTTPError) as exc:
|
||||||
|
error = (
|
||||||
|
exc
|
||||||
|
if isinstance(exc, McpBridgeError)
|
||||||
|
else McpBridgeError(
|
||||||
|
"MCP_HTTP_REQUEST_FAILED",
|
||||||
|
f"MCP HTTP request failed: {type(exc).__name__}.",
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.abandon(request_id, error)
|
||||||
|
|
||||||
|
def _post_notification(self, message: dict[str, Any]) -> None:
|
||||||
|
try:
|
||||||
|
response = self._post(message, timeout=10)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise McpBridgeError(
|
||||||
|
"MCP_HTTP_REQUEST_FAILED",
|
||||||
|
f"MCP HTTP notification failed: {type(exc).__name__}.",
|
||||||
|
status_code=503,
|
||||||
|
) from exc
|
||||||
|
try:
|
||||||
|
self._capture_session(response)
|
||||||
|
if response.status_code not in {200, 202, 204}:
|
||||||
|
raise McpBridgeError(
|
||||||
|
"MCP_HTTP_REQUEST_FAILED",
|
||||||
|
f"MCP HTTP server rejected a notification with status {response.status_code}.",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
response.close()
|
||||||
|
|
||||||
|
def _post(
|
||||||
|
self, message: dict[str, Any], *, timeout: float | None
|
||||||
|
) -> httpx.Response:
|
||||||
|
encoded = json.dumps(message, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
if len(encoded.encode("utf-8")) > MAX_MCP_MESSAGE_BYTES:
|
||||||
|
raise McpBridgeError("MCP_TOOL_CALL_FAILED", "MCP request is too large.")
|
||||||
|
request = self._client.build_request(
|
||||||
|
"POST",
|
||||||
|
self.url,
|
||||||
|
content=encoded.encode("utf-8"),
|
||||||
|
headers=self._request_headers(),
|
||||||
|
)
|
||||||
|
return self._client.send(request, stream=True)
|
||||||
|
|
||||||
|
def _request_headers(self) -> dict[str, str]:
|
||||||
|
headers = {
|
||||||
|
**self.headers,
|
||||||
|
"Accept": "application/json, text/event-stream",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
if self._session_id:
|
||||||
|
headers["MCP-Session-Id"] = self._session_id
|
||||||
|
if self._protocol_version:
|
||||||
|
headers["MCP-Protocol-Version"] = self._protocol_version
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def _capture_session(self, response: httpx.Response) -> None:
|
||||||
|
session_id = response.headers.get("mcp-session-id")
|
||||||
|
if session_id is not None:
|
||||||
|
if (
|
||||||
|
not session_id.isascii()
|
||||||
|
or not session_id.isprintable()
|
||||||
|
or len(session_id) > 1024
|
||||||
|
):
|
||||||
|
raise McpBridgeError(
|
||||||
|
"MCP_HTTP_RESPONSE_INVALID", "MCP session id is invalid."
|
||||||
|
)
|
||||||
|
self._session_id = session_id
|
||||||
|
|
||||||
|
def _handle_message(self, message: dict[str, Any]) -> None:
|
||||||
|
self.on_seen()
|
||||||
|
if "id" in message and ("result" in message or "error" in message):
|
||||||
|
self._deliver(message)
|
||||||
|
elif message.get("method") == "notifications/tools/list_changed":
|
||||||
|
self.on_tools_changed()
|
||||||
|
|
||||||
|
def _deliver(self, message: dict[str, Any]) -> None:
|
||||||
|
request_id = message.get("id")
|
||||||
|
if not isinstance(request_id, int):
|
||||||
|
return
|
||||||
|
with self._pending_lock:
|
||||||
|
pending = self._pending.pop(request_id, None)
|
||||||
|
if pending:
|
||||||
|
pending.response.put(message)
|
||||||
|
|
||||||
|
def _fail_pending(self, error: BaseException) -> None:
|
||||||
|
with self._pending_lock:
|
||||||
|
pending = list(self._pending.values())
|
||||||
|
self._pending.clear()
|
||||||
|
for item in pending:
|
||||||
|
item.response.put(error)
|
||||||
|
|
||||||
|
def _event_stream_loop(self) -> None:
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
headers = {**self._request_headers(), "Accept": "text/event-stream"}
|
||||||
|
headers.pop("Content-Type", None)
|
||||||
|
if self._last_event_id:
|
||||||
|
headers["Last-Event-ID"] = self._last_event_id
|
||||||
|
try:
|
||||||
|
with self._client.stream(
|
||||||
|
"GET", self.url, headers=headers, timeout=None
|
||||||
|
) as response:
|
||||||
|
if response.status_code == 405:
|
||||||
|
return
|
||||||
|
if response.status_code >= 400:
|
||||||
|
self.on_broken(
|
||||||
|
f"MCP HTTP event stream returned status {response.status_code}."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if (
|
||||||
|
"text/event-stream"
|
||||||
|
not in response.headers.get("content-type", "").lower()
|
||||||
|
):
|
||||||
|
self.on_broken("MCP HTTP GET response is not an event stream.")
|
||||||
|
return
|
||||||
|
self._capture_session(response)
|
||||||
|
for _event, event_id, data in _iter_sse(response):
|
||||||
|
if event_id:
|
||||||
|
self._last_event_id = event_id
|
||||||
|
self._handle_message(_json_rpc_message(data))
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
return
|
||||||
|
except (McpBridgeError, httpx.HTTPError):
|
||||||
|
if self._stopping:
|
||||||
|
return
|
||||||
|
self._stop_event.wait(0.25)
|
||||||
|
|
||||||
|
|
||||||
|
class McpLegacySseClient(McpHttpClient):
|
||||||
|
"""Compatibility client for the deprecated 2024-11-05 HTTP+SSE transport."""
|
||||||
|
|
||||||
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._endpoint: str | None = None
|
||||||
|
self._endpoint_ready: queue.Queue[str | BaseException] = queue.Queue(maxsize=1)
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
threading.Thread(target=self._event_loop, daemon=True).start()
|
||||||
|
try:
|
||||||
|
endpoint = self._endpoint_ready.get(timeout=15)
|
||||||
|
except queue.Empty as exc:
|
||||||
|
raise McpBridgeError(
|
||||||
|
"MCP_INITIALIZE_FAILED",
|
||||||
|
"Legacy MCP SSE endpoint event timed out.",
|
||||||
|
status_code=504,
|
||||||
|
) from exc
|
||||||
|
if isinstance(endpoint, BaseException):
|
||||||
|
raise endpoint
|
||||||
|
self._endpoint = endpoint
|
||||||
|
|
||||||
|
def start_event_stream(self) -> None:
|
||||||
|
"""The legacy client already owns its single GET event stream."""
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
def _dispatch_request(self, request_id: int, message: dict[str, Any]) -> None:
|
||||||
|
try:
|
||||||
|
response = self._post(message, timeout=10)
|
||||||
|
try:
|
||||||
|
if response.status_code not in {200, 202, 204}:
|
||||||
|
raise McpBridgeError(
|
||||||
|
"MCP_HTTP_REQUEST_FAILED",
|
||||||
|
f"Legacy MCP endpoint returned status {response.status_code}.",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
response.close()
|
||||||
|
except (McpBridgeError, httpx.HTTPError) as exc:
|
||||||
|
error = (
|
||||||
|
exc
|
||||||
|
if isinstance(exc, McpBridgeError)
|
||||||
|
else McpBridgeError(
|
||||||
|
"MCP_HTTP_REQUEST_FAILED",
|
||||||
|
f"Legacy MCP request failed: {type(exc).__name__}.",
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.abandon(request_id, error)
|
||||||
|
|
||||||
|
def _post(
|
||||||
|
self, message: dict[str, Any], *, timeout: float | None
|
||||||
|
) -> httpx.Response:
|
||||||
|
if self._endpoint is None:
|
||||||
|
raise McpBridgeError(
|
||||||
|
"MCP_INITIALIZE_FAILED", "Legacy MCP endpoint is not ready."
|
||||||
|
)
|
||||||
|
encoded = json.dumps(message, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
request = self._client.build_request(
|
||||||
|
"POST",
|
||||||
|
self._endpoint,
|
||||||
|
content=encoded.encode("utf-8"),
|
||||||
|
headers={
|
||||||
|
**self.headers,
|
||||||
|
"Accept": "application/json, text/event-stream",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return self._client.send(request, stream=True)
|
||||||
|
|
||||||
|
def _event_loop(self) -> None:
|
||||||
|
try:
|
||||||
|
with self._client.stream(
|
||||||
|
"GET",
|
||||||
|
self.url,
|
||||||
|
headers={**self.headers, "Accept": "text/event-stream"},
|
||||||
|
timeout=None,
|
||||||
|
) as response:
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise McpBridgeError(
|
||||||
|
"MCP_HTTP_REQUEST_FAILED",
|
||||||
|
f"Legacy MCP SSE server returned status {response.status_code}.",
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
"text/event-stream"
|
||||||
|
not in response.headers.get("content-type", "").lower()
|
||||||
|
):
|
||||||
|
raise McpBridgeError(
|
||||||
|
"MCP_HTTP_RESPONSE_INVALID",
|
||||||
|
"Legacy MCP GET response is not an event stream.",
|
||||||
|
)
|
||||||
|
for event, _event_id, data in _iter_sse(response):
|
||||||
|
if self._endpoint is None and event == "endpoint":
|
||||||
|
endpoint = _legacy_endpoint_url(self.url, data)
|
||||||
|
self._endpoint_ready.put(endpoint)
|
||||||
|
self._endpoint = endpoint
|
||||||
|
continue
|
||||||
|
self._handle_message(_json_rpc_message(data))
|
||||||
|
except (McpBridgeError, httpx.HTTPError) as exc:
|
||||||
|
if self._endpoint is None:
|
||||||
|
self._endpoint_ready.put(exc)
|
||||||
|
elif not self._stopping:
|
||||||
|
self.on_broken(f"Legacy MCP SSE stream failed: {type(exc).__name__}.")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class _McpHost:
|
class _McpHost:
|
||||||
backend: PluginBackend
|
backend: PluginBackend
|
||||||
client: McpStdioClient
|
client: _McpClient
|
||||||
status: PluginHostStatus
|
status: PluginHostStatus
|
||||||
|
|
||||||
|
|
||||||
|
class _McpClient(Protocol):
|
||||||
|
def start(self) -> None: ...
|
||||||
|
def request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
params: dict[str, Any],
|
||||||
|
*,
|
||||||
|
timeout: float,
|
||||||
|
timeout_code: str,
|
||||||
|
response_error_code: str = "MCP_TOOL_CALL_FAILED",
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
def begin_request(
|
||||||
|
self, method: str, params: dict[str, Any]
|
||||||
|
) -> tuple[int, _PendingRequest]: ...
|
||||||
|
def wait_response(
|
||||||
|
self,
|
||||||
|
request_id: int,
|
||||||
|
pending: _PendingRequest,
|
||||||
|
*,
|
||||||
|
timeout: float,
|
||||||
|
timeout_code: str,
|
||||||
|
response_error_code: str = "MCP_TOOL_CALL_FAILED",
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
def notify(self, method: str, params: dict[str, Any] | None = None) -> None: ...
|
||||||
|
def cancel(self, request_id: int, reason: str = "Cancelled by host.") -> None: ...
|
||||||
|
def abandon(
|
||||||
|
self, request_id: int, wake_error: BaseException | None = None
|
||||||
|
) -> None: ...
|
||||||
|
def stop(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
class McpBridge:
|
class McpBridge:
|
||||||
"""管理每个 Plugin 的独立 MCP Client,并执行 Contract 转换。"""
|
"""管理每个 Plugin 的独立 MCP Client,并执行 Contract 转换。"""
|
||||||
|
|
||||||
@@ -390,19 +866,27 @@ class McpBridge:
|
|||||||
command_override: list[str] | None = None,
|
command_override: list[str] | None = None,
|
||||||
environment: dict[str, str] | None = None,
|
environment: dict[str, str] | None = None,
|
||||||
tool_source: str = "plugin",
|
tool_source: str = "plugin",
|
||||||
|
transport_kind: str | None = None,
|
||||||
|
url: str | None = None,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
) -> list[McpDiscoveredTool]:
|
) -> list[McpDiscoveredTool]:
|
||||||
if backend.transport != "stdio":
|
transport = transport_kind or backend.transport
|
||||||
|
if transport not in {"stdio", "streamable_http", "sse"}:
|
||||||
raise McpBridgeError(
|
raise McpBridgeError(
|
||||||
"MCP_CAPABILITY_UNSUPPORTED",
|
"MCP_CAPABILITY_UNSUPPORTED",
|
||||||
"Phase C only supports the MCP stdio transport.",
|
f"Unsupported MCP transport: {transport}",
|
||||||
status_code=501,
|
status_code=501,
|
||||||
)
|
)
|
||||||
command = command_override or self._resolve_command(package_path, backend)
|
command = (
|
||||||
now = datetime.now(timezone.utc)
|
command_override or self._resolve_command(package_path, backend)
|
||||||
|
if transport == "stdio"
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
now = datetime.now(UTC)
|
||||||
status = PluginHostStatus(
|
status = PluginHostStatus(
|
||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
backend_type="mcp",
|
backend_type="mcp",
|
||||||
transport="stdio",
|
transport="stdio" if transport == "stdio" else "http",
|
||||||
status=PluginHostState.starting,
|
status=PluginHostState.starting,
|
||||||
started_at=now,
|
started_at=now,
|
||||||
last_seen_at=now,
|
last_seen_at=now,
|
||||||
@@ -412,7 +896,7 @@ class McpBridge:
|
|||||||
def seen() -> None:
|
def seen() -> None:
|
||||||
host = host_ref.get("host")
|
host = host_ref.get("host")
|
||||||
if host:
|
if host:
|
||||||
host.status.last_seen_at = datetime.now(timezone.utc)
|
host.status.last_seen_at = datetime.now(UTC)
|
||||||
|
|
||||||
def broken(message: str) -> None:
|
def broken(message: str) -> None:
|
||||||
host = host_ref.get("host")
|
host = host_ref.get("host")
|
||||||
@@ -422,9 +906,13 @@ class McpBridge:
|
|||||||
on_unavailable(plugin_id, message)
|
on_unavailable(plugin_id, message)
|
||||||
|
|
||||||
def tools_changed() -> None:
|
def tools_changed() -> None:
|
||||||
broken("MCP tool list changed; restart the Plugin Host to revalidate tools.")
|
broken(
|
||||||
|
"MCP tool list changed; restart the Plugin Host to revalidate tools."
|
||||||
|
)
|
||||||
|
|
||||||
client = McpStdioClient(
|
if transport == "stdio":
|
||||||
|
assert command is not None
|
||||||
|
client: _McpClient = McpStdioClient(
|
||||||
command,
|
command,
|
||||||
cwd=package_path,
|
cwd=package_path,
|
||||||
environment=environment,
|
environment=environment,
|
||||||
@@ -432,6 +920,21 @@ class McpBridge:
|
|||||||
on_broken=broken,
|
on_broken=broken,
|
||||||
on_tools_changed=tools_changed,
|
on_tools_changed=tools_changed,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
if not url:
|
||||||
|
raise McpBridgeError(
|
||||||
|
"MCP_HOST_START_FAILED", "MCP HTTP transport requires a URL."
|
||||||
|
)
|
||||||
|
client_type = (
|
||||||
|
McpHttpClient if transport == "streamable_http" else McpLegacySseClient
|
||||||
|
)
|
||||||
|
client = client_type(
|
||||||
|
url,
|
||||||
|
headers=headers or {},
|
||||||
|
on_seen=seen,
|
||||||
|
on_broken=broken,
|
||||||
|
on_tools_changed=tools_changed,
|
||||||
|
)
|
||||||
host = _McpHost(backend=backend, client=client, status=status)
|
host = _McpHost(backend=backend, client=client, status=status)
|
||||||
host_ref["host"] = host
|
host_ref["host"] = host
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -474,15 +977,21 @@ class McpBridge:
|
|||||||
if not isinstance(server_info, dict):
|
if not isinstance(server_info, dict):
|
||||||
server_info = {}
|
server_info = {}
|
||||||
status.protocol_version = str(version)
|
status.protocol_version = str(version)
|
||||||
|
set_protocol_version = getattr(client, "set_protocol_version", None)
|
||||||
|
if callable(set_protocol_version):
|
||||||
|
set_protocol_version(str(version))
|
||||||
status.server_name = _optional_string(server_info.get("name"))
|
status.server_name = _optional_string(server_info.get("name"))
|
||||||
status.server_version = _optional_string(server_info.get("version"))
|
status.server_version = _optional_string(server_info.get("version"))
|
||||||
client.notify("notifications/initialized")
|
client.notify("notifications/initialized")
|
||||||
|
start_event_stream = getattr(client, "start_event_stream", None)
|
||||||
|
if callable(start_event_stream):
|
||||||
|
start_event_stream()
|
||||||
discovered = self._discover_tools(
|
discovered = self._discover_tools(
|
||||||
plugin_id, client, backend, declared_permissions, tool_source
|
plugin_id, client, backend, declared_permissions, tool_source
|
||||||
)
|
)
|
||||||
status.status = PluginHostState.ready
|
status.status = PluginHostState.ready
|
||||||
status.tools_count = len(discovered)
|
status.tools_count = len(discovered)
|
||||||
status.last_seen_at = datetime.now(timezone.utc)
|
status.last_seen_at = datetime.now(UTC)
|
||||||
status.error = None
|
status.error = None
|
||||||
return discovered
|
return discovered
|
||||||
except McpBridgeError as exc:
|
except McpBridgeError as exc:
|
||||||
@@ -527,9 +1036,7 @@ class McpBridge:
|
|||||||
host.client.cancel(rpc_id)
|
host.client.cancel(rpc_id)
|
||||||
host.client.abandon(
|
host.client.abandon(
|
||||||
rpc_id,
|
rpc_id,
|
||||||
McpBridgeError(
|
McpBridgeError("MCP_TOOL_CALL_FAILED", "MCP request was cancelled."),
|
||||||
"MCP_TOOL_CALL_FAILED", "MCP request was cancelled."
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
except McpBridgeError as exc:
|
except McpBridgeError as exc:
|
||||||
@@ -539,7 +1046,9 @@ class McpBridge:
|
|||||||
self._calls.pop(call_key, None)
|
self._calls.pop(call_key, None)
|
||||||
|
|
||||||
encoded_size = len(
|
encoded_size = len(
|
||||||
json.dumps(result, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
json.dumps(result, ensure_ascii=False, separators=(",", ":")).encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if encoded_size > MAX_MCP_TOOL_RESULT_BYTES:
|
if encoded_size > MAX_MCP_TOOL_RESULT_BYTES:
|
||||||
raise ToolExecutionError(
|
raise ToolExecutionError(
|
||||||
@@ -606,7 +1115,7 @@ class McpBridge:
|
|||||||
def _discover_tools(
|
def _discover_tools(
|
||||||
self,
|
self,
|
||||||
plugin_id: str,
|
plugin_id: str,
|
||||||
client: McpStdioClient,
|
client: _McpClient,
|
||||||
backend: PluginBackend,
|
backend: PluginBackend,
|
||||||
declared_permissions: list[str],
|
declared_permissions: list[str],
|
||||||
tool_source: str,
|
tool_source: str,
|
||||||
@@ -625,7 +1134,8 @@ class McpBridge:
|
|||||||
raw_tools = result.get("tools")
|
raw_tools = result.get("tools")
|
||||||
if not isinstance(raw_tools, list):
|
if not isinstance(raw_tools, list):
|
||||||
raise McpBridgeError(
|
raise McpBridgeError(
|
||||||
"MCP_TOOL_SCHEMA_INVALID", "MCP tools/list must return a tools array."
|
"MCP_TOOL_SCHEMA_INVALID",
|
||||||
|
"MCP tools/list must return a tools array.",
|
||||||
)
|
)
|
||||||
for raw in raw_tools:
|
for raw in raw_tools:
|
||||||
discovered.append(
|
discovered.append(
|
||||||
@@ -641,7 +1151,8 @@ class McpBridge:
|
|||||||
break
|
break
|
||||||
if not isinstance(next_cursor, str) or not next_cursor:
|
if not isinstance(next_cursor, str) or not next_cursor:
|
||||||
raise McpBridgeError(
|
raise McpBridgeError(
|
||||||
"MCP_TOOL_SCHEMA_INVALID", "MCP nextCursor must be a non-empty string."
|
"MCP_TOOL_SCHEMA_INVALID",
|
||||||
|
"MCP nextCursor must be a non-empty string.",
|
||||||
)
|
)
|
||||||
cursor = next_cursor
|
cursor = next_cursor
|
||||||
else:
|
else:
|
||||||
@@ -675,9 +1186,7 @@ class McpBridge:
|
|||||||
len(remote_name) > 128
|
len(remote_name) > 128
|
||||||
or not remote_name[0].isalnum()
|
or not remote_name[0].isalnum()
|
||||||
or not all(
|
or not all(
|
||||||
character.islower()
|
character.islower() or character.isdigit() or character in "._-"
|
||||||
or character.isdigit()
|
|
||||||
or character in "._-"
|
|
||||||
for character in remote_name
|
for character in remote_name
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
@@ -702,7 +1211,9 @@ class McpBridge:
|
|||||||
) from exc
|
) from exc
|
||||||
metadata = raw.get("_meta")
|
metadata = raw.get("_meta")
|
||||||
permission = (
|
permission = (
|
||||||
metadata.get("notesagent/permission") if isinstance(metadata, dict) else None
|
metadata.get("notesagent/permission")
|
||||||
|
if isinstance(metadata, dict)
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
if permission is not None and (
|
if permission is not None and (
|
||||||
not isinstance(permission, str) or permission not in KNOWN_PERMISSIONS
|
not isinstance(permission, str) or permission not in KNOWN_PERMISSIONS
|
||||||
@@ -721,7 +1232,9 @@ class McpBridge:
|
|||||||
remote_name=remote_name,
|
remote_name=remote_name,
|
||||||
definition=ToolDefinition(
|
definition=ToolDefinition(
|
||||||
name=f"{plugin_id}.{remote_name}",
|
name=f"{plugin_id}.{remote_name}",
|
||||||
description=description if isinstance(description, str) else remote_name,
|
description=description
|
||||||
|
if isinstance(description, str)
|
||||||
|
else remote_name,
|
||||||
parameters=schema,
|
parameters=schema,
|
||||||
permission=permission,
|
permission=permission,
|
||||||
source=tool_source,
|
source=tool_source,
|
||||||
@@ -801,3 +1314,133 @@ def _subprocess_environment() -> dict[str, str]:
|
|||||||
environment["PYTHONUNBUFFERED"] = "1"
|
environment["PYTHONUNBUFFERED"] = "1"
|
||||||
environment["PYTHONIOENCODING"] = "utf-8"
|
environment["PYTHONIOENCODING"] = "utf-8"
|
||||||
return environment
|
return environment
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_json_response(response: httpx.Response) -> dict[str, Any]:
|
||||||
|
content_length = response.headers.get("content-length")
|
||||||
|
if (
|
||||||
|
content_length
|
||||||
|
and content_length.isdigit()
|
||||||
|
and int(content_length) > MAX_MCP_MESSAGE_BYTES
|
||||||
|
):
|
||||||
|
raise McpBridgeError(
|
||||||
|
"MCP_HTTP_RESPONSE_INVALID", "MCP HTTP response is too large."
|
||||||
|
)
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
size = 0
|
||||||
|
for chunk in response.iter_bytes():
|
||||||
|
size += len(chunk)
|
||||||
|
if size > MAX_MCP_MESSAGE_BYTES:
|
||||||
|
raise McpBridgeError(
|
||||||
|
"MCP_HTTP_RESPONSE_INVALID", "MCP HTTP response is too large."
|
||||||
|
)
|
||||||
|
chunks.append(chunk)
|
||||||
|
try:
|
||||||
|
payload = json.loads(b"".join(chunks))
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise McpBridgeError(
|
||||||
|
"MCP_HTTP_RESPONSE_INVALID", "MCP HTTP response is not valid JSON."
|
||||||
|
) from exc
|
||||||
|
if not isinstance(payload, dict) or payload.get("jsonrpc") != "2.0":
|
||||||
|
raise McpBridgeError(
|
||||||
|
"MCP_HTTP_RESPONSE_INVALID", "MCP HTTP response is not a JSON-RPC message."
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_sse(response: httpx.Response):
|
||||||
|
event = "message"
|
||||||
|
event_id: str | None = None
|
||||||
|
data_lines: list[str] = []
|
||||||
|
size = 0
|
||||||
|
for line in response.iter_lines():
|
||||||
|
size += len(line.encode("utf-8")) + 1
|
||||||
|
if size > MAX_MCP_MESSAGE_BYTES:
|
||||||
|
raise McpBridgeError(
|
||||||
|
"MCP_HTTP_RESPONSE_INVALID", "MCP SSE event is too large."
|
||||||
|
)
|
||||||
|
if line == "":
|
||||||
|
if data_lines:
|
||||||
|
yield event, event_id, "\n".join(data_lines)
|
||||||
|
event, event_id, data_lines, size = "message", None, [], 0
|
||||||
|
continue
|
||||||
|
if line.startswith(":"):
|
||||||
|
continue
|
||||||
|
field, _, value = line.partition(":")
|
||||||
|
value = value.removeprefix(" ")
|
||||||
|
if field == "event":
|
||||||
|
event = value
|
||||||
|
elif field == "id" and "\x00" not in value:
|
||||||
|
event_id = value
|
||||||
|
elif field == "data":
|
||||||
|
data_lines.append(value)
|
||||||
|
if data_lines:
|
||||||
|
yield event, event_id, "\n".join(data_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_rpc_message(data: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
message = json.loads(data)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise McpBridgeError(
|
||||||
|
"MCP_HTTP_RESPONSE_INVALID", "MCP SSE data is not valid JSON."
|
||||||
|
) from exc
|
||||||
|
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0":
|
||||||
|
raise McpBridgeError(
|
||||||
|
"MCP_HTTP_RESPONSE_INVALID", "MCP SSE data is not a JSON-RPC message."
|
||||||
|
)
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_endpoint_url(source_url: str, endpoint: str) -> str:
|
||||||
|
target = urljoin(source_url, endpoint.strip())
|
||||||
|
source_parts = urlsplit(source_url)
|
||||||
|
target_parts = urlsplit(target)
|
||||||
|
if (
|
||||||
|
target_parts.scheme not in {"http", "https"}
|
||||||
|
or target_parts.username is not None
|
||||||
|
or target_parts.password is not None
|
||||||
|
or (source_parts.scheme, source_parts.hostname, source_parts.port)
|
||||||
|
!= (target_parts.scheme, target_parts.hostname, target_parts.port)
|
||||||
|
):
|
||||||
|
raise McpBridgeError(
|
||||||
|
"MCP_HTTP_RESPONSE_INVALID",
|
||||||
|
"Legacy MCP endpoint must use the same origin as the configured SSE URL.",
|
||||||
|
)
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _terminate_process_tree(process: subprocess.Popen[str]) -> None:
|
||||||
|
if process.poll() is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if os.name == "nt":
|
||||||
|
subprocess.run(
|
||||||
|
["taskkill.exe", "/PID", str(process.pid), "/T"],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
|
timeout=2,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
os.killpg(process.pid, signal.SIGTERM)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
process.terminate()
|
||||||
|
|
||||||
|
|
||||||
|
def _kill_process_tree(process: subprocess.Popen[str]) -> None:
|
||||||
|
if process.poll() is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if os.name == "nt":
|
||||||
|
subprocess.run(
|
||||||
|
["taskkill.exe", "/PID", str(process.pid), "/T", "/F"],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
|
timeout=2,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
os.killpg(process.pid, signal.SIGKILL)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
process.kill()
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import json
|
|||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from functools import wraps
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlsplit
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, create_model
|
from pydantic import BaseModel, ConfigDict, create_model
|
||||||
@@ -21,6 +23,7 @@ from app.contracts import (
|
|||||||
McpServerSecretStatus,
|
McpServerSecretStatus,
|
||||||
McpServerTransport,
|
McpServerTransport,
|
||||||
McpServerUpdateRequest,
|
McpServerUpdateRequest,
|
||||||
|
McpToolSummary,
|
||||||
PluginBackend,
|
PluginBackend,
|
||||||
PluginHostState,
|
PluginHostState,
|
||||||
)
|
)
|
||||||
@@ -28,6 +31,15 @@ from app.extensions.mcp import McpBridge, McpBridgeError, McpDiscoveredTool
|
|||||||
from app.providers.credentials import CredentialStoreError, EncryptedCredentialStore
|
from app.providers.credentials import CredentialStoreError, EncryptedCredentialStore
|
||||||
|
|
||||||
_ENVIRONMENT_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$")
|
_ENVIRONMENT_KEY = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$")
|
||||||
|
_HEADER_KEY = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$")
|
||||||
|
_RESERVED_HEADERS = {
|
||||||
|
"accept",
|
||||||
|
"content-length",
|
||||||
|
"content-type",
|
||||||
|
"host",
|
||||||
|
"mcp-protocol-version",
|
||||||
|
"mcp-session-id",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class McpRegistryError(RuntimeError):
|
class McpRegistryError(RuntimeError):
|
||||||
@@ -38,6 +50,17 @@ class McpRegistryError(RuntimeError):
|
|||||||
self.status_code = status_code
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
def _serialized_lifecycle(method):
|
||||||
|
"""Serialize lifecycle mutations without blocking MCP failure callbacks."""
|
||||||
|
|
||||||
|
@wraps(method)
|
||||||
|
def wrapped(self, *args, **kwargs):
|
||||||
|
with self._lifecycle_lock:
|
||||||
|
return method(self, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
class McpServerRegistry:
|
class McpServerRegistry:
|
||||||
"""Persists configuration and owns stdio host/tool lifecycles."""
|
"""Persists configuration and owns stdio host/tool lifecycles."""
|
||||||
|
|
||||||
@@ -56,8 +79,10 @@ class McpServerRegistry:
|
|||||||
self.allow_process_launch = allow_process_launch
|
self.allow_process_launch = allow_process_launch
|
||||||
self.bridge = bridge or McpBridge()
|
self.bridge = bridge or McpBridge()
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
|
self._lifecycle_lock = threading.RLock()
|
||||||
self._records = self._read()
|
self._records = self._read()
|
||||||
self._registered: dict[str, list[str]] = {}
|
self._registered: dict[str, list[str]] = {}
|
||||||
|
self._summaries: dict[str, list[McpToolSummary]] = {}
|
||||||
self._last_status: dict[str, dict[str, Any]] = {}
|
self._last_status: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
def list(self) -> list[McpServer]:
|
def list(self) -> list[McpServer]:
|
||||||
@@ -71,57 +96,108 @@ class McpServerRegistry:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return self._public(server_id, self._record(server_id))
|
return self._public(server_id, self._record(server_id))
|
||||||
|
|
||||||
|
def list_tools(self, server_id: str) -> list[McpToolSummary]:
|
||||||
|
self._record(server_id)
|
||||||
|
return [
|
||||||
|
item.model_copy(deep=True) for item in self._summaries.get(server_id, [])
|
||||||
|
]
|
||||||
|
|
||||||
|
@_serialized_lifecycle
|
||||||
def create(self, request: McpServerCreateRequest) -> McpServer:
|
def create(self, request: McpServerCreateRequest) -> McpServer:
|
||||||
self._validate(request)
|
self._validate(request)
|
||||||
server_id = uuid4().hex[:12]
|
server_id = uuid4().hex[:12]
|
||||||
record = request.model_dump(mode="json")
|
record = request.model_dump(mode="json")
|
||||||
record["name"] = request.name.strip()
|
record["name"] = request.name.strip()
|
||||||
record["command"] = request.command.strip()
|
record["command"] = request.command.strip() if request.command else None
|
||||||
record.update(enabled=False, approved_digest=None)
|
record["url"] = request.url.strip() if request.url else None
|
||||||
|
record.update(
|
||||||
|
version=1,
|
||||||
|
enabled=False,
|
||||||
|
approved_digest=None,
|
||||||
|
tested_digest=None,
|
||||||
|
last_tested_at=None,
|
||||||
|
last_test_succeeded=None,
|
||||||
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
updated = {**self._records, server_id: record}
|
updated = {**self._records, server_id: record}
|
||||||
self._write(updated)
|
self._write(updated)
|
||||||
self._records = updated
|
self._records = updated
|
||||||
return self.get(server_id)
|
return self.get(server_id)
|
||||||
|
|
||||||
|
@_serialized_lifecycle
|
||||||
def update(self, server_id: str, request: McpServerUpdateRequest) -> McpServer:
|
def update(self, server_id: str, request: McpServerUpdateRequest) -> McpServer:
|
||||||
self._validate(request)
|
self._validate(request)
|
||||||
|
current = self._record(server_id)
|
||||||
|
if request.version != current.get("version", 1):
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_SERVER_VERSION_CONFLICT",
|
||||||
|
"MCP server configuration version is stale.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
self.disable(server_id)
|
self.disable(server_id)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
previous = self._record(server_id)
|
previous = self._record(server_id)
|
||||||
removed = set(previous.get("secret_environment_keys", [])) - set(
|
removed = [
|
||||||
request.secret_environment_keys
|
(kind, key)
|
||||||
|
for kind, old_keys, new_keys in (
|
||||||
|
(
|
||||||
|
"environment",
|
||||||
|
previous.get("secret_environment_keys", []),
|
||||||
|
request.secret_environment_keys,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"header",
|
||||||
|
previous.get("secret_header_keys", []),
|
||||||
|
request.secret_header_keys,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
record = request.model_dump(mode="json")
|
for key in set(old_keys) - set(new_keys)
|
||||||
|
]
|
||||||
|
record = request.model_dump(mode="json", exclude={"version"})
|
||||||
record["name"] = request.name.strip()
|
record["name"] = request.name.strip()
|
||||||
record["command"] = request.command.strip()
|
record["command"] = request.command.strip() if request.command else None
|
||||||
record.update(enabled=False, approved_digest=None)
|
record["url"] = request.url.strip() if request.url else None
|
||||||
|
record.update(
|
||||||
|
version=request.version + 1,
|
||||||
|
enabled=False,
|
||||||
|
approved_digest=None,
|
||||||
|
tested_digest=None,
|
||||||
|
last_tested_at=None,
|
||||||
|
last_test_succeeded=None,
|
||||||
|
)
|
||||||
updated = {**self._records, server_id: record}
|
updated = {**self._records, server_id: record}
|
||||||
self._write(updated)
|
self._write(updated)
|
||||||
self._records = updated
|
self._records = updated
|
||||||
self._last_status.pop(server_id, None)
|
self._last_status.pop(server_id, None)
|
||||||
for key in removed:
|
self._summaries.pop(server_id, None)
|
||||||
|
for kind, key in removed:
|
||||||
try:
|
try:
|
||||||
self.credentials.delete(self._secret_id(server_id, key))
|
self.credentials.delete(self._secret_id(server_id, key, kind))
|
||||||
except CredentialStoreError as exc:
|
except CredentialStoreError as exc:
|
||||||
raise McpRegistryError(
|
raise McpRegistryError(
|
||||||
"MCP_SECRET_STORE_ERROR", str(exc), status_code=500
|
"MCP_SECRET_STORE_ERROR", str(exc), status_code=500
|
||||||
) from exc
|
) from exc
|
||||||
return self.get(server_id)
|
return self.get(server_id)
|
||||||
|
|
||||||
|
@_serialized_lifecycle
|
||||||
def delete(self, server_id: str) -> None:
|
def delete(self, server_id: str) -> None:
|
||||||
self.disable(server_id)
|
self.disable(server_id)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
record = self._record(server_id)
|
record = self._record(server_id)
|
||||||
secret_ids = [
|
secret_ids = [
|
||||||
self._secret_id(server_id, key)
|
self._secret_id(server_id, key, kind)
|
||||||
for key in record.get("secret_environment_keys", [])
|
for kind, keys in (
|
||||||
|
("environment", record.get("secret_environment_keys", [])),
|
||||||
|
("header", record.get("secret_header_keys", [])),
|
||||||
|
)
|
||||||
|
for key in keys
|
||||||
]
|
]
|
||||||
updated = dict(self._records)
|
updated = dict(self._records)
|
||||||
del updated[server_id]
|
del updated[server_id]
|
||||||
self._write(updated)
|
self._write(updated)
|
||||||
self._records = updated
|
self._records = updated
|
||||||
self._last_status.pop(server_id, None)
|
self._last_status.pop(server_id, None)
|
||||||
|
self._summaries.pop(server_id, None)
|
||||||
try:
|
try:
|
||||||
self.credentials.delete_many(secret_ids)
|
self.credentials.delete_many(secret_ids)
|
||||||
except CredentialStoreError as exc:
|
except CredentialStoreError as exc:
|
||||||
@@ -130,6 +206,7 @@ class McpServerRegistry:
|
|||||||
) from exc
|
) from exc
|
||||||
self.bridge.remove(self._host_id(server_id))
|
self.bridge.remove(self._host_id(server_id))
|
||||||
|
|
||||||
|
@_serialized_lifecycle
|
||||||
def trust(self, server_id: str, command_digest: str) -> McpServer:
|
def trust(self, server_id: str, command_digest: str) -> McpServer:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
record = self._record(server_id)
|
record = self._record(server_id)
|
||||||
@@ -146,40 +223,46 @@ class McpServerRegistry:
|
|||||||
self._records = updated
|
self._records = updated
|
||||||
return self.get(server_id)
|
return self.get(server_id)
|
||||||
|
|
||||||
|
@_serialized_lifecycle
|
||||||
def put_secret(
|
def put_secret(
|
||||||
self, server_id: str, key: str, secret: str
|
self, server_id: str, key: str, secret: str, *, kind: str = "environment"
|
||||||
) -> McpServerSecretStatus:
|
) -> McpServerSecretStatus:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
record = self._record(server_id)
|
record = self._record(server_id)
|
||||||
self._validate_environment_key(key)
|
declared = self._secret_keys(record, kind)
|
||||||
if key not in record.get("secret_environment_keys", []):
|
self._validate_secret_key(key, kind)
|
||||||
|
if key not in declared:
|
||||||
raise McpRegistryError(
|
raise McpRegistryError(
|
||||||
"MCP_SECRET_NOT_DECLARED",
|
"MCP_SECRET_NOT_DECLARED",
|
||||||
"Secret environment key is not declared in this server configuration.",
|
"Secret environment key is not declared in this server configuration.",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
self.credentials.put(self._secret_id(server_id, key), secret)
|
self.credentials.put(self._secret_id(server_id, key, kind), secret)
|
||||||
except CredentialStoreError as exc:
|
except CredentialStoreError as exc:
|
||||||
raise McpRegistryError(
|
raise McpRegistryError(
|
||||||
"MCP_SECRET_STORE_ERROR", str(exc), status_code=500
|
"MCP_SECRET_STORE_ERROR", str(exc), status_code=500
|
||||||
) from exc
|
) from exc
|
||||||
return McpServerSecretStatus(key=key, configured=True)
|
return McpServerSecretStatus(key=key, configured=True)
|
||||||
|
|
||||||
def delete_secret(self, server_id: str, key: str) -> McpServerSecretStatus:
|
@_serialized_lifecycle
|
||||||
|
def delete_secret(
|
||||||
|
self, server_id: str, key: str, *, kind: str = "environment"
|
||||||
|
) -> McpServerSecretStatus:
|
||||||
record = self._record(server_id)
|
record = self._record(server_id)
|
||||||
if key not in record.get("secret_environment_keys", []):
|
if key not in self._secret_keys(record, kind):
|
||||||
raise McpRegistryError(
|
raise McpRegistryError(
|
||||||
"MCP_SECRET_NOT_DECLARED",
|
"MCP_SECRET_NOT_DECLARED",
|
||||||
"Secret environment key is not declared in this server configuration.",
|
"Secret environment key is not declared in this server configuration.",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
self.credentials.delete(self._secret_id(server_id, key))
|
self.credentials.delete(self._secret_id(server_id, key, kind))
|
||||||
except CredentialStoreError as exc:
|
except CredentialStoreError as exc:
|
||||||
raise McpRegistryError(
|
raise McpRegistryError(
|
||||||
"MCP_SECRET_STORE_ERROR", str(exc), status_code=500
|
"MCP_SECRET_STORE_ERROR", str(exc), status_code=500
|
||||||
) from exc
|
) from exc
|
||||||
return McpServerSecretStatus(key=key, configured=False)
|
return McpServerSecretStatus(key=key, configured=False)
|
||||||
|
|
||||||
|
@_serialized_lifecycle
|
||||||
def test(self, server_id: str) -> McpServer:
|
def test(self, server_id: str) -> McpServer:
|
||||||
record = self._record(server_id)
|
record = self._record(server_id)
|
||||||
if record.get("enabled"):
|
if record.get("enabled"):
|
||||||
@@ -188,18 +271,31 @@ class McpServerRegistry:
|
|||||||
"Disable the MCP server before running an isolated connection test.",
|
"Disable the MCP server before running an isolated connection test.",
|
||||||
status_code=409,
|
status_code=409,
|
||||||
)
|
)
|
||||||
self._require_launch_allowed(record)
|
self._require_launch_allowed(record, require_test=False)
|
||||||
try:
|
try:
|
||||||
discovered = self._start(server_id, record)
|
discovered = self._start(server_id, record)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._last_status[server_id] = {
|
tested_at = datetime.now(UTC)
|
||||||
|
failure = {
|
||||||
"status": PluginHostState.error,
|
"status": PluginHostState.error,
|
||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
"last_tested_at": datetime.now(UTC),
|
"last_tested_at": tested_at,
|
||||||
"last_test_succeeded": False,
|
"last_test_succeeded": False,
|
||||||
}
|
}
|
||||||
|
self._last_status[server_id] = failure
|
||||||
|
with self._lock:
|
||||||
|
failed_record = {
|
||||||
|
**record,
|
||||||
|
"tested_digest": None,
|
||||||
|
"last_tested_at": tested_at.isoformat(),
|
||||||
|
"last_test_succeeded": False,
|
||||||
|
}
|
||||||
|
updated = {**self._records, server_id: failed_record}
|
||||||
|
self._write(updated)
|
||||||
|
self._records = updated
|
||||||
raise
|
raise
|
||||||
status = self.bridge.status(self._host_id(server_id), self._backend(record))
|
status = self.bridge.status(self._host_id(server_id), self._backend(record))
|
||||||
|
tested_at = datetime.now(UTC)
|
||||||
self._last_status[server_id] = {
|
self._last_status[server_id] = {
|
||||||
"status": PluginHostState.stopped,
|
"status": PluginHostState.stopped,
|
||||||
"tools_count": len(discovered),
|
"tools_count": len(discovered),
|
||||||
@@ -207,18 +303,31 @@ class McpServerRegistry:
|
|||||||
"remote_server_name": status.server_name,
|
"remote_server_name": status.server_name,
|
||||||
"remote_server_version": status.server_version,
|
"remote_server_version": status.server_version,
|
||||||
"error": None,
|
"error": None,
|
||||||
"last_tested_at": datetime.now(UTC),
|
"last_tested_at": tested_at,
|
||||||
"last_test_succeeded": True,
|
"last_test_succeeded": True,
|
||||||
}
|
}
|
||||||
|
self._summaries[server_id] = self._tool_summaries(discovered)
|
||||||
self.bridge.stop(self._host_id(server_id))
|
self.bridge.stop(self._host_id(server_id))
|
||||||
|
with self._lock:
|
||||||
|
tested_record = {
|
||||||
|
**record,
|
||||||
|
"tested_digest": self._digest(record),
|
||||||
|
"last_tested_at": tested_at.isoformat(),
|
||||||
|
"last_test_succeeded": True,
|
||||||
|
}
|
||||||
|
updated = {**self._records, server_id: tested_record}
|
||||||
|
self._write(updated)
|
||||||
|
self._records = updated
|
||||||
return self.get(server_id)
|
return self.get(server_id)
|
||||||
|
|
||||||
|
@_serialized_lifecycle
|
||||||
def enable(self, server_id: str) -> McpServer:
|
def enable(self, server_id: str) -> McpServer:
|
||||||
record = self._record(server_id)
|
record = self._record(server_id)
|
||||||
if server_id in self._registered:
|
if server_id in self._registered:
|
||||||
return self.get(server_id)
|
return self.get(server_id)
|
||||||
self._require_launch_allowed(record)
|
self._require_launch_allowed(record, require_test=True)
|
||||||
discovered = self._start(server_id, record)
|
discovered = self._start(server_id, record)
|
||||||
|
self._summaries[server_id] = self._tool_summaries(discovered)
|
||||||
registered: list[str] = []
|
registered: list[str] = []
|
||||||
try:
|
try:
|
||||||
for item in discovered:
|
for item in discovered:
|
||||||
@@ -243,6 +352,7 @@ class McpServerRegistry:
|
|||||||
raise
|
raise
|
||||||
return self.get(server_id)
|
return self.get(server_id)
|
||||||
|
|
||||||
|
@_serialized_lifecycle
|
||||||
def disable(self, server_id: str) -> McpServer:
|
def disable(self, server_id: str) -> McpServer:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
record = self._record(server_id)
|
record = self._record(server_id)
|
||||||
@@ -255,6 +365,7 @@ class McpServerRegistry:
|
|||||||
self.bridge.stop(self._host_id(server_id))
|
self.bridge.stop(self._host_id(server_id))
|
||||||
return self.get(server_id)
|
return self.get(server_id)
|
||||||
|
|
||||||
|
@_serialized_lifecycle
|
||||||
def restore_enabled(self) -> None:
|
def restore_enabled(self) -> None:
|
||||||
if not self._records:
|
if not self._records:
|
||||||
return
|
return
|
||||||
@@ -270,6 +381,7 @@ class McpServerRegistry:
|
|||||||
}
|
}
|
||||||
self._write()
|
self._write()
|
||||||
|
|
||||||
|
@_serialized_lifecycle
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
for server_id in list(self._records):
|
for server_id in list(self._records):
|
||||||
for name in self._registered.pop(server_id, []):
|
for name in self._registered.pop(server_id, []):
|
||||||
@@ -280,7 +392,9 @@ class McpServerRegistry:
|
|||||||
environment = dict(record.get("environment", {}))
|
environment = dict(record.get("environment", {}))
|
||||||
for key in record.get("secret_environment_keys", []):
|
for key in record.get("secret_environment_keys", []):
|
||||||
try:
|
try:
|
||||||
value = self.credentials.resolve(self._secret_id(server_id, key))
|
value = self.credentials.resolve(
|
||||||
|
self._secret_id(server_id, key, "environment")
|
||||||
|
)
|
||||||
except CredentialStoreError as exc:
|
except CredentialStoreError as exc:
|
||||||
raise McpRegistryError(
|
raise McpRegistryError(
|
||||||
"MCP_SECRET_STORE_ERROR", str(exc), status_code=500
|
"MCP_SECRET_STORE_ERROR", str(exc), status_code=500
|
||||||
@@ -292,6 +406,23 @@ class McpServerRegistry:
|
|||||||
status_code=409,
|
status_code=409,
|
||||||
)
|
)
|
||||||
environment[key] = value
|
environment[key] = value
|
||||||
|
headers = dict(record.get("headers", {}))
|
||||||
|
for key in record.get("secret_header_keys", []):
|
||||||
|
try:
|
||||||
|
value = self.credentials.resolve(
|
||||||
|
self._secret_id(server_id, key, "header")
|
||||||
|
)
|
||||||
|
except CredentialStoreError as exc:
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_SECRET_STORE_ERROR", str(exc), status_code=500
|
||||||
|
) from exc
|
||||||
|
if value is None:
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_SECRET_REQUIRED",
|
||||||
|
f"Secret HTTP header is not configured: {key}",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
headers[key] = value
|
||||||
host_id = self._host_id(server_id)
|
host_id = self._host_id(server_id)
|
||||||
self.bridge.remove(host_id)
|
self.bridge.remove(host_id)
|
||||||
try:
|
try:
|
||||||
@@ -301,9 +432,16 @@ class McpServerRegistry:
|
|||||||
self._server_dir(server_id),
|
self._server_dir(server_id),
|
||||||
list(record.get("permissions", [])),
|
list(record.get("permissions", [])),
|
||||||
lambda _host, message: self._unavailable(server_id, message),
|
lambda _host, message: self._unavailable(server_id, message),
|
||||||
command_override=[record["command"], *record.get("args", [])],
|
command_override=(
|
||||||
|
[record["command"], *record.get("args", [])]
|
||||||
|
if record.get("command")
|
||||||
|
else None
|
||||||
|
),
|
||||||
environment=environment,
|
environment=environment,
|
||||||
tool_source="mcp_server",
|
tool_source="mcp_server",
|
||||||
|
transport_kind=record["transport"],
|
||||||
|
url=record.get("url"),
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
except McpBridgeError as exc:
|
except McpBridgeError as exc:
|
||||||
raise McpRegistryError(
|
raise McpRegistryError(
|
||||||
@@ -339,23 +477,29 @@ class McpServerRegistry:
|
|||||||
}
|
}
|
||||||
self._write()
|
self._write()
|
||||||
|
|
||||||
def _require_launch_allowed(self, record: dict[str, Any]) -> None:
|
def _require_launch_allowed(
|
||||||
if record.get("transport") != McpServerTransport.stdio.value:
|
self, record: dict[str, Any], *, require_test: bool
|
||||||
raise McpRegistryError(
|
) -> None:
|
||||||
"MCP_TRANSPORT_UNSUPPORTED",
|
digest = self._digest(record)
|
||||||
"C.1 currently supports stdio; Streamable HTTP and SSE are reserved for a later increment.",
|
if (
|
||||||
status_code=501,
|
record.get("transport") == McpServerTransport.stdio.value
|
||||||
)
|
and not self.allow_process_launch
|
||||||
if not self.allow_process_launch:
|
):
|
||||||
raise McpRegistryError(
|
raise McpRegistryError(
|
||||||
"MCP_SANDBOX_REQUIRED",
|
"MCP_SANDBOX_REQUIRED",
|
||||||
"Python process launch is disabled outside development until the desktop sandbox is available.",
|
"Python process launch is disabled outside development until the desktop sandbox is available.",
|
||||||
status_code=403,
|
status_code=403,
|
||||||
)
|
)
|
||||||
if record.get("approved_digest") != self._digest(record):
|
if record.get("approved_digest") != digest:
|
||||||
raise McpRegistryError(
|
raise McpRegistryError(
|
||||||
"MCP_TRUST_APPROVAL_REQUIRED",
|
"MCP_TRUST_APPROVAL_REQUIRED",
|
||||||
"Review and approve the current MCP command before testing or enabling it.",
|
"Review and approve the current MCP connection before testing or enabling it.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
if require_test and record.get("tested_digest") != digest:
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_CONNECTION_TEST_REQUIRED",
|
||||||
|
"Test the current MCP configuration successfully before enabling it.",
|
||||||
status_code=409,
|
status_code=409,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -366,15 +510,22 @@ class McpServerRegistry:
|
|||||||
cached = self._last_status.get(server_id, {})
|
cached = self._last_status.get(server_id, {})
|
||||||
return McpServer(
|
return McpServer(
|
||||||
server_id=server_id,
|
server_id=server_id,
|
||||||
|
version=record.get("version", 1),
|
||||||
name=record["name"],
|
name=record["name"],
|
||||||
transport=record["transport"],
|
transport=record["transport"],
|
||||||
command=record["command"],
|
command=record.get("command"),
|
||||||
args=list(record.get("args", [])),
|
args=list(record.get("args", [])),
|
||||||
|
url=record.get("url"),
|
||||||
|
headers=dict(record.get("headers", {})),
|
||||||
environment=dict(record.get("environment", {})),
|
environment=dict(record.get("environment", {})),
|
||||||
secret_environment={
|
secret_environment={
|
||||||
key: self._secret_configured(server_id, key)
|
key: self._secret_configured(server_id, key)
|
||||||
for key in record.get("secret_environment_keys", [])
|
for key in record.get("secret_environment_keys", [])
|
||||||
},
|
},
|
||||||
|
secret_headers={
|
||||||
|
key: self._secret_configured(server_id, key, "header")
|
||||||
|
for key in record.get("secret_header_keys", [])
|
||||||
|
},
|
||||||
permissions=list(record.get("permissions", [])),
|
permissions=list(record.get("permissions", [])),
|
||||||
startup_timeout_seconds=backend.startup_timeout_seconds,
|
startup_timeout_seconds=backend.startup_timeout_seconds,
|
||||||
tool_timeout_seconds=backend.tool_timeout_seconds,
|
tool_timeout_seconds=backend.tool_timeout_seconds,
|
||||||
@@ -398,8 +549,10 @@ class McpServerRegistry:
|
|||||||
if record.get("enabled")
|
if record.get("enabled")
|
||||||
else cached.get("remote_server_version"),
|
else cached.get("remote_server_version"),
|
||||||
error=status.error if record.get("enabled") else cached.get("error"),
|
error=status.error if record.get("enabled") else cached.get("error"),
|
||||||
last_tested_at=cached.get("last_tested_at"),
|
last_tested_at=record.get("last_tested_at") or cached.get("last_tested_at"),
|
||||||
last_test_succeeded=cached.get("last_test_succeeded"),
|
last_test_succeeded=record.get("last_test_succeeded")
|
||||||
|
if record.get("last_test_succeeded") is not None
|
||||||
|
else cached.get("last_test_succeeded"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _validate(self, request: McpServerCreateRequest) -> None:
|
def _validate(self, request: McpServerCreateRequest) -> None:
|
||||||
@@ -407,12 +560,36 @@ class McpServerRegistry:
|
|||||||
raise McpRegistryError(
|
raise McpRegistryError(
|
||||||
"MCP_SERVER_NAME_INVALID", "MCP server name cannot be blank."
|
"MCP_SERVER_NAME_INVALID", "MCP server name cannot be blank."
|
||||||
)
|
)
|
||||||
if not request.command.strip() or "\x00" in request.command:
|
if request.transport == McpServerTransport.stdio:
|
||||||
raise McpRegistryError("MCP_COMMAND_INVALID", "MCP executable is invalid.")
|
if (
|
||||||
|
not request.command
|
||||||
|
or not request.command.strip()
|
||||||
|
or "\x00" in request.command
|
||||||
|
):
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_COMMAND_INVALID", "MCP executable is invalid."
|
||||||
|
)
|
||||||
|
if request.url or request.headers or request.secret_header_keys:
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_CONFIG_INVALID",
|
||||||
|
"stdio configuration cannot contain HTTP fields.",
|
||||||
|
)
|
||||||
if any("\x00" in arg for arg in request.args):
|
if any("\x00" in arg for arg in request.args):
|
||||||
raise McpRegistryError(
|
raise McpRegistryError(
|
||||||
"MCP_COMMAND_INVALID", "MCP argument contains a null byte."
|
"MCP_COMMAND_INVALID", "MCP argument contains a null byte."
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
self._validate_http_url(request.url)
|
||||||
|
if (
|
||||||
|
request.command
|
||||||
|
or request.args
|
||||||
|
or request.environment
|
||||||
|
or request.secret_environment_keys
|
||||||
|
):
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_CONFIG_INVALID",
|
||||||
|
"HTTP configuration cannot contain stdio fields.",
|
||||||
|
)
|
||||||
for key in [*request.environment, *request.secret_environment_keys]:
|
for key in [*request.environment, *request.secret_environment_keys]:
|
||||||
self._validate_environment_key(key)
|
self._validate_environment_key(key)
|
||||||
if set(request.environment) & set(request.secret_environment_keys):
|
if set(request.environment) & set(request.secret_environment_keys):
|
||||||
@@ -420,6 +597,21 @@ class McpServerRegistry:
|
|||||||
"MCP_ENVIRONMENT_INVALID",
|
"MCP_ENVIRONMENT_INVALID",
|
||||||
"An environment key cannot be both plain and secret.",
|
"An environment key cannot be both plain and secret.",
|
||||||
)
|
)
|
||||||
|
plain_headers = {key.casefold() for key in request.headers}
|
||||||
|
secret_headers = {key.casefold() for key in request.secret_header_keys}
|
||||||
|
for key in [*request.headers, *request.secret_header_keys]:
|
||||||
|
self._validate_header_key(key)
|
||||||
|
if any(
|
||||||
|
"\r" in value or "\n" in value or "\x00" in value
|
||||||
|
for value in request.headers.values()
|
||||||
|
):
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_HEADER_INVALID", "HTTP header value contains control characters."
|
||||||
|
)
|
||||||
|
if plain_headers & secret_headers:
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_HEADER_INVALID", "An HTTP header cannot be both plain and secret."
|
||||||
|
)
|
||||||
unknown_permissions = set(request.permissions) - KNOWN_PERMISSIONS
|
unknown_permissions = set(request.permissions) - KNOWN_PERMISSIONS
|
||||||
if unknown_permissions:
|
if unknown_permissions:
|
||||||
raise McpRegistryError(
|
raise McpRegistryError(
|
||||||
@@ -434,12 +626,36 @@ class McpServerRegistry:
|
|||||||
"MCP_ENVIRONMENT_INVALID", f"Invalid environment variable name: {key}"
|
"MCP_ENVIRONMENT_INVALID", f"Invalid environment variable name: {key}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_header_key(key: str) -> None:
|
||||||
|
if not _HEADER_KEY.fullmatch(key) or key.casefold() in _RESERVED_HEADERS:
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_HEADER_INVALID", f"Invalid or reserved HTTP header: {key}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_http_url(url: str | None) -> None:
|
||||||
|
if not url:
|
||||||
|
raise McpRegistryError("MCP_URL_INVALID", "MCP HTTP URL is required.")
|
||||||
|
parts = urlsplit(url.strip())
|
||||||
|
if (
|
||||||
|
parts.scheme not in {"http", "https"}
|
||||||
|
or not parts.hostname
|
||||||
|
or parts.username is not None
|
||||||
|
or parts.password is not None
|
||||||
|
or parts.fragment
|
||||||
|
):
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_URL_INVALID",
|
||||||
|
"MCP URL must be an HTTP(S) URL without credentials or fragments.",
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _backend(record: dict[str, Any]) -> PluginBackend:
|
def _backend(record: dict[str, Any]) -> PluginBackend:
|
||||||
return PluginBackend(
|
return PluginBackend(
|
||||||
type="mcp",
|
type="mcp",
|
||||||
transport="stdio",
|
transport="stdio",
|
||||||
command=record["command"],
|
command=record.get("command") or "http",
|
||||||
args=record.get("args", []),
|
args=record.get("args", []),
|
||||||
startup_timeout_seconds=record.get("startup_timeout_seconds", 15),
|
startup_timeout_seconds=record.get("startup_timeout_seconds", 15),
|
||||||
tool_timeout_seconds=record.get("tool_timeout_seconds", 30),
|
tool_timeout_seconds=record.get("tool_timeout_seconds", 30),
|
||||||
@@ -464,6 +680,9 @@ class McpServerRegistry:
|
|||||||
"args",
|
"args",
|
||||||
"environment",
|
"environment",
|
||||||
"secret_environment_keys",
|
"secret_environment_keys",
|
||||||
|
"url",
|
||||||
|
"headers",
|
||||||
|
"secret_header_keys",
|
||||||
"permissions",
|
"permissions",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -475,9 +694,16 @@ class McpServerRegistry:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _summary(record: dict[str, Any]) -> str:
|
def _summary(record: dict[str, Any]) -> str:
|
||||||
|
if record.get("transport") != McpServerTransport.stdio.value:
|
||||||
|
header_names = sorted(
|
||||||
|
[*record.get("headers", {}), *record.get("secret_header_keys", [])],
|
||||||
|
key=str.casefold,
|
||||||
|
)
|
||||||
|
suffix = f" headers={','.join(header_names)}" if header_names else ""
|
||||||
|
return f"{record.get('transport')} {record.get('url') or ''}{suffix}"
|
||||||
return " ".join(
|
return " ".join(
|
||||||
[
|
[
|
||||||
record["command"],
|
record.get("command") or "",
|
||||||
*[
|
*[
|
||||||
json.dumps(arg, ensure_ascii=False)
|
json.dumps(arg, ensure_ascii=False)
|
||||||
for arg in record.get("args", [])
|
for arg in record.get("args", [])
|
||||||
@@ -486,18 +712,51 @@ class McpServerRegistry:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _secret_id(server_id: str, key: str) -> str:
|
def _secret_id(server_id: str, key: str, kind: str = "environment") -> str:
|
||||||
suffix = hashlib.sha256(key.encode()).hexdigest()[:20]
|
suffix = hashlib.sha256(f"{kind}\0{key.casefold()}".encode()).hexdigest()[:20]
|
||||||
return f"mcp.{server_id}.{suffix}"
|
return f"mcp.{server_id}.{suffix}"
|
||||||
|
|
||||||
def _secret_configured(self, server_id: str, key: str) -> bool:
|
def _secret_configured(
|
||||||
|
self, server_id: str, key: str, kind: str = "environment"
|
||||||
|
) -> bool:
|
||||||
try:
|
try:
|
||||||
return self.credentials.has(self._secret_id(server_id, key))
|
return self.credentials.has(self._secret_id(server_id, key, kind))
|
||||||
except CredentialStoreError as exc:
|
except CredentialStoreError as exc:
|
||||||
raise McpRegistryError(
|
raise McpRegistryError(
|
||||||
"MCP_SECRET_STORE_ERROR", str(exc), status_code=500
|
"MCP_SECRET_STORE_ERROR", str(exc), status_code=500
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _secret_keys(record: dict[str, Any], kind: str) -> list[str]:
|
||||||
|
if kind == "environment":
|
||||||
|
return list(record.get("secret_environment_keys", []))
|
||||||
|
if kind == "header":
|
||||||
|
return list(record.get("secret_header_keys", []))
|
||||||
|
raise McpRegistryError("MCP_SECRET_KIND_INVALID", "Unknown MCP secret kind.")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_secret_key(key: str, kind: str) -> None:
|
||||||
|
if kind == "environment":
|
||||||
|
McpServerRegistry._validate_environment_key(key)
|
||||||
|
elif kind == "header":
|
||||||
|
McpServerRegistry._validate_header_key(key)
|
||||||
|
else:
|
||||||
|
raise McpRegistryError(
|
||||||
|
"MCP_SECRET_KIND_INVALID", "Unknown MCP secret kind."
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _tool_summaries(discovered: list[McpDiscoveredTool]) -> list[McpToolSummary]:
|
||||||
|
return [
|
||||||
|
McpToolSummary(
|
||||||
|
name=item.definition.name,
|
||||||
|
remote_name=item.remote_name,
|
||||||
|
description=item.definition.description,
|
||||||
|
permission=item.definition.permission,
|
||||||
|
)
|
||||||
|
for item in discovered
|
||||||
|
]
|
||||||
|
|
||||||
def _record(self, server_id: str) -> dict[str, Any]:
|
def _record(self, server_id: str) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
return self._records[server_id]
|
return self._records[server_id]
|
||||||
|
|||||||
+120
-40
@@ -6,6 +6,8 @@ from uuid import uuid4
|
|||||||
from fastapi import APIRouter, Header, Query
|
from fastapi import APIRouter, Header, Query
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from app.agent import AgentCapacityError, AgentRunNotFoundError
|
||||||
|
from app.container import container
|
||||||
from app.contracts import (
|
from app.contracts import (
|
||||||
AgentRun,
|
AgentRun,
|
||||||
AgentRunCreateRequest,
|
AgentRunCreateRequest,
|
||||||
@@ -28,6 +30,7 @@ from app.contracts import (
|
|||||||
McpServerSecretWriteRequest,
|
McpServerSecretWriteRequest,
|
||||||
McpServerTrustRequest,
|
McpServerTrustRequest,
|
||||||
McpServerUpdateRequest,
|
McpServerUpdateRequest,
|
||||||
|
McpToolSummaryListResponse,
|
||||||
ModelEvent,
|
ModelEvent,
|
||||||
ModelEventType,
|
ModelEventType,
|
||||||
Note,
|
Note,
|
||||||
@@ -75,18 +78,16 @@ from app.contracts import (
|
|||||||
WorkspaceOpenRequest,
|
WorkspaceOpenRequest,
|
||||||
WorkspaceSnapshot,
|
WorkspaceSnapshot,
|
||||||
)
|
)
|
||||||
from app.agent import AgentCapacityError, AgentRunNotFoundError
|
|
||||||
from app.container import container
|
|
||||||
from app.errors import ApiError
|
from app.errors import ApiError
|
||||||
from app.extensions import ExtensionError
|
from app.extensions import ExtensionError
|
||||||
from app.extensions.mcp_registry import McpRegistryError
|
from app.extensions.mcp_registry import McpRegistryError
|
||||||
from app.providers.registry import ProviderNotFoundError
|
|
||||||
from app.providers.factory import UnsupportedProviderError
|
|
||||||
from app.providers.base import ProviderError
|
from app.providers.base import ProviderError
|
||||||
from app.providers.credentials import (
|
from app.providers.credentials import (
|
||||||
CredentialStoreError,
|
CredentialStoreError,
|
||||||
validate_provider_credential_id,
|
validate_provider_credential_id,
|
||||||
)
|
)
|
||||||
|
from app.providers.factory import UnsupportedProviderError
|
||||||
|
from app.providers.registry import ProviderNotFoundError
|
||||||
from app.retrieval.engine import engine
|
from app.retrieval.engine import engine
|
||||||
from app.services import (
|
from app.services import (
|
||||||
index_service,
|
index_service,
|
||||||
@@ -225,14 +226,21 @@ async def list_notes(
|
|||||||
folder: str | None = None,
|
folder: str | None = None,
|
||||||
tag: str | None = None,
|
tag: str | None = None,
|
||||||
) -> NoteListResponse:
|
) -> NoteListResponse:
|
||||||
items, total = note_service.list_notes(limit=limit, offset=offset, folder=folder, tag=tag)
|
items, total = note_service.list_notes(
|
||||||
return NoteListResponse(items=items, page=PageMeta(total=total, limit=limit, offset=offset))
|
limit=limit, offset=offset, folder=folder, tag=tag
|
||||||
|
)
|
||||||
|
return NoteListResponse(
|
||||||
|
items=items, page=PageMeta(total=total, limit=limit, offset=offset)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/notes", response_model=Note, tags=["Notes"])
|
@router.post("/notes", response_model=Note, tags=["Notes"])
|
||||||
async def create_note(request: NoteCreateRequest) -> Note:
|
async def create_note(request: NoteCreateRequest) -> Note:
|
||||||
return await note_service.create_note(
|
return await note_service.create_note(
|
||||||
title=request.title, markdown=request.markdown, folder=request.folder, tags=request.tags
|
title=request.title,
|
||||||
|
markdown=request.markdown,
|
||||||
|
folder=request.folder,
|
||||||
|
tags=request.tags,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -240,7 +248,9 @@ async def create_note(request: NoteCreateRequest) -> Note:
|
|||||||
async def get_note(note_id: str) -> Note:
|
async def get_note(note_id: str) -> Note:
|
||||||
note = await note_service.get_note(note_id)
|
note = await note_service.get_note(note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id})
|
raise ApiError(
|
||||||
|
404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id}
|
||||||
|
)
|
||||||
return note
|
return note
|
||||||
|
|
||||||
|
|
||||||
@@ -254,7 +264,9 @@ async def update_note(note_id: str, request: NoteUpdateRequest) -> Note:
|
|||||||
@router.delete("/notes/{note_id}", response_model=OperationResponse, tags=["Notes"])
|
@router.delete("/notes/{note_id}", response_model=OperationResponse, tags=["Notes"])
|
||||||
async def delete_note(note_id: str) -> OperationResponse:
|
async def delete_note(note_id: str) -> OperationResponse:
|
||||||
if not await note_service.delete_note(note_id):
|
if not await note_service.delete_note(note_id):
|
||||||
raise ApiError(404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id})
|
raise ApiError(
|
||||||
|
404, "RESOURCE_NOT_FOUND", "note not found", {"note_id": note_id}
|
||||||
|
)
|
||||||
return OperationResponse(status="completed", resource_id=note_id, message="deleted")
|
return OperationResponse(status="completed", resource_id=note_id, message="deleted")
|
||||||
|
|
||||||
|
|
||||||
@@ -298,7 +310,9 @@ async def chat(request: ChatRequest) -> StreamingResponse:
|
|||||||
data={"code": "PROVIDER_ERROR", "message": str(exc)},
|
data={"code": "PROVIDER_ERROR", "message": str(exc)},
|
||||||
timestamp=utc_now(),
|
timestamp=utc_now(),
|
||||||
)
|
)
|
||||||
done = ModelEvent(event=ModelEventType.done, sequence=1, timestamp=utc_now())
|
done = ModelEvent(
|
||||||
|
event=ModelEventType.done, sequence=1, timestamp=utc_now()
|
||||||
|
)
|
||||||
yield as_sse(error.event.value, error.model_dump_json())
|
yield as_sse(error.event.value, error.model_dump_json())
|
||||||
yield as_sse(done.event.value, done.model_dump_json())
|
yield as_sse(done.event.value, done.model_dump_json())
|
||||||
|
|
||||||
@@ -459,9 +473,7 @@ async def list_skills() -> SkillListResponse:
|
|||||||
return SkillListResponse(items=container.skills.list())
|
return SkillListResponse(items=container.skills.list())
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get("/skills/{skill_id}", response_model=Skill, tags=["Skills"])
|
||||||
"/skills/{skill_id}", response_model=Skill, tags=["Skills"]
|
|
||||||
)
|
|
||||||
async def get_skill(skill_id: str) -> Skill:
|
async def get_skill(skill_id: str) -> Skill:
|
||||||
return extension_call(lambda: container.skills.get(skill_id))
|
return extension_call(lambda: container.skills.get(skill_id))
|
||||||
|
|
||||||
@@ -501,7 +513,9 @@ async def disable_skill(skill_id: str) -> Skill:
|
|||||||
)
|
)
|
||||||
async def uninstall_skill(skill_id: str) -> OperationResponse:
|
async def uninstall_skill(skill_id: str) -> OperationResponse:
|
||||||
extension_call(lambda: container.skills.uninstall(skill_id))
|
extension_call(lambda: container.skills.uninstall(skill_id))
|
||||||
return OperationResponse(status="completed", resource_id=skill_id, message="uninstalled")
|
return OperationResponse(
|
||||||
|
status="completed", resource_id=skill_id, message="uninstalled"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Independent MCP Server Registry
|
# Independent MCP Server Registry
|
||||||
@@ -510,7 +524,9 @@ async def list_mcp_servers() -> McpServerListResponse:
|
|||||||
return McpServerListResponse(items=mcp_call(container.mcp_servers.list))
|
return McpServerListResponse(items=mcp_call(container.mcp_servers.list))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/mcp/servers", response_model=McpServer, status_code=201, tags=["MCP Servers"])
|
@router.post(
|
||||||
|
"/mcp/servers", response_model=McpServer, status_code=201, tags=["MCP Servers"]
|
||||||
|
)
|
||||||
async def create_mcp_server(request: McpServerCreateRequest) -> McpServer:
|
async def create_mcp_server(request: McpServerCreateRequest) -> McpServer:
|
||||||
return mcp_call(lambda: container.mcp_servers.create(request))
|
return mcp_call(lambda: container.mcp_servers.create(request))
|
||||||
|
|
||||||
@@ -520,45 +536,97 @@ async def get_mcp_server(server_id: str) -> McpServer:
|
|||||||
return mcp_call(lambda: container.mcp_servers.get(server_id))
|
return mcp_call(lambda: container.mcp_servers.get(server_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/mcp/servers/{server_id}/tools",
|
||||||
|
response_model=McpToolSummaryListResponse,
|
||||||
|
tags=["MCP Servers"],
|
||||||
|
)
|
||||||
|
async def list_mcp_server_tools(server_id: str) -> McpToolSummaryListResponse:
|
||||||
|
return McpToolSummaryListResponse(
|
||||||
|
items=mcp_call(lambda: container.mcp_servers.list_tools(server_id))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/mcp/servers/{server_id}", response_model=McpServer, tags=["MCP Servers"])
|
@router.put("/mcp/servers/{server_id}", response_model=McpServer, tags=["MCP Servers"])
|
||||||
async def update_mcp_server(server_id: str, request: McpServerUpdateRequest) -> McpServer:
|
async def update_mcp_server(
|
||||||
return await mcp_call_async(lambda: container.mcp_servers.update(server_id, request))
|
server_id: str, request: McpServerUpdateRequest
|
||||||
|
) -> McpServer:
|
||||||
|
return await mcp_call_async(
|
||||||
|
lambda: container.mcp_servers.update(server_id, request)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/mcp/servers/{server_id}", response_model=OperationResponse, tags=["MCP Servers"])
|
@router.delete(
|
||||||
|
"/mcp/servers/{server_id}", response_model=OperationResponse, tags=["MCP Servers"]
|
||||||
|
)
|
||||||
async def delete_mcp_server(server_id: str) -> OperationResponse:
|
async def delete_mcp_server(server_id: str) -> OperationResponse:
|
||||||
await mcp_call_async(lambda: container.mcp_servers.delete(server_id))
|
await mcp_call_async(lambda: container.mcp_servers.delete(server_id))
|
||||||
return OperationResponse(status="completed", resource_id=server_id, message="deleted")
|
return OperationResponse(
|
||||||
|
status="completed", resource_id=server_id, message="deleted"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/mcp/servers/{server_id}/trust", response_model=McpServer, tags=["MCP Servers"])
|
@router.post(
|
||||||
|
"/mcp/servers/{server_id}/trust", response_model=McpServer, tags=["MCP Servers"]
|
||||||
|
)
|
||||||
async def trust_mcp_server(server_id: str, request: McpServerTrustRequest) -> McpServer:
|
async def trust_mcp_server(server_id: str, request: McpServerTrustRequest) -> McpServer:
|
||||||
return mcp_call(lambda: container.mcp_servers.trust(server_id, request.command_digest))
|
return mcp_call(
|
||||||
|
lambda: container.mcp_servers.trust(server_id, request.command_digest)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/mcp/servers/{server_id}/test", response_model=McpServer, tags=["MCP Servers"])
|
@router.post(
|
||||||
|
"/mcp/servers/{server_id}/test", response_model=McpServer, tags=["MCP Servers"]
|
||||||
|
)
|
||||||
async def test_mcp_server(server_id: str) -> McpServer:
|
async def test_mcp_server(server_id: str) -> McpServer:
|
||||||
return await mcp_call_async(lambda: container.mcp_servers.test(server_id))
|
return await mcp_call_async(lambda: container.mcp_servers.test(server_id))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/mcp/servers/{server_id}/enable", response_model=McpServer, tags=["MCP Servers"])
|
@router.post(
|
||||||
|
"/mcp/servers/{server_id}/enable", response_model=McpServer, tags=["MCP Servers"]
|
||||||
|
)
|
||||||
async def enable_mcp_server(server_id: str) -> McpServer:
|
async def enable_mcp_server(server_id: str) -> McpServer:
|
||||||
return await mcp_call_async(lambda: container.mcp_servers.enable(server_id))
|
return await mcp_call_async(lambda: container.mcp_servers.enable(server_id))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/mcp/servers/{server_id}/disable", response_model=McpServer, tags=["MCP Servers"])
|
@router.post(
|
||||||
|
"/mcp/servers/{server_id}/disable", response_model=McpServer, tags=["MCP Servers"]
|
||||||
|
)
|
||||||
async def disable_mcp_server(server_id: str) -> McpServer:
|
async def disable_mcp_server(server_id: str) -> McpServer:
|
||||||
return await mcp_call_async(lambda: container.mcp_servers.disable(server_id))
|
return await mcp_call_async(lambda: container.mcp_servers.disable(server_id))
|
||||||
|
|
||||||
|
|
||||||
@router.put("/mcp/servers/{server_id}/secrets/{key}", response_model=McpServerSecretStatus, tags=["MCP Servers"])
|
@router.put(
|
||||||
async def put_mcp_server_secret(server_id: str, key: str, request: McpServerSecretWriteRequest) -> McpServerSecretStatus:
|
"/mcp/servers/{server_id}/secrets/{key}",
|
||||||
return mcp_call(lambda: container.mcp_servers.put_secret(server_id, key, request.secret.get_secret_value()))
|
response_model=McpServerSecretStatus,
|
||||||
|
tags=["MCP Servers"],
|
||||||
|
)
|
||||||
|
async def put_mcp_server_secret(
|
||||||
|
server_id: str,
|
||||||
|
key: str,
|
||||||
|
request: McpServerSecretWriteRequest,
|
||||||
|
kind: str = Query(default="environment", pattern="^(environment|header)$"),
|
||||||
|
) -> McpServerSecretStatus:
|
||||||
|
return mcp_call(
|
||||||
|
lambda: container.mcp_servers.put_secret(
|
||||||
|
server_id, key, request.secret.get_secret_value(), kind=kind
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/mcp/servers/{server_id}/secrets/{key}", response_model=McpServerSecretStatus, tags=["MCP Servers"])
|
@router.delete(
|
||||||
async def delete_mcp_server_secret(server_id: str, key: str) -> McpServerSecretStatus:
|
"/mcp/servers/{server_id}/secrets/{key}",
|
||||||
return mcp_call(lambda: container.mcp_servers.delete_secret(server_id, key))
|
response_model=McpServerSecretStatus,
|
||||||
|
tags=["MCP Servers"],
|
||||||
|
)
|
||||||
|
async def delete_mcp_server_secret(
|
||||||
|
server_id: str,
|
||||||
|
key: str,
|
||||||
|
kind: str = Query(default="environment", pattern="^(environment|header)$"),
|
||||||
|
) -> McpServerSecretStatus:
|
||||||
|
return mcp_call(
|
||||||
|
lambda: container.mcp_servers.delete_secret(server_id, key, kind=kind)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Plugins
|
# Plugins
|
||||||
@@ -650,11 +718,15 @@ async def restart_plugin_host(plugin_id: str) -> OperationResponse:
|
|||||||
)
|
)
|
||||||
async def uninstall_plugin(plugin_id: str) -> OperationResponse:
|
async def uninstall_plugin(plugin_id: str) -> OperationResponse:
|
||||||
plugin = extension_call(lambda: container.plugins.get(plugin_id))
|
plugin = extension_call(lambda: container.plugins.get(plugin_id))
|
||||||
dependent_skills = container.skills.depending_on_tools(plugin.manifest.contributes.tools)
|
dependent_skills = container.skills.depending_on_tools(
|
||||||
|
plugin.manifest.contributes.tools
|
||||||
|
)
|
||||||
await extension_call_async(
|
await extension_call_async(
|
||||||
lambda: container.plugins.uninstall(plugin_id, dependent_skills)
|
lambda: container.plugins.uninstall(plugin_id, dependent_skills)
|
||||||
)
|
)
|
||||||
return OperationResponse(status="completed", resource_id=plugin_id, message="uninstalled")
|
return OperationResponse(
|
||||||
|
status="completed", resource_id=plugin_id, message="uninstalled"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Plugin Command / Settings Contributions
|
# Plugin Command / Settings Contributions
|
||||||
@@ -729,9 +801,7 @@ async def put_plugin_setting_secret(
|
|||||||
response_model=PluginSecretStatus,
|
response_model=PluginSecretStatus,
|
||||||
tags=["Plugins"],
|
tags=["Plugins"],
|
||||||
)
|
)
|
||||||
async def delete_plugin_setting_secret(
|
async def delete_plugin_setting_secret(plugin_id: str, key: str) -> PluginSecretStatus:
|
||||||
plugin_id: str, key: str
|
|
||||||
) -> PluginSecretStatus:
|
|
||||||
return extension_call(
|
return extension_call(
|
||||||
lambda: container.plugins.delete_setting_secret(plugin_id, key)
|
lambda: container.plugins.delete_setting_secret(plugin_id, key)
|
||||||
)
|
)
|
||||||
@@ -844,7 +914,9 @@ async def update_provider(
|
|||||||
) -> ProviderConfig:
|
) -> ProviderConfig:
|
||||||
current = configurable_provider_or_404(provider_id).config
|
current = configurable_provider_or_404(provider_id).config
|
||||||
if provider_id == "mock":
|
if provider_id == "mock":
|
||||||
raise ApiError(409, "BUILTIN_PROVIDER_IMMUTABLE", "Mock provider cannot be modified.")
|
raise ApiError(
|
||||||
|
409, "BUILTIN_PROVIDER_IMMUTABLE", "Mock provider cannot be modified."
|
||||||
|
)
|
||||||
fields = request.model_fields_set
|
fields = request.model_fields_set
|
||||||
if ("name" in fields and request.name is None) or (
|
if ("name" in fields and request.name is None) or (
|
||||||
"enabled" in fields and request.enabled is None
|
"enabled" in fields and request.enabled is None
|
||||||
@@ -873,7 +945,9 @@ async def update_provider(
|
|||||||
async def delete_provider(provider_id: str) -> OperationResponse:
|
async def delete_provider(provider_id: str) -> OperationResponse:
|
||||||
configurable_provider_or_404(provider_id)
|
configurable_provider_or_404(provider_id)
|
||||||
if provider_id == "mock":
|
if provider_id == "mock":
|
||||||
raise ApiError(409, "BUILTIN_PROVIDER_IMMUTABLE", "Mock provider cannot be deleted.")
|
raise ApiError(
|
||||||
|
409, "BUILTIN_PROVIDER_IMMUTABLE", "Mock provider cannot be deleted."
|
||||||
|
)
|
||||||
container.providers.unregister(provider_id)
|
container.providers.unregister(provider_id)
|
||||||
return OperationResponse(status="completed", resource_id=provider_id)
|
return OperationResponse(status="completed", resource_id=provider_id)
|
||||||
|
|
||||||
@@ -951,7 +1025,9 @@ async def create_task(request: TaskCreateRequest) -> Task:
|
|||||||
async def get_task(task_id: str) -> Task:
|
async def get_task(task_id: str) -> Task:
|
||||||
task = task_service.get_task(task_id)
|
task = task_service.get_task(task_id)
|
||||||
if task is None:
|
if task is None:
|
||||||
raise ApiError(404, "RESOURCE_NOT_FOUND", "task not found", {"task_id": task_id})
|
raise ApiError(
|
||||||
|
404, "RESOURCE_NOT_FOUND", "task not found", {"task_id": task_id}
|
||||||
|
)
|
||||||
return task
|
return task
|
||||||
|
|
||||||
|
|
||||||
@@ -967,7 +1043,9 @@ async def update_task(task_id: str, request: TaskUpdateRequest) -> Task:
|
|||||||
)
|
)
|
||||||
async def delete_task(task_id: str) -> OperationResponse:
|
async def delete_task(task_id: str) -> OperationResponse:
|
||||||
if not task_service.delete_task(task_id):
|
if not task_service.delete_task(task_id):
|
||||||
raise ApiError(404, "RESOURCE_NOT_FOUND", "task not found", {"task_id": task_id})
|
raise ApiError(
|
||||||
|
404, "RESOURCE_NOT_FOUND", "task not found", {"task_id": task_id}
|
||||||
|
)
|
||||||
return OperationResponse(status="completed", resource_id=task_id, message="deleted")
|
return OperationResponse(status="completed", resource_id=task_id, message="deleted")
|
||||||
|
|
||||||
|
|
||||||
@@ -1017,5 +1095,7 @@ async def rebuild_index(request: IndexRebuildRequest) -> IndexJob:
|
|||||||
async def get_index_job(job_id: str) -> IndexJob:
|
async def get_index_job(job_id: str) -> IndexJob:
|
||||||
job = index_service.get_job(job_id)
|
job = index_service.get_job(job_id)
|
||||||
if job is None:
|
if job is None:
|
||||||
raise ApiError(404, "RESOURCE_NOT_FOUND", "index job not found", {"job_id": job_id})
|
raise ApiError(
|
||||||
|
404, "RESOURCE_NOT_FOUND", "index job not found", {"job_id": job_id}
|
||||||
|
)
|
||||||
return job
|
return job
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.agent.tools import ToolRegistry
|
from app.agent.tools import ToolExecutionContext, ToolRegistry
|
||||||
from app.config import BACKEND_DIR, get_settings
|
from app.config import BACKEND_DIR, get_settings
|
||||||
from app.contracts import McpServerCreateRequest, McpServerUpdateRequest
|
from app.contracts import McpServerCreateRequest, McpServerUpdateRequest, ToolCall
|
||||||
from app.extensions.mcp_registry import McpRegistryError, McpServerRegistry
|
from app.extensions.mcp_registry import McpRegistryError, McpServerRegistry
|
||||||
from app.providers.credentials import EncryptedCredentialStore
|
from app.providers.credentials import EncryptedCredentialStore
|
||||||
|
|
||||||
@@ -58,6 +63,7 @@ def test_update_disables_server_and_revokes_command_trust() -> None:
|
|||||||
service = registry()
|
service = registry()
|
||||||
created = service.create(request(secret_environment_keys=[]))
|
created = service.create(request(secret_environment_keys=[]))
|
||||||
service.trust(created.server_id, created.command_digest)
|
service.trust(created.server_id, created.command_digest)
|
||||||
|
service.test(created.server_id)
|
||||||
enabled = service.enable(created.server_id)
|
enabled = service.enable(created.server_id)
|
||||||
assert enabled.enabled is True
|
assert enabled.enabled is True
|
||||||
assert any(
|
assert any(
|
||||||
@@ -68,7 +74,8 @@ def test_update_disables_server_and_revokes_command_trust() -> None:
|
|||||||
updated = service.update(
|
updated = service.update(
|
||||||
created.server_id,
|
created.server_id,
|
||||||
McpServerUpdateRequest(
|
McpServerUpdateRequest(
|
||||||
**request(name="Changed", secret_environment_keys=[]).model_dump()
|
**request(name="Changed", secret_environment_keys=[]).model_dump(),
|
||||||
|
version=enabled.version,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert updated.enabled is False
|
assert updated.enabled is False
|
||||||
@@ -89,25 +96,73 @@ def test_production_rejects_process_launch_even_after_approval() -> None:
|
|||||||
assert error.value.code == "MCP_SANDBOX_REQUIRED"
|
assert error.value.code == "MCP_SANDBOX_REQUIRED"
|
||||||
|
|
||||||
|
|
||||||
def test_non_stdio_transport_is_explicitly_reserved() -> None:
|
def test_enable_requires_successful_test_and_update_checks_version() -> None:
|
||||||
|
service = registry()
|
||||||
|
created = service.create(request(secret_environment_keys=[]))
|
||||||
|
service.trust(created.server_id, created.command_digest)
|
||||||
|
with pytest.raises(McpRegistryError) as error:
|
||||||
|
service.enable(created.server_id)
|
||||||
|
assert error.value.code == "MCP_CONNECTION_TEST_REQUIRED"
|
||||||
|
|
||||||
|
with pytest.raises(McpRegistryError) as error:
|
||||||
|
service.update(
|
||||||
|
created.server_id,
|
||||||
|
McpServerUpdateRequest(
|
||||||
|
**request(secret_environment_keys=[]).model_dump(), version=99
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert error.value.code == "MCP_SERVER_VERSION_CONFLICT"
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_transport_rejects_invalid_cross_transport_fields() -> None:
|
||||||
|
service = registry()
|
||||||
|
with pytest.raises(McpRegistryError) as error:
|
||||||
|
service.create(
|
||||||
|
request(
|
||||||
|
transport="streamable_http",
|
||||||
|
url="https://example.invalid/mcp",
|
||||||
|
secret_environment_keys=[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert error.value.code == "MCP_CONFIG_INVALID"
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_rejects_corrupt_persisted_json(tmp_path) -> None:
|
||||||
|
path = tmp_path / "mcp"
|
||||||
|
path.mkdir()
|
||||||
|
(path / "servers.json").write_text("{broken", encoding="utf-8")
|
||||||
|
with pytest.raises(McpRegistryError) as error:
|
||||||
|
McpServerRegistry(
|
||||||
|
ToolRegistry(),
|
||||||
|
EncryptedCredentialStore(),
|
||||||
|
tmp_path,
|
||||||
|
allow_process_launch=True,
|
||||||
|
)
|
||||||
|
assert error.value.code == "MCP_REGISTRY_INVALID"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stdio_command_is_not_parsed_as_a_shell_string() -> None:
|
||||||
service = registry()
|
service = registry()
|
||||||
created = service.create(
|
created = service.create(
|
||||||
request(
|
request(
|
||||||
transport="streamable_http",
|
command=f'"{sys.executable}" "{SERVER}"',
|
||||||
command="https://example.invalid/mcp",
|
args=[],
|
||||||
secret_environment_keys=[],
|
secret_environment_keys=[],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
service.trust(created.server_id, created.command_digest)
|
service.trust(created.server_id, created.command_digest)
|
||||||
with pytest.raises(McpRegistryError) as error:
|
with pytest.raises(McpRegistryError) as error:
|
||||||
service.test(created.server_id)
|
service.test(created.server_id)
|
||||||
assert error.value.code == "MCP_TRANSPORT_UNSUPPORTED"
|
assert error.value.code == "PLUGIN_HOST_START_FAILED"
|
||||||
|
assert service.get(created.server_id).last_test_succeeded is False
|
||||||
|
service.shutdown()
|
||||||
|
|
||||||
|
|
||||||
def test_enabled_server_is_restored_from_persisted_registry() -> None:
|
def test_enabled_server_is_restored_from_persisted_registry() -> None:
|
||||||
first = registry()
|
first = registry()
|
||||||
created = first.create(request(secret_environment_keys=[]))
|
created = first.create(request(secret_environment_keys=[]))
|
||||||
first.trust(created.server_id, created.command_digest)
|
first.trust(created.server_id, created.command_digest)
|
||||||
|
first.test(created.server_id)
|
||||||
first.enable(created.server_id)
|
first.enable(created.server_id)
|
||||||
first.shutdown()
|
first.shutdown()
|
||||||
|
|
||||||
@@ -121,3 +176,222 @@ def test_enabled_server_is_restored_from_persisted_registry() -> None:
|
|||||||
for item in restored.tools.definitions()
|
for item in restored.tools.definitions()
|
||||||
)
|
)
|
||||||
restored.shutdown()
|
restored.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def test_lifecycle_operations_are_serialized_and_tool_names_are_isolated() -> None:
|
||||||
|
service = registry()
|
||||||
|
servers = [
|
||||||
|
service.create(request(name=f"Echo {index}", secret_environment_keys=[]))
|
||||||
|
for index in range(2)
|
||||||
|
]
|
||||||
|
for server in servers:
|
||||||
|
service.trust(server.server_id, server.command_digest)
|
||||||
|
service.test(server.server_id)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||||
|
enabled = list(pool.map(lambda item: service.enable(item.server_id), servers * 2))
|
||||||
|
assert all(item.enabled for item in enabled)
|
||||||
|
names = [
|
||||||
|
item.name
|
||||||
|
for item in service.tools.definitions()
|
||||||
|
if item.source == "mcp_server"
|
||||||
|
]
|
||||||
|
assert len(names) == len(set(names))
|
||||||
|
assert all(any(name.startswith(f"mcp.{item.server_id}.") for name in names) for item in servers)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||||
|
list(pool.map(lambda item: service.disable(item.server_id), servers * 2))
|
||||||
|
assert not any(item.source == "mcp_server" for item in service.tools.definitions())
|
||||||
|
service.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
def _http_result(request_id: int, result: dict) -> httpx.Response:
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
headers={"content-type": "application/json"},
|
||||||
|
json={"jsonrpc": "2.0", "id": request_id, "result": result},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_streamable_http_supports_session_headers_secrets_and_tool_summary(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
requests: list[httpx.Request] = []
|
||||||
|
|
||||||
|
def handler(request_value: httpx.Request) -> httpx.Response:
|
||||||
|
requests.append(request_value)
|
||||||
|
if request_value.method == "GET":
|
||||||
|
return httpx.Response(405)
|
||||||
|
if request_value.method == "DELETE":
|
||||||
|
return httpx.Response(405)
|
||||||
|
payload = json.loads(request_value.content)
|
||||||
|
if payload.get("method") == "initialize":
|
||||||
|
response = _http_result(
|
||||||
|
payload["id"],
|
||||||
|
{
|
||||||
|
"protocolVersion": "2025-11-25",
|
||||||
|
"capabilities": {"tools": {}},
|
||||||
|
"serverInfo": {"name": "HTTP Fixture", "version": "1"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.headers["MCP-Session-Id"] = "session-test"
|
||||||
|
return response
|
||||||
|
if payload.get("method") == "tools/list":
|
||||||
|
return _http_result(
|
||||||
|
payload["id"],
|
||||||
|
{
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "echo",
|
||||||
|
"description": "Echo over HTTP",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if payload.get("method") == "tools/call":
|
||||||
|
return _http_result(
|
||||||
|
payload["id"], {"structuredContent": {"transport": "http"}}
|
||||||
|
)
|
||||||
|
return httpx.Response(202)
|
||||||
|
|
||||||
|
real_client = httpx.Client
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.extensions.mcp.httpx.Client",
|
||||||
|
lambda **kwargs: real_client(transport=httpx.MockTransport(handler), **kwargs),
|
||||||
|
)
|
||||||
|
service = registry()
|
||||||
|
created = service.create(
|
||||||
|
McpServerCreateRequest(
|
||||||
|
name="Remote MCP",
|
||||||
|
transport="streamable_http",
|
||||||
|
url="https://mcp.example.test/mcp",
|
||||||
|
headers={"X-Client": "NotesAgent"},
|
||||||
|
secret_header_keys=["Authorization"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
service.put_secret(
|
||||||
|
created.server_id, "Authorization", "Bearer hidden", kind="header"
|
||||||
|
)
|
||||||
|
service.trust(created.server_id, created.command_digest)
|
||||||
|
tested = service.test(created.server_id)
|
||||||
|
|
||||||
|
assert tested.last_test_succeeded is True
|
||||||
|
assert tested.secret_headers == {"Authorization": True}
|
||||||
|
assert "Bearer hidden" not in tested.model_dump_json()
|
||||||
|
assert service.list_tools(created.server_id)[0].remote_name == "echo"
|
||||||
|
assert any(
|
||||||
|
request.headers.get("mcp-session-id") == "session-test" for request in requests
|
||||||
|
)
|
||||||
|
assert any(
|
||||||
|
request.headers.get("mcp-protocol-version") == "2025-11-25"
|
||||||
|
for request in requests
|
||||||
|
)
|
||||||
|
assert all(
|
||||||
|
request.headers.get("authorization") == "Bearer hidden" for request in requests
|
||||||
|
)
|
||||||
|
enabled = service.enable(created.server_id)
|
||||||
|
tool_name = service.list_tools(created.server_id)[0].name
|
||||||
|
result = asyncio.run(
|
||||||
|
service.tools.execute(
|
||||||
|
ToolCall(tool_call_id="call-1", name=tool_name, arguments={}),
|
||||||
|
ToolExecutionContext(run_id="run-1"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert enabled.enabled is True
|
||||||
|
assert result.success is True
|
||||||
|
assert result.output == {"transport": "http"}
|
||||||
|
service.disable(created.server_id)
|
||||||
|
service.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
class _LegacyEventStream(httpx.SyncByteStream):
|
||||||
|
def __iter__(self):
|
||||||
|
yield b"event: endpoint\ndata: /messages\n\n"
|
||||||
|
time.sleep(0.1)
|
||||||
|
initialize = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"result": {
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {"tools": {}},
|
||||||
|
"serverInfo": {"name": "Legacy Fixture"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
yield f"data: {json.dumps(initialize)}\n\n".encode()
|
||||||
|
time.sleep(0.1)
|
||||||
|
tools = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 2,
|
||||||
|
"result": {"tools": []},
|
||||||
|
}
|
||||||
|
yield f"data: {json.dumps(tools)}\n\n".encode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_sse_uses_same_origin_endpoint(monkeypatch) -> None:
|
||||||
|
posted_urls: list[str] = []
|
||||||
|
|
||||||
|
def handler(request_value: httpx.Request) -> httpx.Response:
|
||||||
|
if request_value.method == "GET":
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
headers={"content-type": "text/event-stream"},
|
||||||
|
stream=_LegacyEventStream(),
|
||||||
|
)
|
||||||
|
posted_urls.append(str(request_value.url))
|
||||||
|
return httpx.Response(202)
|
||||||
|
|
||||||
|
real_client = httpx.Client
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.extensions.mcp.httpx.Client",
|
||||||
|
lambda **kwargs: real_client(transport=httpx.MockTransport(handler), **kwargs),
|
||||||
|
)
|
||||||
|
service = registry()
|
||||||
|
created = service.create(
|
||||||
|
McpServerCreateRequest(
|
||||||
|
name="Legacy MCP",
|
||||||
|
transport="sse",
|
||||||
|
url="https://legacy.example.test/sse",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
service.trust(created.server_id, created.command_digest)
|
||||||
|
tested = service.test(created.server_id)
|
||||||
|
assert tested.last_test_succeeded is True
|
||||||
|
assert posted_urls and all(
|
||||||
|
url == "https://legacy.example.test/messages" for url in posted_urls
|
||||||
|
)
|
||||||
|
service.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
class _CrossOriginLegacyEventStream(httpx.SyncByteStream):
|
||||||
|
def __iter__(self):
|
||||||
|
yield b"event: endpoint\ndata: https://attacker.example/messages\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_sse_rejects_cross_origin_message_endpoint(monkeypatch) -> None:
|
||||||
|
def handler(request_value: httpx.Request) -> httpx.Response:
|
||||||
|
assert request_value.method == "GET"
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
headers={"content-type": "text/event-stream"},
|
||||||
|
stream=_CrossOriginLegacyEventStream(),
|
||||||
|
)
|
||||||
|
|
||||||
|
real_client = httpx.Client
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.extensions.mcp.httpx.Client",
|
||||||
|
lambda **kwargs: real_client(transport=httpx.MockTransport(handler), **kwargs),
|
||||||
|
)
|
||||||
|
service = registry()
|
||||||
|
created = service.create(
|
||||||
|
McpServerCreateRequest(
|
||||||
|
name="Unsafe legacy MCP",
|
||||||
|
transport="sse",
|
||||||
|
url="https://legacy.example.test/sse",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
service.trust(created.server_id, created.command_digest)
|
||||||
|
with pytest.raises(McpRegistryError) as error:
|
||||||
|
service.test(created.server_id)
|
||||||
|
assert error.value.code == "MCP_HTTP_RESPONSE_INVALID"
|
||||||
|
service.shutdown()
|
||||||
|
|||||||
@@ -1050,7 +1050,7 @@ Python 包形式的 MCP Server 推荐使用固定版本的 `uvx --isolated --fro
|
|||||||
|
|
||||||
MCP Bridge 用于接入具有 MCP Server 接口的插件或外部工具服务。
|
MCP Bridge 用于接入具有 MCP Server 接口的插件或外部工具服务。
|
||||||
|
|
||||||
当前已实现本地 stdio 首版:Plugin Runtime 在授权后的启用阶段启动独立 Server 进程,完成 `initialize`、capability negotiation、分页 `tools/list`、`tools/call`、取消、超时、异常退出和 Host Restart。实现接受 `2025-11-25`、`2025-06-18`、`2025-03-26` 与 `2024-11-05` 协议版本;Streamable HTTP、Resource、Prompt、Sampling 与操作系统级沙箱仍属于后续范围。
|
当前 Plugin Runtime 已实现本地 stdio Host:在授权后的启用阶段启动独立 Server 进程,完成 `initialize`、capability negotiation、分页 `tools/list`、`tools/call`、取消、超时、异常退出和 Host Restart。独立 MCP Server Registry 另行支持 stdio、Streamable HTTP 与旧 HTTP+SSE 兼容,包括 Session、协议 Header、认证 Header Secret、测试门禁和 Tool 动态映射。实现接受 `2025-11-25`、`2025-06-18`、`2025-03-26` 与 `2024-11-05` 协议版本;Plugin Manifest 的 Streamable HTTP、Resource、Prompt、Sampling 与操作系统级沙箱仍属于后续范围。
|
||||||
|
|
||||||
MCP Tool 进入系统后的调用路径为:
|
MCP Tool 进入系统后的调用路径为:
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 文档状态:接口冻结草案
|
> 文档状态:接口冻结草案
|
||||||
>
|
>
|
||||||
> 更新日期:2026-09-02
|
> 更新日期:2026-09-03
|
||||||
>
|
>
|
||||||
> 依据:`../architecture/第二阶段团队分工表.md`、`../architecture/AI笔记软件技术栈说明-团队版-v2.3.md`、`后端接口契约-开发版.md`
|
> 依据:`../architecture/第二阶段团队分工表.md`、`../architecture/AI笔记软件技术栈说明-团队版-v2.3.md`、`后端接口契约-开发版.md`
|
||||||
|
|
||||||
@@ -47,12 +47,13 @@
|
|||||||
| Agent Trace | GET | `/api/agent/runs/{run_id}/trace` | 已实现 | 分页读取可回放 Trace 快照 |
|
| Agent Trace | GET | `/api/agent/runs/{run_id}/trace` | 已实现 | 分页读取可回放 Trace 快照 |
|
||||||
| Plugin Host | GET | `/api/plugins/{plugin_id}/host` | 已实现 | 获取 MCP Host 健康状态 |
|
| Plugin Host | GET | `/api/plugins/{plugin_id}/host` | 已实现 | 获取 MCP Host 健康状态 |
|
||||||
| Plugin Host | POST | `/api/plugins/{plugin_id}/host/restart` | 已实现 | 重启异常 Host 并重新发现 Tool |
|
| Plugin Host | POST | `/api/plugins/{plugin_id}/host/restart` | 已实现 | 重启异常 Host 并重新发现 Tool |
|
||||||
| MCP Server | GET/POST | `/api/mcp/servers` | 已实现(C.1/P0) | 列出、创建独立 MCP Server 配置 |
|
| MCP Server | GET/POST | `/api/mcp/servers` | 已实现(C.1) | 列出、创建独立 MCP Server 配置 |
|
||||||
| MCP Server | GET/PUT/DELETE | `/api/mcp/servers/{server_id}` | 已实现(C.1/P0) | 读取、修改、删除独立配置 |
|
| MCP Server | GET/PUT/DELETE | `/api/mcp/servers/{server_id}` | 已实现(C.1) | 读取、版本化修改、删除独立配置 |
|
||||||
| MCP Server | POST | `/api/mcp/servers/{server_id}/trust` | 已实现(C.1/P0) | 确认当前可执行配置摘要 |
|
| MCP Server | GET | `/api/mcp/servers/{server_id}/tools` | 已实现(C.1) | 获取映射后的 Tool 摘要 |
|
||||||
| MCP Server | POST | `/api/mcp/servers/{server_id}/test` | 已实现(C.1/P0) | 隔离启动、握手、发现工具后退出 |
|
| MCP Server | POST | `/api/mcp/servers/{server_id}/trust` | 已实现(C.1) | 确认当前连接配置摘要 |
|
||||||
| MCP Server | POST | `/api/mcp/servers/{server_id}/enable`、`disable` | 已实现(C.1/P0) | 控制 Host 与动态 Tool 生命周期 |
|
| MCP Server | POST | `/api/mcp/servers/{server_id}/test` | 已实现(C.1) | 临时连接、握手、发现工具后关闭 |
|
||||||
| MCP Server | PUT/DELETE | `/api/mcp/servers/{server_id}/secrets/{key}` | 已实现(C.1/P0) | 写入或删除加密环境变量 |
|
| MCP Server | POST | `/api/mcp/servers/{server_id}/enable`、`disable` | 已实现(C.1) | 控制连接与动态 Tool 生命周期 |
|
||||||
|
| MCP Server | PUT/DELETE | `/api/mcp/servers/{server_id}/secrets/{key}` | 已实现(C.1) | 按 `kind` 写入或删除加密环境变量/Header |
|
||||||
| Plugin Command | GET | `/api/plugin-contributions/commands` | 已实现 | 获取前端可展示的 Command |
|
| Plugin Command | GET | `/api/plugin-contributions/commands` | 已实现 | 获取前端可展示的 Command |
|
||||||
| Plugin Command | POST | `/api/plugin-contributions/commands/{command_id}/execute` | 已实现 | 受控执行 Command |
|
| Plugin Command | POST | `/api/plugin-contributions/commands/{command_id}/execute` | 已实现 | 受控执行 Command |
|
||||||
| Plugin Settings | GET | `/api/plugins/{plugin_id}/settings` | 已实现 | 获取 Schema 与非敏感配置 |
|
| Plugin Settings | GET | `/api/plugins/{plugin_id}/settings` | 已实现 | 获取 Schema 与非敏感配置 |
|
||||||
@@ -659,9 +660,19 @@ MCP_TRANSPORT_UNSUPPORTED
|
|||||||
MCP_SERVER_NOT_FOUND
|
MCP_SERVER_NOT_FOUND
|
||||||
MCP_SERVER_NAME_INVALID
|
MCP_SERVER_NAME_INVALID
|
||||||
MCP_SERVER_ALREADY_ENABLED
|
MCP_SERVER_ALREADY_ENABLED
|
||||||
|
MCP_SERVER_VERSION_CONFLICT
|
||||||
MCP_REGISTRY_WRITE_FAILED
|
MCP_REGISTRY_WRITE_FAILED
|
||||||
|
MCP_REGISTRY_INVALID
|
||||||
|
MCP_CONNECTION_TEST_REQUIRED
|
||||||
|
MCP_CONFIG_INVALID
|
||||||
|
MCP_COMMAND_INVALID
|
||||||
|
MCP_URL_INVALID
|
||||||
|
MCP_HEADER_INVALID
|
||||||
|
MCP_HTTP_REQUEST_FAILED
|
||||||
|
MCP_HTTP_RESPONSE_INVALID
|
||||||
MCP_SECRET_REQUIRED
|
MCP_SECRET_REQUIRED
|
||||||
MCP_SECRET_NOT_DECLARED
|
MCP_SECRET_NOT_DECLARED
|
||||||
|
MCP_SECRET_KIND_INVALID
|
||||||
MCP_SECRET_STORE_ERROR
|
MCP_SECRET_STORE_ERROR
|
||||||
MCP_ENVIRONMENT_INVALID
|
MCP_ENVIRONMENT_INVALID
|
||||||
MCP_PERMISSION_INVALID
|
MCP_PERMISSION_INVALID
|
||||||
@@ -690,11 +701,15 @@ CREDENTIAL_NAMESPACE_RESERVED
|
|||||||
|
|
||||||
### 7.8 独立 MCP Server Registry(C.1)
|
### 7.8 独立 MCP Server Registry(C.1)
|
||||||
|
|
||||||
独立 Server 不依附 Plugin Manifest,配置持久化于 `APP_DATA_DIR/mcp/servers.json`,敏感环境变量只以 `mcp.*` 引用进入加密凭据存储。响应仅返回每个 Secret 是否已配置,不返回明文。动态工具使用 `mcp.{server_id}.{remote_tool}` 命名空间,来源标记为 `mcp_server`,仍通过统一 Tool Registry、Permission Manager 与 Agent Trace。
|
独立 Server 不依附 Plugin Manifest,配置持久化于 `APP_DATA_DIR/mcp/servers.json`。`transport` 支持 `stdio`、`streamable_http` 和兼容旧服务的 `sse`。敏感环境变量与认证 Header 只以 `mcp.*` 引用进入加密凭据存储;读取响应以 `secret_environment`、`secret_headers` 的布尔值表示配置状态,不返回明文。动态工具使用 `mcp.{server_id}.{remote_tool}` 命名空间,来源标记为 `mcp_server`,仍通过统一 Tool Registry、Permission Manager 与 Agent Trace。
|
||||||
|
|
||||||
P0 只真实支持 `stdio`。`streamable_http` 与 `sse` 已作为后续 Contract 枚举保留,但测试或启用会返回 `501 MCP_TRANSPORT_UNSUPPORTED`,前端不可伪装为可用。命令始终以 executable 与 args 数组通过 `shell=False` 启动;普通环境变量和加密 Secret 显式注入,不继承 Provider Key、数据库或 Vault 路径。
|
stdio 配置使用 `command`、`args`、`environment` 和 `secret_environment_keys`;HTTP/SSE 配置使用 `url`、`headers` 和 `secret_header_keys`,两组 Transport 字段不可混用。更新请求必须携带当前 `version`,成功后版本递增;过期版本返回 `409 MCP_SERVER_VERSION_CONFLICT`。`GET /tools` 返回 `name`、`remote_name`、`description` 和可选 `permission`。
|
||||||
|
|
||||||
创建或编辑配置后,调用方必须向 `/trust` 回传服务端计算的 `command_digest`。后端只接受与当前 transport、command、args、环境变量键值及权限完全一致的摘要;配置变化会撤销旧信任。测试连接同样会实际启动进程,因此也要求确认。当前 Python Host 仅在 `APP_ENVIRONMENT=development` 时允许启动;其他环境返回 `403 MCP_SANDBOX_REQUIRED`,等待第三阶段桌面端沙箱接管。
|
创建或编辑配置后,调用方必须向 `/trust` 回传服务端计算的 `command_digest`。后端只接受与当前 Transport、连接参数、环境/Header 及权限完全一致的摘要;配置变化会撤销旧信任和测试结果。只有当前摘要通过 `/test`,才能调用 `/enable`。测试失败也会持久化时间和失败状态。
|
||||||
|
|
||||||
|
Streamable HTTP 支持 Session ID、`MCP-Protocol-Version`、JSON 或 SSE POST 响应、可选 GET 事件流及 `Last-Event-ID`;旧 SSE 按 endpoint 事件确定 POST 地址,并要求与初始 URL 同源。Secret 接口用 `?kind=environment` 或 `?kind=header` 区分类型。HTTP URL 不允许内嵌凭据或 Fragment,配置不得覆盖协议保留 Header。
|
||||||
|
|
||||||
|
stdio 命令始终以 executable 与 args 数组通过 `shell=False` 启动;普通环境变量和加密 Secret 显式注入,不继承 Provider Key、数据库或 Vault 路径。当前 Python Host 仅在 `APP_ENVIRONMENT=development` 时允许启动 stdio;其他环境返回 `403 MCP_SANDBOX_REQUIRED`。远程 HTTP Transport 不创建本机进程,但仍受摘要确认、成功测试、超时、消息限长与 Secret 隔离约束。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -349,4 +349,4 @@ Skill Manifest
|
|||||||
- Task 已持久化到 SQLite;Attachment Tool 读取 Host 管理目录中的 UTF-8 文件。
|
- Task 已持久化到 SQLite;Attachment Tool 读取 Host 管理目录中的 UTF-8 文件。
|
||||||
- `audio.transcribe` 当前消费 Host 预生成的 transcript;faster-whisper 与说话人分离仍待第二阶段后续接入。
|
- `audio.transcribe` 当前消费 Host 预生成的 transcript;faster-whisper 与说话人分离仍待第二阶段后续接入。
|
||||||
- Extension 安装记录暂存内存;后续接入持久化 Registry 与版本升级流程。
|
- Extension 安装记录暂存内存;后续接入持久化 Registry 与版本升级流程。
|
||||||
- 当前 Plugin Host 支持内置声明式 handler、本地 stdio MCP Server 以及 Plugin Command/Settings;Streamable HTTP、OS 级沙箱与 UI Contribution 留在后续阶段。
|
- 当前 Plugin Host 支持内置声明式 handler、本地 stdio MCP Server 以及 Plugin Command/Settings;独立 MCP Server Registry 另行支持 stdio、Streamable HTTP 与旧 SSE 兼容。OS 级沙箱与 UI Contribution 留在后续阶段。
|
||||||
|
|||||||
@@ -1,47 +1,76 @@
|
|||||||
# 独立 MCP Server 配置中心开发说明
|
# 独立 MCP Server 配置中心开发说明
|
||||||
|
|
||||||
> 更新日期:2026-09-03。本文记录第二阶段 C.1 的 P0 实现;它与 Plugin 自带的 MCP Host 是两个并列入口。
|
> 更新日期:2026-09-03。本文记录第二阶段 C.1 的完整实现;独立 MCP Server Registry 与 Plugin 自带 MCP Host 是两个并列入口。
|
||||||
|
|
||||||
## 1. 已实现范围
|
## 1. 已实现范围
|
||||||
|
|
||||||
- 独立 Server 的创建、读取、编辑和删除;
|
- 独立 Server 的创建、读取、版本化编辑、删除和 Tool 摘要查询;
|
||||||
- stdio 命令、参数、普通环境变量、加密环境变量及超时配置;
|
- `stdio`、Streamable HTTP 和旧版 HTTP+SSE 三种 Transport;
|
||||||
- 命令摘要确认、测试连接、启用、停用和异常状态展示;
|
- stdio 可执行文件、参数、普通/加密环境变量,以及 HTTP URL、普通/加密 Header;
|
||||||
- MCP initialize、`tools/list` 与动态 Tool 注册,工具命名为 `mcp.{server_id}.{tool}`;
|
- 配置摘要确认、连接测试、启停、异常状态与最近一次测试结果;
|
||||||
- 启用状态持久化与开发服务重启恢复;
|
- MCP initialize、`tools/list`、`tools/call`、取消与动态 Tool 注册,名称为 `mcp.{server_id}.{tool}`;
|
||||||
- 前端独立“MCP”导航与配置弹窗,提供 stdio/uvx 模板;
|
- Streamable HTTP Session、协议版本 Header、JSON/SSE POST 响应、可选 GET 事件流和 `Last-Event-ID` 重连;
|
||||||
- Streamable HTTP 与旧 SSE 仅作为后续选项展示为禁用,不属于本次完成范围。
|
- 旧 HTTP+SSE 的 endpoint 事件与消息 POST,并强制消息地址和配置地址同源;
|
||||||
|
- 前端表单/JSON 双模式、三种模板、高风险变更确认及请求期 Secret 输入。
|
||||||
|
|
||||||
## 2. 数据与 Secret
|
Streamable HTTP 按 MCP 当前规范实现;SSE 仅用于兼容旧 Server,不应作为新部署首选。
|
||||||
|
|
||||||
普通配置原子写入 `APP_DATA_DIR/mcp/servers.json`。Secret 使用 `mcp.{server_id}.{key_hash}` 作为内部引用写入现有 Fernet 凭据存储;API 和前端只看到 `configured: true/false`。删除 Server 或移除 Secret 键时同步清理密文。
|
## 2. 配置、版本与 Secret
|
||||||
|
|
||||||
前端 Secret 输入使用密码框,提交后立即清空,不写入 localStorage、普通配置 JSON 或日志。`mcp.*` 同 `plugin.*` 一样属于保留凭据命名空间,Provider 配置与通用凭据 API 无权读取。
|
普通配置原子写入 `APP_DATA_DIR/mcp/servers.json`。更新请求必须携带读取到的 `version`;版本过期返回 `409 MCP_SERVER_VERSION_CONFLICT`,避免多个页面互相覆盖。改变 Transport、命令、URL、Header、环境变量或权限后,旧授权和测试结果立即失效。
|
||||||
|
|
||||||
## 3. 启动安全边界
|
Secret 使用带类型和键名哈希的 `mcp.*` 内部 ID 写入 Fernet 凭据存储。API 只返回环境变量或 Header 是否配置,绝不返回明文。删除 Server 或移除 Secret 键会同步清理密文。前端密码框提交后立即清空,不写入 JSON 编辑器、localStorage、普通配置或日志。
|
||||||
|
|
||||||
命令不经过 Shell,管道、重定向和拼接字符串不会被解释。Host 只继承启动所需的系统变量,再叠加用户显式配置;`uvx` 可隔离 Python 依赖,但不能限制文件、网络和系统调用。
|
## 3. 启用与运行时规则
|
||||||
|
|
||||||
后端会对影响执行的配置计算 SHA-256 摘要。测试或启用前,用户必须确认并回传当前摘要;修改配置会立即撤销旧确认。由于 Python Host 尚无 OS 沙箱,非开发环境硬拒绝启动。第三阶段由 Tauri/Rust Host 提供平台级隔离后再替换这道临时门禁。
|
一次连接按以下顺序执行:
|
||||||
|
|
||||||
## 4. 测试与启动
|
1. 用户检查服务端生成的连接摘要并确认当前摘要;
|
||||||
|
2. 后端临时连接,完成 initialize 和 `tools/list` 后关闭连接;
|
||||||
|
3. 只有当前摘要测试成功,启用操作才会启动长期连接并注册 Tool;
|
||||||
|
4. 停用、删除、超时或异常退出会注销 Tool 并关闭连接。
|
||||||
|
|
||||||
|
HTTP Header 中 `Host`、`Content-Type`、`MCP-Session-Id` 等协议保留项不可由配置覆盖。URL 不允许内嵌凭据或 Fragment。旧 SSE 返回的 POST endpoint 必须与初始 URL 同源,防止认证 Header 被转发到其他站点。
|
||||||
|
|
||||||
|
stdio 命令不经过 Shell,管道、重定向和命令拼接不会被解释。Windows 使用新进程组并通过 `taskkill /T` 回收子树;POSIX 使用独立 session/process group 并向进程组发信号。Python 阶段仍无法提供文件、网络、系统调用或操作系统版本差异下的绝对隔离保证。
|
||||||
|
|
||||||
|
`uvx` 模板使用 `--isolated`、明确的 `--from` 和固定包版本。它只能隔离依赖,不能替代安全沙箱。非开发环境仍拒绝启动 stdio Server,并返回 `403 MCP_SANDBOX_REQUIRED`;远程 HTTP Transport 不创建本机子进程,但仍要求摘要确认和成功测试。第三阶段前的 C.5 将冻结 Tauri/Rust 沙箱设计。
|
||||||
|
|
||||||
|
## 4. 接口
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/mcp/servers
|
||||||
|
POST /api/mcp/servers
|
||||||
|
GET /api/mcp/servers/{server_id}
|
||||||
|
PUT /api/mcp/servers/{server_id}
|
||||||
|
DELETE /api/mcp/servers/{server_id}
|
||||||
|
GET /api/mcp/servers/{server_id}/tools
|
||||||
|
POST /api/mcp/servers/{server_id}/trust
|
||||||
|
POST /api/mcp/servers/{server_id}/test
|
||||||
|
POST /api/mcp/servers/{server_id}/enable
|
||||||
|
POST /api/mcp/servers/{server_id}/disable
|
||||||
|
PUT /api/mcp/servers/{server_id}/secrets/{key}?kind=environment|header
|
||||||
|
DELETE /api/mcp/servers/{server_id}/secrets/{key}?kind=environment|header
|
||||||
|
```
|
||||||
|
|
||||||
|
完整字段、状态和错误码见《第二阶段接口契约-开发版》。协议实现参考 MCP 官方的 [Transports](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports) 与 [Lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle)。
|
||||||
|
|
||||||
|
## 5. 验证
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
cd backend
|
cd backend
|
||||||
uv run pytest -q tests/test_mcp_registry.py
|
uv run pytest -q tests/test_mcp_registry.py tests/test_extension_core.py
|
||||||
uv run uvicorn app.main:app --reload
|
|
||||||
|
|
||||||
cd ../frontend
|
cd ../frontend
|
||||||
npm run type-check
|
npm run type-check
|
||||||
npm test
|
npm test
|
||||||
npm run dev
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
打开知识库后进入左侧“MCP”。保存配置,按提示确认命令,先执行“测试连接”;成功后再启用。默认模板 `uvx mcp-server-fetch` 仅为配置示例,首次下载是否联网由本机 uv 缓存与网络环境决定。
|
后端测试使用无需网络或密钥的 stdio Fixture,以及 `httpx.MockTransport` 驱动的确定性 HTTP/SSE Server Fixture。覆盖摘要授权、Secret 不回显、版本冲突、生产门禁、重启恢复、Streamable HTTP Session/Header/工具调用及旧 SSE 同源校验。前端覆盖模板切换、JSON 校验、Secret 请求期输入、测试失败和删除确认。
|
||||||
|
|
||||||
## 5. 后续增量
|
## 6. 后续边界
|
||||||
|
|
||||||
- P1:Streamable HTTP 连接、认证 Header 与重连策略;
|
- 增加真实第三方 Server 的兼容矩阵;确定性 Fixture 只能证明宿主协议行为,不能代表所有实现兼容;
|
||||||
- 兼容项:仅在确有旧服务需求时增加 SSE;
|
- C.5 在第二阶段开发与测试完成后、第三阶段桌面端实现前冻结沙箱 Contract;
|
||||||
- 第三阶段前:把命令确认与进程创建迁移至 Tauri/Rust 沙箱;
|
- 第三阶段将 stdio 进程创建和 Secret 托管迁移至 Tauri/Rust Host 与 Stronghold/系统 Keychain。
|
||||||
- 增加面向真实第三方 Server 的兼容矩阵,不用单一 Fixture 代表协议全兼容。
|
|
||||||
|
|||||||
@@ -539,20 +539,26 @@ export type McpServerTransport = 'stdio' | 'streamable_http' | 'sse'
|
|||||||
export type McpServerState = 'stopped' | 'starting' | 'ready' | 'unhealthy' | 'error'
|
export type McpServerState = 'stopped' | 'starting' | 'ready' | 'unhealthy' | 'error'
|
||||||
|
|
||||||
export interface McpServerInput {
|
export interface McpServerInput {
|
||||||
|
version?: number
|
||||||
name: string
|
name: string
|
||||||
transport: McpServerTransport
|
transport: McpServerTransport
|
||||||
command: string
|
command?: string | null
|
||||||
args: string[]
|
args: string[]
|
||||||
|
url?: string | null
|
||||||
|
headers: Record<string, string>
|
||||||
environment: Record<string, string>
|
environment: Record<string, string>
|
||||||
secret_environment_keys: string[]
|
secret_environment_keys: string[]
|
||||||
|
secret_header_keys: string[]
|
||||||
permissions: string[]
|
permissions: string[]
|
||||||
startup_timeout_seconds: number
|
startup_timeout_seconds: number
|
||||||
tool_timeout_seconds: number
|
tool_timeout_seconds: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface McpServer extends Omit<McpServerInput, 'secret_environment_keys'> {
|
export interface McpServer extends Omit<McpServerInput, 'secret_environment_keys' | 'secret_header_keys'> {
|
||||||
server_id: string
|
server_id: string
|
||||||
|
version: number
|
||||||
secret_environment: Record<string, boolean>
|
secret_environment: Record<string, boolean>
|
||||||
|
secret_headers: Record<string, boolean>
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
trusted: boolean
|
trusted: boolean
|
||||||
command_digest: string
|
command_digest: string
|
||||||
@@ -567,6 +573,13 @@ export interface McpServer extends Omit<McpServerInput, 'secret_environment_keys
|
|||||||
last_test_succeeded?: boolean | null
|
last_test_succeeded?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface McpToolSummary {
|
||||||
|
name: string
|
||||||
|
remote_name: string
|
||||||
|
description: string
|
||||||
|
permission?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiNoteBlock {
|
export interface ApiNoteBlock {
|
||||||
block_id: string
|
block_id: string
|
||||||
note_id: string
|
note_id: string
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { McpServer } from '@/contracts'
|
||||||
|
import * as service from '@/services/mcpServerService'
|
||||||
|
import McpServersView from './McpServersView.vue'
|
||||||
|
|
||||||
|
vi.mock('@/services/mcpServerService', () => ({
|
||||||
|
listMcpServers: vi.fn(), createMcpServer: vi.fn(), updateMcpServer: vi.fn(),
|
||||||
|
deleteMcpServer: vi.fn(), trustMcpServer: vi.fn(), testMcpServer: vi.fn(),
|
||||||
|
enableMcpServer: vi.fn(), disableMcpServer: vi.fn(), putMcpServerSecret: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const server: McpServer = {
|
||||||
|
server_id: 'server-1', version: 2, name: 'Remote', transport: 'streamable_http',
|
||||||
|
command: null, args: [], url: 'https://mcp.example.test/mcp', headers: {}, environment: {},
|
||||||
|
secret_environment: {}, secret_headers: { Authorization: false }, permissions: [],
|
||||||
|
startup_timeout_seconds: 15, tool_timeout_seconds: 30, enabled: false, trusted: true,
|
||||||
|
command_digest: 'a'.repeat(64), command_summary: 'https://mcp.example.test/mcp',
|
||||||
|
status: 'stopped', tools_count: 1, last_test_succeeded: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render(items: McpServer[] = []) {
|
||||||
|
vi.mocked(service.listMcpServers).mockResolvedValue(items)
|
||||||
|
const wrapper = mount(McpServersView, { global: { stubs: { AppIcon: true } } })
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.stubGlobal('confirm', vi.fn(() => true))
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('McpServersView', () => {
|
||||||
|
it('switches transport templates and round-trips the JSON configuration mode', async () => {
|
||||||
|
const wrapper = await render()
|
||||||
|
await wrapper.findAll('button').find(button => button.text() === '新增服务器')!.trigger('click')
|
||||||
|
await wrapper.findAll('button').find(button => button.text() === 'Streamable HTTP')!.trigger('click')
|
||||||
|
expect(wrapper.find('input[placeholder="https://example.com/mcp"]').exists()).toBe(true)
|
||||||
|
|
||||||
|
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
|
||||||
|
const raw = (wrapper.get('.json-editor').element as HTMLTextAreaElement).value
|
||||||
|
expect(JSON.parse(raw)).toMatchObject({ transport: 'streamable_http', command: null })
|
||||||
|
expect(raw).not.toContain('secret_value')
|
||||||
|
|
||||||
|
await wrapper.findAll('button').find(button => button.text() === '表单配置')!.trigger('click')
|
||||||
|
expect(wrapper.text()).toContain('MCP URL')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid JSON without sending a create request', async () => {
|
||||||
|
const wrapper = await render()
|
||||||
|
await wrapper.findAll('button').find(button => button.text() === '新增服务器')!.trigger('click')
|
||||||
|
await wrapper.findAll('button').find(button => button.text() === 'JSON 配置')!.trigger('click')
|
||||||
|
await wrapper.get('.json-editor').setValue('{invalid')
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('服务器配置不是有效 JSON')
|
||||||
|
expect(service.createMcpServer).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps secrets request-only, exposes test failures, and confirms deletion', async () => {
|
||||||
|
const wrapper = await render([server])
|
||||||
|
const password = wrapper.get('input[type="password"]')
|
||||||
|
await password.setValue('request-only-secret')
|
||||||
|
vi.mocked(service.putMcpServerSecret).mockResolvedValue({} as never)
|
||||||
|
await wrapper.findAll('button').find(button => button.text() === '保存')!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(service.putMcpServerSecret).toHaveBeenCalledWith('server-1', 'Authorization', 'request-only-secret', 'header')
|
||||||
|
expect((password.element as HTMLInputElement).value).toBe('')
|
||||||
|
|
||||||
|
vi.mocked(service.testMcpServer).mockRejectedValue(new Error('连接失败'))
|
||||||
|
await wrapper.findAll('button').find(button => button.text().includes('测试连接'))!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('连接失败')
|
||||||
|
|
||||||
|
vi.mocked(service.deleteMcpServer).mockResolvedValue({ status: 'completed' })
|
||||||
|
await wrapper.findAll('button').find(button => button.text().includes('删除'))!.trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(confirm).toHaveBeenCalled()
|
||||||
|
expect(service.deleteMcpServer).toHaveBeenCalledWith('server-1')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,76 +5,134 @@ import AppIcon from '@/components/common/AppIcon.vue'
|
|||||||
import type { McpServer, McpServerInput, McpServerTransport } from '@/contracts'
|
import type { McpServer, McpServerInput, McpServerTransport } from '@/contracts'
|
||||||
import * as service from '@/services/mcpServerService'
|
import * as service from '@/services/mcpServerService'
|
||||||
|
|
||||||
|
type SecretKind = 'environment' | 'header'
|
||||||
|
|
||||||
const servers = ref<McpServer[]>([])
|
const servers = ref<McpServer[]>([])
|
||||||
const busy = ref('')
|
const busy = ref('')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const dialogOpen = ref(false)
|
const dialogOpen = ref(false)
|
||||||
const editingId = ref<string | null>(null)
|
const editingId = ref<string | null>(null)
|
||||||
|
const editingOriginal = ref<McpServer | null>(null)
|
||||||
|
const editorMode = ref<'form' | 'json'>('form')
|
||||||
const argsText = ref('')
|
const argsText = ref('')
|
||||||
const environmentText = ref('{}')
|
const environmentText = ref('{}')
|
||||||
|
const headersText = ref('{}')
|
||||||
const secretKeysText = ref('')
|
const secretKeysText = ref('')
|
||||||
|
const secretHeaderKeysText = ref('')
|
||||||
const permissionsText = ref('')
|
const permissionsText = ref('')
|
||||||
|
const rawConfig = ref('')
|
||||||
const secretDrafts = reactive<Record<string, string>>({})
|
const secretDrafts = reactive<Record<string, string>>({})
|
||||||
const form = reactive<McpServerInput>({
|
const form = reactive<McpServerInput>(emptyForm())
|
||||||
name: '', transport: 'stdio', command: '', args: [], environment: {},
|
|
||||||
secret_environment_keys: [], permissions: [], startup_timeout_seconds: 15, tool_timeout_seconds: 30,
|
|
||||||
})
|
|
||||||
|
|
||||||
const dialogTitle = computed(() => editingId.value ? '编辑 MCP 服务器' : '新增 MCP 服务器')
|
const dialogTitle = computed(() => editingId.value ? '编辑 MCP 服务器' : '新增 MCP 服务器')
|
||||||
|
|
||||||
|
function emptyForm(): McpServerInput {
|
||||||
|
return {
|
||||||
|
name: '', transport: 'stdio', command: '', args: [], url: null, headers: {},
|
||||||
|
environment: {}, secret_environment_keys: [], secret_header_keys: [], permissions: [],
|
||||||
|
startup_timeout_seconds: 15, tool_timeout_seconds: 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try { servers.value = await service.listMcpServers() }
|
try { servers.value = await service.listMcpServers() }
|
||||||
catch (cause) { error.value = message(cause, '读取 MCP 服务器失败') }
|
catch (cause) { error.value = message(cause, '读取 MCP 服务器失败') }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetEditor(input: McpServerInput) {
|
||||||
|
Object.assign(form, input)
|
||||||
|
argsText.value = input.args.join('\n')
|
||||||
|
environmentText.value = JSON.stringify(input.environment, null, 2)
|
||||||
|
headersText.value = JSON.stringify(input.headers, null, 2)
|
||||||
|
secretKeysText.value = input.secret_environment_keys.join('\n')
|
||||||
|
secretHeaderKeysText.value = input.secret_header_keys.join('\n')
|
||||||
|
permissionsText.value = input.permissions.join(', ')
|
||||||
|
editorMode.value = 'form'
|
||||||
|
rawConfig.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editingId.value = null
|
editingId.value = null
|
||||||
Object.assign(form, { name: '', transport: 'stdio', command: '', args: [], environment: {}, secret_environment_keys: [], permissions: [], startup_timeout_seconds: 15, tool_timeout_seconds: 30 })
|
editingOriginal.value = null
|
||||||
argsText.value = ''; environmentText.value = '{}'; secretKeysText.value = ''; permissionsText.value = ''
|
resetEditor(emptyForm())
|
||||||
dialogOpen.value = true
|
dialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(server: McpServer) {
|
function openEdit(server: McpServer) {
|
||||||
editingId.value = server.server_id
|
editingId.value = server.server_id
|
||||||
Object.assign(form, {
|
editingOriginal.value = server
|
||||||
name: server.name, transport: server.transport, command: server.command,
|
resetEditor({
|
||||||
args: [...server.args], environment: { ...server.environment },
|
version: server.version, name: server.name, transport: server.transport,
|
||||||
secret_environment_keys: Object.keys(server.secret_environment), permissions: [...server.permissions],
|
command: server.command, args: [...server.args], url: server.url,
|
||||||
|
headers: { ...server.headers }, environment: { ...server.environment },
|
||||||
|
secret_environment_keys: Object.keys(server.secret_environment),
|
||||||
|
secret_header_keys: Object.keys(server.secret_headers), permissions: [...server.permissions],
|
||||||
startup_timeout_seconds: server.startup_timeout_seconds,
|
startup_timeout_seconds: server.startup_timeout_seconds,
|
||||||
tool_timeout_seconds: server.tool_timeout_seconds,
|
tool_timeout_seconds: server.tool_timeout_seconds,
|
||||||
})
|
})
|
||||||
argsText.value = server.args.join('\n')
|
|
||||||
environmentText.value = JSON.stringify(server.environment, null, 2)
|
|
||||||
secretKeysText.value = Object.keys(server.secret_environment).join('\n')
|
|
||||||
permissionsText.value = server.permissions.join(', ')
|
|
||||||
dialogOpen.value = true
|
dialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTemplate(transport: McpServerTransport) {
|
function applyTemplate(transport: McpServerTransport) {
|
||||||
if (transport !== 'stdio') return
|
form.transport = transport
|
||||||
form.transport = 'stdio'; form.command = 'uvx'; argsText.value = 'mcp-server-fetch'
|
if (transport === 'stdio') {
|
||||||
|
form.command = 'uvx'; form.url = null
|
||||||
|
argsText.value = '--isolated\n--from\npackage-name==1.0.0\nserver-command'
|
||||||
|
} else {
|
||||||
|
form.command = null; argsText.value = ''; form.url = transport === 'sse' ? 'http://127.0.0.1:3000/sse' : 'http://127.0.0.1:3000/mcp'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseObject(value: string, label: string): Record<string, string> {
|
||||||
|
let parsed: unknown
|
||||||
|
try { parsed = JSON.parse(value || '{}') } catch { throw new Error(`${label}必须是 JSON 对象`) }
|
||||||
|
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object' || Object.values(parsed).some(item => typeof item !== 'string')) throw new Error(`${label}必须是字符串键值 JSON 对象`)
|
||||||
|
return parsed as Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
function formPayload(): McpServerInput {
|
||||||
|
const stdio = form.transport === 'stdio'
|
||||||
|
return {
|
||||||
|
version: form.version,
|
||||||
|
name: form.name.trim(), transport: form.transport,
|
||||||
|
command: stdio ? form.command?.trim() : null,
|
||||||
|
args: stdio ? argsText.value.split('\n').map(value => value.trim()).filter(Boolean) : [],
|
||||||
|
url: stdio ? null : form.url?.trim(),
|
||||||
|
headers: stdio ? {} : parseObject(headersText.value, '普通 Header'),
|
||||||
|
environment: stdio ? parseObject(environmentText.value, '普通环境变量') : {},
|
||||||
|
secret_environment_keys: stdio ? splitKeys(secretKeysText.value) : [],
|
||||||
|
secret_header_keys: stdio ? [] : splitKeys(secretHeaderKeysText.value),
|
||||||
|
permissions: permissionsText.value.split(',').map(value => value.trim()).filter(Boolean),
|
||||||
|
startup_timeout_seconds: form.startup_timeout_seconds,
|
||||||
|
tool_timeout_seconds: form.tool_timeout_seconds,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function payload(): McpServerInput {
|
function payload(): McpServerInput {
|
||||||
let environment: Record<string, string>
|
if (editorMode.value === 'form') return formPayload()
|
||||||
try { environment = JSON.parse(environmentText.value || '{}') }
|
let parsed: unknown
|
||||||
catch { throw new Error('普通环境变量必须是 JSON 对象') }
|
try { parsed = JSON.parse(rawConfig.value) } catch { throw new Error('服务器配置不是有效 JSON') }
|
||||||
if (!environment || Array.isArray(environment) || typeof environment !== 'object') throw new Error('普通环境变量必须是 JSON 对象')
|
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') throw new Error('服务器配置必须是 JSON 对象')
|
||||||
return {
|
const value = parsed as McpServerInput
|
||||||
...form,
|
if (editingId.value) value.version = form.version
|
||||||
name: form.name.trim(), command: form.command.trim(),
|
return value
|
||||||
args: argsText.value.split('\n').map(value => value.trim()).filter(Boolean),
|
|
||||||
environment,
|
|
||||||
secret_environment_keys: secretKeysText.value.split(/[\n,]/).map(value => value.trim()).filter(Boolean),
|
|
||||||
permissions: permissionsText.value.split(',').map(value => value.trim()).filter(Boolean),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function switchMode(mode: 'form' | 'json') {
|
||||||
|
try {
|
||||||
|
if (mode === editorMode.value) return
|
||||||
|
if (mode === 'json') rawConfig.value = JSON.stringify(formPayload(), null, 2)
|
||||||
|
else resetEditor(payload())
|
||||||
|
editorMode.value = mode
|
||||||
|
} catch (cause) { error.value = message(cause, '配置转换失败') }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
try {
|
try {
|
||||||
const input = payload()
|
const input = payload()
|
||||||
if (!input.name || !input.command) throw new Error('请填写服务器名称和可执行命令')
|
if (!input.name || (input.transport === 'stdio' ? !input.command : !input.url)) throw new Error('请填写服务器名称和连接地址')
|
||||||
|
if (editingOriginal.value && executionChanged(editingOriginal.value, input) && !confirm('连接命令、地址或认证配置已变化,保存后旧测试与授权会失效。是否保存?')) return
|
||||||
busy.value = 'save'
|
busy.value = 'save'
|
||||||
editingId.value ? await service.updateMcpServer(editingId.value, input) : await service.createMcpServer(input)
|
editingId.value ? await service.updateMcpServer(editingId.value, input) : await service.createMcpServer(input)
|
||||||
dialogOpen.value = false
|
dialogOpen.value = false
|
||||||
@@ -83,15 +141,19 @@ async function save() {
|
|||||||
finally { busy.value = '' }
|
finally { busy.value = '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function executionChanged(server: McpServer, input: McpServerInput) {
|
||||||
|
return JSON.stringify([server.transport, server.command, server.args, server.url, server.headers, Object.keys(server.secret_headers)]) !== JSON.stringify([input.transport, input.command, input.args, input.url, input.headers, input.secret_header_keys])
|
||||||
|
}
|
||||||
|
|
||||||
async function approve(server: McpServer): Promise<McpServer | null> {
|
async function approve(server: McpServer): Promise<McpServer | null> {
|
||||||
if (server.trusted) return server
|
if (server.trusted) return server
|
||||||
const accepted = confirm(`即将允许本机启动以下命令:\n\n${server.command_summary}\n\n当前 Python Host 没有系统级沙箱,仅应运行可信服务器。是否继续?`)
|
const localWarning = server.transport === 'stdio' ? '\n\n本机进程尚无系统级沙箱,仅应运行可信服务器。' : '\n\n连接可能向该地址发送配置的 Header。'
|
||||||
if (!accepted) return null
|
if (!confirm(`请确认 MCP 连接:\n\n${server.command_summary}${localWarning}\n\n是否继续?`)) return null
|
||||||
return service.trustMcpServer(server)
|
return service.trustMcpServer(server)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function test(server: McpServer) { await act(server, 'test', async current => service.testMcpServer(current.server_id)) }
|
async function test(server: McpServer) { await act(server, 'test', current => service.testMcpServer(current.server_id)) }
|
||||||
async function toggle(server: McpServer) { await act(server, 'toggle', async current => current.enabled ? service.disableMcpServer(current.server_id) : service.enableMcpServer(current.server_id)) }
|
async function toggle(server: McpServer) { await act(server, 'toggle', current => current.enabled ? service.disableMcpServer(current.server_id) : service.enableMcpServer(current.server_id)) }
|
||||||
async function act(server: McpServer, action: string, operation: (server: McpServer) => Promise<McpServer>) {
|
async function act(server: McpServer, action: string, operation: (server: McpServer) => Promise<McpServer>) {
|
||||||
busy.value = `${action}:${server.server_id}`; error.value = ''
|
busy.value = `${action}:${server.server_id}`; error.value = ''
|
||||||
try { const current = action === 'toggle' && server.enabled ? server : await approve(server); if (!current) return; await operation(current); await load() }
|
try { const current = action === 'toggle' && server.enabled ? server : await approve(server); if (!current) return; await operation(current); await load() }
|
||||||
@@ -105,46 +167,51 @@ async function remove(server: McpServer) {
|
|||||||
catch (cause) { error.value = message(cause, '删除失败') } finally { busy.value = '' }
|
catch (cause) { error.value = message(cause, '删除失败') } finally { busy.value = '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSecret(server: McpServer, key: string) {
|
async function saveSecret(server: McpServer, key: string, kind: SecretKind) {
|
||||||
const value = secretDrafts[`${server.server_id}:${key}`]?.trim()
|
const draftKey = `${server.server_id}:${kind}:${key}`
|
||||||
|
const value = secretDrafts[draftKey]?.trim()
|
||||||
if (!value) return
|
if (!value) return
|
||||||
try { busy.value = `secret:${server.server_id}:${key}`; await service.putMcpServerSecret(server.server_id, key, value); secretDrafts[`${server.server_id}:${key}`] = ''; await load() }
|
try { busy.value = `secret:${draftKey}`; await service.putMcpServerSecret(server.server_id, key, value, kind); secretDrafts[draftKey] = ''; await load() }
|
||||||
catch (cause) { error.value = message(cause, '保存密钥失败') } finally { busy.value = '' }
|
catch (cause) { error.value = message(cause, '保存密钥失败') } finally { busy.value = '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitKeys(value: string) { return value.split(/[\n,]/).map(item => item.trim()).filter(Boolean) }
|
||||||
function message(cause: unknown, fallback: string) { return cause instanceof Error ? cause.message : fallback }
|
function message(cause: unknown, fallback: string) { return cause instanceof Error ? cause.message : fallback }
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="feature-page mcp-page">
|
<section class="feature-page mcp-page">
|
||||||
<header class="feature-header">
|
<header class="feature-header"><div><h1>MCP 服务器</h1><p>管理独立 MCP Server 的连接、凭据与工具生命周期。</p></div><div class="inline-actions"><button class="button-secondary" :disabled="!!busy" @click="load"><AppIcon :icon="Refresh" /> 刷新</button><button class="button-primary" @click="openCreate"><AppIcon :icon="Plus" /> 新增服务器</button></div></header>
|
||||||
<div><h1>MCP 服务器</h1><p>管理独立 MCP Server 的连接、凭据与工具生命周期。</p></div>
|
<div class="notice-banner">stdio 本机进程仅在开发环境开放;Streamable HTTP 为首选远程传输,SSE 仅用于兼容旧服务器。uvx 隔离依赖但不是安全沙箱。</div>
|
||||||
<div class="inline-actions"><button class="button-secondary" :disabled="!!busy" @click="load"><AppIcon :icon="Refresh" /> 刷新</button><button class="button-primary" @click="openCreate"><AppIcon :icon="Plus" /> 新增服务器</button></div>
|
|
||||||
</header>
|
|
||||||
<div class="notice-banner">开发阶段仅开放 stdio。uvx 负责依赖隔离,但不是安全沙箱;生产环境将在桌面端沙箱接入前禁止启动本机进程。</div>
|
|
||||||
<div v-if="error" class="error-banner">{{ error }}</div>
|
<div v-if="error" class="error-banner">{{ error }}</div>
|
||||||
<div v-if="!servers.length" class="panel empty"><AppIcon :icon="Connection" :size="34" /><h2>尚未配置 MCP 服务器</h2><p>添加一个 stdio Server,保存后可测试连接并启用工具。</p><button class="button-primary" @click="openCreate">新增服务器</button></div>
|
<div v-if="!servers.length" class="panel empty"><AppIcon :icon="Connection" :size="34" /><h2>尚未配置 MCP 服务器</h2><p>添加 Server,测试连接成功后才能启用工具。</p><button class="button-primary" @click="openCreate">新增服务器</button></div>
|
||||||
<div v-else class="server-list">
|
<div v-else class="server-list">
|
||||||
<article v-for="server in servers" :key="server.server_id" class="panel server-card">
|
<article v-for="server in servers" :key="server.server_id" class="panel server-card">
|
||||||
<div class="server-main"><div class="server-title"><AppIcon :icon="Connection" :size="24" /><div><h2>{{ server.name }}</h2><code>{{ server.command_summary }}</code></div></div><span class="badge" :class="{ success: server.status === 'ready', error: ['error','unhealthy'].includes(server.status) }">{{ server.status }}</span></div>
|
<div class="server-main"><div class="server-title"><AppIcon :icon="Connection" :size="24" /><div><h2>{{ server.name }}</h2><code>{{ server.command_summary }}</code></div></div><span class="badge" :class="{ success: server.status === 'ready', error: ['error','unhealthy'].includes(server.status) }">{{ server.status }}</span></div>
|
||||||
<div class="metadata"><span>{{ server.transport }}</span><span>{{ server.tools_count }} 个工具</span><span>{{ server.trusted ? '命令已确认' : '等待确认命令' }}</span><span v-if="server.last_test_succeeded">最近测试成功</span><span v-if="server.remote_server_name">{{ server.remote_server_name }} {{ server.remote_server_version }}</span></div>
|
<div class="metadata"><span>{{ server.transport }}</span><span>v{{ server.version }}</span><span>{{ server.tools_count }} 个工具</span><span>{{ server.trusted ? '连接已确认' : '等待确认连接' }}</span><span v-if="server.last_test_succeeded">当前配置测试成功</span><span v-if="server.remote_server_name">{{ server.remote_server_name }} {{ server.remote_server_version }}</span></div>
|
||||||
<div v-if="server.error" class="error-banner compact">{{ server.error }}</div>
|
<div v-if="server.error" class="error-banner compact">{{ server.error }}</div>
|
||||||
<div v-if="Object.keys(server.secret_environment).length" class="secrets"><label v-for="(configured, key) in server.secret_environment" :key="key"><span>{{ key }} <small>{{ configured ? '已加密保存' : '未配置' }}</small></span><span class="secret-input"><input v-model="secretDrafts[`${server.server_id}:${key}`]" type="password" autocomplete="new-password" placeholder="输入后保存(不会回显)"><button class="button-secondary" @click="saveSecret(server, key)">保存</button></span></label></div>
|
<div v-if="Object.keys(server.secret_environment).length || Object.keys(server.secret_headers).length" class="secrets">
|
||||||
<footer class="card-actions"><button class="button-secondary" :disabled="!!busy || server.enabled" @click="test(server)"><AppIcon :icon="VideoPlay" /> 测试连接</button><button class="button-secondary" :disabled="!!busy" @click="openEdit(server)"><AppIcon :icon="EditPen" /> 编辑</button><button class="button-danger" :disabled="!!busy" @click="remove(server)"><AppIcon :icon="Delete" /> 删除</button><button class="button-primary" :disabled="!!busy" @click="toggle(server)">{{ server.enabled ? '停用' : '启用' }}</button></footer>
|
<label v-for="(configured, key) in server.secret_environment" :key="`env:${key}`"><span>环境变量 · {{ key }} <small>{{ configured ? '已加密保存' : '未配置' }}</small></span><span class="secret-input"><input v-model="secretDrafts[`${server.server_id}:environment:${key}`]" type="password" autocomplete="new-password" placeholder="输入后保存(不会回显)"><button class="button-secondary" @click="saveSecret(server, key, 'environment')">保存</button></span></label>
|
||||||
|
<label v-for="(configured, key) in server.secret_headers" :key="`header:${key}`"><span>HTTP Header · {{ key }} <small>{{ configured ? '已加密保存' : '未配置' }}</small></span><span class="secret-input"><input v-model="secretDrafts[`${server.server_id}:header:${key}`]" type="password" autocomplete="new-password" placeholder="输入后保存(不会回显)"><button class="button-secondary" @click="saveSecret(server, key, 'header')">保存</button></span></label>
|
||||||
|
</div>
|
||||||
|
<footer class="card-actions"><button class="button-secondary" :disabled="!!busy || server.enabled" @click="test(server)"><AppIcon :icon="VideoPlay" /> 测试连接</button><button class="button-secondary" :disabled="!!busy" @click="openEdit(server)"><AppIcon :icon="EditPen" /> 编辑</button><button class="button-danger" :disabled="!!busy" @click="remove(server)"><AppIcon :icon="Delete" /> 删除</button><button class="button-primary" :disabled="!!busy || (!server.enabled && !server.last_test_succeeded)" :title="!server.enabled && !server.last_test_succeeded ? '请先测试当前配置' : ''" @click="toggle(server)">{{ server.enabled ? '停用' : '启用' }}</button></footer>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="dialogOpen" class="modal-backdrop" @click.self="dialogOpen = false">
|
<div v-if="dialogOpen" class="modal-backdrop" @click.self="dialogOpen = false">
|
||||||
<form class="modal-card" @submit.prevent="save">
|
<form class="modal-card" @submit.prevent="save">
|
||||||
<header><h2><AppIcon :icon="Plus" /> {{ dialogTitle }}</h2><button type="button" class="close" @click="dialogOpen = false">×</button></header>
|
<header><h2><AppIcon :icon="Plus" /> {{ dialogTitle }}</h2><button type="button" class="close" @click="dialogOpen = false">×</button></header>
|
||||||
|
<div class="mode-tabs"><button type="button" :class="{ active: editorMode === 'form' }" @click="switchMode('form')">表单配置</button><button type="button" :class="{ active: editorMode === 'json' }" @click="switchMode('json')">JSON 配置</button></div>
|
||||||
|
<template v-if="editorMode === 'form'">
|
||||||
<label>服务器名称<input v-model="form.name" maxlength="80" placeholder="例如:文件系统工具"></label>
|
<label>服务器名称<input v-model="form.name" maxlength="80" placeholder="例如:文件系统工具"></label>
|
||||||
<div class="template-row"><span>服务器配置</span><button type="button" class="template active" @click="applyTemplate('stdio')">stdio 模板</button><button type="button" class="template" disabled>Streamable HTTP(后续)</button><button type="button" class="template" disabled>SSE(兼容项)</button></div>
|
<div class="template-row"><span>服务器配置</span><button type="button" class="template" :class="{ active: form.transport === 'stdio' }" @click="applyTemplate('stdio')">stdio 模板</button><button type="button" class="template" :class="{ active: form.transport === 'streamable_http' }" @click="applyTemplate('streamable_http')">Streamable HTTP</button><button type="button" class="template" :class="{ active: form.transport === 'sse' }" @click="applyTemplate('sse')">SSE(兼容)</button></div>
|
||||||
<label>可执行命令<input v-model="form.command" placeholder="uvx、npx 或可信可执行文件路径"></label>
|
<template v-if="form.transport === 'stdio'"><label>可执行命令<input v-model="form.command" placeholder="uvx、npx 或可信可执行文件路径"></label><label>参数(每行一项)<textarea v-model="argsText" rows="5"></textarea></label><div class="two-columns"><label>普通环境变量(JSON)<textarea v-model="environmentText" rows="5"></textarea></label><label>敏感环境变量名(每行一项)<textarea v-model="secretKeysText" rows="5" placeholder="API_KEY"></textarea></label></div></template>
|
||||||
<label>参数(每行一项)<textarea v-model="argsText" rows="4" placeholder="mcp-server-fetch"></textarea></label>
|
<template v-else><label>MCP URL<input v-model="form.url" placeholder="https://example.com/mcp"></label><div class="two-columns"><label>普通 Header(JSON)<textarea v-model="headersText" rows="5" placeholder='{"X-Client":"NotesAgent"}'></textarea></label><label>敏感 Header 名(每行一项)<textarea v-model="secretHeaderKeysText" rows="5" placeholder="Authorization"></textarea></label></div></template>
|
||||||
<div class="two-columns"><label>普通环境变量(JSON)<textarea v-model="environmentText" rows="5"></textarea></label><label>敏感环境变量名(每行一项)<textarea v-model="secretKeysText" rows="5" placeholder="API_KEY"></textarea></label></div>
|
|
||||||
<label>声明权限(逗号分隔,可选)<input v-model="permissionsText" placeholder="network.request, notes.read"></label>
|
<label>声明权限(逗号分隔,可选)<input v-model="permissionsText" placeholder="network.request, notes.read"></label>
|
||||||
<div class="two-columns"><label>启动超时(秒)<input v-model.number="form.startup_timeout_seconds" type="number" min="1" max="120"></label><label>工具超时(秒)<input v-model.number="form.tool_timeout_seconds" type="number" min="1" max="300"></label></div>
|
<div class="two-columns"><label>启动超时(秒)<input v-model.number="form.startup_timeout_seconds" type="number" min="1" max="120"></label><label>工具超时(秒)<input v-model.number="form.tool_timeout_seconds" type="number" min="1" max="300"></label></div>
|
||||||
|
</template>
|
||||||
|
<label v-else>服务器 JSON 配置<textarea v-model="rawConfig" class="json-editor" rows="22" spellcheck="false"></textarea><small>Secret 只填写键名,明文请在保存后的服务器卡片中单独录入。</small></label>
|
||||||
<footer><button type="button" class="button-secondary" @click="dialogOpen = false">取消</button><button class="button-primary" :disabled="busy === 'save'">保存</button></footer>
|
<footer><button type="button" class="button-secondary" @click="dialogOpen = false">取消</button><button class="button-primary" :disabled="busy === 'save'">保存</button></footer>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,18 +219,10 @@ onMounted(load)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.mcp-page { overflow: auto; }
|
.mcp-page { overflow: auto; }.notice-banner,.error-banner { margin-bottom: var(--space-lg); }.server-list { display: grid; gap: var(--space-lg); }.server-card { display: grid; gap: var(--space-md); }
|
||||||
.notice-banner, .error-banner { margin-bottom: var(--space-lg); }
|
.server-main,.server-title,.metadata,.card-actions,.inline-actions,.template-row,.modal-card header,.modal-card footer { display: flex; align-items: center; gap: var(--space-sm); }.server-main { justify-content: space-between; }.server-title { align-items: flex-start; }.server-title h2 { margin-bottom: 4px; }.server-title code { color: var(--color-text-secondary); overflow-wrap: anywhere; }.metadata { flex-wrap: wrap; color: var(--color-text-tertiary); font-size: var(--font-size-sm); }.metadata span + span::before { content: '·'; margin-right: var(--space-sm); }.compact { margin: 0; }
|
||||||
.server-list { display: grid; gap: var(--space-lg); }
|
.card-actions { justify-content: flex-end; border-top: 1px solid var(--color-border-subtle); padding-top: var(--space-md); }.empty { text-align: center; place-items: center; display: grid; gap: var(--space-md); padding: 64px; }.secrets { border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); padding: var(--space-md); display: grid; gap: var(--space-sm); }.secrets label { display: grid; grid-template-columns: minmax(220px,.7fr) 1fr; align-items: center; gap: var(--space-md); }.secrets small,.modal-card small { color: var(--color-text-tertiary); }.secret-input { display: flex; gap: var(--space-sm); }.secret-input input { flex: 1; }
|
||||||
.server-card { display: grid; gap: var(--space-md); }
|
.modal-backdrop { position: fixed; inset: 0; z-index: 1000; background: rgb(0 0 0 / .48); display: grid; place-items: center; padding: var(--space-xl); }.modal-card { width: min(800px,100%); max-height: calc(100vh - 48px); overflow: auto; background: var(--color-background-primary); border: 1px solid var(--color-border-default); border-radius: var(--radius-xl); box-shadow: var(--shadow-xl); padding: var(--space-xl); display: grid; gap: var(--space-lg); animation: modal-in var(--motion-normal) ease-out; }.modal-card header,.modal-card footer { justify-content: space-between; }.modal-card footer { justify-content: flex-end; }.modal-card label { display: grid; gap: var(--space-xs); font-weight: 600; }.modal-card input,.modal-card textarea { width: 100%; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); padding: 10px 12px; color: var(--color-text-primary); background: var(--color-background-secondary); font: inherit; }.modal-card textarea { resize: vertical; font-family: var(--font-family-mono); font-size: var(--font-size-sm); }.json-editor { line-height: 1.55; }.close { border: 0; background: transparent; color: var(--color-text-secondary); font-size: 28px; cursor: pointer; }
|
||||||
.server-main, .server-title, .metadata, .card-actions, .inline-actions, .template-row, .modal-card header, .modal-card footer { display: flex; align-items: center; gap: var(--space-sm); }
|
.template-row { flex-wrap: wrap; }.template-row > span { margin-right: auto; font-weight: 600; }.template,.mode-tabs button { border: 1px solid var(--color-border-default); background: var(--color-background-secondary); color: var(--color-text-secondary); padding: 7px 10px; border-radius: var(--radius-md); cursor: pointer; }.template.active,.mode-tabs button.active { color: var(--color-accent-primary); border-color: var(--color-accent-primary); background: var(--color-accent-soft); }.mode-tabs { display: inline-flex; justify-self: start; gap: 2px; padding: 3px; border-radius: var(--radius-md); background: var(--color-background-secondary); }.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-md); }
|
||||||
.server-main { justify-content: space-between; }.server-title { align-items: flex-start; }.server-title h2 { margin-bottom: 4px; }.server-title code { color: var(--color-text-secondary); overflow-wrap: anywhere; }
|
@keyframes modal-in { from { opacity: 0; transform: translateY(8px) scale(.99); } } @media (max-width:720px) { .two-columns,.secrets label { grid-template-columns:1fr; }.card-actions { flex-wrap:wrap; } }
|
||||||
.metadata { flex-wrap: wrap; color: var(--color-text-tertiary); font-size: var(--font-size-sm); }.metadata span + span::before { content: '·'; margin-right: var(--space-sm); }.compact { margin: 0; }
|
|
||||||
.card-actions { justify-content: flex-end; border-top: 1px solid var(--color-border-subtle); padding-top: var(--space-md); }.empty { text-align: center; place-items: center; display: grid; gap: var(--space-md); padding: 64px; }
|
|
||||||
.secrets { border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); padding: var(--space-md); display: grid; gap: var(--space-sm); }.secrets label { display: grid; grid-template-columns: minmax(160px,.6fr) 1fr; align-items: center; gap: var(--space-md); }.secrets small { color: var(--color-text-tertiary); }.secret-input { display: flex; gap: var(--space-sm); }.secret-input input { flex: 1; }
|
|
||||||
.modal-backdrop { position: fixed; inset: 0; z-index: 1000; background: rgb(0 0 0 / .48); display: grid; place-items: center; padding: var(--space-xl); }
|
|
||||||
.modal-card { width: min(760px, 100%); max-height: calc(100vh - 48px); overflow: auto; background: var(--color-background-primary); border: 1px solid var(--color-border-default); border-radius: var(--radius-xl); box-shadow: var(--shadow-xl); padding: var(--space-xl); display: grid; gap: var(--space-lg); animation: modal-in var(--motion-normal) ease-out; }
|
|
||||||
.modal-card header, .modal-card footer { justify-content: space-between; }.modal-card footer { justify-content: flex-end; }.modal-card label { display: grid; gap: var(--space-xs); font-weight: 600; }.modal-card input, .modal-card textarea { width: 100%; border: 1px solid var(--color-border-default); border-radius: var(--radius-md); padding: 10px 12px; color: var(--color-text-primary); background: var(--color-background-secondary); font: inherit; }.modal-card textarea { resize: vertical; font-family: var(--font-family-mono); font-size: var(--font-size-sm); }.close { border: 0; background: transparent; color: var(--color-text-secondary); font-size: 28px; cursor: pointer; }.template-row { flex-wrap: wrap; }.template-row > span { margin-right: auto; font-weight: 600; }.template { border: 1px solid var(--color-border-default); background: var(--color-background-secondary); color: var(--color-text-secondary); padding: 7px 10px; border-radius: var(--radius-md); }.template.active { color: var(--color-accent-primary); border-color: var(--color-accent-primary); }.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-md); }
|
|
||||||
@keyframes modal-in { from { opacity: 0; transform: translateY(8px) scale(.99); } }
|
|
||||||
@media (max-width: 720px) { .two-columns, .secrets label { grid-template-columns: 1fr; }.card-actions { flex-wrap: wrap; } }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import apiClient from './apiClient'
|
import apiClient from './apiClient'
|
||||||
import type { McpServer, McpServerInput, OperationResponse } from '@/contracts'
|
import type { McpServer, McpServerInput, McpToolSummary, OperationResponse } from '@/contracts'
|
||||||
|
|
||||||
const base = '/api/mcp/servers'
|
const base = '/api/mcp/servers'
|
||||||
|
|
||||||
@@ -8,10 +8,11 @@ export async function listMcpServers(): Promise<McpServer[]> {
|
|||||||
}
|
}
|
||||||
export const createMcpServer = (input: McpServerInput) => apiClient.post<McpServer>(base, input)
|
export const createMcpServer = (input: McpServerInput) => apiClient.post<McpServer>(base, input)
|
||||||
export const updateMcpServer = (id: string, input: McpServerInput) => apiClient.put<McpServer>(`${base}/${id}`, input)
|
export const updateMcpServer = (id: string, input: McpServerInput) => apiClient.put<McpServer>(`${base}/${id}`, input)
|
||||||
|
export const listMcpServerTools = async (id: string) => (await apiClient.get<{ items: McpToolSummary[] }>(`${base}/${id}/tools`)).items
|
||||||
export const deleteMcpServer = (id: string) => apiClient.delete<OperationResponse>(`${base}/${id}`)
|
export const deleteMcpServer = (id: string) => apiClient.delete<OperationResponse>(`${base}/${id}`)
|
||||||
export const trustMcpServer = (server: McpServer) => apiClient.post<McpServer>(`${base}/${server.server_id}/trust`, { command_digest: server.command_digest })
|
export const trustMcpServer = (server: McpServer) => apiClient.post<McpServer>(`${base}/${server.server_id}/trust`, { command_digest: server.command_digest })
|
||||||
export const testMcpServer = (id: string) => apiClient.post<McpServer>(`${base}/${id}/test`)
|
export const testMcpServer = (id: string) => apiClient.post<McpServer>(`${base}/${id}/test`)
|
||||||
export const enableMcpServer = (id: string) => apiClient.post<McpServer>(`${base}/${id}/enable`)
|
export const enableMcpServer = (id: string) => apiClient.post<McpServer>(`${base}/${id}/enable`)
|
||||||
export const disableMcpServer = (id: string) => apiClient.post<McpServer>(`${base}/${id}/disable`)
|
export const disableMcpServer = (id: string) => apiClient.post<McpServer>(`${base}/${id}/disable`)
|
||||||
export const putMcpServerSecret = (id: string, key: string, secret: string) => apiClient.put(`${base}/${id}/secrets/${encodeURIComponent(key)}`, { secret })
|
export const putMcpServerSecret = (id: string, key: string, secret: string, kind: 'environment' | 'header' = 'environment') => apiClient.put(`${base}/${id}/secrets/${encodeURIComponent(key)}?kind=${kind}`, { secret })
|
||||||
export const deleteMcpServerSecret = (id: string, key: string) => apiClient.delete(`${base}/${id}/secrets/${encodeURIComponent(key)}`)
|
export const deleteMcpServerSecret = (id: string, key: string, kind: 'environment' | 'header' = 'environment') => apiClient.delete(`${base}/${id}/secrets/${encodeURIComponent(key)}?kind=${kind}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user