RSS

Wheels + Claude: Building a Feature via the stdio MCP

There are two ways to make an AI assistant useful inside a framework. The first is to put it in a sidecar — a chat window glued to the IDE, fed with a vector index of your codebase, generating snippets you copy-paste. That’s where most frameworks landed first, and it’s fine. The model is a smarter Stack Overflow.

The second is to teach the assistant the same vocabulary the framework already uses with humans. You don’t ship “AI code generation”; you ship a CLI with wheels generate model Post title:string, and then you let the assistant call that CLI directly — with the same arguments, the same templates, the same validation — when a developer types “make me a Post model with a title.” The model writes nothing. The framework writes everything, the same way it always has. The model just decides which command to run.

Wheels 4.0 ships the second one. The mechanism is the Model Context Protocol, the transport is stdio, and the implementation is small enough to fit in your head: a single Module.cfc whose public functions become tools by reflection, with a declared denylist to hide the ones that don’t make sense over an RPC. This post walks the architecture and then builds a commenting feature end-to-end through Claude Code talking to that surface.

The shape of the integration

┌─────────────────┐   spawn   ┌────────────────────────┐
│   AI editor     │──────────▶│ wheels mcp wheels      │
│ (Claude/Cursor) │           │ (LuCLI stdio MCP)      │
│                 │ JSON-RPC  │                        │
│                 │◀─────────▶│   Module.cfc           │
└─────────────────┘  (stdio)  │   (public functions    │
                              │    → MCP tools)        │
                              └────────────────────────┘

The AI editor spawns wheels mcp wheels as a subprocess and speaks newline-delimited JSON-RPC 2.0 over stdin and stdout. There’s no port, no socket, no running dev server. The subprocess lives for the duration of the session.

wheels mcp wheels is two pieces of vocabulary glued together: wheels mcp is LuCLI’s generic MCP dispatcher (the binary’s runtime is LuCLI, shipped under the wheels brand), and the trailing wheels is the module name to expose. LuCLI loads cli/lucli/Module.cfc, scans its public functions, and turns each one into a tool named after the function — generate, migrate, test. (The server advertises these bare names; MCP clients namespace them under the server name on their end — Claude Code surfaces them as mcp__wheels__generate.) The MCP protocol — initialize, tools/list, tools/call, the JSON-RPC framing — is all handled by the LuCLI runtime. The Wheels codebase contributes the functions, not the protocol.

This is the design choice worth noticing first: the MCP server is not a separate codebase you maintain alongside the CLI. It is the CLI. Anything you can do as a developer typing wheels migrate latest is something Claude can do by calling the migrate tool with the same latest token. New CLI features become MCP features automatically, with no schema to write and no router to update.

Setup, end to end

wheels mcp

That’s a help command, not a wizard — it writes nothing and prints everything you need:

MCP is built into the Wheels CLI. Run:
  wheels mcp wheels

Configure in Claude Code (.mcp.json):
  {"mcpServers":{"wheels":{"command":"wheels","args":["mcp","wheels"]}}}

Paste that snippet into a .mcp.json at your project root (pretty-printed, if you like):

{
    "mcpServers": {
        "wheels": {
            "command": "wheels",
            "args": ["mcp", "wheels"]
        }
    }
}

That’s the whole thing — a command and its arguments. Claude Code reads it on startup, spawns wheels mcp wheels, and starts speaking JSON-RPC over the subprocess’s stdio. There’s no installation step, no API key, no auth handshake. If the wheels binary is on your PATH and the project has a vendor/wheels/ checkout, you have a working MCP integration.

The project-root .mcp.json is Claude Code’s convention. Other MCP clients point at the same server definition — wheels plus ["mcp", "wheels"] — from their own config locations: Cursor from its settings, OpenCode from a .opencode.json you also write by hand (its wrapper shape appears later in this post). The server definition never changes; only the file it lives in does.

Before restarting your editor, smoke-test the server with the one-shot helper the guide documents:

wheels mcp wheels --once tools/list

That runs a single MCP method and exits — no long-lived stdio session — printing the JSON-RPC response that lists every exposed tool. If the tools are in that output, the editor will find them too. Restart it; on first start it spawns the subprocess and calls initialize and tools/list — and the tools panel should now list generate, migrate, and the rest.

