Skip to content

AI in your editor (MCP bridge) ​

@basaltkit/ai-mcp is a dev-only Model Context Protocol server that exposes Basalt's AI developer workflows — analyze, doctor, plan, review and scaffold — to MCP clients like Claude Code and Claude Desktop, over stdio (default) or optional HTTP. Point it at your project and ask an agent to build a feature; it plans against your real stack, previews the diff, and only writes when you say so.

It's a bridge, never a runtime dependency.

@basaltkit/ai-mcp only uses the framework's official public APIs (via @basaltkit/ai). It depends solely on @basaltkit/ai and the zero-dep @basaltkit/mcp-core — never on @basaltkit/core, @basaltkit/http, @basaltkit/cli, or the runtime @basaltkit/mcp. Install it as a devDependency (or run it with npx). Two tests enforce this mechanically: packages/ai-mcp/test/boundary.test.ts walks the transitive import graph and fails if it ever reaches the runtime, and packages/ai-mcp/test/dev-only-guard.test.ts fails if any workspace package lists the AI layer outside devDependencies. At runtime, the server refuses to start when NODE_ENV=production (AiMcpProductionError; the bin exits 1), so a deployed process that pulled it in fails loudly. Only an explicit production refuses — MCP clients launch the bin without NODE_ENV, the normal dev path. Override deliberately with --allow-production (allowProduction: true, BASALT_AI_MCP_ALLOW_PRODUCTION=1).

The four layers ​

Basalt keeps intelligence, the dev bridge, the wire, and the runtime strictly separate:

PackageRoleRuntime?
@basaltkit/aiIntelligence — the basalt ai CLI, providers, the plan/make/review enginedev-only
@basaltkit/ai-mcpThis page. A dev-only MCP server exposing those workflows to MCP clientsdev-only
@basaltkit/mcp-coreZero-dependency MCP wire: protocol + generic server + stdio/HTTP transportsshared
@basaltkit/mcpThe app's runtime MCP surface — opt-in routes become toolsruntime

@basaltkit/ai-mcp and @basaltkit/mcp both speak MCP, but they are different products: the runtime mcp exposes your app's routes to agents in production; ai-mcp exposes dev workflows (scaffolding, diagnostics) to your editor while you build. Never confuse the two.

The bridge itself is thin. It builds an McpServer from mcp-core with five tools, four resources and four prompts, each of which is a thin wrapper over an exported @basaltkit/ai function. All the intelligence lives one layer down; all the safety (workspace confinement, preview-before-write) lives here.

Quickstart (Claude Code / Desktop) ​

No install needed — the bridge runs via npx. It reads your project from --cwd.

Claude Code ​

From your project root:

bash
claude mcp add basalt-ai -- npx -y @basaltkit/ai-mcp --cwd="$PWD"

Or commit a project-scoped .mcp.json at the repo root (this is what create-basalt --mcp generates for you):

json
{
  "mcpServers": {
    "basalt-ai": {
      "command": "npx",
      "args": ["-y", "@basaltkit/ai-mcp", "--cwd=."]
    }
  }
}

Claude Desktop ​

Edit claude_desktop_config.json (Settings → Developer → Edit Config), then restart Claude Desktop:

json
{
  "mcpServers": {
    "basalt-ai": {
      "command": "npx",
      "args": ["-y", "@basaltkit/ai-mcp", "--cwd=/absolute/path/to/my-basalt-app"]
    }
  }
}

What you can now ask ​

Once connected, the agent has your project's stack and tools. Try:

  • "Analyze this Basalt project and tell me what's enabled." → basalt_analyze
  • "Run the doctor and show me any tenancy or security issues." → basalt_doctor
  • "Plan an Invoice resource with amount and status, tenant-scoped." → basalt_plan
  • "Preview making it, then apply it if it looks safe." → basalt_make

The read-only tools (analyze, doctor) and make preview need no API key. Planning and review call an LLM — see Provider setup below.

