Building a hosted MCP server for architecture docs
A request-bound MCP server, OAuth with PKCE, and YAML compilation through the existing document services, including the limits of validation.
An agent asked to add a service needs to read the current document before it writes a replacement. On a hosted server, that read also needs a user and an organization; a document ID by itself is insufficient context.
We built the MCP endpoint around those constraints. The protocol handler gets a request-bound identity, and the tools call the existing document services to read or compile YAML.
Bind the server to the request
The authentication guard reads the bearer token and asks the verifier to check it. The verifier accepts either an Archdocs personal access token or an OAuth access token and returns the user and organization IDs. The guard loads those records and attaches them to the request before the controller creates the MCP server.
The factory closes over that principal. Tool arguments carry document IDs and source text, rather than a user-supplied organization ID. This removes an opportunity for a tool call to select a different tenant, though the services still need to enforce access on each operation.
Our controller creates a fresh McpServer and StreamableHTTPServerTransport for each request. The transport has sessionIdGenerator: undefined. When the response closes, the controller closes both objects.
const server = factory.create(req.mcpUser, req.mcpOrg)
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
})
res.on('close', () => {
transport.close()
server.close()
})
await server.connect(transport)
await transport.handleRequest(req, res, req.body)
This excerpt leaves out the Nest decorators and guard wiring. The trade-off is that a tool can't rely on conversation state held inside a previous server instance. State that needs to survive a request belongs in storage. OAuth clients and tokens are stored there too; stateless transport doesn't make authentication stateless.
Let clients discover authorization
For interactive clients, our OAuth path uses authorization codes with PKCE. Protected-resource metadata points to authorization-server metadata, which advertises the registration, authorization and token endpoints. A missing or invalid bearer token gets a WWW-Authenticate challenge with the protected-resource metadata URL.
Dynamic registration stores the client's redirect URIs. Authorization checks the requested redirect against that list. The code records the selected organization and the PKCE challenge. Exchange requires a verifier and compares its hash with the stored challenge.
An implementation detail worth testing: exchanging a code consumes it before the remaining code checks run. A failed verifier means starting authorization again. Refresh revokes the old token record and creates a new one, and requested refresh scopes are intersected with the scopes already granted.
A token identifying an organization doesn't answer whether an individual document operation should be allowed. Keep that question in the domain services and test it there.
Compile through the document services
update_doc delegates to DslService.compileExisting. That service finds the document in the organization, loads its saved layout and compiles the supplied YAML. On successful compilation, it calls ArchitectureDocsService.update, stores the YAML and refreshed layout, and emits a recompile event.
Saving element JSON directly would skip document behavior owned by the service. MCP shouldn't grow another implementation of reference bookkeeping just because its caller is an agent.
get_doc returns stored YAML for a code-backed document. For a canvas document without stored source, it decompiles the elements. patch_doc reads that YAML, applies operations addressed by node ID or relationship endpoints, then sends the patched YAML through the same compile path as a replacement.
Patching makes a small edit easier to express. It still recompiles the document. It also doesn't provide compare-and-swap: the tool input has no expected revision field. If a person and an agent edit concurrently, read again before writing and inspect the result. A successful patch is no guarantee that another writer's recent change was preserved.
Validation has a narrower job than review
validate_yaml performs a dry-run compile without creating a document. It catches schema errors and unresolved relationship endpoints before a write. get_schema gives the agent the current construct reference, so the model doesn't have to guess the fields of an endpoint card.
There is a reason to validate before calling create_doc: the creation service makes a blank document before compiling into it. An invalid source can fail after that blank document has been created. The tool should not be described as an atomic validate-and-create transaction.
Even a clean dry run says nothing about whether a proposed dependency exists in the deployed system. Review the YAML and the diagram as a design change. The same distinction applies to architecture as code without an agent.
Archdocs exposes this path so an agent can edit the same document a person opens on the structured canvas. The MCP overview starts with the OAuth setup for clients. For a first edit, ask the agent to read an existing document, change a named node, validate the source and show the changed YAML before submitting it.
Architecture docs that stay alive
ArchDocs keeps diagrams and docs in sync with your systems instead of letting them rot. Create a free account to start documenting your architecture.