MCP
Model Context Protocol (MCP) server for the Strav framework. Expose your application's capabilities to AI clients like Claude Desktop through tools, resources, and prompts. Supports both stdio (local AI clients) and HTTP (hosted deployments) transports.Installation
bun add @strav/mcp
bun strav install mcp
The install command copies config/mcp.ts into your project. The file is yours to edit.
Setup
1. Register McpManager
Using a service provider (recommended)
import { McpProvider } from '@strav/mcp'
app.use(new McpProvider())
The McpProvider registers McpManager as a singleton. It depends on the config provider, loads your registration file, and auto-mounts the HTTP transport on the router.
To disable HTTP transport auto-mounting:
app.use(new McpProvider({ mountHttp: false }))
Manual setup
import McpManager from '@strav/mcp'
app.singleton(McpManager)
app.resolve(McpManager)
2. Configure
Editconfig/mcp.ts:
import { env } from '@strav/kernel'
export default {
name: env('MCP_NAME', undefined),
version: env('MCP_VERSION', '1.0.0'),
register: 'mcp/server.ts',
http: {
enabled: env('MCP_HTTP', 'true').bool(),
path: env('MCP_PATH', '/mcp'),
},
}
3. Register tools, resources, and prompts
Create the registration file referenced in your config (e.g.mcp/server.ts):
import { mcp } from '@strav/mcp'
import { z } from 'zod'
import Database from '@strav/database'
// Tool — an action the AI can invoke
mcp.tool('get-user', {
description: 'Fetch a user by ID',
input: { id: z.number() },
handler: async ({ id }, { app }) => {
const db = app.resolve(Database)
const [user] = await db.sql`SELECT * FROM users WHERE id = $\{id\}`
return { content: [{ type: 'text', text: JSON.stringify(user) }] }
},
})
// Resource — data the AI can read
mcp.resource('strav://schema', {
name: 'Database schema',
description: 'Current database schema overview',
mimeType: 'application/json',
handler: async (uri, params, { app }) => {
return { contents: [{ uri: uri.href, text: '{ "tables": [...] }' }] }
},
})
// Prompt — a reusable prompt template
mcp.prompt('summarize', {
description: 'Summarize a topic',
args: { topic: z.string() },
handler: async ({ topic }) => ({
messages: [{
role: 'user',
content: { type: 'text', text: `Summarize: $\{topic\}` },
}],
}),
})
Tools
Tools are functions that AI clients can call. Define typed inputs with Zod schemas.
import { mcp } from '@strav/mcp'
import { z } from 'zod'
mcp.tool('create-post', {
description: 'Create a new blog post',
input: {
title: z.string().describe('Post title'),
body: z.string().describe('Post body in markdown'),
published: z.boolean().optional().describe('Publish immediately'),
},
handler: async ({ title, body, published }, { app }) => {
const post = await Post.create({ title, body, published })
return {
content: [{ type: 'text', text: `Created post #$\{post.id\}: $\{post.title\}` }],
}
},
})
Tools without input schemas are also supported:
mcp.tool('list-posts', {
description: 'List all published posts',
handler: async (params, { app }) => {
const posts = await Post.where('published', true).get()
return {
content: [{ type: 'text', text: JSON.stringify(posts) }],
}
},
})
Resources
Resources expose data via URIs. Use URI templates for dynamic resources.
// Static resource
mcp.resource('strav://config', {
name: 'App configuration',
description: 'Current application configuration',
mimeType: 'application/json',
handler: async (uri) => ({
contents: [{ uri: uri.href, text: JSON.stringify({ env: 'production' }) }],
}),
})
// Dynamic resource with URI template
mcp.resource('strav://posts/{id}', {
name: 'Blog post',
description: 'A blog post by ID',
mimeType: 'application/json',
handler: async (uri, { id }, { app }) => {
const post = await Post.find(Number(id))
return {
contents: [{ uri: uri.href, text: JSON.stringify(post) }],
}
},
})
Prompts
Prompts are reusable templates with typed arguments.
mcp.prompt('code-review', {
description: 'Review code and suggest improvements',
args: {
language: z.string(),
code: z.string(),
},
handler: async ({ language, code }) => ({
messages: [{
role: 'user',
content: {
type: 'text',
text: `Review this $\{language\} code and suggest improvements:\n\n$\{code\}`,
},
}],
}),
})
Transports
Stdio (Claude Desktop)
For local AI clients that communicate over stdin/stdout. Start with the CLI:
bun strav mcp:serve
Add to your Claude Desktop config (claude_desktop_config.json):
{
"mcpServers": {
"my-app": {
"command": "bun",
"args": ["strav", "mcp:serve"],
"cwd": "/path/to/your/app"
}
}
}
HTTP (hosted deployments)
For web-based AI clients. Enabled by default when using the provider. MountsPOST, GET, and DELETE handlers at the configured path (default: /mcp).
import { mountHttpTransport } from '@strav/mcp'
import { router } from '@strav/http'
const transport = mountHttpTransport(router)
Handler context
Every handler receives aToolHandlerContext with access to the DI container:
mcp.tool('send-email', {
description: 'Send an email to a user',
input: { userId: z.number(), subject: z.string(), body: z.string() },
handler: async ({ userId, subject, body }, { app }) => {
const db = app.resolve(Database)
const mailer = app.resolve(MailManager)
const [user] = await db.sql`SELECT * FROM users WHERE id = $\{userId\}`
await mailer.to(user.email).subject(subject).text(body).send()
return { content: [{ type: 'text', text: `Email sent to $\{user.email\}` }] }
},
})
Inspection
List what's registered:
import { mcp } from '@strav/mcp'
mcp.registeredTools() // ['get-user', 'create-post', ...]
mcp.registeredResources() // ['strav://config', 'strav://posts/{id}', ...]
mcp.registeredPrompts() // ['summarize', 'code-review', ...]
// Get a specific registration
const tool = mcp.getToolRegistration('get-user')
// { name: 'get-user', description: '...', input: {...}, handler: [Function] }
Events
MCP operations emit events through theEmitter:
| Event | Payload | When |
|---|---|---|
mcp:tool-registered |
{ name } |
Tool registered |
mcp:resource-registered |
{ uri } |
Resource registered |
mcp:prompt-registered |
{ name } |
Prompt registered |
mcp:tool-called |
{ name, params } |
Tool invoked by AI client |
mcp:resource-read |
{ uri } |
Resource read by AI client |
mcp:prompt-called |
{ name, args } |
Prompt used by AI client |
mcp:stdio-starting |
— | Stdio transport starting |
mcp:stdio-connected |
— | Stdio transport connected |
mcp:stdio-closed |
— | Stdio transport closed |
mcp:http-mounted |
{ path } |
HTTP transport mounted |
mcp:http-request |
{ method, path } |
HTTP request received |
import Emitter from '@strav/kernel'
Emitter.on('mcp:tool-called', ({ name, params }) => {
console.log(`Tool "$\{name\}" called with`, params)
})
CLI commands
| Command | Description |
|---|---|
bun strav mcp:serve |
Start the MCP server in stdio mode |
bun strav mcp:list |
List all registered tools, resources, and prompts |
Testing
CallMcpManager.reset() in test teardown to clear all registrations:
import { beforeEach, test, expect } from 'bun:test'
import McpManager, { mcp } from '@strav/mcp'
beforeEach(() => {
McpManager.reset()
})
test('registers a tool', () => {
mcp.tool('test-tool', {
description: 'A test tool',
handler: async () => ({ content: [{ type: 'text', text: 'ok' }] }),
})
expect(mcp.registeredTools()).toEqual(['test-tool'])
})
Full example
// mcp/server.ts
import { mcp } from '@strav/mcp'
import { z } from 'zod'
import Database from '@strav/database'
mcp.tool('list-users', {
description: 'List all users with optional role filter',
input: { role: z.string().optional() },
handler: async ({ role }, { app }) => {
const db = app.resolve(Database)
const users = role
? await db.sql`SELECT id, name, email FROM users WHERE role = $\{role\}`
: await db.sql`SELECT id, name, email FROM users`
return { content: [{ type: 'text', text: JSON.stringify(users) }] }
},
})
mcp.tool('update-user', {
description: 'Update a user field',
input: {
id: z.number(),
field: z.enum(['name', 'email', 'role']),
value: z.string(),
},
handler: async ({ id, field, value }, { app }) => {
const db = app.resolve(Database)
await db.sql`UPDATE users SET $\{db.sql(field)\} = $\{value\} WHERE id = $\{id\}`
return { content: [{ type: 'text', text: `Updated user #$\{id\}` }] }
},
})
mcp.resource('strav://users/{id}', {
name: 'User profile',
description: 'User details by ID',
handler: async (uri, { id }, { app }) => {
const db = app.resolve(Database)
const [user] = await db.sql`SELECT * FROM users WHERE id = $\{Number(id)\}`
return { contents: [{ uri: uri.href, text: JSON.stringify(user) }] }
},
})
mcp.prompt('onboard-user', {
description: 'Generate an onboarding email for a new user',
args: { name: z.string(), role: z.string() },
handler: async ({ name, role }) => ({
messages: [{
role: 'user',
content: {
type: 'text',
text: `Write a friendly onboarding email for $\{name\} who joined as $\{role\}.`,
},
}],
}),
})