> ## 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 Servers

> Declare an MCP server's tools, its instructions and how its sessions start, and install it in your app

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

`defineMcpServer` from `bijection/mcp` declares one [MCP server](/mcp/overview):
its tools, the instructions that come with them and how its sessions start.
`mcpModule` serves every server you declare, `mcpTables` adds the tables
sessions need, and `mountMcp` mounts the routes.

## Declaring a server

A session holds a role of your [access model](/access/access-model) on the one
object it acts for, and is granted nothing. Mark that role `sessions: true`,
give the service that starts sessions a role carrying the power to issue it
(`customer:issue.supportAgent`), and declare the service:

```ts bijection/access.ts theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
import { defineAccessModel } from "bijection/server";

export const access = defineAccessModel({
  resources: {
    operation: { permissions: ["read", "invoke", "preview"] },
    customer: { permissions: ["see", "read.risk"] },
    refund: { parent: "customer" },
  },
  roles: {
    "root:owner": ["root:*", "operation:*", "customer:*", "refund:*"],
    "operation:invoker": {
      permissions: ["operation:read", "operation:invoke"],
      sessions: true,
    },
    "operation:reader": { permissions: ["operation:read"], sessions: true },
    "operation:issuer": ["operation:issue.invoker", "operation:issue.reader"],
    "root:supportIssuer": ["customer:see", "customer:issue.supportAgent"],
    "customer:supportAgent": {
      permissions: [
        "customer:see",
        "customer:read",
        "refund:read",
        "refund:create",
      ],
      maxDuration: 30 * 60 * 1000,
      sessions: true,
    },
  },
  services: {
    "support-desk": {
      description: "Starts support sessions for customers it has verified.",
      ceiling: ["root:supportIssuer", "operation:issuer"],
    },
  },
});
```

A service is granted only roles of its `ceiling`. `see` covers a customer's
identifier alone, so the service can tell that a customer exists and reads none
of its fields. The session's role has no `read.risk`, so the fields behind it
never reach the agent. `maxDuration` is the longest a session can last.

A session holds its role only while its service holds the matching `issue`
permission on that customer or above it. Revoke the service's role, or let it
expire, and every session it started loses its access at the same moment. A role
marked `sessions` cannot carry `grant.*` or `issue.*` permissions.

