Skip to content

The MCP server — DNA as a live layer

DNA stores the neutral definitions (Agent / Soul / Guardrail / Tool), its self-describing SDLC board, and its declarative memory — and has no runtime of its own. There are two ways to serve that to a runtime:

dna emit dna mcp serve
When build-time runtime
Output a static native artifact (a file) a live answer to a query
Composition structure flattened to one string composed on request
Per-tenant overlay dropped (needs a fork) preserved (compose_prompt(tenant=…))
No-deploy change frozen at emit time live (edit the source, next call reflects it)
Reaches one runtime's loader any MCP client
Protocol the target's file format the neutral MCP protocol

emit is the static de-para (see Emitting to a runtime). The MCP server is the dynamic face — and it recovers exactly what emit loses: a client asks "compose the ACME concierge now" and the server composes it live, tenant-aware, with zero deploy.

DNA already consumes MCP (the MCPFederation Kind pulls external MCP tools into a scope). This is the inverse: DNA exposing itself over MCP, so that Claude Code/Desktop, Cursor, GitHub Copilot, agent-framework, Bedrock AgentCore — any MCP client — can reach everything DNA stores.

One server exposes everything

dna mcp serve boots a single server against the configured source (DNA_SOURCE_URL / DNA_BASE_DIR / ./.dna — the same source every dna command reads) and speaks MCP over stdio. It surfaces three faces of what DNA stores, plus resources:

Definitions — recover what emit drops:

  • compose_prompt(agent, scope?, tenant?) → the live-composed system prompt (Soul + Guardrails + instruction). Pass tenant for the per-tenant overlay — the composition a flat artifact cannot express.
  • list_agents(scope?) · list_tools(scope?) · get_tool(name, scope?).

SDLC — the self-describing board, readable AND writable:

  • Read: sdlc_digest(since?, scope?) · list_stories(status?, scope?) · get_adr(name, scope?).
  • Write (the dogfood loop — an agent creates + manages the board over its own interface): create_story(name, feature, description, title?, priority?, labels?, ac?, dod?, scope?) · create_issue(slug, description, type?, severity?, title?, feature?, scope?) · set_status(kind, name, status, reason?, scope?) · comment(kind, name, body, type?, scope?) · create_feature(name, title, description, epic?, priority?, labels?, scope?).

The write tools reuse the same core the dna sdlc CLI writes through (dna.application.sdlckernel.write_document, so cache invalidation, hooks + schema validation all fire) — one write path, two faces. set_status refuses a status that is not valid for the Kind (Story: todo/in-progress/review/done/ blocked; Issue: open/triaged/resolved; Feature: discovery/ in-development/done). Over an authenticated server the write tools are gated by the tier's sdlc_modeFree = read (the board is listable), Pro = write (create/transition/comment); the stdio / local (no-token) path is unmetered and unrestricted, exactly like remember.

Memory — declarative recall:

  • recall(query, scope?, k?) · remember(summary, …) · consolidate(apply?).

Documents — every registered Kind, generically:

The tools above each name one Kind. These four name none — they loop over the Kind registry at call time, so a Kind that exists is a Kind an agent can use, with no hand-written tool for it. That matters because most Kinds have none: of the Kinds a stock kernel registers, only a handful are covered above, and most are declared purely by a *.kind.yaml descriptor.

  • list_kinds(scope?) — the catalog. Per Kind: alias, api_version, plane (composition = it composes into prompts, record = it does not), tenant_scope, storage_pattern, the quota family a call on it is metered under, and writable + write_refusal.
  • list_documents(kind, scope?, api_version?, limit?, offset?) — the documents of one Kind (paged, with an honest has_more).
  • get_document(kind, name, scope?, api_version?) — one document verbatim, as your layer sees it (the per-tenant overlay wins, live).
  • write_document(kind, name, spec, scope?, api_version?) — create/update one document. The apiVersion/kind/metadata envelope is built from the registered Kind, so a caller cannot write into another Kind's namespace.

