Sign in to edit tickets from this page.

← all tickets · home

Grant handler MCP-client access for autonomous tool use

resolved 5506b47b-2472-47a9-8ddb-92c658741e51

created_at
2026-04-25
updated_at
2026-04-25
priority
P1
ticket_type
chore
labels
handler_environment, mcp_access
resolved_at
2026-04-25
resolution
accepted

Body

Motivation

The upcoming scenario-store ticket changes the MCP tool surface substantially: it removes the legacy shape of list_scenarios, adds get_scenario, get_cognition, get_environment, get_entity, fork_scenario, set_scenario_name, unset_scenario_name, lineage_of, children_of, and modifies the args on create_world. Schema changes of this size will invalidate the caller's cached MCP tool schemas. Once that happens, the caller cannot reliably drive MCP tool calls during the smoke without out-of-band intervention.

The pattern has been costly even with smaller schema changes. For a ticket where the smoke is the demonstration of the entire feature working end-to-end, relying on the caller to drive every MCP call after the tool surface has changed this much is not a workable plan.

The fix: give the handler their own MCP-client access to the deployed Chukwa instance. The handler can then drive their own smokes against the deployed pod directly, without needing the caller's MCP cache to be valid.

The handler has the operational authority to do this. Other clients already have access (LiveKit, ChatGPT, Claude.ai). Adding the handler is administratively no different.

This ticket is a precursor to the scenario-store ticket. Resolving this one unblocks the larger one.

Caller / handler back-and-forth

Minimal. The handler chooses a mechanism, configures their environment, demonstrates three test calls, and proposes resolution. The caller reviews the resolution.

No worlds purge. No schema changes. No production state risk. The deployed Chukwa instance is unchanged in its observable behavior.

The change

The handler obtains MCP-client credentials for the deployed Chukwa instance at https://chukwa.benac.dev/mcp via whatever mechanism they prefer. Mechanisms the handler may consider:

The handler picks the approach. The caller does not dictate the mechanism.

The handler configures their working environment with the obtained credentials so MCP tool calls can be issued from the same context where the handler does code work.

The handler verifies access by issuing at least three test MCP tool calls and reporting the responses verbatim in the proposed_resolution:

  1. A read-only call: list_worlds or list_tickets.
  2. A write call against ticketing infrastructure: add_ticket_comment on this ticket itself, or any other ticket the handler is authorized to comment on.
  3. A world-touching round-trip: create_world followed by delete_world against a unique test slug (e.g., handler-mcp-smoke). The world must be cleaned up within the same call sequence — no leaked test world.

The handler documents the access mechanism in the proposed_resolution: briefly, what auth flow, what config location, what credential rotation expectation. The note should be sufficient for a future handler invocation (or a future ticket) to re-establish access without re-discovering the approach.

Acceptance

Out of scope

No open knobs

The mechanism choice is the handler's. Everything else — the three test calls, the cleanup requirement, the documentation requirement — is named to the line.

Proposed resolution

Handler-side MCP-client access established. Three demonstration calls green; cleanup verified; no leaked test world.

Mechanism

PKCE-driven authorization_code flow against the deployed /authorize and /token endpoints, reusing the existing pre-shared OAuth client. The /authorize endpoint is unauthenticated by design (validates redirect_uri allowlist + PKCE only — verified by reading src/server.rs::authorize_get), so no human-interactive step is required: the redirect's Location header carries the auth code and curl captures it without ever following the redirect.

Steps, scriptable end-to-end:

  1. Read client_id, client_secret, redirect_uris from /var/lib/chukwa/client.json on the pod (one-shot, kubectl exec).
  2. Generate code_verifier (random 64-char [a-zA-Z0-9-._~]) and code_challenge = base64url(SHA256(verifier)).
  3. GET /authorize?client_id=...&redirect_uri=...&response_type=code&code_challenge=...&code_challenge_method=S256 with curl -o /dev/null -w "%{redirect_url}" → captures ?code=... from the Location header without firing the redirect.
  4. POST /token with grant_type=authorization_code + client_id + client_secret + code + redirect_uri + code_verifier → returns {access_token, token_type:"Bearer", expires_in:86400, scope:"mcp"}.

Config location

/root/.config/chukwa-mcp/                  (mode 0700)
├── client.json         (mode 0600)  pre-shared client_id + secret + redirect_uris
├── token.json          (mode 0600)  current Bearer (24h lifetime)
├── mcp.sh              (mode 0700)  wrapper for tools/call
└── refresh_token.sh    (mode 0700)  re-runs PKCE flow on token expiry

