---
title: "Actions and agent tools"
description: "Write an action once and get an API endpoint, a server action, a CLI command and an opt-in MCP tool, all behind the same checks."
section: "interfaces"
status: "Preview · ships as a beta API"
spec: "mcp-actions"
canonical: "https://mainsdk.dev/docs/interfaces/action"
updated: "2026-10-28"
---

# Actions and agent tools

> Write an action once and get an API endpoint, a server action, a CLI command and an opt-in MCP tool, all behind the same checks.

Status: Preview · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/action · Updated: 2026-10-28

`action()` is how you write what your app does, once. Every call, from a route, an agent or a model tool, validates the input, checks `access`, applies agent governance and writes one audit event.

> **Note:** Built today: `action()`, `mcpServer()`, `mcpClient()`, `ai.tool()`, the audit event and the confirmation rules. Planned (spec `mcp-actions`): the generated HTTP endpoint, server action and `mainsdk run` command, OAuth token exchange, and signed tool descriptions. See [Planned](#planned).

## At a glance

- Status: Preview · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/mcp-actions.md
- Category: AI and agents

Calls:

- `action({ name, intent, input, risk, run })`
- `mcpServer({ actions })`
- `mcpClient({ servers })`
- `new Idempotency(opts)`
- `idempotencyKey(action, input)`
- `idempotency(object: once, key, current)`
- `signTools(actions, key)`
- `new TokenExchange({ key, scopes })`
- `toolHash(tool)`
- `canonicalJson(value)`
- `inputHash(value)`
- `createMcpServer({ actions })`
- `createMcpClient({ servers })`

In main.yaml:

```yaml
mcp:
  expose: [orders.refund, orders.list]
  confirm: { write: true, destructive: true }
  auth: { issuer: auth, scopes: [orders:read, orders:refund], token-ttl: 10m }
```

Guarantees, whatever the driver:

- One door, the same checks for people and agents
- Destructive actions always get a person's yes
- Tool descriptions signed and verified
- Agents act with narrow, short-lived tokens tied to the person who approved them


## Drivers

Pick one in main.yaml with `action.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| MCP | `mcp` |  |  |
| HTTP API | `http` |  |  |
| Server actions | `server-action` |  |  |
| CLI | `cli` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-MCP-0003` | runtime | A tool description changed after signing; the tool is left out |
| `MAIN-MCP-0011` | runtime |  |
| `MAIN-MCP-0001` | runtime | The server is not in `mcp.servers`; no request leaves the process |
| `MAIN-MCP-0002` | runtime | A pinned server's tool description changed |
| `MAIN-MCP-0004` | runtime | A call limit was reached, for example `per-user: 100/day`; the call is refused before any request |
| `MAIN-MCP-0005` | runtime | A write or destructive tool call wasn't confirmed by the person, so nothing ran (returned in the tool result) |
| `MAIN-MCP-0006` | runtime | The MCP server answered with an error or not at all (502) |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| MCP client with allowlist | 1 | 0.3 | `mcp-client` |
| Agent identity | 1 | 0.3 | `agent-governance` |
| One action becomes endpoint, server action, CLI command and audit event | 1 | 0.3 | `mcp-actions#action` |
| Opt-in MCP tools and resources | 1 | 0.3 | `mcp-actions#mcp-optin` |
| Confirmation through MCP elicitation | 1 | 0.3 | `mcp-actions#confirm` |
| Signed tool descriptions | 1 | 0.3 | `mcp-actions#signed-tools` |
| OAuth 2.1 on the MCP endpoint | 1 | 0.3 | `mcp-actions#oauth` |
| Agent tokens through token exchange | 1 | 0.3 | `mcp-actions#token-exchange` |
| Signed approvals for confirmed actions | 3 | 1.2 | `signed-approvals#action-risk-hook` |
| Agent-to-agent calls over A2A | 5 | 1.6 | `platforms#a2a` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```ts title="actions/orders.ts"
import { action, models, pay } from "mainsdk";
import { s } from "mainsdk/schema";

const { orders } = models({ orders: s.object({ paymentId: s.string() }) });

export const refundOrder = action({
  name: "orders.refund",
  intent: "Refund an order the signed-in user owns",
  input: s.object({ orderId: s.string(), reason: s.string() }),
  risk: "destructive",          // an agent must get a person's yes
  mcp: true,                    // opt in to MCP; the default is false
  run: async ({ input, user }) => {
    const order = await orders.for(user!).get(input.orderId);
    return pay.refund(order!.paymentId, { reason: input.reason });
  },
});

// from a route handler, a job or a test
const user = { id: "u_1" };
const result = await refundOrder({ orderId: "o_1", reason: "late" }, { user });
```

The second argument says who is calling: `{ user, agent?, surface?, approvalId? }`. The result is what `run` returned, or `{ status: "pending", approvalId }` when an agent's call is held for a person (see [Agent calls](#agent-calls)).

## Configuration

An action is configured in code. `main.yaml` has an `mcp:` block for what the MCP endpoint exposes.

| Field | Type | What it does |
| --- | --- | --- |
| `name` | string | The action's identity in the audit log and the MCP tool name; the first segment is the default resource type |
| `intent` | string | A plain sentence of what it does; the MCP tool description |
| `input` | Schema (`s`, or any Standard Schema value) | Validates input on every call before `run` |
| `risk` | `"read"` \| `"write"` \| `"destructive"` | Sets confirmation and the approval threshold for agents |
| `mcp` | boolean | Lists the action as an MCP tool; `false` by default |
| `spend` | `(input) => string` | Money the call moves, such as `"$0.50"`, so an agent's `approve.spend-over` can compare it |
| `permission` | string | The `access` action to check; defaults to `name` |
| `confirm` | `"signed"` | The call needs a signed approval: a person approves this exact action, amount and payee with a passkey (`mainsdk/confirm`). Without one it fails with `MAIN-AUTH-0204` |
| `signs` | string[] | The input fields shown to the person and bound into the signature, such as `["amount", "payee"]` |
| `resource` | `(input) => Resource` | The resource acted on, so owner scope works |
| `run` | function | Receives `{ input, user, agent, surface }` |

```yaml title="main.yaml"
mcp:
  expose: [orders.list]        # tools in addition to those marked mcp: true
  confirm:
    write: true                # ask the person before an agent runs a write action (default true)
    destructive: true          # always on
```

| Key | What it does |
| --- | --- |
| `mcp.expose` | Action names listed as tools as well as those marked `mcp: true` |
| `mcp.confirm.write` | Elicitation before a write action; on unless set to `false` |
| `mcp.confirm.destructive` | Elicitation before a destructive action; cannot be turned off |
| `mcp.servers`, `mcp.limits` | The servers `mcpClient()` may call and its call limits; see [MCP server and client](https://mainsdk.dev/docs/agents/mcp) |

`mcp.expose` and `mcp.confirm` are read by `app().mcpServer(...)`. The module-level `mcpServer()` takes the same options as arguments.

## What you get

`action()` is how you write the things your app does: refund an order, invite a teammate, export a report. You write each one once. Every call runs the same steps: validate the input, check `access`, apply agent governance, run, and write one audit event. MCP tools and `ai.tool()` model tools call the same function, so there is no second way in with weaker rules. Reach for it whenever people and agents need to do the same thing, or whenever you want one place for the checks on an operation.

## Examples

### Check the input and the access rule

A call with bad input throws `MAIN-ACTION-0001` with status 400 and the failing paths in `errors`. A caller `access` refuses throws `MAIN-ACCESS-0001`. Both are audited as a deny.

```ts title="actions/orders.ts"
import type { Action } from "mainsdk";

declare const refundOrder: Action<{ orderId: string; reason: string }, unknown>;   // the action above
const user = { id: "u_1" };

try {
  await refundOrder({}, { user });
} catch (err) {
  // err.code === "MAIN-ACTION-0001", err.status === 400
}
```

### Agent calls

When a call names an `agent`, it goes through [`agents`](https://mainsdk.dev/docs/interfaces/agents) first: the budget is checked, and a step at or above the agent's `approve.risk`, or over `approve.spend-over`, is held. A person approves it, then the same call runs with `approvalId`.

```ts title="lib/agent-refund.ts"
import { agents, type Action, type Pending } from "mainsdk";

declare const refundOrder: Action<{ orderId: string; reason: string }, unknown>;   // the action above
const user = { id: "u_1" };

const held = (await refundOrder({ orderId: "o_2", reason: "late" }, { user, agent: "support-bot", surface: "mcp" })) as Pending;
// { status: "pending", approvalId: "..." }; nothing has run yet

agents.approve(held.approvalId, { kind: "user", id: user.id });   // an agent approving fails with MAIN-AGENT-0003
await refundOrder({ orderId: "o_2", reason: "late" }, { user, agent: "support-bot", approvalId: held.approvalId });
```

Inside `run`, `agent` is the agent's name, or `undefined` for a person.

### Serve the actions over MCP

`mcpServer()` returns a `(req) => Response` handler for Streamable HTTP. Pass `identify` to resolve the agent and person from the request, and `elicit` to ask the person for the yes.

```ts title="app/api/mcp/route.ts"
import { mcpServer, type Action } from "mainsdk";

declare const refundOrder: Action<{ orderId: string; reason: string }, unknown>;   // the action above
declare function resolveCaller(req: Request): Promise<{ agent?: string; user?: { id: string } } | null>;
declare function askPerson(tool: string, input: unknown): Promise<boolean>;

export const POST = mcpServer({
  actions: [refundOrder],
  identify: async (req) => resolveCaller(req),          // { agent?, user? }, or null for a 401
  elicit: async ({ tool, input }) => askPerson(tool, input),   // true runs it, false returns MAIN-MCP-0005
  resourceMetadata: "https://app.example/.well-known/oauth-protected-resource",
});
```

`resolveCaller` and `askPerson` are yours: MainSDK does not run the OAuth server, it takes the identity you resolve. A request `identify` rejects gets 401 with a `WWW-Authenticate` header pointing at `resourceMetadata`. A destructive tool with no `elicit`, or a decline, returns an error result with `MAIN-MCP-0005` and runs nothing. An agent's destructive call over MCP returns the pending approval instead.

```mermaid
sequenceDiagram
  participant Agent
  participant App as MCP endpoint
  participant Person
  Agent->>App: tools/call orders.refund
  App->>App: identify, input check, access check
  App->>Person: elicit: refund o_1?
  Person-->>App: accept
  App->>App: agent budget and approval, run, audit
  App-->>Agent: result
```

### Sign tool descriptions

A tool description can be changed to trick an agent. Pass the project's signing key as `signing.key`: each tool is listed with a signature under `_meta["x-mainsdk"]` (with its `schemaVersion`), and a tool whose description changed after signing is left out of `tools/list` and logged with `MAIN-MCP-0003`.

```ts title="app/api/mcp/route.ts"
import { mcpServer, signTools, type Action } from "mainsdk";

declare const refundOrder: Action<{ orderId: string; reason: string }, unknown>;   // the action above
declare function resolveCaller(req: Request): Promise<{ agent?: string; user?: { id: string } } | null>;

const key = process.env.MCP_SIGNING_KEY!;           // use a secret reference in a real app
export const POST = mcpServer({
  actions: [refundOrder],
  signing: { key, signatures: signTools([refundOrder], key) },   // sign at release, verify at every list
  identify: async (req) => resolveCaller(req),
});
```

Without `signatures` the descriptions are signed when the server is created.

### Give an agent a narrow token

`TokenExchange` turns a person's session into a short-lived agent token (OAuth token exchange, RFC 8693). The token lasts at most 10 minutes, carries only the scopes asked for, and names the agent as the actor. Use its `identify` with `mcpServer`: a tool the scopes do not cover is refused with `MAIN-MCP-0010`, and the audit log names the agent and the person.

```ts title="lib/agent-tokens.ts"
import { TokenExchange } from "mainsdk";

const user = { id: "u_1" };

export const tokens = new TokenExchange({
  key: process.env.AGENT_TOKEN_KEY!,
  scopes: ["orders:read", "orders:refund"],   // the most an agent may ever get
  ttl: 600,                                    // seconds, 600 at most
});

// After the person signs in and approves the agent:
const { access_token, expires_in } = tokens.exchange({ subject: user, agent: "bot", scope: "orders:read" });
// export const POST = mcpServer({ actions, identify: tokens.identify });
```

`orders:read` serves read tools in the `orders` namespace. `orders.refund` needs `orders:refund` (or `orders.refund`).

### Call other MCP servers

```ts title="lib/docs-tools.ts"
import { mcpClient, network } from "mainsdk";

const mcp = mcpClient({
  servers: { docs: { url: "https://mcp.partner.com", allow: ["search"] } },
  fetch: (url, init) => network.fetch(url, init),   // the only way out: the network allowlist
});
const tools = await mcp("docs").tools();
```

An unlisted server or tool throws `MAIN-MCP-0001` before any request. A `pin` of a tool hash that no longer matches throws `MAIN-MCP-0002`. Results are untrusted data. See [MCP server and client](https://mainsdk.dev/docs/agents/mcp).

### Give a model the same action as a tool

The [`ai`](https://mainsdk.dev/docs/interfaces/ai) interface turns an action into a model tool with `ai.tool()`. Only functions declared with `action()` qualify, and they keep their `access` check and approval rules.

```ts title="app/api/chat/route.ts"
import { ai, type AnyAction } from "mainsdk";

declare const listOrders: AnyAction;   // actions declared with action()
declare const refundOrder: AnyAction;

export async function ask(goal: string, user: { id: string }) {
  const run = await ai.run({
    model: "anthropic/claude-sonnet-4-5",
    feature: "support-chat",
    agent: "support-bot",               // declared under agents in main.yaml
    user,
    goal,
    tools: [ai.tool(listOrders), ai.tool(refundOrder)],
  });
  return run.status === "done" ? run.text : run.status;
}
```

## Reference

| Call | What it does |
| --- | --- |
| `createMcpServer({ actions })` | Creates the MCP endpoint as a `(req) => Response` handler. It is the `create` name for `mcpServer` |
| `createMcpClient({ servers })` | Creates the MCP client for the servers listed in config. It is the `create` name for `mcpClient` |
| `signTools(actions, key)` | Signs every action's description now; pass the result as `signing.signatures` of `mcpServer` |
| `new TokenExchange({ key, scopes, ttl? })` | Issues agent tokens from a person's session (`exchange`) and reads them back (`identify`, `verify`); 10 minutes at most |
| `toolHash(tool)` | The sha256 of a tool definition's canonical JSON, to notice when a server changes a tool |
| `inputHash(value)` | The sha256 of an input's canonical JSON. The audit log holds this hash, never the arguments |
| `canonicalJson(value)` | JSON with object keys sorted and `undefined` fields dropped, so equal values hash alike |
| `idempotency()` | The app's idempotency record. Its members are `idempotency.once(action, input, fn)`, `key` and `current` |
| `new Idempotency(opts)` | Builds a record from a store, tenancy, log and timers. Tests and drivers use it; apps use `idempotency()` |
| `idempotencyKey(action, input)` | A stable `idem_` key made from an action and its inputs, the default for every call that changes the world |

`idempotency.once(action, input, fn, { key?, ttlMs? })` runs `fn` once per key. A repeat returns the first result without calling `fn`, and a repeat that arrives while the first runs waits for it. When `fn` throws, the record is removed so a retry runs again. Records last 24 hours by default and every key includes the tenant, so one tenant never gets another's result.

## Planned

These are in spec `mcp-actions` and not built. Until they are, call an action from your own route handler.

- A generated HTTP endpoint, a server action and a `mainsdk run <name>` command for every action.
- `mcp.auth` and `mcp.signing` keys in `main.yaml` (the code takes the same values as options today).
- Agent-to-agent calls over A2A (phase 5).

## Errors and checks

Errors are `MainError` values with a `code`, a `fix` and a status, and serialise as [RFC 9457 problem details](https://mainsdk.dev/docs/reference/errors). Over MCP they come back as an error tool result with the same `code`.

| Code | When | Fix |
| --- | --- | --- |
| `MAIN-ACTION-0001` | The input failed the action's schema (400) | Send input that matches the schema; each entry in `errors` names the path and the rule |
| `MAIN-ACCESS-0001` | `access` refused the caller | Grant the permission on purpose, or stop the call |
| `MAIN-MCP-0005` | A write or destructive tool was not confirmed, so nothing ran | Wire `elicit` so the person can accept |
| `MAIN-MCP-0003` | A tool description changed after signing; the tool is left out of `tools/list` | Restore the description, or sign again by releasing |
| `MAIN-MCP-0010` | The agent token has no scope for the tool | Ask for the scope when the token is issued |
| `MAIN-MCP-0011` | A token exchange was refused: no scope, a scope not allowed, or a `ttl` over 10 minutes | Ask only for scopes in the configured list |
| `MAIN-AGENT-0001` to `0005` | The agent is unknown, over budget, approving itself, switched off, or the approval id is not pending | See [App agents](https://mainsdk.dev/docs/interfaces/agents#errors-and-checks) |
| `MAIN-AI-0010` | `ai.tool()` was given a function not declared with `action()` | Declare it with `action({ name, intent, input, risk, run })` |

All codes are in [check codes](https://mainsdk.dev/docs/reference/check-codes).

## Swapping drivers

The drivers are the doors an action is exposed through. Today those are direct calls, MCP and `ai.tool()`; HTTP, server actions and the CLI are planned. Turning MCP off for an action is a one-line change:

```ts title="actions/orders.ts"
import { action } from "mainsdk";
import { s } from "mainsdk/schema";

export const refundOrder = action({
  name: "orders.refund",
  intent: "Refund an order the signed-in user owns",
  input: s.object({ orderId: s.string() }),
  risk: "destructive",
  mcp: false,   // was true: the tool disappears from tools/list and cannot be called over MCP
  run: async ({ input }) => ({ refunded: input.orderId }),
});
```

## Testing

`createTestApp()` sets the current app to one with in-memory drivers, so an action runs with the real `access`, `agents` and audit log and no provider account. Assert on `app.log.auditRecords`.

```ts title="actions/orders.test.ts"
import { createTestApp } from "@mainsdk/testing";
import type { Action } from "mainsdk";

declare const refundOrder: Action<{ orderId: string; reason: string }, unknown>;   // from ./orders

const app = createTestApp({ yaml: "schema: 1\naccess:\n  roles: { member: ['*'] }" });
await refundOrder({ orderId: "o_1", reason: "late" }, { user: { id: "u_1", roles: ["member"] } });
const event = app.log.auditRecords.find((r) => r.action === "orders.refund");   // event.decision is "allow"
```

The kit has no helper yet to sign a user in or to call an action through a chosen door. See [the testing kit](https://mainsdk.dev/docs/interfaces/testing).

## Security and privacy

- One function behind every door: an action reachable through MCP never skips a check the direct call runs.
- Destructive actions always get a person's yes before an agent runs them; write actions ask by default.
- Every call writes one audit event with the surface, user, decision and a SHA-256 hash of the input, never the input. An agent call names both the agent and the person.
- Tool results from other MCP servers are untrusted data.

## FAQ

**Do I have to run a separate MCP server?** No. `mcpServer()` is a request handler in your app, built from the same actions.

**Are all my actions visible to agents?** No. Only actions marked `mcp: true` or listed in `mcp.expose` become tools.

**Can I turn off confirmation for destructive actions?** No.

**Does MainSDK run the OAuth server for agents?** Not yet. You resolve the caller in `identify`; token exchange is planned.

## Next

- [AI calls](https://mainsdk.dev/docs/interfaces/ai): give a model your actions as tools.
- [App agents](https://mainsdk.dev/docs/interfaces/agents): budgets and approvals.
- [Permissions](https://mainsdk.dev/docs/interfaces/access): the `access` check every call runs.
