Task guide
Expose a running application over MCP
Turn existing objects into agent tools. Generate a local stdio server. Mount the stateless HTTP endpoint. Call it with curl. Connect a real client. Keep the authorization boundary in the correct location.
Verified against s-m-r-t 0.42.4
Decide what each object exposes
The decorator that already describes your model is where MCP is turned on. mcp: true exposes the generated CRUD actions; the object form narrows that to a deliberate set. Tool names are the lowercased class name, an underscore, and the action, so the vocabulary is predictable from the model alone.
- mcp accepts true, or an object with include and exclude lists covering CRUD actions and custom methods.
- A tool is treated as read-only when its name ends in _list or _get. That suffix rule decides what an unauthenticated caller can ever see.
- Collection classes are scanned too, so a plain build also produces articlecollection_* tools. The application allow-list below is where you drop them.
import { smrt, SmrtObject } from '@happyvertical/smrt-core';
@smrt({
api: true,
mcp: { include: ['list', 'get', 'update'] }
})
export class Article extends SmrtObject {
title = '';
body = '';
status = 'draft';
}
// Generates article_list, article_get, article_update.Generate the local stdio server
The generated stdio server is the generated local MCP surface for an application agent running on the same machine as the application. It reads its database credentials from the environment and has no per-request principal, which is exactly why it stays local.
- --version after the subcommand sets the generated server version; a global --version before the subcommand still prints the CLI version.
- The server reads DATABASE_TYPE, DATABASE_URL, and SMRT_MCP_PERMISSIONS. When the build contains tenant-scoped classes it also reads SMRT_MCP_TENANT_ID and SMRT_MCP_ALLOW_CROSS_TENANT.
- The generated server imports its runtime dependencies from the consuming project, so strict package-manager layouts require them to be declared there.
- Its trust boundary is the process that launched it. Never expose it remotely.
# Generation commands are hyphenated; generate-mcp also answers to
# generate-mcp-server and mcp. The default output is runnable JavaScript.
npx smrt generate-mcp --name my-app --version 0.1.0
# Three files, each reported by absolute path:
# ✅ Generated MCP server: .../.smrt/mcp-server/index.js
# ✅ Generated Claude config example: .../.smrt/mcp-server/claude-config.example.json
# ✅ Generated MCP documentation: .../.smrt/mcp-server/MCP-README.mdCheck it before wiring a client
A stdio server speaks JSON-RPC on stdout, so you can drive it from a shell and see the tool catalog directly. This is the fastest way to confirm your classes were scanned before a client failure sends you looking in the wrong place.
- The default .js output runs directly with Node. A .ts target needs a type-stripping runtime or tsx.
- Anything written to stdout that is not JSON-RPC corrupts the channel, so keep diagnostics on stderr.
- An empty tool list means the scan found no classes, not that MCP is off.
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{
"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientCapabilities":{}}}}' \
| node .smrt/mcp-server/index.js
# {"result":{"tools":[{"name":"article_create",...},
# {"name":"article_get",...}],
# "resultType":"complete","cacheScope":"private"},"jsonrpc":"2.0","id":1}Point a local client at it
With the server running from a file, the client configuration is small. Use an absolute path: a client launched from elsewhere has neither your working directory nor your package manager.
- The generated claude-config.example.json mirrors the resolved output path, including a custom --output-path when you provide one.
- Claude Code can take the same thing as: claude mcp add my-app -- node /absolute/path/.smrt/mcp-server/index.js
{
"mcpServers": {
"my-app": {
"type": "stdio",
"command": "node",
"args": ["/absolute/path/to/.smrt/mcp-server/index.js"],
"env": {
"DATABASE_TYPE": "sqlite",
"DATABASE_URL": "file:./data/app.db"
}
}
}
}Describe the application server
The HTTP surface is a different object with a different job. createMcpAppServer wraps generated tools with four independent gates. The allow-list sets the reachable classes. Public patterns set what an anonymous caller can see. Tool policy makes a decision for each principal. Workflow assertions run before dispatch.
- A tool outside the allow-list gets the safe not-found response rather than a denial that confirms it exists.
- A policy that throws is treated as a denial, so failures close rather than open.
- Denials carry the non-retryable mcp_tool_access_denied code and deliberately reveal nothing about the tool, principal, or policy.
import { createMcpAppServer, McpAccessError } from '@happyvertical/smrt-app-mcp';
import { getDbConfig } from './db';
export const mcpServer = createMcpAppServer({
smrtOptions: () => ({ db: getDbConfig() }),
serverInfo: { name: 'my-app', version: '0.1.0' },
// Only these classes exist as far as this surface is concerned.
allowedClassNames: ['Article'],
// Empty by default: nothing is anonymous until an operator opts in,
// and even then only _list and _get tools pass the base rule.
publicToolPatterns: () => ['article_*'],
toolPolicy: ({ tool, principal }) => {
if (!principal) return tool.name === 'article_get';
return principal.roles?.includes('editor') ?? false;
},
workflowAssertions: {
article_update: (args, user) => {
if (!user?.id) throw new McpAccessError(401, 'sign in first');
args.reviewedByUserId = user.id;
}
}
});Mount one route
This route is not generated for you — the Vite plugin writes REST routes, not this one. Export the handler as POST only, and let the session layer put the principal on locals before it runs.
- A fresh protocol server is built per request, so no session id, sticky routing, or held stream is involved.
- The mount serves server/discover, tools/list, and tools/call. It always reports tools and adds the optional tasks extension only when an allowed object enables a task action.
- Tool discovery is sorted by name, so the catalog is deterministic.
- Exporting it as GET returns 405 with code -32000.
import { mountMcpRoute } from '@happyvertical/smrt-app-mcp/sveltekit';
import { mcpServer } from '$lib/server/mcp';
export const POST = mountMcpRoute(mcpServer);
// resolvePrincipal defaults to event.locals.user. Override it when your
// application stores the principal somewhere else:
// export const POST = mountMcpRoute(mcpServer, {
// resolvePrincipal: (event) => event.locals.apiClient ?? null
// });Call it with curl
A stock client sends more than the JSON-RPC body. The request has an envelope that names the protocol revision. Its headers must agree with the body. Send the request manually to confirm that the endpoint is live and the policy has the intended result.
- The _meta envelope is required. Both the protocol version and the client capabilities keys must be present; capabilities may be an empty object.
- 2026-07-28 is the only revision this release accepts.
- The MCP-Protocol-Version header is optional, but if you send it, it must match the envelope.
- The anonymous catalog above contains only the read-only tools, because the base rule admits nothing else without a principal.
curl -sS -X POST 'https://app.example.com/api/mcp' \
-H 'content-type: application/json' \
-H 'mcp-protocol-version: 2026-07-28' \
-H 'mcp-method: tools/list' \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}'
# HTTP 200, content-type: application/json
# {"jsonrpc":"2.0","id":1,"result":{
# "resultType":"complete",
# "tools":[{"name":"article_get",...},{"name":"article_list",...}],
# "ttlMs":86400000,"cacheScope":"private"}}Call a tool
A tool call adds one more header. Mcp-Name must byte-match the name in the body, which is what lets a proxy route or audit a call without parsing the payload.
- Mcp-Name is required for tools/call, prompts/get, and resources/read, and for nothing else.
- A generated tool on a non-public model still requires the application’s own authentication. Without a principal the call returns HTTP 200 carrying an error result — "Authentication required" — rather than a protocol error.
curl -sS -X POST 'https://app.example.com/api/mcp' \
-H 'content-type: application/json' \
-H 'mcp-protocol-version: 2026-07-28' \
-H 'mcp-method: tools/call' \
-H 'mcp-name: article_list' \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "article_list",
"arguments": { "limit": 5 },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}'Read the refusals
Four rejections account for nearly every failed request against this endpoint, and they are distinguishable at a glance. Knowing which is which tells you whether to look at your client, your gateway, or your policy.
- HTTP 400 with code -32020 means the headers and body disagree — a missing or mismatched Mcp-Method or Mcp-Name. The message names exactly which.
- HTTP 400 with code -32022 means the request did not name a protocol version: the _meta envelope is missing or names something other than 2026-07-28. The response lists what is supported.
- HTTP 415 means the content type was not application/json.
- HTTP 405 with code -32000 means the request reached the route on the wrong HTTP method.
{
"jsonrpc": "2.0",
"error": {
"code": -32020,
"message": "Bad Request: the request headers and body disagree: the body names method tools/list but the required Mcp-Method header is absent",
"data": {
"mismatch": {
"header": "(missing)",
"body": "the body names method tools/list but the required Mcp-Method header is absent"
}
}
},
"id": 1
}Connect a deployed application to a local client
A local MCP client speaks stdio, and the generated stdio server must not leave the machine. The bridge closes that gap: it runs locally, authenticates through the first-party terminal device flow, and forwards to your deployment. It talks to the REST-shaped aliases rather than the modern mount, so mount those alongside it.
- The bridge requests /api/mcp/tools and /api/mcp/call, which are mountMcpToolsRoute and mountMcpCallRoute — deprecated, but the only shape it speaks today.
- --env-prefix is required, or SMRT_MCP_ENV_PREFIX in its place; the prefix names the SERVER_URL and TOKEN variables it reads.
- A stored token is only sent when its server URL exactly matches the request target, so a token cannot follow you to another host.
- smrt-mcp-bridge is the only binary the package publishes. The device flow — /api/cli/auth/start, then polling /api/cli/auth/token — reaches you through an application CLI you assemble with its exported createAppCli.
{
"mcpServers": {
"acme": {
"type": "stdio",
"command": "npx",
"args": [
"-y", "-p", "@happyvertical/smrt-app-cli",
"smrt-mcp-bridge", "--env-prefix=ACME", "--name=acme-mcp"
],
"env": {
"ACME_SERVER_URL": "https://app.acme.example",
"ACME_TOKEN": "<token from the device-code login>"
}
}
}
}Keep the surfaces apart
It is worth restating which surface does what, because the failure mode of confusing them is an agent with the wrong authority. The Development MCP server reads your workspace and cannot touch application data. Generated local MCP and hosted application MCP are servers that operate on data. WebMCP is not a separate server; it registers tools in the browser page and calls the generated REST surface as the signed-in user. None of the three application-facing surfaces know anything about your repository layout.