Farm.js

API MCP

@farm.js/mcp composes selected Farm API routes and standalone tools in one MCP server. Reuse an endpoint's validation and middleware, or use defineTool() for an operation that doesn't need its own HTTP route. Both share the same transport, tool catalog, and authorization policy.

The endpoint defaults to /api/mcp and implements MCP Streamable HTTP. It is a server capability, unlike WebMCP, which registers tools in a supporting browser while a page is open.

Install and configure

pnpm add @farm.js/mcp
farm.config.ts
import { defineConfig } from "@farm.js/core";

export default defineConfig({
  mcp: {
    authorize: async ({ request }) => {
      const session = await getSession(request);
      if (!session) return false;
      return { subject: session.user.id, scopes: session.scopes };
    },
  },
});

The top-level config mounts the transport and owns its global authorization policy. Farm loads the installed @farm.js/mcp package only when this config is enabled. It never exposes an endpoint unless that endpoint opts in or is explicitly selected in mcp.tools.

Declare tools in config

You can keep MCP declarations in farm.config.ts and reference existing endpoint instances. The endpoint still owns its validation, middleware, and handler:

src/app/api/projects/route.ts
import { createEndpoint } from "@farm.js/core/api";
import { requireProjectAccess, listProjects } from "../../../lib/projects";

export const GET = createEndpoint(
  "/api/projects",
  { method: "GET", middleware: [requireProjectAccess] },
  ({ context }) => listProjects(context.session.user.id),
);
farm.config.ts
import { defineConfig } from "@farm.js/core";
import { defineTool } from "@farm.js/mcp";
import { z } from "zod";
import { GET as listProjects } from "./src/app/api/projects/route";
import { getSession } from "./src/lib/session";
import { searchProjects } from "./src/lib/projects";

export default defineConfig({
  mcp: {
    name: "project-tools",
    tools: [
      {
        endpoint: listProjects,
        name: "list_projects",
        description: "List projects visible to the current user.",
      },
      defineTool({
        name: "search_projects",
        description: "Search projects visible to the current user.",
        inputSchema: z.object({ query: z.string().trim().min(1) }),
        readOnlyHint: true,
        destructiveHint: false,
        execute: ({ query }, { authorization, signal }) =>
          searchProjects({ query, userId: authorization.subject, signal }),
      }),
    ],
    authorize: async ({ request, tools }) => {
      const session = await getSession(request);
      if (!session) return false;
      return {
        subject: session.user.id,
        tools: tools
          .filter(
            (tool) =>
              ["list_projects", "search_projects"].includes(tool.name) &&
              session.scopes.includes("projects:read"),
          )
          .map((tool) => tool.name),
      };
    },
  },
});

mcp.tools is an explicit allowlist: only those tools are exposed, even if other endpoints have route-owned mcp metadata. Omit it to discover route-owned declarations instead. A bare reference such as tools: [listProjects] preserves that endpoint's metadata or derives its tool name. The object form overrides metadata without changing the underlying API route. An empty configured list, duplicate endpoint selection, or a missing mounted route fails the build.

Config references require createEndpoint("/api/...", options, handler) with an explicit path matching the mounted route. Farm resolves the reference by method and path, not JavaScript object identity, because config and route modules can be loaded independently. A createRouteFactory() definition can also be passed, provided its endpoint is mounted as an app route. This does not mount new HTTP routes or expose plugin-owned routes.

For endpoints whose path is inferred from their file, use the route-owned form below instead. Keep endpoint imports server-only; never import this config or route module into a browser client.

Standalone tools

Import defineTool from @farm.js/mcp and put its result in mcp.tools, alongside endpoint references or by itself. A standalone tool does not need createEndpoint(), a method, or a path. For example, search_projects above is callable through /api/mcp, but Farm does not create an /api/search_projects route. A server with only standalone tools works without any app API routes.

Each definition requires a unique name, an object inputSchema, and an execute function. Use z.object({}) for a tool with no arguments. Zod 4 works directly; other Standard Schema validators must also expose ~standard.jsonSchema.input() for discovery. Unsupported schemas fail at startup/build rather than advertising an unusable tool.

Standalone arguments are the schema's object directly, with no body or query wrapper:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "search_projects",
    "arguments": { "query": "farm" }
  }
}

Farm advertises the schema's input shape and runs the original validator before execute, including async refinements, defaults, and transforms. The first argument is typed as the validated output, not the unparsed wire input. The second argument provides:

  • authorization: the principal returned by mcp.authorize, including your custom claims. Public servers receive { subject: "anonymous" }.
  • request: the incoming MCP Web Request, including credentials.
  • signal: cancellation from the request or MCP call. Pass it into cancellable work; cancellation cannot undo side effects that already happened.

Return JSON-serializable application data, not a Response, stream, or MCP protocol envelope. Farm returns its JSON as text content and as structuredContent: { result: data }, matching endpoint-backed tools. Validation and execution failures become tool errors. Only authorized tools can reach validation or execution. Keep definitions and their provider SDKs server-only. Use a shared database or storage backend for data read by both routes and standalone tools; config and route modules can be loaded independently, and module-local state is not a shared store.

Validate tool results

Add outputSchema to a standalone tool to check its returned data before sending it to the client. The handler's return type must match the schema's input type. Farm runs the original output validator once, including async refinements, defaults, and transforms, and advertises the schema's output shape in MCP discovery.

import { defineTool } from "@farm.js/mcp";
import { z } from "zod";

const countCharacters = defineTool({
  name: "count_characters",
  inputSchema: z.object({ text: z.string() }),
  outputSchema: z.object({ count: z.number().int().nonnegative() }),
  execute: ({ text }) => ({ count: Array.from(text).length }),
});

// Include countCharacters in mcp.tools.

outputSchema describes your returned data, not the MCP envelope. A result of { count: 4 } still produces JSON text content and structuredContent: { result: { count: 4 } }. Farm wraps the advertised schema to match that existing envelope. Objects, arrays, scalars, and null work when they are JSON-compatible. The SDK also checks the serialized result against the advertised schema. An invalid result becomes a tool error, without successful structured data; standalone output-validator messages are not exposed to the client.

For endpoint-backed tools, Farm derives the MCP output schema from the existing createRouteFactory() output option. There is no second MCP-specific validator to maintain:

src/app/api/health/route.ts
import { createRouteFactory } from "@farm.js/core/api";
import { z } from "zod";

const route = createRouteFactory();
export const GET = route.get("/api/health", {
  mcp: { name: "health", readOnlyHint: true },
  output: z.object({ ok: z.boolean() }),
  handler: () => ({ ok: true }),
}).endpoint;

The endpoint runtime validates and transforms plain handler results; MCP does not run that validator a second time. Raw Response results bypass the endpoint's output parser, but their successful JSON payload must still match the advertised MCP output shape. Non-success HTTP responses remain tool errors rather than being checked against a success schema. createEndpoint() does not have an output option; use the route factory when you need an endpoint output validator.

Both forms are optional. Tools without an output schema retain their current behavior and do not advertise one. Zod 4 schemas must have a JSON-representable output; for example, pipe a transform into a representable schema with .transform(Number).pipe(z.number()). Other Standard Schema validators must expose ~standard.jsonSchema.output() as well as their validation method. An output schema that cannot be represented fails registration/build instead of silently omitting the contract. Output validation cannot undo side effects already performed by the handler.

Authorize individual tools

Every authorize call receives:

  • request: the incoming Web Request, including its credentials.
  • server: the resolved MCP name, version, and endpoint path.
  • tools: the complete, read-only configured catalog. Each entry has its stable MCP name, kind ("endpoint" or "standalone"), optional title/description, and behavior hints. Endpoint entries also have HTTP method and route path; standalone entries do not. The catalog contains no handlers or validators.
  • tool: the untrusted requested name for a tools/call, if present. It is omitted for initialization and listing. For a JSON-RPC batch, Farm calls authorize for every item with that item's tool name and rejects the entire batch before dispatch when any item is denied; compare it against the catalog rather than trusting it.

Return false to reject the entire request with HTTP 401. Otherwise return a subject and, optionally, tools, an array of allowed tool names. Omit this return field to retain access to all configured tools; return tools: [] to allow the MCP connection but no tools. Unknown names or malformed permission lists fail closed with a server error.

Every allowed item in a batch must resolve to the same subject, scopes, and allowed tool names because one HTTP batch has one authenticated identity. Authorization runs once per batch item, so keep batches bounded when the policy performs remote or otherwise expensive session checks.

Only allowed tools appear in discovery, and guessing a hidden name does not make it callable. Permissions are evaluated for each request and do not carry over between callers. A stable explicit name, such as list_projects, is the identifier to use in policy rules.

scopes are authentication metadata; Farm does not automatically translate them into permissions. Your policy must map them to allowed names, as above. Likewise, readOnlyHint and other annotations are descriptive, not security rules. For row-level or argument-dependent checks, keep authorization in endpoint middleware or a standalone tool's execute function. Standalone tools receive the returned principal as authorization; endpoint-backed tools continue to authenticate through their existing middleware using forwarded credentials. The principal is not injected into an endpoint's context. This config does not implement an OAuth authorization server or issue tokens.

Keep the API route authoritative

Define validation and endpoint authorization on the route as usual:

src/app/api/projects/route.ts
import { createEndpoint, type EndpointMiddlewareContext } from "@farm.js/core/api";
import { z } from "zod";