What gets exposed (and what doesn’t)

Tool discovery is a one-line CFML reflection step. LuCLI walks Module.cfc, finds every public function — the return type doesn’t matter — reads its hint: annotation for the description, and emits a tool entry. The tool name is the module name (wheels) joined to the function name with an underscore.

A handful of functions don’t belong over RPC, though, and cli/lucli/Module.cfc names them explicitly:

public array function mcpHiddenTools() {
    var hidden = [
        "main",     // bare `wheels` no-args dispatch target
        "mcp",      // meta command — prints MCP setup instructions
        "d",        // alias for destroy
        "g",        // alias for generate
        "new",      // scaffolds a whole new Wheels project
        "console",  // interactive CFML REPL — not usable over stdio
        "start",    // dev server lifecycle (stateful)
        "stop",     // dev server lifecycle (stateful)
        "browser",  // multi-step browser testing flow
        "mcpToolSpecs",  // per-tool inputSchema registry — not itself a tool
        "$normalizeTestFilter",      // internal helpers, public only
        "$resolveAppTestDataSource"  // so the spec suite can reach them
    ];
    // ...plus a structural sweep: every public function whose name
    // starts with "$" is appended via getMetaData(this).
    return hidden;
}

Each exclusion has a reason that maps to a property of the tool. start and stop manage long-lived processes, which are awkward over a single JSON-RPC call. console needs a bidirectional interactive terminal; stdio MCP gives you one direction per message. new creates a whole project hierarchy and isn’t something a model should fire mid-session without an explicit out-of-band confirmation. mcp itself is hidden because calling it over RPC would let one MCP server spawn another, which is a recursion you don’t want. The aliases (d, g) would just duplicate destroy and generate, and mcpToolSpecs is a registry LuCLI reads, not a command anyone runs. The trailing sweep is defense-in-depth: the $-prefixed functions are internal helpers kept public only so the test suite can reach them, and the getMetaData() pass guarantees a future $helper added without a denylist update can’t quietly leak into tools/list.

mcpHiddenTools() is the module’s own denylist, but it isn’t the first filter. LuCLI’s MCP runtime also auto-excludes its BaseModule plumbing and a few convention functions — init, out, err, getEnv, verbose, getSecret, getAbsolutePath, executeCommand, plus version, showHelp, mcpHiddenTools, and mcpToolSpecsbefore the module denylist is even consulted. That’s why version and showHelp, both perfectly real CLI commands, never appear as tools: they’re framework-level internals, not something the Wheels module chose to hide.

After the exclusions, the surface looks like this:

ToolPurpose
generateCreate models, controllers, migrations, scaffolds, tests, helpers.
destroyRemove generated components, cascading by default.
migrateRun migrations (latest, up, down, info, plus doctor, forget, pretend).
seedRun convention-based seed scripts.
dbDatabase utilities (reset, status, version).
packagesList, search, and add packages from the registry.
testRun the test suite or a named subset.
reloadReload a running dev-server app.
routesPrint the routing table.
infoFramework + project metadata.
analyzeConvention and anti-pattern scanner.
validateConfiguration + model validation.
doctorDiagnose setup issues.
statsProject statistics (model/controller/route counts).
notesFind TODO / FIXME / OPTIMIZE annotations (--annotations customizes the list).
upgradeRead-only breaking-change scanner.
createArgs-driven app creation; delegates to the same scaffolding as new.
deployDeployment orchestration (the wheels deploy family).

Most of these are read-only or strictly additive. destroy is the one to think twice about, but it’s also the one a developer is most likely to want to drive through Claude — “drop the abandoned Comment scaffold, redo it with the new association.”

A worked example: shipping commenting in 90 seconds

The previous post in this series introduced a deliberately toy package called wheels-greeter to walk the manifest fields. Here’s a real flow with no toy in sight: add commenting to an existing Post model on a blog. Migration, association, controller, routes, tests. The whole loop, driven by Claude through the MCP surface.

Type this into Claude Code:

Add commenting to the blog. Each comment belongs to a Post, has an author (string) and body (text). Generate the migration and model, wire the association on Post, run the migration, and add a smoke test that asserts a Post can have many comments.