The `operation` resource is how your access model decides
[who may use an operation](/access/access-model#deciding-who-may-use-operations),
for your staff and for sessions alike. A session holds `operation:invoker` on
each operation its tools request and `operation:reader` on each it may only
follow, until it ends. The service holds `operation:issuer` on those operations,
which lets it issue those roles to its sessions. A server with no operation
tools needs none of the three.

Then declare the server and export the module:

```ts bijection/mcp.ts theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
import { issuedSessions } from "bijection/sessions";
import { defineMcpServer, mcpModule, tool } from "bijection/mcp";
import { access } from "./access";
import { context, hours, refund } from "./support";

export const support = defineMcpServer("support", {
  description: "Customer support for verified customers.",
  instructions:
    "Until the customer is verified, answer only from opening_hours. Read get_context before answering about a refund. Never promise a refund: request_refund says whether one was accepted.",
  sessions: issuedSessions({
    service: "support-desk",
    role: "customer:supportAgent",
    beforeVerification: ["opening_hours"],
  }),
  tools: {
    get_context: tool.query(
      "support:context",
      context,
      "Read the verified customer's name, tier and remaining refundable amount.",
    ),
    opening_hours: tool.query(
      "support:hours",
      hours,
      "Read the support desk's opening hours.",
    ),
    request_refund: tool.operation(
      "support:refund",
      refund,
      "Request a refund for the verified customer.",
    ),
    refund_status: tool.status(
      "support:refund",
      refund,
      "Check an accepted refund request.",
    ),
  },
  limits: { callsPerMinute: 30 },
});

export default mcpModule({ access, servers: [support] });
```

Keep the default export in `bijection/mcp.ts`. To export it from another file,
pass that file's path as `module` to `mcpModule`.

Add the tables to your schema, and mount the routes:

```ts bijection/schema.ts theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
import { defineSchema } from "bijection/server";
import { mcpTables } from "bijection/mcp";
import { access } from "./access";

export default defineSchema({
  // ...your tables
  grants: access.protectGrants(),
  ...mcpTables(),
});
```

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

const http = httpRouter();
mountMcp(http, servers);
export default http;
```

Each server is served at `/mcp/<name>` on your deployment's `.bijection.run`
host, with its OAuth protected-resource metadata beside it. Deployment checks
the tables, their indexes, the public tool definitions, operation authorization
and the mounted routes before it publishes your program, and an incomplete
installation names what is missing.

Adding a server changes nobody else's permissions. Your access model keeps
deciding who may use each operation; a session is one more principal holding
roles, for exactly what its tools do: an operation tool reads and invokes its
operation, a status tool only reads its requests, and no session previews.

## Instructions

`instructions` tell the agent how to use the server's tools together: what to
read first, which tool answers what, what never to promise. Each tool's
description says what that tool does; the instructions say how the tools fit
together for this server's work.

Every client receives the instructions when it connects (`initialize` and
`server/discover`), before it calls any tool. An
[agent your deployment runs](/mcp/agents) reads them before its own
instructions. They are at most 8 KiB of text, and the same for every session of
the server.

## Tools

Each tool takes the definition's `module:export` path, the definition itself,
whose validators become the tool's schema, and the description the model reads.
A tool must be a public query or [operation](/operations/overview) with explicit
validators: publishing it adds a description, never a way in.

* **`tool.query`** publishes a public [query](/functions/query-functions) with a
  `returns` validator. It runs as the session, so your access rules decide which
  rows it reads.
* **`tool.operation`** publishes a business operation defined with
  `defineOperation`. A call is a request of that operation through its ordinary
  invocation path.
* **`tool.status`** publishes the safe status of accepted requests of an
  operation: its review state and each external call's delivery and publication
  state, never its business result.

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

An operation tool answers with its request's receipt:

| `kind` | Meaning |
| - | - |
| `accepted` | The operation was accepted. Includes `invocation_id`, `accepted_revision` and the operation's `result`. |
| `accepted_result_omitted` | The operation was accepted, but its result was too large or could not be encoded. Includes `invocation_id`; 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>

### Binding a tool to the session's object

A tool's function takes the object it is about as an ordinary argument, so your
app and your staff call it too. For a session, `bind` names that argument: the
model never sees it and cannot supply it, and the session fills it in.

An operation that declares the object as its `target` needs no `bind`: its tool
binds the target on its own.

```ts bijection/mcp.ts theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
tools: {
  order_details: tool.query(
    "desk:order",
    order,
    "Read one order of the verified customer by its order number.",
    { bind: "customer" },
  ),
  // requestRefund declares `target: { argument: "customer" }`.
  request_refund: tool.operation(
    "desk:requestRefund",
    requestRefund,
    "Request a refund on one delivered order of the verified customer.",
  ),
},
```

```ts bijection/desk.ts theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
export const order = query({
  args: { customer: v.id("customers"), order_number: v.string() },
  returns: v.union(v.null(), v.object({ status: v.string() })),
  handler: async (ctx, { customer, order_number }) => {
    const found = await ctx.db
      .query("orders")
      .withIndex("by_customer", (q) =>
        q.eq("customer", customer).eq("number", order_number),
      )
      .unique();
    return found && { status: found.status };
  },
});
```

The model is offered `order_details` with `order_number` alone. A bound argument
is a required top-level ID or string. A tool with no arguments can read the
session instead, with `mcpSession(ctx)` from `bijection/mcp`, which returns the
session's `server`, the object it acts for and its channel, or `null` for every
other caller.

### Writing a tool's function

* **Select only what the role may read.** Reading a whole document asks for
  every field. A tool that reads a field the session's role lacks fails as a
  whole request, not as a tool error.
* **Keep an operation's object type to what the session reads.** An operation
  reads its target through the view or table it is declared `on` before it runs.
  Declare a view that selects only fields the session's role may read; a view
  over the whole table is refused for a role that lacks one field.
* **Write under what you read.** A function may write into the table it read,
  the tables below it in your access model, and grants. An operation that reads
  an order and writes a refund works when a refund belongs to its order; it is
  refused when both only belong to the customer.
* **Answer refusals as data.** Return `{ outcome: "refused", reason }` from an
  operation for the model to read. An error thrown after the function read
  protected data reaches the caller as a fixed refusal with no reason.

### Names and values

Tool names are the keys of the `tools` object: a letter followed by letters,
digits or underscores, up to 64 characters. A description is at most 4,096
characters, and a server 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 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.

## When you deploy

A session records each tool's contract when it starts: its name, description,
schemas and binding. When you deploy while a conversation is running, the
session keeps every tool whose contract you left unchanged, at the same URL. A
tool whose contract changed answers `tool_changed` for that session, and a tool
you add reaches new sessions only. The session's host still reads back the
requests it made through a changed operation tool. Changing the instructions
changes no tool.

## Tenants and staff directories

Sessions work with any access model, including one whose staff are a
[workspace directory](/access/access-model) and one that restricts objects to a
tenant. A session principal is never a person of the directory: it holds only
the roles its service may issue. In a workspace directory a service also acts
only while a member [sponsors](/access/access-model#service-sponsors) it.

When the object a session acts for is restricted to a tenant, the session passes
that tenant exactly as its service does, so the service's role must carry the
tenant's `pass`. A session that also reads the tenant itself, such as a store's
opening hours, names a role for that in `ancestorRoles`. It holds that role on
the tenant the customer belongs to, while its service may issue it there.

```ts bijection/mcp.ts theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
import { issuedSessions } from "bijection/sessions";
export const support = defineMcpServer("support", {
  description: "Answers one verified customer.",
  sessions: issuedSessions({
    service: "support-desk",
    role: "customer:supportAgent",
    ancestorRoles: ["store:supportSeat"],
  }),
  tools: {
    /* ... */
  },
});
```

```ts bijection/access.ts theme={"theme":{"light":"github-light-default","dark":"github-dark-default"}}
"store:supportIssuer": [
  "store:pass",
  "store:read",
  "store:issue.supportSeat",
  "customer:see",
  "customer:issue.supportAgent",
],
"store:supportSeat": {
  permissions: ["store:read"],
  maxDuration: 30 * 60 * 1000,
  sessions: true,
},
```

The service's role is granted per tenant, so its sessions hold something only in
the tenants a person authorized it for; elsewhere they hold nothing and the host
library refuses to start them. A service is one principal: every one of its
credentials acts with all its grants, so tenants whose hosts must not reach each
other need a service each. The role that issues a session's ancestor roles is
granted with the one that issues its role, on the same tenant.

## Limits

* Up to 16 servers in a module, 32 tools and four `ancestorRoles` in a server.
* Instructions are at most 8 KiB, a description at most 1,024 characters.
* A session is never given a marking's pass: an object that carries a marking
  refuses the session unless one of its roles confers that pass.
* In an app with a staff directory, a session cannot read people, so a tool
  cannot return a staff member's name.

See [Limits](/mcp/limits) for request and result bounds and every error code.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.