ADR 0005 — MCP server and bearer auth via authservice¶
Status: Accepted Date: 2026-09-21
Context¶
An Agent (an MCP client — Claude or another) becoming a direct consumer of SwarmApi.Api
was discussed as a differentiator over existing single-robot ROS↔MCP bridges: this repo
already has a domain layer above ROS 2 (Mission, SwarmState, Formation), so wrapping
that in MCP is swarm-level by construction, not per-drone, and needs no new bridge —
ISwarmBridge already exists.
That consumer is exactly the trigger docs/architecture/DEVIATIONS.md
already recorded for P5: "no authentication... Trigger: the first deployment reachable
outside localhost, or the LLM layer (M5) introducing a caller identity worth
distinguishing." An MCP server calling POST /api/missions on the swarm's behalf is that
caller identity. Shipping the MCP server without closing P5 at the same time would be
building the trigger and ignoring it.
konradcinkusz/authservice was evaluated
against a general-purpose IdP (Keycloak, Ory, etc.) and against P5's own requirement (JWT,
asymmetric signing). It fits swarmsim's scope deliberately: MIT, RS256 with published
JWKS and rolling key rotation, a documented "own instance per consumer" deployment model,
and — per its own docs/decisions/0003-scope.md — an explicit refusal to become an OIDC
provider (SSO/SAML/LDAP federation, third-party client consent flows), which is exactly
the ceiling swarmsim does not need yet. Its docs/decisions/0004-agent-to-agent-authorization.md
is the closer analogue to this MCP scenario (a non-human caller acting with scoped,
short-lived authority), but its status there is Proposed, not implemented — nothing in
that service's code (AgentIdentity, principal_type, /api/v1/agents/token) exists yet.
This ADR does not depend on it: swarmsim's MCP server is a proxy for a human-obtained
token, not an autonomous agent principal, so ordinary user-issued JWTs from authservice
are sufficient for this phase.
Decision¶
mcp_server/— a new, minimal Python package, independent ofswarm_coordination(no ROS dependency), exposing two MCP tools that wrap the existing REST surface rather than a new bridge:get_swarm_status→GET /api/swarm/state(unauthenticated, matches point 3 below).start_mission→POST /api/missions, forwarding a bearer token the operator obtained fromauthservicethemselves (login flow, or a seeded account) — the MCP server does not implement a login flow of its own. It is a transparent proxy for a human-authorized caller, not yet an autonomous principal.
define_formation is not a separate tool: POST /api/missions with
type: "formation" already computes leader-follower offsets
(SwarmApi.Domain.Formation.Line) — start_mission covers it. assign_swarm_task
(per-drone/subgroup task assignment, as opposed to one mission for the whole swarm) is
explicitly out of scope here — it needs new domain logic in SwarmApi.Application,
not just a new MCP tool over what exists, and is follow-up work.
-
SwarmApi.Apigains JWT bearer authentication against an externalauthserviceinstance (RS256, JWKS discovery), following the same degrade shape as ADR-0003'sISwarmBridge:Auth:Authorityunset (the default —dotnet runwith zero config, exactly as today) runs in Open mode, every endpoint reachable with no token.Auth:Authorityconfigured runs in Enforced mode. Which mode is active is reported byGET /health(auth: "Open" | "Enforced"), mirroringswarmBridge: "Connected" | "Simulated". -
Only
POST /api/missionsis gated when Enforced.GET /api/swarm/state,GET /api/missions/{id}, and the dashboard (wwwroot/) stay unauthenticated. The dashboard's "Start demo mission" button (app.js) callsPOST /api/missionswith no token and has no login UI — gating that route too would silently break M4's manual acceptance criterion. The narrower scope is deliberate: the one thing this ADR is actually closing is "an Agent can dispatch a mission with a distinguishable identity", which is a write, not a read. Protecting the read endpoints and giving the dashboard a login flow is follow-up work, tracked as an open item below, not silently declared done.
Consequences¶
The "zero third-party NuGet packages" invariant gets one deliberate exception¶
Directory.Packages.props and the root README both state runtime code ships zero
third-party packages, relying only on Microsoft.AspNetCore.App's FrameworkReference.
JWT bearer validation is not in that shared framework — unlike, say, health checks or
System.Net.WebSockets.ClientWebSocket — Microsoft.AspNetCore.Authentication.JwtBearer
and its Microsoft.IdentityModel.* dependencies are out-of-band NuGet packages requiring
an explicit PackageReference. Hand-rolling RS256/JWKS validation with only BCL crypto
was considered and rejected: JWT validation (audience/issuer/expiry checks, algorithm
confusion attacks, JWKS kid rotation) is a well-known place to introduce a subtle
security bug, and authservice itself makes the same call (its own ADR-0003: "do not
re-invent auth primitives"). One package, Microsoft.AspNetCore.Authentication.JwtBearer
(Microsoft-owned, matches this repo's own TFM), is added as the one recorded exception —
correctness over the badge.
Open items this ADR does not close¶
assign_swarm_task(per-drone task assignment) — newSwarmApi.Applicationlogic, separate PR.GETendpoints and the dashboard remain unauthenticated even in Enforced mode — the dashboard has no login flow.docs/architecture/DEVIATIONS.md's P5 row is narrowed, not deleted, to reflect exactly this partial scope.- The MCP server holds a static, operator-supplied token (
SWARM_API_TOKEN); it does not refresh it or implementauthservice's login flow itself. Fine for a single operator; worth revisiting onceauthservice's ADR-0004 (agent-to-agent authorization) ships and gives non-human callers their own short-lived, audience-scoped credential instead of a borrowed human token. - No published
ghcr.io/konradcinkusz/authserviceimage tag exists yet (nov*release has been tagged in that repo at the time of this ADR) —docker/docker-compose.ymlbuilds it from that repo's git context as an interim measure; swap to a pinned tag once a release exists (see that repo's README, "Releasing").
Amendment — 2026-09-22: plain-http authority, and tests through the host¶
Auth:RequireHttpsMetadata(defaulttrue) is now an explicit option. The composeauthprofile's authservice serves plain http inside the compose network, and with the JwtBearer default every gated request failed with a 500 before any token was examined — the documented local setup never worked. Setting it tofalseis for that private network only, and the API logs a warning when it is.docker/.env.exampleshows the pair of variables the profile needs.- Enforced mode is now tested through the real host
(
backend/tests/SwarmApi.Api.Tests/EnforcedAuthEndpointTests.cs):POST /api/missionswithout a token is 401, reads and/healthstay open, and the http-authority case above answers 401 rather than 500. The original tests could only check the DI registration becauseConfigureAppConfigurationinWebApplicationFactoryis applied afterProgram.csreadsAuth:Authority;UseSettingis applied before it, which is what the new tests use.
Amendment — 2026-09-22: start_mission is gone; agents go through a plan and a person¶
ADR-0009 replaces decision 1's start_mission tool, which let
an agent start a mission with no person deciding. An agent now proposes a plan
(plan_mission). A person approves it and gets a single-use code. The mission flies only
through dispatch_mission with that code. The MCP server also gained get_mission,
get_mission_plan, abort_mission and land_all. It carries an Idempotency-Key on
every write and was rebuilt on MCP SDK 2.x — the 1.x FastMCP import no longer exists.
Decision 3 is out of date since deny-by-default (2026-09-22): in Enforced mode every write
needs a token, the new plan, approval and dispatch endpoints included; reads stay open.
docs/architecture/API-SURFACE.md is now the
normative, tested list of what is gated.