The write goes through kernel.write_document like every other write path — schema validation, the LayerPolicy gate (Kind-level and the per-field overlayable-fields allowlist), reference validation and the pre_save veto hooks all apply. Two refusals are the generic tool's own, and both fail closed:

  • Bootstrap Kinds are never written genericallyGenome, LayerPolicy and KindDefinition, i.e. exactly the Kinds the kernel marks is_overlayable: false. Their documents declare what a scope is (its identity and parent_scope inheritance; the operator's own override policy; the definition of a Kind itself), so a tool that can write any document must not be the tool that rewrites the frame every other document is validated against. They stay fully readable. The set is derived from the registry, so a Kind that declares is_overlayable: false in its descriptor is covered on arrival.
  • An ambiguous bare Kind name is refused — when a name is registered under more than one apiVersion (a per-scope KindDefinition shadowing a builtin), the tool asks for api_version instead of guessing: the resolved Kind decides both what is written and how the call is metered.

Over an authenticated server the metering is derived from the target Kind, not from which tool was called — so a board Kind is metered as sdlc and gated by sdlc_mode even when reached through write_document, and a read tier cannot smuggle a board write through the generic door. A generic write additionally requires the plan to grant write for that family's access mode (sdlc_mode / memory_mode / definitions_mode); a plan that never declared one grants none. list_kinds reports only the Kinds your plan's feature families unlock (filtered_by_plan: true) — a short honest catalog beats a long one whose entries answer 403. The stdio / local (no-token) path is unmetered and unrestricted, exactly like the rest of the face.

Kind authoring — a tenant declares its own Kind, conversationally:

The generic write_document above refuses every bootstrap Kind, KindDefinition included, and that refusal stays. Authoring gets its own door instead, with its own authorization — it writes exactly one Kind, always without an approval marker, always under the workspace's own assigned apiVersion namespace (minted on first use, then stable, so two workspaces can both author Contrato).

  • author_kind(kind, schema, traits?, tenant?) — write the declaration. kind must be a CamelCase identifier ([A-Z][A-Za-z0-9]{0,63}): it becomes a path component, so anything else is refused by name. Re-calling it for the same kind edits the declaration, and an edit drops any approval it had.
  • list_my_kinds(scope?, tenant?) — the audit view: every authored KindDefinition with its state (unapproved / approved / revoked), the approved boolean, and all three actors (proposed_by/proposed_at, approved_by/approved_at, revoked_by/revoked_at). It reads documents, not the registry — an unapproved Kind is precisely the one the registry does not have, and it is the one a reviewer came for.
  • review_kind(kind, scope?, tenant?) — ONE Kind in full: the same projection the roster publishes plus the schema and the traits. The roster answers which; this answers what, and conferring effect is a decision about the schema.

What an authored Kind does is nothing. approved is always false from author_kind, and an unapproved Kind is never registered — registration is what confers schema validation and storage routing, so "not approved has no effect" is the absence of a mechanism, not a promise. proposed_by is the caller's verified identity, resolved server-side; there is no argument for it, and a proposed_by / approved_by in the call is refused as an unexpected argument before the tool even runs.

Approval is reachable here, and the model cannot reach it.

  • approve_kind(kind, tenant?) — the act that confers effect, declared visibility: ["app"] (MCP Apps / SEP-1865). A conforming host must not put a tool whose visibility omits "model" in the tool list it hands the model, so the only thing that presses it is the Approve button on the review_kind card — pressed by a person, in her own client, signed by her own token. The approver is that connection's verified identity; there is no argument for it. It delegates to the same approve_kind_impl the REST route calls, so there is one implementation of the act and one audit shape.

This is a change of mechanism, not of rule. The rule was always the model must not be able to approve; it used to be bought by the tool not existing, and it is now bought by the declaration. Three things make that trade honest: a workspace has more than one person, so the author and the approver are two distinct verified actors (which is what the audit wanted); the risk that remains is forged consent, not forged identity — a model calling the tool because it is being helpful; and revocation exists, so the grant is undoable in one act.

Whose enforcement visibility is: the host's. The spec states that a server cannot distinguish a UI-initiated tools/call from a model-initiated one — same transport, same token, nothing on the wire that says which pressed. So this is trust in the host, and the danger is not a malicious one (it already holds the user's token) but an incomplete one that has not implemented visibility. What the server does do: the declaration is exact, and a client that tells us it cannot render MCP Apps is not offered approve_kind at all — rather than being handed it with its ui metadata (and therefore its marker) stripped off.

