Task — engineering-spec@1

"CyberOS MCP bridge server — exposes CUO/memory/SKILL tools over MCP 2025-11-25 protocol; single binary with stdio + HTTP transports"

draftTASK-PLUGIN-002
module plugin · class product · priority p0 · created 2026-05-19 · shipped null
depends on TASK-PLUGIN-001, TASK-MCP-001, TASK-MCP-003 · blocks TASK-PLUGIN-003, TASK-PLUGIN-007

§1 — Description (BCP-14 normative)

The PLUGIN module MUST ship the MCP bridge server at services/plugin-host/ as a single Rust binary cyberos-mcp-bridge. The bridge implements MCP 2025-11-25 protocol and exposes 8 CyberOS tools (CUO + memory + SKILL) to any MCP-compliant host.

  1. MUST ship as single binary supporting BOTH transports per DEC-2410 + DEC-2411:
  1. MUST implement the MCP 2025-11-25 handshake per TASK-MCP-001 + DEC-2412:
  1. MUST expose exactly 8 tools at first ship per DEC-2413. Names follow SEP-986 per TASK-MCP-003:
  1. MUST wire each tool to its source-of-truth module per architecture:
  1. MUST implement MCP Tasks primitive per TASK-MCP-007 for cyberos.cuo.execute_workflow + cyberos.skill.invoke_skill per DEC-2414. Long-running tools:
  1. MUST be stateless across requests per DEC-2415. No in-memory session affinity; every request authenticates via OAuth-PKCE JWT (TASK-PLUGIN-005) carrying tenant_id claim. The bridge MUST NOT cache identity between requests. Long-running tasks persist their state in Postgres, not in memory.
  1. MUST return errors in 4 distinct classes per DEC-2416 + clause 8 error envelope below:
  1. MUST emit OpenTelemetry spans per DEC-2417 for every tool call with attributes cyberos.plugin_id, cyberos.tool_name, cyberos.tenant_id, cyberos.trace_id, cyberos.duration_ms, cyberos.outcome (success / error_class). Spans flow to TASK-OBS-001 collector via OTLP gRPC.
  1. MUST honour tool-annotation gating per TASK-MCP-006. Tools with destructive: true or external_effect: true annotations (cyberos.memory.append_audit for example) require the host to set mcp/sampling/elicit_confirm: true in the call envelope OR include a pre-signed confirmation token. Bridge rejects un-confirmed destructive calls with authz_denied.
  1. MUST validate every tools/call request against the tool's declared input_schema per TASK-PLUGIN-001 clause 1 before invoking the upstream. Schema-violation calls produce input_validation errors WITHOUT touching the upstream.
  1. MUST include CORS headers when running in HTTP transport with --cors-origin <pattern> flag. Default: deny all origins (no CORS headers). Operators set the flag explicitly per deployment.
  1. MUST NOT retain client-supplied state across requests beyond what task persistence requires (DEC-2415). No session cookies, no auth caches longer than the JWT's natural validity.
  1. MUST NOT include CyberOS-internal data in error messages — error bodies are user-safe but do NOT leak: memory audit row contents, JWT bytes, internal DB IDs (use trace_id instead), tenant data from other tenants.
  1. MUST NOT speak any MCP protocol version other than 2025-11-25 in v1 per DEC-2412. Future protocol versions land via task-MCP-001b/c successor tasks.

§2 — Why this design

Why single binary, both transports (DEC-2410)? Two binaries doubles the maintenance + signing + audit surface. The transport abstraction (transport/mod.rs trait) costs ~80 lines and saves duplicating the handler stack. Hosts pick the transport that fits — desktop hosts get stdio (no port binding), cloud hosts get HTTP (load-balanced).

Why stdio default (DEC-2411)? Claude Code, Cursor, Codex CLI all use stdio in 2026 — process-per-plugin model. HTTP would require port allocation per plugin, exposes attack surface, and requires the host to manage process supervision differently. Stdio matches existing host expectations.

Why MCP 2025-11-25 only (DEC-2412, clause 14)? Mixing protocol versions in one binary multiplies code paths quadratically. TASK-MCP-001 already commits the gateway to this version. Plugin bridge inherits.

