Direct-agent-auth in the macp-playground
This document describes how the macp-playground spawns agents under
RFC-MACP-0004 §3
("the sender field MUST be derived from authenticated identity"): how
scenarios compile, how per-agent JWTs are minted, how runs are registered with
the control-plane, and how ambient envelopes are authorized. It is the
canonical home for AUTH-2 minting, CP-1 behaviour and ambient-envelope scopes;
policy registration is documented in
policy-authoring.md and
the bootstrap file shape in
worker-bootstrap-contract.md.
Agent-side patterns — the initiator / non-initiator code, the
expected_senderguardrail, andsession.cancel()behaviour — are canonically documented in the SDK guides, not here. See:
- Python:
macp-sdk-python/docs/guides/direct-agent-auth.md- TypeScript:
macp-sdk-typescript/docs/guides/authentication.mdandmacp-sdk-typescript/docs/guides/agent-framework.mdFor onboarding an agent of your own (sender ids,
MACP_RUNTIME_TOKEN), seemultiagentcoordinationprotocol/docs/onboarding-an-agent.md.
Why
Before this change, every spawned agent emitted envelopes by POSTing to the
control-plane's /runs/:id/messages route, and the control-plane forged
SessionStart on the agent's behalf. That violates
RFC-MACP-0004 §3–§4
and the "no MACP bypass" rule of
RFC-MACP-0001 §5.3.
The change re-homes envelope emission to agents themselves and narrows the
control-plane to a read-only observer.
Architectural invariants
- Agents authenticate to the runtime directly using a JWT minted per spawn.
- The initiator agent opens the session with its own identity (the SDK emits
SessionStartfrom the bootstrap'sinitiatorblock). - The control-plane is scenario-agnostic — it does not inspect policy hints, kickoff templates, roles, or commitments.
- Control-plane never calls
Send. Observer-only. - session_id is owned by the macp-playground (UUID v4 allocated at compile time).
- Cancellation stays with the initiator — only the session initiator may cancel by default (RFC-MACP-0001 §7.3); see the cancel note under "CP-1 run registration".
- Scenario policies are registered with the runtime at startup by
PolicyRegistrarService, using a separate admin JWT.
For the runtime-side enforcement of invariants 1–4 (authenticated sender
derivation, observer-identity passive-subscribe, policy_version lookup,
rate limits) see
macp-runtime/docs/getting-started.md § Authentication configuration
and
macp-runtime/docs/API.md.
Compile output (twin artifacts)
CompilerService.compile() produces (src/contracts/launch.ts):
interface CompileLaunchResult {
sessionId: string; // UUID v4 — shared by every agent + control-plane
mode: 'live' | 'sandbox';
runDescriptor: RunDescriptor; // generic POST /runs body (no scenario-specific fields)
initiator?: InitiatorPayload; // SessionStart + kickoff for exactly one participant
scenarioMeta: ScenarioMeta; // policyHints, sessionContext, initiatorParticipantId
display: { title: string; scenarioRef: string; templateId?: string; expectedDecisionKinds?: string[] };
participantBindings: ParticipantAgentBinding[];
}
runDescriptor.session intentionally carries no policyHints,
initiatorParticipantId, participant roles or kickoff; those live only on
initiator, on scenarioMeta (internal to this service), and in the per-agent
bootstrap files.
Agent bootstrap schema
The canonical definition lives at src/hosting/contracts/bootstrap.types.ts.
For the field-by-field reference see
docs/worker-bootstrap-contract.md, which
itself defers to the SDK fromBootstrap() docs for the SDK-owned fields.
Summary of the fields the macp-playground is responsible for populating:
runtime_url— gRPC endpoint (fromMACP_RUNTIME_ADDRESS).auth_token— the Bearer JWT minted for this specific agent (always present).secure/allow_insecure— TLS flags (TLS is required by RFC-MACP-0004 §2).initiator—session_start+kickoff(present on exactly one agent's bootstrap).cancel_callback— host/port/path the SDK binds a local cancel listener on.
End-to-end flow
The sequence (compile → concurrent CP-1 submit + agent attach, initiator spawned
first, 502 on an unconfirmed attached agent) is documented once, in
architecture.md § Run Example.
What is specific to direct-agent-auth: each spawn mints its own JWT
(see AUTH-2), the Bearer is baked into that
agent's bootstrap file, and every agent then talks to the runtime over its own
gRPC channel — the control-plane only observes the session (read-only
StreamSession) and never sends on an agent's behalf.
AUTH-2 — on-demand JWT minting
Every agent spawn mints a short-lived RS256 JWT against the standalone
auth-service (POST /tokens; wire format in
macp-auth-service/docs/API.md § POST /tokens).
There is no static-token fallback. MACP_AUTH_SERVICE_URL must be set to boot
(deployment.md); minting happens on the spawn path, so only
/examples/run requests that bootstrap agents depend on the auth-service being reachable.
The runtime's accepted JWT algorithms and resolver configuration are
runtime-owned — see
macp-runtime/docs/getting-started.md § JWT mode.
This stack is RS256 end-to-end.
What the minter sends
POST /tokens
Content-Type: application/json
{
"sender": "<binding.participantId>",
"ttl_seconds": <MACP_AUTH_TOKEN_TTL_SECONDS>,
"scopes": {
"can_start_sessions": <true iff binding is the initiator>,
"is_observer": false,
"allowed_modes": ["<scenario modeName>", ""]
}
}
MACP_AUTH_SCOPES_JSON[sender]is deep-merged on top (usenullto clear a key).- Initiator detection uses
context.initiator?.participantId === binding.participantId. - The trailing empty string in
allowed_modesis load-bearing: it authorizes ambient envelopes (Signal / Progress) whosemodefield is"". See the "Ambient envelopes" section below.
Single-flight cache
AuthTokenMinterService keeps a short-lived in-memory cache keyed by
(sender, scope-hash):
- Concurrent spawns for the same sender coalesce into one HTTP call (
inflightmap). - Cached entries are returned until
expiresAt - 10s(clock-skew buffer). - The cache is not persistent — it exists to amortize launch bursts, not to extend token lifetime.
- Consequence during an auth-service outage: a relaunch of a recently minted
(sender, scopes)pair is served from cache and succeeds, while new participants fail withAUTH_MINT_FAILED— so failures can look intermittent.
Lifecycle constraint — no mid-stream refresh
Both SDKs bind the Bearer token to the gRPC channel once at stream open
and the runtime captures AuthIdentity once per stream (see
macp-runtime/docs/architecture.md § Layers).
There is no refresh callback in either SDK.
Consequences:
MACP_AUTH_TOKEN_TTL_SECONDSmust exceed the agent process's gRPC stream lifetime.- auth-service
MACP_AUTH_MAX_TTL_SECONDScaps the requested TTL — raise both knobs for long-running agents. - A credentials-provider refresh hook (and a matching bootstrap field) is out of scope for AUTH-2.
Observability
- Successful mints log
auth_mint_success sender=<id> expires_in=<s>s. - Failures log
auth_mint_failure sender=<id> reason=...at warn level; the request surfacesAUTH_MINT_FAILED(HTTP 502). - The token body is never logged (enforced by
auth-token-minter.service.spec.ts).
CP-1 run registration
ExampleRunService.run() submits the compiled runDescriptor to the
control-plane's POST /runs (CP-1; wire contract in
macp-control-plane/docs/API.md § POST /runs)
via ControlPlaneRunClient (src/launch/control-plane-run-client.service.ts).
This is orthogonal to, not a replacement for, the direct-agent-auth gRPC
path above: the initiator agent opens the runtime session itself regardless of
whether this call succeeds. Its only purpose is to let the control-plane's
observer stream learn about the run so UI Console projections have something
to subscribe to from the moment the run starts, not after the first envelope
arrives.
Configuration:
MACP_CONTROL_PLANE_URL— control-plane base URL. Unset by default — when empty, submission is skipped entirely (control_plane_submit_skippedlogged atwarn) and/examples/runbehaves exactly as it does with no control-plane at all.MACP_CONTROL_PLANE_TIMEOUT_MS(default5000) — bounds thefetchcall viaAbortSignal.timeout().MACP_CONTROL_PLANE_API_KEY— sent asAuthorization: Bearer <key>when set. Needed because control-plane's globalAuthGuardrequires anAuthorizationheader on every route, includingPOST /runs, even when the control-plane's ownAUTH_API_KEYSis unset (only the token-validity check is skipped in that case).
Best-effort and non-fatal by design: every failure mode (unset URL,
network error, timeout, non-2xx response, malformed JSON, a response missing
runId, status or sessionId, or a sessionId that doesn't match the one submitted — reason=session_id_mismatch) returns null from submitRun() and logs a
warn — ControlPlaneRunClient never throws. In ExampleRunService.run(),
the submission races agent bootstrap via Promise.allSettled; only
hosting.attach()'s own rejection can fail the request, so a control-plane
outage or misconfiguration never blocks or fails the demo — it just means
the run is invisible to the observer stream, which the warn log makes
diagnosable. It can still delay the HTTP response, though: allSettled
awaits both branches, so a slow or unreachable control-plane holds the
/examples/run response open for up to MACP_CONTROL_PLANE_TIMEOUT_MS even
once agent bootstrap has already finished. There is no circuit breaker — a
sustained control-plane outage means every launch pays the full timeout,
not just the first one. On success, the response is surfaced as
controlPlaneRun on the /examples/run result (see
docs/api-reference.md).
Known gap: control-plane-initiated cancel is not wired. The submitted
runDescriptor.session.metadata carries neither cancelCallback nor
cancellationDelegated (both reserved keys per RunDescriptor's docstring),
so a UI-initiated cancel through the control-plane's
POST /runs/:id/cancel
fails closed because neither cancel option is configured. Both SDKs do
bind a listener on the bootstrap's cancel_callback.{host,port,path} (the
TypeScript SDK when participant.run() starts, the Python SDK inside
from_bootstrap()), but that listener is not yet usable as the
control-plane's Option A target:
- With the default
MACP_CANCEL_CALLBACK_PORT_BASE=0the agent binds an ephemeral port that nothing reports back, and the default host127.0.0.1is unreachable from a control-plane in another container. - The SDK listener's handler calls
participant.stop()— it stops the local agent loop; it does not callCancelSessionon the runtime, which is what the control-plane's Option A expects the initiator to do. - The control-plane's Option A also sends an optional bearer secret; the SDK listeners do not check one.
Option B (cancellationDelegated: true, control-plane calls
CancelSession directly with its own runtime identity) would work, but
widens the control-plane's authority over a running session beyond
"observer" — a deliberate trust-boundary decision this repo hasn't made,
not a wiring gap to close casually. Until one of those changes, the only way
a session in this repo reaches a terminal CANCELLED state early is the
in-band path: the risk-decider coordinator
(src/example-agents/runtime/risk-decider.worker.ts) calling
participant.client.cancelSession() when the runtime rejects its commit
(typically POLICY_DENIED), or when its wait-all deadline
(RISK_DECIDER_WAIT_ALL_TIMEOUT_MS, default 60 s) passes with quorum unmet (see
policy-authoring.md § Runtime Enforcement at Commit Time).
Policy registration (startup)
At startup PolicyRegistrarService mints a separate admin JWT
(sender=macp-playground, scopes
{ can_manage_mode_registry: true, is_observer: false, allowed_modes: ['*'] })
from the same auth-service and registers every non-default policy in
policies/ with the runtime. It shares the auth-service dependency with
agent minting: if the admin mint fails, registration is aborted, the service
still boots, and later runs fail at the runtime with UNKNOWN_POLICY_VERSION.
The full flow (idempotent re-registration, schema_version drift check,
read-only registry verification, skip conditions and log lines) is documented
in policy-authoring.md § How Policies Are Registered,
with a checklist in
policy-authoring.md § Troubleshooting.
Ambient envelopes (Signal / Progress)
Agents can emit ambient envelopes that are not bound to any specific mode —
for example, risk-decider.worker.ts emits a session.context Signal
when the proposal is first observed. Ambient envelopes have:
mode = ""session_id = ""(correlation id travels in the payload instead)
For the runtime's mode-authorization check to accept these, the agent's JWT
must include "" in allowed_modes. The macp-playground does this
automatically in deriveScopes()
(src/hosting/process-example-agent-host.provider.ts) — every agent mint ends
with allowed_modes: [context.modeName, '']. Removing the empty string breaks
ambient emission at the runtime boundary with FORBIDDEN.
For the runtime-side handling (broadcast via WatchSignals, no session
history, authentication and back-pressure on the watch side) see
macp-runtime/docs/API.md § WatchSignals.
Deployment checklist
- Run the auth-service (see
docker-compose.dev.ymlfor a dev topology, ordocker-compose.fullstack.ymlfor the whole stack). - Configure the runtime to trust it (
MACP_AUTH_ISSUER,MACP_AUTH_AUDIENCE,MACP_AUTH_JWKS_URL=<auth-service>/.well-known/jwks.json) — seemacp-runtime/docs/getting-started.md§ Authentication configuration andmacp-auth-service/docs/integration.md§ Runtime wiring. - Set on the macp-playground:
MACP_AUTH_SERVICE_URL=http://auth-service:3200(required, fails fast).MACP_RUNTIME_ADDRESS=runtime.local:50051(required for runs).MACP_AUTH_TOKEN_TTL_SECONDS≥ worst-case run length.
- On first boot, confirm the logs show
policy_registration_completewithfailed=0.
Adding a new agent
- Add the agent to
src/example-agents/example-agent-catalog.service.tsand create a matching manifest inagents/manifests/<agent>.json. - Ensure the worker loads its bootstrap via
loadBootstrapPayload()/from_bootstrap()and lets the SDK construct aMacpClientfromruntime_url+auth_token. - No control-plane or UI changes required. The Bearer token is minted per spawn by the auth-service — no static configuration.
Cross-repo dependencies
This plan has matching tasks in:
macp-sdk-python— PY-1..6 (secure default,expected_sender, cancel-callback binding). Done upstream; this repo pinsmacp-sdk-python>=0.14.1,<0.15(agents/requirements.txt).macp-sdk-typescript— TS-1..5 (secure default,expectedSender, cancel-callback binding). Done upstream; this repo pinsmacp-sdk-typescript@^0.14.1(package.json).macp-control-plane— CP-1..15 (RunDescriptor contract, sessionId response, delete forged-envelope paths, observer-mode). CP-1 landed — the macp-playground submitsrunDescriptortoPOST /runsviaControlPlaneRunClient; see "CP-1 run registration" above.macp-ui-console— UI-1..5 (remove operator inject panel). Independent of macp-playground.
Forward-compat notes
- The compiled
sessionIdis carried asrunDescriptor.session.sessionIdand as every bootstrap'ssession_id, so observer tooling sees the same id as the agents. runDescriptoris produced on every compile and returned in theCompileLaunchResult. Callers consume it directly — there is no legacyexecutionRequestshape.- The write-side control-plane HTTP client removed during the direct-agent-auth rollout (
src/control-plane/control-plane.client.ts) has not been revived — CP-1'sControlPlaneRunClient(src/launch/control-plane-run-client.service.ts) is a new, narrower client that only calls the observer-safePOST /runs, and deliberately does not live undersrc/control-plane/:src/observer-invariant.spec.tsforbids any import path containing that segment, guarding against exactly this kind of write-path client creeping back in.