Approving is also still a human act on the portal (POST /v1/kinds/{kind}/approve on the REST face), made with a reviewer's own credential; and POST /v1/kinds/{kind}/revoke takes it back. Those routes are served on every REST auth mode; on a shared-secret deployment (--auth token) the lane is trusted server-to-server and the calling app is what resolves and verifies the workspace — a documented trust boundary, spelled out in The REST read-API. All four tools meter as definitions; authoring and approving are writes, so over an authenticated server they need the plan to grant definitions_mode: write.

Resources (beyond tools):

  • dna://{scope}/manifest — the scope's Kinds → document names.
  • dna://{scope}/agents — the agent roster.

Built on FastMCP

The server is built on FastMCP (the standalone fastmcp framework — the leading MCP framework, from which the official MCP Python SDK's FastMCP 1.0 was derived). That is a deliberate choice about the phasing: FastMCP ships native transports (stdio + Streamable HTTP) and built-in auth (OAuth 2.1 with Dynamic Client Registration, an OAuth proxy for providers without DCR like WorkOS/Auth0, and JWT token verification with scope enforcement). So the local face is stdio, and the remote + authenticated face is enable + bridge — not build (see Remote + authenticated below).

Install

The MCP dependency is an optional extra (fastmcp, imported lazily, so the base dna install never carries it):

$ pip install 'dna-cli[mcp]'

Run it

$ DNA_SOURCE_URL=file:///abs/path/to/.dna dna mcp serve

Connect a client

Point any MCP client at the command. For Claude Code / Cursor (mcp config JSON):

{
  "mcpServers": {
    "dna": {
      "command": "dna",
      "args": ["mcp", "serve"],
      "env": { "DNA_SOURCE_URL": "file:///abs/path/to/.dna" }
    }
  }
}

Now the client can call the tools and read the resources against your live DNA. For example, composing an agent's prompt live and tenant-aware:

// tool: compose_prompt
{ "agent": "concierge", "scope": "concierge", "tenant": "acme" }
// → { "scope": "concierge", "agent": "concierge", "tenant": "acme",
//     "model": "azure/gpt-4o", "prompt": "You are the Helpdesk Concierge …" }

The same server answers sdlc_digest, list_stories, get_adr (read the board), create_story / set_status / comment (write the board) and recall (the memory) — one server, everything DNA stores. For example, filing a Story straight from an agent:

// tool: create_story
{ "name": "s-add-dark-mode", "feature": "f-theming",
  "description": "Add a dark-mode toggle to the settings panel",
  "ac": ["Given the toggle, when flipped, the theme persists across reloads"],
  "dod": ["Merged + covered by a test"] }
// → { "kind": "Story", "name": "s-add-dark-mode", "status": "todo",
//     "feature": "f-theming" }

Remote + authenticated (Phase 2)

The stdio server above is the LOCAL face — a client on your machine spawns it as a child process. A web client (Claude web, ChatGPT) cannot spawn a local process; it needs a URL. Because the server is on FastMCP — which ships both the transport and the auth natively — the remote face is enable + bridge, not a rebuild. It is the same server, the same tools, reached over HTTP:

local — stdio remote — Streamable HTTP + OAuth
Transport stdio (child process) Streamable HTTP (MCP spec 2025-06-18)
Clients Claude Code, Cursor, Copilot Claude web, ChatGPT, any hosted client
Reachability your machine only hostable / networked
Auth none (local trust) OAuth 2.1 bearer JWT — one IdP (--auth jwt) or the pluggable N-provider layer (--auth config)
Tenancy caller-supplied tenant arg bound to the token (the bridge; the claim is per-provider under --auth config)
Command dna mcp serve dna mcp serve --transport http --auth config

Serve it over HTTP

$ dna mcp serve --transport http --host 0.0.0.0 --port 8000
#  the MCP endpoint is http://<host>:8000/mcp/

Point a remote/web MCP client at that URL. FastMCP serves the Streamable HTTP transport — no transport code is written, it is a flag.

Hosting it for real? Host the MCP server on Azure Container Apps + Microsoft Entra is the one-command (azd up) recipe — Dockerfile + bicep + runbook — that runs this HTTP server behind an HTTPS ingress, keyless (Managed Identity), with Entra as the OAuth IdP.

Connect a web client

A web MCP client takes a URL (and, when auth is on, drives the OAuth flow):

// a hosted/remote MCP client's server entry
{ "type": "http", "url": "https://dna.example.com/mcp/" }

Authenticate — OAuth 2.1 with a JWT Resource Server

Add --auth jwt (HTTP-only — there is no bearer token over stdio). The server becomes an OAuth 2.1 Resource Server: it validates signed bearer JWTs and advertises Protected Resource Metadata (RFC 9728) at /.well-known/oauth-protected-resource/mcp, so a client discovers where to authorize. Configure it from the environment:

Env var Meaning
DNA_MCP_JWT_PUBLIC_KEY PEM public key (static key), or
DNA_MCP_JWKS_URI a JWKS endpoint (a real IdP / rotating keys)
DNA_MCP_JWT_ISSUER expected iss (optional)
DNA_MCP_JWT_AUDIENCE expected aud (optional)
DNA_MCP_RESOURCE_URL + DNA_MCP_AUTH_SERVERS set both to advertise PRM (RFC 9728)
$ DNA_MCP_JWKS_URI=https://idp.example.com/.well-known/jwks.json \
  DNA_MCP_JWT_ISSUER=https://idp.example.com/ \
  DNA_MCP_JWT_AUDIENCE=dna-mcp \
  DNA_MCP_RESOURCE_URL=https://dna.example.com \
  DNA_MCP_AUTH_SERVERS=https://idp.example.com/ \
  dna mcp serve --transport http --auth jwt --host 0.0.0.0 --port 8000

FastMCP conforms to the current MCP Authorization spec (revision 2025-11-25): OAuth 2.1 Resource Server, mandatory PKCE, Protected Resource Metadata (RFC 9728), Resource Indicators (RFC 8707), Authorization Server Metadata (RFC 8414), Dynamic Client Registration (RFC 7591).

--auth jwt is the single-IdP shortcut (one Resource Server from env). For more than one IdP at once — and for a config-driven setup where adding a provider is a config block, not code — use the multi-provider layer below.

Multi-provider auth — a provider is a config block, not code

A serious IdP always exposes JWKS + OIDC discovery, so DNA treats a provider as a block of config, not a code path. Declare auth.providers[] in dna.config.yaml and run dna mcp serve --transport http --auth config. The server accepts a token from any configured provider, routes it to that provider by its issuer, and scopes every tool to the tenant the provider's own claim names. Adding an IdP is one more block — no new code.

# dna.config.yaml
source: postgresql://user:pass@host/db

auth:
  providers:
    # Azure Entra ID — the reference provider (see below).
    - type: entra
      issuer: https://login.microsoftonline.com/<tenant-id-or-common>/v2.0
      audience: <app-id>          # your Entra app registration (client) ID
      # tenant_claim defaults to `tid` (the Azure tenant → the DNA tenant)

    # Any OIDC-generic IdP.
    - type: oidc
      issuer: https://idp.example.com
      jwks_uri: https://idp.example.com/.well-known/jwks.json   # optional; derived if omitted
      audience: dna-mcp
      tenant_claim: org           # REQUIRED for `oidc` (no default)

    - type: clerk                 # tenant_claim defaults to `org_id`
      issuer: https://clerk.acme.dev

    - type: workos                # tenant_claim defaults to `org_id`
      issuer: https://api.workos.com
      audience: dna-mcp

The provider table.

type default tenant_claim JWKS (when jwks_uri omitted) issuer notes
entra tid …/<tenant>/discovery/v2.0/keys (derived) common/organizations/consumers → issuer relaxed, audience required
clerk org_id <issuer>/.well-known/jwks.json Clerk Frontend API origin
workos org_id <issuer>/.well-known/jwks.json organization-based
auth0 org_id <issuer>/.well-known/jwks.json no DCR → OAuthProxy seam
oidc (required — you name it) <issuer>/.well-known/jwks.json any OIDC IdP

Each block accepts issuer, audience, jwks_uri, public_key (static PEM, in place of a JWKS), algorithm, tenant_claim, scope_prefix, and name. A provider needs a key source: jwks_uri, a public_key, or an issuer to derive the JWKS from.

Multi-issuer routing. DNA builds one FastMCP JWTVerifier per provider (each aimed at its own JWKS + issuer + audience) and composes them: a token is verified by the one provider whose issuer + audience + signature match, so it is routed by iss for free. The composite then stamps that provider's tenant_claim onto the verified token, so the tenancy bridge reads the right claim per provider — no global state, and it survives Entra common (where the token's iss carries the real Azure tenant, not the literal common).

