# Building an MCP Server

Give a support team a narrow MCP server with one read-only tool and one tool that writes, connect to it, watch it run in preview mode, promote it to LIVE, and read back every request with its stated intent.

This tutorial builds an [MCP server](https://www.twisp.com/docs/actions/mcp-servers.md) for a support team. The team's agents get exactly two tools: `get_balance`, which reads a customer's balance, and `issue_credit`, which posts a goodwill credit of at most 25.00 USD through a tran code. You define both tools, define the server that offers them, connect to it, and call it. The server starts in preview mode, so the first credit commits nothing but records what it would have written. Then you promote the server to `LIVE`, credit the account for real, run an atomic program, and read back every request with the reason the agent gave for it.

> **Note:**
>
> You need a running [local environment](https://www.twisp.com/docs/infrastructure/local-environment.md). Run each GraphQL step as its own request in GraphiQL in the local console, and each MCP step with `curl` in a terminal. The local environment needs no sign-in; in the cloud, hosts sign in as described in [Connecting MCP Hosts](https://www.twisp.com/docs/guides/mcp-hosts.md).

The UUIDs below identify the accounts, the tran code, the two tools, the server, and the transactions. If you run this tutorial again in the same database, choose fresh UUIDs, and fresh account codes, tran code, and action names.

## 1. Set up the ledger

The support team credits customers from a `SUPPORT-CREDITS` expense account. Create it, a customer account, and the `SUPPORT_CREDIT` tran code the write tool posts through. The tran code fixes what a credit may write: one debit to the expense account, one credit to the customer, and the requester in the transaction's metadata.

```graphql
mutation SupportLedger {
  customer: createAccount(
    input: {
      accountId: "bf93d858-768a-45f9-a186-deb70d171002"
      code: "CUSTOMER-1042"
      name: "Ada Lovelace"
      normalBalanceType: CREDIT
    }
  ) {
    accountId
  }

  credits: createAccount(
    input: {
      accountId: "ed3e1f52-9e3d-4e25-85b2-6c881c25cc8c"
      code: "SUPPORT-CREDITS"
      name: "Support credits"
      normalBalanceType: DEBIT
    }
  ) {
    accountId
  }

  createTranCode(
    input: {
      tranCodeId: "a78563ee-c3e9-4797-b797-4016186fe465"
      code: "SUPPORT_CREDIT"
      description: "A goodwill credit from the support team."
      params: [
        { name: "account", type: UUID }
        { name: "amount", type: DECIMAL }
        { name: "reason", type: STRING }
        { name: "requested_by", type: STRING }
      ]
      transaction: {
        description: "'Support credit: ' + params.reason"
        metadata: "{'requested_by': params.requested_by}"
      }
      entries: [
        {
          accountId: "uuid('ed3e1f52-9e3d-4e25-85b2-6c881c25cc8c')"
          units: "params.amount"
          currency: "'USD'"
          entryType: "'SUPPORT_CREDIT_DR'"
          direction: "DEBIT"
          layer: "SETTLED"
        }
        {
          accountId: "params.account"
          units: "params.amount"
          currency: "'USD'"
          entryType: "'SUPPORT_CREDIT_CR'"
          direction: "CREDIT"
          layer: "SETTLED"
        }
      ]
    }
  ) {
    tranCodeId
  }
}
```

The response returns the three IDs.

## 2. Create the read-only tool

A [tool](https://www.twisp.com/docs/reference/actions/tools.md) is an action of kind `tool` with a Starlark program, an input schema, and its own `policies`. This one allows only `SELECT`, so it can read and never write, whoever calls it.

```graphql
mutation CreateGetBalance {
  actions {
    createAction(
      id: "9e04b8b4-4cf4-41e3-89c5-fb81aeca4176"
      input: {
        name: "support-get-balance"
        description: "Read one customer account's USD balance."
        kind: "tool"
        config: {
          runtime: "starlark"
          src: """
          QUERY = '''
          query Balance($id: UUID!) {
            account(id: $id) {
              code
              name
              balance(currency: "USD") { settled { normalBalance { units } } }
            }
          }
          '''

          def run(event):
              account_id = event["data"]["account_id"]
              data = graphql(QUERY, {"id": account_id})
              account = data["account"]
              if account == None:
                  fail("account not found", hint="Check the account ID.")
              balance = account["balance"]
              units = balance["settled"]["normalBalance"]["units"] if balance else "0.00"
              return {
                  "account_id": account_id,
                  "code": account["code"],
                  "name": account["name"],
                  "balance": units,
              }
          """
        }
        tool: {
          name: "get_balance"
          description: "Look up a customer account's settled USD balance."
          inputSchema: {
            type: "object"
            properties: { account_id: { type: "string", format: "uuid" } }
            required: ["account_id"]
            additionalProperties: false
          }
        }
        policies: [{ effect: ALLOW, actions: [SELECT], resources: ["financial.*"] }]
      }
    ) {
      actionId
      version
      status
    }
  }
}
```

The response reports `status: UNPUBLISHED`; the tool publishes within a moment. `name` is the tool's registry name, which the server references, and `tool.name` is the name the external agent sees.

## 3. Create the tool that writes

`issue_credit` posts through the `SUPPORT_CREDIT` tran code. Its program refuses amounts over 25.00 with a hint the agent can act on, and its input takes a `requested_by` that the server will fill in, so the agent cannot choose it. Its policies allow what a posting needs: inserting the transaction and its entries, and updating the accounts' balances.

```graphql
mutation CreateIssueCredit {
  actions {
    createAction(
      id: "6f13577f-b3be-4506-a3f3-4ea32b6bf39b"
      input: {
        name: "support-issue-credit"
        description: "Post a goodwill credit of up to 25.00 USD to a customer account."
        kind: "tool"
        config: {
          runtime: "starlark"
          src: """
          POST = '''
          mutation Credit($id: UUID!, $params: JSON!) {
            postTransaction(input: { transactionId: $id, tranCode: "SUPPORT_CREDIT", params: $params }) {
              transactionId
            }
          }
          '''

          def run(event):
              data = event["data"]
              amount = decimal(data["amount"])
              if amount <= decimal("0") or amount > decimal("25"):
                  fail("amount must be more than 0 and at most 25.00 USD",
                       hint="Escalate larger credits to a supervisor.")
              graphql(POST, {
                  "id": data["transaction_id"],
                  "params": {
                      "account": data["account_id"],
                      "amount": str(amount),
                      "reason": data["reason"],
                      "requested_by": data["requested_by"],
                  },
              })
              return {"transaction_id": data["transaction_id"], "amount": str(amount)}
          """
        }
        tool: {
          name: "issue_credit"
          description: "Credit a customer account with up to 25.00 USD as a goodwill gesture. Choose a new transaction_id for each credit; a transaction_id that was already posted is refused."
          inputSchema: {
            type: "object"
            properties: {
              transaction_id: { type: "string", format: "uuid" }
              account_id: { type: "string", format: "uuid" }
              amount: { type: "string", pattern: "^[0-9]+(\\.[0-9]{1,2})?$" }
              reason: { type: "string", minLength: 1, maxLength: 200 }
              requested_by: { type: "string" }
            }
            required: ["transaction_id", "account_id", "amount", "reason", "requested_by"]
            additionalProperties: false
          }
        }
        policies: [
          { effect: ALLOW, actions: [SELECT, INSERT], resources: ["financial.*"] }
          {
            effect: ALLOW
            actions: [UPDATE]
            resources: ["financial.Account.*", "financial.Balance.*"]
          }
        ]
      }
    ) {
      actionId
      version
      status
    }
  }
}
```

## 4. Create the server

The server is an action of kind `mcp`. Its `tools` list is everything an agent connected to it can call: the two tools, and the native [`program`](https://www.twisp.com/docs/reference/actions/mcp.md#programs) tool, which runs a Starlark program over them in one transaction. The reference to `support-issue-credit` fixes `requested_by` to the signed-in user, so the agent never sees that argument and cannot set it.

```graphql
mutation CreateSupportDesk {
  actions {
    createAction(
      id: "00d63755-e96c-41a5-97df-3f650c943215"
      input: {
        name: "support-desk"
        description: "Balance lookups and small goodwill credits for the support team."
        kind: "mcp"
        config: {}
        mcp: {
          name: "support-desk"
          instructions: "Tools for the support team. Look up a customer's balance before issuing a credit. Credits are limited to 25.00 USD; escalate anything larger."
          session: {
            start: {
              input: {
                principal: "execution.input.principal"
                subject: "execution.input.subject"
                run: "execution.input.meta[?\"com.example/run\"].orValue(\"\")"
              }
            }
          }
        }
        session: {
          inputSchema: {
            type: "object"
            properties: {
              principal: { type: "string", minLength: 1 }
              subject: { type: "string" }
              run: { type: "string" }
            }
            required: ["principal", "subject", "run"]
            additionalProperties: false
          }
          id: "string(size(session.input.principal)) + ':' + session.input.principal + ':' + string(size(session.input.subject)) + ':' + session.input.subject + ':' + session.input.run"
        }
        tools: [
          { name: "support-get-balance" }
          { name: "support-issue-credit", input: { requested_by: "execution.input.subject" } }
          { name: "program" }
        ]
        policies: [{ effect: ALLOW, actions: [SELECT, INSERT, UPDATE], resources: ["financial.*"] }]
      }
    ) {
      actionId
      version
      status
    }
  }
}
```

The pieces:

| Field                    | What it does here                                                                                                                                                                                                                                                                                                                             |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mcp.name`               | The name hosts see. The endpoint uses the action's registry `name`, also `support-desk`.                                                                                                                                                                                                                                                      |
| `mcp.instructions`       | Guidance returned to the agent when it connects.                                                                                                                                                                                                                                                                                              |
| `mcp.session`, `session` | One [session](https://www.twisp.com/docs/reference/actions/mcp.md#sessions) per user and run. A caller that sends a run ID in the request's `_meta` under `com.example/run` gets a session for that run; a host that sends none, such as Claude Code, gets one session per user. Each field is length-prefixed in `id`, so two different inputs can never share a session. |
| `policies`               | The server's ceiling. Every call must be allowed by the caller, then by these, then by the tool's own. Nothing through this server can delete, or reach anything outside the ledger.                                                                                                                                                          |
| `mcp.mode`, `mcp.intent` | Omitted, so the server starts in `PREVIEW` and every call requires an `intent`.                                                                                                                                                                                                                                                               |

Poll until the server is published, and read the defaults it got:

```graphql
query ReadSupportDesk {
  actions {
    action(id: "00d63755-e96c-41a5-97df-3f650c943215") {
      version
      status
      pending
      definition {
        mcp {
          mode
          intent
          limits {
            callsPerMinute
          }
        }
      }
    }
  }
}
```

```json
{
  "version": 2,
  "status": "ACTIVE",
  "pending": null,
  "definition": {
    "mcp": { "mode": "PREVIEW", "intent": "REQUIRED", "limits": { "callsPerMinute": 300 } }
  }
}
```

## 5. Connect

The server's endpoint in the local environment is:

```
http://localhost:8080/mcp/v1/000000000000/support-desk
```

To use it from Claude Code, add it by URL:

```shell
claude mcp add --transport http support-desk http://localhost:8080/mcp/v1/000000000000/support-desk
```

The rest of this tutorial calls the server with plain JSON-RPC over HTTP, so you see exactly what a host sends and gets. Start with the handshake an MCP host makes, `initialize`. It belongs to protocol revision `2025-11-25`, which the server also speaks; in the current revision, `2026-07-28`, each request carries its own protocol version and client information instead (see [the endpoint](https://www.twisp.com/docs/reference/actions/mcp.md#the-endpoint)).

```shell
curl -si http://localhost:8080/mcp/v1/000000000000/support-desk \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"curl","version":"8"}}}'
```

```
HTTP/1.1 200 OK
Content-Type: application/json
Mcp-Session-Id: twisp1.eyJ2IjoiMjAyNS0xMS0yNSIsImMiOnt9LCJpIjp7Im5hbWUiOiJjdXJsIiwidmVyc2lvbiI6IjgifX0
...

{"jsonrpc":"2.0","id":1,"result":{"_meta":{"com.twisp/preview":true,"io.modelcontextprotocol/serverInfo":{"name":"support-desk","version":"2"}},"capabilities":{"tools":{}},"instructions":"Tools for the support team. ...","protocolVersion":"2025-11-25","serverInfo":{"name":"support-desk","version":"2"}}}
```

The result carries the server's `instructions`, and `"com.twisp/preview": true`: the server is in preview mode. The `Mcp-Session-Id` header is the protocol's own: it carries the client information you sent, so later requests are recorded with it, and it is the same for the same client information. It is not the Twisp session, which the server's `session.id` names.

Define a small shell function that sends each later request with that header and the protocol version, and tags it with a run ID, `ticket-5521`, in `_meta`:

```shell
MCP_SESSION='twisp1.eyJ2IjoiMjAyNS0xMS0yNSIsImMiOnt9LCJpIjp7Im5hbWUiOiJjdXJsIiwidmVyc2lvbiI6IjgifX0'

mcp() {
  curl -s http://localhost:8080/mcp/v1/000000000000/support-desk \
    -H 'content-type: application/json' \
    -H 'accept: application/json, text/event-stream' \
    -H 'MCP-Protocol-Version: 2025-11-25' \
    -H "Mcp-Session-Id: $MCP_SESSION" \
    -d "$1"
}
```

Use the `Mcp-Session-Id` value you got.

## 6. List the tools

```shell
mcp '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{"_meta":{"com.example/run":"ticket-5521"}}}'
```

The result lists the three tools, trimmed here:

```json
{
  "tools": [
    {
      "name": "get_balance",
      "annotations": { "readOnlyHint": true, "destructiveHint": false, "openWorldHint": false },
      "inputSchema": {
        "properties": {
          "account_id": { "format": "uuid", "type": "string" },
          "intent": { "description": "Why you are making this call, in one sentence. It is kept in the audit record and is not passed to the tool.", "minLength": 1, "maxLength": 1024, "type": "string" }
        },
        "required": ["account_id", "intent"],
        ...
      }
    },
    {
      "name": "issue_credit",
      "annotations": { "openWorldHint": false },
      "inputSchema": {
        "properties": { "account_id": ..., "amount": ..., "intent": ..., "reason": ..., "transaction_id": ... },
        "required": ["transaction_id", "account_id", "amount", "reason", "intent"],
        ...
      }
    },
    { "name": "program", "annotations": { "openWorldHint": false }, ... }
  ]
}
```

Look for three things:

- **`readOnlyHint: true` on `get_balance` only.** [Annotations](https://www.twisp.com/docs/reference/actions/mcp.md#annotations) are derived from policies, not declared: `get_balance` allows only `SELECT`, so hosts may call it without asking a person. `issue_credit` and `program` can write, so hosts ask first.
- **`intent` on every tool.** The server added it, because `intent` defaults to `REQUIRED`.
- **No `requested_by`.** The server's fixed input supplies it, so the agent never sees it.

## 7. Call the read-only tool

```shell
mcp '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_balance","arguments":{"account_id":"bf93d858-768a-45f9-a186-deb70d171002","intent":"The customer on ticket 5521 asked why their balance looks low."},"_meta":{"com.example/run":"ticket-5521"}}}'
```

```json
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "_meta": {
      "com.twisp/preview": true,
      "io.modelcontextprotocol/serverInfo": { "name": "support-desk", "version": "2" }
    },
    "content": [{ "type": "text", "text": "{\"account_id\":\"bf93d858-…\",\"balance\":\"0.00\",\"code\":\"CUSTOMER-1042\",\"name\":\"Ada Lovelace\"}" }],
    "structuredContent": {
      "account_id": "bf93d858-768a-45f9-a186-deb70d171002",
      "balance": "0.00",
      "code": "CUSTOMER-1042",
      "name": "Ada Lovelace"
    },
    ...
  }
}
```

Reads are real in preview mode: the agent gets your actual data.

## 8. Issue a credit in preview mode

```shell
mcp '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"issue_credit","arguments":{"transaction_id":"928ca2b5-2a33-42f3-a1ee-efb92f70c88a","account_id":"bf93d858-768a-45f9-a186-deb70d171002","amount":"10.00","reason":"Late card replacement","intent":"Apologize for the late replacement card on ticket 5521 with a 10.00 credit."},"_meta":{"com.example/run":"ticket-5521"}}}'
```

```json
{
  "_meta": { "com.twisp/preview": true, ... },
  "structuredContent": { "amount": "10.00", "transaction_id": "928ca2b5-2a33-42f3-a1ee-efb92f70c88a" },
  ...
}
```

The call ran exactly as it would live and succeeded, and nothing it wrote committed. Call `get_balance` again, and the balance is still `0.00`:

```shell
mcp '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"get_balance","arguments":{"account_id":"bf93d858-768a-45f9-a186-deb70d171002","intent":"Check whether the credit changed the balance."},"_meta":{"com.example/run":"ticket-5521"}}}'
```

```json
{ "structuredContent": { "balance": "0.00", ... }, ... }
```

## 9. Review what the preview would have written

Each request is an [execution](https://www.twisp.com/docs/reference/actions/executions.md) of the server, and its tool call is a child execution. In preview mode, each call records the rows it would have written in its `writeSet`. Read the server's two latest requests, newest first:

```graphql
query LatestRequests {
  actions {
    executions(where: { actionId: "00d63755-e96c-41a5-97df-3f650c943215" }, first: 2) {
      nodes {
        executionId
        preview
        mcpRequest {
          name
          intent
        }
        children(first: 1) {
          nodes {
            preview
            event {
              data
            }
            writeSet {
              recordType
              recordVersion
              data
            }
          }
        }
      }
    }
  }
}
```

The second node is the `issue_credit` request. Its call's `event.data` is the input the tool received, with `requested_by` filled in by the server, and its write set holds the transaction, two balances, and two entries, trimmed here:

```json
{
  "executionId": "…",
  "preview": true,
  "mcpRequest": {
    "name": "issue_credit",
    "intent": "Apologize for the late replacement card on ticket 5521 with a 10.00 credit."
  },
  "children": {
    "nodes": [
      {
        "preview": true,
        "event": {
          "data": {
            "account_id": "bf93d858-768a-45f9-a186-deb70d171002",
            "amount": "10.00",
            "reason": "Late card replacement",
            "requested_by": "",
            "transaction_id": "928ca2b5-2a33-42f3-a1ee-efb92f70c88a"
          }
        },
        "writeSet": [
          {
            "recordType": "Transaction",
            "recordVersion": 1,
            "data": {
              "description": "Support credit: Late card replacement",
              "metadata": { "requested_by": "" },
              "tran_code_id": "a78563ee-c3e9-4797-b797-4016186fe465",
              "transaction_id": "928ca2b5-2a33-42f3-a1ee-efb92f70c88a",
              ...
            }
          },
          {
            "recordType": "Balance",
            "recordVersion": 1,
            "data": {
              "account_id": "bf93d858-768a-45f9-a186-deb70d171002",
              "settled": { "cr_balance": "10.00 USD", "dr_balance": "0 USD", ... },
              ...
            }
          },
          { "recordType": "Balance", ... },
          { "recordType": "Entry", ... },
          { "recordType": "Entry", ... }
        ]
      }
    ]
  }
}
```

`requested_by` is empty because the local environment has no sign-in, so a request has no `subject`. In the cloud it is the signed-in user, and the agent still cannot change it.

## 10. Promote the server to LIVE

You have seen what the agent's credit would write. Promote the server with `updateAction`. A supplied `mcp` block replaces the whole block, so send all of it, with `mode: LIVE`:

```graphql
mutation PromoteSupportDesk {
  actions {
    updateAction(
      id: "00d63755-e96c-41a5-97df-3f650c943215"
      input: {
        mcp: {
          name: "support-desk"
          instructions: "Tools for the support team. Look up a customer's balance before issuing a credit. Credits are limited to 25.00 USD; escalate anything larger."
          session: {
            start: {
              input: {
                principal: "execution.input.principal"
                subject: "execution.input.subject"
                run: "execution.input.meta[?\"com.example/run\"].orValue(\"\")"
              }
            }
          }
          mode: LIVE
        }
      }
    ) {
      version
      status
      pending
    }
  }
}
```

Run `ReadSupportDesk` from step 4 until `mode` is `LIVE` and `pending` is `null`.

## 11. Issue the credit for real

Send the same call as in step 8. The preview committed nothing, so its `transaction_id` is still unused:

```shell
mcp '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"issue_credit","arguments":{"transaction_id":"928ca2b5-2a33-42f3-a1ee-efb92f70c88a","account_id":"bf93d858-768a-45f9-a186-deb70d171002","amount":"10.00","reason":"Late card replacement","intent":"Apologize for the late replacement card on ticket 5521 with a 10.00 credit."},"_meta":{"com.example/run":"ticket-5521"}}}'
```

```json
{
  "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "support-desk", "version": "4" } },
  "structuredContent": { "amount": "10.00", "transaction_id": "928ca2b5-2a33-42f3-a1ee-efb92f70c88a" },
  ...
}
```

The preview marker is gone. Read the balance:

```shell
mcp '{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"get_balance","arguments":{"account_id":"bf93d858-768a-45f9-a186-deb70d171002","intent":"Confirm the credit reached the account."},"_meta":{"com.example/run":"ticket-5521"}}}'
```

```json
{ "structuredContent": { "balance": "10.00", ... }, ... }
```

Now ask for more than the tool allows:

```shell
mcp '{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"issue_credit","arguments":{"transaction_id":"03faa127-625a-4297-ae17-0af8a9ae7ec4","account_id":"bf93d858-768a-45f9-a186-deb70d171002","amount":"40.00","reason":"Late card replacement","intent":"The customer asked for 40.00 instead."},"_meta":{"com.example/run":"ticket-5521"}}}'
```

```json
{
  "content": [
    { "type": "text", "text": "action_failure: amount must be more than 0 and at most 25.00 USD" },
    { "type": "text", "text": "Escalate larger credits to a supervisor." }
  ],
  "isError": true,
  ...
}
```

The refusal is a tool result with `isError`, so the agent reads the hint and can act on it, and nothing was posted.

## 12. Run an atomic program

With `program`, the agent sends one Starlark program over the server's tools, and every call it makes commits together or not at all. This one reads the balance, credits 5.00 only if the balance is under 20.00, and reads the balance again. Save it as `courtesy.star`:

```python
def run(event):
    d = event["data"]
    before = tools.get_balance({"account_id": d["account_id"]})["balance"]
    if decimal(before) >= decimal("20"):
        return {"credited": False, "balance": before}
    tools.issue_credit({
        "transaction_id": d["transaction_id"],
        "account_id": d["account_id"],
        "amount": "5.00",
        "reason": d["reason"],
    })
    after = tools.get_balance({"account_id": d["account_id"]})["balance"]
    return {"credited": True, "before": before, "after": after}
```

Send it with `commit: false` to preview it. `jq` builds the request so the program's source is quoted correctly:

```shell
mcp "$(jq -n --rawfile src courtesy.star '{jsonrpc: "2.0", id: 9, method: "tools/call", params: {
  name: "program",
  arguments: {
    src: $src,
    input: {account_id: "bf93d858-768a-45f9-a186-deb70d171002", transaction_id: "49c01919-8559-484c-859f-3e3fd8e3926c", reason: "Fee courtesy"},
    commit: false,
    intent: "Preview a 5.00 courtesy credit for ticket 5521 if the balance is under 20.00."
  },
  _meta: {"com.example/run": "ticket-5521"}}}')"
