Skip to content

MCP servers

Files in mcp/ turn your project into a Model Context Protocol server, so an AI assistant can call into it. The conventions are the ones you already know: drop a file in a directory and it is live, [param] segments work, and ctx carries the same services your HTTP routes use.

src/
  mcp/
    tools/        actions a model can invoke
    resources/    data a client can read by URI
    prompts/      reusable message templates

Setup

The MCP SDK and zod are optional peer dependencies — projects without an mcp/ directory never load them. Install them when you add your first tool:

bash
npm install @modelcontextprotocol/sdk zod

That is the whole setup. bootstrap() detects mcp/ and serves the endpoint at /mcp alongside your routes.

Tools

A tool is an action the model decides to call. The default export of mcp/tools/searchNotes.ts becomes the tool searchNotes:

ts
import { tool } from "clovejs/mcp"
import { z } from "zod"

export default tool({
  description: "Full-text search across the user's notes",
  input: z.object({
    query: z.string().describe("Search query"),
    limit: z.number().int().max(50).default(10),
  }),
  async handler({ query, limit }, ctx) {
    return ctx.notes.search(query, { limit })
  },
})

The input schema does three jobs at once: it is published to the client as JSON Schema, it validates and applies defaults before your handler runs, and it types the handler's first argument — query is a string and limit is a number with no annotation from you.

description is the single most important field. It is what the model reads when deciding whether this tool is the right one, so write it for the model.

Return values

Whatever you return is serialised for you, exactly like the JSON middleware does for routes:

Handler returnsThe client receives
a stringone text block
an object or arrayone text block of JSON
undefined or nullno content
a content block, or an array of themthose blocks, untouched

To control the blocks yourself, return them directly:

ts
return [
  { type: "text", text: "Here is the chart:" },
  { type: "image", data: png.toString("base64"), mimeType: "image/png" },
]

Annotations

.meta() works as it does on routes. The four known keys become MCP annotations, which clients use to decide what needs confirmation:

ts
export default tool({
  description: "Delete a note",
  input: z.object({ id: z.string() }),
  async handler({ id }, ctx) {
    await ctx.notes.remove(id)
  },
}).meta({
  destructive: true,
  idempotent: true,
})
KeyMeaning
readOnlyThe tool does not modify anything
destructiveThe tool may perform irreversible updates
idempotentCalling it twice with the same input has no extra effect
openWorldThe tool touches systems outside this server

These are advisory. A client is free to ignore them, so enforce anything that matters inside the handler.

Resources

A resource is data the client reads by URI. The URI comes from the file path: the first directory segment becomes the scheme, the rest becomes the path, and [param] segments become {param} template variables.

mcp/resources/notes/[id].ts serves notes://{id}:

ts
import { resource, error } from "clovejs/mcp"

export default resource({
  description: "A single note by id",
  mimeType: "text/markdown",
  async handler({ id }, ctx) {
    const note = await ctx.notes.findById(id)
    if (!note) throw error(404, { message: "No such note" })
    return note.markdown
  },
})
FileURI
mcp/resources/config/app.tsconfig://app
mcp/resources/notes/[id].tsnotes://{id}
mcp/resources/db/users/[id]/tags.tsdb://users/{id}/tags
mcp/resources/config.tsconfig://

Pass uri in the definition when you want something the file path cannot express. Returning a Buffer or Uint8Array sends a base64 blob instead of text.

Prompts

A prompt is a template the user picks explicitly, rather than something the model chooses. mcp/prompts/summarize.ts becomes the prompt summarize:

ts
import { prompt } from "clovejs/mcp"
import { z } from "zod"

export default prompt({
  description: "Summarize a note",
  input: z.object({ noteId: z.string() }),
  async handler({ noteId }, ctx) {
    const note = await ctx.notes.findById(noteId)
    return `Summarize the following note in 3 bullets:\n\n${note.markdown}`
  },
})

Return a string for a single user message, or an array of { role, content } objects for a conversation. Prompt arguments must be z.string() — the protocol transports them as strings, and the project refuses to boot if you declare anything else.

Naming

Tool and prompt names come from the filename, with nested files flattening in camelCase — the same rule services/ and di/ use for ctx keys:

FileName
mcp/tools/searchNotes.tssearchNotes
mcp/tools/notes/search.tsnotesSearch
mcp/tools/notes/index.tsnotes

Set name in the definition to override it. Two files claiming the same name, or the same resource URI, is a boot error naming both files.

Dependency injection

Handlers receive ctx as their second argument, fully typed by the generated .clove/types.d.ts — the same context your routes and WebSocket handlers get. Services are shared, so a tool and a route calling ctx.notes talk to one instance.

The three lifetimes map onto MCP like this:

LifetimeScope in an MCP server
singletonThe whole process, as always
sessionOne MCP session — a client's connection, identified by Mcp-Session-Id
requestOne tool call, resource read or prompt render

Session scope is what makes stateful tools work. Declare a session value and it persists across calls from the same client, and starts fresh for the next one:

ts
// di/currentUser.ts
export default di({ lifetime: "session", value: null as User | null })
ts
// mcp/tools/login.ts
export default tool({
  description: "Authenticate for this session",
  input: z.object({ token: z.string() }),
  async handler({ token }, ctx) {
    ctx.currentUser = await ctx.auth.verify(token)
    return `Signed in as ${ctx.currentUser.name}`
  },
})

Sessions are backed by the same store as HTTP sessions, so a custom services/sessionStore.ts covers both. See Sessions.

The third handler argument

After input and ctx comes a bag of per-call extras:

ts
async handler(input, ctx, { sessionId, signal, log }) {
  log("info", "starting the slow part")
  const rows = await ctx.db.query(sql, { signal })
  return rows
}
FieldWhat it is
sessionIdThe MCP session id, or null over stdio
authThe authenticated principal, or null when no mcp/auth.ts is defined
signalAborts when the client cancels the call or disconnects
log(level, message)Sends a log message to the client
uriResources only: the fully resolved URI that was requested

Errors

error(status, body) behaves the way it does in a route, with the status deciding who is told what:

ts
throw error(404, { message: "No such note" })

A 4xx is the model's problem — bad arguments, a missing record — so the message is passed through verbatim as a failed tool result. The model reads it and can correct itself, which is usually what you want.

Anything else is your problem. It is logged in full on the server, and the client is told only that an internal error occurred, so stack traces and internal detail do not leak into a model's context. Outside production the message is included, matching exposeErrors for HTTP.

Resources and prompts have no way to carry a failure in their result, so for those every error becomes a protocol error — with the same split over which message the client sees.

Middlewares do not run

HTTP middlewares wrap routes, not MCP calls: there is no req/res pair to give them, exactly as with WebSocket upgrades. Authorize inside the handler using ctx and auth, or put shared logic in a service both call. For the token check itself, reach for mcp/auth.ts instead.

Authentication

A single mcp/auth.ts turns the server into an OAuth 2.1 protected resource. Every request to the MCP endpoint must then carry a valid bearer token; the runtime rejects an unauthenticated one with 401 and a WWW-Authenticate header, and publishes protected-resource metadata (RFC 9728) at /.well-known/oauth-protected-resource so a client can discover where to get a token.

ts
// mcp/auth.ts
import { mcpAuth, error } from "clovejs/mcp"

export default mcpAuth({
  metadata: {
    authorizationServers: ["https://auth.example.com"],
    scopesSupported: ["notes:read", "notes:write"],
  },
  async authenticate({ ctx, token, resource }) {
    if (!token) throw error(401, { message: "Bearer token required" })
    const claims = await ctx.keys.verify(token)   // your verifier, via ctx
    return {
      subject: claims.sub,
      tenant: claims.org,        // scopes the session; see below
      scopes: (claims.scope ?? "").split(" ").filter(Boolean),
      claims,
      token,
    }
  },
})

authenticate receives the root ctx (so it can reach singleton services like a JWKS verifier), the raw req, the bearer token, and the absolute resource URL to check as the token audience. Throw error(401, …) for a missing or invalid token — the runtime turns it into the challenge above — or error(403, …) for a valid token that lacks access.

The principal it returns is handed to every tool, resource and prompt as auth:

ts
async handler(input, ctx, { auth }) {
  if (!auth.scopes.includes("notes:write")) {
    throw error(403, { message: "Needs the notes:write scope" })
  }
  return ctx.notes.create(auth.tenant, input)
}

Multi-tenancy

The tenant field is special: the runtime binds each MCP session to the tenant that opened it, and refuses a later request whose token names a different tenant with a 403. So one connection can only ever touch one tenant's data — scope your services by auth.tenant and the boundary holds.

The multi-tenant-mcp example is a complete, runnable server: RS256/JWKS verification, per-tenant isolation, scope enforcement, and a built-in dev authorization server so it runs with no external services.

Inspecting the surface

clove mcp prints everything the server exposes, the analogue of clove routes:

bash
$ npx clove mcp
Endpoint  /mcp

tool      searchNotes              Full-text search across the user's notes
tool      createNote               Create a new note
resource  notes://{id}             A single note by id
resource  config://app             Server configuration
prompt    summarize                Summarize a note

Connecting a client

Over HTTP

The endpoint is Streamable HTTP at /mcp. In an editor's MCP configuration:

json
{
  "mcpServers": {
    "my-app": {
      "url": "http://localhost:3000/mcp"
    }
  }
}

Change the path with bootstrap({ mcpPath: "/agent" }).

Over stdio

Clients that launch a server as a subprocess want stdio instead. clove mcp --stdio serves the same project that way:

json
{
  "mcpServers": {
    "my-app": {
      "command": "npx",
      "args": ["clove", "mcp", "--stdio"],
      "cwd": "/path/to/project"
    }
  }
}

Over stdio there is one client and no session ids, so sessionId is null and session-scoped values live as long as the process.

TIP

In stdio mode stdout is the protocol stream, and console.log, .info and .debug all write to it. clove mcp --stdio redirects those to stderr before your project boots, so ordinary logging — yours or ctx.logger's — cannot corrupt the transport. Writing to process.stdout directly still will.

Released under the MIT License.