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:
| 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 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:
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: trueonget_balanceonly. Annotations are derived from policies, not declared:get_balanceallows onlySELECT, so hosts may call it without asking a person.issue_creditandprogramcan write, so hosts ask first.intenton every tool. The server added it, becauseintentdefaults toREQUIRED.- 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
| 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 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.