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:
Mr. The Plague
2026-08-04 13:02:44 -04:00
parent 9f367fc883
commit 09360a60c8
7 changed files with 625 additions and 76 deletions
+28 -3
View File
@@ -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 |
+39 -2
View File
@@ -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
View File
@@ -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:
+149
View File
@@ -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
View File
@@ -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"}}}},
+137
View File
@@ -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
View File
@@ -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)); }
};