Skip to main content
zerotal

Agent Surface

@zerotal/arch is an MCP server that hands a coding agent what the framework already knows about your app: the exact signature of every export, the routes it actually registered, what the models declare, the documentation for the version you installed, and a health check whose findings come with the fix attached.

The premise is that none of this needs to be inferred. Zerotal produces it mechanically already — api-surface.md is regenerated and diffed by CI on every change, zt doctor returns structured findings, the router knows its routes and the ORM knows its columns. This package exposes it over the protocol agents speak.

Install

# in your project root
bun add -d @zerotal/arch

Register the provider in bootstrap/providers.ts:

// bootstrap/providers.ts
import { ArchProvider } from "@zerotal/arch";

export default [DatabaseProvider, ArchProvider];

Then wire it into whichever agents you use:

# in your project root
bun zt arch:install

That writes three things and restarts nothing:

FileWhat it is
.mcp.jsonRegisters the server so an agent can call its tools. A .cursor/ or .vscode/ directory gets its own config too.
AGENTS.mdThe instructions, composed from the packages you actually installed.
CLAUDE.mdA one-line shim importing AGENTS.md.

Restart your agent afterwards so it picks up the new server.

NoteArchProvider declares environments: ["console"]. It adds three commands and contributes nothing to a request, so a web process never loads it.

The tools

ToolAnswers
app_infoBun version, boot mode, registered providers, and the version and maturity of every installed @zerotal package.
api_surfaceEvery export of a package with its full TypeScript signature, class members included. Takes an optional symbol filter.
search_docsThese pages, for the version installed here. Returns the matching section, not the whole page.
routesThe registered routes with their names, controllers and middleware — including ones a provider added programmatically.
schemaWhat the models declare: tables, primary keys, timestamps, soft deletes, and every column with its flags.
logs / last_errorThe app's own trail from storage/logs, already structured. level acts as a floor, so warn includes errors.
baselinesThe quality ratchets this project records and the command that checks each.
doctorEvery health check, with the fix beside each finding.

Every tool is read-only, and every one publishes an outputSchema and returns structuredContent alongside its text.

The two that carry the most

api_surface is the reason the rest is worth building. Where a documentation search can tell an agent that a fluent builder exists, this hands over the call it has to write for tsc to accept it:

class Collection = {
  new <T>(items?: T[]): Collection<T>
  static make<T>(items: T[]): Collection<T>
  filter(predicate: (item: T, index: number) => boolean): Collection<T>
  …
}

It reads the snapshot from node_modules, so the answer describes the version this app runs rather than whatever is current.

doctor is the one to end a task with. Every finding carries a fix, which is what makes "the app is healthy" a claim an agent can verify rather than assert.

Commands

CommandWhat it does
bun zt arch:installWrite the MCP config and the instruction files.
bun zt arch:updateThe same command — re-running it is the update.
bun zt arch:probe <topic>Print one JSON report: doctor, routes, schema or app-info.

Both writers take --dry, which prints what would change and writes nothing.

Re-running is safe

Generated content lives between markers:

<!-- zerotal:arch:start -->

…generated…
<!-- zerotal:arch:end -->

arch:update replaces what is between them and nothing else, so anything you write above, below, or after the block survives every framework upgrade. The MCP config is merged the same way — other servers in the file are left exactly as they were.

A file whose markers are damaged, or a .mcp.json that is not valid JSON, is reported and left completely alone. Guessing where a half-marked block was meant to end is how a tool eats a paragraph nobody kept a copy of.

How it runs

The server is a bin, not a zt command, and it never boots your application:

// .mcp.json
{
  "mcpServers": {
    "zerotal": {
      "command": "bun",
      "args": ["node_modules/@zerotal/arch/src/bin/mcp.ts"]
    }
  }
}

Two reasons, and both matter.

The protocol forbids noise. MCP's stdio transport says a server must write nothing to stdout that is not a protocol message, and a stray line does not degrade the session — it desynchronises the client's parser and corrupts every message after it. A booted app prints: banners, provider notices, warnings. A process that never boots one cannot.

Answers have to be current. The caller is an agent editing the code between calls. A long-lived server holding a booted app would answer routes from the state it started with. So the tools that need an app spawn one per call — bun zt.ts arch:probe <topic> — and pay about a second for an answer that is true right now. There is no cache anywhere in that path, deliberately.

The tools that read files rather than the app — api_surface, search_docs, baselines, logs — need no app at all and work in a project that will not boot, which is exactly when an agent needs them most.

Protocol support

The server speaks both eras of MCP and picks per request:

  • Modern (2026-07-28) — stateless, with the protocol version in each request's _meta. server/discover is implemented, which is also the probe a dual-era client uses on stdio to decide it need not fall back.
  • Legacy (2025-11-25 and earlier) — the initialize handshake, which is still what most shipping clients open with.

A client that opens with initialize gets legacy semantics; one that tags its requests gets modern ones, including resultType, ttlMs and cacheScope on list results.

Configuration

Only the install side is configurable — the server reads no config, because it boots no app to read one from.

// config/arch.ts
import { ArchConfig } from "@zerotal/arch";

export default ArchConfig({ claudeFile: false });
FieldRequiredDefaultDescription
agentsFilenotrueWrite AGENTS.md, the cross-tool instruction file.
claudeFilenotrueWrite a CLAUDE.md importing it.
mcpConfignotrueWrite the MCP client configuration.
mcpConfigPathno".mcp.json"Where that configuration goes.
serverNameno"zerotal"The key the server is registered under.

