@happyvertical/smrt-app-mcp
App-runtime MCP server scaffolding — expose your deployed SMRT app's Tier 1 runtime MCP surface (live CRUD / list / AI tools) over HTTP, then drive it from an MCP client.
Overview
@happyvertical/smrt-app-mcp wraps the Tier 1 (Runtime) MCP
surface that smrt-core generates from your @smrt() classes and exposes it over HTTP from your deployed application. Where the
Tier 1 tools themselves (one <class>_list / <class>_get / <class>_create / ... per object) come from MCPGenerator, this package decides which of them an app publishes, who may call them, and what server-trusted fields get injected
on the way through.
It provides two things:
- Core —
createMcpAppServer(...)returns a framework-agnostic{ listTools, callTool }server bound to your database and an allow-list of class names. - SvelteKit adapters (
./sveltekit) —mountMcpToolsRoute/mountMcpCallRouteturn that server into the GET/POST handlers a SvelteKit+server.tsexpects. The core is deliberately transport-neutral, so additional adapters (standalone Node HTTP, serverless, Express/Hono) can be added as sibling subpaths without touching it.
Talk to your app from an MCP client
Once the two routes below are live, your deployed app speaks MCP over HTTP at /api/mcp/tools and /api/mcp/call. Point any MCP client that can reach
those endpoints at them — for example a desktop assistant via a stdio-to-HTTP bridge — and it
can list and invoke your app's published tools (subject to the same auth your routes enforce).
Installation
pnpm add @happyvertical/smrt-app-mcpDefine the server
createMcpAppServer binds the generator to your database, an allow-list of class
names, a public-tool policy, and optional per-tool workflow assertions:
// src/lib/server/mcp.ts
import { createMcpAppServer, McpAccessError } from '@happyvertical/smrt-app-mcp';
import { adminResources } from '$lib/admin/resources';
import { getDbConfig } from './db';
export const mcpServer = createMcpAppServer({
// SMRT context bag (db, etc.), resolved lazily per call.
smrtOptions: () => ({ db: getDbConfig() }),
serverInfo: { name: 'my-app', version: '0.1.0' },
// Only publish a subset of your @smrt() classes, not everything.
allowedClassNames: adminResources.map((r) => r.className),
// Read-only tools unauthenticated callers may use (default: none).
publicToolPatterns: () =>
(process.env.MY_APP_PUBLIC_MCP_TOOLS ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
// Per-tool guards. Throw McpAccessError to reject; mutate args to
// inject server-trusted fields.
workflowAssertions: {
application_update: (args, user) => {
if (!user?.id) throw new McpAccessError(401, 'sign in first');
args.approvedByUserId = user.id;
},
},
});Options
| Option | Type | Required | Description |
|---|---|---|---|
smrtOptions | () => Record<string, unknown> | Yes | Thunk returning the SMRT context bag (db, etc.) passed to the generator per
call. A function so env vars resolve lazily at call time. |
serverInfo | { name, version, description? } | Yes | Server identity surfaced in the MCP protocol. |
allowedClassNames | readonly string[] | Yes | SMRT class names the app publishes. Tools whose name does not start with one of these
classes (lowercased + _) are filtered out, even if SMRT generated them. |
publicToolPatterns | () => readonly string[] | No | Thunk of glob-ish patterns (* wildcards) for read-only tools anonymous
callers may use. Defaults to empty — everything requires auth. |
workflowAssertions | Record<string, McpWorkflowAssertion> | No | Per-tool guards keyed by tool name. Each runs after tool resolution and before the call;
throw McpAccessError to reject, or mutate args to inject trusted
fields. |
Mount the SvelteKit routes
Two thin route files turn the server into HTTP endpoints:
// src/routes/api/mcp/tools/+server.ts
import { mountMcpToolsRoute } from '@happyvertical/smrt-app-mcp/sveltekit';
import { mcpServer } from '$lib/server/mcp';
export const GET = mountMcpToolsRoute(mcpServer);// src/routes/api/mcp/call/+server.ts
import { mountMcpCallRoute } from '@happyvertical/smrt-app-mcp/sveltekit';
import { mcpServer } from '$lib/server/mcp';
export const POST = mountMcpCallRoute(mcpServer);By default both adapters read the authenticated user from event.locals.user; pass resolveUser / resolveAuthenticated in the mount options to source it
elsewhere.
// GET /api/mcp/tools -> { tools: MCPTool[] }
// POST /api/mcp/call body: { name, arguments }
//
// Authenticated callers see every allow-listed tool; anonymous callers
// see only read-only tools matching publicToolPatterns. A call to a
// non-public tool without a user throws McpAccessError(401).
fetch('/api/mcp/call', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'application_list',
arguments: { limit: 20 },
}),
});Access model
The server enforces a three-layer policy on every request:
- Allow-list — only tools whose name starts with an
allowedClassNamesprefix are ever returned or callable, so decorating a class with@smrt()does not automatically expose it through this server. - Public-tool policy — unauthenticated callers see only read-only tools (names ending in
_list/_get) that also match apublicToolPatternsentry. A call to any non-public tool without a user throwsMcpAccessError(401); an unknown tool throwsMcpAccessError(404). - Workflow assertions — per-tool hooks run last and can reject the call or
clamp arguments (e.g. force
approvedByUserIdto the authenticated user's id), keeping that policy in your app rather than in the framework.
Exports
| Export | From | Description |
|---|---|---|
createMcpAppServer | . | Build the framework-agnostic server core. |
McpAccessError | . | Error carrying an HTTP status; thrown to reject a call. |
matchesToolPattern, isReadOnlyToolName, isPublicToolName, isAllowedCoreTool, classNamePrefixes | . | Tool-name policy helpers (also used internally) for custom transports. |
mountMcpToolsRoute, mountMcpCallRoute | ./sveltekit | SvelteKit GET/POST adapters for an McpAppServer. |
Types: CreateMcpAppServerOptions, McpAppServer, McpAppUser, McpWorkflowAssertion, CallToolInput, ListToolsInput, and the two thunk aliases are all exported from the root entry.