Scaffold a new app that's MCP-ready ​

create-basalt wires the bridge for you when you opt into MCP:

bash
npm create basalt my-saas -- --mcp

This adds @basaltkit/ai-mcp to devDependencies (never dependencies), writes a project-root .mcp.json, and documents it in the app's README. See create-basalt.

The tools ​

Five tools, mapping to the basalt ai CLI surface. structuredContent mirrors the text output on every call, and each tool advertises an outputSchema derived from @basaltkit/ai's exported Zod schemas.

ToolPurposeNeeds a provider?Writes files?
basalt_analyzeDetected stack, data model, diagnosticsnono
basalt_doctorDiagnostics + in-memory fix previewsnono
basalt_planNatural language → ArchitecturePlanyesno
basalt_reviewLLM critique of a build → verdictyesno
basalt_makeScaffold a resource verticalpreview: no* / apply: —apply only

* basalt_make needs a provider only when you pass a request instead of a ready plan (it plans internally first).

A tool failure is never a protocol error: bad arguments, a missing provider, a refused write and a cancellation all come back as a normal result with isError: true and the reason in content — so an agent can read it and adapt. Only malformed JSON-RPC produces a real error code.

basalt_analyze ​

Static, offline analysis. Input { workspaceRoot? }; output an AnalysisReport (capabilities, installed packages, database, models, tenant-scoped vs unscoped models, diagnostics). workspaceRoot — here and in basalt_doctor / basalt_plan — is confined to the server's --cwd: an absolute path elsewhere, .., or a symlink that resolves outside is refused (Refused: … is outside the project root).

basalt_doctor ​

Diagnoses configuration, security and tenancy issues, and previews the available auto-fixes — the files each would change, computed in memory. It never writes. Output { diagnostics, hasErrors, fixes: [{ id, status, message, files }] }, where status is ready · noop · unfixable.

Note that fixes only lists rules that are both firing and auto-fixable — which today is just fastify-logger-off and insecure-app-secret. Everything else in diagnostics is a manual fix; see the full rule table.

basalt_plan ​

Turns a request into a grounded ArchitecturePlan (entities, steps, permissions, audit events, warnings, schemaVersion). Input:

jsonc
{
  "request": "an Invoice resource: amount, status (pago|pendente), tenant-scoped",
  "workspaceRoot": ".",      // optional
  "temperature": 0,          // optional
  "maxTokens": 4096          // optional
}

Read-only — it produces a plan, it changes nothing. Streams progress and can be cancelled (see Long-running operations below).

basalt_review ​

An LLM pass over a build result against its plan (tenancy, security, RBAC, validation, tests, fit). Input { plan, makeResult } — both required objects; output an AgentReview whose approved flag is derived from the issues — an error-severity issue blocks.

basalt_make ​

Implements a plan: scaffolds the resource vertical (schema, repository, service, routes, tests) and wires it into src/app.ts. Safe by construction — see the next section. Input:

jsonc
{
  "plan": { /* an ArchitecturePlan from basalt_plan */ },
  // or, instead of plan:
  "request": "an Invoice resource …",  // plans then makes (needs a provider)
  "workspaceRoot": ".",                 // optional, confined to the launch dir
  "mode": "preview",                    // "preview" (default) | "apply"
  "force": false,                       // overwrite existing files (apply only)
  "migrate": false                      // run `prisma db push` (apply only)
}

The input schema declares oneOf: [{ required: ['plan'] }, { required: ['request'] }] — exactly one of the two is the entry point.

Plan↔make correlation is stateless: the client carries the full ArchitecturePlan (with its schemaVersion) from basalt_plan into basalt_make — there is no server-side plan store.

Safe make ​