Building on it

The transport is exported separately from the tools, on the @zerotal/arch/mcp subpath. A tool is a plain object with a schema and a run, so you can serve your own alongside these — or serve these over a transport of your own.

import { McpServer, serveStdio } from "@zerotal/arch/mcp";
import { archTools, vendoredDocsDir, spawnProbe } from "@zerotal/arch";

const tools = archTools({
  root: process.cwd(),
  docsDir: vendoredDocsDir(),
  probe: spawnProbe(),
});

await serveStdio({
  server: new McpServer({
    identity: { name: "my-app", title: "My App", version: "1.0.0" },
    tools: [...tools, myOwnTool],
  }),
});

ArchTool, ToolOutcome and ToolContext are the three types you need; ProbeRunner is the seam that lets a tool's tests answer from a fixture instead of booting anything.

References

MemberSignatureDescription
ArchProviderclass ArchProvider extends ServiceProviderRegisters arch:install, arch:update and arch:probe, and one doctor check.
ArchConfigArchConfig(options?: Partial<ArchConfigShape>): ArchConfigShapeConfig factory for the install side.
ArchConfigShapeinterface ArchConfigShapeWhat arch:install writes, and under what name.
ArchErrorclass ArchError extends ZerotalErrorBase for this package's errors.
NoProjectRootErrorclass NoProjectRootError extends ArchErrorThrown when arch:install runs outside a project.
archToolsarchTools(ctx: ToolContext): ArchTool[]Every tool, in listing order.
ToolContextinterface ToolContextThe project root, the docs corpus, and a ProbeRunner.
vendoredDocsDirvendoredDocsDir(): stringPath to the documentation shipped inside this package.
spawnProbespawnProbe(options?: SpawnProbeOptions): ProbeRunnerThe runner that spawns zt arch:probe.
findAppfindApp(start: string): Promise<{ root, entry } | undefined>The nearest enclosing Zerotal app.
ProbeRunnerinterface ProbeRunnerThe seam a tool reaches a booted app through.
ProbeResulttype ProbeResultA probe's answer, or the reason there is none.
SpawnProbeOptionsinterface SpawnProbeOptionscwd and timeoutMs for the runner.
probeprobe(topic: ProbeTopic, app: Application): Promise<unknown>Run one topic against a booted app.
PROBE_TOPICSreadonly ProbeTopic[]The topics arch:probe accepts.
ProbeTopictype ProbeTopic"doctor" | "routes" | "schema" | "app-info".
isProbeTopicisProbeTopic(value: string): value is ProbeTopicNarrow a string to a topic.
DoctorReport / DoctorFindinginterfaceWhat the doctor tool returns.
RouteReport / RouteEntryinterfaceWhat the routes tool returns.
SchemaReport / SchemaModel / SchemaColumninterfaceWhat the schema tool returns.
AppInfo / InstalledPackageinterfaceWhat the app_info tool returns.
detectAgentsdetectAgents(root: string): Promise<Detected>Which agents a project is set up for.
Detected / McpTargetinterfaceThe detection result and one client's config location.
applyMcpConfigapplyMcpConfig(existing, name, target): ConfigOutcomeMerge this server into an MCP config.
ConfigOutcometype ConfigOutcomeCreated, updated, unchanged, or a conflict.
serverEntryserverEntry(): Record<string, unknown>The command and args a client is given.
SERVER_ENTRY_PATHconst SERVER_ENTRY_PATH: stringWhere the server lives in node_modules.
applyBlockapplyBlock(existing, content, preamble?): BlockOutcomeReplace a managed block, preserving everything else.
BlockOutcometype BlockOutcomeThe result of a block write.
fencefence(content: string): stringWrap content in its markers.
BLOCK_START / BLOCK_ENDconstThe markers themselves.
buildGuidelinesbuildGuidelines(options: GuidelineOptions): stringThe generated AGENTS.md body.
GuidelineOptionsinterface GuidelineOptionsInstalled packages and the server name.
agentsPreambleagentsPreamble(): stringThe prose written above the block on creation.
claudeShimclaudeShim(): stringThe CLAUDE.md that imports AGENTS.md.

The transport is on the @zerotal/arch/mcp subpath: McpServer, McpServerOptions, serveStdio, StdioOptions, decodeFrame, encodeFrame, DecodedFrame, success, failure, ArchTool, ToolOutcome, JsonSchema, ServerIdentity, Era, JsonRpcRequest, JsonRpcResponse, JsonRpcSuccess, JsonRpcFailure, JsonRpcId, RpcError, Meta, MODERN_VERSION, LEGACY_VERSIONS and SUPPORTED_VERSIONS.

What stable covers here

The usual promise — anything importable without an @internal marker keeps its shape for the rest of the 1.x line — and one more, because this package's real interface is not its exports.

The MCP tool contract is covered. Tool names, the arguments they accept, and the shape of what they return do not change within 1.x. That is the surface an agent client is configured against: a renamed tool or a dropped field breaks every .mcp.json pointing at this server, and none of it is visible to a type-level check — archTools = (ctx) => ArchTool[] is byte-identical whatever the tools are called. mcp-surface.md records all nine and CI diffs it.

The protocol revision is not. Which version of the Model Context Protocol the server speaks follows the protocol, not this package's major version. A revision that requires a transport change will land in a minor release, described in the notes.

The writers behind arch:install are not. detectAgents, applyMcpConfig, applyBlock, buildGuidelines and the rest are @internal: they exist for the command, and the format of the files it writes is not a promise.

Next steps