s-m-r-t
Open the s-m-r-t source on GitHub Switch to dark color scheme
← Task guides

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

More in Connect agents

This is the only guide in this family.

Manifest
RESTMCPWebMCPCLIBrowser
Each interface reads the same declared model capabilities.

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.
src/lib/objects/Article.ts
typescript
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.
generate.sh
bash
# 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.md

Check 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.
smoke-test.sh
bash
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
.mcp.json
json
{
  "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.
src/lib/server/mcp.ts
typescript
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.
src/routes/api/mcp/+server.ts
typescript
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.
tools-list.sh
bash
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.
tools-call.sh
bash
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.
header-mismatch.json
json
{
  "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
}

Terminate authorization in front of the route

This package validates the request envelope, not the caller. It does not implement an authorization server and does not check bearer tokens; it trusts the principal your application put on the request. For anything reachable from the internet, that principal must be the output of a validated token, not of a header.

  • Validate the token signature, issuer, audience or resource, expiry, and scopes at the gateway, then populate the principal.
  • Compare issuer values as exact strings; a trailing-slash difference is a different issuer.
  • Leave public tool patterns empty unless anonymous read access is a decision someone made on purpose.
  • Header presence is not authentication. The required headers exist to keep the envelope honest, nothing more.

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.
bridge.json
json
{
  "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.