Writing files from an autonomous agent is the risky part, so the safety model is the whole point.

  • Preview is the default and writes nothing. With no mode (or mode:"preview"), the tool returns preview.perFile[] — every file it would write, each with an action (create | overwrite) and a unified diff — plus preview.clashes (paths that already exist). Nothing touches disk.
  • The preview always runs first. Even an apply computes the dry run before writing, so the confinement check below runs against the real target list.
  • Apply is explicit. mode:"apply" is required to write.
  • Overwrites need force. An apply refuses to clobber existing files unless force:true.
  • migrate is double-gated. prisma db push runs only when migrate:true, and never as a default.
  • Writes are confined to the workspace. A workspaceRoot (or any target path) that escapes the launch directory — via .. traversal, an absolute path, or a symlink — is rejected before any write. Confinement resolves the nearest existing ancestor's realpath, so a symlink escape is caught even for a path that doesn't exist yet. An agent cannot write outside your project.
  • Confirmation — fail closed. An apply is confirmed through MCP elicitation with a one-line summary of what will be written. Over stdio this works when the client announces the elicitation capability in initialize. When the client cannot be asked (no elicitation support, or the HTTP transport), the apply is refused — review the preview and apply it yourself — unless the server was started with --allow-unconfirmed-apply (programmatic: allowUnconfirmedApply: true).

The recommended loop:

text
basalt_analyze            → understand the stack
basalt_plan(request)      → get an ArchitecturePlan
basalt_make(plan)         → PREVIEW: read the diffs + clashes
basalt_review(plan, prev) → catch tenancy/security/RBAC issues
basalt_make(plan, apply)  → write, only when the preview + review look right

Resources & prompts ​

Resources — pull project state as context ​

Read-only reflections of your workspace the agent can read directly. They are computed fresh on every resources/read, and always against the server's workspace root (--cwd) — resources take no arguments:

URIMIMEContents
basalt://project/contextapplication/jsonThe detected ProjectContext — stack, Prisma models, app/server/env files
basalt://project/analysisapplication/jsonThe AnalysisReport — capabilities, data-model summary, diagnostics
basalt://project/diagnosticsapplication/jsonThe doctor findings
basalt://knowledge/architecturetext/markdownThe Basalt conventions the planner is grounded in (BASALT_KNOWLEDGE)

Prompts — workflow templates ​

Four prompt templates encode the safe loop and reference the tools by name, so even a naive agent follows preview-before-write:

PromptArgumentsGuides
plan-featurerequest (required)analyze → plan → make preview → review → make apply
scaffold-resourcename (required), fields (optional)a focused single-entity build
harden-tenancy—doctor → review tenancy fixes → apply
add-rbacresource (required)wire permission guards for a resource

In Claude Code, prompts surface as slash commands (e.g. /plan-feature).

Provider setup (for plan / review) ​

basalt_plan, basalt_review, and basalt_make with a request call a model. Configuration is read from the environment the MCP client launches the server with — the same variables the @basaltkit/ai CLI uses:

VariableMeaning
AI_PROVIDERanthropic (default), openai (any OpenAI-compatible gateway), or ollama
AI_API_KEYThe vendor key (not needed for Ollama)
AI_BASE_URLGateway base URL (e.g. an OpenAI-compatible /v1)
AI_MODELModel id override
AI_STREAM'false' disables SSE streaming on the OpenAI-compatible provider

Pass them through the client's env block:

json
{
  "mcpServers": {
    "basalt-ai": {
      "command": "npx",
      "args": ["-y", "@basaltkit/ai-mcp", "--cwd=."],
      "env": { "AI_PROVIDER": "anthropic", "AI_API_KEY": "sk-ant-…" }
    }
  }
}

Keys stay in memory

The bridge reads provider keys only to construct the provider in-process, and only when a provider-backed tool is actually called (the session builds it lazily). It never logs, persists, or echoes them — and the providerHelp error message that guides you when configuration is missing deliberately names only the knobs, never a value. The read-only tools (analyze, doctor) and make preview need no key at all.

Long-running operations ​