PRM lists every provider. Wrap the layer as a Resource Server (DNA_MCP_RESOURCE_URL + DNA_MCP_AUTH_SERVERS, or the per-provider issuers by default) and it advertises Protected Resource Metadata (RFC 9728) naming all configured authorization servers — one discovery document, N providers.

Providers without DCR (WorkOS, Auth0). FastMCP's OAuthProxy bridges an IdP that lacks Dynamic Client Registration to MCP's DCR-compliant flow. It slots into the same dna_cli._mcp_auth.resource_server(...) seam; the tenancy bridge is provider-agnostic (it reads the stamped claim off whatever verified token comes back), so nothing downstream changes.

Entra as the reference provider (+ the azd up step)

Azure Entra ID maps onto DNA cleanly: the Azure tenant is the tid claim, and that is the DNA tenant — so type: entra defaults tenant_claim: tid.

  • Per-tenant (issuer: …/<tenant-guid>/v2.0) — the token's iss is a concrete match, validated strictly; the derived JWKS is that tenant's keys.
  • Multi-tenant (issuer: …/common/v2.0 or …/organizations/v2.0) — Entra mints a per-caller issuer, so strict iss validation is impossible. DNA relaxes the issuer and validates by audience + signature (your app-id audience is the security boundary — it is required here), then reads the Azure tenant from tid.