Why exactly 8 tools at first ship (DEC-2413)? Each tool is an API contract that must be stable (clients depend on the name + schema). Shipping 8 well-considered tools is far better than 30 hastily-named ones. The 8 cover the most common host workflows: orchestration (CUO 4), memory (memory 2), skill discovery (SKILL 2). Future tools land via task-PLUGIN-002a/b/c.

Why subprocess invocation for CUO (clause 4)? CUO supervisor is Python; bridge is Rust. Without a Python embedding (PyO3 is heavy), the cleanest IPC is subprocess + JSON-RPC over stdin/stdout. Subprocess invocation also gives clean process isolation — a CUO crash doesn't take the bridge down.

Why HTTP REST for memory (clause 4)? memory service ships as a Fargate binary (per TASK-MEMORY-104). HTTP REST is the natural client surface. Bridge can call it from any deployment context.

Why Tasks primitive for long-running tools (DEC-2414, clause 5)? CUO workflow chains run for 30 seconds to several minutes. A synchronous JSON-RPC response would either timeout or hold the connection. MCP Tasks primitive (TASK-MCP-007) is the canonical solution: client receives a handle, polls status, optionally cancels. Reconnect-resume is essential for desktop hosts that may disconnect.

Why Postgres-backed task state (clause 5)? In-memory task state breaks reconnect-resume. SQLite works for single-instance, but bridge scaling to multiple instances (Fargate) requires shared store. Postgres is already in the stack for AUTH + memory; reusing it costs nothing.

Why stateless across requests (DEC-2415, clause 6)? Stateful bridges require sticky sessions, complicating load balancers and rolling deploys. Statelessness with JWT auth is the cloud-native default.

Why 4 error classes (DEC-2416, clause 7)? Hosts surface different UI for different error classes: input_validation → "fix your input" inline; authz_denied → "request more scopes" CTA; upstream_unavailable → "try again in a moment" retry banner; internal_error → silent telemetry + generic apology. Without the taxonomy, hosts default to generic messages and users don't know what to do.

Why required OTel spans (DEC-2417, clause 8)? Plugin tool-call failures span 4 systems (host → bridge → upstream module → downstream service). Trace correlation requires every hop to emit spans. The bridge is the middle hop and must emit; otherwise the trace is fragmented and root-cause analysis is hand-stitched.

Why CORS off by default (clause 11)? HTTP transport is for cloud hosts that authenticate before reaching the bridge. Open CORS would let any web page call the bridge with the user's token. Closed-by-default + explicit per-deployment opt-in is safe.

Why don't error messages leak audit data (clause 13)? Plugins run with user-scoped tokens. An error message that includes "audit row seq 4827 was about <subject>" would leak data the user is not authorised to read in this context. Trace_id is the safe correlation token.


§3 — API contract

Initialize handshake

Client → bridge:

{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
  "protocolVersion": "2025-11-25",
  "capabilities": {"sampling": {}, "elicitation": {}},
  "clientInfo": {"name": "claude-code", "version": "0.42.0"}
}}

Bridge → client:

{"jsonrpc": "2.0", "id": 1, "result": {
  "protocolVersion": "2025-11-25",
  "capabilities": {"tools": {"listChanged": false}, "logging": {}},
  "serverInfo": {"name": "cyberos-mcp-bridge", "version": "1.0.0"}
}}

tools/list response (shape)

{"jsonrpc": "2.0", "id": 2, "result": {"tools": [
  {
    "name": "cyberos.cuo.list_personas",
    "description": "List the 47 active CyberOS personas (CTO, CFO, CPO, ...) with metadata for each.",
    "inputSchema": {"type": "object", "properties": {}, "additionalProperties": false},
    "annotations": {"destructive": false, "write": false, "external_effect": false}
  },
  // ... 7 more tools
]}}

tools/call for long-running execute_workflow

Client → bridge:

{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {
  "name": "cyberos.cuo.execute_workflow",
  "arguments": {
    "persona": "chief-technology-officer",
    "workflow": "architect-new-system",
    "inputs": {"context": "Build a payment routing system for SEA markets"}
  }
}}

Bridge → client (immediate, before workflow completes):

{"jsonrpc": "2.0", "id": 3, "result": {
  "content": [{"type": "text", "text": "{\"task_id\":\"t-abc123\",\"status\":\"running\"}"}],
  "isError": false
}}

