MACP Control Plane — Integration Guide
Adding a Runtime Provider
- Implement the
RuntimeProviderinterface fromsrc/contracts/runtime.ts - Register it as a NestJS provider in
app.module.ts - Add it to
RuntimeProviderRegistryso it can be looked up bykind
Key methods to implement (observer-only surface, post direct-agent-auth):
initialize()— protocol version negotiation.subscribeSession({runId, runtimeSessionId, afterSequence?})— read-onlyStreamSessionobserver; returns{events, abort}. Never writes envelopes. Per RFC-MACP-0006 §3.2 the provider writes exactly one passive-subscribe frame ({subscribeSessionId, afterSequence}) and keeps the write side open for the session's lifetime. Half-closing would signal "client is done" and cause the runtime to drop every envelope broadcast afterwards. The runtime replays accepted history fromafterSequence(default 0 = full replay) then switches to live broadcast. See runtime/docs/sdk-guide#streaming and runtime/docs/API#message-transport for the canonical stream lifecycle.watchSessions()— returns anAsyncIterable<SessionLifecycleEvent>forcreated/resolved/expiredevents. BacksSessionDiscoveryService. Canonical RPC: runtime/docs/API#session-lifecycle; SDK-side discovery patterns: python-sdk/docs/guides/session-discovery.md.watchSignals()— returns anAsyncIterable<RawRuntimeEvent>of ambient Signal/Progress envelopes off the runtime'ssignal_bus. BacksSignalConsumerService— token-usage signals (llm.call.completed) arrive here, not on per-session streams. See runtime/docs/API#streaming-watches.getSession()— poll for session state (used by the observer'spollForOpenSessionloop).cancelSession()— only called whenrun.metadata.cancellationDelegated === true(Option B in direct-agent-auth §Cancellation design).getManifest()/listModes()/listRoots()/health()— metadata.registerPolicy()/unregisterPolicy()/getPolicy()/listPolicies()— governance. Rule schemas and evaluation semantics: runtime/docs/policy.md (RFC-MACP-0012).
Agents emit envelopes directly
Agents authenticate to the runtime with their own Bearer tokens (RFC-MACP-0004 §4) and emit envelopes via macp-sdk-python / macp-sdk-typescript. The control-plane never brokers agent envelopes — the old HTTP escalation endpoints (POST /runs/:id/{messages,signal,context}) now return 410 Gone.
For the agent-side bootstrap and how sessionId flows from POST /runs to the initiator and non-initiator agents, see:
- Python SDK — guides/direct-agent-auth.md (bootstrap shape, initiator vs non-initiator,
expected_sender, cancellation) and guides/agent-framework.md (from_bootstrapfactory + handler context) - TypeScript SDK — README.md § Agent Framework and docs/guides/agent-framework.md (
fromBootstrap()+ strategies) - Migration —
../../ui-console/plans/direct-agent-auth.md(end-to-end story of the 2026-04-15 refactor)
Authenticating to the runtime
Per-gRPC-call credential resolution uses a three-step fallback chain:
| Mode | Trigger | Control-plane env vars |
|---|---|---|
| JWT mint (preferred) | MACP_AUTH_SERVICE_URL set | MACP_AUTH_SERVICE_URL, MACP_AUTH_SERVICE_TIMEOUT_MS (5000), MACP_AUTH_TOKEN_TTL_SECONDS (3600), MACP_AUTH_TOKEN_SENDER (control-plane) |
| Static Bearer | JWT disabled or mint failed | RUNTIME_BEARER_TOKEN |
| Dev header (local only) | RUNTIME_USE_DEV_HEADER=true | RUNTIME_DEV_AGENT_ID (control-plane) |
Mint behaviour: token cached until expiry minus 30s refresh buffer minus 10s clock-skew, concurrent refreshes deduped, mint failures log auth_mint_failure and fall through to the static Bearer. For the runtime-side token shape (MACP_AUTH_TOKENS_JSON), TLS/mTLS, and the JWT claim expectations, see runtime/docs/getting-started#authentication and runtime/docs/deployment#authentication.
Consuming SSE Streams
# Subscribe to live events (with initial state snapshot)
curl -N -H 'Authorization: Bearer <key>' \
'http://localhost:3001/runs/{id}/stream?includeSnapshot=true'
# Resume from a specific sequence
curl -N -H 'Authorization: Bearer <key>' \
-H 'Last-Event-Id: 42' \
'http://localhost:3001/runs/{id}/stream'SSE event types:
snapshot— fullRunStateProjectionat connection timecanonical_event— individual event (id = sequence number for resume)heartbeat— keep-alive every 15s (configurable)
Using the Replay API
# Create replay descriptor
curl -X POST http://localhost:3001/runs/{id}/replay \
-H 'Content-Type: application/json' \
-d '{"mode": "timed", "speed": 2}'
# Stream replay
curl -N "http://localhost:3001/runs/{id}/replay/stream?mode=timed&speed=2"
# Get state at specific sequence (for timeline scrubber)
curl http://localhost:3001/runs/{id}/replay/state?seq=42Replay modes: timed (proportional timing), step (all at once), instant (no delay).
Adding Coordination Modes
- Add proto definitions under
proto/macp/modes/{mode}/v1/ - Update
MESSAGE_TYPE_MAPinsrc/runtime/proto-registry.service.ts - Update
deriveEventType()insrc/events/event-normalizer.service.tsfor new message types - Add mode to
test/helpers/scripted-mock-runtime.provider.tssupported modes list (integration tests) - Add a projection reducer branch in
src/projection/projection.service.ts— theprojection-coverage.spec.tsinvariant will fail CI otherwise
Webhooks
Register webhooks for run lifecycle events:
# Create webhook
curl -X POST http://localhost:3001/webhooks \
-H 'Content-Type: application/json' \
-d '{ "url": "https://example.com/webhook", "events": ["run.completed"], "secret": "my-hmac-secret" }'
# Update webhook
curl -X PATCH http://localhost:3001/webhooks/{id} \
-H 'Content-Type: application/json' \
-d '{ "active": false }'Webhook deliveries include X-MACP-Signature (HMAC-SHA256) and X-MACP-Event headers.
Running Integration Tests
# Start the test Postgres (port 5433 — separate from the dev DB on 5432)
docker compose -f docker-compose.test.yml up -d postgres-test
# Mock runtime (fast, no external dependencies)
npm run test:integration
# Real Rust runtime (needs runtime on port 50051)
INTEGRATION_RUNTIME=remote RUNTIME_ADDRESS=127.0.0.1:50051 npm run test:integrationSee test/integration/ for the suites and test/helpers/test-app.ts for the NestJS boot
harness. The harness wraps app.close() so every afterAll hook runs
drainBackgroundWork() first — force-terminating in-progress runs, then awaiting
StreamConsumerService, SessionDiscoveryService, and SignalConsumerService drains
before the DB pool closes. Without this, pending persistRawAndCanonical chain entries
would race the pool teardown and surface as "Test suite failed to run" even when every
assertion passed.
Python agent E2E tests live in the examples-service repo and run against the runtime
directly via macp-sdk-python — see examples-service/README.md.
Environment Variables
See .env.example for all configurable variables with descriptions and defaults.