Real end-to-end validation is done on the owner's azd up. The Foundry already carries entraTenantId / entraApiClientId; a full login → Azure token → Resource Server check needs a live Entra app registration and is therefore run at deploy time, not in CI. The step:

  1. azd up — provision the Entra app registration (audience = its client ID) and host the server with --transport http --auth config.
  2. From a client, sign in against Entra and call a tool with the issued bearer token; confirm compose_prompt returns the tenant matching the token's tid, and that a token for another Azure tenant cannot reach it.

Locally this is covered by an emulated multi-provider test (two OIDC issuers with distinct tenant-claim keys) plus a requires_azure placeholder (DNA_MCP_ENTRA_E2E) that documents the real check without needing a credential.

--auth azure — the Entra facade (zero-config for Claude, OBO preserved)

--auth config verifies a bearer token but does not help a client that can't be pre-registered (Claude wants Dynamic Client Registration; Entra has none). --auth azure wraps the server in FastMCP's AzureProvider (an OAuth 2.1 facade in front of Entra): it serves the DCR / CIMD / PKCE / discovery endpoints Entra refuses, so Claude auto-connects zero-config — while the token reaching a tool stays the Entra assertion, so graph/_obo.py OBO to Graph is unchanged (the facade retains the upstream token server-side and hands it back).

