Skip to content

m1: AgentKeys MCP server — Phase 1 (closes #107)#132

Open
hanwencheng wants to merge 13 commits into
mainfrom
claude/optimistic-elgamal-a1d16d
Open

m1: AgentKeys MCP server — Phase 1 (closes #107)#132
hanwencheng wants to merge 13 commits into
mainfrom
claude/optimistic-elgamal-a1d16d

Conversation

@hanwencheng
Copy link
Copy Markdown
Member

Summary

Resolves #107 — the Phase 1 MCP server that turns the Phase 0 backend (broker + memory/audit/cred workers + signer) into 10 tools that an MCP-speaking LLM host (xiaozhi-server, Volcano Ark) can call.

New crate: crates/agentkeys-mcp-server/. Additive — no existing crate touched besides adding the workspace member.

This PR is parallel to #131, which bundles #107 + #108/#109/#111 across 429 files. This one stays scoped to #107 alone (32 files, +3187 LOC), so it's reviewable in isolation. PR #131 stays open as the alternative if reviewers prefer the bundled approach.

Why Rust, not Python

Issue #107 lists Python as preferred, Rust as fallback. We went Rust because:

  • Every other service in the workspace is Rust — single toolchain, one CI, one Docker pattern.
  • The broker / worker DTOs come from agentkeys-core. Re-declaring as Pydantic models = drift.
  • MCP is a wire protocol; xiaozhi-server doesn't import the server's source. The "Python integrates faster with xiaozhi" claim is weak across a process boundary.
  • Phase 0's crates/agentkeys-mcp/ already proved Rust → xiaozhi-server works.

What landed

Tools (10 total)

Tool Status Backend
agentkeys.identity.whoami active local (M4 lifts to broker /v1/identity/whoami)
agentkeys.memory.get active broker /v1/cap/memory-get → memory worker
agentkeys.memory.put active broker /v1/cap/memory-put → memory worker
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,revoke} + agentkeys.approval.request schema-only {\"error\":\"not_implemented_in_v1\",\"scheduled_for\":\"M4\",\"spec_url\":...}

Auth (HTTP transport)

  • Authorization: Bearer <vendor> → 401 on missing / wrong / malformed
  • X-AgentKeys-Actor: <omni> → 403 on missing
  • Tool param naming a different actor than the header → JSON-RPC -32003 (FORBIDDEN)

Stdio transport bypasses auth (parent process is implicitly trusted).

Acceptance criteria

# Criterion Status Evidence
1 7 active tools respond correctly ✅ wired; live demo operator-driven tests/three_acts.rs, tests/http_auth.rs
2 3 schema-only tools return documented shape tests/schema_only_stubs.rs (3 tests)
3 Bearer + actor scoping (401/403/403) tests/http_auth.rs (6 tests)
4 Unit tests per tool happy + 1 negative 17 unit tests + tool-specific
5 Integration test exercising three-act storyboard tests/three_acts.rs (5 tests)
6 CI publishes image; one-command deploy .github/workflows/mcp-server.yml + Dockerfile
7 Live xiaozhi-server demo ⏳ operator-driven runbook in plan §5

Tests

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  +  clippy clean

Live binary smoke test (built + run, verified all four auth paths and Act 2 verdict):

--- healthz --- {\"name\":\"agentkeys-mcp-server\",\"ok\":true}
--- missing bearer --- 401
--- bad bearer --- 401
--- happy path --- tools_count: 10
--- act 2 ---
  \"verdict\": \"deny\"
  \"reason\": \"daily_spend_cap_exceeded\"
  \"explanation\": \"cap=500, requested=600, period=daily\"

What did NOT land (deferred per spec)

Full landed-vs-deferred table: docs/spec/plans/issue-107-mcp-server-phase1.md.

Test plan

  • cargo test -p agentkeys-mcp-server — 31/31 green
  • cargo clippy -p agentkeys-mcp-server --all-targets -- -D warnings — clean
  • cargo build --workspace — clean (no regressions)
  • Binary smoke test — 4 auth paths + Act 2 verdict verified
  • Live three-act demo against a fresh broker — operator-driven
  • xiaozhi-server mcp_server_settings.json integration dry-run with an internal reviewer

Adds `crates/agentkeys-mcp-server/` — the integration surface that makes
the Phase 0 backend (broker, memory worker, audit worker, signer)
legible to MCP-speaking LLM hosts (xiaozhi-server, Volcano Ark).

Tools (10 total):
- 7 active: identity.whoami, memory.{get,put}, permission.check,
  cap.{mint,revoke}, audit.append
- 3 schema-only: delegation.{grant,revoke}, approval.request — return
  `{"error":"not_implemented_in_v1","scheduled_for":"M4","spec_url":...}`

Auth (HTTP transport): `Authorization: Bearer <vendor>` + per-call
`X-AgentKeys-Actor: <omni>`. Wrong token → 401, missing actor → 403,
cross-actor param → -32003.

Policy engine for permission.check is deterministic — pure function,
no LLM, no I/O. Locks in the storyboard's Act 2 wording (`cap=500,
requested=600, period=daily`).

Tests: 31/31 green (17 unit + 6 HTTP auth + 3 stub shape + 5 three-act).
Clippy clean. Live binary smoke-tested.

Plan + landed-vs-deferred table:
docs/spec/plans/issue-107-mcp-server-phase1.md
Adds a second backend mode so the three-act demo runs on a fresh
laptop with no broker, no workers, no hardware, no LLM key:

  cargo run -p agentkeys-mcp-server -- --backend in-memory

The InMemoryBackend lives in src/backend/in_memory.rs and seeds the
storyboard fixtures from agent-iam-strategy.md §4.3 (Chengdu trip in
travel ns, bday note in family ns, allergy note in profile ns).

Auto-seeds a default vendor token magiclick:demo-tok in dev mode so
the runbook stays one-command.

Two new docs / scripts:
- docs/spec/plans/issue-107-mcp-demo-runbook.md — two modes:
    A. dev / fresh-laptop (full curl walkthrough, validated)
    B. full xiaozhi-server + MagicLick (operator-driven draft —
       clone, env vars, broker URLs, vendor token mint, MagicLick
       firmware flash, LLM provider key, the three acts on hardware)
- scripts/mcp-demo-mode-a.sh — automated assertions for every claim
  in §A; regression check so the runbook can't drift from reality.

Tests + clippy still green (31/31 + clean).
…mcp SDK

Adds scripts/mcp-demo-mode-b-protocol.sh — drives our server end-to-end
with the same Anthropic `mcp` Python SDK xiaozhi-server imports
(confirmed by reading xinnan-tech/xiaozhi-esp32-server@7f73dae —
main/xiaozhi-server/core/providers/tools/server_mcp/mcp_client.py).

Assertions (all green):
  ✓ initialize handshake → name=agentkeys-mcp-server v0.1.0
  ✓ tools/list → all 10 expected tools
  ✓ Act 2 — deterministic deny, storyboard wording verbatim
  ✓ Act 1 — memory.get(travel) returns Chengdu fixture
  ✓ Act 3a — cap.revoke records in-memory (M1 stub)
  ✓ Act 3b — audit.append returns envelope_hash
  ✓ schema-only stub → MCP error: not_implemented_in_v1

This closes the protocol-layer half of the goal: when this script
passes, xiaozhi-server's MCP client works. Remaining gaps are LLM
tool-choice, MagicLick hardware, and live broker deploy — all
outside the MCP server boundary.

Doc fixes from reading xiaozhi-server source at 7f73dae:
- Config path is `data/.mcp_server_settings.json` (leading dot + data/
  prefix) — the root-level file is template-only and not read at
  runtime. Both README and runbook §B.6 had it wrong.
- `"transport": "streamable-http"` is REQUIRED. Default is SSE
  (sse_client) — our server is POST/JSON, not SSE.
- Headers under `headers` round-trip unchanged through mcp_client.py
  → X-AgentKeys-Actor + X-AgentKeys-Session-Bearer work.

Runbook §B.11 now splits "verified — automatable" from "operator-
driven — needs hardware/account/deploy", instead of one undifferenti-
ated "known gaps" list.
…lient)

Adds scripts/mcp-demo-mode-c-xiaozhi-client.sh — loads xiaozhi-server's
*own* ServerMCPClient class from upstream source (xinnan-tech/
xiaozhi-esp32-server@7f73dae) and drives our MCP server through every
act of the storyboard via their actual production integration code,
not just the underlying SDK.

Bundles a deterministic fake-LLM that issues storyboard-expected
tool calls so the full xiaozhi-server-LLM → ServerMCPClient → /mcp
→ tools loop is asserted without needing Ollama, Doubao, Qwen, or
MagicLick hardware.

Mode C output (all green):
  ✓ ServerMCPClient.initialize() succeeded
  ✓ ServerMCPClient sees 10 tools
  ✓ has_tool() lookups match for every active tool
  ✓ Act 1: agentkeys.memory.get(travel) → Chengdu fixture
  ✓ Act 2: agentkeys.permission.check(payment.spend, 600) → deny
  ✓ Act 3a: agentkeys.cap.revoke → in-memory record
  ✓ Act 3b: agentkeys.audit.append → envelope_hash
  ✓ ServerMCPClient.cleanup() clean

Three layered pre-flight scripts now cover everything reachable from
inside the repo:
  - mode A: curl   (13 assertions)
  - mode B: raw Anthropic SDK (7 assertions)
  - mode C: xiaozhi-server's ServerMCPClient (12 assertions)

What remains outside the repo (and outside the MCP server contract):
LLM model tool-choice, MagicLick audio I/O, live broker deploy. All
documented in runbook §B with explicit "verified vs operator-driven"
decomposition.
Codex challenge mode found 9 findings (3 P1, 5 P2, 1 P3). This commit
addresses the P1s and most P2s. Remaining items called out as follow-ups.

P1 fixes:

1. Hex32 fixture IDs (was: O_kevin_001 / O_kevin_op / 0xdeadbeef).
   The real broker validates `0x[0-9a-f]{64}`; old fixtures would have
   been rejected the moment we switched off in-memory. New fixtures
   are wire-compatible with the real broker/audit/memory worker.
     - in_memory.rs `DEMO_ACTOR` / `DEMO_OPERATOR` / `DEMO_DEVICE_KEY_HASH`
     - mode-A/B/C smoke scripts updated
     - runbook §A updated

2. Honest namespace claim — the M1 memory worker does NOT enforce
   namespace cryptographically (the wire field flows through but
   `s3_key` derives from `(actor, service)` only). Runbook now states
   this plainly and links to the M4 follow-up against #108.

3. Act 3 proves revocation isn't a rubber-stamp:
     - InMemoryBackend tracks minted cap nonces.
     - `cap.revoke(known_nonce)` → ok.
     - `cap.revoke(unknown_id)` → 404 error.
     - Mode A/B/C smoke scripts now mint a cap, revoke by its nonce,
       AND assert that an unknown cap_id is rejected.

P2 fixes:

4. Port allocated via ephemeral socket pick (Python `socket`/Ruby
   fallback). Kill -0 liveness check after spawn so a stale server
   on a fixed port can't masquerade.

5. JSON-RPC responses parsed via jq or python3 with explicit
   assertions on `result.isError == false`, `result.structuredContent.*`,
   `error.code`, `error.data.scheduled_for`. No more substring-grep
   masking tool isError.

6. `audit_append` envelope_hash is now a real SHA-256 over a
   deterministic input preimage. Two appends with different content
   produce different hashes; smoke script asserts this.

7. `jq` listed as accepted alternative; smoke script auto-detects
   jq vs python3. Runbook Act 3 shows both `jq` and `python3`
   one-liners side by side.

8. `cargo run` (not hardcoded target/debug/$BIN) so CARGO_TARGET_DIR
   layouts in CI don't desync from the spawn path.

P3 noted as follow-up:

9. Multi-vendor tenancy semantics — memory keyed by `(actor, namespace)`
   so two vendors using the same actor see each other's data. Out of
   M1 scope; vendor isolation lands with the vendor portal in M2 (#114).

CI integration:

   `.github/workflows/mcp-server.yml` runs `bash scripts/mcp-demo-mode-a.sh`
   as a third step in the test job (after cargo test + clippy). This
   IS the one-line CI smoke the user asked for — same command an
   operator runs locally.

All three modes still green:
  mode A: 19 hardened assertions
  mode B: 7 protocol assertions (real Anthropic mcp SDK)
  mode C: 12 assertions (xiaozhi-server's own ServerMCPClient class)

Plus 31 cargo tests + clippy clean.
Adds a fourth transport mode: --transport mcp-endpoint --mcp-endpoint <ws-url>.
The MCP server connects OUT to a xiaozhi-style mcp-endpoint-server relay
as a WebSocket client. The relay forwards MCP JSON-RPC frames between us
(the tool) and the xiaozhi cloud (the client). No HTTP listen socket; no
per-vendor bearer (relay URL token is the binding); reconnect with
exponential backoff 1s..600s (matches xiaozhi's mcp_pipe.py).

This is the real production integration path for xiaozhi. With this in
place, the original goal's "MagicLick firmware flash" + "LLM provider
key" steps are unnecessary:

- No firmware flash. The xiaozhi cloud already talks to xiaozhi devices
  in the wild. We register our MCP server with the cloud via the relay
  URL; any existing device sees our tools.
- No LLM key. The xiaozhi cloud hosts the LLM. We provide tools; the
  cloud LLM decides when to call them.

Why no Docker for the deploy:

  The broker is already on EC2 with systemd + nginx + TLS via
  scripts/setup-broker-host.sh. Adding Docker would be new operational
  surface for no benefit. Both mcp-endpoint-server and the MCP server
  run as systemd units next to the broker. Native Python + native Rust
  binary. The existing host runbook is the template.

New script:

  scripts/mcp-demo-mode-d-xiaozhi-endpoint.sh — spins up a mock
  mcp-endpoint-server that mirrors the real routing exactly:
    /mcp_endpoint/mcp/?token=X   — tool path (our MCP server connects here)
    /mcp_endpoint/call/?token=X  — client path (xiaozhi cloud connects here)
  Same token pairs tool ↔ client; forwards frames verbatim. Then a
  fake-client side drives initialize → tools/list → all three acts.
  All 7 assertions pass.

Runbook §B fully rewritten:

  - Old §B was a draft for the hardware-flash path. Removed.
  - New §B is the relay path: EC2 systemd deploy of mcp-endpoint-server
    + agentkeys-mcp-server, register URL with xiaozhi.me agent, run
    voice prompts on any existing xiaozhi device, no MagicLick toy,
    no Doubao/Qwen key.
  - §B.11 explicitly: verified-no-resources-needed (mode A/B/C/D run
    in CI) vs operator-driven (live broker deploy, xiaozhi account,
    paired device).

Other updates:

  - README §"xiaozhi MCP-endpoint relay" added.
  - .github/workflows/mcp-server.yml trigger now includes mode-D path.
  - Plan §6 references mode D + the relay deploy.

All four modes green:
  mode A (curl + in-memory):                19/19
  mode B (Anthropic mcp SDK):                7/7
  mode C (xiaozhi ServerMCPClient):         12/12
  mode D (xiaozhi-style WS relay):           7/7
  cargo test -p agentkeys-mcp-server:       31/31
  cargo clippy --all-targets -- -D warnings: clean
… all 4 modes

Answers two operator questions:

Q1: How do I run mode A?
  Added §A "TL;DR — one line, end to end" at the top of the runbook:
    `bash scripts/mcp-demo-mode-a.sh`
  Same one-liner CI runs. 19 assertions, exits non-zero on any failure.

Q2: What's the correct way to test §B?
  Added §B.0 "How to test §B — four tiers from no resources to live cloud".
  A ladder of verification: tiers 1-4 are CI-able with progressively more
  realistic substrates (curl → Anthropic mcp SDK → xiaozhi ServerMCPClient
  → xiaozhi-style WS relay). Tier 5 is the live deploy and needs real
  resources. Each tier catches a class of bugs the cheaper tier can't.

CI workflow now runs all four tiers as separate steps so PRs get
end-to-end protocol coverage automatically:

  - cargo test  (31 unit + integration)
  - cargo clippy --all-targets -- -D warnings
  - mode A  (curl + in-memory)                — 19 assertions
  - mode B  (Anthropic mcp SDK)               —  7 assertions
  - mode C  (xiaozhi ServerMCPClient)         — 12 assertions
  - mode D  (xiaozhi-style WS relay)          —  7 assertions

uv installed via the official curl-piped installer for modes B/C/D.
Path filter expanded to trigger the workflow on changes to any of
the four smoke scripts.
…sses)

Add a callout in §B.4 of the demo runbook so the next operator knows
that `AGENTKEYS_CHAIN=heima bash scripts/verify-heima-contracts.sh`
verifies the live v2 stage-1 contracts on Heima mainnet (the
addresses in docs/spec/deployed-contracts.md), not a separate test
set. Demo isolation in this stack is per-actor (operator/actor/device
omni triples + on-chain device binding), not per-contract.

Operators who want to verify against an off-prod env file (staging
operator-workstation, custom deploy) set ENV_FILE=/path/to/x.env
before the command — the script already supports that override per
scripts/verify-heima-contracts.sh:24-27, and setup-heima.sh --test
exports it automatically for its test path.
…--check`

The repo-wide CI (`.github/workflows/ci.yml` → `cargo fmt + clippy + test`)
runs `cargo fmt --all -- --check` and was failing on the new files added
to crates/agentkeys-mcp-server/. `cargo fmt -p agentkeys-mcp-server` brings
every file in line with the project's rustfmt config — no logic changes,
purely whitespace + line-wrap.

Verified:
  cargo fmt --all -- --check       clean
  cargo clippy --all-targets       clean
  cargo test                        31/31
  mode A (curl smoke)               ALL PASSED
  mode B (Anthropic mcp SDK)        ALL PASSED
  mode C (xiaozhi ServerMCPClient)  ALL PASSED
  mode D (xiaozhi WS relay)         ALL PASSED
Adds scripts/setup-mcp-host.sh — one-command idempotent install of the
relay + MCP server on the broker host, with TLS-terminating nginx vhost
for mcp.litentry.org and wss → ws upgrade. Replaces the inline heredoc
recipes that were in runbook §B.5 + §B.6.

Per CLAUDE.md "Idempotent remote-setup rule" — every step:
  - Pre-checks state (sha256 / cmp / certbot live cert / systemctl get)
  - Emits one of: ok proceeding / skip <reason> / fail <reason>
  - Short-circuits when already done (second run = exit 0 with no mutations)

What the script lands, all idempotent:
  /opt/agentkeys/mcp-endpoint/src         pinned clone (default `main`,
                                          --relay-ref pins a sha)
  /opt/agentkeys/mcp-endpoint/src/.venv   Python venv + requirements
  /usr/local/bin/agentkeys-mcp-server     release binary (built via
                                          cargo, installed only on
                                          sha256 drift)
  /etc/agentkeys/mcp-tool-token           32-byte url-safe random; first
                                          run only, preserved on re-runs
                                          so URLs stay stable
  /etc/agentkeys/mcp-health-key           same shape for 智控台 health
  /etc/agentkeys/mcp.env                  0600; backed by the two
                                          secrets above; rewritten only
                                          on content drift
  /etc/systemd/system/mcp-endpoint-server.service
  /etc/systemd/system/agentkeys-mcp-server.service
                                          diff vs target; daemon-reload
                                          + restart ONLY on drift
  /etc/nginx/sites-available/mcp.litentry.org
                                          TLS-terminating vhost with
                                          wss → ws upgrade for both
                                          /mcp_endpoint/mcp/ (tool side)
                                          and /mcp_endpoint/call/
                                          (xiaozhi side). map+Upgrade+
                                          Connection headers per the
                                          WebSocket spec.
  Let's Encrypt cert via `certbot --nginx -d mcp.litentry.org`
                                          first run only; reused on
                                          subsequent runs

Output URLs at the end of every run:
  wss://mcp.litentry.org/mcp_endpoint/mcp/?token=<TOKEN>   (tool side)
  wss://mcp.litentry.org/mcp_endpoint/call/?token=<TOKEN>  (xiaozhi side)
  https://mcp.litentry.org/mcp_endpoint/health?key=<KEY>   (智控台)

Runbook §B.5 + §B.6 collapsed into a single "one command" section
that just invokes the script. §B.7 updated to paste the wss + health
URLs straight into 智控台.

Follow-up still tracked in §B.11: fold this script into
setup-broker-host.sh as `--with-mcp` so the broker bring-up is one
command end-to-end.

Verified:
  bash -n scripts/setup-mcp-host.sh    # syntax OK
…pattern

The previous --certbot-email default in the runbook used a fabricated
ops@litentry.org address that doesn't exist in this repo. The honest
fix: don't require an email at all on first run. Match the existing
setup-broker-host.sh pattern.

The script now tries three behaviors in order:
  1. Existing ACME account on host (most common — setup-broker-host.sh
     registers one when it issues broker.litentry.org). Reuse silently,
     no email flag needed.
  2. --certbot-email <addr> explicitly passed. Used as the account
     email for a new ACME registration.
  3. Fall through to --register-unsafely-without-email. Cert still
     issues. Operator can re-run later with --certbot-email to attach
     a recovery address.

Runbook B.5 updated to drop the fabricated email + explain the three
behaviors. Suggests team aliases like agentkeys@litentry.org or
infra@litentry.org IF Litentry has those, with the honest note that
this script does not know which addresses are real.
Two related fixes for a first-run failure mode on fresh Ubuntu hosts:

1. Add a "0/9 distro packages" step that idempotently apt-installs
   python3-venv + python3-pip + git (+ nginx + certbot when those
   layers are enabled). dpkg -s per package so the script's
   ok/skip/fail output stays honest. dnf branch for RHEL-family
   hosts. Mirrors setup-broker-host.sh's pattern.

2. Treat .venv as "healthy" only when .venv/bin/python3 exists AND
   runs `pass` successfully. If a prior run failed mid-create
   (typical when python3-venv was missing), the .venv directory
   exists but is broken; the new logic detects this, removes it,
   and recreates. Without the fix, the script's "already exists"
   skip would lock the host in the broken state across re-runs.

Reproduces the user's reported error:
    'The virtual environment was not created successfully because
     ensurepip is not available. On Debian/Ubuntu systems, you need
     to install the python3-venv package...'
Now: step 0 installs python3-venv on first run; step 3 detects the
broken half-venv from the previous failed attempt and recreates.
Two real bugs the user hit on the broker host:

  ==> 8/9 certbot certificate for mcp.litentry.org
      ok proceeding — reusing existing ACME account on host
  Saving debug log to /var/log/letsencrypt/letsencrypt.log
  Error while running nginx -c /etc/nginx/nginx.conf -t.

  [warn] protocol options redefined for 0.0.0.0:443 in
    /etc/nginx/sites-enabled/mcp.litentry.org:19
  [emerg] unknown directive "http2" in
    /etc/nginx/sites-enabled/mcp.litentry.org:21
  nginx: configuration file /etc/nginx/nginx.conf test failed
  The nginx plugin is not working; there may be problems with your
  existing configuration.

Root causes:

1. `http2 on;` directive only lands in nginx 1.25.1+ (July 2023).
   The broker host's nginx is older (1.18 / 1.22 era). Old syntax
   `listen 443 ssl http2;` works on both — switched to that.

2. The vhost referenced /etc/letsencrypt/live/<domain>/fullchain.pem
   before the cert existed → certbot's `nginx -t` preflight failed
   → certbot refused to issue → chicken-and-egg.

Fix: two-phase nginx config (same pattern setup-broker-host.sh uses):

  Phase A (cert missing):  :80-only vhost with ACME challenge
                           location at /.well-known/acme-challenge/
                           and a 503 default. nginx loads cleanly
                           with no cert paths.

  Phase B (cert present):  full :80 → :443 redirect + TLS-terminating
                           :443 ssl http2 vhost with the wss → ws
                           upgrade proxy. Cert paths are now valid.

Single-run flow takes the host from zero → cert → phase B without
operator intervention:
  1. Step 7 writes phase A and reloads nginx mid-step (so the ACME
     http-01 challenge has a live listener to talk to).
  2. Step 8 switches certbot from `--nginx` plugin to webroot mode
     (`certbot certonly --webroot -w /var/www/html`) — issues the
     cert without trying to mutate the vhost we just wrote.
  3. Step 8 then rewrites the vhost to phase B inline.
  4. Step 9 reloads nginx with phase B → TLS is live.

Re-runs after the cert is issued skip steps 8's issuance entirely
(idempotency check stays — `test -f /etc/letsencrypt/live/.../fullchain.pem`).

Also: pre-create /var/www/html so certbot --webroot doesn't fail on
missing dir.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Phase 1: AgentKeys MCP server — 7 active tools + 3 schema-only

1 participant