> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bijection.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Defining MCP Endpoints

> Select tools, write dispatch and admission functions, and serve an MCP endpoint

<Warning>MCP endpoints are in beta.</Warning>

You define an MCP endpoint with `defineMcpServer` from `bijection/mcp`. It takes
an async function that returns the tools you publish and references to a few
ordinary functions that you write in the same app: a grant query, two admission
mutations and two dispatch functions. The function runs for each request, so it
can `await` operation references.

This page builds a complete endpoint in `bijection/mcp.ts`. The grant query is
covered in [Authentication and grants](/mcp/authentication).

## Selecting tools

A tool never contains business logic of its own. Each one selects a function
you already have and derives its MCP input and output schemas from that
function's validators.

### Query tools

`mcpQuery` publishes a public [query](/functions/query-functions). Pass the
function reference, the registered query itself, and a description for the
agent:

```ts bijection/tasks.ts theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
import { v } from "bijection/values";
import { query } from "./_generated/server";

export const list = query({
  args: {},
  returns: v.array(v.object({ id: v.id("tasks"), title: v.string() })),
  handler: async (ctx) => {
    const tasks = await ctx.db.query("tasks").take(50);
    return tasks.map((task) => ({ id: task._id, title: task.title }));
  },
});
```

```ts bijection/mcp.ts theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
mcpQuery(api.tasks.list, list, "List the caller's open tasks.");
```

The query must declare a `returns` [validator](/functions/validation);
`mcpQuery` throws if it does not. The query runs as the calling agent, so
`ctx.auth` and your [access rules](/access/overview) see the agent's identity,
just as they would for a query called from a client.

### Operation tools

`mcpOperation` publishes a business [operation](/operations/overview) defined
with `defineOperation`. Pass an operation reference and a description:

```ts bijection/mcp.ts theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
mcpOperation(
  await makeOperationReference("tasks:complete", complete),
  "Mark one task as complete.",
);
```

Operation calls are deduplicated by a request key that the MCP client supplies
outside the model's arguments. When a call arrives, the endpoint first recovers
any invocation already accepted for that key and returns its original receipt.
Only when none exists does it invoke the operation. Repeating a call with the
same key therefore returns the same invocation instead of doing the work twice.
[Connecting clients](/mcp/connecting-clients#recoverable-writes) shows how a
client supplies the key.

An operation tool returns one of these results:

| `kind` | Meaning |
| - | - |
| `accepted` | The operation was accepted. Includes `invocation_id`, `accepted_revision` and the operation's `local_result`. |
| `accepted_result_omitted` | The operation was accepted, but its result was too large or could not be encoded. Includes `invocation_id`, `accepted_revision` and a `reason`. This is a success; don't retry. |
| `absent` | A recovery request found no accepted invocation for its request key. |

<Note>
  Acceptance is a local receipt. If your operation makes external calls, an
  `accepted` result does not mean an external system has applied the change.
  Publish a status tool so the agent can check.
</Note>

### Operation status tools

`mcpOperationStatus` publishes a read-only tool that reports the execution state
of an accepted invocation:

```ts bijection/mcp.ts theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
tools: {
  // ...
  task_status: mcpOperationStatus(
    await makeOperationReference("tasks:complete", complete),
    "Check a task completion request.",
  ),
},
```

It takes `{ invocation_id }` and returns `invocation_id`, the `review` state,
each external call's `delivery` and `publication` state, and
`is_external_outcome_unknown`. It never returns the operation's business result,
reviewer identities or internal diagnostics. When `is_external_outcome_unknown`
is `true`, the outcome must be reconciled; the tool description tells the agent
not to report completion or resubmit.

### Mutations and actions

You can't publish a [mutation](/functions/mutation-functions) or an
[action](/functions/actions) as a tool directly. To let an agent change data,
define an operation and publish it with `mcpOperation`; operations give each
write the stable identity, deduplication and recovery that an agent retrying
over the network needs.

### Tool names, descriptions and values

Tool names are the keys of the `tools` object. A name starts with a letter and
contains only letters, digits and underscores, up to 64 characters. A
description is at most 4,096 characters, and an endpoint publishes between 1
and 32 tools.

Every tool's arguments must be an object validator. Tool inputs and outputs use
JSON, with these encodings for values that JSON can't represent exactly:

| Validator | On the wire |
| - | - |
| `v.int64()` | Signed decimal string, e.g. `"9007199254740993"` |
| `v.bytes()` | Base64 string |
| `v.id("table")` | Opaque string |
| `v.optional(...)`, `v.null()` | Kept distinct: a missing field is not `null` |

Unknown fields and non-finite numbers are rejected. Publishing fails when a
tool uses `v.any()`, or a union whose variants look the same on the wire, such
as `v.union(v.string(), v.int64())`. Use an object union with a required
`v.literal` discriminator instead.

## Dispatch functions

Tool calls run through two internal functions that you export. Their handlers
are the endpoint's `dispatch.read` and `dispatch.write`, which check the
publication revision and the caller's current grant inside the same transaction
as the tool's work:

```ts bijection/mcp.ts theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
import { v } from "bijection/values";
import { internalMutation, internalQuery } from "./_generated/server";

const dispatchArgs = {
  revision: v.string(),
  tool: v.string(),
  arguments: v.string(),
  request: v.string(),
};

export const read = internalQuery({
  args: dispatchArgs,
  handler: mcpServer.dispatch.read,
});

export const write = internalMutation({
  args: dispatchArgs,
  handler: mcpServer.dispatch.write,
});
```

Query tools, status tools and operation recoveries run through `read`.
Operation invocations run through `write`.

## Admission

Every request to the endpoint, including discovery, first acquires a lease from
your `admission.acquire` mutation and releases it through `admission.release`
when it finishes. You own these limits and their tables, so rate and concurrency
counters commit transactionally with the rest of your app.

`acquire` receives `grant_id`, `tool` (the tool name, or the MCP method for
requests that aren't tool calls), a fresh `request_id`, and `is_poll`, which is
`true` for status tools and recovery requests. It returns either `{ lease_id }`
or `{ retry_after_ms }`. Returning `retry_after_ms` refuses the request with
`429 capacity_exhausted` and a `Retry-After` header.

This example keeps a per-minute counter and a list of active leases for each
grant:

```ts bijection/schema.ts theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
mcpAdmissions: defineTable({
  grant: v.string(),
  window: v.number(),
  count: v.number(),
  leases: v.array(v.object({ id: v.string(), until: v.number() })),
}).index("by_grant", ["grant"]),
```

```ts bijection/mcp.ts theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
const RATE_PER_MINUTE = 60;
const MAX_CONCURRENT = 4;
const LEASE_MS = 30 * 60_000; // longer than any request can run

export const acquire = internalMutation({
  args: {
    grant_id: v.string(),
    tool: v.string(),
    request_id: v.string(),
    is_poll: v.boolean(),
  },
  handler: async (ctx, args) => {
    const now = Date.now();
    const window = Math.floor(now / 60_000);
    const row = await ctx.db
      .query("mcpAdmissions")
      .withIndex("by_grant", (q) => q.eq("grant", args.grant_id))
      .unique();
    const leases = (row?.leases ?? []).filter((lease) => lease.until > now);
    const count = row?.window === window ? row.count : 0;
    if (count >= RATE_PER_MINUTE || leases.length >= MAX_CONCURRENT) {
      return { retry_after_ms: 60_000 - (now % 60_000) };
    }
    const next = {
      grant: args.grant_id,
      window,
      count: count + 1,
      leases: [...leases, { id: args.request_id, until: now + LEASE_MS }],
    };
    if (row) await ctx.db.replace(row._id, next);
    else await ctx.db.insert("mcpAdmissions", next);
    return { lease_id: JSON.stringify([args.grant_id, args.request_id]) };
  },
});

export const release = internalMutation({
  args: { lease_id: v.string() },
  handler: async (ctx, args) => {
    const [grant, id] = JSON.parse(args.lease_id);
    const row = await ctx.db
      .query("mcpAdmissions")
      .withIndex("by_grant", (q) => q.eq("grant", grant))
      .unique();
    if (row) {
      await ctx.db.patch(row._id, {
        leases: row.leases.filter((lease) => lease.id !== id),
      });
    }
    return null;
  },
});
```

A lease limits capacity only. It never owns the business work: if `release`
fails, the lease simply expires, and an operation that was accepted stays
accepted.

<Tip>
  You can keep counters at several levels, for example per grant and per
  application, by updating several rows in the same `acquire` mutation. Use
  `is_poll` to give status checks their own budget.
</Tip>

## Serving the endpoint

`defineMcpServer` returns a `handler` HTTP action for MCP requests, a `metadata`
HTTP action for OAuth protected-resource metadata, and a `publication()`
function that returns the current revision. Mount both actions in
`bijection/http.ts`:

```ts bijection/http.ts {5-14} theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
import { httpRouter } from "bijection/server";
import { mcpServer } from "./mcp";

const http = httpRouter();
http.route({
  pathPrefix: "/mcp/",
  method: "POST",
  handler: mcpServer.handler,
});
http.route({
  path: "/.well-known/oauth-protected-resource",
  method: "GET",
  handler: mcpServer.metadata,
});
export default http;
```

The handler accepts a request only when the last path segment equals the
current revision. Export a query that returns it:

```ts bijection/mcp.ts theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
export const revision = internalQuery({
  args: {},
  handler: () => mcpServer.publication(),
});
```

```sh theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
bijection run mcp:revision
```

With the route above, the endpoint URL is
`https://<your deployment name>.bijection.site/mcp/<revision>`.

The revision covers your whole deployed program, not just `mcp.ts`: when
`bijection dev` or `bijection deploy` pushes code, the CLI computes a digest of
every bundled module, schema, auth configuration and component, and the
endpoint combines it with its name, `resource`, authorization servers, tool
descriptions, bindings and schemas. Any change produces a new revision and a new
URL. Environment variables and data are not part of the revision; current
grants and access rules are checked on every call instead.

<Warning>
  An app that defines an MCP endpoint must bundle all of its dependencies.
  Pushing fails if the app installs [external
  packages](/functions/bundling) through `node.externalPackages`, because
  installed packages are not part of the digest.
</Warning>

## Tool results

`tools/list` returns each granted tool with its `inputSchema` and an
`outputSchema` of the form `{ result: ... }`. Query tools are annotated
`readOnlyHint` and `idempotentHint`; operation tools are annotated
`destructiveHint` and carry a `bijection/operation` entry in `_meta` that
clients use for [recovery](/mcp/connecting-clients#recoverable-writes).

A successful `tools/call` returns the value as `structuredContent.result` and as
the same JSON in a text content block. A failed call returns `isError: true` with
an error code such as `tool_refused` or `read_unavailable`. Error messages thrown
by your own functions are not passed to the agent. See [Limits](/mcp/limits#errors)
for every code.
