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.after_sequencecontract (runtime v0.5.0):after_sequenceis the 1-based accepted-envelope ordinal and is exclusive — the runtime replays accepted history strictly after it, then switches to live broadcast.0(the default) replays from the start. The ordinal is now stable across compaction (it no longer shifts). Resuming below the compacted base returnsFAILED_PRECONDITION("session history before ordinal {base} was compacted; resume with after_sequence >= {base} or re-read state via GetSession"); the control-plane handles this by degrading to aGetSessionpoll and emitting asession.stream.gapcontrol event (seeSTREAM_RESUME_ENABLED). Do not pass the CP-side canonical event sequence (lastStreamCursor) here — it is a different counter; use the persisted envelope ordinal.- Lag termination: a
StreamSession(likeWatchSignals/WatchSessions) terminates withRESOURCE_EXHAUSTEDif the receiver falls too far behind. The consumer treats this like any other stream error and reconnects/resubscribes.
See macp-runtime/docs/sdk-guide#streaming and macp-runtime/docs/API#message-transport for the canonical stream lifecycle.
-
watchSessions()— returns anAsyncIterable<SessionLifecycleEvent>forcreated/resolved/expiredevents. BacksSessionDiscoveryService. Canonical RPC: macp-runtime/docs/API#session-lifecycle; SDK-side discovery patterns: macp-sdk-python/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 macp-runtime/docs/API#streaming-watches. Auth (runtime v0.5.0):WatchSignalsnow requires authentication like every other RPC — an unauthenticated (or dev-header-only) call is rejected, so ensure a bearer/JWT (or the dev-bearer fallback) is configured orSignalConsumerServicewill hot-loop reconnects. -
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: macp-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 —
../../macp-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 bearer (deprecated, local only) | RUNTIME_USE_DEV_HEADER=true | RUNTIME_DEV_AGENT_ID (control-plane) sent as Authorization: Bearer <id> — runtime v0.5.0 removed the x-macp-agent-id header |
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 macp-runtime/docs/getting-started#authentication and macp-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:integrationThe integration suites cover the full run lifecycle plus suspend/resume, webhook
delivery (HMAC + retry against a local receiver), replay, retention purge,
stream-gap recovery, batch operations, and SSE resume. Deterministic mock
scripting lives in test/helpers/scripted-mock-runtime.provider.ts — a
RuntimeScript is a list of { delayMs, event } steps (and { error: { code } }
steps to simulate stream failures like a compacted-history FAILED_PRECONDITION);
test/helpers/webhook-receiver.ts is a throwaway HTTP server for asserting
webhook payloads and signatures.
See 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 macp-playground repo and run against the runtime
directly via macp-sdk-python — see macp-playground/README.md.
Environment Variables
See .env.example for all configurable variables with descriptions and defaults.