Actions and agent tools
actionWrite 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.
mcp-actionsFree in your app, Apache-2.0Status 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.
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 }import { action, db, pay } 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(), reason: s.string() }),
risk: "destructive", // agents must get a person's yes
mcp: true,
run: async ({ input, user }) => {
const order = await db.orders.for(user).get(input.orderId);
return pay.refund(order.paymentId, { reason: input.reason });
},
});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"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 })
Drivers
4 drivers. Select one to see its line in main.yaml.
action:
driver: mcpChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-MCP-0003 | RuntimeError responses and logs | A tool description changed after signing; the tool is left out |
MAIN-MCP-0011 | RuntimeError responses and logs | See the spec |
MAIN-MCP-0001 | RuntimeError responses and logs | The server is not in mcp.servers; no request leaves the process |
MAIN-MCP-0002 | RuntimeError responses and logs | A pinned server's tool description changed |
MAIN-MCP-0004 | RuntimeError responses and logs | A call limit was reached, for example per-user: 100/day; the call is refused before any request |
MAIN-MCP-0005 | RuntimeError responses and logs | A write or destructive tool call wasn't confirmed by the person, so nothing ran (returned in the tool result) |
MAIN-MCP-0006 | RuntimeError responses and logs | The 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.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| MCP client with allowlist | Phase 1 | 0.3 | mcp-client |
| Agent identity | Phase 1 | 0.3 | agent-governance |
| One action becomes endpoint, server action, CLI command and audit event | Phase 1 | 0.3 | mcp-actions#action |
| Opt-in MCP tools and resources | Phase 1 | 0.3 | mcp-actions#mcp-optin |
| Confirmation through MCP elicitation | Phase 1 | 0.3 | mcp-actions#confirm |
| Signed tool descriptions | Phase 1 | 0.3 | mcp-actions#signed-tools |
| OAuth 2.1 on the MCP endpoint | Phase 1 | 0.3 | mcp-actions#oauth |
| Agent tokens through token exchange | Phase 1 | 0.3 | mcp-actions#token-exchange |
| Signed approvals for confirmed actions | Phase 3 | 1.2 | signed-approvals#action-risk-hook |
| Agent-to-agent calls over A2A | Phase 5 | 1.6 | platforms#a2a |
Quick start
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.
| 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 } |
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 |
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.
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.
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.
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
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.
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.
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
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.
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.authandmcp.signingkeys inmain.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.
| 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 |
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.
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:
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.
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
- AI calls: give a model your actions as tools.
- App agents: budgets and approvals.
- Permissions: the
accesscheck every call runs.
Was this page helpful?