mirror of
https://github.com/SquidSec/SquidC5
synced 2026-08-09 12:22:26 +00:00
feat(mcp): connection payload for OpenCode/Grok + JSON-RPC /mcp
When minting/rolling an mcp:connect token, return multi-format MCP connection blob (OpenCode remote, Cursor mcpServers, CLI). Ops Admin banner adds Copy MCP payload. POST /mcp speaks JSON-RPC tools/list|call for remote LLM clients.
This commit is contained in:
+28
-3
@@ -1279,9 +1279,14 @@ External models must not get open-ended shell on the C2:
|
||||
|
||||
### How
|
||||
|
||||
1. Enable MCP via features when approved.
|
||||
2. Mint a token with `mcp:connect` and a tight `mcp_tools` list.
|
||||
3. **List tools** / **Call** with JSON args from Ops or CLI.
|
||||
1. Enable MCP: `SQUIDC5_MCP_ENABLED=true` **and** Admin → Features → `mcp_enabled`.
|
||||
2. Mint a token with `mcp:connect` and a tight `mcp_tools` list (Ops **Admin → Tokens**, or CLI).
|
||||
3. On mint/roll, Ops shows a green banner with:
|
||||
- **Token** secret (once)
|
||||
- **Ops connection link** (`/ops#sc5=…`)
|
||||
- **MCP connection payload** — OpenCode / Grok-ready JSON (and Cursor / CLI copies)
|
||||
4. Paste the MCP payload into OpenCode `opencode.json` (merge under `mcp`), or Cursor MCP settings.
|
||||
5. Tools also work via REST: `GET /mcp/tools`, `POST /mcp/call`, and JSON-RPC `POST /mcp`.
|
||||
|
||||
### Example
|
||||
|
||||
@@ -1289,10 +1294,30 @@ External models must not get open-ended shell on the C2:
|
||||
sc5 tokens create ext-ai \
|
||||
--scopes "mcp:connect,sessions:read,tasks:read,tasks:write,metrics:read" \
|
||||
--mcp-tools "list_sessions,get_session,list_tasks,create_task,get_metrics"
|
||||
# stderr prints OpenCode MCP JSON payload; stdout has full API JSON including mcp_connection
|
||||
sc5 mcp tools
|
||||
sc5 mcp call list_sessions --args-json '{}'
|
||||
```
|
||||
|
||||
OpenCode snippet shape (auto-generated on mint):
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"squidc5-ext-ai": {
|
||||
"type": "remote",
|
||||
"url": "https://C2:8443/mcp",
|
||||
"enabled": true,
|
||||
"oauth": false,
|
||||
"headers": {
|
||||
"Authorization": "Bearer sc5_…"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Contrast: MCP vs INKO / Admin AI
|
||||
|
||||
| | INKO / Admin AI | MCP |
|
||||
|
||||
@@ -579,7 +579,31 @@ def build_api_router() -> APIRouter:
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return {"id": tid, "token": raw, "name": body.name, "scopes": body.scopes}
|
||||
out: dict[str, Any] = {
|
||||
"id": tid,
|
||||
"token": raw,
|
||||
"name": body.name,
|
||||
"scopes": body.scopes,
|
||||
}
|
||||
# Include MCP tools actually stored (defaults may apply)
|
||||
try:
|
||||
raw_row = await state.db.get_token_by_id(tid)
|
||||
row = state.tokens.parse_row(raw_row) if raw_row else {}
|
||||
out["mcp_tools"] = row.get("mcp_tools") or body.mcp_tools or []
|
||||
except Exception:
|
||||
out["mcp_tools"] = body.mcp_tools or []
|
||||
if "mcp:connect" in (body.scopes or []) or "admin" in (body.scopes or []):
|
||||
from squidc5.mcp.connection import build_mcp_connection
|
||||
|
||||
out["mcp_connection"] = build_mcp_connection(
|
||||
api_base=_ops_base_url(request),
|
||||
token=raw,
|
||||
name=body.name,
|
||||
scopes=list(body.scopes or []),
|
||||
mcp_tools=list(out.get("mcp_tools") or []),
|
||||
token_id=tid,
|
||||
)
|
||||
return out
|
||||
|
||||
@api.get("/tokens")
|
||||
async def list_tokens(
|
||||
@@ -623,7 +647,7 @@ def build_api_router() -> APIRouter:
|
||||
raise HTTPException(404, "token not found") from e
|
||||
except PermissionError as e:
|
||||
raise HTTPException(403, str(e)) from e
|
||||
return {
|
||||
out_roll: dict[str, Any] = {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"scopes": row["scopes"],
|
||||
@@ -631,6 +655,19 @@ def build_api_router() -> APIRouter:
|
||||
"token": raw,
|
||||
"rolled": True,
|
||||
}
|
||||
scopes_r = list(row.get("scopes") or [])
|
||||
if "mcp:connect" in scopes_r or "admin" in scopes_r:
|
||||
from squidc5.mcp.connection import build_mcp_connection
|
||||
|
||||
out_roll["mcp_connection"] = build_mcp_connection(
|
||||
api_base=_ops_base_url(request),
|
||||
token=raw,
|
||||
name=str(row.get("name") or "squidc5"),
|
||||
scopes=scopes_r,
|
||||
mcp_tools=list(row.get("mcp_tools") or []),
|
||||
token_id=str(row["id"]),
|
||||
)
|
||||
return out_roll
|
||||
|
||||
@api.post("/tokens/{token_id}/connection-link")
|
||||
async def create_connection_link(
|
||||
|
||||
+9
-1
@@ -702,7 +702,15 @@ def cmd_tokens_create(args: argparse.Namespace, client: Client) -> None:
|
||||
body: dict[str, Any] = {"name": args.name, "scopes": scopes}
|
||||
if args.mcp_tools:
|
||||
body["mcp_tools"] = [t.strip() for t in args.mcp_tools.split(",") if t.strip()]
|
||||
pp(client.post("/api/v1/tokens", json=body))
|
||||
r = client.post("/api/v1/tokens", json=body)
|
||||
pp(r)
|
||||
mcp = r.get("mcp_connection") if isinstance(r, dict) else None
|
||||
if isinstance(mcp, dict) and mcp.get("copy_text"):
|
||||
print("\n--- MCP connection payload (OpenCode / Grok) ---", file=sys.stderr)
|
||||
print(mcp["copy_text"])
|
||||
print("--- end payload ---", file=sys.stderr)
|
||||
if mcp.get("cli"):
|
||||
print(f"\nCLI: {mcp['cli']}", file=sys.stderr)
|
||||
|
||||
|
||||
def cmd_tokens_revoke(args: argparse.Namespace, client: Client) -> None:
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Build paste-ready MCP connection payloads for external LLM tools.
|
||||
|
||||
Formats: OpenCode remote MCP, Cursor mcpServers, generic JSON, sc5 CLI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def normalize_api_base(url: str) -> str:
|
||||
u = (url or "").strip().rstrip("/")
|
||||
if not u:
|
||||
return ""
|
||||
# Drop trailing /api/v1 if present so we can rebuild paths cleanly
|
||||
if u.endswith("/api/v1"):
|
||||
u = u[: -len("/api/v1")]
|
||||
return u.rstrip("/")
|
||||
|
||||
|
||||
def mcp_endpoint(api_base: str) -> str:
|
||||
base = normalize_api_base(api_base)
|
||||
return f"{base}/mcp" if base else "/mcp"
|
||||
|
||||
|
||||
def build_mcp_connection(
|
||||
*,
|
||||
api_base: str,
|
||||
token: str,
|
||||
name: str = "squidc5",
|
||||
scopes: list[str] | None = None,
|
||||
mcp_tools: list[str] | None = None,
|
||||
token_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return multi-format connection blob for OpenCode / Grok / Cursor / CLI."""
|
||||
base = normalize_api_base(api_base)
|
||||
mcp_url = mcp_endpoint(base)
|
||||
server_key = _server_key(name)
|
||||
scopes = list(scopes or [])
|
||||
tools = list(mcp_tools or [])
|
||||
|
||||
opencode = {
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
server_key: {
|
||||
"type": "remote",
|
||||
"url": mcp_url,
|
||||
"enabled": True,
|
||||
"oauth": False,
|
||||
"headers": {
|
||||
"Authorization": f"Bearer {token}",
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
# Cursor / VS Code style (also used by several clients)
|
||||
cursor = {
|
||||
"mcpServers": {
|
||||
server_key: {
|
||||
"url": mcp_url,
|
||||
"headers": {
|
||||
"Authorization": f"Bearer {token}",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Generic single-server descriptor (Grok Build / custom)
|
||||
generic = {
|
||||
"name": server_key,
|
||||
"type": "remote",
|
||||
"url": mcp_url,
|
||||
"api_base": base,
|
||||
"token": token,
|
||||
"headers": {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-API-Token": token,
|
||||
},
|
||||
"transport": "http-jsonrpc",
|
||||
"endpoints": {
|
||||
"jsonrpc": mcp_url,
|
||||
"tools_rest": f"{base}/mcp/tools",
|
||||
"call_rest": f"{base}/mcp/call",
|
||||
},
|
||||
"scopes": scopes,
|
||||
"mcp_tools": tools,
|
||||
"token_id": token_id,
|
||||
"notes": [
|
||||
"Authorized SquidC5 MCP only — least-privilege token",
|
||||
"Requires SQUIDC5_MCP_ENABLED=true and feature mcp_enabled",
|
||||
"Paste opencode block into opencode.json / opencode.jsonc under mcp",
|
||||
"Self-signed TLS: trust the teamserver CA or use a public cert",
|
||||
],
|
||||
}
|
||||
|
||||
# Prefer OpenCode snippet as primary copy text (most common paste target)
|
||||
copy_text = json.dumps(opencode, indent=2)
|
||||
|
||||
# Also a one-line sc5 login for operators
|
||||
cli = f'sc5 login --url {base} --token {token}'
|
||||
if base.startswith("https://") and _looks_local_or_ip(base):
|
||||
cli += " --insecure"
|
||||
|
||||
return {
|
||||
"mcp_url": mcp_url,
|
||||
"api_base": base,
|
||||
"server_name": server_key,
|
||||
"opencode": opencode,
|
||||
"cursor": cursor,
|
||||
"generic": generic,
|
||||
"cli": cli,
|
||||
"copy_text": copy_text,
|
||||
"formats": {
|
||||
"opencode_json": copy_text,
|
||||
"cursor_json": json.dumps(cursor, indent=2),
|
||||
"generic_json": json.dumps(generic, indent=2),
|
||||
"cli": cli,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _server_key(name: str) -> str:
|
||||
raw = (name or "squidc5").strip().lower()
|
||||
out = []
|
||||
for ch in raw:
|
||||
if ch.isalnum() or ch in ("-", "_"):
|
||||
out.append(ch)
|
||||
elif ch in (" ", ".", "/"):
|
||||
out.append("-")
|
||||
key = "".join(out).strip("-_") or "squidc5"
|
||||
if not key.startswith("squid") and "mcp" not in key:
|
||||
key = f"squidc5-{key}"
|
||||
return key[:48]
|
||||
|
||||
|
||||
def _looks_local_or_ip(url: str) -> bool:
|
||||
try:
|
||||
host = (urlparse(url).hostname or "").lower()
|
||||
except Exception:
|
||||
return False
|
||||
if host in ("localhost", "127.0.0.1", "::1"):
|
||||
return True
|
||||
parts = host.split(".")
|
||||
if len(parts) == 4 and all(p.isdigit() for p in parts):
|
||||
return True
|
||||
return False
|
||||
+199
-62
@@ -140,68 +140,7 @@ def build_mcp_router() -> APIRouter:
|
||||
auth: AuthContext = Depends(_auth),
|
||||
) -> MCPToolResult:
|
||||
state = _get_state(request)
|
||||
if hasattr(state, "features") and not await state.features.enabled("mcp_enabled"):
|
||||
return MCPToolResult(ok=False, tool=body.name, error="MCP disabled by feature flag")
|
||||
if not auth.can_mcp_tool(body.name):
|
||||
await state.db.audit(
|
||||
actor=auth.name,
|
||||
actor_type=auth.actor_type,
|
||||
action="mcp.call.denied",
|
||||
details={"tool": body.name},
|
||||
allowed=False,
|
||||
risk_score=6,
|
||||
)
|
||||
return MCPToolResult(ok=False, tool=body.name, error="Tool not allow-listed for this token")
|
||||
|
||||
if not _check_budget(auth.token_id):
|
||||
return MCPToolResult(ok=False, tool=body.name, error="MCP rate budget exceeded")
|
||||
|
||||
gate = _TOOL_GATES.get(body.name)
|
||||
if not gate:
|
||||
return MCPToolResult(ok=False, tool=body.name, error="Unknown tool")
|
||||
need_scopes, policy_action = gate
|
||||
if not any(auth.has_scope(s) for s in need_scopes) and not auth.has_scope("admin"):
|
||||
return MCPToolResult(
|
||||
ok=False, tool=body.name, error=f"Requires one of scopes: {need_scopes}"
|
||||
)
|
||||
|
||||
extra: dict[str, Any] = {
|
||||
"args_keys": list(body.arguments.keys()),
|
||||
"command": body.arguments.get("command"),
|
||||
"hitl_request_id": body.arguments.get("hitl_request_id"),
|
||||
}
|
||||
# X05: ignore client chain_length for autonomy (always treat as 1)
|
||||
decision = await state.policy.check_and_audit(
|
||||
auth,
|
||||
action=policy_action,
|
||||
resource=body.arguments.get("session_id"),
|
||||
extra=extra,
|
||||
)
|
||||
if not decision.allowed:
|
||||
return MCPToolResult(ok=False, tool=body.name, error=decision.reason)
|
||||
|
||||
handlers = _handlers(state, auth)
|
||||
handler = handlers.get(body.name)
|
||||
if not handler:
|
||||
return MCPToolResult(ok=False, tool=body.name, error="Unknown tool")
|
||||
|
||||
try:
|
||||
result = await handler(body.arguments)
|
||||
await state.metrics.incr("mcp.calls")
|
||||
await state.metrics.emit("mcp.call", {"tool": body.name, "actor": auth.name})
|
||||
return MCPToolResult(ok=True, tool=body.name, result=result)
|
||||
except PermissionError as exc:
|
||||
return MCPToolResult(ok=False, tool=body.name, error=str(exc))
|
||||
except Exception as exc:
|
||||
await state.db.audit(
|
||||
actor=auth.name,
|
||||
actor_type=auth.actor_type,
|
||||
action="mcp.call.error",
|
||||
details={"tool": body.name, "error": type(exc).__name__},
|
||||
allowed=False,
|
||||
risk_score=4,
|
||||
)
|
||||
return MCPToolResult(ok=False, tool=body.name, error="tool error")
|
||||
return await _dispatch_tool_call(state, auth, body.name, body.arguments)
|
||||
|
||||
@router.get("/health")
|
||||
async def mcp_health(request: Request) -> dict[str, str]:
|
||||
@@ -213,9 +152,207 @@ def build_mcp_router() -> APIRouter:
|
||||
raise HTTPException(404, "not found")
|
||||
raise HTTPException(401, "auth required")
|
||||
|
||||
@router.post("")
|
||||
@router.post("/")
|
||||
async def mcp_jsonrpc(
|
||||
request: Request,
|
||||
auth: AuthContext = Depends(_auth),
|
||||
) -> dict[str, Any]:
|
||||
"""MCP JSON-RPC over HTTP (OpenCode / Grok / Cursor remote MCP).
|
||||
|
||||
Supports initialize, notifications/initialized, tools/list, tools/call,
|
||||
ping. Same auth + allow-list + policy gates as /mcp/call.
|
||||
"""
|
||||
state = _get_state(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception as e:
|
||||
raise HTTPException(400, "invalid JSON body") from e
|
||||
|
||||
async def handle_one(msg: dict[str, Any]) -> dict[str, Any] | None:
|
||||
if not isinstance(msg, dict):
|
||||
return {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Invalid Request"}}
|
||||
mid = msg.get("id")
|
||||
method = str(msg.get("method") or "")
|
||||
params = msg.get("params") if isinstance(msg.get("params"), dict) else {}
|
||||
# Notifications (no id) — acknowledge with empty body via None
|
||||
is_notif = mid is None and method.startswith("notifications/")
|
||||
|
||||
if method in ("initialize",):
|
||||
try:
|
||||
from squidc5 import __version__ as _ver
|
||||
except Exception:
|
||||
_ver = "0.1.0"
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": mid,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {"listChanged": False}},
|
||||
"serverInfo": {
|
||||
"name": "squidc5",
|
||||
"version": str(_ver),
|
||||
},
|
||||
"instructions": (
|
||||
"SquidC5 external MCP — authorized red team only. "
|
||||
"Tools are allow-listed per token; chain length is server-enforced."
|
||||
),
|
||||
},
|
||||
}
|
||||
if method in ("notifications/initialized", "initialized"):
|
||||
return None if is_notif else {"jsonrpc": "2.0", "id": mid, "result": {}}
|
||||
if method in ("ping",):
|
||||
return {"jsonrpc": "2.0", "id": mid, "result": {}}
|
||||
if method in ("tools/list",):
|
||||
allowed = set(auth.mcp_tools) if "admin" not in auth.scopes else None
|
||||
tools = []
|
||||
for t in _tool_catalog():
|
||||
if allowed is not None and t["name"] not in allowed:
|
||||
continue
|
||||
if not auth.can_mcp_tool(t["name"]):
|
||||
continue
|
||||
tools.append(
|
||||
{
|
||||
"name": t["name"],
|
||||
"description": t.get("description") or t["name"],
|
||||
"inputSchema": t.get("inputSchema")
|
||||
or {"type": "object", "properties": {}},
|
||||
}
|
||||
)
|
||||
await state.db.audit(
|
||||
actor=auth.name,
|
||||
actor_type=auth.actor_type,
|
||||
action="mcp.jsonrpc.tools_list",
|
||||
details={"count": len(tools)},
|
||||
)
|
||||
return {"jsonrpc": "2.0", "id": mid, "result": {"tools": tools}}
|
||||
if method in ("tools/call",):
|
||||
name = str(params.get("name") or "")
|
||||
arguments = params.get("arguments") if isinstance(params.get("arguments"), dict) else {}
|
||||
result = await _dispatch_tool_call(state, auth, name, arguments)
|
||||
if not result.ok:
|
||||
# MCP tool errors are result content, not JSON-RPC errors
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": mid,
|
||||
"result": {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": result.error or "tool failed",
|
||||
}
|
||||
],
|
||||
"isError": True,
|
||||
},
|
||||
}
|
||||
text = result.result
|
||||
if not isinstance(text, str):
|
||||
import json as _json
|
||||
|
||||
try:
|
||||
text = _json.dumps(text, default=str, indent=2)
|
||||
except Exception:
|
||||
text = str(text)
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": mid,
|
||||
"result": {
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"isError": False,
|
||||
},
|
||||
}
|
||||
if is_notif:
|
||||
return None
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"id": mid,
|
||||
"error": {"code": -32601, "message": f"Method not found: {method}"},
|
||||
}
|
||||
|
||||
# Batch or single
|
||||
if isinstance(body, list):
|
||||
out: list[dict[str, Any]] = []
|
||||
for item in body:
|
||||
r = await handle_one(item if isinstance(item, dict) else {})
|
||||
if r is not None:
|
||||
out.append(r)
|
||||
return out # type: ignore[return-value]
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "JSON-RPC body must be object or array")
|
||||
single = await handle_one(body)
|
||||
if single is None:
|
||||
# Notification — empty 202-style body; FastAPI needs a body
|
||||
return {}
|
||||
return single
|
||||
|
||||
return router
|
||||
|
||||
|
||||
async def _dispatch_tool_call(
|
||||
state: AppState,
|
||||
auth: AuthContext,
|
||||
name: str,
|
||||
arguments: dict[str, Any],
|
||||
) -> MCPToolResult:
|
||||
"""Shared path for REST /mcp/call and JSON-RPC tools/call."""
|
||||
if hasattr(state, "features") and not await state.features.enabled("mcp_enabled"):
|
||||
return MCPToolResult(ok=False, tool=name, error="MCP disabled by feature flag")
|
||||
if not auth.can_mcp_tool(name):
|
||||
await state.db.audit(
|
||||
actor=auth.name,
|
||||
actor_type=auth.actor_type,
|
||||
action="mcp.call.denied",
|
||||
details={"tool": name},
|
||||
allowed=False,
|
||||
risk_score=6,
|
||||
)
|
||||
return MCPToolResult(ok=False, tool=name, error="Tool not allow-listed for this token")
|
||||
if not _check_budget(auth.token_id):
|
||||
return MCPToolResult(ok=False, tool=name, error="MCP rate budget exceeded")
|
||||
gate = _TOOL_GATES.get(name)
|
||||
if not gate:
|
||||
return MCPToolResult(ok=False, tool=name, error="Unknown tool")
|
||||
need_scopes, policy_action = gate
|
||||
if not any(auth.has_scope(s) for s in need_scopes) and not auth.has_scope("admin"):
|
||||
return MCPToolResult(
|
||||
ok=False, tool=name, error=f"Requires one of scopes: {need_scopes}"
|
||||
)
|
||||
extra: dict[str, Any] = {
|
||||
"args_keys": list(arguments.keys()),
|
||||
"command": arguments.get("command"),
|
||||
"hitl_request_id": arguments.get("hitl_request_id"),
|
||||
}
|
||||
decision = await state.policy.check_and_audit(
|
||||
auth,
|
||||
action=policy_action,
|
||||
resource=arguments.get("session_id"),
|
||||
extra=extra,
|
||||
)
|
||||
if not decision.allowed:
|
||||
return MCPToolResult(ok=False, tool=name, error=decision.reason)
|
||||
handlers = _handlers(state, auth)
|
||||
handler = handlers.get(name)
|
||||
if not handler:
|
||||
return MCPToolResult(ok=False, tool=name, error="Unknown tool")
|
||||
try:
|
||||
result = await handler(arguments)
|
||||
await state.metrics.incr("mcp.calls")
|
||||
await state.metrics.emit("mcp.call", {"tool": name, "actor": auth.name})
|
||||
return MCPToolResult(ok=True, tool=name, result=result)
|
||||
except PermissionError as exc:
|
||||
return MCPToolResult(ok=False, tool=name, error=str(exc))
|
||||
except Exception:
|
||||
await state.db.audit(
|
||||
actor=auth.name,
|
||||
actor_type=auth.actor_type,
|
||||
action="mcp.call.error",
|
||||
details={"tool": name, "error": "tool error"},
|
||||
allowed=False,
|
||||
risk_score=4,
|
||||
)
|
||||
return MCPToolResult(ok=False, tool=name, error="tool error")
|
||||
|
||||
|
||||
def _tool_catalog() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{"name": "list_sessions", "description": "List C2 sessions", "inputSchema": {"type": "object", "properties": {"status": {"type": "string"}}}},
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""MCP connection payload + JSON-RPC remote endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from conftest import bearer, mint_token
|
||||
|
||||
from squidc5.mcp.connection import build_mcp_connection, mcp_endpoint, normalize_api_base
|
||||
|
||||
|
||||
def test_normalize_api_base():
|
||||
assert normalize_api_base("https://c2.example:8443/") == "https://c2.example:8443"
|
||||
assert normalize_api_base("https://c2.example:8443/api/v1") == "https://c2.example:8443"
|
||||
assert mcp_endpoint("https://c2.example:8443") == "https://c2.example:8443/mcp"
|
||||
|
||||
|
||||
def test_build_mcp_connection_opencode_shape():
|
||||
p = build_mcp_connection(
|
||||
api_base="https://c2.example:8443",
|
||||
token="sc5_testtokenvalue",
|
||||
name="mcp-lab",
|
||||
scopes=["mcp:connect", "sessions:read"],
|
||||
mcp_tools=["list_sessions"],
|
||||
token_id="tok_abc",
|
||||
)
|
||||
assert p["mcp_url"] == "https://c2.example:8443/mcp"
|
||||
assert "sc5_testtokenvalue" in p["copy_text"]
|
||||
key = p["server_name"]
|
||||
assert key in p["opencode"]["mcp"]
|
||||
assert p["opencode"]["mcp"][key]["type"] == "remote"
|
||||
assert p["opencode"]["mcp"][key]["url"].endswith("/mcp")
|
||||
assert (
|
||||
p["opencode"]["mcp"][key]["headers"]["Authorization"]
|
||||
== "Bearer sc5_testtokenvalue"
|
||||
)
|
||||
assert "mcpServers" in p["cursor"]
|
||||
assert p["cli"].startswith("sc5 login")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mint_mcp_token_includes_connection_payload(client, admin_headers):
|
||||
r = await client.post(
|
||||
"/api/v1/tokens",
|
||||
headers=admin_headers,
|
||||
json={
|
||||
"name": "mcp-ext",
|
||||
"scopes": ["mcp:connect", "sessions:read", "metrics:read"],
|
||||
"mcp_tools": ["list_sessions", "get_metrics"],
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body.get("token", "").startswith("sc5_")
|
||||
mcp = body.get("mcp_connection")
|
||||
assert isinstance(mcp, dict)
|
||||
assert mcp.get("copy_text")
|
||||
assert "Bearer " + body["token"] in mcp["copy_text"]
|
||||
assert mcp["opencode"]["mcp"]
|
||||
assert mcp["formats"]["cursor_json"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mint_non_mcp_has_no_payload(client, admin_headers):
|
||||
r = await client.post(
|
||||
"/api/v1/tokens",
|
||||
headers=admin_headers,
|
||||
json={"name": "plain", "scopes": ["sessions:read"]},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert "mcp_connection" not in r.json() or not r.json().get("mcp_connection")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_jsonrpc_initialize_and_tools(client, admin_headers):
|
||||
t = await mint_token(
|
||||
client,
|
||||
admin_headers,
|
||||
"mcp-rpc",
|
||||
["mcp:connect", "sessions:read", "metrics:read"],
|
||||
mcp_tools=["list_sessions", "get_metrics"],
|
||||
)
|
||||
h = bearer(t["token"])
|
||||
init = await client.post(
|
||||
"/mcp",
|
||||
headers=h,
|
||||
json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
|
||||
)
|
||||
assert init.status_code == 200, init.text
|
||||
assert init.json()["result"]["serverInfo"]["name"] == "squidc5"
|
||||
|
||||
tools = await client.post(
|
||||
"/mcp",
|
||||
headers=h,
|
||||
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
|
||||
)
|
||||
assert tools.status_code == 200
|
||||
names = {x["name"] for x in tools.json()["result"]["tools"]}
|
||||
assert names == {"list_sessions", "get_metrics"}
|
||||
|
||||
call = await client.post(
|
||||
"/mcp",
|
||||
headers=h,
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": 3,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "list_sessions", "arguments": {}},
|
||||
},
|
||||
)
|
||||
assert call.status_code == 200
|
||||
body = call.json()["result"]
|
||||
assert body.get("isError") is False
|
||||
assert body["content"][0]["type"] == "text"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_jsonrpc_denied_tool(client, admin_headers):
|
||||
t = await mint_token(
|
||||
client,
|
||||
admin_headers,
|
||||
"mcp-rpc-deny",
|
||||
["mcp:connect", "sessions:read"],
|
||||
mcp_tools=["list_sessions"],
|
||||
)
|
||||
h = bearer(t["token"])
|
||||
call = await client.post(
|
||||
"/mcp",
|
||||
headers=h,
|
||||
json={
|
||||
"jsonrpc": "2.0",
|
||||
"id": 9,
|
||||
"method": "tools/call",
|
||||
"params": {"name": "interact_shell", "arguments": {"session_id": "x", "command": "id"}},
|
||||
},
|
||||
)
|
||||
assert call.status_code == 200
|
||||
assert call.json()["result"].get("isError") is True
|
||||
+64
-8
@@ -2223,11 +2223,21 @@
|
||||
<input id="adSecretToken" class="mono" readonly style="flex:1;font-size:0.75rem" />
|
||||
<button type="button" id="adSecretCopyTok">Copy token</button>
|
||||
</div>
|
||||
<label>Connection link</label>
|
||||
<div class="row" style="margin:4px 0 0;gap:6px;align-items:stretch">
|
||||
<label>Connection link (Ops / phone)</label>
|
||||
<div class="row" style="margin:4px 0 8px;gap:6px;align-items:stretch">
|
||||
<input id="adSecretLink" class="mono" readonly style="flex:1;font-size:0.68rem" />
|
||||
<button type="button" id="adSecretCopyLink">Copy link</button>
|
||||
</div>
|
||||
<div id="adMcpPayloadBox" class="hidden">
|
||||
<label>MCP connection payload (OpenCode / Grok / Cursor)</label>
|
||||
<p class="muted" style="margin:2px 0 4px;font-size:0.68rem">Paste into <span class="mono">opencode.json</span> or merge under <span class="mono">mcp</span>. Uses remote MCP + Bearer token.</p>
|
||||
<textarea id="adMcpPayload" class="mono" readonly rows="12" style="width:100%;font-size:0.68rem;resize:vertical"></textarea>
|
||||
<div class="row" style="margin:6px 0 0;gap:6px;flex-wrap:wrap">
|
||||
<button type="button" class="primary sm" id="adSecretCopyMcp">Copy MCP payload</button>
|
||||
<button type="button" class="ghost sm" id="adSecretCopyMcpCursor" title="Cursor mcpServers format">Copy Cursor format</button>
|
||||
<button type="button" class="ghost sm" id="adSecretCopyCli">Copy sc5 login</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted" id="adSecretMeta" style="margin:8px 0 0;font-size:0.68rem"></p>
|
||||
</div>
|
||||
<label>Name</label><input id="adName" placeholder="squidc5-admin" />
|
||||
@@ -2503,13 +2513,17 @@
|
||||
const payload = { url: apiUrl, token: rawToken, refresh: 10 };
|
||||
return opsOrigin + "/ops#sc5=" + b64urlEncodeObj(payload);
|
||||
}
|
||||
let _lastMcpConn = null;
|
||||
function hideSecretBanner() {
|
||||
const b = el("adSecretBanner");
|
||||
if (b) b.classList.add("hidden");
|
||||
if (el("adSecretToken")) el("adSecretToken").value = "";
|
||||
if (el("adSecretLink")) el("adSecretLink").value = "";
|
||||
if (el("adMcpPayload")) el("adMcpPayload").value = "";
|
||||
if (el("adMcpPayloadBox")) el("adMcpPayloadBox").classList.add("hidden");
|
||||
_lastMcpConn = null;
|
||||
}
|
||||
function showSecretBanner({ title, token, name, id, scopes, linkOnly }) {
|
||||
function showSecretBanner({ title, token, name, id, scopes, linkOnly, mcp_connection }) {
|
||||
const b = el("adSecretBanner");
|
||||
if (!b) return;
|
||||
b.classList.remove("hidden");
|
||||
@@ -2524,10 +2538,23 @@
|
||||
? (token || "") // when linkOnly, `token` arg carries the URL
|
||||
: buildTokenConnectLink(token || "");
|
||||
}
|
||||
_lastMcpConn = mcp_connection || null;
|
||||
const mcpBox = el("adMcpPayloadBox");
|
||||
const mcpTa = el("adMcpPayload");
|
||||
if (mcpBox && mcpTa) {
|
||||
if (_lastMcpConn && _lastMcpConn.copy_text) {
|
||||
mcpBox.classList.remove("hidden");
|
||||
mcpTa.value = _lastMcpConn.copy_text;
|
||||
} else {
|
||||
mcpBox.classList.add("hidden");
|
||||
mcpTa.value = "";
|
||||
}
|
||||
}
|
||||
if (el("adSecretMeta")) {
|
||||
const sc = (scopes || []).slice(0, 8).join(", ");
|
||||
const mcpHint = _lastMcpConn ? " · MCP payload ready" : "";
|
||||
el("adSecretMeta").textContent =
|
||||
(name ? name + " - " : "") + (id || "") + (sc ? " - " + sc : "");
|
||||
(name ? name + " - " : "") + (id || "") + (sc ? " - " + sc : "") + mcpHint;
|
||||
}
|
||||
try { b.scrollIntoView({ block: "nearest", behavior: "smooth" }); } catch (_) {}
|
||||
}
|
||||
@@ -2657,13 +2684,14 @@
|
||||
try {
|
||||
const r = await api("POST", "/api/v1/tokens/" + encodeURIComponent(id) + "/roll");
|
||||
showSecretBanner({
|
||||
title: "Rolled token secret",
|
||||
title: r.mcp_connection ? "Rolled MCP token + connection payload" : "Rolled token secret",
|
||||
token: r.token,
|
||||
name: r.name || (tok && tok.name),
|
||||
id: r.id || id,
|
||||
scopes: r.scopes || (tok && tok.scopes),
|
||||
mcp_connection: r.mcp_connection || null,
|
||||
});
|
||||
showOk("Token rolled - copy new secret");
|
||||
showOk(r.mcp_connection ? "Token rolled — copy new MCP payload" : "Token rolled - copy new secret");
|
||||
clearEditMode();
|
||||
loadTokenTable();
|
||||
} catch (e) { showError(String(e.message || e)); }
|
||||
@@ -2720,6 +2748,27 @@
|
||||
if (el("adSecretDismiss")) el("adSecretDismiss").onclick = () => hideSecretBanner();
|
||||
if (el("adSecretCopyTok")) el("adSecretCopyTok").onclick = () => copyField("adSecretToken", "Token copied");
|
||||
if (el("adSecretCopyLink")) el("adSecretCopyLink").onclick = () => copyField("adSecretLink", "Connection link copied");
|
||||
if (el("adSecretCopyMcp")) el("adSecretCopyMcp").onclick = () => copyField("adMcpPayload", "MCP payload copied — paste into opencode.json");
|
||||
if (el("adSecretCopyMcpCursor")) el("adSecretCopyMcpCursor").onclick = async () => {
|
||||
if (!_lastMcpConn || !_lastMcpConn.formats || !_lastMcpConn.formats.cursor_json) {
|
||||
return showError("No Cursor MCP payload (mint an mcp:connect token)");
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(_lastMcpConn.formats.cursor_json);
|
||||
showOk("Cursor mcpServers JSON copied");
|
||||
} catch (_) {
|
||||
showError("Clipboard unavailable");
|
||||
}
|
||||
};
|
||||
if (el("adSecretCopyCli")) el("adSecretCopyCli").onclick = async () => {
|
||||
if (!_lastMcpConn || !_lastMcpConn.cli) return showError("No CLI line");
|
||||
try {
|
||||
await navigator.clipboard.writeText(_lastMcpConn.cli);
|
||||
showOk("sc5 login command copied");
|
||||
} catch (_) {
|
||||
showError("Clipboard unavailable");
|
||||
}
|
||||
};
|
||||
if (el("adMint")) el("adMint").onclick = async () => {
|
||||
try {
|
||||
const scopes = selectedScopes();
|
||||
@@ -2732,13 +2781,20 @@
|
||||
}
|
||||
const r = await api("POST", "/api/v1/tokens", body);
|
||||
showSecretBanner({
|
||||
title: "Minted token secret",
|
||||
title: scopes.includes("mcp:connect") || scopes.includes("admin")
|
||||
? "Minted MCP token + connection payload"
|
||||
: "Minted token secret",
|
||||
token: r.token,
|
||||
name: r.name || name,
|
||||
id: r.id,
|
||||
scopes: r.scopes || scopes,
|
||||
mcp_connection: r.mcp_connection || null,
|
||||
});
|
||||
showOk("Token minted - copy secret or connection link");
|
||||
showOk(
|
||||
r.mcp_connection
|
||||
? "Token minted — copy MCP payload for OpenCode/Grok"
|
||||
: "Token minted - copy secret or connection link"
|
||||
);
|
||||
loadTokenTable();
|
||||
} catch (e) { showError(String(e.message || e)); }
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user