Claude reads the surface (it called tools/list on session start), recognises that this is a generate-then-migrate-then-test loop, and starts dispatching:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "generate",
    "arguments": { "arg1": "model", "arg2": "Comment",
                   "arg3": "postId:integer", "arg4": "author:string",
                   "arg5": "body:text" }
  }
}

Those arg1/arg2/… keys are not an accident. LuCLI hands the arguments object to the module as the same structured collection a shell invocation produces — bare tokens keyed by position — so this call is exactly wheels generate model Comment postId:integer author:string body:text, one token per key. Commands that have migrated to the ArgSpec parser also accept named keys (you’ll see test do it below); generate and migrate still parse their tokens by hand, so positional keys are the safe currency.

Module.generate() runs, the codegen service substitutes templates, and you see create app/models/Comment.cfc plus a *_create_comments_table.cfc migration land on disk. The response back over stdout is a JSON-RPC result wrapping the CLI’s stdout output as a single text content block:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [
      { "type": "text", "text": "  create  app/models/Comment.cfc\n  create  app/migrator/migrations/20260529140100_create_comments_table.cfc" }
    ]
  }
}

Claude reads the response, sees the migration filename, and continues. Next it edits app/models/Post.cfc directly (no MCP tool needed for a one-line file edit) to add hasMany(name="comments", dependent="delete"). Then:

{ "method": "tools/call",
  "params": { "name": "migrate", "arguments": { "arg1": "latest" } } }

Migration runs, the comments table appears in the DB. Then:

{ "method": "tools/call",
  "params": { "name": "generate",
              "arguments": { "arg1": "test", "arg2": "model", "arg3": "Comment" } } }

A CommentSpec.cfc gets generated under tests/specs/models/. Claude writes the actual assertions into it (expect(post.commentCount()).toBe(2)), then runs the test suite — and test is one of the ArgSpec-migrated commands, so a named key binds directly:

{ "method": "tools/call",
  "params": { "name": "test",
              "arguments": { "filter": "tests.specs.models" } } }

If the test passes, Claude reports back to the developer. If it fails, Claude reads the error output (still text content, still over stdio), patches the model or the spec, and re-runs. The loop is conversational because the protocol is conversational — every tool call returns text, every text is something the model can read and act on.

The whole exchange takes the model maybe four seconds of round-trip plus however long the migration and test suite take. The developer types one prompt; the framework does what it would have done if a developer had typed five commands. The MCP server is the bridge.

Why the reflection model holds up

The temptation, when designing an AI integration, is to invent a separate surface. A new “AI service” CFC. A aiCommands.cfm that wraps the real CLI with a parallel set of carefully-curated entry points. A JSON schema kept by hand and updated whenever someone adds a new generator type.

That’s how you end up with two surfaces that drift. The CLI gains a wheels generate api-resource verb and somebody forgets to add it to the AI surface. The MCP tool advertises an --attributes flag that the underlying CLI renamed three months ago. The two surfaces are different enough that a bug fix in one doesn’t reach the other.

The reflection approach skips that drift by construction. Module.cfc is the CLI. It is also the MCP server. There is no second copy of “what generate accepts” to keep in sync. The tools/list response is regenerated every time the subprocess starts; the descriptions come from the same hint: comments humans read when they run wheels generate --help. If a generator gains a new component type, the MCP tool gains the same type at the same moment, with no extra work.

The cost — through 4.0.3, anyway — was that the MCP schema was less expressive than a hand-curated one. Tool descriptions were one-line hint: strings, and because the module’s functions declare no formal parameters (they consume LuCLI’s structured argument collection), the advertised inputSchema was an empty properties map. Models coped — one-liners plus a couple of canonical examples go a long way — but it was the honest ceiling. That ceiling has since been raised, and in exactly the way the design predicts: develop (shipping in 4.0.4) wires the same ArgSpec declarations the commands already parse with into tools/list, via ArgSpec.toInputSchema() and a mcpToolSpecs() registry on the module. Eight commands advertise typed, described, defaulted parameters today; the hand-rolled token parsers (generate, migrate, and friends) gain entries as they migrate to ArgSpec. Nobody writes a schema by hand — the CLI’s own argument vocabulary is the schema source, so the parse surface and the advertisement can’t drift.

The deprecated HTTP endpoint