export DNA_MCP_AZURE_CLIENT_ID=<mcp app-reg client id>       # the audience GUID
export DNA_MCP_AZURE_CLIENT_SECRET=<confidential-client secret>
export DNA_MCP_AZURE_TENANT=organizations                    # multi-tenant (default) or a GUID
export DNA_MCP_AZURE_BASE_URL=https://<facade public url>
export DNA_MCP_AZURE_IDENTIFIER_URI=api://<mcp app id uri>   # optional
dna mcp serve --transport http --auth azure --host 0.0.0.0 --port 8000

A multi-tenant authority (organizations/common/consumers) relaxes issuer validation to audience+signature only — real Entra tokens carry the caller's own tenant GUID as iss, so a pinned issuer would reject every partner-org token (the same verifier_issuer() is None policy --auth config uses).

OBO note: keep DNA's own graph/_obo.py (it exchanges to each user's home tenant dynamically). Do not route OBO through AzureProvider's built-in EntraOBOToken, which is pinned to the organizations authority.

The tenancy bridge — a token composes only what is its tenant's

This is the DNA-specific value. FastMCP verifies the token; it does not know DNA tenancy. The bridge (dna_cli._mcp_auth) maps the verified token's claims/scopes → a DNA tenant and enforces it, so every tool becomes tenant-scoped by the token, not by a caller argument:

  • A token with claim {"tenant": "acme"} (or a scope tenant:acme) makes compose_prompt / recall / list_stories compose and read only ACME's layer — the token's tenant is injected into every data access.
  • A caller that also passes a different tenant argument is denied (cross-tenant). Omitting it resolves to the token's tenant.
  • A token with no tenant claim/scope is denied — fail closed; an authenticated request with no tenant binding never falls back to "all tenants".
  • With no auth (stdio / local) the bridge is an identity: the caller-supplied tenant passes through unchanged, so the base/stdio path is untouched.

Under --auth jwt (single IdP) the claim key is configurable via env (DNA_MCP_TENANT_CLAIM, default tenant; scope prefix DNA_MCP_TENANT_SCOPE_PREFIX, default tenant:). Under --auth config (the multi-provider layer) the claim key is per provider — each block's tenant_claim (Entra tid, Clerk/WorkOS org_id, an oidc block's own) — and the composite verifier binds the right one to each token automatically. Either way: auth + multi-tenant in one mechanism — the token IS the tenancy.

Serving one operator — DNA_WORKSPACE_ENFORCEMENT

Once WorkspaceMembership grants exist, the tenancy dimension is resolved from the caller's membership, and an authenticated identity holding none is denied the whole tenant-scoped surface. That is correct with two identities and useless with one, so a single-operator deployment can opt out explicitly:

Env var Meaning
DNA_WORKSPACE_ENFORCEMENT unset (default) or enforce → the membership boundary denies, as above. open → it stops denying; the caller's workspace selector is taken at face value. Any other value enforces, and the door logs that it ignored it.

It is not a boolean — 0/false/off/1/true all enforce. Only the literal open changes anything, and a door running open announces it at boot. Authentication, scope binding, the plan gates and metering are untouched: a call that resolves no workspace meters against the caller's own verified identity. Full semantics — including which denials are neutralised and which stay — in Tenancy and layers.

Why this completes the thesis

DNA is a vendor-neutral intelligence layer with no runtime. emit proved one face (author once, materialize per runtime). The MCP server is the other: the live layer any client consumes over the market's neutral protocol — preserving composition, per-tenant overlay, and no-deploy change, the exact axes a static artifact drops. Locally over stdio, or remotely over authenticated, multi-tenant HTTP — the same server, the token scoping each client to its own tenant.

See the ADR adr-dna-mcp-runtime-face for the decision, the phasing, and the auth↔tenancy bridge design.