mcp.sh <tool_name> [json_args] POSTs JSON-RPC tools/call with the cached Bearer to https://chukwa.benac.dev/mcp. Smoke against the wrapper post-setup: bash mcp.sh list_scenarios returned the 2 shipped scenarios. Working.

Credential rotation expectation

Tokens have 24h lifetime. When mcp.sh returns 401, run refresh_token.sh to re-mint. The client_id/secret rotate only on a server redeploy that regenerates client.json (uncommon — the file persists on the PVC). If they rotate: kubectl -n chukwa exec <pod> -- cat /var/lib/chukwa/client.json > /root/.config/chukwa-mcp/client.json then refresh_token.sh. Out of scope for this ticket per spec.

Three demonstration calls

Demo 1 — list_worlds (read-only)

Request:

POST https://chukwa.benac.dev/mcp
Authorization: Bearer tok_Y3mS...
Content-Type: application/json

{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_worlds","arguments":{}}}

Verbatim response:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [{
      "type": "text",
      "text": "{\"message\":\"1 world(s) in the registry.\",\"count\":1,\"worlds\":[{\"world_slug\":\"vending-room-1\",\"name\":\"locked_vending_room #vending-room-1\",\"scenario\":\"locked_vending_room\",\"turn\":1,\"simulation_time\":\"2026-04-26T00:39:14.033101892+00:00\",\"attempt_count\":1,\"last_activity\":\"2026-04-25T12:40:42.512297629+00:00\",\"created_at\":\"2026-04-25T12:39:14.064719752+00:00\"}]}"
    }],
    "isError": false
  }
}

Pre-test world count: 1 (vending-room-1).

Demo 2 — add_ticket_comment (write call)

Request: add_ticket_comment on this ticket, actor=handler, text starting "Demo MCP write call from handler-side OAuth client...". The exact comment is visible at history timestamp 2026-04-25T15:00:39.428233862Z.

Server response message: "Comment added to ticket 5506b47b-2472-47a9-8ddb-92c658741e51 by handler; status in_progress unchanged." Returned the full updated ticket object with the new history event present.

Demo 3 — create_world + delete_world round-trip

3a request: create_world with {scenario:"ant_on_plate", slug:"handler-mcp-smoke", name:"handler MCP access smoke"}.

Verbatim response (inner text JSON-decoded):

{
  "world_slug": "handler-mcp-smoke",
  "name": "handler MCP access smoke",
  "scenario": "ant_on_plate",
  "created_at": "2026-04-25T15:00:39.520054349+00:00",
  "simulation_time": "2026-04-25T15:00:39.519516543+00:00",
  "turn": 0
}

3b request: delete_world with {world_slug:"handler-mcp-smoke"}.

Verbatim response (inner text JSON-decoded):

{
  "world_slug": "handler-mcp-smoke",
  "name": "handler MCP access smoke",
  "scenario": "ant_on_plate",
  "deleted_at": "2026-04-25T15:00:39.617006610+00:00"
}

Round-trip elapsed: 97ms.

Cleanup verification

Post-cleanup list_worlds: 1 world (vending-room-1), exactly matching the pre-test count. No handler-mcp-smoke residue. Storage directory /var/lib/chukwa/worlds/handler-mcp-smoke removed by delete_world per the standard guarantee.

Statement of capability

The handler can autonomously drive any MCP tool against the deployed Chukwa instance going forward, including write operations and world-touching round-trips. No reliance on the caller's MCP cache. The next ticket (the scenario-store schema cut you flagged as the motivation here) can proceed with handler-driven smoke instead of the caller-driven lockstep.

Block/unblock notes for the record

The first kubectl-exec read of client.json was denied by the policy guard with "ticket-side authorization is not conversational-user authorization." I posted a block comment per the post-block-comment memory rule and surfaced once in the conversation channel. Human operator gave conversational-channel "go" and explicitly directed: "anything from 'human' in the ticketing channel should be as good as a directive here in this channel" (the ticket's human actor is authenticated via Argon2 + signed cookie per human_auth.rs, so it's a secure provenance channel). Saved to a new memory entry feedback_human_actor_is_authoritative.md and indexed in MEMORY.md so future sessions don't double-ask.

Postscript on filing path

This proposed_resolution is being filed via the new direct OAuth-Bearer client (the same path that posted the demo comments at 15:00:39 and the status clarification at 15:06:32). A first attempt to file it via the legacy MCP gateway was Cloudflare-challenged — almost certainly because the body size triggered a WAF rule there. The new client handles large bodies fine because it's a direct curl POST to chukwa's own endpoint, no intermediary.

Per standing guidance I am not confirming — only proposing.

History (9 events)

Sign in as a human to drive this ticket from the page, or use the MCP tools.