Client polls:

{"jsonrpc": "2.0", "id": 4, "method": "tasks/get", "params": {"id": "t-abc123"}}

Bridge → client when complete:

{"jsonrpc": "2.0", "id": 4, "result": {
  "task_id": "t-abc123",
  "status": "completed",
  "output": {"steps_executed": 10, "final_artifacts": [...]},
  "duration_ms": 47230
}}

Error envelope

{"jsonrpc": "2.0", "id": 5, "error": {
  "code": -32000,
  "message": "Tool 'cyberos.memory.append_audit' requires scope 'cyberos:memory:write' which is not in your token.",
  "data": {
    "class": "authz_denied",
    "trace_id": "01HXXXXXXXXXXXXXXXXXXXXXXX",
    "hint": "Re-authorise the plugin with the 'cyberos:memory:write' scope, or have an admin grant it.",
    "missing_scopes": ["cyberos:memory:write"]
  }
}}

Postgres schema for task persistence

CREATE TABLE plugin_host.tasks (
  task_id TEXT PRIMARY KEY,
  tenant_id UUID NOT NULL,
  plugin_id TEXT NOT NULL,
  tool_name TEXT NOT NULL,
  status TEXT NOT NULL CHECK (status IN ('running','completed','failed','cancelled')),
  input JSONB NOT NULL,
  output JSONB,
  error JSONB,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  completed_at TIMESTAMPTZ,
  timeout_at TIMESTAMPTZ NOT NULL,
  trace_id CHAR(32) NOT NULL
);
ALTER TABLE plugin_host.tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY tasks_rls ON plugin_host.tasks
  USING (tenant_id = current_setting('auth.tenant_id')::uuid)
  WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);
CREATE INDEX ON plugin_host.tasks (tenant_id, status, created_at DESC);

§4 — Acceptance criteria

  1. Initialize handshake succeeds — bridge returns protocolVersion 2025-11-25 on initialize.
  2. tools/list returns 8 tools — exactly 8 entries, all with SEP-986-conformant names.
  3. Tool name pattern enforced — bridge rejects renaming a tool to a non-SEP-986 string at compile time.
  4. execute_workflow returns task_id — synchronous response contains running status + task_id within 100ms.
  5. Task status polling workstasks/get returns current status; transitions running → completed.
  6. Task cancellation workstasks/cancel aborts upstream subprocess and marks task cancelled.
  7. Reconnect-resume works — disconnect/reconnect on same task_id retrieves current state from Postgres.
  8. Cross-tenant denied — token for tenant A cannot retrieve task created by tenant B (RLS).
  9. Input validation rejects malformed args — calling cyberos.cuo.execute_workflow with missing required persona returns input_validation error.
  10. Authz denied on missing scope — calling cyberos.memory.append_audit with read-only token returns authz_denied with missing_scopes array.
  11. Upstream unavailable on memory down — when memory HTTP is unreachable, bridge returns upstream_unavailable (not internal_error).
  12. Internal error on bridge bug — panic in handler returns internal_error envelope (panic_hook catches).
  13. All errors carry trace_id — 4 error classes, 4 fixture tests, all assert trace_id present.
  14. OTel span emitted per tool call — test inspects OTel exporter, finds span with cyberos.tool_name attribute.
  15. Destructive tool requires confirmationcyberos.memory.append_audit without elicit_confirm: true returns authz_denied.
  16. Stateless across requests — second tools/call with new JWT for different tenant returns different tenant's results.
  17. Task timeout enforced — task running > timeout_at marked failed; status: failed; error.class: internal_error with timeout hint.
  18. CORS denies by default — HTTP transport without --cors-origin flag returns no CORS headers; browser-origin OPTIONS denied.
  19. CORS allows configured origin--cors-origin https://example.com adds Access-Control-Allow-Origin header on matching requests.
  20. stdio transport echoes JSON-RPC — send initialize via stdin → response on stdout; no port binding.
  21. HTTP transport binds port--listen 0.0.0.0:8082 opens port; /healthz returns 200.
  22. Tool error message does not leak audit content — failing read_audit for missing row returns generic message + trace_id, no body bytes.
  23. Initialize negotiates capabilities — client sends capabilities.sampling: {}; bridge responds without sampling in its own caps; client knows.
  24. Protocol version mismatch rejected — client sends protocolVersion: "2024-01-01"; bridge returns error code -32602 with hint.

