diff --git a/docs/user-guide.md b/docs/user-guide.md index a7b570a..ab3cee1 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -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 | diff --git a/src/squidc5/api/routes.py b/src/squidc5/api/routes.py index 4f56715..a116f14 100644 --- a/src/squidc5/api/routes.py +++ b/src/squidc5/api/routes.py @@ -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( diff --git a/src/squidc5/cli.py b/src/squidc5/cli.py index 656e467..4b18c78 100644 --- a/src/squidc5/cli.py +++ b/src/squidc5/cli.py @@ -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: diff --git a/src/squidc5/mcp/connection.py b/src/squidc5/mcp/connection.py new file mode 100644 index 0000000..c3826e0 --- /dev/null +++ b/src/squidc5/mcp/connection.py @@ -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 diff --git a/src/squidc5/mcp/server.py b/src/squidc5/mcp/server.py index f840123..a01fee7 100644 --- a/src/squidc5/mcp/server.py +++ b/src/squidc5/mcp/server.py @@ -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"}}}}, diff --git a/tests/test_mcp_connection_payload.py b/tests/test_mcp_connection_payload.py new file mode 100644 index 0000000..c9663ac --- /dev/null +++ b/tests/test_mcp_connection_payload.py @@ -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 diff --git a/web/ops-admin.js b/web/ops-admin.js index 3a3017e..f3dc0fc 100644 --- a/web/ops-admin.js +++ b/web/ops-admin.js @@ -2223,11 +2223,21 @@ - -
Paste into opencode.json or merge under mcp. Uses remote MCP + Bearer token.
+ +