plan, review and make report progress and can be cancelled through the MCP protocol:

  • Progress — pass a _meta.progressToken with your tools/call; the bridge emits notifications/progress as the model streams and as make builds each resource. (Live progress requires stdio — see Transports.)
  • Cancellation — send notifications/cancelled with the request id; the in-flight generation is aborted promptly and the tool returns isError: true with the text Cancelled.

Transports ​

TransportWhenHow
stdio (default)Local dev; Claude Code/Desktop spawn the serverjust run the bin
HTTP (opt-in)Remote/CI, shared team serverbasalt-ai-mcp --http[=port]
bash
# stdio (default) — the client launches this
npx @basaltkit/ai-mcp --cwd=.

# HTTP on an ephemeral port (prints the URL); loopback-only
npx @basaltkit/ai-mcp --http --cwd=.

# HTTP on a fixed port
npx @basaltkit/ai-mcp --http=8848 --cwd=.

The HTTP transport is request/response JSON-RPC over POST /mcp (minimal, no SSE); use stdio when you need live progress streaming.

HTTP is guarded, and binds loopback by default

The HTTP transport binds 127.0.0.1 and rejects requests whose Host header isn't a loopback name (anti-DNS-rebinding) or whose Origin, when present, isn't a loopback origin (anti-CSRF — a browser always sends Origin on a cross-site POST, so its absence means a non-browser client). A rejected request gets 403 and never reaches a tool.

That guard stops browsers; it is not authentication (any non-browser client can send Host: 127.0.0.1). So binding elsewhere (--host=0.0.0.0 for CI) is refused unless you also give a token — --token=<secret> or BASALT_AI_MCP_TOKEN — which every request must then send as Authorization: Bearer <secret>. Add the hostnames clients use with --allowed-hosts=ci.internal,…. Request bodies are capped at 1 MiB (413).

Programmatic use ​

For tests or embedding, build the server without a transport and drive it directly:

ts
import { buildAiMcpServer } from '@basaltkit/ai-mcp'

const server = buildAiMcpServer({ cwd: '/path/to/project' })
const res = await server.handleMessage({ jsonrpc: '2.0', id: 1, method: 'tools/list' })

createAiMcpServer(opts) starts stdio; createAiMcpHttpServer(opts) starts HTTP. Both accept cwd, an injectable createReader (for tests over an in-memory project), and createProvider (to inject a mock model — no network).

Options reference ​

CLI flags (basalt-ai-mcp) ​

FlagTypeDefaultPurpose
--cwd=<path>stringprocess.cwd()The project root every tool and resource reads. With .mcp.json, --cwd=. resolves to the directory the client opened
--http / --http=<port>boolean / numberstdio (off)Switch to the HTTP transport. Bare --http uses port 0 — an ephemeral port, printed on stdout as basalt-ai-mcp listening on <url>
--host=<host>string127.0.0.1Bind address; only read when --http is present. Binding off loopback requires --token
--token=<secret>stringBASALT_AI_MCP_TOKENHTTP only: require Authorization: Bearer <secret> on every request. Mandatory for a non-loopback --host
--allowed-hosts=<a,b>comma listloopback names onlyHTTP only: extra Host hostnames to accept when bound off loopback
--sessionsbooleanoff (stateless)HTTP only: turn on Mcp-Session-Id sessions, so a notifications/cancelled POSTed separately cancels a running call (see sessions below)
--allow-unconfirmed-applybooleanoffLet basalt_make apply when the client cannot confirm (no elicitation). Off by default: such an apply is refused
--allow-productionbooleanoff (BASALT_AI_MCP_ALLOW_PRODUCTION)Start even when NODE_ENV=production. Off by default: the dev-only bridge refuses to start there

buildAiMcpServer(options) · createAiMcpServer(options) ​

AiMcpOptions is the session config; createAiMcpServer adds the stdio streams.

