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). Passtenantfor 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.sdlc → kernel.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_mode — Free = 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 quotafamilya call on it is metered under, andwritable+write_refusal.list_documents(kind, scope?, api_version?, limit?, offset?)— the documents of one Kind (paged, with an honesthas_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. TheapiVersion/kind/metadataenvelope 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 generically —
Genome,LayerPolicyandKindDefinition, i.e. exactly the Kinds the kernel marksis_overlayable: false. Their documents declare what a scope is (its identity andparent_scopeinheritance; 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 declaresis_overlayable: falsein 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-scopeKindDefinitionshadowing a builtin), the tool asks forapi_versioninstead 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.kindmust 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 samekindedits the declaration, and an edit drops any approval it had.list_my_kinds(scope?, tenant?)— the audit view: every authoredKindDefinitionwith itsstate(unapproved/approved/revoked), theapprovedboolean, 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, declaredvisibility: ["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 thereview_kindcard — 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 sameapprove_kind_implthe 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):
Run it¶
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'sissis a concrete match, validated strictly; the derived JWKS is that tenant's keys. - Multi-tenant (
issuer: …/common/v2.0or…/organizations/v2.0) — Entra mints a per-caller issuer, so strictissvalidation 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 fromtid.
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:
azd up— provision the Entra app registration (audience = its client ID) and host the server with--transport http --auth config.- From a client, sign in against Entra and call a tool with the issued bearer
token; confirm
compose_promptreturns the tenant matching the token'stid, 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 throughAzureProvider's built-inEntraOBOToken, which is pinned to theorganizationsauthority.
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 scopetenant:acme) makescompose_prompt/recall/list_storiescompose and read only ACME's layer — the token's tenant is injected into every data access. - A caller that also passes a different
tenantargument 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
tenantpasses 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.