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

You need a running local environment. 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.

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.

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

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.

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

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:

FieldWhat it does here
mcp.nameThe name hosts see. The endpoint uses the action's registry name, also support-desk.
mcp.instructionsGuidance returned to the agent when it connects.
mcp.session, sessionOne session 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.
policiesThe 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.intentOmitted, so the server starts in PREVIEW and every call requires an intent.

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

query ReadSupportDesk {
  actions {
    action(id: "00d63755-e96c-41a5-97df-3f650c943215") {
      version
      status
      pending
      definition {
        mcp {
          mode
          intent
          limits {
            callsPerMinute
          }
        }
      }
    }
  }
}
{
  "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:

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

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:

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

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

The result lists the three tools, trimmed here:

{
  "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 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

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"}}}'
{
  "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

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"}}}'
{
  "_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:

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"}}}'
{ "structuredContent": { "balance": "0.00", ... }, ... }

9. Review what the preview would have written

Each request is an execution 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:

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:

{
  "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:

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:

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"}}}'
{
  "_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:

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"}}}'
{ "structuredContent": { "balance": "10.00", ... }, ... }

Now ask for more than the tool allows:

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"}}}'
{
  "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:

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:

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"}}}')"
{
  "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:

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:

{
  "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 walks to every call and write beneath it.

To stop one run without affecting anyone else, close its session with closeSession: its next requests are refused with session_closed.

Troubleshooting

SymptomCheck
404 with mcp_server_not_foundThe 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_requiredEvery tool requires intent while mcp.intent is REQUIRED. Add it to arguments.
A call returns action_access_denied with a hint naming a layerThe 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 blockA supplied mcp block replaces the whole block: send name and session with mode.
400 with mcp_session_invalidThe 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 violationThat transaction_id already posted. Choose a new one.
The balance does not change after a creditThe server is still in PREVIEW, and the result carries "com.twisp/preview": true. Promote it in step 10.

Next, read MCP Servers for how the pieces fit, and Twisp in Your Agent Platform 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. The MCP server reference covers every field, method, and error.

Previous
Classifying with a Model