OptionTypeDefaultPurpose
cwdstringprocess.cwd()Workspace root tools and resources default to; also the confinement root for every tool's workspaceRoot (reads and writes)
envRecord<string, string | undefined>process.envWhere provider config (and NODE_ENV, for the dev-only guard) is read from — inject a fixed env instead of the process's
createReader(root: string) => ProjectReadernodeReaderHow project files are read. Inject an in-memory reader to test without disk
createProvider() => AIProviderbuilt from envInject a mock model — no network, no keys
allowUnconfirmedApplybooleanfalseLet basalt_make apply without an elicitation confirmation. Default: refuse (fail closed)
allowProductionbooleanfalseBuild even when NODE_ENV=production. Default: throw AiMcpProductionError (createAiMcpHttpServer rejects with it)
inputNodeJS.ReadableStreamprocess.stdinstdio only: read JSON-RPC from a different stream (tests)
output{ write(chunk: string): unknown }process.stdoutstdio only: write JSON-RPC to a different sink (tests)

createAiMcpServer returns a StdioHandle whose close() detaches the stdin listener.

createAiMcpHttpServer(options) ​

AiMcpOptions plus mcp-core's ServeHttpOptions. Returns a Promise<HttpHandle> ({ port, url, close() }).

OptionTypeDefaultPurpose
portnumber00 picks an ephemeral port (read it back from handle.port / handle.url)
hoststring'127.0.0.1'Bind address. Loopback by default — this is a dev surface
pathstring'/mcp'JSON-RPC endpoint path. No CLI flag; programmatic only
allowedHostsstring[]loopback names onlyExtra Host hostnames to accept when you deliberately bind off loopback. Compared case-insensitively, port ignored
allowedOriginsstring[]loopback origins onlyExtra Origin values to accept (full scheme + host + port)
allowRequest(origin, host, req) => boolean—Full override of the guard; replaces the loopback/allowedHosts/allowedOrigins checks
tokenstring—Require Authorization: Bearer <token> (constant-time compare, via bearerAuthorizer). Needed for a non-loopback host
authorize(req) => boolean | Promise<boolean>—Custom authentication instead of token; false answers 401
maxBodyBytesnumber1048576Larger bodies get 413
sessionsboolean | { ttlMs?, maxSessions? }falseStreamable-HTTP sessions: initialize answers with an Mcp-Session-Id, every later request must carry it (400 without, 404 unknown/expired/foreign — re-initialize), DELETE ends it, and a separate notifications/cancelled cancels the call it names within the same session only. Off by default so header-less clients keep working
principal(req) => string | undefinedhash of AuthorizationWho owns a session — a request resolving to another principal gets 404

Tool arguments ​

ToolArgumentTypeDefaultPurpose
basalt_analyze · basalt_doctorworkspaceRootstringserver cwdAnalyze a sub-project (absolute or relative). Must stay inside cwd — enforced, symlinks resolved
basalt_planrequeststring— (required)What to build, in natural language
basalt_planworkspaceRootstringserver cwdGround the plan in a sub-project. Must stay inside cwd — enforced
basalt_plantemperaturenumber0Sampling temperature; 0 keeps plans reproducible
basalt_planmaxTokensinteger4096Raise it for a large multi-entity plan that gets truncated
basalt_reviewplan / makeResultobject— (both required)The basalt_plan output and the basalt_make output to critique
basalt_makeplan or requestobject / string— (exactly one)A ready plan, or a request to plan first (needs a provider)
basalt_makeworkspaceRootstringserver cwdMust stay inside the launch directory — enforced, not advisory
basalt_makemode'preview' | 'apply''preview'apply is the only value that writes
basalt_makeforcebooleanfalseAllow overwriting the paths reported in preview.clashes
basalt_makemigratebooleanfalseRun prisma db push after writing (apply only)

Failure modes & troubleshooting ​

Tool-level failures ride in the result (isError: true); only malformed JSON-RPC produces a protocol error code.

