MCP Servers
Expose a curated set of tools to agents running outside Twisp over the Model Context Protocol, with every request recorded as an execution and every call bounded by the requesting principal's and the server's policies.
An MCP server is an action of kind mcp that offers some of your tools to agents running outside Twisp, such as a coding assistant or an operations bot, over the Model Context Protocol. Every request an external agent makes is one execution of the server: what it asked, what it was shown, and everything its calls did are recorded and can be investigated like the work of an agent Twisp runs.
An MCP server grants nothing. Every operation a tool performs through it must be allowed by the principal the request's token resolves to, by the server's policies, and by the tool's own policies. A principal cannot do through an MCP server what it could not do itself, and the server's policies bound what any principal can do through it.
Defining a server
An MCP server is created with createAction, like any action. It has kind mcp, an mcp block, a session, and optionally tools, skills, and policies. It has no triggers, tool, or agent block, and its config is {}.
mutation {
actions {
createAction(
id: "3c1e4d1a-7a1b-4bde-9b7e-6b8f3f0c5a21"
input: {
name: "fraud-ops"
description: "Account review tools for the fraud operations team."
kind: "mcp"
config: {}
mcp: {
name: "fraud-ops"
instructions: "Tools for reviewing account activity."
session: {
start: {
input: { principal: "execution.input.principal", subject: "execution.input.subject" }
}
}
}
session: {
inputSchema: {
type: "object"
properties: { principal: { type: "string" }, subject: { type: "string" } }
required: ["principal", "subject"]
}
id: "string(size(session.input.principal)) + ':' + session.input.principal + ':' + session.input.subject"
}
tools: [
{ name: "get_account_context" }
{ name: "notify_human_ops", input: { requested_by: "execution.input.subject" } }
]
policies: [{ effect: ALLOW, actions: [SELECT], resources: ["financial.*"] }]
}
) {
version
}
}
}
mcp field | Meaning |
|---|---|
name | The server's name as MCP clients see it: 1 to 64 letters, digits, underscores, hyphens, or periods. Required. |
instructions | Guidance for the external agent, returned by server/discover. At most 64 KiB. |
session | How each request selects its session. Required. |
mode | PREVIEW or LIVE. See preview mode. Omitted resolves to PREVIEW when the definition is written, and is stored. |
intent | REQUIRED, OPTIONAL, or NONE. See stated intent. Omitted is REQUIRED. |
limits | callsPerMinute, the rate limit of each session: 300 when omitted, at most 6,000. |
resources | Resource templates, each with a uriTemplate (RFC 6570 level 1, such as twisp://accounts/{account_id}), a name, a description, and the tool that reads it. The tool must be read-only, and each template variable must name a top-level input property of the tool that accepts a string. |
- Tools.
toolsare ordinary tool references, with aname, an optionalversion, and fixedinput. A reference can name a tool, a callable agent, or a native tool:graphqlandcel, andprogram,invoke,get_task, andcancel_task, which only an MCP server can list. A server offers exactly the tools it lists. At most 128 tools. The names the external agent sees must be distinct at the versions the write reads.approvalcannot be required. - Fixed input. Expressions read
execution.input, the request, andsession.idandsession.input. That is how you stamp who asked into a tool's input, asrequested_by: "execution.input.subject"does above.eventis not available. Fixed values replace anything the external agent supplies. - Skills.
skillsare skill references, at most 64, withoutpreload, which the server offers as skills and prompts. A skill's name must be a valid Agent Skills name: lowercase letters and digits, in words joined by single hyphens, at most 64 characters. Itsdescriptionis required, and at most 1,024 characters. - Versions. A reference with a
versionalways calls that version. One without calls the tool's latest version, resolved when each request's execution starts. The server itself answers at its current version, and disabling it refuses every request at once. - Execution settings. The server's
execution.responseTimeoutbounds each request: 1 minute by default, and at most 10 minutes. A tool called beneath it is also bounded by its ownresponseTimeout.
Creating a server requires permission to read and invoke each tool and skill it references, as for an agent.
The endpoint
Each MCP server has its own endpoint, which is also the resource its tokens are issued for:
https://api.<region>.<environment>.twisp.com/mcp/v1/<account_id>/<name>
<name> is the action's registry name. The server speaks protocol revision 2026-07-28 over Streamable HTTP. It is stateless: every request carries its protocol version, client information, and capabilities, and is authorized by its own bearer token. Hosts that speak an earlier revision, 2025-11-25, 2025-06-18, or 2025-03-26, can connect too: they get tools, resources, and prompts, but not tasks, so they cannot call callable agents.
- Signing in. A person connects an MCP host by signing in to Twisp as the user they are, on the same page and with the same MFA as the console; Connecting MCP hosts has the step for each host. The host discovers the sign-in from the endpoint, and stores and refreshes its token itself. Each request is made as that user, with the permissions they have in the console.
- Tokens. A request carries the access token the host got from Twisp sign-in, with an
audclaim naming the region's MCP endpoints,https://api.<region>.<environment>.twisp.com/mcp. A token for one MCP server works at the region's other MCP servers your user may reach, and nowhere else: not at the GraphQL API, and not in another region. A token from anywhere else, including Twisp's IAM token exchange, is not accepted. A missing, invalid, or wrongly addressed token gets401with aWWW-Authenticateheader naming the server's Protected Resource Metadata and the scope to request,openid. The endpoint's account must be one of the user's accounts. - Connecting. The principal needs permission to read the server's action and
INVOKEon it. WithoutINVOKE, a request gets403. The principal needs noINVOKEon the server's tools: the server's references authorize those calls. - Discovery.
server/discoverreturns the supported protocol version, the capabilities the server offers, among them the Tasks extension, and itsinstructions.tools/listreturns one tool per reference that can be called, in reference order: a tool whose action or version is disabled or deleted is left out. Each tool'sinputSchemais its input schema without the fixed input's properties and without anyx-mcp-headerannotation: the server reads arguments only from the request body, so a host sends noMcp-Paramheaders. Both can be cached for 60 seconds. - Methods. Every server answers
server/discover,tools/list, andtools/call, and the Tasks extension'stasks/get,tasks/update, andtasks/cancel. A server withskillsalso offers resources, prompts, and the Skills extension (io.modelcontextprotocol/skills), and a server with resource templates offers resources;server/discoverdeclares exactly what the server offers. A method of a capability the server does not offer, like any other method, gets404and-32601.
A request refused before its token is checked, such as one with mismatched protocol headers or an unsupported version, is not recorded.
Requests
Each request that passes the token check is one execution of the server, whose input is the request, with its event type mcp.request. ActionExecution.mcpRequest reads it:
| Field | Meaning |
|---|---|
method | The MCP method, such as tools/list or tools/call. |
name | The tool a tools/call named, the URI a resources/read or skills/get named, or the prompt a prompts/get named. |
arguments | The call's arguments as the external agent sent them, without its stated intent. |
intent | The call's stated intent. |
principal | The Twisp principal the token resolved to. |
subject | The token's sub: the signed-in user. |
oauthClientId | The token's client_id: the MCP host application. |
clientInfo | The client's name and version, as it reported them. They are not verified. |
meta, metaOmitted | The request's _meta other than the protocol's own fields, such as trace context; not recorded over 16 KiB. |
argumentsOmitted | The request was refused as too large to record, and its arguments were not recorded. |
requestId | The JSON-RPC request ID. |
protocolVersion, tasks | The protocol revision the client requested, and whether it declared the Tasks extension. |
taskId | The task a tasks/get, tasks/update, or tasks/cancel named. |
The execution's caller is the principal, and its outcome is:
SUCCEEDEDwhen Twisp answered with a result, including a tool result withisError.FAILEDwhen Twisp answered with a JSON-RPC error, such as for an unknown tool, a passed deadline, or a transaction that did not commit, and when the request was refused, including atools/callin a closed session, which the external model sees as a tool errorsession_closed.CANCELLEDwhen the client closed the request before its result.
A tools/call makes exactly one call, a child execution of the request, or, for a callable agent, starts one task. A call of a native tool, such as program or invoke, is the request's one call too: an execution of the native tool, whose own calls are its children. Its invocation.output records whether the result was an error, and the call records its effective input, with fixed input applied, and its output or error. A successful call's output is recorded as a TOOL use of the request. A tools/list records, in its invocation's tools, the exact version of every tool it offered.
Nothing leaves before it is recorded. Twisp returns a request's result only after the execution that recorded it commits. If the commit fails, the client gets an error and no content.
The result is a fixed rendering of what was recorded:
| Call | tools/call result |
|---|---|
| Succeeded with a string output, and no output schema | One text content block with the string. |
| Succeeded with a string output, and an output schema | structuredContent with the string, and one text content block with the string. |
| Succeeded with any other output | structuredContent with the output, and one text content block with its canonical JSON. |
| Failed | isError: true, one text block <code>: <message>, and a second with the hint when there is one. |
The principal, subject, and OAuth client on the request come from the verified token, never from what the client sends.
Arguments that fail the tool's input schema, a policy denial, and any other call error are results with isError, so the external agent can correct itself. A policy denial's message names the operation, the resource, and the layer that denied it: the principal, the server, or the tool, with a hint. It leaves out the principal's identifier, the index of the denying policy, and action IDs, which the call's recorded error keeps for you. Every result carries the server's name and the action version that answered in _meta["io.modelcontextprotocol/serverInfo"].
A callable agent runs only as a task.
Tasks
A callable agent runs its whole loop, with its model exchanges and tool calls, often for minutes: longer than a request should be held open. So a call that runs one answers with a task: a tools/call naming a callable agent, and an invoke of an agent. The server offers tasks through the MCP Tasks extension, io.modelcontextprotocol/tasks, which server/discover declares, and to hosts without it through get_task. Every other tool answers its call at once.
Creating. A
tools/callnaming a callable agent starts the agent with its arguments and the reference's fixed input. To a client that declares the extension in the request's client capabilities, it answers with the task:resultType: "task",taskId,status: "working",createdAtandlastUpdatedAt,ttlMs(24 hours), andpollIntervalMs(5 seconds). The task is the started execution:taskIdis its ID, and itsstartedBynames the request. The answer is sent only once the task is recorded and queued, so it can be polled at once. The request records no call and no use.Running. The agent runs as a started execution, under its own
executionsettings, with retries and recovery. Its authority is the principal's, then the server's policies, then the agent's own, checked again at each invocation: revoking the principal, or its credential expiring, stops the work that has not run.Polling.
tasks/getanswers with the task'sstatus, and itslastUpdatedAt, when the execution last changed:Execution statusAlso QUEUED,RUNNING,ACCEPTED,RETRY_SCHEDULEDworkingSUCCEEDEDcompletedresult: the agent's output, rendered as atools/callresult is.FAILEDcompletedresult: a tool error with the execution's error code, the messageThe task's agent failed, and its hint.EXHAUSTEDfailedstatusMessage, anderror:-32603, with the execution's error code indata.codeand its hint indata.hint. Its retries ran out.CANCELLEDcancelledThe execution's full error, whose message and hint can name principals, policies, and actions, stays on it for you to investigate: a hint the task returns names a denying layer by name alone, and a hint that would name more is replaced by a generic one.
A result is readable only once the execution that recorded it committed. The poll that returns an agent's output records it as a
TOOLuse of the poll's execution, even after the server stops referencing the agent. A task stays readable after itsttlMs.Updating. Twisp never asks the client for input, so
tasks/updatehas nothing to update, and is acknowledged.Cancelling.
tasks/cancelcancels the task, and is acknowledged, also for a task that has finished. A queued task iscancelledat once. A running agent stops at its next step, its writes roll back, and it becomescancelled. The execution'scancelledBynames the principal and thetasks/cancelrequest. A cancellation is not a business write: on a server in preview mode it takes effect too, though the request is markedpreview.Which tasks. A task is reached only by requests to the same server in the session that created it. A task ID that is unknown, or belongs to another session or server, gets
-32602withmcp_task_not_found, the same in every case, so a request cannot learn that another session's task exists.How many. A session has at most 32 unfinished tasks, and so does a user, one principal and token subject, across all of a server's sessions. An agent
invokeruns counts against the same limits. A call beyond them is a tool error,start_limit, and starts nothing. A finished task frees its place.Without the extension. A call that answers with a task, from a client that does not declare the extension, starts it only on a server that lists the native tools
get_taskandcancel_task, and answers with a tool result whose text names it:task_id,status: "working",poll_interval_ms, and ahint. On a server that does not list both, such a call is refused with JSON-RPC-32021,mcp_task_required, whosedata.requiredCapabilitiesnames the extension, and aninvokeof an agent with a tool error of the same code, so a client is never started an agent it cannot read or cancel. Read the task withget_task:Argument Meaning task_idThe task_ida call returned. Required.intentThe call's stated intent. get_taskanswers withtask_id,status(working,completed,failed, orcancelled, as fortasks/get),poll_interval_mswhile it isworking, theresultof acompletedtask, rendered as atools/callresult is but with the agent's output once, asstructuredContentwhen it has one, and theerrorof afailedone. It reaches the same taskstasks/getdoes, and gives the same answer for every task it cannot reach: a tool error,mcp_task_not_found. The read that returns an agent's output records it as aTOOLuse.get_taskis read-only.Cancel the task with the native tool
cancel_task, with the same arguments, when the server lists it. It cancels the task astasks/canceldoes, in the same scope, and answers with itstask_idand ahint, also for a task that has finished; a task it cannot reach is a tool error,mcp_task_not_found. The execution'scancelledBynames the principal and thetools/callrequest. It is never annotated read-only: a running agent's writes roll back.tasks/get,tasks/update, andtasks/cancelthemselves need the extension: from a client that does not declare it they are refused with400and JSON-RPC-32021, whosedata.requiredCapabilitiesnames the extension anddata.methodthe method, even for a task that a request declaring it created.
Each tasks/get, tasks/update, and tasks/cancel is its own execution of the server, in its session, and counts against its rate limit. A tasks/get records the status it returned in its invocation.output.
Skills
A server serves each of its skills as one file, SKILL.md, at a URI that names the skill version it renders:
skill://v<version>/<name>/SKILL.md
<name> is the skill's registry name, and <version> the version the reference resolves to when the request runs: its pinned version, or the skill's latest. A host can cache a skill's content by its URI for an hour.
The file is YAML frontmatter with the skill's name and description, then its instructions, then an ## Examples section with each example's situation, resolution, and reason. When the skill's tools list tools the server offers, a closing ## Tools section names them as the external agent sees them; tools the server does not offer are not mentioned. The file never declares allowed-tools: a skill grants nothing.
| Method | Result |
|---|---|
skills/list | One entry per skill: its uri, its frontmatter, identical to the file's, and resources, the file's uri, digest (sha256: and its SHA-256 in lowercase hex), and size in bytes. |
skills/get | The entry for one skill's SKILL.md URI. |
resources/list | Each skill's SKILL.md, with mimeType: text/markdown, the skill's name and description, and size. Cached for 60 seconds. |
resources/read of a SKILL.md | The file as text/markdown, whose bytes match the entry's digest and size. |
prompts/list | Each skill as a prompt without arguments, named after the skill. Cached for 60 seconds. |
prompts/get | One user message with the same file. |
- Loads. A
resources/readof aSKILL.md, or aprompts/get, loads the skill: its one call is a load of that exact version, the same load an agent makes, recorded as a child execution. The request'sinvocation.outputrecords the file'suri,digest, andsize, and the load's output is aSKILLuse. A load from an MCP server makes none of the skill'stoolsavailable: the external agent can call only the server's own tools. Listings load nothing, and record ininvocation.skillsthe version of each skill they offered. - Versions. When a skill gets a new version, a reference without a
versionserves it at a new URI. Askills/getorresources/readof the previous URI gets-32602withmcp_skill_superseded, so the host fetches the current one. - Availability. A skill whose action or resolved version is disabled or deleted, or whose latest version's
descriptionis empty or longer than 1,024 characters, is left out of every listing, and a request for it gets-32602.
A host caches a skill's content by its URI, so later reads of the same version may not reach Twisp. The first read of each version by each host is recorded, and that is when the content left Twisp.
Resources
Resources are context a host attaches to a conversation, often because the user named something, such as an account. Each mcp.resources entry is a template of resource URIs, read by one of the server's tools:
mcp: {
resources: [{ uriTemplate: "twisp://accounts/{account_id}", name: "account", description: "An account and its balances.", tool: "get_account" }]
}
- Listing.
resources/templates/listreturns the templates, each with itsuriTemplate,name, anddescription, and amimeTypewhen the tool's output schema fixes one. It can be cached for 60 seconds.resources/listlists no resources besides skills' files. - Reading.
resources/readmatches the URI against the templates in order, and the first that matches reads it. Each variable matches one or more characters, percent-decoded, and becomes a top-level string property of the tool's input; the tool reference's fixed input still applies, and replaces a variable of the same name. The read is the request's one call, recorded like any other, and its output is aTOOLuse. The result is one item for the URI:application/jsonwith the output's canonical JSON, ortext/plainfor a string output. It is never cached, because ledger data is live and every read is recorded. A read states no intent: the host makes it, not the model. - Read-only. A host may read a resource without asking anyone, so a template's tool must be read-only by the derived rule. The definition write checks it, and every request checks it again for a reference without a
version: when a later version of the tool can write, its templates are left out ofresources/templates/listand their reads get-32602withmcp_resource_unavailable. - Errors. A URI no template matches gets
-32602withmcp_resource_not_foundand the URI indata.uri. A read whose tool fails gets-32602with the call'scodeandhintand the URI; the failed call is recorded with the request.
In preview mode, a resource read and a skill load run as preview calls, and a replay reruns them against the draft's versions.
Stated intent
Twisp never sees why an external model makes a call, so an MCP server asks. With intent: REQUIRED, the default, every tool gains a required intent argument, a sentence of 1 to 1,024 characters. The server removes it from the arguments before the call and records it on the request, so the tool never receives it. A call without it is a tool error that asks for one, and is recorded. OPTIONAL offers the argument without requiring it, and NONE adds nothing.
A tool that declares its own top-level intent property cannot be offered while intent is on: the definition write rejects it. If a later version of a tool adds one, that tool is left out of tools/list, and a call to it is a tool error whose hint says to pin a version or rename the property. The same applies to a later version whose name another of the server's tools already has.
The stated intent is the external model's own account. It is evidence for an investigator, and is never used for authorization.
Annotations
The tool annotations hosts use to decide when to ask a person before a call are derived from policies, not declared:
readOnlyHint: truewhen the server's policies, or the tool's own, allow onlySELECT: everyALLOWpolicy names onlySELECTactions. Any other action counts as a write, includingINVOKE,RESPOND, and a wildcard such as*oractions:*. For a callable agent, its policies are the tool's.celis always read-only, andgraphqlhas only the server's policies.destructiveHint: falsewhen the server's or the tool's policies allow onlySELECTandINSERT. Otherwise it is omitted.openWorldHint: truefor a model, whose call sends its input to the model's provider, and for a tool or callable agent that declares one, directly or through the tools it declares, at the versions its references resolve to.programis open world when the server lists an open-world tool. Every other tool isopenWorldHint: false.
Annotations are derived for each listing from the versions it offers, so they change when a tool's policies do. Give a tool read-only policies, and hosts can call it without asking. program and invoke allow everything, so they have only the server's policies.
Programs
A server that lists the native tool program offers it to the external agent. It runs a program the external agent writes, in Starlark, with the conventions of a Starlark tool: it defines run(event), reads its input as event["data"], calls the server's tools as tools.<name>(input), and returns the result's value. graphql and cel are available only when the server lists them. CEL's functions, such as decimal("10.50") and money("5", "USD"), are builtins in every program and run inside it; the ones that read the clock or randomness, such as uuid.New(), are cel calls and need the server to list cel. A program can do nothing its tools could not do one call at a time.
| Argument | Meaning |
|---|---|
src | The program, at most 64 KiB. Required. |
input | An object, passed to the program as event["data"]. |
commit | true commits the program's writes. false previews them. Required. |
intent | The call's stated intent, as for every tool. |
- Checked first. Before a program runs, a GraphQL document it passes to
graphql()as a literal or a constant, directly or through its own functions, is validated against the schema, with itsoperation_nameand the variables it requires, and eachtools.<name>against the server's tools. A program that fails is refused withaction_input_invalid, whose message says how the document reaches the call, and makes no call. A document the program builds or chooses at run time, such as one alternative of a conditional or one element of a loop, or passes to a call with acheckargument other thanTrue, is validated when the call runs. - Atomic. Every call a program makes commits together, or none does. An error the program does not handle, such as
fail(...)or a tool error it did not catch withcheck=False, rolls back every write and returns a tool error with itscode,message, andhint, the message led by the traceback lines of the program's own source. A policy denial's message names the operation, the resource, and the denying layer, as a call's does; the program's record keeps the error in full. - Listings. A program's calls share one transaction, so an eventually consistent listing, such as
accountsbyNAME, does not include records the program wrote earlier. Read them by ID or unique key instead; see Consistency of Reads. - Recorded. The program is the request's one call: a child execution of the native
programtool, whose input is the program's arguments, source included, and whose output is its result. Each call it makes is a child of the program, including calls a failed program rolled back, which carryrolledBackByExecutionId. - Previews. With
commit: false, the program runs every call and commits none. Its result is{value, writes, unreadable_writes, omitted_writes}:writeslists each row it would write that the principal may read, through its own policies and the server's, with the row'stable,key, and newvalue; rows it may not read, and deleted rows, are counted inunreadable_writes, not shown, so a preview cannot reveal a balance the principal cannot read. Rows beyond the result's size limit are counted inomitted_writes. The preview's execution is recorded withpreviewset and every row it would write in itswriteSet, so an investigator sees the preview and the commit that followed it. Reading the execution shows the same limit: itswriteSet, and each call's, lists only the rows the reader may read, and counts the rest inwriteSetUnreadable. A committed program's result is{value}. - Preview mode. In preview mode a program never commits: with
commit: trueit runs as it would live, its writes are rolled back and recorded in itswriteSet, and its result carries the preview marker. - Annotations.
programisreadOnlyHint: trueonly when the server's own policies allow onlySELECT, anddestructiveHint: falseonly when they allow onlySELECTandINSERT: a program can call every tool the server lists. - Limits. At most 64 tool calls, 64
graphqland 64celcalls, and 1,000,000 execution steps, within the server'sresponseTimeout. The program's call and each call it makes count against the session's rate limit. A program cannot callprogram,invoke,get_task, or a callable agent. A program that passes the deadline fails the request withmcp_request_deadline, and nothing it did is recorded.
Invoking an action
A server that lists the native tool invoke lets the external agent run one of your tools or callable agents on an input it chooses. With commit: false, the default, it previews: a stored version, a disabled one, or an unpublished definition runs, and none of its writes commit. With commit: true, it runs a published, enabled version for real. So an agent that builds tools and agents through the server can run what it built before anything depends on it, and check it once it is live.
| Argument | Meaning |
|---|---|
action_id | The action to invoke. It must exist and not be deleted. A preview may run one that is disabled, or not yet published. Required. |
version | A stored version of the action. With neither version nor definition, the current version runs. |
definition | An unpublished definition of the action, in the shape createAction's input takes, as JSON: config and schemas as objects, enums such as ALLOW and SELECT by name, and durations as strings such as "30s". It is validated as createAction validates its input, and never stored. Only with commit: false; it cannot be combined with version. |
input | The input to call the action with, an object. Required. |
commit | false, the default, previews. true runs a published, enabled version, and commits what it writes. |
intent | The call's stated intent, as for every tool. |
- What runs. An action of kind
tool,graphql, orcelruns in the request. An agent with atoolblock answers with a task, in both modes. A skill, an MCP server, a native tool, an agent without atoolblock, and a tool with a session cannot be invoked. - A tool the server lists.
invokenever goes around the server's reference to an action it lists: the reference's fixed input applies, in both modes and to a draft, and the version the reference names runs unless a draft does. Anotherversionis refused withaction_input_invalid. - The loop. An action created with
enabled: falseis never triggered or called, but can be previewed: create a new tool disabled, preview it, preview drafts of it until it works, thenupdateActionwith the draft that worked andenabled: true, andinvokeit withcommit: truewhere a real run is safe. Preview a change to a live action as a draft, beforeupdateActionpublishes it to every caller. - Authority. What
invokeruns runs under the principal, then the server's policies, then the action's own policies, which can only narrow them, then each tool it calls, as a call of a tool the server lists would. Before anything runs, the principal and the server must be allowed to read the action andINVOKEit, and, for a draft, toINVOKEevery action the draft references and every tool its skills make available. So a server's policies needSELECTandINVOKEon the actions it invokes. An action the principal or the server may not read is refused as one that does not exist. - A draft keeps its kind. A draft of another kind than the action's is refused with
action_input_invalid. The principal and the server must be allowed to read andINVOKEthe action as the draft would store it, as well as the stored action, soINVOKEassertions ondefinitionapply to what runs. - A draft uses only what the server offers. A draft is your code, so it reaches what the server lists only as the server does: a call it makes, or a draft agent's, to a tool the server lists goes through the server's reference, at its version and with its fixed input, whatever the draft's own reference says. It reaches the native
graphqlandceltools only when the server lists them. A draft of a native tool's kind, or whosetools, or whose skills' tools, name a native tool the server does not list, is refused withaction_input_invalid: "the definition uses the native tool graphql, which this server does not offer". A Starlark draft can callgraphql()andcel()without declaring them; through a server that does not list the one it calls, that call fails withtool_not_declared, and the result reports it as the tool's error. A stored action runs as it would when called, and so do the stored tools a draft calls. - What it offers.
invokecan run any tool or agent the principal and the server mayINVOKE, and a draft can call any of them, not only the tools the server lists. Their reads are real; a preview's writes are rolled back, and a commit's are committed. Listinvokeonly on a server agents build with. - Nothing a preview commits. A preview's writes are rolled back, on a
LIVEserver as on aPREVIEWone. Work that would follow a commit is dropped: acreateActioninside a preview publishes nothing, and no dispatch, workflow, or search index change happens. An operation that acts outside the ledger, or writes to another tenant, fails. Reads are real, and so are an agent's model calls. On aPREVIEWserver, a commit commits nothing either, as no call there does. - Annotations.
invokeallows everything, so its annotations are the server's: it is read-only only when the server's policies allow onlySELECT.
Invoking a tool
A tool runs at once, as the invoke call's own call, with its own fixed input, input and output schemas, response timeout, and policies:
{
"name": "invoke",
"arguments": {
"action_id": "0b6a4c4e-2f7e-4c55-9d43-6a3f4e2b8d10",
"input": { "account_id": "70eec1a6-eac1-47aa-a507-e18f2d486425" },
"intent": "Check the new low-balance flag before enabling it."
}
}
A preview's result structuredContent:
{
"execution_id": "4b1d2a38-0c7e-4d1a-9b55-2f8e6c0a9d31",
"status": "SUCCEEDED",
"output": { "flagged": true, "balance": "12.50" },
"calls": [{ "name": "graphql", "status": "SUCCEEDED", "execution_id": "9c0e5f12-…" }],
"writes": [
{
"table": "KVValue",
"key": "…",
"value": { "namespace": "alerts", "key": "70eec1a6-…", "value": { "low": true } }
}
],
"unreadable_writes": 0,
"omitted_writes": 0
}
Writes.
writeslists each row the preview would have written that the principal may read, through its own policies and the server's, with the row'stable,key, and newvalue, as a program's preview does. Rows it may not read, and deleted rows, are counted inunreadable_writes; rows beyond the result's size limit are counted inomitted_writes.Calls.
callslists the tool's direct calls in order, each with itsname,status, andexecution_id, itserrorwhen it failed, androlled_back: truewhen the tool's failure discarded its writes. The whole call tree is readable byexecution_id.A commit. With
commit: true, the result hasexecution_id,status, andoutputorerror, and nocallsorwrites: what it wrote is committed, and readable as any other record.Failure. A tool that runs and fails is not a tool error: the invocation succeeded, and the tool's failure is in the result. The result has
status: "FAILED", nooutput,errorwith itscode,message, andhint, and for a preview its calls and emptywrites. A Starlark error's message is the traceback of the tool's own source. A policy denial's message names the operation, the resource, and the denying layer, as a call's does:{ "execution_id": "4b1d2a38-0c7e-4d1a-9b55-2f8e6c0a9d31", "status": "FAILED", "error": { "code": "action_failure", "message": "Traceback (most recent call last):\n program.star:9:22: in run\n program.star:3:15: in ratio\nError: floored division by zero" }, "calls": [ { "name": "get_account", "status": "SUCCEEDED", "execution_id": "a7f2c0d4-…", "rolled_back": true } ], "writes": [], "unreadable_writes": 0, "omitted_writes": 0 }Limits. A tool is bounded by the server's
responseTimeout, and by its own; one that passes the request's deadline fails the request withmcp_request_deadline. Theinvokecall and the calls beneath it count against the session's rate limit.
Invoking an agent
An agent runs for minutes, so invoke starts it as a task and answers at once, in both modes. To a client without the Tasks extension:
{
"task_id": "7d2a9e04-5b1c-4f3e-8a6d-0c9b2e7f4a18",
"status": "working",
"poll_interval_ms": 5000,
"hint": "It runs on its own, and nothing it does is committed. Read it with get_task, passing task_id, every poll_interval_ms, or with tasks/get when your client has the Tasks extension."
}
A client with the extension gets the task itself, and polls it with tasks/get. Either way, a read of a finished task returns the agent's result.
- Running. The agent runs as a started execution, under its own execution settings and limits, and under the principal and the server's policies, then its own. In a preview its calls' writes are discarded and recorded in their write sets, and a call that requires approval runs as if approved, marked
grantedInPreview, as in preview mode. A preview of a draft keeps nothing of the agent's, such as its memory. Read its transcript and write sets withexecution(id:). - Who can read it. Only requests to the same server, in the session that started it, by the same principal and user. Any other task is
mcp_task_not_found. - How many. An agent
invokeruns counts against the session's and the user's 32 unfinished tasks; one beyond them isstart_limit, and starts nothing.
Refused invocations
An invocation that cannot run is a tool error, with isError, which the external agent can act on. The invoke call is recorded FAILED, with the refusal.
code | When |
|---|---|
action_not_callable | The action does not exist, is deleted, may not be read by the principal or the server, is a native tool, or the definition has no tool block or has a tool session; or, for a commit, the version is not published and enabled. |
action_input_invalid | Arguments that break the input schema, version with definition, a definition with commit: true, a version the action does not have, an invalid draft, whose message names the rule, or a definition that uses a native tool the server does not list. |
action_access_denied | The principal or the server may not INVOKE the action, or an action the draft references. |
start_limit | An agent beyond the session's or the user's 32 unfinished tasks. |
action_failure | The deployment cannot decode a draft, or cannot roll a preview back. |
Records
- The request records
invoke's arguments, a draft in full, and the stated intent. - The call. Every
invokecall is an execution of the nativeinvoketool, a call of the request, as a program is. Its input is the arguments, a draft in full, and its output is what it answered. A refused invocation is that execution,FAILEDwith the refusal. - The invoked execution is the action's own execution. A preview's is
preview, as is every call beneath it, each with its write set. For a draft,draftis the unpublished definition it ran, with its references resolved, andactionVersionthe action's version when it ran. For a stored version,draftis null, andactionVersionis the version that ran. - Lineage. A tool is a call of the
invokecall, and its calls are its own, so lineage reads: the request, theinvokecall, the tool, its calls. An agent is started by the request, as a task is: itsstartedBynames the request, and theinvokecall's output names the task. Lineage reads the request, then theinvokecall, and, through aSTARTedge from the request, the agent and its calls. - Failed tools. Beneath a failed tool, each call is kept, with its write set, and carries
rolledBackByExecutionId, naming the failed execution. When an invocation of an agent fails, the calls it made are kept the same way. - Finding previews.
actions { executions(where: { actionId: $id, preview: true }) }lists what committed nothing, previews included, andpreview: falseleaves them out of an action's history.action(id:) { executions(where: { preview: false }) }takes the same filter. - Uses. As for any call, a successful
invokecall's output is aTOOLuse of the request. A read that returns an agent's output records a use of the agent's execution. - No rerun or replay of a draft. A draft's preview is never rerun or replayed:
rerunExecutionandreplayExecutionof one are rejected. Preview the draft again. - Replay of the request. A replay of a request that invoked a tool reruns it against its recorded arguments, rolled back as always, and records an agent's start as not started.
- Nesting. A program cannot call
invoke.
Sessions
Every request is recorded in a session of the server. mcp.session.start.input maps the request, execution.input, to the session's input, and the session block's id names the session. A request finds its session, or creates it.
| Grouping | start.input |
|---|---|
| Per user | principal: "execution.input.principal", subject: "execution.input.subject" |
| Per user of each host | the above, and host: "execution.input.oauth_client_id" |
| Per principal | principal: "execution.input.principal" |
| Per host-supplied conversation | conversation: "execution.input.meta[\"com.example/conversation\"]" |
The principal of a request is Twisp sign-in, shared by every user of your organization, and its subject is the user; group by principal and subject to give each user a session.
Build id so that distinct inputs can never produce the same ID. Joining fields with a separator such as '|' can collide when a field contains the separator, which puts two users in one session. Prefix each field but the last with its length, as the example above does: string(size(session.input.principal)) + ':' + session.input.principal + ':' + session.input.subject.
A session's input is fixed when it is created. If a later version of the server maps a request to a different
inputunder theidof an existing session, such as after adding a field tostart.inputthatiddoes not include, that session's requests are refused with400andmcp_session_invaliduntil you close it. Include every field ofstart.inputinid.History. Each request adds one
REQUESTinput to its session when it is admitted, with the execution inexecutionId. The input isCLAIMEDwhile the request runs, andCONSUMEDonce its outcome is recorded, with the number of tool calls it made, including those made beneath them, incalls. The session'sinputsare the ordered request log, andexecutions(where: {actionId, sessionId})lists the requests' executions.ActionExecution.sessionIdnames a request's session.Closing.
closeSessionstops one user, principal, host, or conversation. Its new requests fail withsession_closed: a tool error fortools/call, and a JSON-RPC error otherwise. They run nothing. A request already running when the session closes finishes and is recorded in it. Disabling the server likewise refuses new requests and lets running ones finish.Mismatched requests. A request whose record does not produce a valid session input, such as one without the
_metafield its grouping reads, is refused with400.
Rate limits
mcp.limits.callsPerMinute bounds each session: a request counts as one from the moment it is admitted, while it runs, or as the number of tool calls it made, including those beneath them, once its outcome is recorded, when that is more. A request made when the session's requests admitted in the last minute reach the limit gets 429 with a Retry-After header. Requests to one session made at the same moment are admitted one after another, so they cannot pass the limit together; when many arrive at once, some may instead be refused with mcp_transaction_error and retryable: true.
Refusals
A request refused after its token was checked, for a missing INVOKE permission, a method the server does not serve, a task request from a client without the Tasks extension, a closed session, a mismatched request, the rate limit, or a request too large to record, is recorded as a FAILED execution of the server with its request and the reason, outside any session. It runs nothing. So that a flood of refused requests cannot become a flood of records, only the first refusal of a principal and subject at a server in each minute is recorded: the users who share a principal never hide one another's refusals. The refusals after it in that minute are counted, and the next recorded refusal's invocation.error.details carries unrecorded_refusals, the number counted since the refusal recorded before it, and window, the minute it opened. The count is best effort.
Refusal code | HTTP | When |
|---|---|---|
action_access_denied | 403 | The principal has no client in the account, or lacks INVOKE on the server. |
mcp_method_not_found | 404 | The server does not serve the request's method; the answer is -32601. |
mcp_task_required | 400 | A task request is made, and the client did not declare the Tasks extension; the answer is -32021. A call that would start a task, from such a client on a server that does not list both get_task and cancel_task, gets -32021 from its execution, with 200. |
mcp_request_too_large | 400 | The request is larger than a request's record can hold. |
mcp_session_invalid | 400 | The request does not produce a valid session input, or its session's input differs. |
session_closed | 200 | The request's session is closed. |
mcp_rate_limited | 429 | The session is over its rate limit. |
mcp_tenant_busy | 429 | Too many of the account's MCP requests are running at once, across all its servers and sessions; with Retry-After. |
mcp_user_busy | 429 | Too many of the user's MCP requests, those made with one principal and token subject, are running at once, across all the account's servers and sessions; with Retry-After. |
mcp_transaction_error | 200 | The request could not be admitted to its session; retryable: true. |
A name that is not an MCP server the principal can read, or a disabled server, gets 404 with mcp_server_not_found and is not recorded.
Preview mode
Every MCP server starts in preview mode. In preview mode the server runs every call exactly as it would live, and commits none of its writes: each call is recorded with preview set and the writes it would have made in its writeSet. The request's execution, its session history, and its uses commit, and each result carries "com.twisp/preview": true in _meta, so an informed host can say so. The external agent gets real outputs from your data.
A task a preview request creates is preview too: the agent runs as it would live, and none of its writes commit; each of its calls records what it would have written.
Connect a new external agent in preview mode, review what it would write, then promote the server with updateAction. A supplied mcp block replaces the whole block, so send all of it, with mode: LIVE; a block with only mode is rejected, since name and session are required.
mutation {
actions {
updateAction(
id: "3c1e4d1a-7a1b-4bde-9b7e-6b8f3f0c5a21"
input: {
mcp: {
name: "fraud-ops"
instructions: "Tools for reviewing account activity."
session: {
start: {
input: { principal: "execution.input.principal", subject: "execution.input.subject" }
}
}
mode: LIVE
}
}
) {
version
}
}
}
Replay
replayExecution accepts a request's execution. It reruns the recorded request, such as the tool call with its recorded arguments, against a later version of the server or an unpublished definition, reading current data. Its writes are always rolled back and recorded as write sets, and it records no request in any session. A reference without a version calls the version the original request used, and a pinned reference calls its pin, so a draft that pins a new version of a tool tests exactly that change. Replay the recent requests to a server against a draft before publishing a change to it, and compare outputs and write sets.
A replay runs under both the principal replaying it and the principal of the original request, then the server's policies and each tool's: it can do nothing either principal could not. The original principal's policies are evaluated with the original request's claims, so authority that depends on claims, such as a signed-in user's own policies, is the original user's. The replayer needs INVOKE on the server. A request's replay involves no model, so model: RECORDED is rejected. A refused request ran nothing and cannot be replayed. A replay of a request that was answered with a JSON-RPC error fails with the same code. rerunExecution of a request is rejected: a request runs only when its endpoint serves it.
A replay starts nothing and cancels nothing. A replayed tools/call that created a task records the task it would have started as not started: CANCELLED, preview, with startedBy.notStarted set and the agent's input, and never queued. A replayed invoke reruns a tool, rolled back as always, and records an agent's start as not started in the same way. A replayed tasks/cancel cancels nothing. Such a task never ran, so it cannot be rerun. A rerun of a task the original request did start is a new start by you, in preview mode when that task was a preview.
Errors
JSON-RPC errors that carry a Twisp error identifier put it in error.data.code, with a hint when there is one: -32603 when Twisp failed to serve the request, and -32602 otherwise. An error's message is a fixed sentence for its code, whatever the request; what the request named is in error.data: the server's name for mcp_server_not_found, the method for -32601, the tool's name for tool_not_declared, the method for a task request refused with -32021, the uri for mcp_resource_not_found, mcp_skill_superseded, mcp_resource_unavailable, and a failed read or load, the session's limit in calls per minute for mcp_rate_limited, and the server's response timeout in seconds for mcp_request_deadline. What went wrong in detail is recorded on the execution's invocation.error, for you to investigate.
| Situation | HTTP | Result |
|---|---|---|
| The name is not an MCP server the principal can read, or the server is disabled | 404 | -32602, mcp_server_not_found. Not recorded. |
The principal lacks INVOKE on the server | 403 | -32602, action_access_denied. A refusal. |
| The request's session is closed | 200 | A tool error session_closed for tools/call; otherwise -32602, session_closed. A refusal. |
| The request does not produce a valid session input, or its session exists with a different input | 400 | -32602, mcp_session_invalid. A refusal. |
| The session is over its rate limit | 429 | -32602, mcp_rate_limited, with Retry-After. A refusal. |
| Too many of the account's MCP requests are running at once | 429 | -32602, mcp_tenant_busy, with Retry-After. A refusal. |
| Too many of the user's MCP requests are running at once | 429 | -32602, mcp_user_busy, with Retry-After. A refusal. |
| A method of a capability the server does not offer | 404 | -32601. A refusal. |
A tools/call naming no tool the server offers, including a native tool it does not list | 200 | -32602, tool_not_declared. FAILED. |
A resources/read or skills/get of a URI the server does not serve | 200 | -32602, mcp_resource_not_found, with data.uri. FAILED. |
A resources/read or skills/get of a skill version the server no longer serves | 200 | -32602, mcp_skill_superseded, with data.uri. FAILED. |
A resources/read whose template's tool is no longer read-only, or can no longer be called | 200 | -32602, mcp_resource_unavailable, with data.uri. FAILED. |
A resources/read whose tool fails, or a skill load that fails | 200 | -32602 with the call's code and hint, and data.uri. FAILED, with the failed call. |
A prompts/get naming no prompt the server offers | 200 | -32602, mcp_prompt_not_found. FAILED. |
| A task request from a client without the Tasks extension | 400 | -32021 naming the Tasks extension. A refusal. |
| A task request naming a task the session did not create | 200 | -32602, mcp_task_not_found. FAILED. |
A tools/call to a callable agent, or an invoke of an agent, while the session has 32 unfinished tasks, or its user has 32 across the server's sessions | 200 | A tool error start_limit. SUCCEEDED. |
A tasks/cancel whose task kept changing while it was being cancelled | 200 | -32603, action_conflict, retryable: true. FAILED. |
A tools/call without an intent under REQUIRED, or with an invalid one | 200 | A tool error mcp_intent_required or mcp_intent_invalid. SUCCEEDED. |
| A call to a tool the server can no longer offer at its latest version | 200 | A tool error mcp_tool_unavailable, with a hint. SUCCEEDED. |
| A call error, including arguments the tool's schema rejects and a policy denial | 200 | A tool error with the call's code, message, and hint. SUCCEEDED. |
An invoke that cannot run | 200 | A tool error, as in refused invocations. SUCCEEDED, with the invoke call FAILED. |
The request passed the server's responseTimeout | 200 | -32603, mcp_request_deadline, retryable: true, with a hint. Rolled back; FAILED. |
| The principal's client was revoked, or its token expired, after the request was admitted | 200 | -32602, action_access_denied, with a hint. FAILED. |
The request's transaction did not commit, or did not finish committing within 10 seconds after its responseTimeout | 200 | -32603, mcp_transaction_error, retryable: true. Rolled back; FAILED. |
| Twisp could not run the admitted request, or it did not answer in time | 200 | -32603, mcp_execution_unavailable, retryable: true. Its changes were not committed; FAILED. |
| The request's outcome was recorded, but its answer was lost | 200 | -32603, mcp_reply_lost, when it succeeded: it took effect. When it failed, -32603 with the recorded failure's code and hint. |
| The client closed the request before its result | — | Rolled back; CANCELLED, with cancelledBy naming the principal. |
A definition with triggers, a tool or agent block, a config other than {}, no session, approval, preload, colliding tool names, a tool declaring intent while intent is on, an invalid skill name or a skill description that is empty or too long, a resource whose tool is not read-only or whose variables do not fit its input, or more than 128 tools or 64 skills | — | Error whose message names the rule; the write is rejected. |
The default server
The local environment installs an MCP server named default, at http://localhost:8080/mcp/v1/000000000000/default. It is made of ordinary actions, which you can change. It lists the native tools graphql, cel, program, invoke, and get_task and cancel_task, the tool audit, and the skills basics, design, verify, actions, agents, and mcp. Its policies allow every action on every resource, so each request is bounded by the principal's own policies. It runs LIVE, so its writes commit, requires stated intent, and gives each principal and subject a session.
audit reads the session's own record back: a tool with SELECT-only policies, whose reference fixes session_id to session.id and server to the server's name. The external agent passes:
| Argument | Meaning |
|---|---|
since | Only requests after this time. |
limit | The most recent requests to return, 1 to 1,000. Defaults to 100. |
include_discovery | Also list discovery requests, such as tools/list. Defaults to false. |
It answers with server, session_id, steps, the requests oldest first, and complete, false when there were more requests than it returned. Each step has at, did, the tool called and what it ran, such as a GraphQL operation's head or whether a program previewed or committed, the intent, status, the execution's status or TOOL_ERROR for a tool error, error when the request or its call failed, and execution_id. The last few requests can take a moment to appear.
The mcp handler
Serves the requests of agents running outside Twisp over the Model Context Protocol, each as one execution whose input is the request. Kind mcp requires an mcp block and a session, and its config is {}; it runs only when its endpoint serves a request. See MCP Servers.