If you’ve used the MCP integration in a 3.x build, you may remember a different shape: a dev-server route at /wheels/mcp that spoke Streamable HTTP JSON-RPC, required a running app, and lived in vendor/wheels/public/views/mcp.cfm. That endpoint still exists, with a deprecation notice at the top of the file and a one-time WriteLog(type="warning", ...) on first request. It’s scheduled for removal in a future release.

The reason for the shift is the same as the reason for the design choice above: the HTTP endpoint was a parallel surface that had to be kept in sync. Tool schemas in vendor/wheels/public/mcp/McpServer.cfc were hand-written, separate from the CLI’s behaviour, and drifted. The stdio surface deletes the parallel surface entirely; the CLI is the MCP server, and the MCP server is the CLI.

If you have a .mcp.json or .opencode.json from a 3.x project that points at http://localhost:<port>/wheels/mcp, replace the stanza with the stdio form — the same snippet wheels mcp prints.

What changed while writing this post

Drafting the post turned up two pieces of drift around the MCP config. The first was fixed in the same PR as this post; the second has since been fixed — but only halfway, which makes it the better story.

The repo ships the MCP config as templates in two places — cli/src/templates/ (the CommandBox-era CLI’s setup templates) and app/snippets/ (the app skeleton’s snippet copies). The .mcp.json template — the shape Claude Code reads — was correct in both, pointing at the canonical {"command": "wheels", "args": ["mcp", "wheels"]} stdio surface. The OpenCode template was not. It still pointed at the deprecated HTTP endpoint:

{
    "$schema": "https://opencode.ai/config.json",
    "mcp": {
        "wheels": {
            "url": "http://localhost:{PORT}/wheels/mcp",
            "type": "remote",
            "enabled": true
        }
    }
}

Two problems. First, the URL is the deprecated endpoint that emits a warning every time it’s called and is scheduled for removal. Second, the {PORT} placeholder is a literal string — nothing ever substitutes it, so an OpenCode user copying the template ends up with a .opencode.json that contains the literal characters {PORT} in the URL. It does not resolve to anything. The OpenCode MCP plumbing tries to connect to a host called {PORT} and fails.

The fix is the same shape OpenCode supports for any stdio MCP server — type: "local" plus a command array:

"wheels": {
    "type": "local",
    "command": ["wheels", "mcp", "wheels"],
    "enabled": true
}

This shape was already in tools/build/base/.opencode.json (the canonical reference copy used by the monorepo’s build), and the CHANGELOG entry from when the stdio shift landed actually claimed the templates had been updated everywhere. They hadn’t — two files (cli/src/templates/OpenCodeConfig.json and app/snippets/OpenCodeConfig.json) were missed. Both are now corrected, and an OpenCode user copying the template gets a working stdio config on the first try.

The second piece of drift is smaller, and its arc is the more instructive part. When this post was first drafted, the mcp() meta function in Module.cfc printed “For OpenCode, Cursor, and other AI IDEs, see: docs/command-line-tools/commands/mcp/mcp-configuration-guide.md” — a guide that was planned but never written. The CLI surface was fixed first, in the 4.0.3-era audit sweep: wheels mcp now prints the live guide URL (https://guides.wheels.dev/v4-0-0/command-line-tools/mcp-integration). The deprecated HTTP endpoint lagged behind — for a while the deprecation notice in vendor/wheels/public/views/mcp.cfm and the hand-written McpServer.cfc still advertised the phantom path, in their comments and in the warning they wrote to the log. Fittingly, the surface that was deprecated because it drifted was the last place the fix reached. A later sweep closed it: both references now point to the live guide, and a regression test (McpDeprecationNoticeStaleDocPathSpec) fails the build if the phantom path ever creeps back.

Neither of these is a code-path bug. They’re documentation-and-templates drift, the same shape as the package-system fixes from the previous post. The pattern keeps holding: writing the article forces you to actually walk every path a reader will walk, and the parts where the docs disagree with the code are exactly the parts where the next person was going to get stuck.

The next post in the series picks up the other surface where 4.0 quietly changed posture: Beyond findAll — scopes, enums, and the chainable query builder. Coming next.

Comments

Newsletter

Release notes and new posts, once a month. No spam.

Prefer RSS? Subscribe to the feed →