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.
- qca_mcp_proxy/__init__.py +3 -0
- qca_mcp_proxy/__main__.py +152 -0
- qca_mcp_proxy/config.py +118 -0
- qca_mcp_proxy/server.py +165 -0
- qca_mcp_proxy/upstream.py +128 -0
- qca_mcp_proxy-0.1.0.dist-info/METADATA +203 -0
- qca_mcp_proxy-0.1.0.dist-info/RECORD +10 -0
- qca_mcp_proxy-0.1.0.dist-info/WHEEL +4 -0
- qca_mcp_proxy-0.1.0.dist-info/entry_points.txt +2 -0
- qca_mcp_proxy-0.1.0.dist-info/licenses/LICENSE +21 -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())
|
qca_mcp_proxy/config.py
ADDED
|
@@ -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
|
+
)
|
qca_mcp_proxy/server.py
ADDED
|
@@ -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,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.
|