MainSDK
Search⌘K
Interfaces

AI and agents

Actions and agent tools

action

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.

Previewships as a beta APIPhase 1 · Core, public betaSpec mcp-actionsFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
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.

At a glance

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

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 })

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

4 drivers. Select one to see its line in main.yaml.

main.yamlMCP
action:
  driver: mcp

Change this one line to swap providers; your calls stay the same.Built-in and local drivers integration

Codes

CodeWhere it appearsMeaning
MAIN-MCP-0003RuntimeError responses and logsA tool description changed after signing; the tool is left out
MAIN-MCP-0011RuntimeError responses and logsSee the spec
MAIN-MCP-0001RuntimeError responses and logsThe server is not in mcp.servers; no request leaves the process
MAIN-MCP-0002RuntimeError responses and logsA pinned server's tool description changed
MAIN-MCP-0004RuntimeError responses and logsA call limit was reached, for example per-user: 100/day; the call is refused before any request
MAIN-MCP-0005RuntimeError responses and logsA write or destructive tool call wasn't confirmed by the person, so nothing ran (returned in the tool result)
MAIN-MCP-0006RuntimeError responses and logsThe MCP server answered with an error or not at all (502)

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
MCP client with allowlistPhase 10.3mcp-client
Agent identityPhase 10.3agent-governance
One action becomes endpoint, server action, CLI command and audit eventPhase 10.3mcp-actions#action
Opt-in MCP tools and resourcesPhase 10.3mcp-actions#mcp-optin
Confirmation through MCP elicitationPhase 10.3mcp-actions#confirm
Signed tool descriptionsPhase 10.3mcp-actions#signed-tools
OAuth 2.1 on the MCP endpointPhase 10.3mcp-actions#oauth
Agent tokens through token exchangePhase 10.3mcp-actions#token-exchange
Signed approvals for confirmed actionsPhase 31.2signed-approvals#action-risk-hook
Agent-to-agent calls over A2APhase 51.6platforms#a2a

Quick start

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).

Configuration

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

FieldTypeWhat it does
namestringThe action's identity in the audit log and the MCP tool name; the first segment is the default resource type
intentstringA plain sentence of what it does; the MCP tool description
inputSchema (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
mcpbooleanLists the action as an MCP tool; false by default
spend(input) => stringMoney the call moves, such as "$0.50", so an agent's approve.spend-over can compare it
permissionstringThe 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
signsstring[]The input fields shown to the person and bound into the signature, such as ["amount", "payee"]
resource(input) => ResourceThe resource acted on, so owner scope works
runfunctionReceives { input, user, agent, surface }
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
KeyWhat it does
mcp.exposeAction names listed as tools as well as those marked mcp: true
mcp.confirm.writeElicitation before a write action; on unless set to false
mcp.confirm.destructiveElicitation before a destructive action; cannot be turned off
mcp.servers, mcp.limitsThe servers mcpClient() may call and its call limits; see MCP server and client

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.

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 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.

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.

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.

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
Diagram

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.

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.

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

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.

Give a model the same action as a tool

The 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.

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

CallWhat 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. Over MCP they come back as an error tool result with the same code.

CodeWhenFix
MAIN-ACTION-0001The 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-0001access refused the callerGrant the permission on purpose, or stop the call
MAIN-MCP-0005A write or destructive tool was not confirmed, so nothing ranWire elicit so the person can accept
MAIN-MCP-0003A tool description changed after signing; the tool is left out of tools/listRestore the description, or sign again by releasing
MAIN-MCP-0010The agent token has no scope for the toolAsk for the scope when the token is issued
MAIN-MCP-0011A token exchange was refused: no scope, a scope not allowed, or a ttl over 10 minutesAsk only for scopes in the configured list
MAIN-AGENT-0001 to 0005The agent is unknown, over budget, approving itself, switched off, or the approval id is not pendingSee App agents
MAIN-AI-0010ai.tool() was given a function not declared with action()Declare it with action({ name, intent, input, risk, run })

All codes are in 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:

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.

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.

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

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet