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 fieldMeaning
nameThe server's name as MCP clients see it: 1 to 64 letters, digits, underscores, hyphens, or periods. Required.
instructionsGuidance for the external agent, returned by server/discover. At most 64 KiB.
sessionHow each request selects its session. Required.
modePREVIEW or LIVE. See preview mode. Omitted resolves to PREVIEW when the definition is written, and is stored.
intentREQUIRED, OPTIONAL, or NONE. See stated intent. Omitted is REQUIRED.
limitscallsPerMinute, the rate limit of each session: 300 when omitted, at most 6,000.
resourcesResource 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. tools are ordinary tool references, with a name, an optional version, and fixed input. A reference can name a tool, a callable agent, or a native tool: graphql and cel, and program, invoke, get_task, and cancel_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. approval cannot be required.
  • Fixed input. Expressions read execution.input, the request, and session.id and session.input. That is how you stamp who asked into a tool's input, as requested_by: "execution.input.subject" does above. event is not available. Fixed values replace anything the external agent supplies.
  • Skills. skills are skill references, at most 64, without preload, 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. Its description is required, and at most 1,024 characters.
  • Versions. A reference with a version always 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.responseTimeout bounds each request: 1 minute by default, and at most 10 minutes. A tool called beneath it is also bounded by its own responseTimeout.

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 aud claim 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 gets 401 with a WWW-Authenticate header 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 INVOKE on it. Without INVOKE, a request gets 403. The principal needs no INVOKE on the server's tools: the server's references authorize those calls.
  • Discovery. server/discover returns the supported protocol version, the capabilities the server offers, among them the Tasks extension, and its instructions. tools/list returns 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's inputSchema is its input schema without the fixed input's properties and without any x-mcp-header annotation: the server reads arguments only from the request body, so a host sends no Mcp-Param headers. Both can be cached for 60 seconds.
  • Methods. Every server answers server/discover, tools/list, and tools/call, and the Tasks extension's tasks/get, tasks/update, and tasks/cancel. A server with skills also offers resources, prompts, and the Skills extension (io.modelcontextprotocol/skills), and a server with resource templates offers resources; server/discover declares exactly what the server offers. A method of a capability the server does not offer, like any other method, gets 404 and -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:

FieldMeaning
methodThe MCP method, such as tools/list or tools/call.
nameThe tool a tools/call named, the URI a resources/read or skills/get named, or the prompt a prompts/get named.
argumentsThe call's arguments as the external agent sent them, without its stated intent.
intentThe call's stated intent.
principalThe Twisp principal the token resolved to.
subjectThe token's sub: the signed-in user.
oauthClientIdThe token's client_id: the MCP host application.
clientInfoThe client's name and version, as it reported them. They are not verified.
meta, metaOmittedThe request's _meta other than the protocol's own fields, such as trace context; not recorded over 16 KiB.
argumentsOmittedThe request was refused as too large to record, and its arguments were not recorded.
requestIdThe JSON-RPC request ID.
protocolVersion, tasksThe protocol revision the client requested, and whether it declared the Tasks extension.
taskIdThe task a tasks/get, tasks/update, or tasks/cancel named.

The execution's caller is the principal, and its outcome is:

  • SUCCEEDED when Twisp answered with a result, including a tool result with isError.
  • FAILED when 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 a tools/call in a closed session, which the external model sees as a tool error session_closed.
  • CANCELLED when 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:

Calltools/call result
Succeeded with a string output, and no output schemaOne text content block with the string.
Succeeded with a string output, and an output schemastructuredContent with the string, and one text content block with the string.
Succeeded with any other outputstructuredContent with the output, and one text content block with its canonical JSON.
FailedisError: 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/call naming 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", createdAt and lastUpdatedAt, ttlMs (24 hours), and pollIntervalMs (5 seconds). The task is the started execution: taskId is its ID, and its startedBy names 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 execution settings, 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/get answers with the task's status, and its lastUpdatedAt, when the execution last changed:

    ExecutionstatusAlso
    QUEUED, RUNNING, ACCEPTED, RETRY_SCHEDULEDworking
    SUCCEEDEDcompletedresult: the agent's output, rendered as a tools/call result is.
    FAILEDcompletedresult: a tool error with the execution's error code, the message The task's agent failed, and its hint.
    EXHAUSTEDfailedstatusMessage, and error: -32603, with the execution's error code in data.code and its hint in data.hint. Its retries ran out.
    CANCELLEDcancelled

    The 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 TOOL use of the poll's execution, even after the server stops referencing the agent. A task stays readable after its ttlMs.

  • Updating. Twisp never asks the client for input, so tasks/update has nothing to update, and is acknowledged.

  • Cancelling. tasks/cancel cancels the task, and is acknowledged, also for a task that has finished. A queued task is cancelled at once. A running agent stops at its next step, its writes roll back, and it becomes cancelled. The execution's cancelledBy names the principal and the tasks/cancel request. A cancellation is not a business write: on a server in preview mode it takes effect too, though the request is marked preview.

  • 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 -32602 with mcp_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 invoke runs 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_task and cancel_task, and answers with a tool result whose text names it: task_id, status: "working", poll_interval_ms, and a hint. On a server that does not list both, such a call is refused with JSON-RPC -32021, mcp_task_required, whose data.requiredCapabilities names the extension, and an invoke of 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 with get_task:

    ArgumentMeaning
    task_idThe task_id a call returned. Required.
    intentThe call's stated intent.

    get_task answers with task_id, status (working, completed, failed, or cancelled, as for tasks/get), poll_interval_ms while it is working, the result of a completed task, rendered as a tools/call result is but with the agent's output once, as structuredContent when it has one, and the error of a failed one. It reaches the same tasks tasks/get does, 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 a TOOL use. get_task is read-only.

    Cancel the task with the native tool cancel_task, with the same arguments, when the server lists it. It cancels the task as tasks/cancel does, in the same scope, and answers with its task_id and a hint, also for a task that has finished; a task it cannot reach is a tool error, mcp_task_not_found. The execution's cancelledBy names the principal and the tools/call request. It is never annotated read-only: a running agent's writes roll back.

    tasks/get, tasks/update, and tasks/cancel themselves need the extension: from a client that does not declare it they are refused with 400 and JSON-RPC -32021, whose data.requiredCapabilities names the extension and data.method the 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.

MethodResult
skills/listOne 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/getThe entry for one skill's SKILL.md URI.
resources/listEach 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.mdThe file as text/markdown, whose bytes match the entry's digest and size.
prompts/listEach skill as a prompt without arguments, named after the skill. Cached for 60 seconds.
prompts/getOne user message with the same file.
  • Loads. A resources/read of a SKILL.md, or a prompts/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's invocation.output records the file's uri, digest, and size, and the load's output is a SKILL use. A load from an MCP server makes none of the skill's tools available: the external agent can call only the server's own tools. Listings load nothing, and record in invocation.skills the version of each skill they offered.
  • Versions. When a skill gets a new version, a reference without a version serves it at a new URI. A skills/get or resources/read of the previous URI gets -32602 with mcp_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 description is 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/list returns the templates, each with its uriTemplate, name, and description, and a mimeType when the tool's output schema fixes one. It can be cached for 60 seconds. resources/list lists no resources besides skills' files.
  • Reading. resources/read matches 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 a TOOL use. The result is one item for the URI: application/json with the output's canonical JSON, or text/plain for 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 of resources/templates/list and their reads get -32602 with mcp_resource_unavailable.
  • Errors. A URI no template matches gets -32602 with mcp_resource_not_found and the URI in data.uri. A read whose tool fails gets -32602 with the call's code and hint and 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: true when the server's policies, or the tool's own, allow only SELECT: every ALLOW policy names only SELECT actions. Any other action counts as a write, including INVOKE, RESPOND, and a wildcard such as * or actions:*. For a callable agent, its policies are the tool's. cel is always read-only, and graphql has only the server's policies.
  • destructiveHint: false when the server's or the tool's policies allow only SELECT and INSERT. Otherwise it is omitted.
  • openWorldHint: true for 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. program is open world when the server lists an open-world tool. Every other tool is openWorldHint: 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.

ArgumentMeaning
srcThe program, at most 64 KiB. Required.
inputAn object, passed to the program as event["data"].
committrue commits the program's writes. false previews them. Required.
intentThe 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 its operation_name and the variables it requires, and each tools.<name> against the server's tools. A program that fails is refused with action_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 a check argument other than True, 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 with check=False, rolls back every write and returns a tool error with its code, message, and hint, 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 accounts by NAME, 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 program tool, 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 carry rolledBackByExecutionId.
  • Previews. With commit: false, the program runs every call and commits none. Its result is {value, writes, unreadable_writes, omitted_writes}: writes lists each row it would write that the principal may read, through its own policies and the server's, with the row's table, key, and new value; rows it may not read, and deleted rows, are counted in unreadable_writes, not shown, so a preview cannot reveal a balance the principal cannot read. Rows beyond the result's size limit are counted in omitted_writes. The preview's execution is recorded with preview set and every row it would write in its writeSet, so an investigator sees the preview and the commit that followed it. Reading the execution shows the same limit: its writeSet, and each call's, lists only the rows the reader may read, and counts the rest in writeSetUnreadable. A committed program's result is {value}.
  • Preview mode. In preview mode a program never commits: with commit: true it runs as it would live, its writes are rolled back and recorded in its writeSet, and its result carries the preview marker.
  • Annotations. program is readOnlyHint: true only when the server's own policies allow only SELECT, and destructiveHint: false only when they allow only SELECT and INSERT: a program can call every tool the server lists.
  • Limits. At most 64 tool calls, 64 graphql and 64 cel calls, and 1,000,000 execution steps, within the server's responseTimeout. The program's call and each call it makes count against the session's rate limit. A program cannot call program, invoke, get_task, or a callable agent. A program that passes the deadline fails the request with mcp_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.

ArgumentMeaning
action_idThe action to invoke. It must exist and not be deleted. A preview may run one that is disabled, or not yet published. Required.
versionA stored version of the action. With neither version nor definition, the current version runs.
definitionAn 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.
inputThe input to call the action with, an object. Required.
commitfalse, the default, previews. true runs a published, enabled version, and commits what it writes.
intentThe call's stated intent, as for every tool.
  • What runs. An action of kind tool, graphql, or cel runs in the request. An agent with a tool block answers with a task, in both modes. A skill, an MCP server, a native tool, an agent without a tool block, and a tool with a session cannot be invoked.
  • A tool the server lists. invoke never 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. Another version is refused with action_input_invalid.
  • The loop. An action created with enabled: false is never triggered or called, but can be previewed: create a new tool disabled, preview it, preview drafts of it until it works, then updateAction with the draft that worked and enabled: true, and invoke it with commit: true where a real run is safe. Preview a change to a live action as a draft, before updateAction publishes it to every caller.
  • Authority. What invoke runs 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 and INVOKE it, and, for a draft, to INVOKE every action the draft references and every tool its skills make available. So a server's policies need SELECT and INVOKE on 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 and INVOKE the action as the draft would store it, as well as the stored action, so INVOKE assertions on definition apply 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 graphql and cel tools only when the server lists them. A draft of a native tool's kind, or whose tools, or whose skills' tools, name a native tool the server does not list, is refused with action_input_invalid: "the definition uses the native tool graphql, which this server does not offer". A Starlark draft can call graphql() and cel() without declaring them; through a server that does not list the one it calls, that call fails with tool_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. invoke can run any tool or agent the principal and the server may INVOKE, 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. List invoke only on a server agents build with.
  • Nothing a preview commits. A preview's writes are rolled back, on a LIVE server as on a PREVIEW one. Work that would follow a commit is dropped: a createAction inside 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 a PREVIEW server, a commit commits nothing either, as no call there does.
  • Annotations. invoke allows everything, so its annotations are the server's: it is read-only only when the server's policies allow only SELECT.

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. writes lists each row the preview would have written that the principal may read, through its own policies and the server's, with the row's table, key, and new value, as a program's preview does. Rows it may not read, and deleted rows, are counted in unreadable_writes; rows beyond the result's size limit are counted in omitted_writes.

  • Calls. calls lists the tool's direct calls in order, each with its name, status, and execution_id, its error when it failed, and rolled_back: true when the tool's failure discarded its writes. The whole call tree is readable by execution_id.

  • A commit. With commit: true, the result has execution_id, status, and output or error, and no calls or writes: 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", no output, error with its code, message, and hint, and for a preview its calls and empty writes. 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 with mcp_request_deadline. The invoke call 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 with execution(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 invoke runs counts against the session's and the user's 32 unfinished tasks; one beyond them is start_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.

codeWhen
action_not_callableThe 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_invalidArguments 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_deniedThe principal or the server may not INVOKE the action, or an action the draft references.
start_limitAn agent beyond the session's or the user's 32 unfinished tasks.
action_failureThe 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 invoke call is an execution of the native invoke tool, 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, FAILED with 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, draft is the unpublished definition it ran, with its references resolved, and actionVersion the action's version when it ran. For a stored version, draft is null, and actionVersion is the version that ran.
  • Lineage. A tool is a call of the invoke call, and its calls are its own, so lineage reads: the request, the invoke call, the tool, its calls. An agent is started by the request, as a task is: its startedBy names the request, and the invoke call's output names the task. Lineage reads the request, then the invoke call, and, through a START edge 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, and preview: false leaves them out of an action's history. action(id:) { executions(where: { preview: false }) } takes the same filter.
  • Uses. As for any call, a successful invoke call's output is a TOOL use 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: rerunExecution and replayExecution of 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.

Groupingstart.input
Per userprincipal: "execution.input.principal", subject: "execution.input.subject"
Per user of each hostthe above, and host: "execution.input.oauth_client_id"
Per principalprincipal: "execution.input.principal"
Per host-supplied conversationconversation: "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 input under the id of an existing session, such as after adding a field to start.input that id does not include, that session's requests are refused with 400 and mcp_session_invalid until you close it. Include every field of start.input in id.

  • History. Each request adds one REQUEST input to its session when it is admitted, with the execution in executionId. The input is CLAIMED while the request runs, and CONSUMED once its outcome is recorded, with the number of tool calls it made, including those made beneath them, in calls. The session's inputs are the ordered request log, and executions(where: {actionId, sessionId}) lists the requests' executions. ActionExecution.sessionId names a request's session.

  • Closing. closeSession stops one user, principal, host, or conversation. Its new requests fail with session_closed: a tool error for tools/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 _meta field its grouping reads, is refused with 400.

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 codeHTTPWhen
action_access_denied403The principal has no client in the account, or lacks INVOKE on the server.
mcp_method_not_found404The server does not serve the request's method; the answer is -32601.
mcp_task_required400A 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_large400The request is larger than a request's record can hold.
mcp_session_invalid400The request does not produce a valid session input, or its session's input differs.
session_closed200The request's session is closed.
mcp_rate_limited429The session is over its rate limit.
mcp_tenant_busy429Too many of the account's MCP requests are running at once, across all its servers and sessions; with Retry-After.
mcp_user_busy429Too 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_error200The 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.

SituationHTTPResult
The name is not an MCP server the principal can read, or the server is disabled404-32602, mcp_server_not_found. Not recorded.
The principal lacks INVOKE on the server403-32602, action_access_denied. A refusal.
The request's session is closed200A 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 input400-32602, mcp_session_invalid. A refusal.
The session is over its rate limit429-32602, mcp_rate_limited, with Retry-After. A refusal.
Too many of the account's MCP requests are running at once429-32602, mcp_tenant_busy, with Retry-After. A refusal.
Too many of the user's MCP requests are running at once429-32602, mcp_user_busy, with Retry-After. A refusal.
A method of a capability the server does not offer404-32601. A refusal.
A tools/call naming no tool the server offers, including a native tool it does not list200-32602, tool_not_declared. FAILED.
A resources/read or skills/get of a URI the server does not serve200-32602, mcp_resource_not_found, with data.uri. FAILED.
A resources/read or skills/get of a skill version the server no longer serves200-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 called200-32602, mcp_resource_unavailable, with data.uri. FAILED.
A resources/read whose tool fails, or a skill load that fails200-32602 with the call's code and hint, and data.uri. FAILED, with the failed call.
A prompts/get naming no prompt the server offers200-32602, mcp_prompt_not_found. FAILED.
A task request from a client without the Tasks extension400-32021 naming the Tasks extension. A refusal.
A task request naming a task the session did not create200-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 sessions200A tool error start_limit. SUCCEEDED.
A tasks/cancel whose task kept changing while it was being cancelled200-32603, action_conflict, retryable: true. FAILED.
A tools/call without an intent under REQUIRED, or with an invalid one200A tool error mcp_intent_required or mcp_intent_invalid. SUCCEEDED.
A call to a tool the server can no longer offer at its latest version200A tool error mcp_tool_unavailable, with a hint. SUCCEEDED.
A call error, including arguments the tool's schema rejects and a policy denial200A tool error with the call's code, message, and hint. SUCCEEDED.
An invoke that cannot run200A tool error, as in refused invocations. SUCCEEDED, with the invoke call FAILED.
The request passed the server's responseTimeout200-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 admitted200-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 responseTimeout200-32603, mcp_transaction_error, retryable: true. Rolled back; FAILED.
Twisp could not run the admitted request, or it did not answer in time200-32603, mcp_execution_unavailable, retryable: true. Its changes were not committed; FAILED.
The request's outcome was recorded, but its answer was lost200-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:

ArgumentMeaning
sinceOnly requests after this time.
limitThe most recent requests to return, 1 to 1,000. Defaults to 100.
include_discoveryAlso 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.

Previous
Executions