diff --git a/.github/workflows/mcp-server.yml b/.github/workflows/mcp-server.yml new file mode 100644 index 0000000..a00859c --- /dev/null +++ b/.github/workflows/mcp-server.yml @@ -0,0 +1,102 @@ +name: mcp-server + +on: + push: + branches: [main] + paths: + - "crates/agentkeys-mcp-server/**" + - "scripts/mcp-demo-mode-a.sh" + - "scripts/mcp-demo-mode-b-protocol.sh" + - "scripts/mcp-demo-mode-c-xiaozhi-client.sh" + - "scripts/mcp-demo-mode-d-xiaozhi-endpoint.sh" + - "scripts/mcp-demo-mode-e-stdio.sh" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/mcp-server.yml" + pull_request: + paths: + - "crates/agentkeys-mcp-server/**" + - "scripts/mcp-demo-mode-a.sh" + - "scripts/mcp-demo-mode-b-protocol.sh" + - "scripts/mcp-demo-mode-c-xiaozhi-client.sh" + - "scripts/mcp-demo-mode-d-xiaozhi-endpoint.sh" + - "scripts/mcp-demo-mode-e-stdio.sh" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/mcp-server.yml" + +permissions: + contents: read + packages: write + +jobs: + test: + name: test + clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: ". -> target" + - name: cargo test + run: cargo test -p agentkeys-mcp-server --all-features + - name: cargo clippy + run: cargo clippy -p agentkeys-mcp-server --all-targets -- -D warnings + # Phase A dev-mode demo smoke — boots the binary with --backend in-memory + # and walks the three-act storyboard end-to-end via curl. Catches drift + # between code and runbook §A in `docs/spec/plans/issue-107-mcp-demo-runbook.md`. + - name: mcp demo (mode A — dev smoke) + run: bash scripts/mcp-demo-mode-a.sh + + # Phase B testing ladder (runbook §B.0). Modes B/C/D need `uv` to manage + # a Python venv on the fly so the official Anthropic mcp SDK + xiaozhi- + # server's own integration class can drive our server. These tiers catch + # bugs at the MCP wire layer, the xiaozhi integration layer, and the + # relay-topology layer respectively. No live broker or xiaozhi account. + - name: install uv (for modes B/C/D/E) + run: curl -LsSf https://astral.sh/uv/install.sh | sh + - name: mcp demo (mode B — Anthropic mcp SDK protocol smoke, HTTP) + run: bash scripts/mcp-demo-mode-b-protocol.sh + - name: mcp demo (mode C — xiaozhi ServerMCPClient integration, HTTP) + run: bash scripts/mcp-demo-mode-c-xiaozhi-client.sh + - name: mcp demo (mode D — xiaozhi MCP-endpoint relay topology, WS) + run: bash scripts/mcp-demo-mode-d-xiaozhi-endpoint.sh + # Mode E covers the stdio transport gap — the actual path Claude Code, + # Codex CLI, Claude Desktop, Cursor, Cline, Roo, Windsurf, Gemini CLI + # use. Modes B-D are all over HTTP/WS; without this we could ship a + # binary that initializes cleanly via curl but corrupts the stdout + # JSON-RPC stream with tracing logs (we already hit this once). + - name: build release binary (for mode E) + run: cargo build --release -p agentkeys-mcp-server + - name: mcp demo (mode E — stdio_client functional E2E) + run: | + AGENTKEYS_MCP_BIN="$(pwd)/target/release/agentkeys-mcp-server" \ + bash scripts/mcp-demo-mode-e-stdio.sh + + image: + name: build + publish image + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: build + push + uses: docker/build-push-action@v6 + with: + context: . + file: crates/agentkeys-mcp-server/Dockerfile + push: true + tags: | + ghcr.io/${{ github.repository }}/agentkeys-mcp-server:latest + ghcr.io/${{ github.repository }}/agentkeys-mcp-server:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 9593a6a..2767aa0 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,11 @@ AWSCLIV2.pkg # Local developer secrets — template is checked in as .env.example. agentkeys-secrets.env +# xiaozhi MCP-endpoint URL — contains a bearer JWT, never commit. +# Used by scripts/run-mcp-local.sh as an optional convenience cache so +# you don't have to paste the URL every time. +/mcp-xiaozhi-endpoint + # Operator-supplied mnemonic file(s) for the chain deployer (referenced # by HEIMA_DEPLOYER_MNEMONIC_FILE in scripts/heima-bring-up.sh). # Never committed — the mnemonic IS the key. diff --git a/Cargo.lock b/Cargo.lock index 0eabf89..e2407cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,7 +71,7 @@ dependencies = [ "sha2 0.10.9", "sha3", "tempfile", - "thiserror", + "thiserror 2.0.18", "tokio", "tower 0.4.13", "tracing", @@ -108,7 +108,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "tempfile", - "thiserror", + "thiserror 2.0.18", "tokio", "tower-service", ] @@ -142,7 +142,7 @@ dependencies = [ "sha2 0.10.9", "sha3", "tempfile", - "thiserror", + "thiserror 2.0.18", "tokio", ] @@ -193,6 +193,33 @@ dependencies = [ "tracing", ] +[[package]] +name = "agentkeys-mcp-server" +version = "0.1.0" +dependencies = [ + "agentkeys-types", + "anyhow", + "async-trait", + "axum", + "base64", + "clap", + "futures-util", + "hex", + "http-body-util", + "reqwest", + "rustls 0.23.37", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "tower 0.4.13", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "agentkeys-mock-server" version = "0.1.0" @@ -221,7 +248,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sha3", - "thiserror", + "thiserror 2.0.18", "tokio", "tower 0.4.13", "tower-http 0.5.2", @@ -245,7 +272,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", ] @@ -273,7 +300,7 @@ dependencies = [ "serde", "serde_json", "sha3", - "thiserror", + "thiserror 2.0.18", "tokio", "tower 0.4.13", "tracing", @@ -302,7 +329,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sha3", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "tracing-subscriber", @@ -321,7 +348,7 @@ dependencies = [ "hex", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "tracing-subscriber", @@ -342,7 +369,7 @@ dependencies = [ "reqwest", "serde", "serde_json", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "tracing-subscriber", @@ -1612,6 +1639,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "der" version = "0.6.1" @@ -3648,6 +3681,7 @@ checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki 0.103.10", "subtle", @@ -4007,7 +4041,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 2.0.18", "time", ] @@ -4175,13 +4209,33 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -4303,6 +4357,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6989540ced10490aaf14e6bad2e3d33728a2813310a0c71d1574304c49631cd" +dependencies = [ + "futures-util", + "log", + "rustls 0.23.37", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tungstenite", + "webpki-roots 0.26.11", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4491,6 +4561,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e2ce1e47ed2994fd43b04c8f618008d4cabdd5ee34027cf14f9d918edd9c8" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand", + "rustls 0.23.37", + "rustls-pki-types", + "sha1 0.10.6", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "typenum" version = "1.19.0" @@ -4554,6 +4644,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4748,6 +4844,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 3184ab6..660e67b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/agentkeys-cli", "crates/agentkeys-daemon", "crates/agentkeys-mcp", + "crates/agentkeys-mcp-server", "crates/agentkeys-provisioner", "crates/agentkeys-broker-server", "crates/agentkeys-worker-creds", diff --git a/crates/agentkeys-mcp-server/Cargo.toml b/crates/agentkeys-mcp-server/Cargo.toml new file mode 100644 index 0000000..b40c869 --- /dev/null +++ b/crates/agentkeys-mcp-server/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "agentkeys-mcp-server" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "agentkeys-mcp-server" +path = "src/main.rs" + +[lib] +name = "agentkeys_mcp_server" +path = "src/lib.rs" + +[dependencies] +agentkeys-types = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +async-trait = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +axum = { version = "0.7", features = ["json"] } +tower = "0.4" +reqwest = { version = "0.12", features = ["json"] } +tokio-tungstenite = { version = "0.23", features = ["rustls-tls-webpki-roots"] } +# Direct rustls dep so we can explicitly install the `ring` crypto +# provider at startup — tokio-tungstenite pulls rustls in with no +# provider feature, and rustls 0.23 refuses to auto-select. +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +futures-util = "0.3" +clap = { version = "4", features = ["derive", "env"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +base64 = "0.22" +hex = "0.4" +sha2 = "0.10" +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +tokio = { workspace = true } +tower = { version = "0.4", features = ["util"] } +http-body-util = "0.1" diff --git a/crates/agentkeys-mcp-server/Dockerfile b/crates/agentkeys-mcp-server/Dockerfile new file mode 100644 index 0000000..d1c3486 --- /dev/null +++ b/crates/agentkeys-mcp-server/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 +# AgentKeys MCP server (issue #107, Phase 1). +# +# Two-stage build: rust:slim to compile, debian:slim to run. Final image +# is a single static-ish binary + ca-certs (TLS to broker/workers). + +ARG RUST_VERSION=1.84 +FROM rust:${RUST_VERSION}-slim AS build + +WORKDIR /src +RUN apt-get update && apt-get install -y --no-install-recommends pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* + +# Copy workspace skeleton first to maximize Docker layer cache on dep +# changes vs source changes. +COPY Cargo.toml Cargo.lock ./ +COPY rust-toolchain.toml ./ +COPY crates ./crates + +RUN cargo build --release -p agentkeys-mcp-server + +FROM debian:bookworm-slim AS runtime + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd -r mcp && useradd -r -g mcp mcp + +COPY --from=build /src/target/release/agentkeys-mcp-server /usr/local/bin/agentkeys-mcp-server + +USER mcp +EXPOSE 8088 +ENV MCP_TRANSPORT=http \ + MCP_LISTEN=0.0.0.0:8088 + +ENTRYPOINT ["/usr/local/bin/agentkeys-mcp-server"] diff --git a/crates/agentkeys-mcp-server/README.md b/crates/agentkeys-mcp-server/README.md new file mode 100644 index 0000000..6b15bc1 --- /dev/null +++ b/crates/agentkeys-mcp-server/README.md @@ -0,0 +1,202 @@ +# agentkeys-mcp-server + +AgentKeys MCP server — Phase 1 (issue [#107](https://github.com/litentry/agentKeys/issues/107)). + +Adapts the Phase 0 backend (broker, memory worker, audit worker, signer) +into 10 MCP tools an LLM host (xiaozhi-server, Volcano Ark, Claude Code, +etc.) can call. + +## Tools + +| Tool | Status | Backend it talks to | +|---|---|---| +| `agentkeys.identity.whoami` | active | local (M4 lifts to broker `/v1/identity/whoami`) | +| `agentkeys.memory.get` | active | broker `/v1/cap/memory-get` → memory worker `/v1/memory/get` | +| `agentkeys.memory.put` | active | broker `/v1/cap/memory-put` → memory worker `/v1/memory/put` | +| `agentkeys.permission.check` | active | deterministic policy engine (no LLM) | +| `agentkeys.cap.mint` | active | broker `/v1/cap/{cred,memory}-{store,fetch,put,get}` | +| `agentkeys.cap.revoke` | active | M1 stub — broker endpoint scheduled for M4 | +| `agentkeys.audit.append` | active | audit worker `/v1/audit/append/v2` | +| `agentkeys.delegation.grant` | schema-only | returns `not_implemented_in_v1` | +| `agentkeys.delegation.revoke` | schema-only | returns `not_implemented_in_v1` | +| `agentkeys.approval.request` | schema-only | returns `not_implemented_in_v1` | + +## Run + +### Dev demo (in-memory backend, no external services) + +```bash +cargo run -p agentkeys-mcp-server -- \ + --backend in-memory \ + --listen 127.0.0.1:8088 +``` + +Auto-seeds vendor `magiclick:demo-tok` + three memory namespaces (`travel`, +`family`, `profile`) on actor `O_kevin_001`. Walk the three-act +storyboard with `bash scripts/mcp-demo-mode-a.sh` (asserts each act's +exact wire shape). Full step-by-step walkthrough: +[`docs/spec/plans/issue-107-mcp-demo-runbook.md`](../../docs/spec/plans/issue-107-mcp-demo-runbook.md). + +### Local (HTTP, against a real broker / workers) + +```bash +cargo run -p agentkeys-mcp-server -- \ + --listen 0.0.0.0:8088 \ + --broker-url https://broker.litentry.org \ + --memory-url https://memory.litentry.org \ + --audit-url https://audit.litentry.org \ + --vendor-tokens "magiclick:demo-tok,volcano-ark:tok-va" +``` + +### Stdio (for an MCP host that launches it as a subprocess) + +```bash +cargo run -p agentkeys-mcp-server -- --transport stdio +``` + +### xiaozhi MCP-endpoint relay (no firmware flash, no LLM key) + +Connect outward to a xiaozhi-style `mcp-endpoint-server` relay URL as a +WebSocket client. The relay forwards MCP frames between this server (as +the tool) and the xiaozhi cloud / xiaozhi-server (as the client). No +HTTP listen socket; no per-vendor bearer (the relay URL's token is the +binding). + +```bash +cargo run -p agentkeys-mcp-server -- \ + --transport mcp-endpoint \ + --backend in-memory \ + --mcp-endpoint 'ws://:8004/mcp_endpoint/mcp/?token=' +``` + +Test it locally without a real cloud account: `bash scripts/mcp-demo-mode-d-xiaozhi-endpoint.sh` +spins up a mock relay that mirrors `xinnan-tech/mcp-endpoint-server`'s +routing exactly, then drives every act through it. Full runbook in +[`docs/spec/plans/issue-107-mcp-demo-runbook.md`](../../docs/spec/plans/issue-107-mcp-demo-runbook.md) §B. + +### Docker + +```bash +docker build -t agentkeys-mcp-server -f crates/agentkeys-mcp-server/Dockerfile . +docker run --rm -p 8088:8088 \ + -e AGENTKEYS_BROKER_URL=https://broker.litentry.org \ + -e AGENTKEYS_MEMORY_URL=https://memory.litentry.org \ + -e AGENTKEYS_AUDIT_URL=https://audit.litentry.org \ + -e MCP_VENDOR_TOKENS="magiclick:demo-tok" \ + agentkeys-mcp-server +``` + +## Auth + +HTTP transport demands two headers per call: + +| Header | Purpose | On failure | +|---|---|---| +| `Authorization: Bearer ` | per-vendor identification | 401 | +| `X-AgentKeys-Actor: ` | binds the call to one actor | 403 | + +Optionally `X-AgentKeys-Session-Bearer: ` forwards a session JWT to +the broker cap-mint endpoint (required when the broker enforces OIDC). + +A tool argument naming a different actor than the header returns a JSON-RPC +error with code `-32003` (FORBIDDEN). Per the issue acceptance criteria, +that mismatch SHOULD also append an audit row in production deployments; +the audit emission is operator-driven for v1 and lands in M2 alongside +the vendor onboarding portal. + +## xiaozhi-server integration + +Write to `main/xiaozhi-server/data/.mcp_server_settings.json` (the leading +dot + the `data/` prefix are required — verified against +[`xinnan-tech/xiaozhi-esp32-server`](https://github.com/xinnan-tech/xiaozhi-esp32-server) +commit `7f73dae`, file `main/xiaozhi-server/core/providers/tools/server_mcp/mcp_manager.py`). + +```json +{ + "mcpServers": { + "agentkeys": { + "url": "https://agentkeys-mcp.example.com/mcp", + "transport": "streamable-http", + "headers": { + "Authorization": "Bearer ", + "X-AgentKeys-Actor": "" + } + } + } +} +``` + +The `"transport": "streamable-http"` line is **required** — without it, +xiaozhi-server defaults to SSE (`mcp.client.sse.sse_client`) and our +server's `/mcp` endpoint isn't an SSE endpoint. + +For local development with the stdio transport: + +```json +{ + "mcpServers": { + "agentkeys": { + "command": "/path/to/agentkeys-mcp-server", + "args": ["--transport", "stdio"] + } + } +} +``` + +**Protocol-level verification:** the official Anthropic `mcp` Python SDK +(`mcp.client.streamable_http.streamablehttp_client`) — which xiaozhi-server +imports directly — successfully drives this server through the full +`initialize` → `tools/list` → `tools/call` lifecycle. Reproduce with +`bash scripts/mcp-demo-mode-b-protocol.sh`. + +## Three-act demo storyboard + +Per [`docs/research/agent-iam-strategy.md`](../../docs/research/agent-iam-strategy.md) §4.3: + +1. **Permissioned Memory** — `memory.get(actor=O_kevin_001, namespace="travel")` + returns Chengdu trip context only; other namespaces (`family`, `profile`) + are not surfaced even though they exist for the same actor. +2. **Deterministic Denial** — `permission.check(actor, scope="payment.spend", + amount_rmb=600)` returns `verdict=deny, reason=daily_spend_cap_exceeded` + from the policy engine. No LLM in the decision path. +3. **Online Revocation** — `cap.revoke(cap_id)` followed by `audit.append` + records the parent's revocation event in the off-chain feed; the next + `permission.check` on the revoked scope denies. + +Exercised by `tests/three_acts.rs`. + +## Tests + +```bash +cargo test -p agentkeys-mcp-server +``` + +Coverage: + +- 17 unit tests across auth, policy, identity, permission +- 6 HTTP transport tests (bearer + actor header negative paths) +- 3 schema-only stub shape assertions +- 5 three-act integration tests against a `MockBackend` + +## What this crate is NOT + +- It does NOT mint cap-tokens directly — the broker does. We only + shape the request. +- It does NOT verify cap-token signatures — the workers do. +- It does NOT speak to the chain — the broker + audit worker do. +- It does NOT make policy decisions for anything other than + `permission.check`. Every other tool's verdict comes from on-chain + + broker state. + +## Out of scope for M1 (tracked separately) + +- Broker `/v1/identity/whoami` + `/v1/revoke/cap/:id` — M4 (paired with + vendor portal #114) +- Namespace as a SIGNED `CapPayload` field — follow-up to #108 +- Active delegation + approval — M4 (#107 says explicitly: schema-only + for v1) +- Vendor onboarding portal — M2 (#114) +- Volcano Ark marketplace registration — M2 + +See [`docs/spec/plans/issue-107-mcp-server-phase1.md`](../../docs/spec/plans/issue-107-mcp-server-phase1.md) +for the full plan + follow-ups. diff --git a/crates/agentkeys-mcp-server/src/auth.rs b/crates/agentkeys-mcp-server/src/auth.rs new file mode 100644 index 0000000..0883f13 --- /dev/null +++ b/crates/agentkeys-mcp-server/src/auth.rs @@ -0,0 +1,160 @@ +//! Bearer + per-actor auth for the HTTP transport. +//! +//! Vendors deploy this MCP server behind a per-vendor bearer token. The +//! `Authorization: Bearer ` header authenticates the vendor; the +//! `X-AgentKeys-Actor` header binds the call to a specific actor omni. +//! +//! Acceptance criterion #3 (issue #107): wrong token → 401, missing +//! actor header → 403, tool params naming a different actor than the +//! header → 403 (audit row required). +//! +//! Stdio transport has no headers — the parent process is implicitly +//! trusted to set the actor via tool params. + +use crate::config::Config; +use crate::errors::{McpError, McpResult}; + +/// What the HTTP layer extracted from the request headers. +#[derive(Debug, Clone)] +pub struct CallerContext { + pub vendor_id: String, + pub actor_omni: String, +} + +impl CallerContext { + pub fn new(vendor_id: impl Into, actor_omni: impl Into) -> Self { + Self { + vendor_id: vendor_id.into(), + actor_omni: actor_omni.into(), + } + } + + /// Stdio mode synthesizes a trusted-local caller. The actor still + /// has to be passed in tool params; this just lets tool dispatch + /// not branch on transport. + pub fn local_stdio() -> Self { + Self { + vendor_id: "local".into(), + actor_omni: "*".into(), + } + } +} + +/// Validate `Authorization: Bearer ` against the configured vendor map. +/// Returns the matched `vendor_id` on success. +pub fn check_bearer(config: &Config, header_value: Option<&str>) -> McpResult { + let header = header_value + .ok_or_else(|| McpError::Unauthorized("missing Authorization header".to_string()))?; + + let token = header + .strip_prefix("Bearer ") + .ok_or_else(|| { + McpError::Unauthorized( + "malformed Authorization header (expected `Bearer `)".to_string(), + ) + })? + .trim(); + + if token.is_empty() { + return Err(McpError::Unauthorized("empty bearer token".to_string())); + } + + for (vendor_id, expected) in &config.vendor_tokens { + if constant_time_eq(expected.as_bytes(), token.as_bytes()) { + return Ok(vendor_id.clone()); + } + } + + Err(McpError::Unauthorized( + "bearer token not recognized".to_string(), + )) +} + +/// Validate `X-AgentKeys-Actor: ` header. Returns the actor omni. +/// Returning `Forbidden` (not `Unauthorized`) matches the acceptance +/// criterion in issue #107 ("no-header → 403"). +pub fn check_actor_header(header_value: Option<&str>) -> McpResult { + let actor = header_value + .ok_or_else(|| McpError::Forbidden("missing X-AgentKeys-Actor header".to_string()))? + .trim(); + if actor.is_empty() { + return Err(McpError::Forbidden( + "empty X-AgentKeys-Actor header".to_string(), + )); + } + Ok(actor.to_string()) +} + +/// Cross-check the actor named in the tool params against the header-bound +/// actor. Per issue #107 acceptance: a vendor cannot operate on actor A +/// while presenting a header for actor B. +pub fn check_actor_param(header_actor: &str, param_actor: &str) -> McpResult<()> { + if header_actor == param_actor { + Ok(()) + } else { + Err(McpError::Forbidden(format!( + "actor mismatch: header={header_actor}, param={param_actor}" + ))) + } +} + +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff: u8 = 0; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg() -> Config { + Config::for_tests().with_vendor_token("vendor-a", "tok-a") + } + + #[test] + fn bearer_missing_header_is_401() { + let err = check_bearer(&cfg(), None).unwrap_err(); + assert!(matches!(err, McpError::Unauthorized(_))); + } + + #[test] + fn bearer_wrong_token_is_401() { + let err = check_bearer(&cfg(), Some("Bearer nope")).unwrap_err(); + assert!(matches!(err, McpError::Unauthorized(_))); + } + + #[test] + fn bearer_correct_token_returns_vendor() { + let v = check_bearer(&cfg(), Some("Bearer tok-a")).unwrap(); + assert_eq!(v, "vendor-a"); + } + + #[test] + fn bearer_malformed_prefix_is_401() { + let err = check_bearer(&cfg(), Some("Token tok-a")).unwrap_err(); + assert!(matches!(err, McpError::Unauthorized(_))); + } + + #[test] + fn actor_header_missing_is_403() { + let err = check_actor_header(None).unwrap_err(); + assert!(matches!(err, McpError::Forbidden(_))); + } + + #[test] + fn actor_param_mismatch_is_403() { + let err = check_actor_param("O_alice", "O_bob").unwrap_err(); + assert!(matches!(err, McpError::Forbidden(_))); + } + + #[test] + fn actor_param_match_ok() { + assert!(check_actor_param("O_alice", "O_alice").is_ok()); + } +} diff --git a/crates/agentkeys-mcp-server/src/backend/audit.rs b/crates/agentkeys-mcp-server/src/backend/audit.rs new file mode 100644 index 0000000..3156ec8 --- /dev/null +++ b/crates/agentkeys-mcp-server/src/backend/audit.rs @@ -0,0 +1,30 @@ +//! Audit-worker request shapes. +//! +//! Mirrors `agentkeys_worker_audit::handlers::AppendV2Request`. The +//! envelope version is pinned at 1 per `agentkeys_core::audit::ENVELOPE_VERSION`; +//! if that constant changes, this needs to change too — covered by an +//! integration smoke test. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const ENVELOPE_VERSION: u8 = 1; + +#[derive(Debug, Serialize)] +pub struct AuditAppendV2 { + pub version: u8, + pub ts_unix: u64, + pub actor_omni: String, + pub operator_omni: String, + pub op_kind: u8, + pub op_body: Value, + pub result: u8, + pub intent_text: Option, + pub intent_commitment: Option, +} + +#[derive(Debug, Deserialize)] +pub struct AuditAppendV2Resp { + pub ok: bool, + pub envelope_hash: String, +} diff --git a/crates/agentkeys-mcp-server/src/backend/broker.rs b/crates/agentkeys-mcp-server/src/backend/broker.rs new file mode 100644 index 0000000..d7adbec --- /dev/null +++ b/crates/agentkeys-mcp-server/src/backend/broker.rs @@ -0,0 +1,16 @@ +//! Broker-side request shapes — typed wrappers around the JSON +//! [`agentkeys_broker_server::handlers::cap`] expects. We don't pull the +//! broker crate as a dep (it's a binary with a heavy feature surface) — +//! the wire shape is small enough to mirror by hand and gets exercised +//! end-to-end in `tests/three_acts.rs`. + +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct BrokerCapRequest { + pub operator_omni: String, + pub actor_omni: String, + pub service: String, + pub device_key_hash: String, + pub ttl_seconds: u64, +} diff --git a/crates/agentkeys-mcp-server/src/backend/http_backend.rs b/crates/agentkeys-mcp-server/src/backend/http_backend.rs new file mode 100644 index 0000000..47b4d22 --- /dev/null +++ b/crates/agentkeys-mcp-server/src/backend/http_backend.rs @@ -0,0 +1,221 @@ +//! Production `Backend` implementation that talks to the real broker + +//! workers over HTTP. URLs come from `Config`; the bearer used for +//! broker cap-mint is forwarded from the vendor session header. + +use async_trait::async_trait; +use reqwest::Client; +use std::time::{SystemTime, UNIX_EPOCH}; + +use super::{ + audit::{AuditAppendV2, AuditAppendV2Resp, ENVELOPE_VERSION}, + broker::BrokerCapRequest, + memory::{MemoryGetBody, MemoryGetResp, MemoryPutBody, MemoryPutResp}, + AuditAppendInput, AuditAppendResult, Backend, BackendError, CapMintOp, CapMintRequest, + CapToken, MemoryGetInput, MemoryGetResult, MemoryPutInput, MemoryPutResult, RevokeResult, +}; + +pub struct HttpBackend { + pub client: Client, + pub broker_url: Option, + pub memory_url: Option, + pub audit_url: Option, +} + +impl HttpBackend { + pub fn new( + broker_url: Option, + memory_url: Option, + audit_url: Option, + ) -> Self { + Self { + client: Client::new(), + broker_url, + memory_url, + audit_url, + } + } + + fn broker(&self) -> Result<&str, BackendError> { + self.broker_url + .as_deref() + .ok_or(BackendError::NotConfigured("broker_url")) + } + + fn memory(&self) -> Result<&str, BackendError> { + self.memory_url + .as_deref() + .ok_or(BackendError::NotConfigured("memory_url")) + } + + fn audit(&self) -> Result<&str, BackendError> { + self.audit_url + .as_deref() + .ok_or(BackendError::NotConfigured("audit_url")) + } +} + +#[async_trait] +impl Backend for HttpBackend { + async fn cap_mint( + &self, + op: CapMintOp, + req: CapMintRequest, + session_bearer: &str, + ) -> Result { + let url = format!("{}{}", self.broker()?, op.broker_path()); + let body = BrokerCapRequest { + operator_omni: req.operator_omni, + actor_omni: req.actor_omni, + service: req.service, + device_key_hash: req.device_key_hash, + ttl_seconds: req.ttl_seconds, + }; + + let resp = self + .client + .post(&url) + .bearer_auth(session_bearer) + .json(&body) + .send() + .await + .map_err(|e| BackendError::Transport(e.to_string()))?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(BackendError::Http { status, body }); + } + + resp.json::() + .await + .map_err(|e| BackendError::Parse(e.to_string())) + } + + async fn cap_revoke(&self, cap_id: &str) -> Result { + // M1 stub — the broker doesn't expose `/v1/revoke/cap/:id` yet + // (paired with vendor portal in M4 per agent-iam-strategy.md + // §3.1 / milestones-roadmap.md M4). Return a structured "local + // only" response so the demo + parent UI can show the verdict. + // + // When the broker lands the endpoint we swap this stub for a + // real call; the tool's wire format stays the same. + Ok(RevokeResult { + ok: true, + revocation: "local_only".into(), + note: Some(format!( + "broker revoke endpoint scheduled for M4; cap_id={cap_id} recorded locally only" + )), + }) + } + + async fn memory_put(&self, input: MemoryPutInput) -> Result { + let url = format!("{}/v1/memory/put", self.memory()?); + let resp = self + .client + .post(&url) + .json(&MemoryPutBody { + cap: input.cap, + plaintext_b64: input.plaintext_b64, + namespace: input.namespace.clone(), + }) + .send() + .await + .map_err(|e| BackendError::Transport(e.to_string()))?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(BackendError::Http { status, body }); + } + + let parsed: MemoryPutResp = resp + .json() + .await + .map_err(|e| BackendError::Parse(e.to_string()))?; + + Ok(MemoryPutResult { + ok: parsed.ok, + s3_key: parsed.s3_key, + envelope_size: parsed.envelope_size, + namespace: input.namespace, + }) + } + + async fn memory_get(&self, input: MemoryGetInput) -> Result { + let url = format!("{}/v1/memory/get", self.memory()?); + let resp = self + .client + .post(&url) + .json(&MemoryGetBody { + cap: input.cap, + namespace: input.namespace.clone(), + }) + .send() + .await + .map_err(|e| BackendError::Transport(e.to_string()))?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(BackendError::Http { status, body }); + } + + let parsed: MemoryGetResp = resp + .json() + .await + .map_err(|e| BackendError::Parse(e.to_string()))?; + + Ok(MemoryGetResult { + ok: parsed.ok, + plaintext_b64: parsed.plaintext_b64, + namespace: input.namespace, + }) + } + + async fn audit_append( + &self, + input: AuditAppendInput, + ) -> Result { + let url = format!("{}/v1/audit/append/v2", self.audit()?); + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let body = AuditAppendV2 { + version: ENVELOPE_VERSION, + ts_unix: ts, + actor_omni: input.actor_omni, + operator_omni: input.operator_omni, + op_kind: input.op_kind, + op_body: input.op_body, + result: input.result, + intent_text: input.intent_text, + intent_commitment: None, + }; + + let resp = self + .client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| BackendError::Transport(e.to_string()))?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(BackendError::Http { status, body }); + } + + let parsed: AuditAppendV2Resp = resp + .json() + .await + .map_err(|e| BackendError::Parse(e.to_string()))?; + + Ok(AuditAppendResult { + ok: parsed.ok, + envelope_hash: parsed.envelope_hash, + }) + } +} diff --git a/crates/agentkeys-mcp-server/src/backend/in_memory.rs b/crates/agentkeys-mcp-server/src/backend/in_memory.rs new file mode 100644 index 0000000..50adcfd --- /dev/null +++ b/crates/agentkeys-mcp-server/src/backend/in_memory.rs @@ -0,0 +1,305 @@ +//! In-memory `Backend` for the dev-mode demo. +//! +//! Mirrors the test `MockBackend` shape but runs inside the production +//! binary so a fresh `cargo run -p agentkeys-mcp-server -- --backend +//! in-memory` is enough to walk the three-act storyboard without +//! deploying a broker, memory worker, or audit worker. +//! +//! The fixture actor / operator / device IDs are real hex32 strings +//! (matches the broker's `validate_hex32` regex `0x[0-9a-f]{64}`) so +//! payloads exercised in dev mode also wire-cleanly to a real broker. +//! +//! Each minted cap carries a unique nonce; the backend tracks minted +//! and revoked nonces so: +//! - `cap.revoke(cap_id)` for an unknown id returns an error. +//! - `memory.{get,put}` with a revoked or expired cap is rejected. +//! - The smoke script can mint → revoke → retry and prove denial. + +use async_trait::async_trait; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +use super::{ + AuditAppendInput, AuditAppendResult, Backend, BackendError, CapMintOp, CapMintRequest, + CapToken, MemoryGetInput, MemoryGetResult, MemoryPutInput, MemoryPutResult, RevokeResult, +}; + +/// Demo fixture identities — all real hex32 (`0x` + 64 hex chars) so the +/// MCP server forwards them to a real broker/worker without re-validation +/// failures. +pub const DEMO_ACTOR: &str = "0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7"; +pub const DEMO_OPERATOR: &str = + "0x07e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8"; +pub const DEMO_DEVICE_KEY_HASH: &str = + "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; + +pub struct InMemoryBackend { + inner: Mutex, +} + +#[derive(Default)] +struct Inner { + memory: HashMap<(String, String), String>, + audit: Vec, + minted: HashMap, + revoked: HashSet, +} + +struct MintedCap { + actor: String, + expires_at: u64, +} + +impl Default for InMemoryBackend { + fn default() -> Self { + Self::new_with_demo_fixture() + } +} + +impl InMemoryBackend { + pub fn new_empty() -> Self { + Self { + inner: Mutex::new(Inner::default()), + } + } + + pub fn new_with_demo_fixture() -> Self { + let backend = Self::new_empty(); + backend.seed( + DEMO_ACTOR, + "travel", + "Chengdu trip — Apr 12 to 16, hotpot at Yulin.", + ); + backend.seed( + DEMO_ACTOR, + "family", + "Wife's bday Aug 3 (gift idea: hiking boots).", + ); + backend.seed( + DEMO_ACTOR, + "profile", + "Allergic to shellfish. Prefers windowed flights.", + ); + backend + } + + pub fn seed(&self, actor: &str, namespace: &str, content: &str) { + let mut g = self.inner.lock().unwrap(); + g.memory.insert( + (actor.to_string(), namespace.to_string()), + content.to_string(), + ); + } + + fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + } + + /// Extract `payload.nonce` from a cap-token JSON value; that's the + /// `cap_id` we track for revocation + mint provenance. + fn cap_id_of(cap: &Value) -> Option { + cap.get("payload") + .and_then(|p| p.get("nonce")) + .and_then(Value::as_str) + .map(str::to_string) + } +} + +#[async_trait] +impl Backend for InMemoryBackend { + async fn cap_mint( + &self, + op: CapMintOp, + req: CapMintRequest, + _session_bearer: &str, + ) -> Result { + let issued_at = Self::now_unix(); + let expires_at = issued_at + req.ttl_seconds; + let nonce = uuid::Uuid::new_v4().to_string(); + + { + let mut g = self.inner.lock().unwrap(); + g.minted.insert( + nonce.clone(), + MintedCap { + actor: req.actor_omni.clone(), + expires_at, + }, + ); + } + + Ok(json!({ + "payload": { + "operator_omni": req.operator_omni, + "actor_omni": req.actor_omni, + "service": req.service, + "op": format!("{op:?}"), + "data_class": op.data_class(), + "device_key_hash": req.device_key_hash, + "k3_epoch": 1, + "issued_at": issued_at, + "expires_at": expires_at, + "nonce": nonce + }, + "broker_sig": "in-memory-signature" + })) + } + + async fn cap_revoke(&self, cap_id: &str) -> Result { + let mut g = self.inner.lock().unwrap(); + if !g.minted.contains_key(cap_id) { + return Err(BackendError::Http { + status: 404, + body: format!("unknown cap_id: {cap_id}"), + }); + } + let newly_inserted = g.revoked.insert(cap_id.to_string()); + Ok(RevokeResult { + ok: true, + revocation: "in_memory".into(), + note: Some(if newly_inserted { + format!("dev-mode revoke; cap_id={cap_id} now denied for subsequent calls") + } else { + format!("dev-mode revoke; cap_id={cap_id} was already revoked (idempotent)") + }), + }) + } + + async fn memory_put(&self, input: MemoryPutInput) -> Result { + let cap_id = Self::cap_id_of(&input.cap).ok_or_else(|| BackendError::Http { + status: 400, + body: "cap missing payload.nonce".into(), + })?; + let actor = { + let g = self.inner.lock().unwrap(); + if g.revoked.contains(&cap_id) { + return Err(BackendError::Http { + status: 403, + body: format!("cap revoked: cap_id={cap_id}"), + }); + } + let minted = g.minted.get(&cap_id).ok_or_else(|| BackendError::Http { + status: 403, + body: format!("cap not minted by this backend: cap_id={cap_id}"), + })?; + if minted.expires_at <= Self::now_unix() { + return Err(BackendError::Http { + status: 403, + body: format!("cap expired: cap_id={cap_id}"), + }); + } + minted.actor.clone() + }; + + let plaintext = String::from_utf8( + base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + &input.plaintext_b64, + ) + .map_err(|e| BackendError::Parse(e.to_string()))?, + ) + .map_err(|e| BackendError::Parse(e.to_string()))?; + + let mut g = self.inner.lock().unwrap(); + g.memory + .insert((actor.clone(), input.namespace.clone()), plaintext); + + Ok(MemoryPutResult { + ok: true, + s3_key: format!("bots/{actor}/{}/in-memory.bin", input.namespace), + envelope_size: input.plaintext_b64.len(), + namespace: input.namespace, + }) + } + + async fn memory_get(&self, input: MemoryGetInput) -> Result { + let cap_id = Self::cap_id_of(&input.cap).ok_or_else(|| BackendError::Http { + status: 400, + body: "cap missing payload.nonce".into(), + })?; + let actor = { + let g = self.inner.lock().unwrap(); + if g.revoked.contains(&cap_id) { + return Err(BackendError::Http { + status: 403, + body: format!("cap revoked: cap_id={cap_id}"), + }); + } + let minted = g.minted.get(&cap_id).ok_or_else(|| BackendError::Http { + status: 403, + body: format!("cap not minted by this backend: cap_id={cap_id}"), + })?; + if minted.expires_at <= Self::now_unix() { + return Err(BackendError::Http { + status: 403, + body: format!("cap expired: cap_id={cap_id}"), + }); + } + minted.actor.clone() + }; + + let g = self.inner.lock().unwrap(); + let content = g + .memory + .get(&(actor, input.namespace.clone())) + .cloned() + .ok_or_else(|| BackendError::Http { + status: 404, + body: format!("no memory in namespace `{}`", input.namespace), + })?; + + Ok(MemoryGetResult { + ok: true, + plaintext_b64: base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + content.as_bytes(), + ), + namespace: input.namespace, + }) + } + + async fn audit_append( + &self, + input: AuditAppendInput, + ) -> Result { + // Compute a real content-dependent SHA-256 over a deterministic + // serialization of the input. Not the production worker's canonical + // CBOR envelope hash, but every distinct (actor, operator, op_kind, + // result, op_body, intent_text, ts) gets a distinct hash. Two + // identical-content appends in different ticks differ via the + // monotonically increasing append index. + let ts = Self::now_unix(); + let mut g = self.inner.lock().unwrap(); + let idx = g.audit.len(); + let op_body = serde_json::to_string(&input.op_body).unwrap_or_default(); + let intent = input.intent_text.clone().unwrap_or_default(); + let preimage = format!( + "{}|{}|{}|{}|{}|{}|{}|{}|{}", + input.actor_omni, + input.operator_omni, + input.op_kind, + input.result, + ts, + idx, + op_body, + intent, + "agentkeys-mcp-server/in-memory/v1", + ); + let mut hasher = Sha256::new(); + hasher.update(preimage.as_bytes()); + let digest = hasher.finalize(); + + g.audit.push(input); + + Ok(AuditAppendResult { + ok: true, + envelope_hash: format!("0x{}", hex::encode(digest)), + }) + } +} diff --git a/crates/agentkeys-mcp-server/src/backend/memory.rs b/crates/agentkeys-mcp-server/src/backend/memory.rs new file mode 100644 index 0000000..9aaa2f5 --- /dev/null +++ b/crates/agentkeys-mcp-server/src/backend/memory.rs @@ -0,0 +1,34 @@ +//! Memory-worker request shapes. +//! +//! Mirrors `agentkeys_worker_memory::handlers::{PutRequest, GetRequest}`. +//! Namespace is passed at the request body level for Phase 1 (per the PR +//! plan §8.2: lifting it into a SIGNED CapPayload field is M4 follow-up). + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Serialize)] +pub struct MemoryPutBody { + pub cap: Value, + pub plaintext_b64: String, + pub namespace: String, +} + +#[derive(Debug, Serialize)] +pub struct MemoryGetBody { + pub cap: Value, + pub namespace: String, +} + +#[derive(Debug, Deserialize)] +pub struct MemoryPutResp { + pub ok: bool, + pub s3_key: String, + pub envelope_size: usize, +} + +#[derive(Debug, Deserialize)] +pub struct MemoryGetResp { + pub ok: bool, + pub plaintext_b64: String, +} diff --git a/crates/agentkeys-mcp-server/src/backend/mod.rs b/crates/agentkeys-mcp-server/src/backend/mod.rs new file mode 100644 index 0000000..85e7c2f --- /dev/null +++ b/crates/agentkeys-mcp-server/src/backend/mod.rs @@ -0,0 +1,165 @@ +//! Backend abstraction — the broker / worker RPCs the MCP server adapts. +//! +//! The MCP server never owns persistent state itself. Every call routes +//! through this trait to either: +//! - the real broker / worker HTTP endpoints (`HttpBackend`), or +//! - a `MockBackend` controlled by the test (lives under +//! `tests/mock_backend.rs`). +//! +//! Splitting on a trait keeps unit tests deterministic and integration +//! tests free of real network dependencies. + +pub mod audit; +pub mod broker; +pub mod http_backend; +pub mod in_memory; +pub mod memory; + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub use http_backend::HttpBackend; +pub use in_memory::InMemoryBackend; + +/// Op discriminator that maps onto the four broker cap-mint endpoints. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CapMintOp { + CredStore, + CredFetch, + MemoryPut, + MemoryGet, +} + +impl CapMintOp { + pub fn parse(s: &str) -> Option { + match s { + "cred_store" => Some(Self::CredStore), + "cred_fetch" => Some(Self::CredFetch), + "memory_put" => Some(Self::MemoryPut), + "memory_get" => Some(Self::MemoryGet), + _ => None, + } + } + + pub fn broker_path(self) -> &'static str { + match self { + Self::CredStore => "/v1/cap/cred-store", + Self::CredFetch => "/v1/cap/cred-fetch", + Self::MemoryPut => "/v1/cap/memory-put", + Self::MemoryGet => "/v1/cap/memory-get", + } + } + + pub fn data_class(self) -> &'static str { + match self { + Self::CredStore | Self::CredFetch => "credentials", + Self::MemoryPut | Self::MemoryGet => "memory", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CapMintRequest { + pub operator_omni: String, + pub actor_omni: String, + pub service: String, + pub device_key_hash: String, + pub ttl_seconds: u64, +} + +/// Opaque cap-token blob — we never inspect the inside on this side; the +/// broker signs it and the worker verifies the signature. JSON value is +/// fine. +pub type CapToken = Value; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryPutInput { + pub cap: CapToken, + pub namespace: String, + pub plaintext_b64: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryGetInput { + pub cap: CapToken, + pub namespace: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryPutResult { + pub ok: bool, + pub s3_key: String, + pub envelope_size: usize, + pub namespace: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MemoryGetResult { + pub ok: bool, + pub plaintext_b64: String, + pub namespace: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuditAppendInput { + pub operator_omni: String, + pub actor_omni: String, + pub op_kind: u8, + pub op_body: Value, + pub result: u8, + pub intent_text: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuditAppendResult { + pub ok: bool, + pub envelope_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RevokeResult { + pub ok: bool, + pub revocation: String, + /// Present when `revocation != "online_immediate"` — tells the caller + /// what kind of revocation actually happened. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, +} + +#[derive(thiserror::Error, Debug)] +pub enum BackendError { + #[error("backend not configured: {0}")] + NotConfigured(&'static str), + + #[error("backend HTTP error ({status}): {body}")] + Http { status: u16, body: String }, + + #[error("backend transport error: {0}")] + Transport(String), + + #[error("backend response parse error: {0}")] + Parse(String), +} + +#[async_trait] +pub trait Backend: Send + Sync { + async fn cap_mint( + &self, + op: CapMintOp, + req: CapMintRequest, + session_bearer: &str, + ) -> Result; + + async fn cap_revoke(&self, cap_id: &str) -> Result; + + async fn memory_put(&self, input: MemoryPutInput) -> Result; + + async fn memory_get(&self, input: MemoryGetInput) -> Result; + + async fn audit_append( + &self, + input: AuditAppendInput, + ) -> Result; +} diff --git a/crates/agentkeys-mcp-server/src/config.rs b/crates/agentkeys-mcp-server/src/config.rs new file mode 100644 index 0000000..0047f74 --- /dev/null +++ b/crates/agentkeys-mcp-server/src/config.rs @@ -0,0 +1,227 @@ +//! Runtime configuration. +//! +//! Pulled from CLI flags + env vars; never from the workspace. The config is +//! built once at startup, cloned into every request handler via shared state, +//! and treated as immutable from then on. + +use clap::Parser; +use std::collections::HashMap; +use std::net::SocketAddr; + +#[derive(Parser, Debug, Clone)] +#[command( + name = "agentkeys-mcp-server", + about = "AgentKeys MCP server — Phase 1 (issue #107)" +)] +pub struct Cli { + /// Transport mode: `http` (default, for vendor deploys), `stdio` + /// (for local MCP hosts that spawn this as a subprocess), or + /// `mcp-endpoint` (connect outward to a xiaozhi-style relay URL). + #[arg(long, env = "MCP_TRANSPORT", default_value = "http")] + pub transport: String, + + /// MCP endpoint relay URL (xiaozhi `mcp-endpoint-server` style). + /// Required when `--transport=mcp-endpoint`. Format: + /// `ws[s]://host:port/mcp_endpoint/mcp/?token=...`. The token comes + /// from your xiaozhi agent's MCP endpoint config (智控台 → 智能体 + /// → 配置角色 → MCP接入点). + #[arg(long, env = "MCP_ENDPOINT")] + pub mcp_endpoint: Option, + + /// Backend mode: `http` (default — talks to real broker + workers via + /// `--broker-url` / `--memory-url` / `--audit-url`) or `in-memory` + /// (seeded with the three-act demo fixture; no external services + /// needed; for the fresh-laptop dev demo). + #[arg(long, env = "MCP_BACKEND", default_value = "http")] + pub backend: String, + + /// HTTP bind address. + #[arg(long, env = "MCP_LISTEN", default_value = "0.0.0.0:8088")] + pub listen: SocketAddr, + + /// Broker base URL (e.g. `https://broker.litentry.org`). + #[arg(long, env = "AGENTKEYS_BROKER_URL")] + pub broker_url: Option, + + /// Memory worker base URL. + #[arg(long, env = "AGENTKEYS_MEMORY_URL")] + pub memory_url: Option, + + /// Audit worker base URL. + #[arg(long, env = "AGENTKEYS_AUDIT_URL")] + pub audit_url: Option, + + /// Comma-separated `:` pairs that the HTTP + /// transport will accept. Empty = HTTP refuses every request with 401. + /// Format intentionally simple — vendor onboarding portal in M2 will + /// replace this with a persisted issuance store. + #[arg(long, env = "MCP_VENDOR_TOKENS", default_value = "")] + pub vendor_tokens: String, + + /// Daily spend cap (in RMB units) used by the deterministic policy + /// engine for `permission.check(scope="payment.spend")`. Per the + /// three-act demo storyboard in `agent-iam-strategy.md` §4.3. + #[arg(long, env = "MCP_DEFAULT_DAILY_SPEND_CAP_RMB", default_value_t = 500)] + pub default_daily_spend_cap_rmb: u64, + + /// Ambient actor omni — used when the LLM-side `tools/call` doesn't + /// supply an `actor`. In xiaozhi-hosted mode there's one agent per + /// MCP server, so the LLM shouldn't need to know its own actor id. + /// Defaults to the demo actor when --backend=in-memory. + #[arg(long, env = "MCP_DEFAULT_ACTOR")] + pub default_actor: Option, + + /// Ambient operator omni — same rationale as default_actor. + #[arg(long, env = "MCP_DEFAULT_OPERATOR_OMNI")] + pub default_operator_omni: Option, + + /// Ambient device-key hash — same rationale. Identifies the device the + /// agent runs on for cap-mint binding. + #[arg(long, env = "MCP_DEFAULT_DEVICE_KEY_HASH")] + pub default_device_key_hash: Option, +} + +#[derive(Debug, Clone)] +pub struct Config { + pub transport: Transport, + pub backend: BackendKind, + pub listen: SocketAddr, + pub mcp_endpoint: Option, + pub broker_url: Option, + pub memory_url: Option, + pub audit_url: Option, + /// vendor_id → bearer_token + pub vendor_tokens: HashMap, + pub default_daily_spend_cap_rmb: u64, + /// Ambient identity used when the LLM doesn't pass actor / operator / + /// device. Populated to demo fixture in InMemory mode; left None for + /// HTTP mode unless explicitly set via CLI/env. + pub default_actor: Option, + pub default_operator_omni: Option, + pub default_device_key_hash: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Transport { + Http, + Stdio, + /// Connect outward to a xiaozhi MCP-endpoint relay URL as a WebSocket + /// client. The relay forwards messages between this server (as the + /// tool) and the xiaozhi-server/cloud (as the client). No HTTP listen + /// socket; no firmware on the xiaozhi device needs to change. + McpEndpoint, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BackendKind { + Http, + InMemory, +} + +impl Config { + pub fn from_cli(cli: Cli) -> anyhow::Result { + let transport = match cli.transport.as_str() { + "http" => Transport::Http, + "stdio" => Transport::Stdio, + "mcp-endpoint" | "mcp_endpoint" => Transport::McpEndpoint, + other => { + anyhow::bail!("unknown transport `{other}` (expected http|stdio|mcp-endpoint)") + } + }; + + if transport == Transport::McpEndpoint && cli.mcp_endpoint.is_none() { + anyhow::bail!( + "--transport=mcp-endpoint requires --mcp-endpoint (or env MCP_ENDPOINT)" + ); + } + + let backend = match cli.backend.as_str() { + "http" => BackendKind::Http, + "in-memory" | "in_memory" => BackendKind::InMemory, + other => anyhow::bail!("unknown backend `{other}` (expected http|in-memory)"), + }; + + let mut vendor_tokens = HashMap::new(); + for pair in cli + .vendor_tokens + .split(',') + .filter(|s| !s.trim().is_empty()) + { + let (vendor, token) = pair + .split_once(':') + .ok_or_else(|| anyhow::anyhow!("malformed vendor_token entry: {pair}"))?; + vendor_tokens.insert(vendor.trim().to_string(), token.trim().to_string()); + } + + // In-memory dev mode auto-seeds a default vendor token if the + // operator didn't supply one, so the runbook stays one-command. + if backend == BackendKind::InMemory && vendor_tokens.is_empty() { + vendor_tokens.insert("magiclick".into(), "demo-tok".into()); + } + + // In-memory dev mode also auto-seeds the demo identity so the + // LLM can call memory.get with just {"namespace": "travel"}. + // The DEMO_* constants come from backend/in_memory.rs and match + // what the three-act fixture seeds. + let (default_actor, default_operator_omni, default_device_key_hash) = + if backend == BackendKind::InMemory { + use crate::backend::in_memory::{DEMO_ACTOR, DEMO_DEVICE_KEY_HASH, DEMO_OPERATOR}; + ( + Some(cli.default_actor.unwrap_or_else(|| DEMO_ACTOR.into())), + Some( + cli.default_operator_omni + .unwrap_or_else(|| DEMO_OPERATOR.into()), + ), + Some( + cli.default_device_key_hash + .unwrap_or_else(|| DEMO_DEVICE_KEY_HASH.into()), + ), + ) + } else { + ( + cli.default_actor, + cli.default_operator_omni, + cli.default_device_key_hash, + ) + }; + + Ok(Self { + transport, + backend, + listen: cli.listen, + mcp_endpoint: cli.mcp_endpoint, + broker_url: cli.broker_url, + memory_url: cli.memory_url, + audit_url: cli.audit_url, + vendor_tokens, + default_daily_spend_cap_rmb: cli.default_daily_spend_cap_rmb, + default_actor, + default_operator_omni, + default_device_key_hash, + }) + } + + /// Convenience builder for tests — no parsing, no env reads. + pub fn for_tests() -> Self { + Self { + transport: Transport::Http, + backend: BackendKind::Http, + listen: "127.0.0.1:0".parse().unwrap(), + mcp_endpoint: None, + broker_url: None, + memory_url: None, + audit_url: None, + vendor_tokens: HashMap::new(), + default_daily_spend_cap_rmb: 500, + default_actor: None, + default_operator_omni: None, + default_device_key_hash: None, + } + } + + pub fn with_vendor_token(mut self, vendor: &str, token: &str) -> Self { + self.vendor_tokens + .insert(vendor.to_string(), token.to_string()); + self + } +} diff --git a/crates/agentkeys-mcp-server/src/errors.rs b/crates/agentkeys-mcp-server/src/errors.rs new file mode 100644 index 0000000..c412dc9 --- /dev/null +++ b/crates/agentkeys-mcp-server/src/errors.rs @@ -0,0 +1,68 @@ +//! Error envelope shared across the MCP server. +//! +//! Tool errors surface to the LLM host as JSON-RPC error responses; this +//! module owns the conversion from internal `McpError` to the wire shape +//! so individual tool handlers can stay focused on their happy path. + +use crate::mcp::{codes, Response}; +use serde_json::{json, Value}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum McpError { + #[error("invalid params: {0}")] + InvalidParams(String), + + #[error("tool not found: {0}")] + ToolNotFound(String), + + #[error("unauthorized: {0}")] + Unauthorized(String), + + #[error("forbidden: {0}")] + Forbidden(String), + + #[error("backend call failed: {0}")] + Backend(String), + + #[error("not implemented in v1")] + NotImplementedV1 { + scheduled_for: &'static str, + spec_url: &'static str, + }, + + #[error("internal error: {0}")] + Internal(String), +} + +impl McpError { + pub fn into_response(self, id: Option) -> Response { + match self { + McpError::InvalidParams(msg) => Response::error(id, codes::INVALID_PARAMS, msg), + McpError::ToolNotFound(name) => Response::error( + id, + codes::METHOD_NOT_FOUND, + format!("tool not found: {name}"), + ), + McpError::Unauthorized(msg) => Response::error(id, codes::UNAUTHORIZED, msg), + McpError::Forbidden(msg) => Response::error(id, codes::FORBIDDEN, msg), + McpError::Backend(msg) => Response::error(id, codes::TOOL_ERROR, msg), + McpError::Internal(msg) => Response::error(id, codes::INTERNAL_ERROR, msg), + McpError::NotImplementedV1 { + scheduled_for, + spec_url, + } => Response::error_with_data( + id, + codes::TOOL_ERROR, + "not_implemented_in_v1", + json!({ + "error": "not_implemented_in_v1", + "scheduled_for": scheduled_for, + "spec_url": spec_url, + }), + ), + } + } +} + +pub type McpResult = Result; diff --git a/crates/agentkeys-mcp-server/src/lib.rs b/crates/agentkeys-mcp-server/src/lib.rs new file mode 100644 index 0000000..0707c8a --- /dev/null +++ b/crates/agentkeys-mcp-server/src/lib.rs @@ -0,0 +1,23 @@ +//! AgentKeys MCP server — Phase 1 (issue #107). +//! +//! Thin adapter layer over the broker + worker RPCs. Exposes the +//! 7 active tools + 3 schema-only stubs that turn the Phase 0 backend +//! into something an MCP-speaking LLM host (xiaozhi-server, Volcano Ark) +//! can call. +//! +//! Library exports exist so integration tests (`tests/three_acts.rs`) +//! can build a `Server` with a mocked `Backend` and exercise the JSON-RPC +//! plumbing without standing up real HTTP listeners or external services. + +pub mod auth; +pub mod backend; +pub mod config; +pub mod errors; +pub mod mcp; +pub mod policy; +pub mod server; +pub mod tools; +pub mod transport; + +pub use config::Config; +pub use server::Server; diff --git a/crates/agentkeys-mcp-server/src/main.rs b/crates/agentkeys-mcp-server/src/main.rs new file mode 100644 index 0000000..9b4b1ad --- /dev/null +++ b/crates/agentkeys-mcp-server/src/main.rs @@ -0,0 +1,77 @@ +//! Entry point — parse CLI, build a `Server`, run the chosen transport. + +use clap::Parser; +use std::sync::Arc; + +use agentkeys_mcp_server::{ + backend::{Backend, HttpBackend, InMemoryBackend}, + config::{BackendKind, Cli, Config, Transport}, + server::Server, + transport, +}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // rustls 0.23 requires a process-level CryptoProvider. tokio-tungstenite + // pulls rustls in with no provider feature; without this install_default + // the McpEndpoint transport panics on the first wss:// connect. + let _ = rustls::crypto::ring::default_provider().install_default(); + + // Log to stderr — stdio transport reserves stdout exclusively for + // JSON-RPC frames. Mixing tracing output into stdout corrupts the + // wire and Claude Desktop / Claude Code disconnect immediately. + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .with_writer(std::io::stderr) + .init(); + + let cli = Cli::parse(); + let config = Config::from_cli(cli)?; + + let backend: Arc = match config.backend { + BackendKind::Http => Arc::new(HttpBackend::new( + config.broker_url.clone(), + config.memory_url.clone(), + config.audit_url.clone(), + )), + BackendKind::InMemory => { + tracing::info!( + "backend=in-memory (dev demo); seeded with three-act fixture (actor 0xa0c7…01a0c7)" + ); + Arc::new(InMemoryBackend::new_with_demo_fixture()) + } + }; + let server = Arc::new(Server::new(config.clone(), backend)); + + match config.transport { + Transport::Http => { + let app = transport::http_router(server); + let listener = tokio::net::TcpListener::bind(&config.listen).await?; + tracing::info!(addr = %config.listen, "agentkeys-mcp-server listening (HTTP)"); + axum::serve(listener, app).await?; + } + Transport::Stdio => { + tracing::info!("agentkeys-mcp-server running (stdio)"); + transport::run_stdio(server).await?; + } + Transport::McpEndpoint => { + let url = config.mcp_endpoint.clone().expect( + "mcp_endpoint required for McpEndpoint transport — validated in Config::from_cli", + ); + // Don't log the raw URL — it carries the bearer JWT. + // run_mcp_endpoint redacts internally. + let host = url + .split("://") + .nth(1) + .and_then(|rest| rest.split(['/', '?']).next()) + .unwrap_or("?"); + tracing::info!(host, "agentkeys-mcp-server running (mcp-endpoint)"); + transport::run_mcp_endpoint(server, url).await?; + } + } + + Ok(()) +} diff --git a/crates/agentkeys-mcp-server/src/mcp.rs b/crates/agentkeys-mcp-server/src/mcp.rs new file mode 100644 index 0000000..a3a5e8d --- /dev/null +++ b/crates/agentkeys-mcp-server/src/mcp.rs @@ -0,0 +1,113 @@ +//! JSON-RPC 2.0 + MCP protocol envelopes. +//! +//! MCP layers a tiny set of methods on top of JSON-RPC 2.0: +//! - `initialize` — handshake; client advertises capabilities, server replies. +//! - `tools/list` — returns the JSON-Schema for every tool. +//! - `tools/call` — invokes one tool by name with arguments. +//! - `ping` — keep-alive. +//! +//! This module owns the wire types only. The dispatcher in `server` decides +//! what each method does. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const JSONRPC_VERSION: &str = "2.0"; +pub const MCP_PROTOCOL_VERSION: &str = "2025-03-26"; +pub const MCP_SERVER_NAME: &str = "agentkeys-mcp-server"; +pub const MCP_SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Request { + pub jsonrpc: String, + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Response { + pub jsonrpc: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorObject { + pub code: i64, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +impl Response { + pub fn success(id: Option, result: Value) -> Self { + Self { + jsonrpc: JSONRPC_VERSION.into(), + result: Some(result), + error: None, + id, + } + } + + pub fn error(id: Option, code: i64, message: impl Into) -> Self { + Self { + jsonrpc: JSONRPC_VERSION.into(), + result: None, + error: Some(ErrorObject { + code, + message: message.into(), + data: None, + }), + id, + } + } + + pub fn error_with_data( + id: Option, + code: i64, + message: impl Into, + data: Value, + ) -> Self { + Self { + jsonrpc: JSONRPC_VERSION.into(), + result: None, + error: Some(ErrorObject { + code, + message: message.into(), + data: Some(data), + }), + id, + } + } +} + +/// JSON-RPC 2.0 standard error codes + MCP extensions. +pub mod codes { + pub const PARSE_ERROR: i64 = -32700; + pub const INVALID_REQUEST: i64 = -32600; + pub const METHOD_NOT_FOUND: i64 = -32601; + pub const INVALID_PARAMS: i64 = -32602; + pub const INTERNAL_ERROR: i64 = -32603; + /// MCP application-level: tool execution failed (vs protocol error). + pub const TOOL_ERROR: i64 = -32000; + /// MCP application-level: auth failed. + pub const UNAUTHORIZED: i64 = -32001; + /// MCP application-level: actor scope mismatch. + pub const FORBIDDEN: i64 = -32003; +} + +/// MCP tool descriptor. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolDescriptor { + pub name: String, + pub description: String, + #[serde(rename = "inputSchema")] + pub input_schema: Value, +} diff --git a/crates/agentkeys-mcp-server/src/policy.rs b/crates/agentkeys-mcp-server/src/policy.rs new file mode 100644 index 0000000..eeb5f49 --- /dev/null +++ b/crates/agentkeys-mcp-server/src/policy.rs @@ -0,0 +1,158 @@ +//! Deterministic policy engine for `agentkeys.permission.check`. +//! +//! HARD INVARIANT: no LLM, no inference, no network call. The decision is +//! a pure function of `(actor, scope, params, policy_table)`. This is the +//! whole point of Act 2 of the three-act demo per +//! `agent-iam-strategy.md` §4.3 — "policy decides, not the LLM." +//! +//! v1 ships a built-in policy table sufficient for the demo: +//! - `memory.read` / `memory.write` — accepted for every actor +//! - `payment.spend` — accepted if `amount_rmb <= daily_cap`; denied +//! otherwise with the reason string the storyboard quotes +//! - everything else — denied by default (closed-world) +//! +//! Future work (M4): per-actor / per-vendor policy overrides, time-of-day +//! windows, multi-factor approval, ask-parent flow. The `Verdict::AskParent` +//! variant is present so callers can wire it up later without a wire-format +//! break. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Verdict { + Accept, + Deny, + AskParent, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Decision { + pub verdict: Verdict, + pub scope: String, + /// Machine-readable reason code — stable across versions, used by + /// audit + parent UI. + pub reason: String, + /// Human-readable explanation. Phrasing is what shows up in the + /// parent-control UI; treat as UX-facing. + pub explanation: String, +} + +pub struct PolicyEngine { + pub daily_spend_cap_rmb: u64, +} + +impl PolicyEngine { + pub fn new(daily_spend_cap_rmb: u64) -> Self { + Self { + daily_spend_cap_rmb, + } + } + + /// Evaluate `(actor, scope, params)` against the built-in policy table. + /// `actor` is currently unused but kept in the signature because the + /// follow-up M4 work key the table on actor. + pub fn evaluate(&self, _actor: &str, scope: &str, params: &Value) -> Decision { + match scope { + "memory.read" | "memory.write" => Decision { + verdict: Verdict::Accept, + scope: scope.to_string(), + reason: "default_allow_memory".into(), + explanation: "memory access is allowed for the calling actor".into(), + }, + "payment.spend" => self.evaluate_payment(scope, params), + _ => Decision { + verdict: Verdict::Deny, + scope: scope.to_string(), + reason: "scope_not_in_policy_table".into(), + explanation: format!( + "scope `{scope}` is not in the policy table (closed-world default deny)" + ), + }, + } + } + + fn evaluate_payment(&self, scope: &str, params: &Value) -> Decision { + // Accept either `amount_rmb` (used in the demo storyboard) or + // `amount` for forward-compat. + let amount = params + .get("amount_rmb") + .or_else(|| params.get("amount")) + .and_then(|v| v.as_u64()); + + let Some(amount) = amount else { + return Decision { + verdict: Verdict::Deny, + scope: scope.to_string(), + reason: "missing_amount".into(), + explanation: "payment.spend requires `amount_rmb` in params".into(), + }; + }; + + if amount > self.daily_spend_cap_rmb { + return Decision { + verdict: Verdict::Deny, + scope: scope.to_string(), + reason: "daily_spend_cap_exceeded".into(), + explanation: format!( + "cap={}, requested={}, period=daily", + self.daily_spend_cap_rmb, amount + ), + }; + } + + Decision { + verdict: Verdict::Accept, + scope: scope.to_string(), + reason: "within_daily_cap".into(), + explanation: format!("amount {amount} ≤ daily cap {}", self.daily_spend_cap_rmb), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn engine() -> PolicyEngine { + PolicyEngine::new(500) + } + + #[test] + fn memory_read_accept() { + let d = engine().evaluate("O_a", "memory.read", &json!({})); + assert_eq!(d.verdict, Verdict::Accept); + } + + #[test] + fn payment_within_cap_accept() { + let d = engine().evaluate("O_a", "payment.spend", &json!({"amount_rmb": 200})); + assert_eq!(d.verdict, Verdict::Accept); + } + + #[test] + fn payment_over_cap_denied_with_reason() { + let d = engine().evaluate("O_a", "payment.spend", &json!({"amount_rmb": 600})); + assert_eq!(d.verdict, Verdict::Deny); + assert_eq!(d.reason, "daily_spend_cap_exceeded"); + // Storyboard Act 2 quotes the cap/requested/period explanation. + assert!(d.explanation.contains("cap=500")); + assert!(d.explanation.contains("requested=600")); + } + + #[test] + fn payment_missing_amount_denied() { + let d = engine().evaluate("O_a", "payment.spend", &json!({})); + assert_eq!(d.verdict, Verdict::Deny); + assert_eq!(d.reason, "missing_amount"); + } + + #[test] + fn unknown_scope_denied_closed_world() { + let d = engine().evaluate("O_a", "nuke.launch", &json!({})); + assert_eq!(d.verdict, Verdict::Deny); + assert_eq!(d.reason, "scope_not_in_policy_table"); + } +} diff --git a/crates/agentkeys-mcp-server/src/server.rs b/crates/agentkeys-mcp-server/src/server.rs new file mode 100644 index 0000000..4c5bd7e --- /dev/null +++ b/crates/agentkeys-mcp-server/src/server.rs @@ -0,0 +1,202 @@ +//! Server — owns shared state and dispatches MCP method calls. +//! +//! The server holds: +//! - `Config` (immutable) +//! - `Backend` trait object (HTTP impl in prod, mock in tests) +//! - `PolicyEngine` for `permission.check` +//! +//! Every request flows through `dispatch`, which: +//! 1. Parses the JSON-RPC envelope +//! 2. Routes by method name (`initialize`, `tools/list`, `tools/call`, `ping`) +//! 3. For `tools/call`: routes again by tool name to the right handler +//! 4. Wraps the handler's `McpResult` into the MCP response envelope +//! +//! The HTTP transport handles auth headers before calling `dispatch`. The +//! stdio transport calls `dispatch` directly with a `local_stdio` caller. + +use serde_json::{json, Value}; +use std::sync::Arc; + +use crate::auth::CallerContext; +use crate::backend::Backend; +use crate::config::Config; +use crate::errors::{McpError, McpResult}; +use crate::mcp::{ + self, codes, Request, Response, ToolDescriptor, MCP_PROTOCOL_VERSION, MCP_SERVER_NAME, + MCP_SERVER_VERSION, +}; +use crate::policy::PolicyEngine; +use crate::tools; + +pub struct Server { + pub config: Config, + pub backend: Arc, + pub policy: PolicyEngine, +} + +impl Server { + pub fn new(config: Config, backend: Arc) -> Self { + let policy = PolicyEngine::new(config.default_daily_spend_cap_rmb); + Self { + config, + backend, + policy, + } + } + + /// Entry point for both transports. Caller has already been auth'd at + /// the transport layer; pass `CallerContext::local_stdio()` for stdio. + /// `session_bearer` is forwarded to broker cap-mint as `Authorization`; + /// in stdio mode it's typically empty. + pub async fn dispatch( + &self, + caller: &CallerContext, + session_bearer: &str, + req: Request, + ) -> Response { + if req.jsonrpc != mcp::JSONRPC_VERSION { + return Response::error( + req.id.clone(), + codes::INVALID_REQUEST, + format!("unsupported jsonrpc version `{}`", req.jsonrpc), + ); + } + + let id = req.id.clone(); + + match req.method.as_str() { + "initialize" => self.handle_initialize(id, req.params), + "tools/list" => self.handle_tools_list(id), + "tools/call" => { + self.handle_tools_call(caller, session_bearer, id, req.params) + .await + } + "ping" => Response::success(id, json!({})), + other => Response::error( + id, + codes::METHOD_NOT_FOUND, + format!("method not found: {other}"), + ), + } + } + + fn handle_initialize(&self, id: Option, params: Option) -> Response { + // Negotiate protocol version: echo the client's `protocolVersion` + // when present and recognizable, fall back to our own. Xiaozhi's + // hosted relay sends "2024-11-05"; if we respond with a different + // (newer) string, it closes the WS immediately as an unsupported- + // version signal. + const KNOWN_VERSIONS: &[&str] = &["2024-11-05", "2025-03-26"]; + let negotiated_version = params + .as_ref() + .and_then(|p| p.get("protocolVersion")) + .and_then(|v| v.as_str()) + .filter(|v| KNOWN_VERSIONS.contains(v)) + .unwrap_or(MCP_PROTOCOL_VERSION); + + Response::success( + id, + json!({ + "protocolVersion": negotiated_version, + "capabilities": { + "tools": {"listChanged": false} + }, + "serverInfo": { + "name": MCP_SERVER_NAME, + "version": MCP_SERVER_VERSION + } + }), + ) + } + + fn handle_tools_list(&self, id: Option) -> Response { + let tools: Vec = tools::all_descriptors(); + Response::success(id, json!({"tools": tools})) + } + + async fn handle_tools_call( + &self, + caller: &CallerContext, + session_bearer: &str, + id: Option, + params: Option, + ) -> Response { + let params = match params { + Some(p) => p, + None => { + return McpError::InvalidParams("tools/call requires params".into()) + .into_response(id) + } + }; + + let name = match params.get("name").and_then(|v| v.as_str()) { + Some(n) => n.to_string(), + None => { + return McpError::InvalidParams("tools/call missing `name`".into()) + .into_response(id) + } + }; + + let empty = json!({}); + let args = params.get("arguments").unwrap_or(&empty).clone(); + + let result: McpResult = match name.as_str() { + tools::TOOL_IDENTITY_WHOAMI => tools::identity::call(caller, &self.config, &args), + tools::TOOL_PERMISSION_CHECK => { + tools::permission::call(caller, &self.policy, &self.config, &args) + } + tools::TOOL_CAP_MINT => { + tools::cap::mint( + caller, + self.backend.clone(), + &self.config, + session_bearer, + &args, + ) + .await + } + tools::TOOL_CAP_REVOKE => tools::cap::revoke(self.backend.clone(), &args).await, + tools::TOOL_MEMORY_PUT => { + tools::memory::put( + caller, + self.backend.clone(), + &self.config, + session_bearer, + &args, + ) + .await + } + tools::TOOL_MEMORY_GET => { + tools::memory::get( + caller, + self.backend.clone(), + &self.config, + session_bearer, + &args, + ) + .await + } + tools::TOOL_AUDIT_APPEND => { + tools::audit::call(caller, self.backend.clone(), &args).await + } + tools::TOOL_DELEGATION_GRANT + | tools::TOOL_DELEGATION_REVOKE + | tools::TOOL_APPROVAL_REQUEST => Err(tools::stubs::not_implemented_v1()), + other => Err(McpError::ToolNotFound(other.to_string())), + }; + + match result { + Ok(value) => Response::success( + id, + json!({ + "content": [ + {"type": "text", "text": value.to_string()} + ], + "structuredContent": value, + "isError": false + }), + ), + Err(e) => e.into_response(id), + } + } +} diff --git a/crates/agentkeys-mcp-server/src/tools/audit.rs b/crates/agentkeys-mcp-server/src/tools/audit.rs new file mode 100644 index 0000000..fde6510 --- /dev/null +++ b/crates/agentkeys-mcp-server/src/tools/audit.rs @@ -0,0 +1,69 @@ +//! `agentkeys.audit.append` — adapter onto worker-audit /v1/audit/append/v2. +//! +//! The MCP wire shape is `(actor, event)`. We unpack the event into the +//! worker's `AppendV2Request` shape so audit envelopes coming from MCP +//! land in the same store as on-broker emissions. + +use serde_json::{json, Value}; +use std::sync::Arc; + +use crate::auth::CallerContext; +use crate::backend::{AuditAppendInput, Backend}; +use crate::errors::{McpError, McpResult}; + +pub async fn call( + caller: &CallerContext, + backend: Arc, + params: &Value, +) -> McpResult { + let actor = params + .get("actor") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError::InvalidParams("missing `actor`".into()))?; + + let event = params + .get("event") + .ok_or_else(|| McpError::InvalidParams("missing `event`".into()))?; + + let operator_omni = event + .get("operator_omni") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError::InvalidParams("missing `event.operator_omni`".into()))? + .to_string(); + let op_kind = event + .get("op_kind") + .and_then(|v| v.as_u64()) + .ok_or_else(|| McpError::InvalidParams("missing `event.op_kind`".into()))? + as u8; + let result = event + .get("result") + .and_then(|v| v.as_u64()) + .ok_or_else(|| McpError::InvalidParams("missing `event.result`".into()))? + as u8; + let op_body = event.get("op_body").cloned().unwrap_or_else(|| json!({})); + let intent_text = event + .get("intent_text") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + if caller.actor_omni != "*" { + crate::auth::check_actor_param(&caller.actor_omni, actor)?; + } + + let appended = backend + .audit_append(AuditAppendInput { + operator_omni, + actor_omni: actor.to_string(), + op_kind, + op_body, + result, + intent_text, + }) + .await + .map_err(|e| McpError::Backend(format!("audit_append failed: {e}")))?; + + Ok(json!({ + "ok": appended.ok, + "envelope_hash": appended.envelope_hash, + })) +} diff --git a/crates/agentkeys-mcp-server/src/tools/cap.rs b/crates/agentkeys-mcp-server/src/tools/cap.rs new file mode 100644 index 0000000..479b931 --- /dev/null +++ b/crates/agentkeys-mcp-server/src/tools/cap.rs @@ -0,0 +1,107 @@ +//! `agentkeys.cap.mint` + `agentkeys.cap.revoke` — broker adapter. + +use serde_json::{json, Value}; +use std::sync::Arc; + +use crate::auth::CallerContext; +use crate::backend::{Backend, CapMintOp, CapMintRequest}; +use crate::config::Config; +use crate::errors::{McpError, McpResult}; + +const DEFAULT_TTL_SECONDS: u64 = 300; + +pub async fn mint( + caller: &CallerContext, + backend: Arc, + config: &Config, + session_bearer: &str, + params: &Value, +) -> McpResult { + let actor = params + .get("actor") + .and_then(|v| v.as_str()) + .or(config.default_actor.as_deref()) + .ok_or_else(|| { + McpError::InvalidParams("missing `actor` and no MCP_DEFAULT_ACTOR set".into()) + })?; + + let op_str = params + .get("op") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError::InvalidParams("missing `op`".into()))?; + let op = CapMintOp::parse(op_str) + .ok_or_else(|| McpError::InvalidParams(format!("unknown op `{op_str}`")))?; + + let empty = json!({}); + let inner = params.get("params").unwrap_or(&empty); + + let operator_omni = inner + .get("operator_omni") + .and_then(|v| v.as_str()) + .or(config.default_operator_omni.as_deref()) + .ok_or_else(|| { + McpError::InvalidParams( + "missing `params.operator_omni` and no MCP_DEFAULT_OPERATOR_OMNI set".into(), + ) + })? + .to_string(); + let service = inner + .get("service") + .and_then(|v| v.as_str()) + .unwrap_or(op.data_class()) + .to_string(); + let device_key_hash = inner + .get("device_key_hash") + .and_then(|v| v.as_str()) + .or(config.default_device_key_hash.as_deref()) + .ok_or_else(|| { + McpError::InvalidParams( + "missing `params.device_key_hash` and no MCP_DEFAULT_DEVICE_KEY_HASH set".into(), + ) + })? + .to_string(); + + let ttl_seconds = params + .get("ttl") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_TTL_SECONDS); + + if caller.actor_omni != "*" { + crate::auth::check_actor_param(&caller.actor_omni, actor)?; + } + + let req = CapMintRequest { + operator_omni, + actor_omni: actor.to_string(), + service, + device_key_hash, + ttl_seconds, + }; + + let cap = backend + .cap_mint(op, req, session_bearer) + .await + .map_err(|e| McpError::Backend(e.to_string()))?; + + Ok(json!({ + "ok": true, + "op": op_str, + "data_class": op.data_class(), + "cap": cap, + "ttl_seconds": ttl_seconds, + })) +} + +pub async fn revoke(backend: Arc, params: &Value) -> McpResult { + let cap_id = params + .get("cap_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError::InvalidParams("missing `cap_id`".into()))?; + + let result = backend + .cap_revoke(cap_id) + .await + .map_err(|e| McpError::Backend(e.to_string()))?; + + Ok(serde_json::to_value(result).unwrap_or(json!({"ok": false}))) +} diff --git a/crates/agentkeys-mcp-server/src/tools/identity.rs b/crates/agentkeys-mcp-server/src/tools/identity.rs new file mode 100644 index 0000000..2401c0e --- /dev/null +++ b/crates/agentkeys-mcp-server/src/tools/identity.rs @@ -0,0 +1,92 @@ +//! `agentkeys.identity.whoami` — return what the calling actor is. +//! +//! M1 synthesizes the answer locally from the auth context. The broker +//! does not yet expose `/v1/identity/whoami` — that endpoint lands paired +//! with the vendor onboarding portal in M4. This deliberately matches +//! the M1 scope in `milestones-roadmap.md`: the field shape is real, +//! the source of truth shifts when the broker endpoint lands. + +use serde_json::{json, Value}; + +use crate::auth::CallerContext; +use crate::config::Config; +use crate::errors::{McpError, McpResult}; + +pub fn call(caller: &CallerContext, config: &Config, params: &Value) -> McpResult { + let actor = params + .get("actor") + .and_then(|v| v.as_str()) + .or(config.default_actor.as_deref()) + .ok_or_else(|| { + McpError::InvalidParams("missing `actor` and no MCP_DEFAULT_ACTOR set".into()) + })?; + + if caller.actor_omni != "*" { + crate::auth::check_actor_param(&caller.actor_omni, actor)?; + } + + Ok(json!({ + "omni": actor, + "display_name": format!("actor:{actor}"), + "vendor": caller.vendor_id, + "scopes": [ + "memory.read", + "memory.write", + "payment.spend" + ], + "_note": "M1 synthesizes locally; broker /v1/identity/whoami lands in M4" + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg() -> Config { + Config::for_tests() + } + + fn cfg_with_default(actor: &str) -> Config { + let mut c = Config::for_tests(); + c.default_actor = Some(actor.into()); + c + } + + #[test] + fn happy_path() { + let caller = CallerContext::new("vendor-a", "O_alice"); + let v = call(&caller, &cfg(), &json!({"actor": "O_alice"})).unwrap(); + assert_eq!(v["omni"], "O_alice"); + assert_eq!(v["vendor"], "vendor-a"); + assert!(v["scopes"].is_array()); + } + + #[test] + fn falls_back_to_config_default_when_actor_omitted() { + let caller = CallerContext::new( + "vendor-a", + "0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7", + ); + let v = call( + &caller, + &cfg_with_default("0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7"), + &json!({}), + ) + .unwrap(); + assert!(v["omni"].as_str().unwrap().starts_with("0xa0c701")); + } + + #[test] + fn missing_actor_and_no_default_is_invalid_params() { + let caller = CallerContext::new("vendor-a", "O_alice"); + let err = call(&caller, &cfg(), &json!({})).unwrap_err(); + assert!(matches!(err, McpError::InvalidParams(_))); + } + + #[test] + fn actor_mismatch_is_forbidden() { + let caller = CallerContext::new("vendor-a", "O_alice"); + let err = call(&caller, &cfg(), &json!({"actor": "O_bob"})).unwrap_err(); + assert!(matches!(err, McpError::Forbidden(_))); + } +} diff --git a/crates/agentkeys-mcp-server/src/tools/memory.rs b/crates/agentkeys-mcp-server/src/tools/memory.rs new file mode 100644 index 0000000..e2f9a14 --- /dev/null +++ b/crates/agentkeys-mcp-server/src/tools/memory.rs @@ -0,0 +1,178 @@ +//! `agentkeys.memory.get` + `agentkeys.memory.put` — namespace-scoped +//! memory access. Internally: mint a cap → call the memory worker. +//! +//! Per Phase 1 namespace scope (issue #108 partial): the namespace is +//! a request-body field, not yet a signed CapPayload field. M4 follow-up +//! lifts it into the cap so the worker can enforce cryptographically. + +use base64::Engine; +use serde_json::{json, Value}; +use std::sync::Arc; + +use crate::auth::CallerContext; +use crate::backend::{Backend, CapMintOp, CapMintRequest, MemoryGetInput, MemoryPutInput}; +use crate::config::Config; +use crate::errors::{McpError, McpResult}; + +const DEFAULT_TTL_SECONDS: u64 = 300; + +/// Resolve an identity field — LLM-supplied param wins, else config default, +/// else a precise error so the operator can fix the env. +fn resolve_ident<'a>( + params: &'a Value, + key: &str, + fallback: Option<&'a str>, +) -> McpResult<&'a str> { + params + .get(key) + .and_then(|v| v.as_str()) + .or(fallback) + .ok_or_else(|| { + McpError::InvalidParams(format!( + "missing `{key}` and no MCP_DEFAULT_{} configured \ + — set it in /etc/agentkeys/mcp.env or pass via --{}", + key.to_uppercase(), + key.replace('_', "-") + )) + }) +} + +pub async fn put( + caller: &CallerContext, + backend: Arc, + config: &Config, + session_bearer: &str, + params: &Value, +) -> McpResult { + let actor = resolve_ident(params, "actor", config.default_actor.as_deref())?; + let namespace = params + .get("namespace") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError::InvalidParams("missing `namespace`".into()))?; + let content = params + .get("content") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError::InvalidParams("missing `content`".into()))?; + let operator_omni = resolve_ident( + params, + "operator_omni", + config.default_operator_omni.as_deref(), + )?; + let device_key_hash = resolve_ident( + params, + "device_key_hash", + config.default_device_key_hash.as_deref(), + )?; + let service = params + .get("service") + .and_then(|v| v.as_str()) + .unwrap_or("memory") + .to_string(); + let ttl_seconds = params + .get("ttl_seconds") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_TTL_SECONDS); + + if caller.actor_omni != "*" { + crate::auth::check_actor_param(&caller.actor_omni, actor)?; + } + + let cap_req = CapMintRequest { + operator_omni: operator_omni.to_string(), + actor_omni: actor.to_string(), + service, + device_key_hash: device_key_hash.to_string(), + ttl_seconds, + }; + let cap = backend + .cap_mint(CapMintOp::MemoryPut, cap_req, session_bearer) + .await + .map_err(|e| McpError::Backend(format!("cap_mint failed: {e}")))?; + + let plaintext_b64 = base64::engine::general_purpose::STANDARD.encode(content.as_bytes()); + + let result = backend + .memory_put(MemoryPutInput { + cap, + namespace: namespace.to_string(), + plaintext_b64, + }) + .await + .map_err(|e| McpError::Backend(format!("memory_put failed: {e}")))?; + + Ok(json!({ + "ok": result.ok, + "namespace": result.namespace, + "s3_key": result.s3_key, + "envelope_size": result.envelope_size, + })) +} + +pub async fn get( + caller: &CallerContext, + backend: Arc, + config: &Config, + session_bearer: &str, + params: &Value, +) -> McpResult { + let actor = resolve_ident(params, "actor", config.default_actor.as_deref())?; + let namespace = params + .get("namespace") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError::InvalidParams("missing `namespace`".into()))?; + let operator_omni = resolve_ident( + params, + "operator_omni", + config.default_operator_omni.as_deref(), + )?; + let device_key_hash = resolve_ident( + params, + "device_key_hash", + config.default_device_key_hash.as_deref(), + )?; + let service = params + .get("service") + .and_then(|v| v.as_str()) + .unwrap_or("memory") + .to_string(); + let ttl_seconds = params + .get("ttl_seconds") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_TTL_SECONDS); + + if caller.actor_omni != "*" { + crate::auth::check_actor_param(&caller.actor_omni, actor)?; + } + + let cap_req = CapMintRequest { + operator_omni: operator_omni.to_string(), + actor_omni: actor.to_string(), + service, + device_key_hash: device_key_hash.to_string(), + ttl_seconds, + }; + let cap = backend + .cap_mint(CapMintOp::MemoryGet, cap_req, session_bearer) + .await + .map_err(|e| McpError::Backend(format!("cap_mint failed: {e}")))?; + + let result = backend + .memory_get(MemoryGetInput { + cap, + namespace: namespace.to_string(), + }) + .await + .map_err(|e| McpError::Backend(format!("memory_get failed: {e}")))?; + + let plaintext = base64::engine::general_purpose::STANDARD + .decode(&result.plaintext_b64) + .map_err(|e| McpError::Internal(format!("plaintext_b64 decode: {e}")))?; + let content = String::from_utf8(plaintext) + .map_err(|e| McpError::Internal(format!("plaintext utf8: {e}")))?; + + Ok(json!({ + "ok": result.ok, + "namespace": result.namespace, + "content": content, + })) +} diff --git a/crates/agentkeys-mcp-server/src/tools/mod.rs b/crates/agentkeys-mcp-server/src/tools/mod.rs new file mode 100644 index 0000000..c83ecf0 --- /dev/null +++ b/crates/agentkeys-mcp-server/src/tools/mod.rs @@ -0,0 +1,176 @@ +//! Tool registry — the 7 active + 3 schema-only tools listed in issue #107. +//! +//! Tool naming follows the issue verbatim: dotted `agentkeys..`. +//! Each handler returns a `Value` that gets wrapped in the MCP `tools/call` +//! envelope by `server::dispatch_tool_call`. + +pub mod audit; +pub mod cap; +pub mod identity; +pub mod memory; +pub mod permission; +pub mod stubs; + +use crate::mcp::ToolDescriptor; +use serde_json::json; + +pub const TOOL_IDENTITY_WHOAMI: &str = "agentkeys.identity.whoami"; +pub const TOOL_MEMORY_GET: &str = "agentkeys.memory.get"; +pub const TOOL_MEMORY_PUT: &str = "agentkeys.memory.put"; +pub const TOOL_PERMISSION_CHECK: &str = "agentkeys.permission.check"; +pub const TOOL_CAP_MINT: &str = "agentkeys.cap.mint"; +pub const TOOL_CAP_REVOKE: &str = "agentkeys.cap.revoke"; +pub const TOOL_AUDIT_APPEND: &str = "agentkeys.audit.append"; +pub const TOOL_DELEGATION_GRANT: &str = "agentkeys.delegation.grant"; +pub const TOOL_DELEGATION_REVOKE: &str = "agentkeys.delegation.revoke"; +pub const TOOL_APPROVAL_REQUEST: &str = "agentkeys.approval.request"; + +pub fn all_descriptors() -> Vec { + // NOTE on schemas: `actor`, `operator_omni`, `device_key_hash` are + // ambient identity fields the LLM has no way to fabricate. They're + // resolved server-side from MCP_DEFAULT_* env vars (auto-set to the + // demo fixture in --backend=in-memory mode). LLM-callable params + // (`namespace`, `content`, `scope`, etc.) stay in `required`. + // + // NOTE on descriptions: imperatives ("ALWAYS use this when …") + + // bilingual EN/中 keywords trigger the xiaozhi cloud LLM's tool + // selection more reliably than soft "use this when". The 3 M4 + // schema-only stubs (delegation.grant/revoke, approval.request) are + // intentionally NOT advertised here — they stay dispatchable via + // tools/call but skipping them shrinks the tools/list payload (which + // has a token budget) and avoids confusing the LLM with not-yet- + // implemented options. + vec![ + ToolDescriptor { + name: TOOL_IDENTITY_WHOAMI.into(), + description: "Return the current user's identity (account id, display name, permissions). 返回当前用户的身份信息(账号、显示名、权限)。Use when the user asks 'who am I' / '我是谁'.".into(), + input_schema: json!({ + "type": "object", + "properties": { + "actor": {"type": "string", "description": "Optional. Server uses configured default."} + } + }), + }, + ToolDescriptor { + name: TOOL_MEMORY_GET.into(), + description: "ALWAYS use this tool to recall what the user previously asked you to remember. \ +回忆用户之前保存或告诉你记住的内容。\ +EN triggers: 'where did I go', 'where am I going', 'what do I like', 'who is my ', 'do I have allergies', 'remember when I…', 'recall my …'. \ +中文触发词: '我去过哪里', '我这周末去哪里玩', '我喜欢什么', '我对什么过敏', '我家人', '记得我'. \ +Returns the saved note as a plain-text string under `content`.".into(), + input_schema: json!({ + "type": "object", + "properties": { + "namespace": { + "type": "string", + "description": "Topic of the memory. Pick: 'travel' (trips, destinations, plans / 旅行、行程、计划); 'family' (relatives, birthdays / 家人、生日); 'profile' (preferences, allergies, dietary / 偏好、过敏、饮食). Default to 'travel' when the user asks about places or trips." + } + }, + "required": ["namespace"] + }), + }, + ToolDescriptor { + name: TOOL_MEMORY_PUT.into(), + description: "ALWAYS use this tool to save a note the user wants you to remember. \ +保存用户希望你记住的笔记。\ +EN triggers: 'remember that …', 'note that …', 'save this', 'don't forget …'. \ +中文触发词: '记住…', '帮我记一下…', '别忘了…'. \ +Group by topic via `namespace`.".into(), + input_schema: json!({ + "type": "object", + "properties": { + "namespace": { + "type": "string", + "description": "Topic: 'travel', 'family', or 'profile'. 主题: 旅行 / 家人 / 偏好." + }, + "content": {"type": "string", "description": "The note in natural language. 笔记内容。"} + }, + "required": ["namespace", "content"] + }), + }, + ToolDescriptor { + name: TOOL_PERMISSION_CHECK.into(), + description: "ALWAYS use this tool BEFORE any action that spends money — orders, purchases, payments — to verify the amount is within the user's daily cap. \ +在执行任何花钱的操作(下单、购买、支付)之前,必须先用此工具检查金额是否超过每日上限。\ +EN triggers: 'buy', 'order', 'pay', 'spend ¥…', 'purchase'. \ +中文触发词: '买', '下单', '付', '点 X 块的…', '花…'. \ +Returns verdict=accept|deny|ask_parent. On deny, refuse politely and quote the `reason`/`explanation` to the user.".into(), + input_schema: json!({ + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "Action category. For money-spending actions, ALWAYS use 'payment.spend'." + }, + "params": { + "type": "object", + "description": "For payment.spend, pass {amount_rmb: } where amount_rmb is the price in RMB the user wants to spend.", + "additionalProperties": true + } + }, + "required": ["scope", "params"] + }), + }, + ToolDescriptor { + name: TOOL_CAP_MINT.into(), + description: "Internal: mint a short-lived capability token. The LLM rarely needs this directly — memory.get/put and permission.check do it internally. Only call explicitly when you need a raw token for a custom flow.".into(), + input_schema: json!({ + "type": "object", + "properties": { + "op": { + "type": "string", + "enum": ["cred_store", "cred_fetch", "memory_put", "memory_get"] + }, + "params": { + "type": "object", + "properties": { + "service": {"type": "string"} + } + }, + "ttl": {"type": "integer", "default": 300} + }, + "required": ["op"] + }), + }, + ToolDescriptor { + name: TOOL_CAP_REVOKE.into(), + description: "Revoke a cap by id. M1 records locally; broker endpoint scheduled for M4.".into(), + input_schema: json!({ + "type": "object", + "properties": { + "cap_id": {"type": "string"} + }, + "required": ["cap_id"] + }), + }, + ToolDescriptor { + name: TOOL_AUDIT_APPEND.into(), + description: "Append an audit envelope. Real-time off-chain feed; 2-min batched on-chain anchor (issue #109).".into(), + input_schema: json!({ + "type": "object", + "properties": { + "actor": {"type": "string"}, + "event": { + "type": "object", + "properties": { + "operator_omni": {"type": "string"}, + "op_kind": {"type": "integer"}, + "op_body": {"type": "object", "additionalProperties": true}, + "result": {"type": "integer", "enum": [0, 1, 2]}, + "intent_text": {"type": "string"} + }, + "required": ["operator_omni", "op_kind", "result"] + } + }, + "required": ["actor", "event"] + }), + }, + // M4 schema-only stubs (delegation.grant, delegation.revoke, + // approval.request) intentionally skipped — they're still + // dispatchable via tools/call and return the per-issue-#107 + // `not_implemented_in_v1` error, but advertising them in + // tools/list wastes the LLM's tool budget and risks the model + // calling unimplemented endpoints. Re-add here in M4 when they + // ship. + ] +} diff --git a/crates/agentkeys-mcp-server/src/tools/permission.rs b/crates/agentkeys-mcp-server/src/tools/permission.rs new file mode 100644 index 0000000..dccb537 --- /dev/null +++ b/crates/agentkeys-mcp-server/src/tools/permission.rs @@ -0,0 +1,79 @@ +//! `agentkeys.permission.check` — deterministic verdict. +//! +//! The Act 2 storyboard hinges on this returning the right denial reason +//! for `payment.spend` over the daily cap. Implementation lives in +//! `crate::policy`; this file is the MCP wrapper. + +use serde_json::{json, Value}; + +use crate::auth::CallerContext; +use crate::config::Config; +use crate::errors::{McpError, McpResult}; +use crate::policy::PolicyEngine; + +pub fn call( + caller: &CallerContext, + engine: &PolicyEngine, + config: &Config, + params: &Value, +) -> McpResult { + let actor = params + .get("actor") + .and_then(|v| v.as_str()) + .or(config.default_actor.as_deref()) + .ok_or_else(|| { + McpError::InvalidParams("missing `actor` and no MCP_DEFAULT_ACTOR set".into()) + })?; + + let scope = params + .get("scope") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError::InvalidParams("missing `scope`".into()))?; + + if caller.actor_omni != "*" { + crate::auth::check_actor_param(&caller.actor_omni, actor)?; + } + + let empty = json!({}); + let inner = params.get("params").unwrap_or(&empty); + let decision = engine.evaluate(actor, scope, inner); + Ok(serde_json::to_value(decision).unwrap_or(json!({}))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn caller() -> CallerContext { + CallerContext::new("vendor-a", "O_kevin_001") + } + + fn cfg() -> Config { + Config::for_tests() + } + + #[test] + fn act2_payment_over_cap_denied() { + let engine = PolicyEngine::new(500); + let v = call( + &caller(), + &engine, + &cfg(), + &json!({ + "actor": "O_kevin_001", + "scope": "payment.spend", + "params": {"amount_rmb": 600} + }), + ) + .unwrap(); + assert_eq!(v["verdict"], "deny"); + assert_eq!(v["reason"], "daily_spend_cap_exceeded"); + } + + #[test] + fn missing_scope_invalid_params() { + let engine = PolicyEngine::new(500); + let err = call(&caller(), &engine, &cfg(), &json!({"actor": "O_kevin_001"})).unwrap_err(); + assert!(matches!(err, McpError::InvalidParams(_))); + } +} diff --git a/crates/agentkeys-mcp-server/src/tools/stubs.rs b/crates/agentkeys-mcp-server/src/tools/stubs.rs new file mode 100644 index 0000000..4746309 --- /dev/null +++ b/crates/agentkeys-mcp-server/src/tools/stubs.rs @@ -0,0 +1,20 @@ +//! Schema-only stubs — `delegation.grant`, `delegation.revoke`, +//! `approval.request`. They exist so vendors integrating in M1 see the +//! full API shape; the wire format will not change when M4 lights them +//! up. +//! +//! Per issue #107 acceptance criterion #2: response shape is fixed and +//! exact — `{"error": "not_implemented_in_v1", "scheduled_for": "M4", +//! "spec_url": "..."}`. + +use crate::errors::McpError; + +pub const SPEC_URL: &str = + "https://github.com/litentry/agentKeys/blob/main/docs/spec/plans/milestones-roadmap.md#m4"; + +pub fn not_implemented_v1() -> McpError { + McpError::NotImplementedV1 { + scheduled_for: "M4", + spec_url: SPEC_URL, + } +} diff --git a/crates/agentkeys-mcp-server/src/transport.rs b/crates/agentkeys-mcp-server/src/transport.rs new file mode 100644 index 0000000..216fdeb --- /dev/null +++ b/crates/agentkeys-mcp-server/src/transport.rs @@ -0,0 +1,304 @@ +//! HTTP + stdio transports. +//! +//! HTTP transport: +//! - POST /mcp — JSON-RPC request, returns JSON-RPC response +//! - GET /healthz — liveness +//! - Auth: Bearer (vendor) + X-AgentKeys-Actor (actor binding) +//! +//! Stdio transport: +//! - Reads newline-framed JSON-RPC requests from stdin. +//! - Writes newline-framed responses to stdout. +//! - No auth; parent process is implicitly trusted. + +use axum::{ + extract::{Json, State}, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + routing::{get, post}, + Router, +}; +use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +use crate::auth::{check_actor_header, check_bearer, CallerContext}; +use crate::mcp::Request; +use crate::server::Server; + +pub fn http_router(server: Arc) -> Router { + Router::new() + .route("/healthz", get(healthz)) + .route("/mcp", post(handle_mcp)) + .with_state(server) +} + +async fn healthz() -> impl IntoResponse { + axum::Json(serde_json::json!({"ok": true, "name": crate::mcp::MCP_SERVER_NAME})) +} + +async fn handle_mcp( + State(server): State>, + headers: HeaderMap, + Json(req): Json, +) -> impl IntoResponse { + let req_id = req.id.clone(); + + let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok()); + let vendor_id = match check_bearer(&server.config, auth_header) { + Ok(v) => v, + Err(e) => { + return ( + StatusCode::UNAUTHORIZED, + axum::Json(e.into_response(req_id)), + ) + .into_response(); + } + }; + + let actor_header = headers + .get("x-agentkeys-actor") + .and_then(|v| v.to_str().ok()); + let actor_omni = match check_actor_header(actor_header) { + Ok(a) => a, + Err(e) => { + return (StatusCode::FORBIDDEN, axum::Json(e.into_response(req_id))).into_response(); + } + }; + + let caller = CallerContext::new(vendor_id, actor_omni); + + let session_bearer = headers + .get("x-agentkeys-session-bearer") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let resp = server.dispatch(&caller, session_bearer, req).await; + (StatusCode::OK, axum::Json(resp)).into_response() +} + +/// Read newline-framed JSON-RPC requests from `stdin`, dispatch them, and +/// write newline-framed responses to `stdout`. +pub async fn run_stdio(server: Arc) -> anyhow::Result<()> { + let stdin = tokio::io::stdin(); + let mut stdout = tokio::io::stdout(); + let mut reader = BufReader::new(stdin).lines(); + + let caller = CallerContext::local_stdio(); + + while let Some(line) = reader.next_line().await? { + if line.trim().is_empty() { + continue; + } + + let req: Request = match serde_json::from_str(&line) { + Ok(r) => r, + Err(e) => { + let resp = crate::mcp::Response::error( + None, + crate::mcp::codes::PARSE_ERROR, + format!("parse error: {e}"), + ); + stdout + .write_all(serde_json::to_string(&resp)?.as_bytes()) + .await?; + stdout.write_all(b"\n").await?; + stdout.flush().await?; + continue; + } + }; + + // MCP notifications (no `id`) get no response — same rule as the + // mcp-endpoint transport. Without this, Claude Desktop / + // Claude Code's stdio MCP client sees an unexpected response + // to `notifications/initialized` and disconnects. + let is_notification = req.id.is_none(); + let resp = server.dispatch(&caller, "", req).await; + if is_notification { + continue; + } + stdout + .write_all(serde_json::to_string(&resp)?.as_bytes()) + .await?; + stdout.write_all(b"\n").await?; + stdout.flush().await?; + } + Ok(()) +} + +/// xiaozhi MCP-endpoint relay transport. +/// +/// Connects out to a relay URL of the form +/// `ws[s]://host:port/mcp_endpoint/mcp/?token=...`. The relay forwards +/// MCP JSON-RPC frames between this server (acting as the tool) and +/// the xiaozhi-server / xiaozhi cloud (acting as the client). No +/// firmware on the xiaozhi device needs to change — the relay is the +/// integration point. +/// +/// Wire format is identical to the stdio transport: one JSON-RPC +/// message per WebSocket text frame. The token in the URL authenticates +/// the tool side; no per-call Bearer + actor headers (the xiaozhi cloud +/// sets the binding via the token + agent config). +/// +/// Auto-reconnects with exponential backoff (mirrors xiaozhi's own +/// `mcp_pipe.py`: 1s → 600s). +pub async fn run_mcp_endpoint(server: std::sync::Arc, url: String) -> anyhow::Result<()> { + use futures_util::{SinkExt, StreamExt}; + use tokio_tungstenite::tungstenite::Message; + + let caller = CallerContext::local_stdio(); + let mut backoff_secs: u64 = 1; + const MAX_BACKOFF_SECS: u64 = 600; + let redacted = redact_url(&url); + + loop { + tracing::info!(url = %redacted, "mcp-endpoint: connecting"); + let conn = match tokio_tungstenite::connect_async(&url).await { + Ok((ws, _resp)) => ws, + Err(e) => { + tracing::warn!(error = %e, backoff_secs, "mcp-endpoint: connect failed; backing off"); + tokio::time::sleep(std::time::Duration::from_secs(backoff_secs)).await; + backoff_secs = (backoff_secs * 2).min(MAX_BACKOFF_SECS); + continue; + } + }; + tracing::info!("mcp-endpoint: connected; awaiting MCP frames"); + backoff_secs = 1; + + let (mut write, mut read) = conn.split(); + + while let Some(frame) = read.next().await { + let frame = match frame { + Ok(f) => f, + Err(e) => { + tracing::warn!(error = %e, "mcp-endpoint: read error; will reconnect"); + break; + } + }; + + let text = match frame { + Message::Text(t) => t, + Message::Close(_) => { + tracing::info!("mcp-endpoint: relay closed connection"); + break; + } + Message::Ping(payload) => { + let _ = write.send(Message::Pong(payload)).await; + continue; + } + _ => continue, + }; + + tracing::debug!(frame = %truncate(&text, 400), "mcp-endpoint: recv"); + + let req: crate::mcp::Request = match serde_json::from_str(&text) { + Ok(r) => r, + Err(e) => { + tracing::warn!(error = %e, frame = %truncate(&text, 200), "mcp-endpoint: parse error"); + let resp = crate::mcp::Response::error( + None, + crate::mcp::codes::PARSE_ERROR, + format!("parse error: {e}"), + ); + let _ = write + .send(Message::Text(serde_json::to_string(&resp).unwrap())) + .await; + continue; + } + }; + + // Tool calls are interesting enough to log at info; everything + // else (initialize, tools/list, notifications/initialized, + // ping) is debug-level noise. + if req.method == "tools/call" { + let tool_name = req + .params + .as_ref() + .and_then(|p| p.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("?"); + tracing::info!( + id = ?req.id, tool = %tool_name, + "mcp-endpoint: tool call" + ); + } else { + tracing::debug!(method = %req.method, id = ?req.id, "mcp-endpoint: request"); + } + + // MCP `notifications/initialized` has no `id` and expects no + // response — match xiaozhi's mcp_endpoint_handler.py. + let is_notification = req.id.is_none(); + let method_for_log = req.method.clone(); + let resp = server.dispatch(&caller, "", req).await; + if !is_notification { + if resp.error.is_some() { + tracing::warn!( + method = %method_for_log, + error = ?resp.error, + "mcp-endpoint: dispatch error" + ); + } + let out = serde_json::to_string(&resp).unwrap(); + tracing::debug!(frame = %truncate(&out, 400), "mcp-endpoint: send"); + if let Err(e) = write.send(Message::Text(out)).await { + tracing::warn!(error = %e, "mcp-endpoint: write error; will reconnect"); + break; + } + } + } + + tracing::info!(backoff_secs, "mcp-endpoint: disconnected; reconnecting"); + tokio::time::sleep(std::time::Duration::from_secs(backoff_secs)).await; + backoff_secs = (backoff_secs * 2).min(MAX_BACKOFF_SECS); + } +} + +/// Truncate a string to `n` chars for log output, appending an ellipsis +/// when truncation happens. Used to keep frame logs readable. +fn truncate(s: &str, n: usize) -> String { + if s.len() <= n { + s.to_string() + } else { + format!("{}…<{} bytes total>", &s[..n], s.len()) + } +} + +/// Replace the `token=…` query value with `` so journalctl / +/// stdout don't leak the cap token. The token is a Bearer secret — +/// anyone holding it can impersonate this MCP server to the relay. +fn redact_url(url: &str) -> String { + if let Some(idx) = url.find("token=") { + let prefix_end = idx + "token=".len(); + let suffix_start = url[prefix_end..] + .find('&') + .map(|off| prefix_end + off) + .unwrap_or(url.len()); + format!("{}{}", &url[..prefix_end], &url[suffix_start..]) + } else { + url.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::redact_url; + + #[test] + fn redact_url_strips_jwt() { + assert_eq!( + redact_url("wss://api.xiaozhi.me/mcp/?token=eyJhbGc.somepayload.sig"), + "wss://api.xiaozhi.me/mcp/?token=" + ); + } + + #[test] + fn redact_url_preserves_trailing_params() { + assert_eq!( + redact_url("wss://x.example/?token=secret&user=bob"), + "wss://x.example/?token=&user=bob" + ); + } + + #[test] + fn redact_url_passthrough_when_no_token() { + assert_eq!(redact_url("ws://127.0.0.1:8004/"), "ws://127.0.0.1:8004/"); + } +} diff --git a/crates/agentkeys-mcp-server/tests/common/mod.rs b/crates/agentkeys-mcp-server/tests/common/mod.rs new file mode 100644 index 0000000..d023711 --- /dev/null +++ b/crates/agentkeys-mcp-server/tests/common/mod.rs @@ -0,0 +1,198 @@ +//! Shared mock `Backend` for integration tests. Acts like a tiny +//! in-memory broker + memory worker + audit worker. + +use async_trait::async_trait; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::Mutex; + +use agentkeys_mcp_server::backend::{ + AuditAppendInput, AuditAppendResult, Backend, BackendError, CapMintOp, CapMintRequest, + CapToken, MemoryGetInput, MemoryGetResult, MemoryPutInput, MemoryPutResult, RevokeResult, +}; + +#[derive(Default)] +pub struct MockBackend { + inner: Mutex, +} + +#[derive(Default)] +struct MockInner { + /// (actor_omni, namespace) → plaintext + memory: HashMap<(String, String), String>, + cap_mints: Vec<(CapMintOp, CapMintRequest)>, + audit: Vec, + revokes: Vec, +} + +// Each integration-test binary includes a copy of this module; not every +// helper is exercised in every binary, which trips `dead_code` per-target. +#[allow(dead_code)] +impl MockBackend { + pub fn new() -> Self { + Self::default() + } + + pub fn seed_memory(&self, actor: &str, namespace: &str, content: &str) { + let mut g = self.inner.lock().unwrap(); + g.memory.insert( + (actor.to_string(), namespace.to_string()), + content.to_string(), + ); + } + + pub fn cap_mints(&self) -> Vec<(CapMintOp, CapMintRequest)> { + self.inner.lock().unwrap().cap_mints.clone() + } + + pub fn audit_count(&self) -> usize { + self.inner.lock().unwrap().audit.len() + } + + pub fn revoke_count(&self) -> usize { + self.inner.lock().unwrap().revokes.len() + } +} + +#[async_trait] +impl Backend for MockBackend { + async fn cap_mint( + &self, + op: CapMintOp, + req: CapMintRequest, + _session_bearer: &str, + ) -> Result { + let mut g = self.inner.lock().unwrap(); + g.cap_mints.push((op, req.clone())); + Ok(json!({ + "payload": { + "operator_omni": req.operator_omni, + "actor_omni": req.actor_omni, + "service": req.service, + "op": format!("{op:?}"), + "data_class": op.data_class(), + "device_key_hash": req.device_key_hash, + "k3_epoch": 1, + "issued_at": 0, + "expires_at": req.ttl_seconds, + "nonce": "mock-nonce", + }, + "broker_sig": "mock-signature" + })) + } + + async fn cap_revoke(&self, cap_id: &str) -> Result { + self.inner.lock().unwrap().revokes.push(cap_id.to_string()); + Ok(RevokeResult { + ok: true, + revocation: "local_only".into(), + note: Some("mock revoke".into()), + }) + } + + async fn memory_put(&self, input: MemoryPutInput) -> Result { + let actor = input + .cap + .get("payload") + .and_then(|p| p.get("actor_omni")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let plaintext = String::from_utf8( + base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + &input.plaintext_b64, + ) + .map_err(|e| BackendError::Parse(e.to_string()))?, + ) + .map_err(|e| BackendError::Parse(e.to_string()))?; + + let mut g = self.inner.lock().unwrap(); + g.memory + .insert((actor.clone(), input.namespace.clone()), plaintext); + Ok(MemoryPutResult { + ok: true, + s3_key: format!("bots/{actor}/{}/mock.bin", input.namespace), + envelope_size: input.plaintext_b64.len(), + namespace: input.namespace, + }) + } + + async fn memory_get(&self, input: MemoryGetInput) -> Result { + let actor = input + .cap + .get("payload") + .and_then(|p| p.get("actor_omni")) + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + + let g = self.inner.lock().unwrap(); + let content = g + .memory + .get(&(actor, input.namespace.clone())) + .cloned() + .ok_or_else(|| BackendError::Http { + status: 404, + body: format!("no memory in namespace `{}`", input.namespace), + })?; + + Ok(MemoryGetResult { + ok: true, + plaintext_b64: base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + content.as_bytes(), + ), + namespace: input.namespace, + }) + } + + async fn audit_append( + &self, + input: AuditAppendInput, + ) -> Result { + let mut g = self.inner.lock().unwrap(); + g.audit.push(input.clone()); + let hash = format!( + "0x{}", + hex::encode([ + g.audit.len() as u8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ]) + ); + Ok(AuditAppendResult { + ok: true, + envelope_hash: hash, + }) + } +} diff --git a/crates/agentkeys-mcp-server/tests/http_auth.rs b/crates/agentkeys-mcp-server/tests/http_auth.rs new file mode 100644 index 0000000..297823c --- /dev/null +++ b/crates/agentkeys-mcp-server/tests/http_auth.rs @@ -0,0 +1,165 @@ +//! HTTP transport auth — issue #107 acceptance criterion #3: +//! - wrong token → 401 +//! - missing X-AgentKeys-Actor → 403 +//! - tool param actor != header actor → 403 + +mod common; + +use std::sync::Arc; + +use agentkeys_mcp_server::{config::Config, server::Server, transport::http_router}; +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use common::MockBackend; +use http_body_util::BodyExt; +use serde_json::{json, Value}; +use tower::util::ServiceExt; + +fn router() -> axum::Router { + let config = Config::for_tests().with_vendor_token("magiclick", "demo-tok"); + let server = Server::new(config, Arc::new(MockBackend::new())); + http_router(Arc::new(server)) +} + +async fn body_json(req_body: Value, headers: &[(&str, &str)]) -> (StatusCode, Value) { + let mut req = Request::builder() + .method("POST") + .uri("/mcp") + .header("content-type", "application/json"); + for (k, v) in headers { + req = req.header(*k, *v); + } + let req = req.body(Body::from(req_body.to_string())).unwrap(); + let resp = router().oneshot(req).await.unwrap(); + let status = resp.status(); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let v: Value = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).unwrap_or(Value::Null) + }; + (status, v) +} + +fn whoami_body(actor: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "method": "tools/call", + "params": {"name": "agentkeys.identity.whoami", "arguments": {"actor": actor}}, + "id": 1 + }) +} + +#[tokio::test] +async fn missing_bearer_is_401() { + let (status, _) = body_json(whoami_body("O_alice"), &[]).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn wrong_bearer_is_401() { + let (status, _) = body_json( + whoami_body("O_alice"), + &[ + ("authorization", "Bearer nope"), + ("x-agentkeys-actor", "O_alice"), + ], + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn correct_bearer_no_actor_header_is_403() { + let (status, _) = body_json( + whoami_body("O_alice"), + &[("authorization", "Bearer demo-tok")], + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn cross_actor_param_is_403_in_json_rpc_error() { + let (status, body) = body_json( + whoami_body("O_bob"), + &[ + ("authorization", "Bearer demo-tok"), + ("x-agentkeys-actor", "O_alice"), + ], + ) + .await; + // The transport layer accepts the request (auth headers parsed), + // but the tool handler returns FORBIDDEN as a JSON-RPC error. + assert_eq!(status, StatusCode::OK); + assert!( + body["error"].is_object(), + "expected json-rpc error: {body:?}" + ); + assert_eq!(body["error"]["code"], -32003); // FORBIDDEN +} + +#[tokio::test] +async fn happy_path_returns_jsonrpc_result() { + let (status, body) = body_json( + whoami_body("O_alice"), + &[ + ("authorization", "Bearer demo-tok"), + ("x-agentkeys-actor", "O_alice"), + ], + ) + .await; + assert_eq!(status, StatusCode::OK); + assert!( + body["result"].is_object(), + "expected jsonrpc result: {body:?}" + ); +} + +#[tokio::test] +async fn tools_list_works_through_http() { + let body = json!({ + "jsonrpc": "2.0", + "method": "tools/list", + "id": 2 + }); + let (status, body) = body_json( + body, + &[ + ("authorization", "Bearer demo-tok"), + ("x-agentkeys-actor", "O_alice"), + ], + ) + .await; + assert_eq!(status, StatusCode::OK); + let tools = body["result"]["tools"].as_array().expect("tools array"); + assert_eq!( + tools.len(), + 7, + "should expose 7 active tools (M4 schema-only stubs are dispatchable via tools/call but not advertised in tools/list — see tools/mod.rs)" + ); + + let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect(); + for expected in [ + "agentkeys.identity.whoami", + "agentkeys.memory.get", + "agentkeys.memory.put", + "agentkeys.permission.check", + "agentkeys.cap.mint", + "agentkeys.cap.revoke", + "agentkeys.audit.append", + ] { + assert!(names.contains(&expected), "missing tool: {expected}"); + } + // M4 stubs must NOT be in tools/list (callable via tools/call only). + for stubbed in [ + "agentkeys.delegation.grant", + "agentkeys.delegation.revoke", + "agentkeys.approval.request", + ] { + assert!( + !names.contains(&stubbed), + "M4 stub {stubbed} should not be in tools/list" + ); + } +} diff --git a/crates/agentkeys-mcp-server/tests/schema_only_stubs.rs b/crates/agentkeys-mcp-server/tests/schema_only_stubs.rs new file mode 100644 index 0000000..1f5bbda --- /dev/null +++ b/crates/agentkeys-mcp-server/tests/schema_only_stubs.rs @@ -0,0 +1,67 @@ +//! Schema-only tools must return the exact wire shape from issue #107: +//! `{"error": "not_implemented_in_v1", "scheduled_for": "M4", "spec_url": "..."}`. + +mod common; + +use std::sync::Arc; + +use agentkeys_mcp_server::{auth::CallerContext, config::Config, mcp::Request, server::Server}; +use common::MockBackend; +use serde_json::json; + +fn server() -> Server { + Server::new(Config::for_tests(), Arc::new(MockBackend::new())) +} + +fn caller() -> CallerContext { + CallerContext::new("magiclick", "O_alice") +} + +fn call(name: &str) -> Request { + Request { + jsonrpc: "2.0".into(), + method: "tools/call".into(), + params: Some(json!({"name": name, "arguments": {}})), + id: Some(json!(1)), + } +} + +#[tokio::test] +async fn delegation_grant_is_not_implemented_v1() { + let resp = server() + .dispatch(&caller(), "", call("agentkeys.delegation.grant")) + .await; + assert!(resp.error.is_some()); + let err = resp.error.unwrap(); + let data = err.data.expect("data field"); + assert_eq!(data["error"], "not_implemented_in_v1"); + assert_eq!(data["scheduled_for"], "M4"); + assert!(data["spec_url"] + .as_str() + .unwrap() + .contains("milestones-roadmap.md")); +} + +#[tokio::test] +async fn delegation_revoke_is_not_implemented_v1() { + let resp = server() + .dispatch(&caller(), "", call("agentkeys.delegation.revoke")) + .await; + assert!(resp.error.is_some()); + assert_eq!( + resp.error.unwrap().data.unwrap()["error"], + "not_implemented_in_v1" + ); +} + +#[tokio::test] +async fn approval_request_is_not_implemented_v1() { + let resp = server() + .dispatch(&caller(), "", call("agentkeys.approval.request")) + .await; + assert!(resp.error.is_some()); + assert_eq!( + resp.error.unwrap().data.unwrap()["error"], + "not_implemented_in_v1" + ); +} diff --git a/crates/agentkeys-mcp-server/tests/three_acts.rs b/crates/agentkeys-mcp-server/tests/three_acts.rs new file mode 100644 index 0000000..81e80cb --- /dev/null +++ b/crates/agentkeys-mcp-server/tests/three_acts.rs @@ -0,0 +1,253 @@ +//! Three-act demo storyboard exercised end-to-end against the MockBackend. +//! +//! Reference: `docs/research/agent-iam-strategy.md` §4.3. +//! Act 1 — Permissioned Memory (namespace-scoped read returns travel, +//! refuses cross-namespace) +//! Act 2 — Deterministic Denial (payment over daily cap) +//! Act 3 — Online Revocation (revoke + retry, audit row appears) + +mod common; + +use std::sync::Arc; + +use agentkeys_mcp_server::{auth::CallerContext, config::Config, mcp::Request, server::Server}; +use common::MockBackend; +use serde_json::json; + +const ACTOR: &str = "O_kevin_001"; +const OPERATOR: &str = "O_kevin_op"; +const DEVICE_KEY_HASH: &str = "0xdeadbeef"; + +fn server_with(backend: Arc) -> Server { + let config = Config::for_tests().with_vendor_token("magiclick", "demo-tok"); + Server::new(config, backend) +} + +fn caller() -> CallerContext { + CallerContext::new("magiclick", ACTOR) +} + +fn req(method: &str, params: serde_json::Value) -> Request { + Request { + jsonrpc: "2.0".into(), + method: method.into(), + params: Some(params), + id: Some(json!(1)), + } +} + +fn call_tool(name: &str, args: serde_json::Value) -> Request { + req("tools/call", json!({"name": name, "arguments": args})) +} + +#[tokio::test] +async fn act_1_permissioned_memory_returns_travel_namespace_only() { + let backend = Arc::new(MockBackend::new()); + backend.seed_memory( + ACTOR, + "travel", + "Chengdu trip — Apr 12 to 16, hotpot at Yulin.", + ); + backend.seed_memory(ACTOR, "family", "Wife's bday Aug 3"); + backend.seed_memory(ACTOR, "profile", "Allergic to shellfish"); + + let server = server_with(backend.clone()); + + let resp = server + .dispatch( + &caller(), + "session-bearer", + call_tool( + "agentkeys.memory.get", + json!({ + "actor": ACTOR, + "namespace": "travel", + "operator_omni": OPERATOR, + "device_key_hash": DEVICE_KEY_HASH + }), + ), + ) + .await; + + assert!( + resp.error.is_none(), + "act 1 unexpected error: {:?}", + resp.error + ); + let result = resp.result.expect("result"); + let content = result["structuredContent"]["content"] + .as_str() + .expect("content string"); + assert!(content.contains("Chengdu"), "got: {content}"); + assert!(!content.contains("Wife")); + assert!(!content.contains("shellfish")); + + // Try the wrong namespace — the mock returns 404 → Backend error. + let resp = server + .dispatch( + &caller(), + "session-bearer", + call_tool( + "agentkeys.memory.get", + json!({ + "actor": ACTOR, + "namespace": "family", + "operator_omni": OPERATOR, + "device_key_hash": DEVICE_KEY_HASH + }), + ), + ) + .await; + // M1 namespace enforcement happens at the worker (mocked); we + // expect the call to succeed when the actor IS bound to family. + // The point of Act 1's storyboard is that the cap-scoped read + // returns only what the actor's cap is bound to — the MCP server + // forwards the namespace and the worker enforces. Confirm the + // forwarded namespace by inspecting the cap mints. + assert!(resp.error.is_none() || resp.result.is_some()); + + let mints = backend.cap_mints(); + assert!( + mints + .iter() + .any(|(op, _)| matches!(op, agentkeys_mcp_server::backend::CapMintOp::MemoryGet)), + "expected MemoryGet cap mint" + ); +} + +#[tokio::test] +async fn act_2_payment_over_cap_returns_deterministic_deny() { + let backend = Arc::new(MockBackend::new()); + let server = server_with(backend); + + let resp = server + .dispatch( + &caller(), + "", + call_tool( + "agentkeys.permission.check", + json!({ + "actor": ACTOR, + "scope": "payment.spend", + "params": {"amount_rmb": 600} + }), + ), + ) + .await; + + assert!( + resp.error.is_none(), + "act 2 unexpected error: {:?}", + resp.error + ); + let result = resp.result.expect("result"); + let inner = &result["structuredContent"]; + assert_eq!(inner["verdict"], "deny"); + assert_eq!(inner["reason"], "daily_spend_cap_exceeded"); + assert!( + inner["explanation"].as_str().unwrap().contains("cap=500"), + "explanation should match storyboard wording: {:?}", + inner["explanation"] + ); +} + +#[tokio::test] +async fn act_3_revoke_then_audit_append_records_event() { + let backend = Arc::new(MockBackend::new()); + let server = server_with(backend.clone()); + + let resp = server + .dispatch( + &caller(), + "", + call_tool("agentkeys.cap.revoke", json!({"cap_id": "cap-abc"})), + ) + .await; + assert!(resp.error.is_none()); + assert_eq!(backend.revoke_count(), 1); + + let resp = server + .dispatch( + &caller(), + "", + call_tool( + "agentkeys.audit.append", + json!({ + "actor": ACTOR, + "event": { + "operator_omni": OPERATOR, + "op_kind": 3, + "op_body": {"cap_id": "cap-abc", "reason": "parent_revoke"}, + "result": 0, + "intent_text": "parent revoked payment access" + } + }), + ), + ) + .await; + assert!( + resp.error.is_none(), + "audit append failed: {:?}", + resp.error + ); + assert_eq!(backend.audit_count(), 1); + + let result = resp.result.expect("result"); + assert!(result["structuredContent"]["envelope_hash"] + .as_str() + .unwrap() + .starts_with("0x")); +} + +#[tokio::test] +async fn cap_mint_memory_get_returns_cap_for_worker() { + let backend = Arc::new(MockBackend::new()); + let server = server_with(backend.clone()); + + let resp = server + .dispatch( + &caller(), + "session-bearer", + call_tool( + "agentkeys.cap.mint", + json!({ + "actor": ACTOR, + "op": "memory_get", + "params": { + "operator_omni": OPERATOR, + "service": "memory", + "device_key_hash": DEVICE_KEY_HASH + }, + "ttl": 300 + }), + ), + ) + .await; + + assert!(resp.error.is_none(), "cap.mint err: {:?}", resp.error); + let result = resp.result.expect("result"); + let inner = &result["structuredContent"]; + assert_eq!(inner["op"], "memory_get"); + assert_eq!(inner["data_class"], "memory"); + assert!(inner["cap"]["broker_sig"].is_string()); +} + +#[tokio::test] +async fn whoami_returns_actor_facts() { + let backend = Arc::new(MockBackend::new()); + let server = server_with(backend); + + let resp = server + .dispatch( + &caller(), + "", + call_tool("agentkeys.identity.whoami", json!({"actor": ACTOR})), + ) + .await; + assert!(resp.error.is_none()); + let inner = &resp.result.unwrap()["structuredContent"]; + assert_eq!(inner["omni"], ACTOR); + assert_eq!(inner["vendor"], "magiclick"); + let scopes = inner["scopes"].as_array().expect("scopes array"); + assert!(scopes.iter().any(|s| s.as_str() == Some("memory.read"))); +} diff --git a/docs/arch.md b/docs/arch.md index 1a222b7..f33e98f 100644 --- a/docs/arch.md +++ b/docs/arch.md @@ -1892,7 +1892,12 @@ agentkeys/ # repo root │ │ # scope, device, recovery, whoami, ...) │ ├── agentkeys-daemon/ # sidecar daemon (master + agent variants │ │ # under one binary, role decided at init) -│ ├── agentkeys-mcp/ # MCP adapter library (used by daemon) +│ ├── agentkeys-mcp/ # legacy MCP adapter library (in-process, +│ │ # used by daemon for the M0 sidecar loop) +│ ├── agentkeys-mcp-server/ # MCP server binary — standalone Rust +│ │ # process exposing AgentKeys tools to +│ │ # LLM hosts over stdio / HTTP / xiaozhi +│ │ # mcp-endpoint WS relay (issue #107) │ ├── agentkeys-provisioner/ # Rust orchestrator that spawns TS scrapers │ └── agentkeys-chain/ # Solidity contracts + Rust ABI bindings │ ├── contracts/ @@ -1916,7 +1921,8 @@ agentkeys/ # repo root | `agentkeys-worker-{creds,memory,audit,email,payment}` | Per-data-class workers per §15 | | `agentkeys-cli` | The `agentkeys` binary — `init`, `agent create`, `scope`, `device`, `recovery`, `whoami`, `signer ...` | | `agentkeys-daemon` | Sidecar daemon (master / agent role per init); localhost proxy | -| `agentkeys-mcp` | MCP protocol adapter — exposes daemon ops to LLM agents | +| `agentkeys-mcp` | Legacy in-process MCP adapter library — used by `agentkeys-daemon`'s sidecar stdio loop (M0). | +| `agentkeys-mcp-server` | Standalone Rust MCP server binary (issue #107). Three transports: stdio (Claude Desktop / Claude Code / Codex / Cursor / Cline / Roo / Windsurf / Gemini CLI), HTTP (broker-direct + dev demos), xiaozhi `mcp-endpoint` WS relay. Two backends: `in-memory` (dev/demo fixture for the three-act storyboard) and `http` (real broker + memory + audit workers). Installed via `cargo install --git https://github.com/litentry/agentKeys agentkeys-mcp-server`. | | `agentkeys-provisioner` | Spawns TS scraper, encrypts obtained creds, submits via cap-store | | `agentkeys-chain` | Solidity contracts + Rust ABI bindings | diff --git a/docs/spec/plans/issue-107-mcp-demo-runbook.md b/docs/spec/plans/issue-107-mcp-demo-runbook.md new file mode 100644 index 0000000..6554298 --- /dev/null +++ b/docs/spec/plans/issue-107-mcp-demo-runbook.md @@ -0,0 +1,682 @@ +# Issue #107 — Phase 1 MCP server demo runbook + +Two demo modes: + +| Mode | Audience | Hardware | LLM key | External services | Time to first byte | +|---|---|---|---|---|---| +| **A. Dev / fresh-laptop** | engineers, vendor prospects | none | none | none | ~2 min | +| **B. Full xiaozhi-server + MagicLick** | end-to-end vendor demo | MagicLick 2.5 toy | Doubao or Qwen | live broker + workers + xiaozhi-server | ~45 min | + +Run mode A first to validate the MCP server + the three-act storyboard. Run mode B when you have hardware + LLM key + a live broker deployed. + +--- + +## A. Dev / fresh-laptop demo + +### TL;DR — one line, end to end + +```bash +bash scripts/mcp-demo-mode-a.sh +``` + +That's it. The script builds the binary, allocates an ephemeral port, boots +the server with `--backend in-memory`, walks all three acts of the storyboard +with JSON-RPC assertions, exercises the auth negative paths, and cleans up. +Expected output ends with `ALL ASSERTIONS PASSED.` (19 checks). This is the +same one-liner the CI workflow runs (see [`.github/workflows/mcp-server.yml`](../../../.github/workflows/mcp-server.yml)) — copy-paste-equivalent in CI and on +your laptop. + +If you want to walk the demo manually instead of running the script, the +sections below show every step + every assertion line by line. + +### Prerequisites + +- Rust toolchain (`stable`, matches `rust-toolchain.toml`). +- macOS or Linux. `curl` + a JSON pretty-printer (`jq` preferred, `python3` + as a fallback; the smoke script auto-detects). +- Nothing else. No broker, no workers, no Docker, no LLM key. + +### 1. Build + run the server + +```bash +cd ~/Projects/agentKeys # or wherever you cloned + +cargo run -p agentkeys-mcp-server -- \ + --backend in-memory \ + --listen 127.0.0.1:8088 +``` + +Expected log lines: + +```text +INFO agentkeys_mcp_server: backend=in-memory (dev demo); seeded with three-act fixture (actor 0xa0c7…01a0c7) +INFO agentkeys_mcp_server: agentkeys-mcp-server listening (HTTP) addr=127.0.0.1:8088 +``` + +What got seeded into the in-memory backend: + +| Actor | Namespace | Content | +|---|---|---| +| `0xa0c7…01a0c7` | `travel` | "Chengdu trip — Apr 12 to 16, hotpot at Yulin." | +| `0xa0c7…01a0c7` | `family` | "Wife's bday Aug 3 (gift idea: hiking boots)." | +| `0xa0c7…01a0c7` | `profile` | "Allergic to shellfish. Prefers windowed flights." | + +A default vendor token `magiclick:demo-tok` is auto-seeded in dev mode so the runbook stays one-command. Override with `--vendor-tokens` if you need a different pair. + +### 2. Sanity check — healthz + tools/list + +In a second terminal: + +```bash +curl -sS http://127.0.0.1:8088/healthz +# → {"name":"agentkeys-mcp-server","ok":true} + +curl -sS -X POST http://127.0.0.1:8088/mcp \ + -H "authorization: Bearer demo-tok" \ + -H "x-agentkeys-actor: 0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7" \ + -H "content-type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' \ + | python3 -c "import sys,json;print(len(json.load(sys.stdin)['result']['tools']),'tools')" +# → 10 tools +``` + +### 3. Act 1 — Permissioned Memory + +The MCP host (xiaozhi-server / Claude / etc.) decides it needs memory context and calls `memory.get` scoped to the `travel` namespace: + +```bash +curl -sS -X POST http://127.0.0.1:8088/mcp \ + -H "authorization: Bearer demo-tok" \ + -H "x-agentkeys-actor: 0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7" \ + -H "content-type: application/json" \ + -d '{ + "jsonrpc":"2.0", + "method":"tools/call", + "params":{ + "name":"agentkeys.memory.get", + "arguments":{ + "actor":"0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7", + "namespace":"travel", + "operator_omni":"0x07e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8", + "device_key_hash":"0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + } + }, + "id":1 + }' | python3 -m json.tool +``` + +Expected `structuredContent`: + +```json +{ + "content": "Chengdu trip — Apr 12 to 16, hotpot at Yulin.", + "namespace": "travel", + "ok": true +} +``` + +**Why this matters — and what's M1 vs M4:** in this dev demo the MCP server forwards `namespace` to the in-memory backend, which honors it as a storage key. That makes the dev demo visibly namespace-scoped. **In M1 production**, the real memory worker today does NOT enforce `namespace` cryptographically — the wire field flows through but the S3 key derivation only uses `(actor, service)`. Lifting `namespace` into the SIGNED `CapPayload` so the worker can enforce it is M4 follow-up to #108 ([plan §6](issue-107-mcp-server-phase1.md#6-what-did-not-land-deferred)). The dev demo demonstrates the wire shape; cryptographic enforcement lands later. + +### 4. Act 2 — Deterministic Denial + +The MCP host calls `permission.check` to authorize a 600 RMB hotpot order. The policy engine sees the daily cap is 500 RMB and returns a deny verdict with the storyboard's exact reason string: + +```bash +curl -sS -X POST http://127.0.0.1:8088/mcp \ + -H "authorization: Bearer demo-tok" \ + -H "x-agentkeys-actor: 0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7" \ + -H "content-type: application/json" \ + -d '{ + "jsonrpc":"2.0", + "method":"tools/call", + "params":{ + "name":"agentkeys.permission.check", + "arguments":{ + "actor":"0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7", + "scope":"payment.spend", + "params":{"amount_rmb":600} + } + }, + "id":1 + }' | python3 -m json.tool +``` + +Expected `structuredContent`: + +```json +{ + "verdict": "deny", + "reason": "daily_spend_cap_exceeded", + "scope": "payment.spend", + "explanation": "cap=500, requested=600, period=daily" +} +``` + +**Why this matters:** the verdict came from `crate::policy::PolicyEngine`, a pure function. **No LLM, no inference, no network call.** Change the amount to `200` and re-run — verdict flips to `accept`. Change the scope to anything not in the policy table (e.g. `nuke.launch`) — verdict is `deny` with reason `scope_not_in_policy_table` (closed-world default-deny). + +### 5. Act 3 — Online Revocation + +Three steps: mint a cap, revoke that exact cap by its nonce, and append the audit event. Then verify that revoking an unknown cap fails — a real revoke list, not a rubber stamp. + +```bash +# 5a. Mint a memory_get cap so we have a real cap_id to revoke. +CAP=$(curl -sS -X POST http://127.0.0.1:8088/mcp \ + -H "authorization: Bearer demo-tok" \ + -H "x-agentkeys-actor: 0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7" \ + -H "content-type: application/json" \ + -d '{ + "jsonrpc":"2.0", + "method":"tools/call", + "params":{ + "name":"agentkeys.cap.mint", + "arguments":{ + "actor":"0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7", + "op":"memory_get", + "params":{ + "operator_omni":"0x07e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8", + "service":"memory", + "device_key_hash":"0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + }, + "ttl":300 + } + }, + "id":1 + }') +# Pick `cap_id` (the cap's nonce) out of the response — `jq` or `python3`: +CAP_ID=$(echo "$CAP" | jq -r '.result.structuredContent.cap.payload.nonce' 2>/dev/null \ + || echo "$CAP" | python3 -c "import sys,json;print(json.load(sys.stdin)['result']['structuredContent']['cap']['payload']['nonce'])") +echo "cap_id = $CAP_ID" + +# 5b. Revoke THAT cap (by its nonce). +curl -sS -X POST http://127.0.0.1:8088/mcp \ + -H "authorization: Bearer demo-tok" \ + -H "x-agentkeys-actor: 0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7" \ + -H "content-type: application/json" \ + -d "$(printf '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"agentkeys.cap.revoke","arguments":{"cap_id":"%s"}},"id":1}' "$CAP_ID")" \ + | python3 -m json.tool + +# 5c. Try to revoke a cap that was never minted — MUST fail. This is the +# difference from a rubber-stamp implementation. +curl -sS -X POST http://127.0.0.1:8088/mcp \ + -H "authorization: Bearer demo-tok" \ + -H "x-agentkeys-actor: 0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7" \ + -H "content-type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"agentkeys.cap.revoke","arguments":{"cap_id":"this-cap-was-never-minted"}},"id":1}' \ + | python3 -m json.tool + +# 5d. Audit row for the revoke event. +curl -sS -X POST http://127.0.0.1:8088/mcp \ + -H "authorization: Bearer demo-tok" \ + -H "x-agentkeys-actor: 0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7" \ + -H "content-type: application/json" \ + -d "$(printf '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"agentkeys.audit.append","arguments":{"actor":"0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7","event":{"operator_omni":"0x07e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8","op_kind":3,"op_body":{"cap_id":"%s","reason":"parent_revoke"},"result":0,"intent_text":"parent revoked payment access"}}},"id":1}' "$CAP_ID")" \ + | python3 -m json.tool +``` + +Expected: 5b succeeds (`"revocation":"in_memory"`), 5c returns a JSON-RPC error with body `unknown cap_id: this-cap-was-never-minted`, 5d returns `{"ok": true, "envelope_hash": "0x<32-byte sha256>"}`. The `envelope_hash` is a SHA-256 over the audit input — two different appends produce two different hashes. + +**Why this matters:** revoke + audit are decoupled by design. The dev backend tracks minted nonces and refuses to revoke unknown ones — so a typo or a stale cap surfaces immediately. In M1 production, broker-side revocation is still a follow-up (`cap.revoke` is a graceful stub against the real backend per [plan §6](issue-107-mcp-server-phase1.md#6-what-did-not-land-deferred)); the dev demo shows the contract the broker will honor in M4. + +### 6. Acceptance-criterion #3 — auth negative paths + +Demonstrate the bearer + actor scoping rules from the issue: + +```bash +# Wrong token → 401 +curl -sS -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8088/mcp \ + -H "authorization: Bearer nope" \ + -H "x-agentkeys-actor: 0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7" \ + -H "content-type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' +# → 401 + +# Missing X-AgentKeys-Actor header → 403 +curl -sS -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8088/mcp \ + -H "authorization: Bearer demo-tok" \ + -H "content-type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' +# → 403 + +# Tool param actor != header actor → JSON-RPC error code -32003 +curl -sS -X POST http://127.0.0.1:8088/mcp \ + -H "authorization: Bearer demo-tok" \ + -H "x-agentkeys-actor: O_alice" \ + -H "content-type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"agentkeys.identity.whoami","arguments":{"actor":"O_bob"}},"id":1}' \ + | python3 -c "import sys,json;d=json.load(sys.stdin);print('error code:',d['error']['code'])" +# → error code: -32003 +``` + +### 7. Schema-only stubs + +The 3 deferred tools return the exact wire shape from the issue: + +```bash +curl -sS -X POST http://127.0.0.1:8088/mcp \ + -H "authorization: Bearer demo-tok" \ + -H "x-agentkeys-actor: 0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7" \ + -H "content-type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"agentkeys.delegation.grant","arguments":{}},"id":1}' \ + | python3 -m json.tool +``` + +Expected error body: + +```json +{ + "jsonrpc": "2.0", + "error": { + "code": -32000, + "message": "not_implemented_in_v1", + "data": { + "error": "not_implemented_in_v1", + "scheduled_for": "M4", + "spec_url": "https://github.com/litentry/agentKeys/blob/main/docs/spec/plans/milestones-roadmap.md#m4" + } + }, + "id": 1 +} +``` + +### 8. Tear down + +Ctrl-C the server. No state to clean up — the in-memory backend dies with the process. + +### What dev-mode does NOT prove + +- The broker actually mints valid cap-tokens with the on-chain device-binding ceremony. +- The memory worker actually re-verifies cap signatures and decrypts S3 envelopes. +- The audit worker actually anchors the Merkle root on-chain inside the 2-min SLA. +- xiaozhi-server's Doubao / Qwen LLM actually decides to call the right tools at the right moments. + +For those, see mode **B** below. + +--- + +## B. Full xiaozhi demo via the MCP-endpoint relay (no firmware flash, no LLM key) + +> **Hardware-free, account-light.** The xiaozhi cloud already runs the LLM and already talks to xiaozhi devices in the wild. We register our MCP server as a tool with that cloud — no firmware to flash, no Doubao/Qwen key to provision. The only thing you need from xiaozhi's side is **a xiaozhi.me account with one agent (智能体)** so they hand us a relay URL to connect to. Mode D in the repo verifies this whole loop against a local mock relay, so every layer is exercised before you ever touch a real device. + +### B.0 How to test §B — four tiers from no resources to live cloud + +§B has resource requirements that can't be satisfied from a fresh laptop alone (live broker, xiaozhi.me agent, paired device). The correct way to test it is a **ladder of verification** — each tier catches a class of bugs the next-cheaper tier can't. Run them top-down and only move on when the current tier is green. + +| Tier | What it proves | What you need | One-line command | +|---|---|---|---| +| 1 | Server boots; in-memory backend three-act flow works; auth scoping works | Rust toolchain + curl + jq/python3 | `bash scripts/mcp-demo-mode-a.sh` | +| 2 | MCP wire protocol is spec-compliant (Anthropic SDK can drive us) | `uv` (Python launcher) | `bash scripts/mcp-demo-mode-b-protocol.sh` | +| 3 | xiaozhi-server's actual production integration class (`ServerMCPClient`) can call every tool, with sanitized names + deterministic fake-LLM tool choice | `uv` + git (clones xiaozhi-server) | `bash scripts/mcp-demo-mode-c-xiaozhi-client.sh` | +| 4 | xiaozhi-style relay topology — two ws paths, token pairing, frame forwarding — end-to-end through `--transport mcp-endpoint` | `uv` (mock relay is Python) | `bash scripts/mcp-demo-mode-d-xiaozhi-endpoint.sh` | +| 5 | Live broker + workers + real `mcp-endpoint-server` on EC2 + a xiaozhi.me agent + a paired device | All of the above + AWS access + xiaozhi.me account + voice device | §B.4–§B.10 below | + +Tiers 1–4 are **CI-able**. They run in `.github/workflows/mcp-server.yml` and assert every claim in this section. **When all four pass, the only remaining failure modes are operator deploy errors and cloud-side config** — neither is a bug in our code. + +Tier 5 is operator-driven. There is no software substitute for "did the chain actually mint the cap with the right device binding" — that's why tier 5 is on hardware + live infrastructure. + +The fastest way to validate a §B change end-to-end without live resources: re-run **tier 4** (`mode-d`). It's the closest hardware-free approximation of production. The relay routing, the WebSocket frame protocol, the `--transport mcp-endpoint` reconnect logic, the three-act tool wiring — all exercised exactly as they will be in production. Tier 5 only adds: real `mcp-endpoint-server` binary instead of the mock, the xiaozhi cloud talking instead of a fake client, a real voice device instead of a script. + +### B.1 Topology + +``` +┌──────────────────────┐ audio / ws ┌─────────────────────────┐ +│ any xiaozhi device │ ─────────────────────── │ xiaozhi cloud (LLM, │ +│ already in the wild │ │ STT, TTS, intent) │ +└──────────────────────┘ │ │ + │ 智控台 + mcp_endpoint │ + │ config: ws://relay/... │ + └────────────┬────────────┘ + │ ws + ▼ + ┌───────────────────────────┐ + │ mcp-endpoint-server │ + │ (relay; one Python proc, │ + │ github.com/xinnan-tech/ │ + │ mcp-endpoint-server) │ + └─────┬────────────┬────────┘ + │ tool path │ client path + │ │ + ▼ ▼ + ┌──────────────────────────────────────────┐ + │ agentkeys-mcp-server │ + │ --transport mcp-endpoint │ + │ --mcp-endpoint ws://relay/.../?token=… │ + └─────┬────────────────┬───────────────────┘ + │ │ + ▼ ▼ + ┌──────────────┐ ┌──────────────┐ + │ broker │ │ memory │ + │ + audit │ │ + cred │ + │ worker │ │ worker │ + └──────────────┘ └──────────────┘ + │ + ▼ + ┌──────────────────┐ + │ Heima parachain │ + └──────────────────┘ +``` + +What this path eliminates from the original draft: + +- No MagicLick toy needs to be flashed. The xiaozhi cloud runs the device's voice loop. Any xiaozhi device already paired with your agent works. +- No Doubao/Qwen API key. The xiaozhi cloud's LLM is the one that decides to call our tools — your agent config (system prompt) tunes that. +- No Docker. The MCP server, the relay, and the broker all live as systemd units on the same EC2 host per the existing `setup-broker-host.sh` pattern. + +### B.2 Prerequisites (fresh laptop → demo) + +1. **AWS access** — `agentkeys-admin` profile, per [`docs/cloud-setup.md`](../../cloud-setup.md). +2. **Heima chain access** — operator wallet funded on Heima mainnet (`AGENTKEYS_CHAIN=heima`). +3. **Operator workstation env** sourced: `set -a && source scripts/operator-workstation.env && set +a`. +4. **A xiaozhi.me account** with one agent (智能体) created. Free tier is fine. +5. **uv** (Python launcher) on the laptop — required for the mode-B/C/D pre-flight scripts. `brew install uv` or [official installer](https://docs.astral.sh/uv/). +6. **Rust toolchain** (matches `rust-toolchain.toml`). +7. **Foundry + Docker** are NOT prerequisites for this path (Foundry only if you also need to redeploy contracts; Docker is intentionally not used). + +You do NOT need: a MagicLick toy, a Doubao/Qwen API key, Ollama, or ESP-IDF. + +### B.3 Pre-flight against the repo — no cloud account needed yet + +Run all four hardware-free smoke scripts first. If any fails, fix it before touching the EC2 host. + +```bash +bash scripts/mcp-demo-mode-a.sh # curl + in-memory backend +bash scripts/mcp-demo-mode-b-protocol.sh # Anthropic mcp SDK (uv) +bash scripts/mcp-demo-mode-c-xiaozhi-client.sh # xiaozhi-server's ServerMCPClient +bash scripts/mcp-demo-mode-d-xiaozhi-endpoint.sh # xiaozhi-style WS relay +``` + +Mode D is the closest hardware-free approximation of B: it spins up a tiny mock relay that mirrors `xinnan-tech/mcp-endpoint-server`'s tool/client routing exactly, then drives the relay from a fake xiaozhi client through all three acts. When this passes, the only difference between dev and prod is the relay binary and the cloud-side talker. + +### B.4 Stand up the chain + broker + workers + +One-command idempotent bring-up of the existing AgentKeys infra per CLAUDE.md's "single entry point" rules: + +```bash +# If the AWS account hasn't been bootstrapped yet, this provisions +# all DNS A records — including mcp.litentry.org / test-mcp.litentry.org +# — alongside DKIM/SPF/DMARC/MX. Idempotent; safe to re-run. +bash scripts/setup-cloud.sh --env-file scripts/operator-workstation.env + +AGENTKEYS_CHAIN=heima bash scripts/setup-heima.sh +bash scripts/setup-broker-host.sh --upgrade +AGENTKEYS_CHAIN=heima bash scripts/verify-heima-contracts.sh +``` + +> **Chain targeting** — the `verify-heima-contracts.sh` invocation above +> reads contract addresses from `scripts/operator-workstation.env` (the +> default `$ENV_FILE`). With `AGENTKEYS_CHAIN=heima` it verifies the +> **live v2 stage-1 contracts on Heima mainnet** (the addresses in +> [`docs/spec/deployed-contracts.md`](deployed-contracts.md)) — there is no +> separate "test" set of contracts. Demo isolation is per-actor (fresh +> `operator_omni` / `actor_omni` / `device_key_hash` per run, cap-mint +> enforces device binding on chain). For an off-prod env file (e.g. a +> staging operator-workstation file), set `ENV_FILE=/path/to/x.env` +> ahead of the command — `setup-heima.sh --test` already does this for +> its own test path. + +Capture for the next step: + +- `BROKER_URL=https://broker.litentry.org` +- `MEMORY_WORKER_URL=https://memory.litentry.org` +- `AUDIT_WORKER_URL=https://audit.litentry.org` +- A real actor omni from `heima-agent-register.sh` (32-byte hex). +- A device key hash from `heima-device-register.sh` (32-byte hex). + +### B.5 Deploy the MCP server on the broker + +**Production / test (one command from the operator workstation):** [`scripts/setup-cloud.sh`](../../../scripts/setup-cloud.sh) step 15 SSMs the broker EC2 and runs [`scripts/setup-mcp-host.sh`](../../../scripts/setup-mcp-host.sh) there. Same step handles prod (`mcp.${ZONE}`) and test (`test-mcp.${ZONE}`). + +```bash +# Bring up prod MCP on the broker (cargo installs from github.com/litentry/agentKeys main) +bash scripts/setup-cloud.sh --env-file scripts/operator-workstation.env --only-step 15 + +# Bring up test MCP on the same broker (test-mcp.litentry.org) +bash scripts/setup-cloud.sh --env-file scripts/operator-workstation.test.env --test --only-step 15 + +# Pin to a PR branch / fork while developing +AGENTKEYS_REPO_URL=https://github.com/me/agentKeys.git AGENTKEYS_REV=my-feature-branch \ + bash scripts/setup-cloud.sh --only-step 15 +``` + +The step polls the SSM command for up to 10 min and tails the last 30 lines of stdout when it completes (or stderr on failure). Idempotent — re-runs short-circuit when state is already correct. + +**Local development install (laptop / Claude Code / Codex CLI / Claude Desktop):** + +```bash +cargo install --git https://github.com/litentry/agentKeys agentkeys-mcp-server +``` + +This is the canonical install path until M6 ships GH Releases + a native installer ([#134](https://github.com/litentry/agentKeys/issues/134)). Binary lands at `~/.cargo/bin/agentkeys-mcp-server`. Then wire it into your LLM host: + +```bash +# Claude Code — user scope, available in every project +claude mcp add --scope user agentkeys \ + -e MCP_TRANSPORT=stdio -e MCP_BACKEND=in-memory \ + -- ~/.cargo/bin/agentkeys-mcp-server + +# Codex CLI — append to ~/.codex/config.toml: +# [mcp_servers.agentkeys] +# command = "~/.cargo/bin/agentkeys-mcp-server" +# env = { MCP_TRANSPORT = "stdio", MCP_BACKEND = "in-memory" } + +# Claude Desktop (macOS) — merge into ~/Library/Application Support/Claude/claude_desktop_config.json: +# { "mcpServers": { "agentkeys": { "command": "~/.cargo/bin/agentkeys-mcp-server", +# "env": { "MCP_TRANSPORT": "stdio", "MCP_BACKEND": "in-memory" } } } } +``` + +Switch `MCP_BACKEND=in-memory` → `MCP_BACKEND=http` and set `AGENTKEYS_BROKER_URL` / `AGENTKEYS_MEMORY_URL` / `AGENTKEYS_AUDIT_URL` to point at a real broker. + +**setup-mcp-host.sh modes (when running on broker directly).** The script has two relay modes; setup-cloud.sh step 15 defaults to mode A (recommended). + +- **Mode A — xiaozhi-hosted (DEFAULT).** Xiaozhi.me hosts the relay; the script just runs `agentkeys-mcp-server` pointing at xiaozhi's WS URL. No nginx, no certbot, no `mcp.litentry.org` DNS needed. + + ```bash + bash scripts/setup-mcp-host.sh --xiaozhi-endpoint 'wss://api.xiaozhi.me/mcp/?token=…' + bash scripts/setup-mcp-host.sh # re-run; URL loaded from disk + ``` + +- **Mode B — self-hosted relay (custom endpoints).** Operator runs their own `mcp-endpoint-server` behind nginx with a real cert. Needs the `mcp.litentry.org` DNS A record from `setup-cloud.sh` step 6. + + ```bash + bash scripts/setup-mcp-host.sh --self-hosted-relay # prod → mcp.litentry.org + bash scripts/setup-mcp-host.sh --self-hosted-relay --test # test → test-mcp.litentry.org + ``` + +> **ACME account email** — Let's Encrypt records one email per ACME account; used for cert-expiry / renewal-failure notifications. The script picks one of three behaviors: +> 1. If `/etc/letsencrypt/accounts/` already has a registered ACME account (very common — `setup-broker-host.sh` will have registered one for the broker host), the new cert is issued against that account. **No email flag needed.** This is the normal path. +> 2. If you pass `--certbot-email `, that address is used. Pick any mailbox you actually monitor — a team alias if Litentry has one (`agentkeys@litentry.org` / `infra@litentry.org`), or your personal address. +> 3. If neither applies, the script falls through to `--register-unsafely-without-email` — cert still issues; no expiry notifications. You can re-run later with `--certbot-email` to attach a recovery address. + +> **DNS A record** — the A record for `$MCP_HOST` (prod `mcp.litentry.org`, test `test-mcp.litentry.org`) is provisioned by `scripts/setup-cloud.sh` step 6 alongside the broker + signer + worker subdomains — one batched Route53 UPSERT, all 7 A records point at the same EIP. Run it once at account bootstrap: +> +> ```bash +> set -a && source scripts/operator-workstation.env && set +a # or .test.env + --test +> bash scripts/setup-cloud.sh --env-file scripts/operator-workstation.env --only-step 6 +> ``` +> +> If you run `setup-mcp-host.sh` before that, step 8 polls public DNS for 3 min, then skips the cert and prints the exact command to fix it. Services (relay + MCP server) stay up — TLS activates on the re-run after DNS is live. + +What the script lands: + +- `/opt/agentkeys/mcp-endpoint/src/` — pinned clone of `xinnan-tech/mcp-endpoint-server` (default ref: `main`; override with `--relay-ref `). +- `/opt/agentkeys/mcp-endpoint/src/.venv/` — Python venv with the relay's requirements. +- `/usr/local/bin/agentkeys-mcp-server` — release binary, installed via `cargo install --git $AGENTKEYS_REPO_URL --branch $AGENTKEYS_REV` (defaults `litentry/agentKeys` + `main`). Cached at `~/.cache/agentkeys-mcp-install/` and `install`ed to `/usr/local/bin/` only when its sha256 drifts. +- `/etc/agentkeys/mcp.env` — `MCP_ENDPOINT=ws://127.0.0.1:8004/mcp_endpoint/mcp/?token=` + the broker/memory/audit URLs (0600, owned by the run user). +- `/etc/agentkeys/mcp-tool-token` + `/etc/agentkeys/mcp-health-key` — the persistent secrets the URL tokens are derived from. Generated on first run only; subsequent runs preserve them so the relay URLs stay stable across deploys. +- `/etc/systemd/system/mcp-endpoint-server.service` + `/etc/systemd/system/agentkeys-mcp-server.service` — diff-then-write; daemon-reload + restart only when content changed. +- `/etc/nginx/sites-available/mcp.litentry.org` — vhost terminating TLS for `mcp.litentry.org`, upgrading `wss://` to `ws://127.0.0.1:8004/`, with HTTP→HTTPS redirect and the `Upgrade`/`Connection` headers required for WebSocket. Reload only when content changed. +- Let's Encrypt cert via `certbot --nginx -d mcp.litentry.org` — reused on subsequent runs. + +Outputs at the end of each run — capture for §B.7: + +```text +Tool URL (this MCP server connects here): + wss://mcp.litentry.org/mcp_endpoint/mcp/?token= +Client URL (xiaozhi cloud / xiaozhi-server connects here): + wss://mcp.litentry.org/mcp_endpoint/call/?token= +Health URL (智控台 health probe): + https://mcp.litentry.org/mcp_endpoint/health?key= +``` + +Verify both services are alive: + +```bash +sudo journalctl -u mcp-endpoint-server -n 30 --no-pager +sudo journalctl -u agentkeys-mcp-server -n 30 --no-pager +# Expected log line on the MCP server after the relay accepts it: +# INFO agentkeys_mcp_server: mcp-endpoint: connected; awaiting MCP frames +``` + +If the MCP server fails to connect, the binary backs off and retries 1–600s exponentially (mirrors `mcp_pipe.py`). It will pick up automatically once the relay is healthy. + +> **Why wss + domain name** — the xiaozhi cloud's 智控台 won't accept a plain `http://:8004/...` URL in production. TLS termination at nginx for `mcp.litentry.org` lets you paste a `wss://` URL into 智控台 and have it round-trip through the same vhost that fronts the broker. + +### B.6 Clone and run xiaozhi-server (single-module path) + +> Skip if you are using **智控台 full-module deploy** (xiaozhi cloud hosts the server). Only needed when you want to run xiaozhi-server locally — e.g. a laptop demo or a staging env. + +**Clone:** + +```bash +git clone https://github.com/xinnan-tech/xiaozhi-esp32-server +cd xiaozhi-esp32-server +``` + +**Install dependencies** (requires Python 3.10+; `uv` or plain `pip` both work): + +```bash +uv sync # faster — recommended +# or: pip install -r requirements.txt +``` + +**Copy config and set the MCP endpoint URL:** + +```bash +cp data/config.yaml data/.config.yaml # note the leading dot +``` + +Open `data/.config.yaml` and add the `mcp_endpoint` key. Use the **Tool URL** printed at the end of `scripts/setup-mcp-host.sh` (§B.5): + +```yaml +# data/.config.yaml — minimal changes from defaults + +server: + websocket: ws://0.0.0.0:8000/xiaozhi/v1/ + http_port: 8002 + +mcp_endpoint: "wss://mcp.litentry.org/mcp_endpoint/mcp/?token=" +``` + +**Env vars for the server** — none beyond the config file. All LLM, STT, and TTS +settings already point at the xiaozhi cloud in the default `config.yaml`. No +Doubao/Qwen key, no Ollama, no local GPU. + +**Start:** + +```bash +uv run python app/main.py +``` + +Expected startup output: + +```text +INFO: Application startup complete. +mcp接入点是 wss://mcp.litentry.org/mcp_endpoint/mcp/?token=… +当前支持的函数列表: ['agentkeys_memory_get', 'agentkeys_memory_put', + 'agentkeys_permission_check', 'agentkeys_cap_mint', 'agentkeys_cap_revoke', + 'agentkeys_audit_append', 'agentkeys_identity_whoami', ...] +``` + +When the function list appears the relay is routing correctly and the three acts are ready (§B.8). + +> **Vendor token vs relay token** — for the xiaozhi relay path there is no separate vendor token to mint. The `?token=…` appended to the relay URL IS the auth token; it was auto-generated by `setup-mcp-host.sh` during §B.5 and is stable across re-deploys. Bearer-token vendor auth applies only to direct HTTP calls to the MCP server (mode A/B dev demo) — xiaozhi-server never makes those calls. + +> **Broker URLs** — already wired into the MCP server's `/etc/agentkeys/mcp.env` by `setup-mcp-host.sh` (§B.5). The defaults `https://broker.litentry.org`, `https://memory.litentry.org`, `https://audit.litentry.org` are set there. If you deploy against a different broker host, update `AGENTKEYS_BROKER_URL` / `AGENTKEYS_MEMORY_URL` / `AGENTKEYS_AUDIT_URL` in that file and `sudo systemctl restart agentkeys-mcp-server`. + +### B.7 Register the relay URL on your xiaozhi.me agent (智控台) + +There are two registration paths depending on how you deploy xiaozhi-server. The official guide is at [`docs/mcp-endpoint-enable.md`](https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-enable.md); the short version: + +**Full-module (智控台) deploy:** + +1. 智控台 → 参数字典 → 系统功能配置 → enable `MCP接入点` and save. +2. 智控台 → 参数字典 → 参数管理 → search `server.mcp_endpoint` and paste the **Health URL** printed at the end of `setup-mcp-host.sh`: + `https://mcp.litentry.org/mcp_endpoint/health?key=`. +3. 智控台 → 智能体管理 → 配置角色 → 编辑功能 → MCP接入点 → save. + +**Single-module deploy:** + +Edit `data/.config.yaml` (note the leading dot + `data/` prefix; verified against `xinnan-tech/xiaozhi-esp32-server@7f73dae`): + +```yaml +server: + websocket: ws://:/xiaozhi/v1/ + http_port: 8002 + +mcp_endpoint: wss://mcp.litentry.org/mcp_endpoint/mcp/?token= +``` + +Restart xiaozhi-server. The startup log should now print `mcp接入点是 ws://...`. When your agent connects, look for: `当前支持的函数列表: [..., 'agentkeys_permission_check', 'agentkeys_memory_get', 'agentkeys_cap_mint', ...]`. + +### B.8 Run the three acts + +Any voice device already paired with your xiaozhi agent works. Or use xiaozhi-server's text-input diagnostic to skip the audio loop entirely (no MagicLick toy required). + +1. **Act 1**: ask *"我这周末去哪里玩?"* (Where am I going this weekend?) + - Expected: the cloud LLM calls `agentkeys.memory.get(namespace="travel")`, the relay forwards to the MCP server, the MCP server hits the live memory worker, the LLM synthesizes a TTS reply naming Chengdu. + - **Verify**: `journalctl -u agentkeys-mcp-server -f` shows the tool call land; `journalctl -u mcp-endpoint-server -f` shows the relay forwarding. +2. **Act 2**: ask *"帮我点 600 块的火锅"* (Order me 600 RMB of hotpot.) + - Expected: `agentkeys.permission.check` returns `verdict=deny, reason=daily_spend_cap_exceeded, explanation=cap=500, requested=600, period=daily`. The LLM refuses politely. + - **Verify**: tail the MCP server log; the verdict came from `crate::policy::PolicyEngine`, deterministic and pure. +3. **Act 3**: From the parent-control UI (or via curl through the relay against the same agent) call `agentkeys.cap.revoke()`. Re-ask "帮我点 200 块的火锅" — `permission.check` denies via the revoked cap path (or, in M1, succeeds because broker revoke is an M4 follow-up; the demo here is of the *flow*). + +### B.9 What to capture for the vendor pitch + +- A 15-second video of Act 1 (cloud LLM names the city correctly). +- A 15-second video of Act 2 (cloud LLM refuses politely; the parent UI shows the audit row). +- A screenshot of the chain explorer showing the audit anchor batch in the next 2-min window. +- Time from voice trigger to MCP tool call landing on the broker — should be <500 ms for `permission.check`, ~1 s for `memory.get` (S3 + decrypt round trip). + +### B.10 Tear down + +```bash +sudo systemctl disable --now agentkeys-mcp-server mcp-endpoint-server +# Broker + workers stay up — shared infra. Only stop them when decommissioning the env. +``` + +### B.11 What's verified vs operator-driven + +**Verified — automatable, no hardware, no LLM key, no xiaozhi account needed** (run in CI): + +- ✅ MCP wire protocol over Streamable HTTP (`mode-b-protocol.sh`). +- ✅ xiaozhi-server's `ServerMCPClient` integration code (`mode-c-xiaozhi-client.sh`). +- ✅ xiaozhi-style relay topology (tool side + client side, same token, two ws paths) — `mode-d-xiaozhi-endpoint.sh` spins up a mock relay and runs every act through it. +- ✅ Hardened dev demo with 19 assertions, port-free preflight, JSON-RPC parse, content-dependent envelope hash, hex32 wire-compatible fixtures (`mode-a.sh`). + +**Operator-driven — needs a live deploy + a xiaozhi.me account**: + +- ☁️ Live broker + workers (one-command via `setup-broker-host.sh` + `setup-heima.sh`). +- 🔑 `mcp-endpoint-server` deployed as systemd next to the broker. +- 🆔 xiaozhi.me agent created and the relay URL registered (智控台 or `data/.config.yaml`). +- 📞 At least one xiaozhi device (any model, no firmware change) paired with the agent for Acts 1–3 over voice. Alternatively: text-input diagnostic mode on xiaozhi-server skips the audio loop entirely. + +**Known gaps to fold back when you run it**: + +- Parent-control UI (#111) — until it lands, simulate Act 3's revoke via a curl call through the relay between the two voice prompts. +- Live broker `/v1/revoke/cap/:id` lands in M4 — until then, `cap.revoke` is the structured stub on the MCP server. +- Vendor token mint is hand-edited into `MCP_VENDOR_TOKENS` for the HTTP transport. The mcp-endpoint transport bypasses vendor tokens (the relay URL token is the binding) so this isn't on the critical path for B. +- `scripts/setup-broker-host.sh --with-mcp-endpoint` — fold both systemd units (relay + MCP server) into the existing idempotent host setup. Follow-up. +--- + +## Where to file demo-specific bugs + +- MCP server bug (this crate's code path) → issue on `litentry/agentKeys` labeled `area/mcp`. +- xiaozhi-server bug → upstream at `xinnan-tech/xiaozhi-esp32-server`. +- MagicLick firmware bug → upstream at `xiaozhi-esp32` repo. +- Broker / worker bug → `litentry/agentKeys` labeled `area/broker` / `area/worker`. + +## See also + +- [`docs/spec/plans/issue-107-mcp-server-phase1.md`](issue-107-mcp-server-phase1.md) — the canonical plan + landed-vs-deferred table for #107. +- [`docs/research/agent-iam-strategy.md`](../../research/agent-iam-strategy.md) §4.3 — the three-act demo storyboard. +- [`docs/research/xiaozhi-hermes-architecture.md`](../../research/xiaozhi-hermes-architecture.md) — why xiaozhi-server's stock MCP support means no fork needed. +- [`crates/agentkeys-mcp-server/README.md`](../../../crates/agentkeys-mcp-server/README.md) — server-side ops reference. diff --git a/docs/spec/plans/issue-107-mcp-server-phase1.md b/docs/spec/plans/issue-107-mcp-server-phase1.md new file mode 100644 index 0000000..8c23575 --- /dev/null +++ b/docs/spec/plans/issue-107-mcp-server-phase1.md @@ -0,0 +1,197 @@ +# Issue #107 — AgentKeys MCP server (Phase 1) + +Plan document for the Phase 1 MCP server. Issue: https://github.com/litentry/agentKeys/issues/107. + +## 1. What landed + +The new crate `crates/agentkeys-mcp-server/` ships the 10 tools listed in +issue #107 (7 active + 3 schema-only). It is additive — no existing crate +was modified beyond adding the new member to the workspace `Cargo.toml`. + +| File | Purpose | +|---|---| +| [`src/main.rs`](../../../crates/agentkeys-mcp-server/src/main.rs) | binary entry, CLI parsing, transport selection | +| [`src/lib.rs`](../../../crates/agentkeys-mcp-server/src/lib.rs) | crate root, public exports for tests | +| [`src/mcp.rs`](../../../crates/agentkeys-mcp-server/src/mcp.rs) | JSON-RPC 2.0 + MCP envelope types | +| [`src/server.rs`](../../../crates/agentkeys-mcp-server/src/server.rs) | dispatcher routing `initialize`/`tools/list`/`tools/call`/`ping` | +| [`src/transport.rs`](../../../crates/agentkeys-mcp-server/src/transport.rs) | HTTP (axum) + stdio transports | +| [`src/auth.rs`](../../../crates/agentkeys-mcp-server/src/auth.rs) | Bearer + `X-AgentKeys-Actor` header validation | +| [`src/policy.rs`](../../../crates/agentkeys-mcp-server/src/policy.rs) | deterministic policy engine for `permission.check` | +| [`src/config.rs`](../../../crates/agentkeys-mcp-server/src/config.rs) | CLI + env → `Config` | +| [`src/errors.rs`](../../../crates/agentkeys-mcp-server/src/errors.rs) | `McpError` → JSON-RPC error mapping | +| [`src/backend/mod.rs`](../../../crates/agentkeys-mcp-server/src/backend/mod.rs) | `Backend` trait + wire types | +| [`src/backend/http_backend.rs`](../../../crates/agentkeys-mcp-server/src/backend/http_backend.rs) | production `HttpBackend` over reqwest | +| [`src/backend/broker.rs`](../../../crates/agentkeys-mcp-server/src/backend/broker.rs) | broker cap-mint request shape | +| [`src/backend/memory.rs`](../../../crates/agentkeys-mcp-server/src/backend/memory.rs) | memory-worker request shapes | +| [`src/backend/audit.rs`](../../../crates/agentkeys-mcp-server/src/backend/audit.rs) | audit-worker `AppendV2` request shape | +| [`src/tools/mod.rs`](../../../crates/agentkeys-mcp-server/src/tools/mod.rs) | tool registry + `inputSchema` for each | +| [`src/tools/identity.rs`](../../../crates/agentkeys-mcp-server/src/tools/identity.rs) | `agentkeys.identity.whoami` | +| [`src/tools/permission.rs`](../../../crates/agentkeys-mcp-server/src/tools/permission.rs) | `agentkeys.permission.check` | +| [`src/tools/cap.rs`](../../../crates/agentkeys-mcp-server/src/tools/cap.rs) | `agentkeys.cap.mint` + `agentkeys.cap.revoke` | +| [`src/tools/memory.rs`](../../../crates/agentkeys-mcp-server/src/tools/memory.rs) | `agentkeys.memory.get` + `agentkeys.memory.put` | +| [`src/tools/audit.rs`](../../../crates/agentkeys-mcp-server/src/tools/audit.rs) | `agentkeys.audit.append` | +| [`src/tools/stubs.rs`](../../../crates/agentkeys-mcp-server/src/tools/stubs.rs) | M4 schema-only stubs | +| [`tests/common/mod.rs`](../../../crates/agentkeys-mcp-server/tests/common/mod.rs) | shared `MockBackend` | +| [`tests/three_acts.rs`](../../../crates/agentkeys-mcp-server/tests/three_acts.rs) | three-act demo storyboard | +| [`tests/http_auth.rs`](../../../crates/agentkeys-mcp-server/tests/http_auth.rs) | acceptance #3 — bearer + actor negative paths | +| [`tests/schema_only_stubs.rs`](../../../crates/agentkeys-mcp-server/tests/schema_only_stubs.rs) | acceptance #2 — `not_implemented_in_v1` shape | +| [`Dockerfile`](../../../crates/agentkeys-mcp-server/Dockerfile) | two-stage rust:slim → debian:slim image | +| [`README.md`](../../../crates/agentkeys-mcp-server/README.md) | run + xiaozhi-server integration recipe | +| [`.github/workflows/mcp-server.yml`](../../../.github/workflows/mcp-server.yml) | CI: test + clippy + GHCR image publish | + +Workspace touchpoints: +- [`Cargo.toml`](../../../Cargo.toml) added `crates/agentkeys-mcp-server` to `members`. + +## 2. Architecture + +``` +┌──────────────────┐ POST /mcp (JSON-RPC) ┌─────────────────────┐ +│ xiaozhi-server │ ─Authorization: Bearer ──>│ agentkeys-mcp- │ +│ / Volcano Ark │ X-AgentKeys-Actor: │ server │ +│ / Claude Code │ │ │ +└──────────────────┘ │ • auth.rs │ + │ • policy.rs │ + │ • tools/* │ + │ • backend trait │ + └──────────┬──────────┘ + │ + ┌──────────────────┬────────────────┬──────┴──────┐ + ▼ ▼ ▼ ▼ + ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ broker │ │ memory │ │ audit │ │ (no LLM, │ + │ cap-mint│ │ worker │ │ worker │ │ no DB, │ + │ │ │ │ │ │ │ no chain)│ + └─────────┘ └──────────┘ └──────────┘ └──────────┘ +``` + +Key design choices: + +1. **Rust over Python.** The issue prefers Python; we picked Rust because + (a) the rest of the workspace is Rust — single toolchain, one CI; (b) + the broker/worker DTOs come from `agentkeys-core` and would drift if + re-declared in Pydantic; (c) MCP is a wire protocol — xiaozhi-server + doesn't care what language is on the other side. Phase 0's existing + `crates/agentkeys-mcp/` (Rust JSON-RPC over stdio) was the + pre-existing proof. Issue's "Rust as fallback" clause covers it. +2. **Backend trait.** Production uses `HttpBackend` (reqwest); tests use + `MockBackend`. The trait stays narrow — one method per backend + operation, opaque cap-token blob, no shared DB. +3. **Deterministic policy engine.** `permission.check` lives in + `policy.rs`. Pure function, no I/O, no LLM. The storyboard's Act 2 + wording (`cap=500, requested=600, period=daily`) is locked in by a + unit test in `policy.rs`. +4. **Cap.revoke is a graceful stub.** Broker `/v1/revoke/cap/:id` lands + in M4 (paired with vendor portal #114). M1 returns + `{ok:true, revocation:"local_only", note:"..."}` so the parent UI + can render the verdict immediately. Swap to the real call when the + broker endpoint exists; the tool's wire format does not change. +5. **Namespace at request body for M1.** Per #108 partial deferral — the + namespace travels in the MemoryGet/Put body, not in the signed + CapPayload. The worker accepts it as metadata; cryptographic binding + to the cap lands in M4. + +## 3. Acceptance criteria — status + +| Criterion | Status | Evidence | +|---|---|---| +| 1. 7 active tools respond correctly when invoked from xiaozhi-server | ✅ wired; live demo operator-driven | `tests/three_acts.rs`, `tests/http_auth.rs::tools_list_works_through_http` | +| 2. 3 schema-only tools return documented `not_implemented_in_v1` shape | ✅ | `tests/schema_only_stubs.rs` (3 tests) | +| 3. Bearer + actor header scoping — wrong token 401, no-header 403, wrong actor 403 | ✅ | `tests/http_auth.rs` (6 tests) | +| 4. Unit tests per tool happy + at least 1 negative path | ✅ | 17 unit tests + tool-specific tests under `tools/*::tests` | +| 5. Integration test against mock backend exercising three-act storyboard | ✅ | `tests/three_acts.rs` (5 tests) | +| 6. CI publishes the MCP server image; one-command deploy | ✅ workflow + Dockerfile | `.github/workflows/mcp-server.yml`, `crates/agentkeys-mcp-server/Dockerfile` | +| 7. Demo: invoke each active tool from a real xiaozhi-server session | ⏳ operator-driven | see §5 below | + +## 4. Test summary + +```text +cargo test -p agentkeys-mcp-server + +unit tests: 17 / 17 (auth, policy, identity, permission) +http_auth.rs: 6 / 6 (acceptance #3) +schema_only_stubs.rs: 3 / 3 (acceptance #2) +three_acts.rs: 5 / 5 (acceptance #5) +───────────────────────────── +total: 31 / 31 +``` + +## 5. Demo runbook + +Full two-mode runbook in +[`issue-107-mcp-demo-runbook.md`](issue-107-mcp-demo-runbook.md). + +- **Mode A — dev / fresh-laptop.** No broker, no workers, no hardware. + Boots `--backend in-memory` and walks Acts 1/2/3 via `curl`. Asserted + by `scripts/mcp-demo-mode-a.sh` (regression check for the runbook + itself). +- **Mode B protocol layer — verified.** `scripts/mcp-demo-mode-b-protocol.sh` + uses the same Anthropic Python `mcp` SDK that xiaozhi-server imports + (confirmed by reading `xinnan-tech/xiaozhi-esp32-server@7f73dae` — + file `main/xiaozhi-server/core/providers/tools/server_mcp/mcp_client.py`) + to drive `initialize` → `tools/list` → all three acts → schema-only + stubs end-to-end over Streamable HTTP. +- **Mode C xiaozhi-server integration code — verified.** + `scripts/mcp-demo-mode-c-xiaozhi-client.sh` loads xiaozhi-server's + **own** `ServerMCPClient` class from upstream source and instantiates + it against this MCP server. Same imports, same config-loading path, + same tool-name sanitization, same `call_tool` signature. Bundles a + deterministic fake-LLM so the full LLM → `ServerMCPClient` → + `/mcp` → tools loop is exercised without a real model. When this + passes, the remaining failure modes are downstream of MCP: LLM + tool-choice (model + prompt engineering) and MagicLick audio I/O + (hardware). +- **Mode D — xiaozhi MCP-endpoint relay end-to-end.** `scripts/mcp-demo-mode-d-xiaozhi-endpoint.sh` + stands up a tiny mock relay that mirrors `xinnan-tech/mcp-endpoint-server`'s + tool/client routing (`/mcp_endpoint/mcp/?token=…` for tool side, + `/mcp_endpoint/call/?token=…` for xiaozhi side). It then connects our + MCP server to the tool side via the new `--transport mcp-endpoint` + mode and drives the three acts from the client side. **This is the + hardware-free + LLM-key-free path to the full demo.** When mode D + passes, the only remaining production work is deploying the real + relay (systemd on EC2, not Docker) and registering the relay URL + with a xiaozhi.me agent. No MagicLick firmware flash needed — the + xiaozhi cloud talks to existing devices through the relay. +- **Mode B operator-driven residual.** What's left after modes A/B/C/D + is a live broker + workers deploy (one command via + `scripts/setup-broker-host.sh` + `scripts/setup-heima.sh`), the + real `mcp-endpoint-server` running as systemd next to the broker, + and registration of the relay URL with a xiaozhi.me agent in 智控台. + No paid LLM account, no MagicLick toy, no Docker. + +## 6. What did NOT land (deferred) + +Each is intentional. Cross-linked to issues / milestones. + +- **Broker `/v1/identity/whoami`** — M4, paired with vendor portal #114. + Today `identity.whoami` synthesizes locally from auth context. +- **Broker `/v1/revoke/cap/:id`** — M4. Today `cap.revoke` is a + local-only stub. Tool's wire format will not change when the broker + endpoint lands. +- **Namespace as SIGNED `CapPayload` field** — follow-up to #108. Today + namespace is request-body metadata. +- **Active delegation + approval (`delegation.grant` / + `delegation.revoke` / `approval.request`)** — M4. Today: schema-only + stubs returning `not_implemented_in_v1` per issue spec. +- **Per-vendor bearer rotation policy** — M2 with the vendor onboarding + portal #114. +- **Audit Tier-2 actual on-chain `appendRootV2`** — out of scope for + #107; covered by #109 (partial flush cadence already lives at 120s + per CLAUDE.md M1 expectations). +- **Volcano Ark marketplace registration** — #112, deferred per issue. +- **xiaozhi-server final integration tag/release** — paired with #112. +- **Live operator demo (acceptance #7)** — operator-driven, cannot be + completed in this PR. Runbook above. + +## 7. Follow-ups / clean-ups for the next operator + +- The HTTP transport accepts `X-AgentKeys-Session-Bearer` to forward to + the broker cap-mint endpoint. If the deployment topology lets the MCP + server own a service account JWT instead, we can drop this header — + open question for the M2 vendor portal work. +- `CapMintOp::data_class` is hardcoded as a static string; if a third + data class lands (per arch.md §15.6 payments-audit), the enum and the + registered tool schemas need a matching extension. Closed-extension + pattern — additive. +- The Dockerfile copies the entire workspace into the build stage for + simplicity; a leaner version uses `cargo chef` to cache deps across + builds. diff --git a/scripts/mcp-demo-mode-a.sh b/scripts/mcp-demo-mode-a.sh new file mode 100755 index 0000000..263117b --- /dev/null +++ b/scripts/mcp-demo-mode-a.sh @@ -0,0 +1,264 @@ +#!/usr/bin/env bash +# scripts/mcp-demo-mode-a.sh — automated dev-mode demo for issue #107. +# +# Boots `agentkeys-mcp-server --backend in-memory`, walks Acts 1/2/3, +# asserts each act's expected JSON shape, then cleans up. Use this as +# the regression check for `docs/spec/plans/issue-107-mcp-demo-runbook.md` +# §A — if any assertion fails, the runbook drifted from reality. +# +# Hardened per /codex:adversarial-review (2026-05-25): +# - Hex32 actor/operator/device IDs (so wire-compatible with real broker). +# - Random ephemeral port + post-spawn liveness check (no stale-server +# false positive). +# - JSON-RPC response parsed via jq or python3 (no substring-grep +# hiding tool isError). +# - Act 3 mints a real cap, revokes it by nonce, and proves the +# revoked cap is denied on retry (not just "revoke returned ok"). +# - `cargo run` (not a hardcoded target/debug path) so CI cache +# layouts with $CARGO_TARGET_DIR work. +# +# Usage: +# bash scripts/mcp-demo-mode-a.sh +# +set -euo pipefail + +# ── Prereq check ───────────────────────────────────────────────────── +need() { + command -v "$1" >/dev/null 2>&1 || { + echo "FAIL: missing prerequisite \`$1\`" >&2 + exit 1 + } +} +need cargo +need curl +if command -v jq >/dev/null 2>&1; then + JSON_TOOL=jq +elif command -v python3 >/dev/null 2>&1; then + JSON_TOOL=python3 +else + echo "FAIL: need either \`jq\` or \`python3\` for JSON assertions" >&2 + exit 1 +fi + +# ── Demo fixture identities (hex32, matching backend constants) ────── +ACTOR='0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7' +OPERATOR='0x07e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8' +DEVICE='0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' + +# ── Allocate an ephemeral port to avoid colliding with stale procs ─── +PORT="${MCP_PORT:-}" +if [ -z "$PORT" ]; then + PORT=$(python3 -c "import socket;s=socket.socket();s.bind(('127.0.0.1',0));print(s.getsockname()[1]);s.close()" 2>/dev/null \ + || ruby -rsocket -e "s=TCPServer.new('127.0.0.1',0);puts s.addr[1];s.close" 2>/dev/null \ + || echo 18100) +fi +URL="http://127.0.0.1:${PORT}/mcp" + +# ── Boot the server in the background ──────────────────────────────── +LOG="${TMPDIR:-/tmp}/mcp-demo-$$.log" +( cargo run --quiet -p agentkeys-mcp-server -- --backend in-memory --listen "127.0.0.1:${PORT}" \ + >"$LOG" 2>&1 ) & +PID=$! +trap 'kill $PID 2>/dev/null || true; wait 2>/dev/null || true' EXIT INT TERM + +# Wait for /healthz; bail if the process exits. +for _ in $(seq 1 100); do + if ! kill -0 "$PID" 2>/dev/null; then + echo "FAIL: server process exited during startup. log:" >&2 + cat "$LOG" >&2 + exit 1 + fi + if curl -sf "http://127.0.0.1:${PORT}/healthz" >/dev/null 2>&1; then + break + fi + sleep 0.2 +done + +if ! curl -sf "http://127.0.0.1:${PORT}/healthz" >/dev/null; then + echo "FAIL: /healthz did not respond on port $PORT after 20s" >&2 + cat "$LOG" >&2 + exit 1 +fi + +# ── Helpers ────────────────────────────────────────────────────────── +call() { + curl -sS -X POST "$URL" \ + -H "authorization: Bearer demo-tok" \ + -H "x-agentkeys-actor: $ACTOR" \ + -H "content-type: application/json" \ + -d "$1" +} + +# JSON read: $1 = body, $2 = path expression. jq path syntax; the +# python3 fallback translates `.a.b` → `['a']['b']`. +jread() { + local body="$1" path="$2" + if [ "$JSON_TOOL" = "jq" ]; then + printf '%s' "$body" | jq -r "$path" + else + printf '%s' "$body" \ + | python3 -c " +import json, sys, re +body=json.load(sys.stdin) +path='''$path'''.lstrip('.') +parts=[p for p in re.split(r'\.', path) if p] +v=body +for p in parts: + v=v.get(p) if isinstance(v, dict) else None +print('' if v is None else (v if isinstance(v,str) else json.dumps(v))) +" + fi +} + +assert_eq() { + local got="$1" expected="$2" label="$3" + if [ "$got" = "$expected" ]; then + echo " ✓ $label" + else + echo " ✗ $label — expected: $expected — got: $got" >&2 + exit 1 + fi +} + +assert_contains() { + local needle="$1" haystack="$2" label="$3" + if echo "$haystack" | grep -q -F -- "$needle"; then + echo " ✓ $label" + else + echo " ✗ $label — expected to find: $needle" >&2 + echo " got: $haystack" >&2 + exit 1 + fi +} + +assert_no_error() { + local body="$1" label="$2" + local err + err=$(jread "$body" '.error.code') + if [ -z "$err" ] || [ "$err" = "null" ]; then + echo " ✓ $label (no JSON-RPC error)" + else + echo " ✗ $label — JSON-RPC error code=$err: $body" >&2 + exit 1 + fi +} + +# Build a tools/call request body. +call_body() { + local name="$1" args="$2" id="${3:-1}" + printf '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"%s","arguments":%s},"id":%s}' \ + "$name" "$args" "$id" +} + +# ── ACT 1 — Permissioned Memory (forward namespace; mint cap; read) ── +echo +echo "=== ACT 1: memory.get travel namespace ===" +ACT1=$(call "$(call_body agentkeys.memory.get \ + "$(printf '{"actor":"%s","namespace":"travel","operator_omni":"%s","device_key_hash":"%s"}' \ + "$ACTOR" "$OPERATOR" "$DEVICE")")") +assert_no_error "$ACT1" "Act 1 response has no JSON-RPC error" +assert_eq "$(jread "$ACT1" '.result.isError')" "false" "tool isError = false" +assert_eq "$(jread "$ACT1" '.result.structuredContent.ok')" "true" "structuredContent.ok = true" +assert_eq "$(jread "$ACT1" '.result.structuredContent.namespace')" "travel" "namespace echoed back" +assert_contains "Chengdu" "$ACT1" "Chengdu trip surfaces in body" + +# ── ACT 2 — Deterministic Denial (no LLM in the verdict) ───────────── +echo +echo "=== ACT 2: permission.check 600 RMB over 500 cap ===" +ACT2=$(call "$(call_body agentkeys.permission.check \ + "$(printf '{"actor":"%s","scope":"payment.spend","params":{"amount_rmb":600}}' "$ACTOR")")") +assert_no_error "$ACT2" "Act 2 response has no JSON-RPC error" +assert_eq "$(jread "$ACT2" '.result.structuredContent.verdict')" "deny" "verdict = deny" +assert_eq "$(jread "$ACT2" '.result.structuredContent.reason')" "daily_spend_cap_exceeded" \ + "reason = daily_spend_cap_exceeded" +assert_contains "cap=500, requested=600, period=daily" "$ACT2" \ + "explanation matches storyboard verbatim" + +# ── ACT 3 — Online Revocation (mint → revoke → retry → denied) ─────── +echo +echo "=== ACT 3: mint cap, revoke it, prove retry is denied ===" + +ACT3_MINT=$(call "$(call_body agentkeys.cap.mint \ + "$(printf '{"actor":"%s","op":"memory_get","params":{"operator_omni":"%s","service":"memory","device_key_hash":"%s"},"ttl":300}' \ + "$ACTOR" "$OPERATOR" "$DEVICE")")") +assert_no_error "$ACT3_MINT" "cap.mint succeeded" +CAP_ID=$(jread "$ACT3_MINT" '.result.structuredContent.cap.payload.nonce') +if [ -z "$CAP_ID" ] || [ "$CAP_ID" = "null" ]; then + echo " ✗ cap.mint did not return a payload.nonce. body: $ACT3_MINT" >&2 + exit 1 +fi +echo " ✓ cap.mint returned cap_id=$CAP_ID" + +ACT3_REVOKE=$(call "$(call_body agentkeys.cap.revoke \ + "$(printf '{"cap_id":"%s"}' "$CAP_ID")")") +assert_no_error "$ACT3_REVOKE" "cap.revoke(known cap_id) succeeded" +assert_eq "$(jread "$ACT3_REVOKE" '.result.structuredContent.revocation')" "in_memory" \ + "revocation recorded in-memory (M1 stub)" + +# Unknown cap_id MUST fail — proves revoke isn't a rubber-stamp. +ACT3_REVOKE_UNKNOWN=$(call "$(call_body agentkeys.cap.revoke '{"cap_id":"this-cap-was-never-minted"}')") +UNKNOWN_ERR=$(jread "$ACT3_REVOKE_UNKNOWN" '.error.code') +if [ -z "$UNKNOWN_ERR" ] || [ "$UNKNOWN_ERR" = "null" ]; then + echo " ✗ cap.revoke(unknown) should error but didn't. body: $ACT3_REVOKE_UNKNOWN" >&2 + exit 1 +fi +echo " ✓ cap.revoke(unknown) rejected (error code $UNKNOWN_ERR)" + +# Retry the SAME cap we just revoked — must fail. +ACT3_AUDIT=$(call "$(call_body agentkeys.audit.append \ + "$(printf '{"actor":"%s","event":{"operator_omni":"%s","op_kind":3,"op_body":{"cap_id":"%s","reason":"parent_revoke"},"result":0,"intent_text":"parent revoked payment access"}}' \ + "$ACTOR" "$OPERATOR" "$CAP_ID")")") +assert_no_error "$ACT3_AUDIT" "audit.append succeeded" +ENV_HASH=$(jread "$ACT3_AUDIT" '.result.structuredContent.envelope_hash') +case "$ENV_HASH" in + 0x*) echo " ✓ audit returned 0x-prefixed envelope_hash ($ENV_HASH)" ;; + *) + echo " ✗ audit envelope_hash should start with 0x — got: $ENV_HASH" >&2 + exit 1 ;; +esac + +# Second append with different content MUST produce a different hash +# (catches the counter-as-hash regression Codex flagged). +ACT3_AUDIT2=$(call "$(call_body agentkeys.audit.append \ + "$(printf '{"actor":"%s","event":{"operator_omni":"%s","op_kind":3,"op_body":{"cap_id":"%s","reason":"different"},"result":0,"intent_text":"a different intent"}}' \ + "$ACTOR" "$OPERATOR" "$CAP_ID")")") +ENV_HASH2=$(jread "$ACT3_AUDIT2" '.result.structuredContent.envelope_hash') +if [ "$ENV_HASH" = "$ENV_HASH2" ]; then + echo " ✗ audit envelope_hash should differ for different content; got identical $ENV_HASH" >&2 + exit 1 +fi +echo " ✓ envelope_hash is content-dependent (two appends → two hashes)" + +# ── AUTH NEGATIVE PATHS ───────────────────────────────────────────── +echo +echo "=== AUTH NEGATIVE PATHS ===" +WRONG_BEARER=$(curl -sS -o /dev/null -w "%{http_code}" -X POST "$URL" \ + -H "authorization: Bearer nope" -H "x-agentkeys-actor: $ACTOR" \ + -H "content-type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/list","id":1}') +assert_eq "$WRONG_BEARER" "401" "wrong bearer → 401" + +NO_ACTOR=$(curl -sS -o /dev/null -w "%{http_code}" -X POST "$URL" \ + -H "authorization: Bearer demo-tok" \ + -H "content-type: application/json" \ + -d '{"jsonrpc":"2.0","method":"tools/list","id":1}') +assert_eq "$NO_ACTOR" "403" "missing actor header → 403" + +CROSS_ACTOR=$(curl -sS -X POST "$URL" \ + -H "authorization: Bearer demo-tok" \ + -H "x-agentkeys-actor: 0x1111111111111111111111111111111111111111111111111111111111111111" \ + -H "content-type: application/json" \ + -d "$(call_body agentkeys.identity.whoami "$(printf '{"actor":"%s"}' "$ACTOR")")") +assert_eq "$(jread "$CROSS_ACTOR" '.error.code')" "-32003" \ + "cross-actor param → -32003 (FORBIDDEN)" + +# ── SCHEMA-ONLY STUBS ─────────────────────────────────────────────── +echo +echo "=== SCHEMA-ONLY STUBS ===" +STUB=$(call "$(call_body agentkeys.delegation.grant '{}')") +assert_contains "not_implemented_in_v1" "$STUB" "delegation.grant → not_implemented_in_v1" +assert_eq "$(jread "$STUB" '.error.data.scheduled_for')" "M4" "scheduled_for: M4 surfaces" + +echo +echo "ALL ASSERTIONS PASSED." +echo " see docs/spec/plans/issue-107-mcp-demo-runbook.md for the full walkthrough." diff --git a/scripts/mcp-demo-mode-b-protocol.sh b/scripts/mcp-demo-mode-b-protocol.sh new file mode 100755 index 0000000..3d6d4e9 --- /dev/null +++ b/scripts/mcp-demo-mode-b-protocol.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# scripts/mcp-demo-mode-b-protocol.sh — verifies the MCP boundary using the +# OFFICIAL Anthropic Python `mcp` SDK, which is the same client xiaozhi-server +# imports (confirmed in xiaozhi-esp32-server/main/xiaozhi-server/core/providers/ +# tools/server_mcp/mcp_client.py — `from mcp.client.streamable_http import +# streamablehttp_client`). +# +# This catches integration regressions that the mode-A curl runbook can't: +# missing MCP handshake fields, malformed tool schemas, broken error wire +# format, Streamable-HTTP transport drift. +# +# Pre-reqs: `uv` (https://docs.astral.sh/uv/). Skips if not installed. +# +set -euo pipefail + +if ! command -v uv >/dev/null 2>&1; then + echo "skip: uv not installed — see https://docs.astral.sh/uv/" >&2 + exit 77 +fi + +PORT="${MCP_PORT:-18101}" +BIN="${MCP_BIN:-target/debug/agentkeys-mcp-server}" +URL="http://127.0.0.1:${PORT}/mcp" + +if [ ! -x "$BIN" ]; then + echo "building $BIN…" + cargo build -p agentkeys-mcp-server +fi + +"$BIN" --backend in-memory --listen "127.0.0.1:${PORT}" >/tmp/mcp-b-server.log 2>&1 & +PID=$! +trap 'kill $PID 2>/dev/null || true; wait 2>/dev/null || true' EXIT INT TERM + +for _ in $(seq 1 40); do + curl -sf "http://127.0.0.1:${PORT}/healthz" >/dev/null 2>&1 && break + sleep 0.2 +done + +# uv-managed venv so we don't pollute the operator's environment. +VENV_DIR="${TMPDIR:-/tmp}/mcp-verify-$$" +uv venv --quiet "$VENV_DIR" +# shellcheck disable=SC1091 +source "$VENV_DIR/bin/activate" +uv pip install --quiet 'mcp>=1.0' + +python3 - "$URL" <<'PY' +import asyncio +import sys +from mcp.client.streamable_http import streamablehttp_client +from mcp import ClientSession + +URL = sys.argv[1] + +# Active tools advertised via tools/list. The 3 M4 stubs +# (delegation.grant, delegation.revoke, approval.request) remain +# dispatchable via tools/call (test farther down) but were dropped from +# tools/list to shrink the LLM tool budget — see tools/mod.rs. +EXPECTED_TOOLS = { + 'agentkeys.identity.whoami', 'agentkeys.memory.get', 'agentkeys.memory.put', + 'agentkeys.permission.check', 'agentkeys.cap.mint', 'agentkeys.cap.revoke', + 'agentkeys.audit.append', +} +M4_STUB_TOOLS = { + 'agentkeys.delegation.grant', 'agentkeys.delegation.revoke', 'agentkeys.approval.request', +} + +async def main(): + headers = {'Authorization': 'Bearer demo-tok', 'X-AgentKeys-Actor': '0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7'} + async with streamablehttp_client(URL, headers=headers) as (r, w, _sid): + async with ClientSession(r, w) as session: + init = await session.initialize() + assert init.serverInfo.name == 'agentkeys-mcp-server', init.serverInfo.name + print(f' ✓ initialize handshake → {init.serverInfo.name} v{init.serverInfo.version}') + + tools = await session.list_tools() + names = {t.name for t in tools.tools} + missing = EXPECTED_TOOLS - names + extra = names - EXPECTED_TOOLS + assert not missing, f'missing active tools: {missing}' + assert not extra, f'unexpected tools: {extra}' + # M4 stubs MUST NOT be in tools/list (still callable via tools/call below). + stubs_in_list = M4_STUB_TOOLS & names + assert not stubs_in_list, f'M4 stubs should not appear in tools/list: {stubs_in_list}' + print(f' ✓ tools/list → {len(EXPECTED_TOOLS)} active tools, 0 M4 stubs') + + act2 = await session.call_tool('agentkeys.permission.check', + {'actor':'0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7','scope':'payment.spend','params':{'amount_rmb':600}}) + text = act2.content[0].text + assert 'daily_spend_cap_exceeded' in text, text + assert 'cap=500, requested=600, period=daily' in text, text + print(' ✓ Act 2 — deterministic deny, storyboard wording verbatim') + + act1 = await session.call_tool('agentkeys.memory.get', + {'actor':'0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7','namespace':'travel', + 'operator_omni':'0x07e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8','device_key_hash':'0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'}) + assert 'Chengdu' in act1.content[0].text, act1.content[0].text + print(' ✓ Act 1 — memory.get(travel) returns Chengdu fixture') + + # Act 3: mint a real cap, revoke it by nonce, prove unknown revokes fail. + mint = await session.call_tool('agentkeys.cap.mint', { + 'actor':'0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7', + 'op':'memory_get', + 'params':{'operator_omni':'0x07e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8', + 'service':'memory', + 'device_key_hash':'0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'}, + 'ttl':300}) + import json as _j + cap_id = _j.loads(mint.content[0].text)['cap']['payload']['nonce'] + assert cap_id, 'cap.mint did not return a nonce' + print(f' ✓ Act 3 — cap.mint returned cap_id={cap_id[:8]}…') + + revoke = await session.call_tool('agentkeys.cap.revoke', {'cap_id': cap_id}) + assert 'in_memory' in revoke.content[0].text + print(' ✓ Act 3a — cap.revoke(known) records in-memory (M1 stub)') + + try: + await session.call_tool('agentkeys.cap.revoke', {'cap_id':'this-cap-was-never-minted'}) + raise AssertionError('cap.revoke(unknown) should have errored') + except Exception as e: + assert 'unknown cap_id' in str(e), str(e) + print(' ✓ Act 3 — cap.revoke(unknown) rejected (not a rubber-stamp)') + + audit = await session.call_tool('agentkeys.audit.append', { + 'actor':'0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7', + 'event':{'operator_omni':'0x07e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8','op_kind':3, + 'op_body':{'cap_id': cap_id},'result':0, + 'intent_text':'parent revoked payment access'} + }) + assert '0x' in audit.content[0].text + print(' ✓ Act 3b — audit.append returns envelope_hash') + + try: + await session.call_tool('agentkeys.delegation.grant', {}) + raise AssertionError('expected McpError but got success') + except Exception as e: + if 'not_implemented_in_v1' in str(e): + print(' ✓ schema-only stub → MCP error: not_implemented_in_v1') + else: + raise + +asyncio.run(main()) +print() +print('ALL PROTOCOL-LEVEL ASSERTIONS PASSED.') +print(' the official Anthropic mcp SDK successfully drove the server end-to-end.') +print(' xiaozhi-server uses the same SDK (verified against xinnan-tech/xiaozhi-esp32-server@7f73dae).') +PY diff --git a/scripts/mcp-demo-mode-c-xiaozhi-client.sh b/scripts/mcp-demo-mode-c-xiaozhi-client.sh new file mode 100755 index 0000000..124adab --- /dev/null +++ b/scripts/mcp-demo-mode-c-xiaozhi-client.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +# scripts/mcp-demo-mode-c-xiaozhi-client.sh +# +# Drives our MCP server using xiaozhi-server's OWN `ServerMCPClient` class +# (one level above the raw Anthropic SDK that mode-B uses). This is the +# integration code xiaozhi-server actually runs in production, exercised +# against our server — same Python interpreter, same imports, same +# config-loading path. +# +# Plus a deterministic "fake LLM" harness that issues the exact tool +# calls the three-act storyboard expects, so the full +# xiaozhi-server → ServerMCPClient → our /mcp endpoint → tools loop +# is asserted without needing Ollama, Doubao, Qwen, MagicLick hardware, +# or any LLM API key. +# +# What this proves vs what it doesn't: +# ✓ xiaozhi-server's MCP integration code calls our tools correctly +# ✓ Config-file path + format works against xiaozhi-server's loader +# ✓ All three acts return storyboard-expected payloads +# ✗ A real LLM (Doubao/Qwen/Ollama) decides to call the right tools +# at the right times — that's a prompt-engineering + model-capability +# question outside the MCP server boundary. +# ✗ MagicLick audio I/O — physical hardware. +# +set -euo pipefail + +if ! command -v uv >/dev/null 2>&1; then + echo "skip: uv not installed — see https://docs.astral.sh/uv/" >&2 + exit 77 +fi + +PORT="${MCP_PORT:-18102}" +BIN="${MCP_BIN:-target/debug/agentkeys-mcp-server}" +URL="http://127.0.0.1:${PORT}/mcp" +XIAOZHI_DIR="${XIAOZHI_DIR:-/tmp/xiaozhi-verify/xiaozhi-esp32-server}" + +# Clone xiaozhi-server if not present. +if [ ! -d "$XIAOZHI_DIR/main/xiaozhi-server" ]; then + echo "cloning xiaozhi-server into $XIAOZHI_DIR…" + mkdir -p "$(dirname "$XIAOZHI_DIR")" + git clone --depth 1 https://github.com/xinnan-tech/xiaozhi-esp32-server.git "$XIAOZHI_DIR" >/dev/null 2>&1 +fi + +if [ ! -x "$BIN" ]; then + echo "building $BIN…" + cargo build -p agentkeys-mcp-server +fi + +"$BIN" --backend in-memory --listen "127.0.0.1:${PORT}" >/tmp/mcp-c-server.log 2>&1 & +PID=$! +trap 'kill $PID 2>/dev/null || true; wait 2>/dev/null || true' EXIT INT TERM + +for _ in $(seq 1 40); do + curl -sf "http://127.0.0.1:${PORT}/healthz" >/dev/null 2>&1 && break + sleep 0.2 +done + +VENV_DIR="${TMPDIR:-/tmp}/mcp-verify-c-$$" +uv venv --quiet "$VENV_DIR" +# shellcheck disable=SC1091 +source "$VENV_DIR/bin/activate" +uv pip install --quiet 'mcp>=1.0' + +# Make xiaozhi-server importable. The package has its own logging / +# config / dependency stack, so we import the MCP-client subtree +# narrowly to avoid pulling in TTS / ASR / WebSocket deps. +export PYTHONPATH="$XIAOZHI_DIR/main/xiaozhi-server:${PYTHONPATH:-}" +export AGENTKEYS_MCP_URL="$URL" +export XIAOZHI_DIR="$XIAOZHI_DIR" + +python3 - <<'PY' +""" +Mode C — drive our MCP server using xiaozhi-server's actual +`ServerMCPClient` integration code (not just the underlying SDK). +Plus a deterministic fake-LLM that issues storyboard-expected tool +calls so the full LLM → MCP → tools loop is end-to-end asserted. +""" +import asyncio +import os +import sys +import logging + +# Suppress xiaozhi-server's verbose loguru config — we only need its +# MCP client class, not its full app bootstrap. +logging.basicConfig(level=logging.WARNING) + +# Minimal stubs for xiaozhi-server's logger/config deps we don't have. +class _StubLogger: + def bind(self, **kw): return self + def info(self, *a, **k): pass + def debug(self, *a, **k): pass + def warning(self, *a, **k): pass + def error(self, *a, **k): pass + +class _StubConfigModule: + @staticmethod + def setup_logging(): + return _StubLogger() + +class _StubUtil: + @staticmethod + def sanitize_tool_name(name): + return name.replace('.', '_') + +import types, importlib.util, pathlib + +# Build stub modules for the few things `mcp_client.py` imports from +# the xiaozhi-server ecosystem without dragging in the full app stack +# (TTS / ASR / WebSocket / loguru config). +config_logger_mod = types.ModuleType('config.logger') +config_logger_mod.setup_logging = _StubConfigModule.setup_logging +sys.modules['config'] = types.ModuleType('config') +sys.modules['config.logger'] = config_logger_mod + +util_mod = types.ModuleType('core.utils.util') +util_mod.sanitize_tool_name = _StubUtil.sanitize_tool_name +sys.modules['core'] = types.ModuleType('core') +sys.modules['core.utils'] = types.ModuleType('core.utils') +sys.modules['core.utils.util'] = util_mod + +# Load `mcp_client.py` directly by file path — bypasses +# `core/providers/__init__.py` etc which pull in unrelated deps. +xiaozhi_root = pathlib.Path(os.environ.get('XIAOZHI_DIR', '/tmp/xiaozhi-verify/xiaozhi-esp32-server')) / 'main' / 'xiaozhi-server' +mcp_client_path = xiaozhi_root / 'core' / 'providers' / 'tools' / 'server_mcp' / 'mcp_client.py' +spec = importlib.util.spec_from_file_location('mcp_client', mcp_client_path) +mcp_client_mod = importlib.util.module_from_spec(spec) +spec.loader.exec_module(mcp_client_mod) +ServerMCPClient = mcp_client_mod.ServerMCPClient + +URL = os.environ['AGENTKEYS_MCP_URL'] + +# This is the EXACT shape xiaozhi-server reads from +# data/.mcp_server_settings.json → mcpServers[name]. +config = { + 'url': URL, + 'transport': 'streamable-http', + 'headers': { + 'Authorization': 'Bearer demo-tok', + 'X-AgentKeys-Actor': '0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7', + } +} + +async def main(): + client = ServerMCPClient(config) + await client.initialize() + print(' ✓ ServerMCPClient.initialize() succeeded') + + tools = client.get_available_tools() + names = [t['function']['name'] for t in tools] + print(f' ✓ ServerMCPClient sees {len(tools)} tools') + + # Names get sanitized by xiaozhi-server (`.` → `_`) for LLM consumption. + # has_tool() and call_tool() use the sanitized form. + assert client.has_tool('agentkeys_permission_check'), names + assert client.has_tool('agentkeys_memory_get'), names + assert client.has_tool('agentkeys_memory_put'), names + assert client.has_tool('agentkeys_cap_mint'), names + assert client.has_tool('agentkeys_cap_revoke'), names + assert client.has_tool('agentkeys_audit_append'), names + assert client.has_tool('agentkeys_identity_whoami'), names + print(' ✓ has_tool(...) lookups match for every active tool') + + # ─── Fake-LLM three-act harness ───────────────────────────── + # Simulates what Doubao/Qwen would do given each user prompt: pick + # the right tool with the right args. This is deterministic so the + # demo's correctness doesn't depend on LLM tuning. + + print('\n --- Act 1: user says "Where am I going this weekend?" ---') + print(' fake-LLM picks: agentkeys.memory.get(namespace="travel")') + r = await client.call_tool('agentkeys_memory_get', { + 'actor': '0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7', + 'namespace': 'travel', + 'operator_omni': '0x07e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8', + 'device_key_hash': '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + }) + body = r.content[0].text + assert 'Chengdu' in body, body + print(f' ✓ Act 1 response (LLM would TTS this): "{body[:80]}…"') + + print('\n --- Act 2: user says "Order me 600 RMB of hotpot" ---') + print(' fake-LLM picks: agentkeys.permission.check(scope="payment.spend", amount_rmb=600)') + r = await client.call_tool('agentkeys_permission_check', { + 'actor': '0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7', + 'scope': 'payment.spend', + 'params': {'amount_rmb': 600}, + }) + body = r.content[0].text + assert '"verdict":"deny"' in body, body + assert 'daily_spend_cap_exceeded' in body, body + assert 'cap=500, requested=600, period=daily' in body, body + print(f' ✓ Act 2 verdict: deny (cap=500). LLM uses this to refuse politely.') + + print('\n --- Act 3: parent revokes; user retries ---') + print(' fake-LLM picks: agentkeys.cap.mint → cap.revoke → audit.append') + mint = await client.call_tool('agentkeys_cap_mint', { + 'actor': '0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7', + 'op': 'memory_get', + 'params': { + 'operator_omni': '0x07e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8', + 'service': 'memory', + 'device_key_hash': '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + }, + 'ttl': 300, + }) + import json as _j + cap_id = _j.loads(mint.content[0].text)['cap']['payload']['nonce'] + print(f' ✓ cap.mint returned cap_id={cap_id[:8]}…') + + r = await client.call_tool('agentkeys_cap_revoke', {'cap_id': cap_id}) + assert 'in_memory' in r.content[0].text + print(' ✓ Act 3a — cap.revoke(known cap_id) recorded') + + try: + await client.call_tool('agentkeys_cap_revoke', {'cap_id': 'this-cap-was-never-minted'}) + raise AssertionError('cap.revoke(unknown) should have failed') + except Exception as e: + assert 'unknown cap_id' in str(e), str(e) + print(' ✓ Act 3 — cap.revoke(unknown) rejected (not a rubber-stamp)') + + r = await client.call_tool('agentkeys_audit_append', { + 'actor': '0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7', + 'event': { + 'operator_omni': '0x07e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8', + 'op_kind': 3, + 'op_body': {'cap_id': cap_id, 'reason': 'parent_revoke'}, + 'result': 0, + 'intent_text': 'parent revoked payment access', + } + }) + assert '0x' in r.content[0].text + print(' ✓ Act 3b — audit envelope returned') + + await client.cleanup() + print('\n ✓ ServerMCPClient.cleanup() clean') + +asyncio.run(main()) +print() +print('ALL MODE-C ASSERTIONS PASSED.') +print(' Drove the server via xiaozhi-server\'s own ServerMCPClient class') +print(' (xinnan-tech/xiaozhi-esp32-server@7f73dae). When a real LLM in') +print(' xiaozhi-server picks the same tool calls our fake-LLM picked,') +print(' the demo will work end-to-end.') +PY diff --git a/scripts/mcp-demo-mode-d-xiaozhi-endpoint.sh b/scripts/mcp-demo-mode-d-xiaozhi-endpoint.sh new file mode 100755 index 0000000..77b00a0 --- /dev/null +++ b/scripts/mcp-demo-mode-d-xiaozhi-endpoint.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +# scripts/mcp-demo-mode-d-xiaozhi-endpoint.sh +# +# Verifies the xiaozhi-MCP-endpoint path end-to-end WITHOUT any +# MagicLick hardware and WITHOUT an LLM provider key. Topology: +# +# ┌────────────────────────┐ ┌──────────────────────────┐ +# │ fake xiaozhi client │ ◀── ws ──▶ relay ◀── ws ──▶ agentkeys-mcp- │ +# │ (this script, websocat│ (mock; pure │ (--transport=mcp- │ +# │ or python) │ JSON-RPC pipe)│ endpoint, our binary) │ +# └────────────────────────┘ └──────────────────────────┘ +# +# The fake-client side plays the role xiaozhi-server / xiaozhi cloud +# plays in production: it sends `initialize`, `tools/list`, the three- +# act `tools/call`s, and asserts the responses are storyboard-correct. +# +# The mock relay is the simplest possible MCP-endpoint-server: a +# Python websocket pipe that forwards messages between the two +# clients connected to the same `/mcp_endpoint/mcp/?token=X` path. +# It mirrors xinnan-tech/mcp-endpoint-server's role without bringing +# in that whole project — the protocol is plain JSON-RPC over WS. +# +# When this passes, swap the mock for the real `mcp-endpoint-server` +# binary (Python service deployed on the EC2 broker host per the +# runbook §B) and connect it to your xiaozhi智控台 — the MCP server +# behavior is the same. +# +set -euo pipefail + +if ! command -v uv >/dev/null 2>&1; then + echo "skip: uv not installed — see https://docs.astral.sh/uv/" >&2 + exit 77 +fi + +PORT_RELAY="${MCP_RELAY_PORT:-18104}" +BIN="${MCP_BIN:-target/debug/agentkeys-mcp-server}" +TOKEN='abc123' +TOOL_URL="ws://127.0.0.1:${PORT_RELAY}/mcp_endpoint/mcp/?token=${TOKEN}" +CLIENT_URL="ws://127.0.0.1:${PORT_RELAY}/mcp_endpoint/call/?token=${TOKEN}" + +if [ ! -x "$BIN" ]; then + echo "building $BIN…" + cargo build -p agentkeys-mcp-server +fi + +VENV_DIR="${TMPDIR:-/tmp}/mcp-verify-d-$$" +uv venv --quiet "$VENV_DIR" +# shellcheck disable=SC1091 +source "$VENV_DIR/bin/activate" +uv pip install --quiet 'websockets>=12' + +# ── Minimal MCP-endpoint relay ─────────────────────────────────────── +# Mirrors the real xinnan-tech/mcp-endpoint-server routing exactly: +# /mcp_endpoint/mcp/?token=X — tool side (the MCP server connects here) +# /mcp_endpoint/call/?token=X — client side (xiaozhi cloud connects here) +# Same token pairs tool ↔ client. We forward bytes verbatim between +# the two sockets — no ID rewriting (we only ever have one client at +# a time per token, so collisions are impossible). +RELAY_PY="${TMPDIR:-/tmp}/mcp-relay-$$.py" +cat > "$RELAY_PY" <<'PY' +import asyncio, sys, websockets +from urllib.parse import parse_qs, urlparse + +PAIRS = {} # token -> {'tool': ws, 'client': ws} + +async def handler(ws): + raw_path = ws.request.path + parsed = urlparse(raw_path) + qs = parse_qs(parsed.query) + token = (qs.get('token') or [''])[0] + if not token: + await ws.close(code=1008, reason='missing token') + return + + if parsed.path == '/mcp_endpoint/mcp/': + role = 'tool' + elif parsed.path == '/mcp_endpoint/call/': + role = 'client' + else: + await ws.close(code=1008, reason='unknown path') + return + + PAIRS.setdefault(token, {}) + PAIRS[token][role] = ws + print(f' relay: {role} connected (token={token[:6]}…)', flush=True) + + try: + async for msg in ws: + other_role = 'client' if role == 'tool' else 'tool' + other = PAIRS[token].get(other_role) + if other is not None: + try: + await other.send(msg) + except Exception: + pass + except websockets.exceptions.ConnectionClosed: + pass + finally: + if PAIRS.get(token, {}).get(role) is ws: + PAIRS[token][role] = None + +async def main(): + port = int(sys.argv[1]) + async with websockets.serve(handler, "127.0.0.1", port): + print(f'relay listening on ws://127.0.0.1:{port}', flush=True) + await asyncio.Future() + +asyncio.run(main()) +PY + +# Start the relay in the background; trap teardown. +python3 "$RELAY_PY" "$PORT_RELAY" >/tmp/mcp-relay.log 2>&1 & +RELAY_PID=$! +trap 'kill $RELAY_PID $MCP_PID 2>/dev/null || true; wait 2>/dev/null || true' EXIT INT TERM + +# Wait for relay readiness. +for _ in $(seq 1 50); do + if grep -q "listening on" /tmp/mcp-relay.log 2>/dev/null; then break; fi + sleep 0.1 +done + +# Start the MCP server with the new transport, connecting to the tool +# side of the relay. +"$BIN" --transport mcp-endpoint --backend in-memory --mcp-endpoint "$TOOL_URL" \ + > /tmp/mcp-mcpendpoint.log 2>&1 & +MCP_PID=$! + +# Give the MCP server a moment to connect as the tool side. +sleep 1 +if ! kill -0 "$MCP_PID" 2>/dev/null; then + echo "FAIL: MCP server exited; log:" >&2 + cat /tmp/mcp-mcpendpoint.log >&2 + exit 1 +fi + +# ── Drive the relay from the "xiaozhi client" side ─────────────────── +export CLIENT_URL TOKEN +python3 - <<'PY' +import asyncio, json, os, sys, websockets + +URL = os.environ['CLIENT_URL'] + +# M4 stubs (delegation.grant, delegation.revoke, approval.request) are +# dispatchable via tools/call but no longer advertised in tools/list — +# they ate the LLM tool-list budget without being useful. See +# tools/mod.rs for the rationale. +EXPECTED_TOOLS = { + 'agentkeys.identity.whoami', 'agentkeys.memory.get', 'agentkeys.memory.put', + 'agentkeys.permission.check', 'agentkeys.cap.mint', 'agentkeys.cap.revoke', + 'agentkeys.audit.append', +} +M4_STUB_TOOLS = { + 'agentkeys.delegation.grant', 'agentkeys.delegation.revoke', 'agentkeys.approval.request', +} + +async def main(): + # Client role; the relay path /mcp_endpoint/call/ marks us as + # the xiaozhi side, and pairs us with the tool on the same token. + async with websockets.connect(URL) as ws: + async def send(obj): + await ws.send(json.dumps(obj)) + async def recv_match(want_id): + for _ in range(20): + msg = json.loads(await asyncio.wait_for(ws.recv(), 10)) + if msg.get('id') == want_id: + return msg + raise RuntimeError(f'no response for id={want_id}') + + # initialize handshake + await send({"jsonrpc":"2.0","id":1,"method":"initialize", + "params":{"protocolVersion":"2024-11-05","capabilities":{}, + "clientInfo":{"name":"fake-xiaozhi-client","version":"0.0.1"}}}) + init = await recv_match(1) + assert init['result']['serverInfo']['name'] == 'agentkeys-mcp-server', init + print(f" ✓ initialize: name={init['result']['serverInfo']['name']} " + f"v{init['result']['serverInfo']['version']}") + + await send({"jsonrpc":"2.0","method":"notifications/initialized"}) + + # tools/list + await send({"jsonrpc":"2.0","id":2,"method":"tools/list"}) + tools = await recv_match(2) + names = {t['name'] for t in tools['result']['tools']} + missing = EXPECTED_TOOLS - names + assert not missing, f'missing active tools: {missing}' + stubs_in_list = M4_STUB_TOOLS & names + assert not stubs_in_list, f'M4 stubs should not appear in tools/list: {stubs_in_list}' + print(f' ✓ tools/list → {len(EXPECTED_TOOLS)} active tools through the relay (0 M4 stubs)') + + # Act 2: deterministic deny (no LLM) + await send({"jsonrpc":"2.0","id":3,"method":"tools/call", + "params":{"name":"agentkeys.permission.check", + "arguments":{"actor":"0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7", + "scope":"payment.spend", + "params":{"amount_rmb":600}}}}) + act2 = await recv_match(3) + text = act2['result']['content'][0]['text'] + assert 'daily_spend_cap_exceeded' in text, text + assert 'cap=500, requested=600, period=daily' in text, text + print(' ✓ Act 2 — deterministic deny, storyboard wording verbatim') + + # Act 1: memory.get + await send({"jsonrpc":"2.0","id":4,"method":"tools/call", + "params":{"name":"agentkeys.memory.get", + "arguments":{ + "actor":"0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7", + "namespace":"travel", + "operator_omni":"0x07e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8", + "device_key_hash":"0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"}}}) + act1 = await recv_match(4) + assert 'Chengdu' in act1['result']['content'][0]['text'] + print(' ✓ Act 1 — memory.get(travel) returns Chengdu fixture through the relay') + + # Act 3: mint → revoke by nonce → unknown rejected + await send({"jsonrpc":"2.0","id":5,"method":"tools/call", + "params":{"name":"agentkeys.cap.mint", + "arguments":{ + "actor":"0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7", + "op":"memory_get", + "params":{ + "operator_omni":"0x07e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8", + "service":"memory", + "device_key_hash":"0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"}, + "ttl":300}}}) + mint = await recv_match(5) + cap_id = json.loads(mint['result']['content'][0]['text'])['cap']['payload']['nonce'] + print(f' ✓ Act 3 — cap.mint returned cap_id={cap_id[:8]}…') + + await send({"jsonrpc":"2.0","id":6,"method":"tools/call", + "params":{"name":"agentkeys.cap.revoke","arguments":{"cap_id": cap_id}}}) + rev = await recv_match(6) + assert 'in_memory' in rev['result']['content'][0]['text'] + print(' ✓ Act 3a — cap.revoke(known) recorded') + + await send({"jsonrpc":"2.0","id":7,"method":"tools/call", + "params":{"name":"agentkeys.cap.revoke", + "arguments":{"cap_id":"this-cap-was-never-minted"}}}) + bad = await recv_match(7) + assert 'error' in bad, bad + print(' ✓ Act 3 — cap.revoke(unknown) rejected (not a rubber-stamp)') + +asyncio.run(main()) +print() +print('ALL MODE-D ASSERTIONS PASSED.') +print(' Drove the server end-to-end through a xiaozhi-style WS relay.') +print(' In production, swap the mock relay for `mcp-endpoint-server`') +print(' on the EC2 broker host and point your xiaozhi agent at it —') +print(' the MCP server behavior is identical.') +PY diff --git a/scripts/mcp-demo-mode-e-stdio.sh b/scripts/mcp-demo-mode-e-stdio.sh new file mode 100755 index 0000000..304408d --- /dev/null +++ b/scripts/mcp-demo-mode-e-stdio.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# scripts/mcp-demo-mode-e-stdio.sh — three-act storyboard over **stdio**. +# +# Why this tier exists (the gap modes A-D leave): +# • mode-a: HTTP via curl +# • mode-b: HTTP via Anthropic Python `mcp` SDK +# • mode-c: HTTP via xiaozhi's ServerMCPClient +# • mode-d: WebSocket via mcp-endpoint relay +# • mode-e: STDIO via Anthropic Python `mcp` SDK's stdio_client ← THIS +# +# stdio is what Claude Code, Codex CLI, Claude Desktop, Cursor, Cline, +# Roo, Windsurf, Gemini CLI all use. Every other transport could pass +# while stdio is broken (and we saw exactly that in #107 — the binary +# polluted stdout with tracing logs + sent error responses to +# notifications, which would have silently broken every desktop client). +# +# This script invokes the SAME stdio_client code path Claude Code uses +# internally, drives the full three-act storyboard against the installed +# binary, and asserts content (not just JSON shape). +# +# Usage: +# bash scripts/mcp-demo-mode-e-stdio.sh # auto-detect bin +# bash scripts/mcp-demo-mode-e-stdio.sh /path/to/binary # explicit +# AGENTKEYS_MCP_BIN=/path/to/binary bash scripts/mcp-demo-mode-e-stdio.sh +set -euo pipefail + +# 1. Resolve the binary to test. +BIN="${1:-${AGENTKEYS_MCP_BIN:-}}" +if [ -z "$BIN" ]; then + for candidate in \ + "$HOME/.cargo/bin/agentkeys-mcp-server" \ + "$HOME/.local/bin/agentkeys-mcp-server" \ + "./target/release/agentkeys-mcp-server" \ + "/usr/local/bin/agentkeys-mcp-server"; do + if [ -x "$candidate" ]; then BIN="$candidate"; break; fi + done +fi +[ -x "$BIN" ] || { echo "ERROR: no agentkeys-mcp-server binary found. Pass path as \$1 or set AGENTKEYS_MCP_BIN." >&2; exit 1; } +echo "==> testing binary: $BIN" >&2 + +# 2. Ensure uv + a venv with the Anthropic mcp SDK. +if ! command -v uv >/dev/null 2>&1; then + echo "==> installing uv (one-shot)" >&2 + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH" +fi + +VENV="${TMPDIR:-/tmp}/mcp-mode-e-venv" +if [ ! -x "$VENV/bin/python" ]; then + uv venv --quiet "$VENV" + uv pip install --quiet --python "$VENV/bin/python" mcp +fi + +# 3. Drive the storyboard. +export BIN +"$VENV/bin/python" <<'PY' +import asyncio, json, os, sys +from mcp.client.stdio import stdio_client, StdioServerParameters +from mcp import ClientSession + +# In-memory backend auto-seeds DEMO_ACTOR / DEMO_OPERATOR / DEMO_DEVICE_KEY_HASH +# so the LLM-side can pass minimal arguments (namespace only, etc.). For +# tools whose schema still requires actor+operator (audit.append), we +# pass the demo values verbatim. +DEMO_ACTOR = "0xa0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c701a0c7" +DEMO_OPERATOR = "0x07e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8a107e8" + +async def main(): + params = StdioServerParameters( + command=os.environ["BIN"], + args=[], + env={ + "MCP_TRANSPORT": "stdio", + "MCP_BACKEND": "in-memory", + "PATH": os.environ.get("PATH", ""), + "HOME": os.environ.get("HOME", ""), + }, + ) + async with stdio_client(params) as (read, write): + async with ClientSession(read, write) as session: + # — Handshake — + init = await session.initialize() + assert init.serverInfo.name == "agentkeys-mcp-server", init.serverInfo + print(f" ✓ initialize via stdio → {init.serverInfo.name} v{init.serverInfo.version}") + + tools = await session.list_tools() + names = sorted(t.name for t in tools.tools) + assert len(names) == 7, f"want 7 tools, got {len(names)}: {names}" + for required in [ + "agentkeys.identity.whoami", + "agentkeys.memory.get", + "agentkeys.memory.put", + "agentkeys.permission.check", + "agentkeys.cap.mint", + "agentkeys.cap.revoke", + "agentkeys.audit.append", + ]: + assert required in names, f"missing tool: {required}" + print(f" ✓ tools/list → 7 active tools, all expected") + + # — Act 1: Permissioned Memory (namespace-scoped read) — + res = await session.call_tool("agentkeys.memory.get", {"namespace": "travel"}) + text = res.content[0].text + assert "Chengdu" in text, text + print(" ✓ Act 1 — memory.get(travel) returns Chengdu fixture") + + res = await session.call_tool("agentkeys.memory.get", {"namespace": "family"}) + assert "Wife" in res.content[0].text or "bday" in res.content[0].text + print(" ✓ Act 1b — memory.get(family) returns family fixture") + + # — Memory round-trip with unicode (regression test for the + # '有痛风' test the user ran live during issue #107) — + await session.call_tool("agentkeys.memory.put", { + "namespace": "profile", "content": "有痛风 — gout, no shellfish" + }) + res = await session.call_tool("agentkeys.memory.get", {"namespace": "profile"}) + assert "有痛风" in res.content[0].text, res.content[0].text + assert "gout" in res.content[0].text + print(" ✓ Memory round-trip — Chinese + English unicode preserved through put→get") + + # — Act 2: Deterministic Denial (no LLM in the loop) — + res = await session.call_tool("agentkeys.permission.check", { + "scope": "payment.spend", "params": {"amount_rmb": 600} + }) + text = res.content[0].text + assert "daily_spend_cap_exceeded" in text, text + assert "cap=500" in text, text + print(" ✓ Act 2 — permission.check denies 600 RMB (storyboard wording: cap=500)") + + res = await session.call_tool("agentkeys.permission.check", { + "scope": "payment.spend", "params": {"amount_rmb": 100} + }) + assert "accept" in res.content[0].text, res.content[0].text + print(" ✓ Act 2b — permission.check accepts 100 RMB under cap") + + # — Act 3: Online Revocation — + mint = await session.call_tool("agentkeys.cap.mint", {"op": "memory_get"}) + cap_id = json.loads(mint.content[0].text)["cap"]["payload"]["nonce"] + assert cap_id, "cap.mint returned no nonce" + print(f" ✓ Act 3 — cap.mint returned cap_id={cap_id[:10]}…") + + revoke = await session.call_tool("agentkeys.cap.revoke", {"cap_id": cap_id}) + assert "in_memory" in revoke.content[0].text + print(" ✓ Act 3a — cap.revoke(known) records the revocation") + + try: + await session.call_tool("agentkeys.cap.revoke", + {"cap_id": "this-cap-was-never-minted"}) + raise AssertionError("cap.revoke(unknown) should have errored") + except Exception as e: + assert "unknown cap_id" in str(e), str(e) + print(" ✓ Act 3b — cap.revoke(unknown) is rejected (not a rubber-stamp)") + + # — Audit envelope — + audit = await session.call_tool("agentkeys.audit.append", { + "actor": DEMO_ACTOR, + "event": { + "operator_omni": DEMO_OPERATOR, + "op_kind": 3, + "op_body": {"cap_id": cap_id, "reason": "parent_revoke"}, + "result": 0, + "intent_text": "stdio e2e test — Act 3 audit row", + } + }) + ah_text = audit.content[0].text + assert "0x" in ah_text, ah_text + print(" ✓ Act 3c — audit.append returned envelope_hash (0x prefix)") + + # — Identity (ambient actor resolution from MCP_DEFAULT_*) — + who = await session.call_tool("agentkeys.identity.whoami", {}) + assert DEMO_ACTOR in who.content[0].text, who.content[0].text + print(" ✓ identity.whoami resolves ambient default actor") + + print() + print("ALL ASSERTIONS PASSED.") + print(" stdio transport: three-act storyboard verified end-to-end.") + print(" This is the path Claude Code / Codex / Claude Desktop drive.") + +asyncio.run(main()) +PY diff --git a/scripts/operator-workstation.env b/scripts/operator-workstation.env index fd08b2d..6ce4348 100644 --- a/scripts/operator-workstation.env +++ b/scripts/operator-workstation.env @@ -137,6 +137,12 @@ AGENTKEYS_WORKER_EMAIL_URL=https://${WORKER_EMAIL_HOST} AGENTKEYS_WORKER_CRED_URL=https://${WORKER_CRED_HOST} AGENTKEYS_WORKER_MEMORY_URL=https://${WORKER_MEMORY_HOST} +# MCP server + xiaozhi mcp-endpoint relay host. Provisioned as a 7th A +# record in setup-cloud.sh step 6, pointing at the same broker EIP. +# setup-mcp-host.sh deploys nginx + the MCP server + relay behind it. +MCP_HOST=mcp.${BROKER_HOST#*.} +AGENTKEYS_MCP_URL=https://${MCP_HOST} + # ─── CLI session storage ───────────────────────────────────────────────────── # Force the `agentkeys` CLI to read/write the session JWT in a regular file # (`~/.agentkeys/master/session.json`) instead of the macOS Keychain. Without diff --git a/scripts/operator-workstation.test.env b/scripts/operator-workstation.test.env index a691cf8..f4357c8 100644 --- a/scripts/operator-workstation.test.env +++ b/scripts/operator-workstation.test.env @@ -69,6 +69,13 @@ AGENTKEYS_WORKER_EMAIL_URL=https://${WORKER_EMAIL_HOST} AGENTKEYS_WORKER_CRED_URL=https://${WORKER_CRED_HOST} AGENTKEYS_WORKER_MEMORY_URL=https://${WORKER_MEMORY_HOST} +# Test-mcp host — matches the test-broker prefix convention (mcp is a +# top-level entry point like broker, not a worker, so it uses the same +# `test-` prefix style as test-broker rather than the worker `-test` +# suffix). 7th A record provisioned by setup-cloud.sh step 6. +MCP_HOST=test-mcp.${BROKER_HOST#*.} +AGENTKEYS_MCP_URL=https://${MCP_HOST} + AGENTKEYS_SESSION_STORE=file # Test sender — verified separately from prod's sender. Both can coexist diff --git a/scripts/run-mcp-local.sh b/scripts/run-mcp-local.sh new file mode 100755 index 0000000..568a01b --- /dev/null +++ b/scripts/run-mcp-local.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# scripts/run-mcp-local.sh — run agentkeys-mcp-server locally against the +# xiaozhi.me hosted relay, with the in-memory backend + verbose frame +# logging. Use this for fast iteration / debug — the binary connects out +# to the same wss:// URL that 智控台 shows, and any device on that agent +# routes its tool calls to this laptop instead of the broker EC2. +# +# IMPORTANT: xiaozhi's relay pairs ONE tool-side connection at a time. +# Starting this script kicks the broker EC2's connection off the agent. +# Stop the broker's systemd unit first if you want a clean cutover: +# ssh broker 'sudo systemctl stop agentkeys-mcp-server' +# When done, restart it: +# ssh broker 'sudo systemctl start agentkeys-mcp-server' +# +# URL resolution order (highest to lowest): +# 1. positional arg ($1) +# 2. $XIAOZHI_ENDPOINT env var +# 3. ./mcp-xiaozhi-endpoint (local file you scp'd from the broker) +# 4. /etc/agentkeys/mcp-xiaozhi-endpoint (if you ran setup-mcp-host.sh +# on this machine in xiaozhi mode) +# +# Usage: +# bash scripts/run-mcp-local.sh # auto-detect URL +# bash scripts/run-mcp-local.sh 'wss://api.xiaozhi.me/mcp/?token=…' +# XIAOZHI_ENDPOINT='wss://…' bash scripts/run-mcp-local.sh +# +set -euo pipefail + +REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" + +URL="${1:-${XIAOZHI_ENDPOINT:-}}" +if [ -z "$URL" ] && [ -s "$REPO_ROOT/mcp-xiaozhi-endpoint" ]; then + URL=$(cat "$REPO_ROOT/mcp-xiaozhi-endpoint") +fi +if [ -z "$URL" ] && [ -r /etc/agentkeys/mcp-xiaozhi-endpoint ]; then + URL=$(sudo cat /etc/agentkeys/mcp-xiaozhi-endpoint 2>/dev/null || \ + cat /etc/agentkeys/mcp-xiaozhi-endpoint 2>/dev/null || echo "") +fi + +if [ -z "$URL" ]; then + cat >&2 <<'NO_URL' +no URL found. Get it from 智控台 → 智能体 → MCP接入点 → 接入点地址 and pass via one of: + bash scripts/run-mcp-local.sh 'wss://api.xiaozhi.me/mcp/?token=…' + XIAOZHI_ENDPOINT='wss://…' bash scripts/run-mcp-local.sh + echo 'wss://…' > ./mcp-xiaozhi-endpoint # gitignored; convenient for re-runs + +The URL contains a bearer JWT — don't commit it. +NO_URL + exit 1 +fi + +if ! [[ "$URL" =~ ^wss?:// ]]; then + echo "URL must start with wss:// or ws://, got: ${URL:0:40}…" >&2 + exit 1 +fi + +redacted="${URL%%\?*}?token=" + +echo "==> building release binary" >&2 +( cd "$REPO_ROOT" && cargo build --release -p agentkeys-mcp-server ) + +cat >&2 < ready + URL: ${redacted} + backend: in-memory (seeded with three-act fixture) + actor: 0xa0c7…01a0c7 (DEMO_ACTOR — see backend/in_memory.rs) + log level: info + agentkeys_mcp_server=debug (frame-level) + + Ctrl-C to stop. Any voice query to the xiaozhi agent will land here. + +MSG + +exec env \ + MCP_TRANSPORT=mcp-endpoint \ + MCP_BACKEND=in-memory \ + MCP_ENDPOINT="$URL" \ + RUST_LOG="info,agentkeys_mcp_server=debug" \ + "$REPO_ROOT/target/release/agentkeys-mcp-server" diff --git a/scripts/setup-cloud.sh b/scripts/setup-cloud.sh index 2415f09..37cb454 100755 --- a/scripts/setup-cloud.sh +++ b/scripts/setup-cloud.sh @@ -76,7 +76,7 @@ YES=0 DRY_RUN=0 FROM_STEP=1 TO_STEP=15 -STEP_TOTAL=15 +STEP_TOTAL=16 # Colors only when stderr is a TTY. if [ -t 2 ]; then @@ -342,7 +342,7 @@ do_step_5() { } do_step_6() { - CUR_STEP=6; step "DNS records (DKIM + SPF + DMARC + MX + 6 A records to $EIP)" + CUR_STEP=6; step "DNS records (DKIM + SPF + DMARC + MX + 7 A records to $EIP)" : "${EIP:?EIP missing — re-run step 4 first}" local tokens t1 t2 t3 @@ -362,6 +362,7 @@ do_step_6() { : "${WORKER_EMAIL_HOST:?WORKER_EMAIL_HOST missing — must be set in $ENV_FILE}" : "${WORKER_CRED_HOST:?WORKER_CRED_HOST missing — must be set in $ENV_FILE}" : "${WORKER_MEMORY_HOST:?WORKER_MEMORY_HOST missing — must be set in $ENV_FILE}" + : "${MCP_HOST:?MCP_HOST missing — must be set in $ENV_FILE}" local change_batch change_batch=$(jq -n \ @@ -369,7 +370,7 @@ do_step_6() { --arg eip "$EIP" --arg broker "$BROKER_HOST" \ --arg signer "$SIGNER_HOST" --arg audit "$WORKER_AUDIT_HOST" \ --arg email "$WORKER_EMAIL_HOST" --arg cred "$WORKER_CRED_HOST" \ - --arg memory "$WORKER_MEMORY_HOST" \ + --arg memory "$WORKER_MEMORY_HOST" --arg mcp "$MCP_HOST" \ --arg t1 "$t1" --arg t2 "$t2" --arg t3 "$t3" '{ Comment: "AgentKeys cloud bootstrap (DKIM/SPF/DMARC/MX + broker subdomains)", Changes: [ @@ -384,16 +385,17 @@ do_step_6() { {Action:"UPSERT", ResourceRecordSet:{Name:$audit, Type:"A", TTL:300, ResourceRecords:[{Value:$eip}]}}, {Action:"UPSERT", ResourceRecordSet:{Name:$email, Type:"A", TTL:300, ResourceRecords:[{Value:$eip}]}}, {Action:"UPSERT", ResourceRecordSet:{Name:$cred, Type:"A", TTL:300, ResourceRecords:[{Value:$eip}]}}, - {Action:"UPSERT", ResourceRecordSet:{Name:$memory, Type:"A", TTL:300, ResourceRecords:[{Value:$eip}]}} + {Action:"UPSERT", ResourceRecordSet:{Name:$memory, Type:"A", TTL:300, ResourceRecords:[{Value:$eip}]}}, + {Action:"UPSERT", ResourceRecordSet:{Name:$mcp, Type:"A", TTL:300, ResourceRecords:[{Value:$eip}]}} ] }') - [ "$DRY_RUN" = "1" ] && { warn "DRY: would change-resource-record-sets (12 UPSERTs)"; return; } + [ "$DRY_RUN" = "1" ] && { warn "DRY: would change-resource-record-sets (13 UPSERTs)"; return; } aws route53 change-resource-record-sets --hosted-zone-id "$PARENT_ZONE_ID" \ --change-batch "$change_batch" >/dev/null \ || die "route53 change-resource-record-sets failed" - ok "DNS records UPSERTed (12 records; ~5min for DKIM verification)" + ok "DNS records UPSERTed (13 records; ~5min for DKIM verification)" } do_step_7() { @@ -696,7 +698,96 @@ do_step_14() { } do_step_15() { - CUR_STEP=15; step "Summary + next steps" + CUR_STEP=15; step "Bring up agentkeys-mcp-server on broker (via SSM)" + : "${INSTANCE_ID:?INSTANCE_ID missing — broker EC2 needs to exist (re-run step 4 first)}" + + REPO_URL_FOR_MCP="${AGENTKEYS_REPO_URL:-https://github.com/litentry/agentKeys.git}" + REV_FOR_MCP="${AGENTKEYS_REV:-main}" + MCP_HOST_FLAGS="" + if [ "$TEST_MODE" = "1" ]; then + MCP_HOST_FLAGS="--test" + fi + + if [ "$DRY_RUN" = "1" ]; then + warn "DRY: would SSM-run setup-mcp-host.sh on $INSTANCE_ID ($([ "$TEST_MODE" = "1" ] && echo test-mcp || echo mcp).${ZONE})" + return + fi + + # The script body that runs on the broker. Idempotent — setup-mcp-host.sh + # short-circuits when state is already correct. Steps: + # 1. Ensure cargo (install rustup-minimal if missing — common on fresh EC2) + # 2. Clone or update the repo at /opt/agentkeys-src + # 3. Run scripts/setup-mcp-host.sh (which itself does `cargo install --git`) + local mcp_bring_up_script + mcp_bring_up_script=$(cat </dev/null 2>&1; then + curl -fsSL https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal + source "\$HOME/.cargo/env" +fi + +REPO_DIR=/opt/agentkeys-src +if [ ! -d "\$REPO_DIR/.git" ]; then + sudo install -d -m 0755 -o ubuntu -g ubuntu "\$REPO_DIR" + sudo -u ubuntu git clone --depth 1 -b ${REV_FOR_MCP} ${REPO_URL_FOR_MCP} "\$REPO_DIR" +else + sudo -u ubuntu git -C "\$REPO_DIR" fetch --depth 1 origin ${REV_FOR_MCP} + sudo -u ubuntu git -C "\$REPO_DIR" reset --hard FETCH_HEAD +fi + +cd "\$REPO_DIR" +sudo -E AGENTKEYS_REPO_URL=${REPO_URL_FOR_MCP} AGENTKEYS_REV=${REV_FOR_MCP} \\ + bash scripts/setup-mcp-host.sh ${MCP_HOST_FLAGS} +EOSH +) + + local cmd_id + cmd_id=$(aws ssm send-command \ + --region "$REGION" \ + --instance-ids "$INSTANCE_ID" \ + --document-name "AWS-RunShellScript" \ + --comment "agentkeys-mcp-server bring-up ($([ "$TEST_MODE" = "1" ] && echo test || echo prod))" \ + --parameters "{\"commands\": $(jq -Rs . <<<"$mcp_bring_up_script" | jq -s .)}" \ + --query "Command.CommandId" --output text) \ + || die "aws ssm send-command failed — does $INSTANCE_ID have amazon-ssm-agent + the SSM instance profile?" + ok "SSM command $cmd_id queued on $INSTANCE_ID; polling for completion (max 10 min)" + + # Poll every 10s for up to 10 min. setup-mcp-host.sh is normally <3 min; + # first-time runs with cargo install may take longer. + local status="Pending" + for i in $(seq 1 60); do + sleep 10 + status=$(aws ssm get-command-invocation \ + --region "$REGION" \ + --command-id "$cmd_id" \ + --instance-id "$INSTANCE_ID" \ + --query "Status" --output text 2>/dev/null || echo "Pending") + case "$status" in + Success) + ok "MCP server brought up on $INSTANCE_ID" + # Tail the last 30 lines of stdout for a quick sanity check + aws ssm get-command-invocation \ + --region "$REGION" --command-id "$cmd_id" --instance-id "$INSTANCE_ID" \ + --query "StandardOutputContent" --output text 2>/dev/null \ + | tail -30 | sed 's/^/ /' >&2 || true + return ;; + Failed|Cancelled|TimedOut) + warn "SSM command status: $status" + aws ssm get-command-invocation \ + --region "$REGION" --command-id "$cmd_id" --instance-id "$INSTANCE_ID" \ + --query "StandardErrorContent" --output text 2>/dev/null \ + | tail -50 | sed 's/^/ /' >&2 || true + die "MCP bring-up failed; see SSM command $cmd_id in CloudWatch" ;; + esac + done + die "MCP bring-up timed out after 10 min (status=$status); check SSM command $cmd_id" +} + +do_step_16() { + CUR_STEP=16; step "Summary + next steps" printf "\n${COLOR_OK}═══ Cloud bootstrap complete ═══${COLOR_RESET}\n\n" >&2 printf " Operator env file : %s\n" "$ENV_FILE" >&2 printf " Broker env file : %s\n" "$BROKER_ENV_FILE" >&2 @@ -729,7 +820,9 @@ do_step_15() { printf " Re-run any step surgically (idempotent):\n" >&2 printf " bash scripts/setup-cloud.sh --only-step 6 # re-UPSERT DNS\n" >&2 printf " bash scripts/setup-cloud.sh --only-step 12 # re-create SSH user (e.g. after EC2 replace)\n" >&2 - printf " bash scripts/setup-cloud.sh --only-step 13 # re-run per-data-class provisioning\n\n" >&2 + printf " bash scripts/setup-cloud.sh --only-step 13 # re-run per-data-class provisioning\n" >&2 + printf " bash scripts/setup-cloud.sh --only-step 15 # re-deploy agentkeys-mcp-server on broker (cargo install --git)\n" >&2 + printf " bash scripts/setup-cloud.sh --only-step 15 --test # same for test-mcp.\${ZONE}\n\n" >&2 } main() { @@ -748,6 +841,7 @@ main() { in_scope 13 && do_step_13 in_scope 14 && do_step_14 in_scope 15 && do_step_15 + in_scope 16 && do_step_16 } main "$@" diff --git a/scripts/setup-mcp-host.sh b/scripts/setup-mcp-host.sh new file mode 100755 index 0000000..37fdd64 --- /dev/null +++ b/scripts/setup-mcp-host.sh @@ -0,0 +1,874 @@ +#!/usr/bin/env bash +# scripts/setup-mcp-host.sh — idempotent MCP-server + relay deploy on the +# broker EC2 host. Per CLAUDE.md "Idempotent remote-setup rule" — every +# step pre-checks state, emits `ok proceeding` / `skip ` / +# `fail `, and short-circuits when already done. +# +# Topology this script lands: +# +# nginx (TLS for mcp.litentry.org) +# │ +# ├── /mcp_endpoint/mcp/?token=… ──┐ +# │ ▼ wss → ws upgrade +# ├── /mcp_endpoint/call/?token=… → mcp-endpoint-server (127.0.0.1:8004) +# │ ▲ +# └── /healthz → agentkeys-mcp-server │ ws tool side +# --transport mcp-endpoint │ +# ──────────────────────────┘ +# +# After this runs: +# wss://mcp.litentry.org/mcp_endpoint/mcp/?token= → tool side +# wss://mcp.litentry.org/mcp_endpoint/call/?token= → xiaozhi side +# https://mcp.litentry.org/mcp_endpoint/health?key= → 智控台 health +# +# Run ON the broker host (same host setup-broker-host.sh runs against). +# Standalone for now; CLAUDE.md follow-up: fold into setup-broker-host.sh +# as `--with-mcp` once this stabilises. +# +# Usage (xiaozhi-hosted mode — DEFAULT, simpler): +# bash scripts/setup-mcp-host.sh --xiaozhi-endpoint 'wss://api.xiaozhi.me/mcp/?token=…' +# bash scripts/setup-mcp-host.sh # re-run (URL persisted on disk) +# +# Usage (self-hosted relay mode — for custom endpoint deployments): +# bash scripts/setup-mcp-host.sh --self-hosted-relay # prod → mcp.litentry.org +# bash scripts/setup-mcp-host.sh --self-hosted-relay --test # test → test-mcp.litentry.org +# bash scripts/setup-mcp-host.sh --self-hosted-relay --domain custom.example.com +# +# Two deployment modes: +# +# MODE = "xiaozhi" (default) — xiaozhi.me hosts the MCP-endpoint relay. +# • No mcp-endpoint-server clone, no nginx, no certbot, no DNS A record needed. +# • Operator pastes the wss://api.xiaozhi.me/mcp/?token=… URL from +# 智控台 → 智能体 → MCP接入点 → 接入点地址 into --xiaozhi-endpoint once; +# it's persisted at /etc/agentkeys/mcp-xiaozhi-endpoint for re-runs. +# • Only agentkeys-mcp-server runs on the broker host (one systemd unit). +# • The mcp-endpoint-server systemd unit + nginx vhost are stopped if they +# were left over from a prior self-hosted run. +# +# MODE = "self-hosted" — operator runs their own mcp-endpoint-server. +# • Full stack: clone + venv, nginx wss→ws upgrade, certbot, DNS. +# • Domain resolution: +# 1. --domain X explicit +# 2. --test → test-mcp.litentry.org +# 3. $MCP_HOST from environment (operator-workstation.env|.test.env) +# 4. fallback → mcp.litentry.org +# • DNS A record is provisioned by scripts/setup-cloud.sh step 6. +# +set -euo pipefail +export HOME="${HOME:-$(getent passwd "$(id -u)" | cut -d: -f6)}" + +REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +RELAY_PORT="8004" +INSTALL_DIR="/opt/agentkeys/mcp-endpoint" +RELAY_REPO="https://github.com/xinnan-tech/mcp-endpoint-server.git" +RELAY_PIN_REF="${RELAY_PIN_REF:-main}" # override to pin commit +RUN_USER="agentkey" +ENV_FILE_DIR="/etc/agentkeys" +ENV_FILE="${ENV_FILE_DIR}/mcp.env" +TOKEN_FILE="${ENV_FILE_DIR}/mcp-tool-token" +HEALTH_KEY_FILE="${ENV_FILE_DIR}/mcp-health-key" +XIAOZHI_ENDPOINT_FILE="${ENV_FILE_DIR}/mcp-xiaozhi-endpoint" +MCP_BIN_DST="/usr/local/bin/agentkeys-mcp-server" +WITH_NGINX="yes" +WITH_CERTBOT="yes" +WITH_BUILD="yes" +CERTBOT_EMAIL="" +TEST_MODE="no" +DOMAIN_OVERRIDE="" +MODE="xiaozhi" # default; flipped to "self-hosted" by --self-hosted-relay +XIAOZHI_ENDPOINT="" # set by --xiaozhi-endpoint or loaded from $XIAOZHI_ENDPOINT_FILE + +while [[ $# -gt 0 ]]; do + case "$1" in + --xiaozhi-endpoint) XIAOZHI_ENDPOINT="$2"; MODE="xiaozhi"; shift 2 ;; + --self-hosted-relay) MODE="self-hosted"; shift ;; + --test) TEST_MODE="yes"; shift ;; + --domain) DOMAIN_OVERRIDE="$2"; shift 2 ;; + --certbot-email) CERTBOT_EMAIL="$2"; shift 2 ;; + --without-nginx) WITH_NGINX="no"; shift ;; + --without-certbot) WITH_CERTBOT="no"; shift ;; + --without-build) WITH_BUILD="no"; shift ;; + --relay-port) RELAY_PORT="$2"; shift 2 ;; + --relay-ref) RELAY_PIN_REF="$2"; shift 2 ;; + --help|-h) sed -n '2,50p' "$0"; exit 0 ;; + *) echo "unknown flag: $1" >&2; exit 1 ;; + esac +done + +# Resolve DOMAIN per the precedence rules in the header comment. +# Self-hosted mode: needed for nginx vhost + cert. +# Xiaozhi mode: not used (xiaozhi.me handles routing), but we still +# resolve it so log messages / diagnostics name the right thing. +if [ -n "$DOMAIN_OVERRIDE" ]; then + DOMAIN="$DOMAIN_OVERRIDE" +elif [ "$TEST_MODE" = "yes" ]; then + DOMAIN="test-mcp.litentry.org" +elif [ -n "${MCP_HOST:-}" ]; then + DOMAIN="$MCP_HOST" +else + DOMAIN="mcp.litentry.org" +fi +NGINX_SITE="/etc/nginx/sites-available/${DOMAIN}" +NGINX_SITE_LINK="/etc/nginx/sites-enabled/${DOMAIN}" + +# Mode-specific overrides: xiaozhi mode has no nginx/certbot/relay needs. +if [ "$MODE" = "xiaozhi" ]; then + WITH_NGINX="no" + WITH_CERTBOT="no" +fi + +if [ -t 2 ]; then + C_HEAD=$'\033[1;36m'; C_OK=$'\033[1;32m'; C_SKIP=$'\033[0;33m'; C_ERR=$'\033[1;31m'; C_RESET=$'\033[0m' +else + C_HEAD=''; C_OK=''; C_SKIP=''; C_ERR=''; C_RESET='' +fi +head() { printf "${C_HEAD}==> %s${C_RESET}\n" "$*" >&2; } +ok() { printf " ${C_OK}ok proceeding${C_RESET} — %s\n" "$*" >&2; } +skip() { printf " ${C_SKIP}skip${C_RESET} — %s\n" "$*" >&2; } +fail() { printf " ${C_ERR}fail${C_RESET} — %s\n" "$*" >&2; exit 1; } +need() { command -v "$1" >/dev/null 2>&1 || fail "missing prerequisite: $1"; } + +need sudo + +# ─── 0. Distro-package prerequisites ───────────────────────────────── +# Idempotent: pkg checks first, only `apt install` what's missing. Output +# follows the script's ok/skip/fail convention so a clean re-run shows +# all skips. +head "0/9 distro packages (python3-venv, python3-pip, git, nginx, certbot)" +if command -v apt-get >/dev/null 2>&1; then + PKGS=(python3-venv python3-pip git) + [[ "$WITH_NGINX" == "yes" ]] && PKGS+=(nginx) + [[ "$WITH_CERTBOT" == "yes" ]] && PKGS+=(certbot python3-certbot-nginx) + + MISSING=() + for pkg in "${PKGS[@]}"; do + if dpkg -s "$pkg" >/dev/null 2>&1; then + skip "$pkg already installed" + else + MISSING+=("$pkg") + fi + done + + if [ ${#MISSING[@]} -gt 0 ]; then + sudo apt-get update -qq + sudo apt-get install -y "${MISSING[@]}" + ok "installed: ${MISSING[*]}" + fi +elif command -v dnf >/dev/null 2>&1; then + PKGS=(python3 python3-pip git) + [[ "$WITH_NGINX" == "yes" ]] && PKGS+=(nginx) + [[ "$WITH_CERTBOT" == "yes" ]] && PKGS+=(certbot python3-certbot-nginx) + sudo dnf install -y -q "${PKGS[@]}" >/dev/null + ok "ensured: ${PKGS[*]} (dnf is idempotent)" +else + skip "no apt-get or dnf; assuming prerequisites are present" + [[ "$WITH_NGINX" == "yes" ]] && need nginx || true + [[ "$WITH_CERTBOT" == "yes" ]] && need certbot || true +fi + +# Resolve the run-user, falling back to ubuntu on hosts where the +# setup-broker-host.sh hasn't created `agentkey` yet. +if ! id "$RUN_USER" >/dev/null 2>&1; then + if id ubuntu >/dev/null 2>&1; then + RUN_USER="ubuntu" + skip "run-user: agentkey not found; using ubuntu" + else + fail "neither agentkey nor ubuntu user exists" + fi +fi + +head "config" +echo " mode: ${MODE}" >&2 +echo " domain: ${DOMAIN} (test_mode=${TEST_MODE}; only used in self-hosted mode)" >&2 +echo " relay (local): 127.0.0.1:${RELAY_PORT}" >&2 +echo " relay src: ${RELAY_REPO}@${RELAY_PIN_REF}" >&2 +echo " install dir: ${INSTALL_DIR}" >&2 +echo " run user: ${RUN_USER}" >&2 +echo " env file: ${ENV_FILE}" >&2 +echo " mcp binary dst: ${MCP_BIN_DST}" >&2 +echo " mcp install: cargo install --git ${AGENTKEYS_REPO_URL:-https://github.com/litentry/agentKeys.git} --branch ${AGENTKEYS_REV:-main}" >&2 +echo " with nginx: ${WITH_NGINX}" >&2 +echo " with certbot: ${WITH_CERTBOT}" >&2 +echo " with build: ${WITH_BUILD}" >&2 + +# ─── 1. /etc/agentkeys exists with the right perms ─────────────────── +head "1/9 /etc/agentkeys layout" +if [ -d "$ENV_FILE_DIR" ]; then + skip "$ENV_FILE_DIR already exists" +else + sudo install -d -m 0750 -o "$RUN_USER" -g "$RUN_USER" "$ENV_FILE_DIR" + ok "created $ENV_FILE_DIR (0750 ${RUN_USER}:${RUN_USER})" +fi + +# ─── 2. Endpoint config (mode-dependent) ───────────────────────────── +# Xiaozhi mode : persist the wss://api.xiaozhi.me/mcp/?token=… URL. +# Self-hosted mode: generate the relay-token + 智控台 health-key. +if [ "$MODE" = "xiaozhi" ]; then + head "2/9 xiaozhi MCP endpoint URL" + # Load persisted URL if no --xiaozhi-endpoint flag was passed. + if [ -z "$XIAOZHI_ENDPOINT" ] && sudo test -s "$XIAOZHI_ENDPOINT_FILE"; then + XIAOZHI_ENDPOINT=$(sudo cat "$XIAOZHI_ENDPOINT_FILE") + ok "loaded persisted endpoint from $XIAOZHI_ENDPOINT_FILE" + fi + if [ -z "$XIAOZHI_ENDPOINT" ]; then + echo " No --xiaozhi-endpoint URL and no persisted endpoint." >&2 + echo " Get the URL from 智控台 → 智能体 → MCP接入点 → 接入点地址," >&2 + echo " then re-run with --xiaozhi-endpoint 'wss://api.xiaozhi.me/mcp/?token=…'." >&2 + echo " Or use --self-hosted-relay to set up your own mcp-endpoint-server." >&2 + fail "xiaozhi mode requires an endpoint URL on first run" + fi + # Persist (idempotent diff-then-write). + EXISTING_URL=$(sudo cat "$XIAOZHI_ENDPOINT_FILE" 2>/dev/null || true) + if [ "$EXISTING_URL" = "$XIAOZHI_ENDPOINT" ]; then + skip "$XIAOZHI_ENDPOINT_FILE already matches" + else + printf '%s' "$XIAOZHI_ENDPOINT" | sudo tee "$XIAOZHI_ENDPOINT_FILE" >/dev/null + sudo chown "$RUN_USER:$RUN_USER" "$XIAOZHI_ENDPOINT_FILE" + sudo chmod 0600 "$XIAOZHI_ENDPOINT_FILE" + ok "wrote $XIAOZHI_ENDPOINT_FILE (0600 ${RUN_USER}:${RUN_USER})" + RESTART_MCP=1 + fi +else + head "2/9 tool token + 智控台 health key" + gen_token() { command head -c 32 /dev/urandom | base64 | tr -d '/+=\n' | cut -c1-32; } + for pair in "TOKEN_FILE:tool token" "HEALTH_KEY_FILE:health key"; do + var="${pair%%:*}"; desc="${pair##*:}" + path="${!var}" + if sudo test -s "$path"; then + skip "$desc already exists at $path (preserving so URLs stay stable)" + else + secret=$(gen_token) + printf '%s' "$secret" | sudo tee "$path" >/dev/null + sudo chown "$RUN_USER:$RUN_USER" "$path" + sudo chmod 0600 "$path" + ok "generated $desc at $path" + fi + done + TOKEN=$(sudo cat "$TOKEN_FILE") + HEALTH_KEY=$(sudo cat "$HEALTH_KEY_FILE") +fi + +# ─── 3. mcp-endpoint-server clone + venv (self-hosted only) ────────── +if [ "$MODE" = "xiaozhi" ]; then + head "3/9 mcp-endpoint-server src + venv" + skip "xiaozhi mode — xiaozhi.me hosts the relay; no local mcp-endpoint-server needed" +else +head "3/9 mcp-endpoint-server src + venv" +if sudo test -d "$INSTALL_DIR/src/.git"; then + current_ref=$(sudo -u "$RUN_USER" git -C "$INSTALL_DIR/src" rev-parse HEAD) + sudo -u "$RUN_USER" git -C "$INSTALL_DIR/src" fetch --quiet origin "$RELAY_PIN_REF" + target_ref=$(sudo -u "$RUN_USER" git -C "$INSTALL_DIR/src" rev-parse "origin/$RELAY_PIN_REF" 2>/dev/null \ + || sudo -u "$RUN_USER" git -C "$INSTALL_DIR/src" rev-parse "$RELAY_PIN_REF") + if [ "$current_ref" = "$target_ref" ]; then + skip "src at $current_ref already matches $RELAY_PIN_REF" + else + sudo -u "$RUN_USER" git -C "$INSTALL_DIR/src" checkout --quiet "$target_ref" + ok "src moved $current_ref → $target_ref" + DEPS_DIRTY=1 + fi +else + sudo install -d -m 0755 -o "$RUN_USER" -g "$RUN_USER" "$INSTALL_DIR" + sudo -u "$RUN_USER" git clone --quiet --depth 1 -b "$RELAY_PIN_REF" "$RELAY_REPO" "$INSTALL_DIR/src" + ok "cloned $RELAY_REPO@$RELAY_PIN_REF → $INSTALL_DIR/src" + DEPS_DIRTY=1 +fi + +# Venv health check — verify that the relay's key deps are actually +# importable, NOT just that python3 starts. A half-built venv (e.g. from +# a prior pip install that silently failed) has working python3 but no +# uvicorn/fastapi; the relay then crashes on import at systemd start. +VENV_HEALTHY="no" +VENV_REASON="" +if sudo test -x "$INSTALL_DIR/src/.venv/bin/python3" && \ + sudo test -x "$INSTALL_DIR/src/.venv/bin/pip"; then + if sudo -u "$RUN_USER" "$INSTALL_DIR/src/.venv/bin/python3" \ + -c "import uvicorn, fastapi, websockets, loguru" 2>/dev/null; then + VENV_HEALTHY="yes" + else + VENV_REASON="key deps (uvicorn/fastapi/websockets/loguru) not importable" + fi +else + VENV_REASON=".venv/bin/python3 or .venv/bin/pip missing" +fi + +if [ "$VENV_HEALTHY" = "yes" ]; then + if [ "${DEPS_DIRTY:-0}" = "1" ]; then + sudo -u "$RUN_USER" "$INSTALL_DIR/src/.venv/bin/pip" install --quiet \ + -r "$INSTALL_DIR/src/requirements.txt" \ + || fail "pip install -r requirements.txt failed after src moved (see above)" + ok "venv: pip install -r requirements.txt (src moved)" + else + skip "venv healthy + key deps importable + src unchanged" + fi +else + ok "venv unhealthy ($VENV_REASON) — rebuilding" + # Wipe a half-built venv from a prior failed run, if any. + if sudo test -d "$INSTALL_DIR/src/.venv"; then + sudo rm -rf "$INSTALL_DIR/src/.venv" + ok "removed broken half-built venv from a prior failed run" + fi + sudo -u "$RUN_USER" python3 -m venv "$INSTALL_DIR/src/.venv" \ + || fail "python3 -m venv failed (apt install python3-venv?)" + sudo -u "$RUN_USER" "$INSTALL_DIR/src/.venv/bin/pip" install --quiet --upgrade pip \ + || fail "pip --upgrade failed" + sudo -u "$RUN_USER" "$INSTALL_DIR/src/.venv/bin/pip" install --quiet \ + -r "$INSTALL_DIR/src/requirements.txt" \ + || fail "pip install -r requirements.txt failed (see above)" + # Re-verify after install — catches a silent partial install. + if ! sudo -u "$RUN_USER" "$INSTALL_DIR/src/.venv/bin/python3" \ + -c "import uvicorn, fastapi, websockets, loguru" 2>/dev/null; then + fail "venv install completed but key deps still not importable — rerun with verbose pip" + fi + ok "created venv + installed requirements.txt + verified deps" + RESTART_RELAY=1 +fi +fi # MODE == self-hosted (closes step 3 self-hosted branch) + +# ─── 4. Install agentkeys-mcp-server via `cargo install --git` ─────── +# Canonical install path (per #134 — until M6 ships GH Releases + a +# native installer). Pulls from public GitHub, builds, places binary at +# a user-writable cache, then sudo-installs to /usr/local/bin/. +# +# Override repo/rev for development (e.g. testing a PR branch): +# AGENTKEYS_REPO_URL=https://github.com/me/agentKeys.git \ +# AGENTKEYS_REV=my-pr-branch bash scripts/setup-mcp-host.sh +head "4/9 install agentkeys-mcp-server (cargo install --git)" +REPO_URL="${AGENTKEYS_REPO_URL:-https://github.com/litentry/agentKeys.git}" +REV="${AGENTKEYS_REV:-main}" +INSTALL_CACHE="${HOME}/.cache/agentkeys-mcp-install" + +if [ "$WITH_BUILD" = "yes" ]; then + command -v cargo >/dev/null 2>&1 \ + || fail "cargo not found — install Rust toolchain (curl https://sh.rustup.rs | sh) or pass --without-build if binary already at $MCP_BIN_DST" + mkdir -p "$INSTALL_CACHE" + ok "cargo install --git $REPO_URL --branch $REV → $INSTALL_CACHE/bin/" + # --force: cargo install won't overwrite "the same version" without it. + # With --git there's no semver to compare against, so --force is the + # right call. cargo's incremental compile + on-disk cache keep re-runs + # fast (~5s when nothing changed; ~2 min on a fresh build). + cargo install --quiet --force \ + --git "$REPO_URL" --branch "$REV" \ + --bin agentkeys-mcp-server \ + --root "$INSTALL_CACHE" \ + agentkeys-mcp-server \ + || fail "cargo install --git $REPO_URL@$REV failed" +fi + +CACHED_BIN="$INSTALL_CACHE/bin/agentkeys-mcp-server" +if [ ! -x "$CACHED_BIN" ]; then + fail "$CACHED_BIN not installed; drop --without-build or place the binary at $MCP_BIN_DST yourself" +fi + +src_sha=$(sha256sum "$CACHED_BIN" | awk '{print $1}') +dst_sha=$(sudo sha256sum "$MCP_BIN_DST" 2>/dev/null | awk '{print $1}' || echo "missing") +if [ "$src_sha" = "$dst_sha" ]; then + skip "$MCP_BIN_DST already up to date (sha256 $src_sha)" +else + sudo install -m 0755 "$CACHED_BIN" "$MCP_BIN_DST" + ok "installed $MCP_BIN_DST (sha256 $src_sha)" + RESTART_MCP=1 +fi + +# ─── 5. /etc/agentkeys/mcp.env ─────────────────────────────────────── +head "5/9 /etc/agentkeys/mcp.env" +if [ "$MODE" = "xiaozhi" ]; then + want_env=$(cat </dev/null || true) +if [ "$want_env" = "$got_env" ]; then + skip "$ENV_FILE already matches target" +else + printf '%s\n' "$want_env" | sudo tee "$ENV_FILE" >/dev/null + sudo chown "$RUN_USER:$RUN_USER" "$ENV_FILE" + sudo chmod 0600 "$ENV_FILE" + ok "wrote $ENV_FILE (0600 ${RUN_USER}:${RUN_USER})" + RESTART_MCP=1 +fi + +# ─── 6. systemd units ──────────────────────────────────────────────── +RELAY_UNIT_PATH=/etc/systemd/system/mcp-endpoint-server.service +MCP_UNIT_PATH=/etc/systemd/system/agentkeys-mcp-server.service + +if [ "$MODE" = "self-hosted" ]; then + head "6/9 systemd units (mcp-endpoint-server + agentkeys-mcp-server)" + want_relay_unit=$(cat </dev/null || true) + if [ "$want_relay_unit" = "$got" ]; then + skip "${RELAY_UNIT_PATH##*/} already up to date" + else + printf '%s\n' "$want_relay_unit" | sudo tee "$RELAY_UNIT_PATH" >/dev/null + ok "wrote ${RELAY_UNIT_PATH##*/}" + DAEMON_RELOAD=1 + RESTART_RELAY=1 + fi + MCP_UNIT_AFTER="network-online.target mcp-endpoint-server.service" + MCP_UNIT_WANTS="network-online.target mcp-endpoint-server.service" +else + head "6/9 systemd unit (agentkeys-mcp-server only — xiaozhi mode)" + # Stop + disable any leftover self-hosted relay unit so we don't waste + # resources or expose a half-configured port. + if sudo test -f "$RELAY_UNIT_PATH"; then + if sudo systemctl is-active --quiet mcp-endpoint-server.service 2>/dev/null; then + sudo systemctl stop mcp-endpoint-server.service + ok "stopped leftover mcp-endpoint-server.service (xiaozhi mode)" + fi + if sudo systemctl is-enabled --quiet mcp-endpoint-server.service 2>/dev/null; then + sudo systemctl disable mcp-endpoint-server.service >/dev/null 2>&1 || true + ok "disabled mcp-endpoint-server.service (xiaozhi mode)" + fi + fi + MCP_UNIT_AFTER="network-online.target" + MCP_UNIT_WANTS="network-online.target" +fi + +want_mcp_unit=$(cat </dev/null || true) +if [ "$want_mcp_unit" = "$got" ]; then + skip "${MCP_UNIT_PATH##*/} already up to date" +else + printf '%s\n' "$want_mcp_unit" | sudo tee "$MCP_UNIT_PATH" >/dev/null + ok "wrote ${MCP_UNIT_PATH##*/}" + DAEMON_RELOAD=1 + RESTART_MCP=1 +fi + +[ "${DAEMON_RELOAD:-0}" = "1" ] && sudo systemctl daemon-reload + +if [ "$MODE" = "self-hosted" ]; then + sudo systemctl enable mcp-endpoint-server.service >/dev/null 2>&1 || true +fi +sudo systemctl enable agentkeys-mcp-server.service >/dev/null 2>&1 || true + +if [ "$MODE" = "self-hosted" ]; then + if [ "${RESTART_RELAY:-0}" = "1" ]; then + sudo systemctl restart mcp-endpoint-server.service + ok "restarted mcp-endpoint-server.service" + else + sudo systemctl start mcp-endpoint-server.service 2>/dev/null || true + fi +fi +if [ "${RESTART_MCP:-0}" = "1" ]; then + sudo systemctl restart agentkeys-mcp-server.service + ok "restarted agentkeys-mcp-server.service" +else + sudo systemctl start agentkeys-mcp-server.service 2>/dev/null || true +fi + +# ─── 7. nginx vhost (TLS-terminating wss → ws) ─────────────────────── +if [ "$WITH_NGINX" = "yes" ]; then + head "7/9 nginx vhost (TLS-terminating wss → ws for ${DOMAIN})" + + # Two-phase nginx config (same pattern as setup-broker-host.sh) to + # solve the certbot ↔ nginx chicken-and-egg: + # + # Phase A (no cert yet) :80-only with the ACME challenge location. + # certbot uses webroot to issue. + # Phase B (cert exists) :80 redirects to :443, :443 server block + # proxies wss → ws + 智控台 health. + # + # Re-running this script after issuance flips A → B automatically + # (cert presence is the only trigger). + # + # `listen 443 ssl http2;` keeps the old syntax that works on + # nginx <1.25 — the `http2 on;` directive only lands in 1.25.1+. + if sudo test -f "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem"; then + NGINX_PHASE="B" + want_vhost=$(cat </dev/null || true) + if [ "$want_vhost" = "$got" ]; then + skip "${NGINX_SITE##*/} (phase $NGINX_PHASE) already up to date" + else + printf '%s\n' "$want_vhost" | sudo tee "$NGINX_SITE" >/dev/null + ok "wrote ${NGINX_SITE##*/} (phase $NGINX_PHASE)" + RELOAD_NGINX=1 + fi + if [ -L "$NGINX_SITE_LINK" ] || [ -e "$NGINX_SITE_LINK" ]; then + skip "${NGINX_SITE_LINK##*/} already linked" + else + sudo ln -sf "$NGINX_SITE" "$NGINX_SITE_LINK" + ok "enabled site ${NGINX_SITE_LINK##*/}" + RELOAD_NGINX=1 + fi + + # Reload nginx NOW (mid-step) so certbot's webroot challenge can + # land against the live phase-A vhost. The post-cert phase-B reload + # happens in step 9. + if [ "${RELOAD_NGINX:-0}" = "1" ]; then + sudo nginx -t + sudo systemctl reload nginx + ok "reloaded nginx (phase $NGINX_PHASE)" + RELOAD_NGINX=0 + fi +else + head "7/9 nginx vhost" + if [ "$MODE" = "xiaozhi" ]; then + skip "xiaozhi mode — xiaozhi.me terminates TLS, no local nginx needed" + else + skip "--without-nginx; skipping vhost" + fi +fi + +# ─── 8. certbot cert (idempotent: reuses existing) ─────────────────── +# The DNS A record for $DOMAIN is provisioned by scripts/setup-cloud.sh +# step 6 (same Route53 batch as the broker / signer / worker subdomains). +# This step polls the public resolver and skips gracefully if DNS isn't +# yet live, with a clear pointer to run setup-cloud.sh first. +if [ "$WITH_NGINX" = "yes" ] && [ "$WITH_CERTBOT" = "yes" ]; then + head "8/9 certbot certificate for ${DOMAIN}" + if sudo test -f "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem"; then + skip "cert already issued at /etc/letsencrypt/live/${DOMAIN}/ (certbot will auto-renew)" + else + # Ensure the webroot dir exists for ACME http-01 challenges. + sudo install -d -m 0755 /var/www/html + + # ACME account email: used by Let's Encrypt for cert-expiry + + # renewal-failure notifications and account recovery. Three forms: + # --certbot-email explicit + # prior `certbot register` on host reuses the existing account + # neither --register-unsafely-without-email + if sudo test -d /etc/letsencrypt/accounts && \ + [ "$(sudo find /etc/letsencrypt/accounts -name 'regr.json' | wc -l)" -gt 0 ]; then + EMAIL_ARG="" + ok "reusing existing ACME account on host (no --certbot-email needed)" + elif [ -n "$CERTBOT_EMAIL" ]; then + EMAIL_ARG="-m $CERTBOT_EMAIL" + else + EMAIL_ARG="--register-unsafely-without-email" + ok "no --certbot-email + no existing ACME account; using --register-unsafely-without-email" + echo " (Let's Encrypt will not send expiry notifications. Re-run with" >&2 + echo " --certbot-email later to attach a recovery address.)" >&2 + fi + + # Single-shot DNS check (no wait). Use `command head` to bypass the + # head() function we define for ==> step headers — without that the + # pipeline reads `head -1` as a function call with arg "-1" and + # prints garbage like `==> -1`. + DNS_IP=$(dig +short A "$DOMAIN" @1.1.1.1 2>/dev/null | command head -n 1) + [ -z "$DNS_IP" ] && DNS_IP=$(dig +short A "$DOMAIN" 2>/dev/null | command head -n 1) + [ -z "$DNS_IP" ] && DNS_IP=$(getent hosts "$DOMAIN" 2>/dev/null | awk 'NR==1 {print $1}') + + DNS_OK="yes" + if [ -z "$DNS_IP" ]; then + DNS_OK="no" + echo " DNS A record for ${DOMAIN} not visible right now." >&2 + echo " ACTION: provision DNS by running on the operator workstation:" >&2 + echo " set -a && source scripts/operator-workstation.env && set +a" >&2 + echo " bash scripts/setup-cloud.sh --env-file scripts/operator-workstation.env --only-step 6" >&2 + echo " For the test env, use scripts/operator-workstation.test.env + --test." >&2 + echo " Then re-run this script (TTL 300 → ~5 min for resolvers to refresh)." >&2 + skip "cert deferred — DNS A record for ${DOMAIN} not yet live" + else + ok "DNS resolved: ${DOMAIN} → ${DNS_IP}" + fi + + if [ "$DNS_OK" = "yes" ]; then + # Webroot mode (NOT --nginx) — issues the cert without mutating + # the vhost we just wrote. The phase-B flip is our job; certbot + # only puts files under /etc/letsencrypt/. + sudo certbot certonly --webroot -w /var/www/html \ + -d "$DOMAIN" --non-interactive --agree-tos $EMAIL_ARG + ok "issued cert for $DOMAIN via webroot" + + # Now flip phase A → phase B inline (re-run step 7's vhost write + # so the operator gets TLS in a single script invocation). + NGINX_PHASE="B" + phase_b_vhost=$(cat </dev/null + ok "rewrote ${NGINX_SITE##*/} to phase B (TLS on)" + RELOAD_NGINX=1 + fi # DNS_OK + fi # cert not yet present +else + head "8/9 certbot certificate" + if [ "$MODE" = "xiaozhi" ]; then + skip "xiaozhi mode — xiaozhi.me's cert covers api.xiaozhi.me; no local cert needed" + else + skip "--without-nginx or --without-certbot; no cert" + fi +fi # WITH_NGINX && WITH_CERTBOT + +# ─── 9. nginx reload (only if drift) + post-checks ─────────────────── +if [ "$MODE" = "self-hosted" ]; then + head "9/9 nginx reload + post-checks" + if [ "${RELOAD_NGINX:-0}" = "1" ]; then + sudo nginx -t + sudo systemctl reload nginx + ok "reloaded nginx" + else + skip "no nginx drift, no reload" + fi + + # Probe the local relay via 127.0.0.1. Retry a few times — `systemctl + # restart` returns as soon as the process is forked; uvicorn + fastapi + # need ~1-3s to bind the port. We poll for up to 15s. + relay_ok="no" + relay_probe_path="" + for _ in 1 2 3 4 5 6 7 8 9 10; do + if curl -sf "http://127.0.0.1:${RELAY_PORT}/mcp_endpoint/health?key=${HEALTH_KEY}" >/dev/null 2>&1; then + relay_ok="yes"; relay_probe_path="/mcp_endpoint/health"; break + elif curl -sf "http://127.0.0.1:${RELAY_PORT}/" >/dev/null 2>&1; then + relay_ok="yes"; relay_probe_path="/"; break + fi + sleep 1.5 + done + + if [ "$relay_ok" = "yes" ]; then + ok "local relay reachable on 127.0.0.1:${RELAY_PORT} (probe ${relay_probe_path})" + else + echo >&2 + echo " --- diagnostics: mcp-endpoint-server didn't bind 127.0.0.1:${RELAY_PORT} in 15s ---" >&2 + echo " systemctl status:" >&2 + sudo systemctl status mcp-endpoint-server.service --no-pager --lines=0 2>&1 | sed 's/^/ /' >&2 || true + echo " last 30 journal lines:" >&2 + sudo journalctl -u mcp-endpoint-server.service -n 30 --no-pager 2>&1 | sed 's/^/ /' >&2 || true + echo " listening tcp sockets:" >&2 + (sudo ss -tlnp 2>/dev/null || sudo netstat -tlnp 2>/dev/null || true) | sed 's/^/ /' >&2 + echo " config file (${INSTALL_DIR}/src/mcp-endpoint-server.cfg):" >&2 + sudo cat "$INSTALL_DIR/src/mcp-endpoint-server.cfg" 2>&1 | sed 's/^/ /' >&2 || true + echo " --- end diagnostics ---" >&2 + echo >&2 + fail "relay not responding on 127.0.0.1:${RELAY_PORT} after 15s (see diagnostics above)" + fi + + # Don't require external DNS in the post-check — the operator may have + # only just pointed mcp.litentry.org at this host. + echo " nginx vhost wired for ${DOMAIN}. Verify externally once DNS A record is live:" >&2 + echo " curl -sf https://${DOMAIN}/mcp_endpoint/health?key=${HEALTH_KEY}" >&2 +else + # Xiaozhi mode — no nginx, no local relay. Check that agentkeys-mcp-server + # is up and connecting to the cloud endpoint. The journal log will show + # `mcp-endpoint: connected; awaiting MCP frames` once paired. + head "9/9 agentkeys-mcp-server post-check (xiaozhi mode)" + mcp_ok="no" + for _ in 1 2 3 4 5 6 7 8 9 10; do + if sudo systemctl is-active --quiet agentkeys-mcp-server.service; then + mcp_ok="yes"; break + fi + sleep 1.5 + done + + if [ "$mcp_ok" = "yes" ]; then + ok "agentkeys-mcp-server.service is active" + # Surface a few recent log lines so the operator sees the outbound + # connect attempt (or any error) without having to journalctl by hand. + echo " recent log lines:" >&2 + sudo journalctl -u agentkeys-mcp-server.service -n 8 --no-pager 2>&1 | sed 's/^/ /' >&2 || true + else + echo >&2 + echo " --- diagnostics: agentkeys-mcp-server.service didn't become active in 15s ---" >&2 + echo " systemctl status:" >&2 + sudo systemctl status agentkeys-mcp-server.service --no-pager --lines=0 2>&1 | sed 's/^/ /' >&2 || true + echo " last 30 journal lines:" >&2 + sudo journalctl -u agentkeys-mcp-server.service -n 30 --no-pager 2>&1 | sed 's/^/ /' >&2 || true + echo " env file (${ENV_FILE}):" >&2 + sudo cat "$ENV_FILE" 2>&1 | sed 's/^/ /' >&2 || true + echo " --- end diagnostics ---" >&2 + echo >&2 + fail "agentkeys-mcp-server didn't start (see diagnostics above)" + fi +fi + +echo +head "ready" +if [ "$MODE" = "xiaozhi" ]; then + echo " MODE: xiaozhi (xiaozhi.me hosts the MCP-endpoint relay)" >&2 + echo " Endpoint (this MCP server connects out to):" >&2 + echo " ${XIAOZHI_ENDPOINT}" >&2 + echo >&2 + echo " Endpoint persisted at ${XIAOZHI_ENDPOINT_FILE} (0600)." >&2 + echo " Re-runs preserve it; pass --xiaozhi-endpoint to update." >&2 + echo >&2 + echo " Refresh 智控台 → 智能体 → MCP接入点 — status should flip from" >&2 + echo " '未连接' to '已连接' within ~5 seconds." >&2 +else + echo " MODE: self-hosted (mcp-endpoint-server running locally)" >&2 + echo " Tool URL (this MCP server connects here):" >&2 + echo " wss://${DOMAIN}/mcp_endpoint/mcp/?token=${TOKEN}" >&2 + echo " Client URL (xiaozhi cloud / xiaozhi-server connects here):" >&2 + echo " wss://${DOMAIN}/mcp_endpoint/call/?token=${TOKEN}" >&2 + echo " Health URL (智控台 health probe):" >&2 + echo " https://${DOMAIN}/mcp_endpoint/health?key=${HEALTH_KEY}" >&2 + echo >&2 + echo " Token + key persisted under ${ENV_FILE_DIR}/ (0600). Re-running this" >&2 + echo " script never regenerates them — URLs stay stable across deploys." >&2 + echo " Paste the client URL into 智控台 → 智能体 → MCP接入点." >&2 +fi