```

```json
{
  "structuredContent": {
    "value": { "after": "15.00", "before": "10.00", "credited": true },
    "writes": [
      {
        "table": "Transaction",
        "key": "…",
        "value": { "description": "Support credit: Fee courtesy", "transaction_id": "49c01919-8559-484c-859f-3e3fd8e3926c", ... }
      },
      { "table": "Balance", ... },
      { "table": "Balance", ... },
      { "table": "Entry", ... },
      { "table": "Entry", ... }
    ],
    "unreadable_writes": 0,
    "omitted_writes": 0
  },
  ...
}
```

The second `get_balance` saw the first call's credit, and `writes` lists every row the program would write. Nothing committed. Send the same request with `"commit": true` and the credit posts, and the result is `{"value": {...}}`. The `issue_credit` call inside the program gets the same fixed `requested_by` as a direct call.

## 13. Read the session's requests

Every request was recorded with the reason the agent gave. List the server's requests in the `ticket-5521` run's session. Its ID is built by `session.id`: here, the local principal's length and value, an empty subject, and the run ID. Find it on any request as `sessionId`, then filter on it:

```graphql
query TicketRequests {
  actions {
    executions(
      where: {
        actionId: "00d63755-e96c-41a5-97df-3f650c943215"
        sessionId: "36:arn:aws:iam::000000000000:user/twisp:0::ticket-5521"
      }
      first: 20
    ) {
      nodes {
        created
        status
        preview
        mcpRequest {
          method
          name
          intent
          principal
          clientInfo {
            name
            version
          }
        }
      }
    }
  }
}
```

Newest first, trimmed:

```json
{
  "nodes": [
    {
      "status": "SUCCEEDED",
      "preview": false,
      "mcpRequest": {
        "method": "tools/call",
        "name": "program",
        "intent": "Preview a 5.00 courtesy credit for ticket 5521 if the balance is under 20.00.",
        ...
      }
    },
    {
      "status": "SUCCEEDED",
      "preview": false,
      "mcpRequest": { "method": "tools/call", "name": "issue_credit", "intent": "The customer asked for 40.00 instead.", ... }
    },
    {
      "status": "SUCCEEDED",
      "preview": false,
      "mcpRequest": { "method": "tools/call", "name": "get_balance", "intent": "Confirm the credit reached the account.", ... }
    },
    {
      "status": "SUCCEEDED",
      "preview": false,
      "mcpRequest": {
        "method": "tools/call",
        "name": "issue_credit",
        "intent": "Apologize for the late replacement card on ticket 5521 with a 10.00 credit.",
        "principal": "arn:aws:iam::000000000000:user/twisp",
        "clientInfo": { "name": "curl", "version": "8" }
      }
    },
    ...
    {
      "status": "SUCCEEDED",
      "preview": true,
      "mcpRequest": { "method": "tools/list", "name": null, "intent": null, ... }
    }
  ]
}
```

The run reads as a story: what the agent listed, what it called, why, and whether it committed. Requests before the promotion are `preview: true`. The program request is not, because the server is `LIVE`: its preview is marked on its call, the `program` execution under `children`, and on every call beneath that. The refused 40.00 credit is `SUCCEEDED` because Twisp answered with a tool result; its call is `FAILED`, under `children`. From any request, [lineage](https://www.twisp.com/docs/reference/actions/executions.md#lineage) walks to every call and write beneath it.

To stop one run without affecting anyone else, close its session with [`closeSession`](https://www.twisp.com/docs/reference/actions/mcp.md#sessions): its next requests are refused with `session_closed`.

## Troubleshooting

| Symptom                                                                    | Check                                                                                                                                                                                                             |
| -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `404` with `mcp_server_not_found`                                          | The URL's last segment is the action's registry `name`, and the server must be `ACTIVE` and enabled. Run `ReadSupportDesk`.                                                                                       |
| A call returns `mcp_intent_required`                                       | Every tool requires `intent` while `mcp.intent` is `REQUIRED`. Add it to `arguments`.                                                                                                                             |
| A call returns `action_access_denied` with a hint naming a layer           | The message names the operation, the resource, and the layer that denied it: the server or the tool. Add that access to that layer's `policies`. A posting needs `INSERT`, and `UPDATE` on accounts and balances. |
| `updateAction` rejects the `mcp` block                                     | A supplied `mcp` block replaces the whole block: send `name` and `session` with `mode`.                                                                                                                           |
| `400` with `mcp_session_invalid`                                           | The request's session input does not match `session.inputSchema`, or the session exists with a different input. Include every `start.input` field in `id`.                                                        |
| A credit returns `action_graphql_error` with a unique constraint violation | That `transaction_id` already posted. Choose a new one.                                                                                                                                                           |
| The balance does not change after a credit                                 | The server is still in `PREVIEW`, and the result carries `"com.twisp/preview": true`. Promote it in step 10.                                                                                                      |

Next, read [MCP Servers](https://www.twisp.com/docs/actions/mcp-servers.md) for how the pieces fit, and [Twisp in Your Agent Platform](https://www.twisp.com/docs/guides/your-agents.md) for grouping sessions by your own run IDs and connecting traces. To connect Claude, Claude Code, VS Code, and other hosts to a server in your Twisp cloud environment, see [Connecting MCP Hosts](https://www.twisp.com/docs/guides/mcp-hosts.md). The [MCP server reference](https://www.twisp.com/docs/reference/actions/mcp.md) covers every field, method, and error.