async function requireProjectAccess({ request }: EndpointMiddlewareContext) {
  const session = await getSession(request);
  if (!session) return Response.json({ error: "Unauthorized" }, { status: 401 });
  return { session };
}

export const GET = createEndpoint(
  {
    method: "GET",
    mcp: {
      name: "list_projects",
      description: "List projects visible to the current user.",
      readOnlyHint: true,
    },
    query: z.object({ status: z.enum(["active", "planned"]).optional() }),
    middleware: [requireProjectAccess],
  },
  async ({ query, context }) => ({
    projects: await listProjects(context.session.user.id, query.status),
  }),
);

Use mcp: true when the generated name is sufficient. Farm derives names from the method and path, such as get_projects for GET /api/projects and get_projects_by_team for GET /api/projects/[team]. Use an object to set name, title, description, or behavior hints. Duplicate or invalid explicit names fail the build. A path that cannot produce a valid derived name fails with an instruction to provide one explicitly. Duplicate tool names, unsupported schemas, or an empty tool set also fail the build instead of producing a partially working server.

Package and plugin authors use the same route-owned metadata on createRouteFactory() routes:

import { createRouteFactory } from "@farm.js/core/api";
import { z } from "zod";

const route = createRouteFactory();

export const projectsRoute = route.post("/api/projects", {
  mcp: {
    name: "create_project",
    description: "Create a project.",
    destructiveHint: false,
  },
  input: { body: z.object({ name: z.string().min(1) }) },
  handler: async (_request, { input }) => createProject(input.body),
});

Both route APIs keep the HTTP method and path next to the handler, so there is no separate "METHOD /api/path" MCP map to synchronize.

An MCP call runs the endpoint validator, middleware, handler, and error mapping exactly once. It also forwards the original request's cookies and authorization header, so the route sees the same credentials as the MCP boundary. Tool arguments cannot replace security-sensitive credentials or request-provenance headers such as Host, Origin, Referer, Forwarded, or X-Forwarded-*.

Use both authorization layers:

  • mcp.authorize protects the MCP transport and can reject requests before protocol handling.
  • Endpoint middleware protects the underlying API route, including direct HTTP and typed local calls.

Authorization is required by default. allowUnauthenticated: true is an explicit escape hatch for a deliberately public server.

Generated tool inputs

Farm maps route inputs into one MCP argument object:

Route inputTool argumentNotes
ParamsparamsIncludes dynamic, catch-all, and optional catch-all segments.
QueryqueryPreserves repeated query values as arrays.
JSON bodybodyAvailable for methods that accept a request body.
HeadersheadersCredential and request-provenance headers are never agent-controlled.

For example, a tool call for GET /api/projects/[team] can send:

{
  "params": { "team": "framework" },
  "query": { "status": ["active", "planned"] }
}

The route's Standard Schema implementation must be convertible to JSON Schema. Zod 4 routes work without additional configuration. Selected routes currently need JSON responses; binary, multipart, and streaming responses are rejected as tool errors.

Connect a client

Point an MCP client at the deployed endpoint and supply credentials using that client's secure header configuration. For a direct protocol check in local development:

curl http://localhost:3000/api/mcp \
  -H 'Authorization: Bearer development-token' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  --data '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Do not put production tokens in source, browser code, an MCP tool description, or a checked-in client configuration.

Options

OptionDefaultPurpose
authorizerequiredReceive the tool catalog and server identity; return a subject and optional allowed tool names.
toolsroute opt-insCompose endpoint references and standalone defineTool() definitions.
allowUnauthenticatedfalseDeliberately permit a public MCP endpoint instead of authorize.
path/api/mcpCanonical Farm API path for the Streamable HTTP endpoint.
namefarm-apiMCP server name returned during initialization.
version1.0.0Application server version returned during initialization.

Tool metadata supports name, title, description, readOnlyHint, destructiveHint, idempotentHint, and openWorldHint. Annotations describe intent to the client; they do not enforce permissions or replace confirmation for consequential actions.

Runtime support

The MCP transport works in development and production on Farm's stable Node, Vercel, Cloudflare, and Netlify targets. Removing the top-level mcp config removes the endpoint and MCP runtime from the application bundle.

The lower-level apiMcp() helper remains available for advanced manual plugin composition. It has the same options as top-level config, including mixed tool definitions and per-request permissions. Endpoint-backed tools keep their route's validation, middleware, and execution.

import { apiMcp } from "@farm.js/mcp";

export default defineConfig({
  plugins: [apiMcp({ authorize: authorizeAgent })],
});

Normal applications do not need this manual form.

The runnable examples/api-mcp shows two endpoint-backed tools and a standalone search tool sharing one authorization policy.