MessageKindWhereWhen
basalt_plan needs an AI provider — … (also basalt_make / basalt_review)isErrorproviderHelpcreateProvider threw — no AI_API_KEY, or an unknown AI_PROVIDER
basalt_plan requires a non-empty "request".isErrorbasalt_planThe request argument was missing or blank
basalt_make requires either a "plan" (from basalt_plan) or a "request" to plan.isErrorbasalt_makeNeither entry point was supplied
basalt_review requires a "plan" object (from basalt_plan). / … a "makeResult" object …isErrorbasalt_reviewA required object argument was missing
Refused: workspaceRoot '<x>' escapes the launch directory (<root>)isErrorWorkspaceEscapeErrorbasalt_make: workspaceRoot resolved outside --cwd — by design
Refused: workspaceRoot '<x>' is outside the project root (<root>)isErrorWorkspaceEscapeErrorbasalt_analyze / basalt_doctor / basalt_plan: workspaceRoot (or a symlink in it) resolves outside --cwd
Refused: absolute path not allowed: <p> · Refused: path escapes workspace: <p> · Refused: path resolves outside workspace via symlink: <p>isErrorassertConfinedA target file would land outside the workspace
Refusing to overwrite N existing file(s) without force:true — …isErrorbasalt_makeAn apply hit preview.clashes. Review the diffs, then re-run with force:true
Apply cancelled — not confirmed.isErrorbasalt_makeThe client's elicitation prompt was declined
Refusing to apply without confirmation — …isErrorbasalt_makeThe client cannot elicit (or HTTP transport) and the server was not started with --allow-unconfirmed-apply
Cancelled.isErrorany provider-backed toolA notifications/cancelled aborted the in-flight call
Unknown tool: <name>JSON-RPC -32602mcp-coreThe client called a tool that isn't one of the five
Method not found: <method>JSON-RPC -32601mcp-coreAn MCP method outside the implemented set
Forbidden: host/origin not allowedHTTP 403serveHttpThe HTTP guard rejected a foreign Host/Origin before dispatch
UnauthorizedHTTP 401serveHttp--token is set and the request's bearer token is missing/wrong
failed to start HTTP server — serveHttp: refusing to bind non-loopback host …startupbin--host off loopback without --token
failed to start … refuses to start with NODE_ENV=productionstartup (exit 1)bin / AiMcpProductionErrorNODE_ENV=production in the client's env block or shell, without --allow-production
Bad Request: Mcp-Session-Id header required …HTTP 400serveHttp--sessions is on and the request carries no session header — initialize first
Session not found (expired or unknown) — initialize againHTTP 404serveHttp--sessions is on and the session id is unknown, expired or opened by another token
  • The agent can't see my project — check --cwd points at the project root (where package.json / prisma/schema.prisma live). Resources always use the server's --cwd; only tools accept a per-call workspaceRoot.
  • basalt_doctor shows errors but almost no fixes — expected. Only two rules have auto-fixers; the rest are deliberate manual decisions.
  • Routes 500 after mode:"apply" — a Prisma model was added but the client wasn't regenerated. Re-run apply with migrate:true, or run npx prisma db push yourself, then restart the dev server.
  • Progress never arrives — you're on the HTTP transport. It's request/response only; server→client notifications need stdio.
  • The server starts but the client shows nothing — on stdio, stdout is the JSON-RPC channel. Anything else written there corrupts the stream; the bridge itself only prints to stdout in --http mode.
  • Is this safe to leave connected? — Yes. Nothing writes without an explicit mode:"apply", overwrites need force, DB changes need migrate, and all writes are confined to the project subtree.

See also ​

  • AI-assisted development — the basalt ai CLI the bridge is built on, including the full doctor rule table and provider configuration.
  • @basaltkit/mcp-core — the protocol layer; build your own MCP server on it.
  • MCP (runtime) — expose your app's routes as tools in production.
  • Architecture: docs/rfcs/0001-basaltkit-ai-mcp.md. Source: packages/ai-mcp/src/** (tools, resources, prompts, safety.ts, server.ts, bin.ts).

Released under the MIT License.