qca-mcp-proxy 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """Filtered MCP proxy for Qoder Cloud Agents (QCA)."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,152 @@
1
+ """Entry point: serve the filtered QCA proxy over stdio or Streamable HTTP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import sys
8
+ from dataclasses import replace
9
+
10
+ import anyio
11
+ from mcp.server.stdio import stdio_server
12
+
13
+ from .config import Config, ConfigError, load_config
14
+ from .server import MCP_PATH, build_app, build_server, explain
15
+ from .upstream import Upstream, UpstreamUnavailable
16
+
17
+
18
+ def _configure_logging(debug: bool) -> None:
19
+ logging.basicConfig(
20
+ level=logging.DEBUG if debug else logging.INFO,
21
+ stream=sys.stderr,
22
+ format="[qca-proxy] %(levelname)s %(name)s: %(message)s",
23
+ )
24
+ # The SDK and its HTTP client are chatty at INFO; keep our own lines readable.
25
+ if not debug:
26
+ for noisy in ("httpx2", "httpcore", "mcp"):
27
+ logging.getLogger(noisy).setLevel(logging.WARNING)
28
+
29
+
30
+ async def _serve(config: Config) -> None:
31
+ server = build_server(config)
32
+ async with stdio_server() as (read_stream, write_stream):
33
+ await server.run(read_stream, write_stream, server.create_initialization_options())
34
+
35
+
36
+ async def _serve_http(config: Config, host: str, port: int, stateless: bool) -> int:
37
+ import uvicorn
38
+
39
+ # Probe the upstream first: a failed connection inside the ASGI lifespan only
40
+ # surfaces as a uvicorn startup traceback, which hides the actual cause.
41
+ probe = Upstream(config)
42
+ try:
43
+ await probe.open()
44
+ except UpstreamUnavailable as exc:
45
+ print(f"[qca-proxy] cannot reach QCA: {explain(exc, config)}", file=sys.stderr)
46
+ return 1
47
+ finally:
48
+ await probe.aclose()
49
+
50
+ app = build_app(config, host=host, stateless=stateless)
51
+ server = uvicorn.Server(
52
+ uvicorn.Config(
53
+ app,
54
+ host=host,
55
+ port=port,
56
+ log_config=None,
57
+ log_level="debug" if config.debug else "info",
58
+ )
59
+ )
60
+ logging.getLogger("qca_mcp_proxy.server").info(
61
+ "serving Streamable HTTP on http://%s:%d%s%s%s",
62
+ host,
63
+ port,
64
+ MCP_PATH,
65
+ " (bearer token required)" if config.http_token else "",
66
+ " (stateless)" if stateless else "",
67
+ )
68
+ await server.serve()
69
+ return 0
70
+
71
+
72
+ async def _check(config: Config) -> int:
73
+ upstream = Upstream(config)
74
+ try:
75
+ try:
76
+ await upstream.open()
77
+ all_tools = await upstream.list_all_tools()
78
+ except UpstreamUnavailable as exc:
79
+ print(f"[qca-proxy] upstream unreachable: {explain(exc, config)}", file=sys.stderr)
80
+ return 1
81
+ finally:
82
+ await upstream.aclose()
83
+
84
+ names = sorted(tool.name for tool in all_tools)
85
+ exposed = [name for name in names if name in config.allowed_tools]
86
+ hidden = [name for name in names if name not in config.allowed_tools]
87
+ missing = sorted(config.allowed_tools - set(names))
88
+ print(f"endpoint {config.endpoint} ({config.region})")
89
+ print(f"upstream {len(names)} tools")
90
+ print(f"exposed {len(exposed)}: {', '.join(exposed) if exposed else '-'}")
91
+ print(f"hidden {len(hidden)}: {', '.join(hidden) if hidden else '-'}")
92
+ if missing:
93
+ print(f"allowlisted but absent upstream: {', '.join(missing)}")
94
+ return 0
95
+
96
+
97
+ def main(argv: list[str] | None = None) -> int:
98
+ parser = argparse.ArgumentParser(
99
+ prog="qca-mcp-proxy",
100
+ description=(
101
+ "MCP proxy exposing only the Qoder Cloud Agents session tools, "
102
+ "over stdio or as a Streamable HTTP URL."
103
+ ),
104
+ )
105
+ parser.add_argument(
106
+ "--check",
107
+ action="store_true",
108
+ help="verify the PAT against the upstream server, print the tool split, then exit",
109
+ )
110
+ parser.add_argument(
111
+ "--transport",
112
+ choices=("stdio", "http"),
113
+ default="stdio",
114
+ help="stdio for a local MCP host (default), http to serve a URL over Streamable HTTP",
115
+ )
116
+ parser.add_argument("--host", default="127.0.0.1", help="bind address for --transport http (default 127.0.0.1)")
117
+ parser.add_argument("--port", type=int, default=8790, help="bind port for --transport http (default 8790)")
118
+ parser.add_argument(
119
+ "--auth-token",
120
+ default=None,
121
+ help="require 'Authorization: Bearer <token>' on the HTTP transport (env QCA_HTTP_TOKEN)",
122
+ )
123
+ parser.add_argument(
124
+ "--stateless",
125
+ action="store_true",
126
+ help="HTTP transport without a session id, for clients that open a new request per call",
127
+ )
128
+ args = parser.parse_args(argv)
129
+
130
+ try:
131
+ config = load_config()
132
+ except ConfigError as exc:
133
+ print(f"[qca-proxy] {exc}", file=sys.stderr)
134
+ return 2
135
+ if args.auth_token:
136
+ config = replace(config, http_token=args.auth_token)
137
+
138
+ _configure_logging(config.debug)
139
+ try:
140
+ if args.check:
141
+ return anyio.run(_check, config)
142
+ if args.transport == "http":
143
+ return anyio.run(_serve_http, config, args.host, args.port, args.stateless)
144
+ else:
145
+ anyio.run(_serve, config)
146
+ except KeyboardInterrupt:
147
+ return 130
148
+ return 0
149
+
150
+
151
+ if __name__ == "__main__":
152
+ sys.exit(main())
@@ -0,0 +1,118 @@
1
+ """Proxy configuration: credentials, upstream endpoint and the tool allowlist."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass, field
8
+
9
+ REGION_ENDPOINTS: dict[str, str] = {
10
+ "global": "https://mcp.qoder.com/api/v1/mcp/servers/qca",
11
+ "cn": "https://mcp.qoder.cn/api/v1/mcp/servers/qca",
12
+ }
13
+
14
+ # Everything the proxy is allowed to expose. Anything else on the QCA server
15
+ # (agent/environment/vault writes, skills, deployments, memory stores, session
16
+ # resources, ...) stays invisible: it is neither listed nor callable.
17
+ DEFAULT_ALLOWED_TOOLS: tuple[str, ...] = (
18
+ # session lifecycle
19
+ "create_session",
20
+ "get_session",
21
+ "list_sessions",
22
+ "update_session",
23
+ "cancel_session",
24
+ # session runtime
25
+ "send_session_events",
26
+ "list_session_event_summaries",
27
+ "list_session_events",
28
+ # read-only lookups needed to feed the calls above
29
+ "list_agents",
30
+ "list_environments",
31
+ "list_vaults",
32
+ # files delivered by or attached to a session: metadata only, the upstream
33
+ # MCP has no download tool (GET /files/{id}/content is HTTP-only)
34
+ "list_files",
35
+ "get_file",
36
+ )
37
+
38
+ _TOKEN_ENV_VARS: tuple[str, ...] = ("QCA_PAT", "QODER_PAT", "QCA_TOKEN")
39
+
40
+
41
+ class ConfigError(RuntimeError):
42
+ """Raised when the environment does not describe a usable proxy."""
43
+
44
+
45
+ def _split_list(raw: str) -> list[str]:
46
+ return [item.strip() for item in raw.replace("\n", ",").split(",") if item.strip()]
47
+
48
+
49
+ def _clean_token(raw: str) -> str:
50
+ token = raw.strip()
51
+ if token.lower().startswith("bearer "):
52
+ token = token[7:].strip()
53
+ return token
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Config:
58
+ token: str
59
+ endpoint: str
60
+ region: str
61
+ allowed_tools: frozenset[str] = field(default_factory=lambda: frozenset(DEFAULT_ALLOWED_TOOLS))
62
+ connect_timeout: float = 30.0
63
+ read_timeout: float = 300.0
64
+ tools_ttl: float = 300.0
65
+ debug: bool = False
66
+ # when set, the HTTP transport requires `Authorization: Bearer <http_token>`
67
+ http_token: str | None = None
68
+
69
+ @property
70
+ def authorization(self) -> str:
71
+ return f"Bearer {self.token}"
72
+
73
+
74
+ def load_config(env: Mapping[str, str] | None = None) -> Config:
75
+ source = os.environ if env is None else env
76
+
77
+ token = ""
78
+ for name in _TOKEN_ENV_VARS:
79
+ raw = source.get(name, "")
80
+ if raw.strip():
81
+ token = _clean_token(raw)
82
+ break
83
+ if not token:
84
+ raise ConfigError(
85
+ "missing QCA PAT: set QCA_PAT (or QODER_PAT) to a personal access token "
86
+ "issued at https://qoder.com.cn/cloud/pat-keys (CN) or "
87
+ "https://qoder.com/cloud/pat-keys (Global)"
88
+ )
89
+
90
+ region = source.get("QCA_REGION", "cn").strip().lower() or "cn"
91
+ if region not in REGION_ENDPOINTS:
92
+ raise ConfigError(f"unknown QCA_REGION {region!r}, expected one of {sorted(REGION_ENDPOINTS)}")
93
+
94
+ endpoint = source.get("QCA_MCP_URL", "").strip() or REGION_ENDPOINTS[region]
95
+ if not endpoint.startswith(("http://", "https://")):
96
+ raise ConfigError(f"QCA_MCP_URL must be an http(s) URL, got {endpoint!r}")
97
+
98
+ if "QCA_ALLOWED_TOOLS" in source:
99
+ allowed = frozenset(_split_list(source["QCA_ALLOWED_TOOLS"]))
100
+ if not allowed:
101
+ raise ConfigError("QCA_ALLOWED_TOOLS is set but empty")
102
+ else:
103
+ allowed = frozenset(DEFAULT_ALLOWED_TOOLS)
104
+ extra = frozenset(_split_list(source.get("QCA_EXTRA_TOOLS", "")))
105
+ if extra:
106
+ allowed |= extra
107
+
108
+ return Config(
109
+ token=token,
110
+ endpoint=endpoint,
111
+ region=region,
112
+ allowed_tools=allowed,
113
+ connect_timeout=float(source.get("QCA_CONNECT_TIMEOUT", 30)),
114
+ read_timeout=float(source.get("QCA_READ_TIMEOUT", 300)),
115
+ tools_ttl=float(source.get("QCA_TOOLS_TTL", 300)),
116
+ debug=source.get("QCA_DEBUG", "").strip().lower() in {"1", "true", "yes", "on"},
117
+ http_token=source.get("QCA_HTTP_TOKEN", "").strip() or None,
118
+ )
@@ -0,0 +1,165 @@
1
+ """The downstream MCP server: exposes only the allowlisted QCA tools.
2
+
3
+ Served either over stdio (``build_server``) or as a URL over Streamable HTTP
4
+ (``build_app``, endpoint ``/mcp``).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from collections.abc import AsyncIterator
11
+ from contextlib import asynccontextmanager
12
+
13
+ from mcp.server import Server, ServerRequestContext
14
+ from mcp.types import (
15
+ CallToolRequestParams,
16
+ CallToolResult,
17
+ ListToolsResult,
18
+ PaginatedRequestParams,
19
+ TextContent,
20
+ )
21
+
22
+ from . import __version__
23
+ from .config import Config
24
+ from .upstream import Upstream, UpstreamUnavailable
25
+
26
+ logger = logging.getLogger("qca_mcp_proxy.server")
27
+
28
+ MCP_PATH = "/mcp"
29
+
30
+ INSTRUCTIONS = (
31
+ "Qoder Cloud Agents (QCA) through a filtered proxy. Session lifecycle "
32
+ "(create_session, get_session, list_sessions, update_session, cancel_session), "
33
+ "session events (send_session_events, list_session_event_summaries, "
34
+ "list_session_events), read-only list_agents / list_environments / list_vaults, "
35
+ "and file metadata via list_files / get_file. Call list_agents and "
36
+ "list_environments before create_session, attach any files, repositories or "
37
+ "memory stores in the same create_session call, then send a user.message with "
38
+ "send_session_events. Poll progress with list_session_event_summaries first - "
39
+ "it omits payloads - and only call list_session_events with a narrow types "
40
+ "filter and a small limit when you need the content. Files a session produced "
41
+ "arrive as agent.artifact_delivered events; pass the session id as scope_id to "
42
+ "list_files. Nothing else is exposed: no agent/environment/vault writes, no "
43
+ "session resources, no deployments, and no file download - use the QCA HTTP "
44
+ "API GET /files/{id}/content for that."
45
+ )
46
+
47
+
48
+ def _error(message: str) -> CallToolResult:
49
+ return CallToolResult(content=[TextContent(type="text", text=message)], is_error=True)
50
+
51
+
52
+ def explain(exc: BaseException, config: Config) -> str:
53
+ """Append a hint about the region when the upstream rejected the request."""
54
+ text = str(exc)
55
+ lowered = text.lower()
56
+ rejected = (
57
+ "401" in text
58
+ or "403" in text
59
+ or "unauthorized" in lowered
60
+ or "forbidden" in lowered
61
+ or "error response" in lowered # QCA answers a bad token with a bare -32603
62
+ )
63
+ if rejected:
64
+ other = "global" if config.region == "cn" else "cn"
65
+ text += (
66
+ f"\n\nThe {config.region} endpoint ({config.endpoint}) rejected the request. "
67
+ "The usual causes are an invalid or expired PAT, or a token issued for the other "
68
+ f"region - restart the proxy with QCA_REGION={other} to try that one."
69
+ )
70
+ return text
71
+
72
+
73
+ def build_server(config: Config) -> Server:
74
+ upstream = Upstream(config)
75
+ available = ", ".join(sorted(config.allowed_tools))
76
+
77
+ @asynccontextmanager
78
+ async def lifespan(_server: Server) -> AsyncIterator[None]:
79
+ try:
80
+ await upstream.open()
81
+ except UpstreamUnavailable as exc:
82
+ raise RuntimeError(f"cannot reach QCA: {explain(exc, config)}") from exc
83
+ logger.info(
84
+ "qca proxy ready: region=%s endpoint=%s exposed=%d tools",
85
+ config.region,
86
+ config.endpoint,
87
+ len(config.allowed_tools),
88
+ )
89
+ try:
90
+ yield None
91
+ finally:
92
+ await upstream.aclose()
93
+
94
+ async def on_list_tools(
95
+ ctx: ServerRequestContext, params: PaginatedRequestParams | None
96
+ ) -> ListToolsResult:
97
+ try:
98
+ tools = await upstream.list_tools()
99
+ except UpstreamUnavailable as exc:
100
+ raise RuntimeError(f"cannot list QCA tools: {explain(exc, config)}") from exc
101
+ return ListToolsResult(tools=tools)
102
+
103
+ async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
104
+ if params.name not in config.allowed_tools:
105
+ return _error(f"tool {params.name!r} is not exposed by this proxy. Available: {available}")
106
+ try:
107
+ return await upstream.call_tool(params.name, params.arguments or {})
108
+ except Exception as exc:
109
+ logger.warning("upstream call %s failed: %s", params.name, exc)
110
+ return _error(f"{params.name} failed. {explain(exc, config)}")
111
+
112
+ return Server(
113
+ "qca-proxy",
114
+ version=__version__,
115
+ instructions=INSTRUCTIONS,
116
+ lifespan=lifespan,
117
+ on_list_tools=on_list_tools,
118
+ on_call_tool=on_call_tool,
119
+ )
120
+
121
+
122
+ class _BearerGuard:
123
+ """ASGI middleware requiring `Authorization: Bearer <token>` on HTTP requests."""
124
+
125
+ def __init__(self, app: object, token: str) -> None:
126
+ self._app = app
127
+ self._expected = f"Bearer {token}"
128
+
129
+ async def __call__(self, scope: dict, receive: object, send: object) -> None:
130
+ if scope["type"] in {"http", "websocket"}:
131
+ headers = {key.decode("latin-1").lower(): value.decode("latin-1") for key, value in scope["headers"]}
132
+ if headers.get("authorization", "") != self._expected:
133
+ body = b'{"error":"unauthorized"}'
134
+ await send(
135
+ {
136
+ "type": "http.response.start",
137
+ "status": 401,
138
+ "headers": [
139
+ (b"content-type", b"application/json"),
140
+ (b"content-length", str(len(body)).encode()),
141
+ ],
142
+ }
143
+ )
144
+ await send({"type": "http.response.body", "body": body})
145
+ return
146
+ await self._app(scope, receive, send) # type: ignore[operator]
147
+
148
+
149
+ def build_app(config: Config, *, host: str = "127.0.0.1", stateless: bool = False):
150
+ """ASGI app serving the proxy over Streamable HTTP at ``/mcp``.
151
+
152
+ ``host`` is the bind address: the SDK only enables DNS rebinding protection
153
+ for loopback, so a non-loopback host is served as-is. ``stateless`` drops the
154
+ per-client session id, which some agents that open a fresh request per call
155
+ need; the upstream connection is shared either way.
156
+ """
157
+ app = build_server(config).streamable_http_app(
158
+ streamable_http_path=MCP_PATH,
159
+ host=host,
160
+ stateless_http=stateless,
161
+ debug=config.debug,
162
+ )
163
+ if config.http_token:
164
+ return _BearerGuard(app, config.http_token)
165
+ return app
@@ -0,0 +1,128 @@
1
+ """Connection to the upstream QCA MCP server, with the allowlist applied on read."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from contextlib import AsyncExitStack
8
+
9
+ import httpx2
10
+ from mcp import Client
11
+ from mcp.client.streamable_http import streamable_http_client
12
+ from mcp.shared.exceptions import MCPError
13
+ from mcp.types import CallToolResult, Tool
14
+
15
+ from .config import Config
16
+
17
+ logger = logging.getLogger("qca_mcp_proxy.upstream")
18
+
19
+
20
+ class UpstreamUnavailable(RuntimeError):
21
+ """The upstream QCA MCP server could not be reached or authenticated."""
22
+
23
+
24
+ def describe(exc: BaseException) -> str:
25
+ """Flatten a (possibly nested) exception group into one readable line."""
26
+ if isinstance(exc, BaseExceptionGroup):
27
+ parts = [describe(inner) for inner in exc.exceptions]
28
+ unique = list(dict.fromkeys(part for part in parts if part))
29
+ return "; ".join(unique) or type(exc).__name__
30
+ if isinstance(exc, MCPError):
31
+ detail = f"MCPError({exc.code}): {exc.message}"
32
+ return f"{detail} data={exc.data}" if exc.data else detail
33
+ text = str(exc).strip()
34
+ return f"{type(exc).__name__}: {text}" if text else type(exc).__name__
35
+
36
+
37
+ class Upstream:
38
+ """One long-lived Streamable HTTP session to the remote QCA server.
39
+
40
+ The connection must be opened from the server lifespan task, not from a
41
+ request handler: the MCP client owns a task group, and leaving one open
42
+ across a handler boundary corrupts the request task's cancel scope stack.
43
+ """
44
+
45
+ def __init__(self, config: Config) -> None:
46
+ self._config = config
47
+ self._stack: AsyncExitStack | None = None
48
+ self._client: Client | None = None
49
+ self._tools: list[Tool] = []
50
+ self._tools_at = 0.0
51
+
52
+ async def open(self) -> None:
53
+ if self._client is not None:
54
+ return
55
+ stack = AsyncExitStack()
56
+ await stack.__aenter__()
57
+ try:
58
+ http_client = await stack.enter_async_context(
59
+ httpx2.AsyncClient(
60
+ headers={
61
+ "Authorization": self._config.authorization,
62
+ "Accept": "application/json, text/event-stream",
63
+ },
64
+ timeout=httpx2.Timeout(self._config.connect_timeout, read=self._config.read_timeout),
65
+ )
66
+ )
67
+ client = await stack.enter_async_context(
68
+ Client(streamable_http_client(self._config.endpoint, http_client=http_client))
69
+ )
70
+ except BaseException as exc:
71
+ await stack.aclose()
72
+ if isinstance(exc, Exception):
73
+ raise UpstreamUnavailable(describe(exc)) from exc
74
+ raise
75
+ self._stack, self._client = stack, client
76
+ logger.info("connected to %s (%s region)", self._config.endpoint, self._config.region)
77
+
78
+ async def aclose(self) -> None:
79
+ stack, self._stack, self._client = self._stack, None, None
80
+ self._tools, self._tools_at = [], 0.0
81
+ if stack is None:
82
+ return
83
+ try:
84
+ await stack.aclose()
85
+ except Exception: # teardown of a broken connection is best effort
86
+ logger.debug("error while closing upstream connection", exc_info=True)
87
+
88
+ def _require_client(self) -> Client:
89
+ if self._client is None:
90
+ raise UpstreamUnavailable("upstream connection is not open")
91
+ return self._client
92
+
93
+ async def _fetch_tools(self) -> list[Tool]:
94
+ client = self._require_client()
95
+ tools: list[Tool] = []
96
+ cursor: str | None = None
97
+ while True:
98
+ page = await client.list_tools(cursor=cursor)
99
+ tools.extend(page.tools)
100
+ if page.next_cursor is None:
101
+ return tools
102
+ cursor = page.next_cursor
103
+
104
+ async def list_all_tools(self) -> list[Tool]:
105
+ """Return every tool the upstream advertises, allowlist ignored."""
106
+ try:
107
+ return await self._fetch_tools()
108
+ except Exception as exc:
109
+ raise UpstreamUnavailable(describe(exc)) from exc
110
+
111
+ async def list_tools(self, *, refresh: bool = False) -> list[Tool]:
112
+ """Return the exposed subset of the upstream tool list."""
113
+ fresh = self._tools_at > 0 and (time.monotonic() - self._tools_at) < self._config.tools_ttl
114
+ if fresh and not refresh:
115
+ return self._tools
116
+ tools = await self.list_all_tools()
117
+ exposed = [tool for tool in tools if tool.name in self._config.allowed_tools]
118
+ logger.info(
119
+ "upstream exposes %d tools, %d proxied, %d hidden",
120
+ len(tools),
121
+ len(exposed),
122
+ len(tools) - len(exposed),
123
+ )
124
+ self._tools, self._tools_at = exposed, time.monotonic()
125
+ return exposed
126
+
127
+ async def call_tool(self, name: str, arguments: dict[str, object]) -> CallToolResult:
128
+ return await self._require_client().call_tool(name, arguments)
@@ -0,0 +1,203 @@
1
+ Metadata-Version: 2.5
2
+ Name: qca-mcp-proxy
3
+ Version: 0.1.0
4
+ Summary: Filtered MCP proxy in front of the Qoder Cloud Agents (QCA) remote MCP server, over stdio or Streamable HTTP
5
+ Project-URL: Homepage, https://github.com/xiaochus/qca_mcp_proxy
6
+ Project-URL: Repository, https://github.com/xiaochus/qca_mcp_proxy
7
+ Project-URL: Issues, https://github.com/xiaochus/qca_mcp_proxy/issues
8
+ Author-email: Larry <xiaochus@live.cn>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cloud-agents,mcp,model-context-protocol,proxy,qoder,stdio
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Internet :: Proxy Servers
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: mcp<3,>=2.0
23
+ Requires-Dist: uvicorn>=0.31
24
+ Description-Content-Type: text/markdown
25
+
26
+ # qca-mcp-proxy
27
+
28
+ 把 Qoder Cloud Agents(QCA)的远程 MCP 服务包一层 MCP 服务:只转发 **session 相关工具**,外加 `list_agents` / `list_environments` / `list_vaults` 三个只读查询和 `list_files` / `get_file` 两个文件元数据查询。其余工具(agent / environment / vault / skill / deployment 的写操作、session 资源等)既不列出也不可调用。
29
+
30
+ - 上游:QCA 远程 MCP,Streamable HTTP + `Authorization: Bearer <PAT>`
31
+ - 下游:两种跑法,同一套白名单
32
+ - `--transport stdio`(默认):给 Qoder / Claude Desktop 这类拉起子进程的客户端
33
+ - `--transport http`:起一个 `http://127.0.0.1:8790/mcp` 的服务地址,客户端填 URL 即可
34
+ - 实现用 MCP Python SDK v2(`mcp>=2,<3`)
35
+ - 工具列表每次 5 分钟内缓存一次,避免每次 `tools/list` 都打上游
36
+
37
+ ## 暴露的工具(13 个)
38
+
39
+ | 分组 | 工具 |
40
+ | --- | --- |
41
+ | session 生命周期 | `create_session` `get_session` `list_sessions` `update_session` `cancel_session` |
42
+ | session 事件 | `send_session_events` `list_session_event_summaries` `list_session_events` |
43
+ | 只读查询 | `list_agents` `list_environments` `list_vaults` |
44
+ | 文件元数据 | `list_files`(传 `scope_id=<session_id>` 查会话产出)`get_file` |
45
+
46
+ 工具名与参数 schema 全部原样透传上游,不做改写;返回的 `content` / `structured_content` / `is_error` 也原样返回。
47
+
48
+ 刻意不在白名单里(`tools/list` 不返回,`tools/call` 也拒绝):
49
+
50
+ - session 资源四件套 `add_session_resource` / `get_session_resource` / `list_session_resources` / `delete_session_resource`:文件、GitHub 仓库、memory store 只能在 `create_session` 的 `resources` 里一次性挂上,会话中途不能再挂
51
+ - `archive_session` / `delete_session`:只能 `cancel_session` 停掉会话,清理交给控制台或 `QCA_EXTRA_TOOLS` 追加
52
+ - agent / environment / vault / skill / deployment 的写操作全部隐藏,只留三个 `list_*`
53
+
54
+ ## 安装
55
+
56
+ ```bash
57
+ # 1) 直接跑源码(推荐本地开发)
58
+ uvx --from /Users/wbc/Desktop/ascend_adapter/qca-mcp-proxy qca-mcp-proxy --check
59
+
60
+ # 2) 从 git 安装
61
+ uvx --from git+https://github.com/<you>/qca-mcp-proxy qca-mcp-proxy
62
+
63
+ # 3) 装成常驻命令行
64
+ uv tool install /Users/wbc/Desktop/ascend_adapter/qca-mcp-proxy
65
+ qca-mcp-proxy --check
66
+ ```
67
+
68
+ `--check` 会用当前 PAT 连一次上游,打印「上游工具数 / 已暴露 / 被隐藏」后退出,用来确认 PAT 和区域对不对。
69
+
70
+ ## 以服务地址跑(Streamable HTTP)
71
+
72
+ ```bash
73
+ cd /Users/wbc/Desktop/ascend_adapter/qca-mcp-proxy
74
+ export QCA_PAT=<你的 PAT>
75
+ export QCA_REGION=cn
76
+
77
+ # 起服务(默认监听 127.0.0.1:8790)
78
+ .venv/bin/qca-mcp-proxy --transport http
79
+ # 或者: uvx --from . qca-mcp-proxy --transport http
80
+ ```
81
+
82
+ 启动成功会打印:
83
+
84
+ ```
85
+ [qca-proxy] INFO qca_mcp_proxy.server: serving Streamable HTTP on http://127.0.0.1:8790/mcp
86
+ [qca-proxy] INFO qca_mcp_proxy.upstream: connected to https://mcp.qoder.cn/... (cn region)
87
+ [qca-proxy] INFO qca_mcp_proxy.server: qca proxy ready: region=cn ...
88
+ ```
89
+
90
+ 然后在 agent 里把服务地址填成 `http://127.0.0.1:8790/mcp`;如果客户端用 `mcpServers` 配置,写:
91
+
92
+ ```json
93
+ {"mcpServers": {"qca": {"url": "http://127.0.0.1:8790/mcp"}}}
94
+ ```
95
+
96
+ 相关开关:
97
+
98
+ | 参数 | 默认 | 说明 |
99
+ | --- | --- | --- |
100
+ | `--host` | `127.0.0.1` | 只给本机用就保持默认;要同网段访问改 `0.0.0.0`(SDK 只对回环地址做 DNS rebinding 防护,改后请自行加 `--auth-token`) |
101
+ | `--port` | `8790` | 监听端口,端口冲突时换一个 |
102
+ | `--stateless` | 关 | 不带 `mcp-session-id` 的无状态模式。有些 agent 每个请求都新开连接,用它会报 “session not found”,这时加这个参数 |
103
+ | `--auth-token` | 空 | 要求请求带 `Authorization: Bearer <token>`,也可用 `QCA_HTTP_TOKEN`。只在本机测可以不加 |
104
+
105
+ 启动前会先探一次上游:PAT 不对或区域不匹配时直接一行报错退出,不会起一个连不上上游的空服务:
106
+
107
+ ```
108
+ [qca-proxy] cannot reach QCA: MCPError(-32603): Server returned an error response
109
+
110
+ The cn endpoint (https://mcp.qoder.cn/api/v1/mcp/servers/qca) rejected the request. ...
111
+ ```
112
+
113
+ ## 配置
114
+
115
+ ### Qoder / 兼容 `mcpServers` 的客户端
116
+
117
+ `args` 里的 `--from` 有两种来源,按安装方式二选一。
118
+
119
+ 从 git 安装(推荐,换机器不用改路径):
120
+
121
+ ```json
122
+ {
123
+ "mcpServers": {
124
+ "qca": {
125
+ "command": "uvx",
126
+ "args": ["--from", "git+https://github.com/xiaochus/qca_mcp_proxy", "qca-mcp-proxy"],
127
+ "env": {
128
+ "QCA_PAT": "<你的 PAT>",
129
+ "QCA_REGION": "cn"
130
+ }
131
+ }
132
+ }
133
+ }
134
+ ```
135
+
136
+ 从本机源码安装(改代码即时生效):
137
+
138
+ ```json
139
+ {
140
+ "mcpServers": {
141
+ "qca": {
142
+ "command": "uvx",
143
+ "args": ["--from", "/Users/wbc/Desktop/ascend_adapter/qca-mcp-proxy", "qca-mcp-proxy"],
144
+ "env": {
145
+ "QCA_PAT": "<你的 PAT>",
146
+ "QCA_REGION": "cn"
147
+ }
148
+ }
149
+ }
150
+ }
151
+ ```
152
+
153
+ 等价命令行(先在终端验证能不能起):
154
+
155
+ ```bash
156
+ uvx --from git+https://github.com/xiaochus/qca_mcp_proxy qca-mcp-proxy --check
157
+
158
+ # 固定到某个 commit / tag,避免每次拉到不一样的代码
159
+ uvx --from git+https://github.com/xiaochus/qca_mcp_proxy@v0.1.0 qca-mcp-proxy --check
160
+ ```
161
+
162
+ `uvx` 会把该仓库缓存到本地 `~/.cache/uv`,改了远端代码后需要 `--refresh` 或清缓存才会更新。
163
+
164
+ ### 环境变量
165
+
166
+ | 变量 | 必填 | 默认 | 说明 |
167
+ | --- | --- | --- | --- |
168
+ | `QCA_PAT` | 是 | – | 个人访问令牌;也接受 `QODER_PAT` / `QCA_TOKEN`,带不带 `Bearer ` 前缀都可以 |
169
+ | `QCA_REGION` | 否 | `cn` | `cn` → `https://mcp.qoder.cn/api/v1/mcp/servers/qca`;`global` → `https://mcp.qoder.com/api/v1/mcp/servers/qca` |
170
+ | `QCA_MCP_URL` | 否 | 由 region 决定 | 直接指定上游 MCP 地址,优先于 region |
171
+ | `QCA_EXTRA_TOOLS` | 否 | 空 | 在默认白名单上追加工具(逗号分隔),例如 `list_accessible_workspaces` |
172
+ | `QCA_ALLOWED_TOOLS` | 否 | 默认白名单 | **整体替换**默认白名单(逗号分隔) |
173
+ | `QCA_TOOLS_TTL` | 否 | `300` | 工具列表缓存秒数 |
174
+ | `QCA_CONNECT_TIMEOUT` | 否 | `30` | 连接上游超时(秒) |
175
+ | `QCA_READ_TIMEOUT` | 否 | `300` | 上游单次读取超时(秒) |
176
+ | `QCA_DEBUG` | 否 | 关 | `1` 打开 DEBUG 日志(含 SDK 与 HTTP 层) |
177
+ | `QCA_HTTP_TOKEN` | 否 | 空 | 仅 `--transport http` 生效:要求请求带 `Authorization: Bearer <值>` |
178
+
179
+ 常用扩展例子:
180
+
181
+ ```bash
182
+ # 需要工作区选择能力时
183
+ QCA_EXTRA_TOOLS=list_accessible_workspaces
184
+
185
+ # 只要读,不要写
186
+ QCA_ALLOWED_TOOLS=get_session,list_sessions,list_session_event_summaries,list_session_events,list_agents,list_environments,list_vaults,list_files,get_file
187
+ ```
188
+
189
+ ## 典型用法
190
+
191
+ 1. `list_agents` / `list_environments` / `list_vaults` 拿到 `agent_xxx` / `env_xxx` / `vault_xxx`
192
+ 2. `create_session` 传 `agent` + `environment_id`(需要凭证时再传 `vault_ids`,需要挂载文件/仓库/记忆库时传 `resources`)
193
+ 3. `send_session_events` 发一条 `user.message` 事件
194
+ 4. `list_session_event_summaries` 轮询进度(不带载荷,省 token),需要内容时再用 `list_session_events` 加 `types` 过滤、`limit` 调小
195
+ 5. 取产物:`list_files` 传 `scope_id=<session_id>` 拿到 `file_id`,再用 `get_file` 看元数据;下载走 HTTP `GET /files/{id}/content`(见上文)
196
+ 6. 结束:`cancel_session`(没有归档/删除权限)
197
+
198
+ ## 行为约定
199
+
200
+ - **启动即连上游**:生命周期里建立一条长连接,PAT 无效或区域不匹配时启动直接失败,stderr 会给出换区域的提示(日志只走 stderr,stdout 留给协议)。这样也避免了在请求处理任务里创建 MCP 客户端任务组导致的 cancel scope 破坏。
201
+ - **写操作不重试**:`create_session` / `send_session_events` 等调用失败不再自动重放,避免上游重复执行;失败以 `is_error=true` 的工具结果返回,模型可自行决定下一步。
202
+ - **未授权工具双重拦截**:`tools/list` 不返回,`tools/call` 也直接拒绝并给出可调用的工具名。
203
+ - **不暴露 resources / prompts**:上游只有 tools,本代理也只声明 tools 能力。
@@ -0,0 +1,10 @@
1
+ qca_mcp_proxy/__init__.py,sha256=Y1yEFo9iSLJ32K-XTR1aNpST6hwZrCbYX0mxLAKzdMQ,77
2
+ qca_mcp_proxy/__main__.py,sha256=PieqXx9T-teuUlH3iGZqoaJOF7tzBauN3VkTJJNGUO4,5129
3
+ qca_mcp_proxy/config.py,sha256=9DbSvKa2PhWxyxRCSXITSlsuPq4KuycE6AdM9CTX3vo,3988
4
+ qca_mcp_proxy/server.py,sha256=KX8OcpsDKH3kkFrYGZOj97Wm3rt_GkjzGJqRtGIvc7w,6444
5
+ qca_mcp_proxy/upstream.py,sha256=KNN6qOBp3jBE4yhy0_RKfu6NY2fKp0Xkh2EINGABxzU,4956
6
+ qca_mcp_proxy-0.1.0.dist-info/METADATA,sha256=4a2OcEz9Dr8-ONdLYS1IPuDwZPu425Qd-sVMGxEvqNo,9344
7
+ qca_mcp_proxy-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
8
+ qca_mcp_proxy-0.1.0.dist-info/entry_points.txt,sha256=NZl2P140ivwx7WqCi4LwbR-5vyDzOWT1lKOetDs1lEM,62
9
+ qca_mcp_proxy-0.1.0.dist-info/licenses/LICENSE,sha256=ZoG5VPXzt8SAEPlyFFln01G0Ofb2DrKpECHwDzFsSwI,1061
10
+ qca_mcp_proxy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ qca-mcp-proxy = qca_mcp_proxy.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Larry
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.