§5 — Verification

// services/plugin-host/tests/initialize_handshake_test.rs
#[tokio::test]
async fn initialize_returns_2025_11_25() {
    let bridge = TestBridge::new_stdio().await;
    let resp = bridge.send(json!({
        "jsonrpc":"2.0","id":1,"method":"initialize",
        "params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"0.1.0"}}
    })).await;
    assert_eq!(resp["result"]["protocolVersion"], "2025-11-25");
    assert_eq!(resp["result"]["serverInfo"]["name"], "cyberos-mcp-bridge");
}
// services/plugin-host/tests/tools_list_returns_8_tools_test.rs
#[tokio::test]
async fn tools_list_has_exactly_8() {
    let bridge = TestBridge::new_stdio().await.initialized().await;
    let resp = bridge.send_method("tools/list", json!({})).await;
    let tools = resp["result"]["tools"].as_array().unwrap();
    assert_eq!(tools.len(), 8);
    let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
    let sep_pattern = regex::Regex::new(r"^cyberos\.[a-z][a-z0-9]*\.[a-z][a-z0-9_]*$").unwrap();
    for name in &names {
        assert!(sep_pattern.is_match(name), "name '{}' violates SEP-986", name);
    }
    assert!(names.contains(&"cyberos.cuo.execute_workflow"));
}
// services/plugin-host/tests/execute_workflow_as_task_test.rs
#[tokio::test]
async fn execute_workflow_returns_task_id_and_completes() {
    let bridge = TestBridge::new_with_mock_cuo().await.initialized().await;
    let resp = bridge.tools_call("cyberos.cuo.execute_workflow", json!({
        "persona": "chief-technology-officer",
        "workflow": "adr-quick-capture",
        "inputs": {"title": "test"}
    })).await;
    let body: serde_json::Value = serde_json::from_str(
        resp["result"]["content"][0]["text"].as_str().unwrap()
    ).unwrap();
    let task_id = body["task_id"].as_str().unwrap().to_string();
    assert_eq!(body["status"], "running");

    // Poll until complete (≤ 5s)
    for _ in 0..50 {
        let s = bridge.tasks_get(&task_id).await;
        if s["status"] == "completed" { return; }
        tokio::time::sleep(Duration::from_millis(100)).await;
    }
    panic!("task did not complete");
}
// services/plugin-host/tests/error_class_taxonomy_test.rs
#[tokio::test]
async fn missing_scope_returns_authz_denied() {
    let bridge = TestBridge::new_with_read_only_token().await.initialized().await;
    let resp = bridge.tools_call("cyberos.memory.append_audit", json!({
        "kind": "decision", "body": {}
    })).await;
    assert_eq!(resp["error"]["data"]["class"], "authz_denied");
    assert!(resp["error"]["data"]["missing_scopes"]
        .as_array().unwrap().contains(&json!("cyberos:memory:write")));
    assert!(resp["error"]["data"]["trace_id"].is_string());
}

#[tokio::test]
async fn malformed_input_returns_input_validation() {
    let bridge = TestBridge::new_with_full_token().await.initialized().await;
    let resp = bridge.tools_call("cyberos.cuo.execute_workflow", json!({
        // missing required 'persona'
        "workflow": "adr-quick-capture", "inputs": {}
    })).await;
    assert_eq!(resp["error"]["data"]["class"], "input_validation");
}
// services/plugin-host/tests/cross_tenant_denied_test.rs
#[tokio::test]
async fn rls_prevents_cross_tenant_task_read() {
    let tenant_a = TestBridge::new_for_tenant("a").await.initialized().await;
    let resp = tenant_a.tools_call("cyberos.cuo.execute_workflow", workflow_args()).await;
    let task_id = extract_task_id(&resp);

    let tenant_b = TestBridge::new_for_tenant("b").await.initialized().await;
    let resp_b = tenant_b.tasks_get(&task_id).await;
    assert_eq!(resp_b["error"]["data"]["class"], "input_validation"); // task not found from b's view
}

§6 — Implementation skeleton

Bridge crate layout:

services/plugin-host/
├── Cargo.toml                              (cyberos-plugin-host crate)
├── src/
│   ├── main.rs                             (transport dispatch: stdio | http)
│   ├── transport/
│   │   ├── mod.rs                          (Transport trait)
│   │   ├── stdio.rs                        (read stdin frames, write stdout)
│   │   └── http.rs                         (axum router with /mcp endpoint)
│   ├── handlers/
│   │   ├── initialize.rs                   (capabilities negotiation)
│   │   ├── tools_list.rs                   (static 8-tool registry)
│   │   └── tools_call.rs                   (dispatch + Tasks for long-running)
│   ├── tools/
│   │   ├── cuo.rs                          (4 tools via cyberos-cuo subprocess)
│   │   ├── memory.rs                        (2 tools via memory HTTP)
│   │   └── skill.rs                        (2 tools via skill-broker / fs scan)
│   ├── error.rs                            (4-class taxonomy + envelope)
│   └── otel.rs                             (span emission)
└── tests/                                  (5 integration tests)

Tool registry is static (tools_list.rs::TOOLS: &[ToolDef; 8]) — no dynamic registration in v1.


§7 — Dependencies


§8 — Example payloads

(See §3 for handshake, tools/list, tools/call, error envelopes, and DB schema.)

Sample tool: cyberos.cuo.route call

Request:

{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{
  "name":"cyberos.cuo.route",
  "arguments":{"query":"Architect a new payment routing system for Southeast Asian markets"}
}}

Response:

{"jsonrpc":"2.0","id":7,"result":{
  "content":[{"type":"text","text":"{\"persona\":\"chief-technology-officer\",\"workflow\":\"architect-new-system\",\"confidence\":0.92,\"alternates\":[\"chief-product-officer/product-roadmap\"]}"}],
  "isError":false
}}

Sample memory audit row from a plugin tool invocation

Emitted by cyberos.memory.append_audit itself, but also by cyberos.cuo.execute_workflow upon completion:

{
  "kind": "plugin.invoked",
  "actor_id": "00000000-...",
  "tenant_id": "11111111-...",
  "body": {
    "plugin_id": "cyberos",
    "plugin_version": "1.0.0",
    "tool_name": "cyberos.cuo.execute_workflow",
    "trace_id": "01HX...",
    "duration_ms": 47230,
    "outcome": "success"
  }
}

§9 — Open questions

All resolved.


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
Client sends wrong protocol versioninitialize handler version checkerror -32602 with hintClient upgrades to 2025-11-25
stdio frame malformedJSON-RPC parser failserror -32700 (parse error)Client retries with valid frame
CUO subprocess crashestokio::process child exit code != 0task marked failed; error class upstream_unavailableBridge spawns fresh subprocess on next call
memory HTTP times outreqwest 5s timeoutupstream_unavailableRetry with backoff or surface to user
Postgres unavailablesqlx connection errorupstream_unavailable for task ops; degraded mode for short toolsRecovery on next call; alerting via TASK-OBS-007
JWT signature invalidauth middlewareauthz_denied with hint "re-authenticate"Client refreshes token
JWT expiredauth middleware exp claim checkauthz_denied with hint "token expired"Client refreshes via OAuth-PKCE (TASK-PLUGIN-005)
Scope missing for destructive tooltools_call.rs scope checkauthz_denied with missing_scopes arrayClient re-authorises with broader scope
Cross-tenant task fetchPostgres RLS deniesinput_validation "task not found" (intentionally generic)Inherent — no recovery, no leak
Task timeout exceededtokio::time::timeout futuretask marked failed; error.message includes timeout hintClient invokes again with longer timeout if tool supports
Tool input fails JSON schematools_call.rs schema validatorinput_validation with JSON-pointer to bad fieldClient fixes input
Bridge panic in handlercatch_unwind in transport layerinternal_error envelope; OTel span tagged with outcome=panicService restart by Fargate health check
OTel exporter unreachableexporter buffer fillsspans dropped (logged), tool call still succeedsOTel recovers; spans buffered up to capacity
Concurrent task_id collisionPostgres PK collisionretry with new ID; if persistent, internal_errorInherent (PK retry)
CORS preflight from disallowed originhttp handler checks origin allowlist403 with explicit reasonOperator extends --cors-origin allowlist

§11 — Implementation notes


End of TASK-PLUGIN-002 spec.