Sokin MCP - Technical Docs
Sokin MCP — Technical Docs#
Disclaimer: The MCP functionality described on this page is currently
available only in Sokin's UAT environment and is not yet available to all
customers across all live regions. This is a temporary limitation, and Sokin
plans to make these capabilities available to all customers across its live
regions in the near future. Please contact Sokin for further information
regarding availability and access.
The Sokin MCP server exposes the Sokin B2B API to AI agents over the Model
Context Protocol. This page is the reference catalogue: every tool, its
parameters, and the exact shape of what it returns.It is not an install guide. For connecting a specific client, signing in, and
reaching a first successful call, see the companion page, Sokin MCP —
Setup and Usage Guide.Server#
| Environment | Endpoint | Best for |
|---|
| UAT | https://mcp.uat.sokin.com/mcp | Building and testing an integration. This is the environment this reference documents. |
| Production | https://mcp.sokin.com/mcp | Live production data and real corporate accounts. Arranged through your Sokin contact. |
Both environments share the same protocol, authentication model and response
contracts:| Field | Value |
|---|
| Transport | Streamable HTTP |
| Protocol | JSON-RPC 2.0 (MCP) |
| Authentication | OAuth 2.1 + PKCE, with Dynamic Client Registration |
| Tools | 23 |
| Session model | Stateless: every request is independent |
The tool catalogue below reflects UAT, read directly from the tools
registered in this repository. Production runs its own deployed image and
may expose an older or smaller tool set; confirm availability with your
Sokin contact before depending on a specific tool in production.Three transport details that surprise people:No handshake is required. The server is stateless, so a tools/call
works with no prior initialize round trip.
Responses arrive as SSE. Even a single, non-streaming call comes back
as a text/event-stream with the result in a data: event, not as a
plain JSON body.
Your Accept header must include both content types. Send
Accept: application/json, text/event-stream. Omitting the second value
is the single most common cause of a client failing to parse a valid
response.
Authentication#
The server is itself the OAuth Authorization Server your client talks to. It
brokers to Auth0 underneath, and that indirection is the point: requesting
the Sokin B2B API audience requires a parameter an MCP client has no way to
set itself. The server sets it on your behalf, so a client never needs (and
cannot supply) a Sokin API audience of its own.Discovery follows the standard well-known paths:| Endpoint | Purpose |
|---|
/.well-known/oauth-authorization-server | Advertises the server you are talking to as the issuer, plus its authorize and register endpoints |
/.well-known/oauth-protected-resource | Points back at that same server as the authorization server for this resource |
Both are served from whichever environment's host you are using: the server
advertises its own public base URL, so discovery works the same way on UAT
and production without either being hardcoded.Dynamic Client Registration is enabled at /register, so clients
self-register rather than needing credentials issued by hand.
Scopes are openid, profile, email.
There is no offline_access yet, so there is no refresh token. A
session lasts the Auth0 access token's own lifetime, after which the user
re-authenticates. (The provider's code already has an MCP-level refresh-token
path wired up for when this changes, but it only ever activates if Auth0
hands back its own refresh token, which does not happen without requesting
offline_access -- so today it never fires.)
You cannot set the API audience yourself, and you do not need to.
Requests to /mcp are additionally gated by Host header checks (DNS
rebinding protection). A request addressed to a host other than the
advertised public one is rejected with 421, not 401. If you see a 421,
check the Host header before looking at your token.Anatomy of a result#
Every tool call returns a CallToolResult with three parts:| Part | Type | What it carries |
|---|
content | array of text blocks | The model-facing rendering: a sentence or a markdown table. Always present, and always compact in every mode. |
structuredContent | object, or absent | The machine-readable payload. This is the half the full-response opt-in widens. See the caveats below; it is not present on every result. |
isError | boolean | Whether the call failed. |
On a successful call both channels arrive together, and that duplication is
deliberate: content is what a model reads cheaply, structuredContent is
what your own code parses.structuredContent is absent in two cases, and code that parses results
must handle both:Every error result. A failure carries content and isError: true and
nothing else. Check isError before reaching into structuredContent, or
the first upstream failure your integration meets becomes a KeyError
rather than a handled error.
play_cheetah_dash, which returns a bare string and never goes
through the compact/full machinery at all.
Every other tool returns structuredContent on success.A stray message field#
Upstream envelopes also carry a message field, filled by the B2B API's own
response helpers with message or "Success.". It is almost always that
literal string, describes the HTTP exchange rather than your result, and is
never worth reading.Some tools strip it before building structuredContent; some pass it
through unchanged. Tolerate it rather than assume a fixed key set:| Behaviour | Tools |
|---|
| Stripped | list_corporates, list_beneficiaries, get_beneficiary, get_beneficiary_schema, list_financial_institution_branches, validate_payment_instruction, settle_fx_quote |
| Passed through | list_accounts, get_account, get_account_ledger, list_instructions, get_instruction, get_instruction_request, list_financial_institutions |
| Not applicable, no upstream envelope | get_fx_quote, create_fx_quote, create_payment_instruction, create_fx_payment_instruction, get_documentation_links, visualize, play_cheetah_dash |
| Both, depending on the branch taken | submit_payment_instruction, submit_fx_payment_instruction: stripped on a fresh submission, passed through on a replay that reports an existing payment's state |
Where a tool strips it, opting into the full response brings it back. That
is noted per tool below wherever it is the only difference the opt-in makes.Response modes: compact and full#
By default structuredContent is compact: trimmed to the fields a
caller can actually act on, with internal bookkeeping removed. A client can
opt into the full raw B2B API payload instead.Two independent triggers, combined with OR semantics. Use whichever your
framework lets you reach.Trigger 1: the MCP _meta side channel.{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {
"name": "list_corporates",
"arguments": {},
"_meta": { "sokin.com/full-response": true }
}
}
The check is a strict identity comparison against boolean true. A
stringified "true" is ignored. This fails closed on purpose, so a proxy
that coerces JSON types cannot silently opt you into the wider payload.Trigger 2: an HTTP header.Case-insensitive and whitespace-trimmed, but the value must be exactly
true; any other non-empty value is ignored. This path exists because
several agent frameworks (LangChain/LangGraph, CrewAI, Semantic Kernel) let
application code set custom HTTP headers on a request, but expose no way to
set _meta on an individual call, even though the MCP SDK beneath them
already supports it.Neither trigger is a tool parameter, so the calling model can never opt
itself in; only your client code can.Here is the same list_corporates call's structuredContent, compact by
default and widened when a client opts in (real values from this server's
own test suite):{
"data": {
"items": [
{
"corporateReference": "CORP-1",
"corporateName": "Acme Ltd",
"assignmentType": "direct"
}
]
}
}
Full (opted in via either trigger above):{
"data": {
"items": [
{
"corporateReference": "CORP-1",
"corporateName": "Acme Ltd",
"assignmentType": "direct",
"activeFrom": "2026-01-01T00:00:00Z",
"features": { "paymentAcceptance": true }
}
]
}
}
content is identical either way: only structuredContent widens.One more limit worth knowing: whether structuredContent itself reaches the
model at all is a host decision, not this server's. Some hosts (OpenAI's
Apps SDK among them) do surface it to the model. A client that opts in on
such a host is choosing to put the full payload into model context.Errors#
Every failure comes back with isError: true and, with one narrow
exception noted per tool, no structuredContent at all: only content
and the flag. Branch on isError before parsing. Never string-match the
message text.| Class | Message shape | Cause |
|---|
| B2B API error | API error (404): <message from the API> | The upstream Sokin API rejected the call. The status and message are passed through as is. |
| Unexpected failure | Something went wrong while processing that request. Please try again, and contact support if it keeps happening. | Anything unanticipated: a malformed upstream response, a bug. Internal detail is deliberately not leaked. |
| Schema validation | Rejection from the MCP layer, before the tool body ever runs | A wrongly typed argument, or a value outside a closed Literal enum. |
| Tool-local validation | Usually Error: <specific problem>, but not reliably; see below | Per-tool argument checks: blank references, unsupported pagination parameters, malformed amounts. Listed per tool below. |
Most tool-local validation messages start with Error: , but two do not:
visualize returns markdown must not be empty. and
list_financial_institution_branches returns Unknown financial institution
id: '999'. Call list_financial_institutions to see valid ids. That
inconsistency is exactly why the rule above is to branch on isError: no
prefix, and no substring, is a dependable signal across every tool.A message starting with Error: is not on its own proof that isError is
true. create_payment_instruction and create_fx_payment_instruction can
both return content text that begins with the literal word Error: (an
expired-or-invalid-quote message on the FX-payment side, for instance) while
isError stays false -- these are reported findings from the validation
dry run, not tool failures, and the draft is still real. Branching on the
prefix instead of the flag gets exactly this case backwards.An upstream response that is structurally unusable is reported as a
synthesized 502 through the same channel rather than rendered as a false
success. validate_payment_instruction does this when a 200 arrives with
no verdict field; the create/submit tools do it when a 202 arrives with no
externalReference.Cursor-based, never page/offset. A paginated response carries:{
"data": [ "..." ],
"pagination": {
"nextToken": "eyJrIjoi...",
"previousToken": null,
"totalItems": 128,
"hasMore": true
}
}
When hasMore is true, pass nextToken back as the next_token argument.
No tool here exposes a previous_token parameter, so previousToken is
informational only.| Tool | Pagination |
|---|
get_account_ledger | Cursor: limit, next_token |
list_instructions | Cursor: limit, next_token |
list_beneficiaries | Cursor: limit, next_token |
list_financial_institution_branches | Cursor: limit, next_token |
list_accounts | Envelope present but inert: the upstream route hardcodes hasMore: false and accepts no pagination parameter at all, so this always returns everything in one call |
list_corporates | None |
list_financial_institutions | None; the upstream endpoint accepts no parameters at all |
19 of the 23 tools are read-only (readOnlyHint: true) and move no money.
Four are writes, and they are not equivalent:| Tool | Annotations | What it does |
|---|
create_fx_quote | readOnlyHint: false, destructiveHint: false, idempotentHint: false | Mints a real, short-lived FX quote. Additive: it only ever creates, never mutates or removes, and it moves no money on its own |
settle_fx_quote | readOnlyHint: false, destructiveHint: true, idempotentHint: true | Executes a real FX conversion between two of the corporate's own currency accounts |
submit_payment_instruction | readOnlyHint: false, destructiveHint: true, idempotentHint: true | Submits a prepared payment to a beneficiary |
submit_fx_payment_instruction | readOnlyHint: false, destructiveHint: true, idempotentHint: true | Submits a prepared FX payment: converts and pays a beneficiary in one motion against a live quote |
Hosts prompt before running any destructive tool. The corresponding
prepare-side tools stay on the read-only default: validate_payment_instruction
is a pure dry run, and both create_payment_instruction and
create_fx_payment_instruction prepare a draft for a human to look at
without ever touching the write endpoint -- though create_fx_payment_instruction's
own read-only badge deserves a closer look than that sentence gives it; see
its own entry below.That split describes today's tool set, not a permanent property of this
server. The release of any write tool to production is gated behind a
separate go-live decision independent of what UAT exposes; re-read the
environment note in the Server section above before depending on a write
tool in production.None of these money-moving flows is a single call. Each is a pair (or, for
FX quoting, a triple) joined by a server-side draft gate, and the shape is
worth understanding before integrating against any of them.The two payment pairs no longer require an MCP UI host#
Both create_payment_instruction/submit_payment_instruction and
create_fx_payment_instruction/submit_fx_payment_instruction used to
hard-refuse a session that had not positively declared MCP Apps (SEP-1865)
support at initialize time. That guard was removed from all four tools
(Vuk's ARC-455 review, finding #2): the real human control for a payment is
the corporate's own approval configuration in the Sokin B2B portal, not the
review widget. Refusing a plain-chat client bought no additional safety and
only blocked a legitimate create -> confirm in chat -> submit flow for a
host with no widget surface at all.Only create_fx_quote and settle_fx_quote keep a hard capability refusal
(see server.py's own UI_ONLY_TOOL_NAMES set and settle_fx_quote's entry
below for why: FX settlement executes instantly, with no portal approval
step behind it, so the widget's Approve click really is the only human
control that pair has).All four payment-pair tools still read the session's declared capability --
just to choose which of two response texts to show, never to refuse:MCP UI declared: the create_* tool renders its review card and its
text says so ("awaiting the user's confirmation in the review widget").
Only the user's Approve click there invokes the paired submit_* tool.
Not declared, or unknown (the common case for a plain-chat client):
there is no card. The create_* tool's own content text spells the
idempotencyKey out in plain words -- structuredContent is not reliably
surfaced to the calling model, so the key has to travel where the model
can actually see it -- and instructs the calling model to read the draft
and fees back to the user, get their explicit approval in chat, and only
then call the paired submit_* tool itself with that key and
byte-identical arguments.
structuredContent does not change between these two branches: idempotencyKey
is present in both once validation passes. Only the content wording
changes, and both submit_* tools accept a direct call under either
branch -- there is no special "widget-only" enforcement on the submit side,
and there never was one for this pair.Payments prepare in the conversation and commit from a review card (or, for
a non-UI client, from an explicit chat confirmation):validate_payment_instruction and create_payment_instruction both persist
nothing upstream and move no money; the first is a dry run, the second
prepares a draft for a human to look at. Only submit_payment_instruction
commits, and it will only ever send a byte-identical copy of the draft that
was prepared, under a key the server minted itself. See its own entry for
what that gate does and does not guarantee.create_fx_payment_instruction and submit_fx_payment_instruction follow a
similar prepare/review/approve/submit shape, with the same draft-gate
guarantees -- but create_fx_payment_instruction no longer takes a quote_id.
Per Vuk's review of the same ticket (finding #1), it now takes raw pricing
inputs (buy_currency, sell_currency, amount, fixed_side,
corporate_reference) and mints its own quote internally, via the same FX
quote adapter create_fx_quote uses, rather than requiring a quote minted
by a separate earlier call. The minted quote's id then travels inside the
registered draft, which is why submit_fx_payment_instruction still takes
quote_id as a parameter: it has to match that same draft. See both
entries below.FX quoting runs its whole lifecycle inside one card instead, because a
quote expires too quickly to survive a round trip through a conversation:Both create_fx_quote and settle_fx_quote exist only to back that card's
own buttons, and both are refused outright for a client that has not
declared MCP UI support. See settle_fx_quote's entry for why, and for what
its gate does and does not guarantee.list_corporates#
List Corporates · read-only · touches an external systemLists the corporates the authenticated user can access. This is the entry
point for most sessions: nearly every other tool needs a
corporateReference, and this is where one comes from. Takes no arguments.2 corporates: CORP-8842 — Northwind Trading Ltd (direct assignment); CORP-9110 — Northwind Logistics BV (inherited access).
structuredContent, envelope {"data": {"items": [...]}}, each item:| Field | Type | Description |
|---|
corporateReference | string | The reference to pass to every other tool |
corporateName | string | Display name |
assignmentType | string | direct or inherited |
Full response adds: activeFrom (an ISO-8601 timestamp nothing in this
server consumes) and features (an opaque corporate feature-flag object
passed through without being read). It also restores the envelope's
message field, which the compact response strips.list_accounts#
List Accounts · read-only · touches an external systemLists all Corporate Currency Accounts (CCAs) for a corporate, with balances.| Parameter | Type | Required | Description |
|---|
corporate_reference | string | yes | The corporateReference from list_corporates |
2 accounts for CORP-8842: CCA-1001 (GBP) — balance 48200.00, of which 47150.00 available; CCA-1002 (USD) — balance 12000.00, available balance not computable.
availableBalance is nullable upstream: it is currentBalance minus the
amount reserved by outstanding instructions, and it cannot be computed when
the caller lacks Instruction Read permission. The summary states that in
words rather than rendering an empty value.structuredContent, envelope {"message": "Success.", "data": [...], "pagination": {...}}.
The pagination object is always present here but inert: nextToken and
previousToken are always null and hasMore is always false; the upstream
route accepts no pagination parameter at all. Each item:| Field | Type | Description |
|---|
corporateReference | string | The corporate that owns this account |
corporateCurrencyAccountReference | string | The account reference used by other tools |
currencyCode | string | ISO 4217 |
balance | decimal | Current balance |
availableBalance | decimal, or null | Balance minus outstanding instruction reservations; null when not computable |
Full response adds: nothing. Compact is the identity of raw here; every
field is caller-actionable, so nothing is trimmed.get_account#
Get Account · read-only · touches an external systemFull detail for one Corporate Currency Account, including the pay-in
details someone would use to fund it.| Parameter | Type | Required | Description |
|---|
account_reference | string | yes | The corporate currency account reference |
content, a sentence per funding route:Account CCA-1001 (GBP): balance 48200.00, of which 47150.00 available. Local funding: active — ready to receive payments — pay into Northwind Trading Ltd, account 12345678, SortCode 04-00-75, at Modulr FS Limited (London branch), GB. International funding: not yet set up.
structuredContent, envelope {"message": "Success.", "data": {...}}:| Field | Type | Description |
|---|
externalReference | string | The account reference |
currency | string | ISO 4217 |
currentBalance | decimal | Current balance |
availableBalance | decimal, or null | A computed value: currentBalance minus outstanding instruction reservations, null when not computable. The value it is computed from, outstandingInstructionBalance, is never itself serialized on the wire |
localAccountStatus | string | Locality status, see the enums appendix |
internationalAccountStatus | string | Locality status, see the enums appendix |
localPaymentDetails | object, or null | Pay-in details for the local route |
internationalPaymentDetails | array | Pay-in details for international routes; defaults to an empty list, may hold several options |
Each payment-details object carries accountName, accountNumber,
accountType, iban, routingCodeType, routingCode, bicCode, email,
corporateReference, bankName, bankCountry, bankAddress,
bankBranch.Full response adds: version only, an optimistic-concurrency counter
with no user-facing meaning.Validation errors: account_reference must not be blank. This is
enforced locally rather than left to the upstream API because a blank path
segment collapses the request down to the account collection route; it
would return a list of accounts instead of erroring.get_account_ledger#
Get Account Ledger · read-only · touches an external systemTransaction history for one account. Cursor-paginated (this is a genuinely
paginated endpoint, unlike list_accounts).| Parameter | Type | Required | Description |
|---|
account_reference | string | yes | The corporate currency account reference |
from_date | string | no | Filter from this date (YYYY-MM-DD) |
to_date | string | no | Filter to this date (YYYY-MM-DD) |
limit | integer | no | Items per page (default 25, max 100) |
next_token | string | no | Cursor from the previous page's pagination.nextToken |
content, a markdown table, amounts signed for readability:3 ledger entries for CCA-1001 (amounts shown as +credit/-debit):| Date | Type | Amount | Counterparty | Description |
|---|
| 2026-08-14 | deposit | +5000.00 | Contoso Ltd | Invoice 4471 |
| 2026-08-15 | withdrawal | -1250.00 | Fabrikam GmbH | Supplier payment |
| 2026-08-16 | FX Trade | -800.00 | | GBP to EUR conversion |
The counterparty column reads sourceReference for credit rows and
beneficiary for debit rows; the two are mutually exclusive upstream,
gated by direction.structuredContent, envelope {"message": "Success.", "data": [...], "pagination": {...}},
each entry (8 fields, the complete set on this model, nothing trimmed):| Field | Type | Description |
|---|
amount | decimal | Unsigned amount. The sign shown in content is derived from direction |
direction | string | credit or debit in practice (see the enums appendix); typed as a plain string upstream, not an enforced enum |
type | string | Entry type. Only FX and forward legs get a friendly label; every other kind appears under its own raw snake_case value. See the enums appendix for the full set |
effectiveDate | date | Date the entry takes effect |
sourceReference | string, or null | Counterparty on credit entries |
beneficiary | string, or null | Counterparty on debit entries |
description | string, or null | Free text |
transactionReference | string, or null | Logical reference tying together every leg of one transaction |
Full response adds: nothing; this is already the complete raw model.
transactionReference is kept in the compact response deliberately despite
looking like an internal correlation id: it is the reference that makes
reconciliation possible.Validation errors: account_reference must not be blank.list_instructions#
List Instructions · read-only · touches an external systemLists payment instructions for a corporate or for a single account.
Cursor-paginated.| Parameter | Type | Required | Description |
|---|
corporate_reference | string | one of | The corporate to list for |
cca_reference | string | one of | The account to list for |
completed | boolean | no | true for completed, false for in progress |
limit | integer | no | Max instructions (default 20, max 100) |
next_token | string | no | Cursor from the previous page |
Exactly one of corporate_reference or cca_reference is required.content, a markdown table:2 instructions for CORP-8842:| Reference | Account | Type | Amount | Currency | Status | Created |
|---|
| INS-5512 | CCA-1001 | Payment | 1250.00 | GBP | Processed | 2026-08-15T09:12:44Z |
| INS-5513 | CCA-1002 | FXPayment | 800.00 | USD | Pending Settlement | 2026-08-16T11:03:02Z |
structuredContent, envelope {"message": "Success.", "data": [...], "pagination": {...}},
each item:| Field | Type | Description |
|---|
instructionReference | string | The instruction's external reference |
corporateReference | string | Owning corporate |
ccaReference | string | Source account. A corporate can hold several accounts in one currency, so currency alone does not identify it |
instructionType | string | See the enums appendix |
amount | decimal | Instruction amount |
currency | string | ISO 4217 |
displayStatus | string | User-facing status, see the enums appendix |
createdAt | datetime | Creation timestamp |
updatedAt | datetime | Last update timestamp |
This list summary is a lighter model than get_instruction's full detail
response below; it does not carry fees, beneficiary, or currency-conversion
fields at all.Full response adds: status, the internal instruction status. The
API's own field description calls it internal, and displayStatus already
carries the user-facing equivalent.Error: You must provide either corporate_reference or cca_reference.
Error: Provide only one of corporate_reference or cca_reference, not both.
get_instruction#
Get Instruction · read-only · touches an external systemFull detail for one instruction: status, currencies, fees, beneficiary.| Parameter | Type | Required | Description |
|---|
instruction_reference | string | yes | The instruction reference to look up |
content, a sentence, with optional clauses appearing only when the
underlying field is populated:Instruction INS-5513 (FXPayment) for corporate CORP-8842, account CCA-1002: 800.00 USD -> 742.10 EUR, status Pending Settlement. Beneficiary: BEN-3301. Sender reference (shown to the recipient): INV-4471. Purpose: SUPPLIER_PAYMENT. Fees: 4.20 FX, 1.50 transaction, 0.00 reseller. Created 2026-08-16T11:03:02Z, updated 2026-08-16T11:04:18Z.
structuredContent, envelope {"message": "Success.", "data": {...}}.
The full upstream model has 25 fields; the compact response drops 3 of them
(version, status, quoteId), leaving 22:| Field | Type | Description |
|---|
externalReference | string | The instruction reference |
createdAt | datetime | Creation timestamp |
updatedAt | datetime | Last update timestamp |
failureReason | string, or null | Why it failed, when it did |
displayStatus | string | User-facing status, see the enums appendix |
instructionType | string | See the enums appendix |
corporateReference | string | Owning corporate |
corporateCurrencyAccount | string | Source account |
amount | decimal | Source amount |
destinationAmount | decimal, or null | Destination amount; set only when a conversion happens |
sourceCurrency | string | ISO 4217 |
destinationCurrency | string | ISO 4217 |
beneficiaryReference | string, or null | The payee |
senderReference | string, or null | Text shown to the recipient |
paymentPurpose | string, or null | Purpose code |
paymentReference | string, or null | Payment reference |
sokinFxFee | decimal | FX fee |
sokinTransactionFee | decimal | Transaction fee |
resellerTransactionFee | decimal | Reseller fee |
destinationCorporateReference | string, or null | Destination corporate, when distinct from the source |
destinationCorporateCurrencyAccount | string, or null | Destination account, when applicable |
instructionRequestReference | string, or null | The request this instruction came from. Pass it to get_instruction_request |
Full response adds: version (an optimistic-concurrency counter),
status (the internal status; the API's own field description calls it
internal, and even that description is known to be incomplete versus the
real enum, so prefer displayStatus), and quoteId (an internal
correlation id no tool here reads).get_instruction_request#
Get Instruction Request · read-only · touches an external systemReads the outcome of a submitted payment. A submitted payment becomes an
instruction request first and is processed asynchronously: acceptance at
submission does not mean the payment was created. This is the way to get
from a submission to the instruction it produced.| Parameter | Type | Required | Description |
|---|
instruction_request_reference | string | yes | The instruction request reference exactly as the API returned it (currently IR--prefixed, e.g. IR-Payment-..., though treat the prefix as convention, not contract). Not an instruction reference; those belong to get_instruction. If you only have an instruction, its instructionRequestReference field is this value. |
Read status carefully: the names run counter to their meaning.
Created is the initial, still-processing state, not a success. Accepted
is terminal success. Rejected is terminal failure. These are the only
three values this field takes.content, one sentence, branching on status:Instruction request IR-Payment-1 is still being processed -- no outcome yet. Check again shortly.
Instruction request IR-Payment-1 was accepted: instruction INS-42 was created. Use get_instruction for its full detail.
Instruction request IR-Payment-1 was rejected: Insufficient balance (category: amount). No instruction was created.
createdInstructionReference is nullable on the contract even when the
status is Accepted; in that case the sentence ends "accepted; the created
instruction's reference is not available yet" rather than rendering a
literal None. A status outside the three above is echoed verbatim (...
has status <value>.) rather than reinterpreted.structuredContent, envelope {"message": "Success.", "data": {...}},
5 fields:| Field | Type | Description |
|---|
externalReference | string | The instruction request reference |
status | string | Created, Accepted, or Rejected; see the enums appendix |
createdInstructionReference | string, or null | The instruction produced on acceptance. Feed it to get_instruction. Nullable even when Accepted |
failureReason | string, or null | Why the request was rejected |
errorCategory | string, or null | The field or category that caused the rejection. See the enums appendix |
Full response adds: version, an optimistic-concurrency counter.Tool-specific errors: a blank reference is rejected locally before any
call. A response whose payload is missing externalReference or status
is rejected as an unexpected shape rather than reported as a confident
success. A not-found is usually final: the reference is wrong (commonly an
instruction reference passed here by mistake), or the request belongs to a
different login. The one transient case is a request submitted moments ago,
which can briefly read as not-found while still being persisted; retry
once, then stop.validate_payment_instruction#
Validate Payment Instruction · read-only · touches an external systemDry-runs a payment: the same checks real creation performs (account,
beneficiary, balance, fee resolution) run synchronously with nothing
persisted and no money moved.It is marked read-only despite issuing a POST upstream, and that is not a
convenience label. The validate handler is wired with no repositories at
all (unlike the create handler beside it), and the engine it runs performs
no writes. The path holds nothing capable of persisting; the wire verb is
an artifact of sending a request body.| Parameter | Type | Required | Description |
|---|
corporate_currency_account | string | yes | Source account reference from list_accounts. Currency and corporate are derived from it |
beneficiary_reference | string | yes | The payee, from list_beneficiaries. Must already exist |
amount | string | yes | Plain decimal, max 2 places, greater than zero (e.g. "125.50") |
reseller_fee | string | no | Same format as amount, zero or greater |
sender_reference | string | no | Shown to the recipient, max 18 characters |
payment_purpose | string | no | Purpose code, case-insensitive; uppercased before sending |
amount_is_fees_inclusive | boolean | no | true takes fees out of amount; default false adds them on top |
content, a verdict sentence:Validation passed: this payment would clear the creation checks. Total debit 1256.70 (5.20 Sokin fee, 1.50 reseller fee); beneficiary would receive 1250.00. Nothing was created and no money moved.
Validation failed: insufficient available balance on the source account (category: amount). The engine still computed amounts: total debit 1256.70 (5.20 Sokin fee, 1.50 reseller fee). Nothing was created and no money moved.
structuredContent, envelope {"data": {...}}:| Field | Type | Description |
|---|
success | boolean | Whether the dry run passed every check |
failureReason | string, or null | Why it failed |
errorCategory | string, or null | Machine-readable failure category. Not a closed set on the wire; see the enums appendix |
feeBreakdown | object, or null | Amounts, when the engine got far enough to compute them |
feeBreakdown carries amount (total debit including fees, required),
destinationAmount (what the beneficiary receives, nullable), sokinFee
(required), resellerFee (required), and feeRateCardReference (nullable).
It is populated on failures too; an insufficient-balance rejection still
shows how short the account is.Full response adds: the envelope's message field, the literal string
"Success." on every call regardless of the verdict. It describes the HTTP
exchange, not the validation, and reading it as a result would be a bug.Error: corporate_currency_account must not be empty.
Error: beneficiary_reference must not be empty.
Error: amount must be a plain decimal number with at most 2 decimal places (e.g. '125.50'), got '1e3'.
Error: amount must be greater than zero.
Error: reseller_fee must be ... (same format rule, but zero is allowed)
Error: sender_reference must be at most 18 characters, got 24.
The amount rule is deliberately stricter than the upstream API's. Pydantic
would parse "1e3" as 1000 and accept signed forms; for anything
payment-shaped, silent reinterpretation is the worst available failure
mode, so only a plain unsigned decimal passes here.A malformed upstream response is a hard error. A 200 whose body lacks
the success verdict is rejected as a synthesized 502 with the message
"API returned an unexpected response shape for the validation, the result
is unknown; do not report the payment as passed or failed." A missing
verdict must never render as a failed validation.create_payment_instruction#
Create Payment Instruction · read-only · touches an external systemPrepares a payment for a human to review. It submits nothing. Despite
the name, no payment exists after this call and no money has moved.What it actually does: re-runs the validate dry run so the review can
show server-computed fees rather than model-relayed figures, resolves the
source account's currency with one read-only account lookup, mints an
idempotency key server-side, and registers the normalized draft against
that key. When the calling session has declared MCP UI support it also
renders its own MCP UI embed (ui://sokin-mcp/payment-review), a review
card with Approve and Cancel; only the user's Approve click there submits,
by invoking submit_payment_instruction from inside the widget.Call validate_payment_instruction first. This tool's own description
instructs the calling model to validate and iterate with the user (fixing
inputs, asking for missing data) until validation passes, and only then
prepare. The dry run this tool re-runs internally is a guard, not the
pre-flight: a draft that fails it produces no approvable draft and no
registered key, costing the user a round trip.MCP UI support changes only which text this tool shows you, not whether
it will run. Earlier revisions of this pair hard-refused a session that
had not positively declared MCP Apps support -- that guard came off both
create_payment_instruction and submit_payment_instruction per Vuk's
review of ARC-455 (finding #2; see server.py's own comment on
UI_ONLY_TOOL_NAMES). The real human control for a payment is the
corporate's own approval configuration in the Sokin B2B portal, not this
tool's widget. Refusing a plain-chat client bought no additional safety and
just blocked a legitimate create -> confirm in chat -> submit flow.The session's declared capability is still read, but only to choose the
wording:MCP UI declared: the card renders, and the text says "awaiting the
user's confirmation in the review widget." Only the widget's Approve
click should invoke submit_payment_instruction.
Not declared, or unknown: there is no card. The text spells out the
idempotencyKey in plain words -- structuredContent is not reliably
surfaced to the calling model, so the key has to travel where the model
can read it -- and instructs the calling model to read the draft and fees
back to the user, get their explicit approval in chat, and only then call
submit_payment_instruction itself with that key and byte-identical
arguments.
structuredContent does not change between the two branches: idempotencyKey
is present in both once validation passes. Only content changes.Earlier revisions of this page also said this pair "fails safe" on a
non-UI host because no card would appear, so nothing could be approved.
That was never accurate -- the prepare half always returned the idempotency
key in its structuredContent, which was by itself enough for a caller to
invoke the submit half directly and skip human confirmation. Nothing in
today's design closes that by refusal; what actually stands between a model
seeing the key and a real submission is the explicit-approval instruction in
the non-UI text above, plus the host's own confirmation prompt on
submit_payment_instruction's destructive annotation. Treat that prompt as
load-bearing, not decorative, if you are embedding this server in your own
agent -- see submit_payment_instruction's own entry for the full
reasoning.It is marked read-only for the same reason validate_payment_instruction
is: its only upstream call is the side-effect-free dry run. It does write
one piece of server-side state (the draft record the paired submit tool
checks against), which is gate bookkeeping, not a business write.| Parameter | Type | Required | Description |
|---|
corporate_currency_account | string | yes | Source account reference from list_accounts. Currency and corporate are derived from it |
beneficiary_reference | string | yes | The payee, from list_beneficiaries. Must already exist; there is no tool that creates beneficiaries |
amount | string | yes | Plain decimal, max 2 places, greater than zero (e.g. "125.50") |
reseller_fee | string | no | Same format as amount, zero or greater |
sender_reference | string | no | Shown to the recipient, max 18 characters |
payment_purpose | string | no | Purpose code such as ACCOUNTS_PAYABLE, case-insensitive, uppercased before sending. Validated server-side against a feature-flagged catalogue; see the enums appendix |
amount_is_fees_inclusive | boolean | no | true takes fees out of amount; default false adds them on top |
content, when MCP UI is declared -- a not-submitted notice worded so a
model relaying it cannot truthfully claim the payment was made:Payment prepared and validated -- awaiting the user's confirmation in the review widget. Nothing has been submitted; do not report this payment as made, and do not call submit_payment_instruction yourself.
content, when MCP UI is not declared (or unknown) -- the idempotency
key spelled out, and explicit chat-confirmation instructions:Payment prepared and validated -- nothing has been submitted yet. idempotency key: 3fae1c9e-... . Read the draft and fees back to the user and get their explicit approval; only after they approve, call submit_payment_instruction yourself with this idempotency_key and the exact same arguments. Never submit without that approval, and never tell the user the payment was made until submit_payment_instruction succeeds -- it may still need approval in the Sokin portal afterward.
When the dry run fails, there is no approvable draft and no registered key
(this text is the same in both branches), and the text says what to do
instead:The payment could not be validated: insufficient available balance on the source account. Fix the inputs (validate_payment_instruction is the pre-flight) and prepare again. Nothing has been submitted.
structuredContent: this tool builds its own payload, so there is no
{"data": ...} envelope:| Field | Type | Description |
|---|
draft | object | The normalized payment exactly as it would be submitted |
currency | string, or null | The source account's currency, for display. Null if the lookup failed (display sugar that never blocks a valid draft). Absent entirely on the validation-failure branch |
validation | object | The dry-run verdict, same shape as validate_payment_instruction's data, including feeBreakdown |
idempotencyKey | string, or null | Server-minted, registered to this exact draft. Null when validation failed, because no approvable draft exists |
duplicateWarning | object | Present only when an identical draft was already submitted: instructionRequestReference and submittedAt of that earlier payment |
draft uses the tool's own snake_case parameter names, not the API's
camelCase: corporate_currency_account, beneficiary_reference, amount,
reseller_fee, sender_reference, payment_purpose,
amount_is_fees_inclusive. It sits next to camelCase siblings like
idempotencyKey in the same object because it is the tool's argument set,
not an upstream payload. Normalization strips every string and uppercases
payment_purpose; a whitespace-only optional becomes null.duplicateWarning is not an error. It means this exact payment has
already gone out. When a widget is present it renders an already-submitted
card with no Approve button; either way submit_payment_instruction
refuses the draft unconditionally, and the block does not expire. A
deliberate second identical payment is prepared with a distinguishing
sender_reference, which makes it a different draft. There is no
acknowledgement flag to override this by design: any flag a caller could
set would be one a model could set too.Full response adds: nothing. This tool assembles its own payload, so
the compact and full responses are the same object.Validation errors: the same set validate_payment_instruction lists,
over the same normalized draft. A draft that renders as reviewable is one
submit would also accept.Draft lifetime. The key and the reviewed draft live in the server's
session store. Pending drafts expire after 24 hours; an expired key is
refused and the payment is simply prepared again. Submitted records are
kept with no expiry, mirroring the backend's permanent dedupe. Hosts may
re-execute a read-only tool freely (on session restore, for instance); each
execution mints a fresh key and registers a fresh draft, which is why
unapproved drafts expire rather than accumulate.submit_payment_instruction#
Submit Payment Instruction · destructive, idempotent · touches an
external systemThe payment commit. This is the one tool on this server that moves real
money. The payment may auto-approve with no further human step, and it
cannot be cancelled once submitted.When the calling session declared MCP UI support, this tool is invoked by
the payment review widget after the user clicks Approve, and is not meant
to be called directly in that case. When it did not, create_payment_instruction's
own text hands back the idempotency key and instructs the calling model to
get the user's explicit approval in chat first -- calling this tool
directly at that point is the intended path, not a bypass; there is no
separate capability check here that would refuse it. Either way it accepts
only an idempotency key that create_payment_instruction minted, and only
with arguments that match the reviewed draft exactly. It declares
readOnlyHint: false, destructiveHint: true and idempotentHint: true,
and carries no MCP UI embed of its own: when a widget invoked it, its
result renders in that same widget.| Parameter | Type | Required | Description |
|---|
idempotency_key | string | yes | The key create_payment_instruction minted for this exact draft, from its idempotencyKey field |
corporate_currency_account | string | yes | Must match the reviewed draft |
beneficiary_reference | string | yes | Must match the reviewed draft |
amount | string | yes | Must match the reviewed draft |
reseller_fee | string | no | Must match the reviewed draft |
sender_reference | string | no | Must match the reviewed draft |
payment_purpose | string | no | Must match the reviewed draft |
amount_is_fees_inclusive | boolean | no | Must match the reviewed draft |
content, a 202 receipt. Acceptance is not completion:Payment submitted: instruction request IR-Payment-77 for 125.50 from CCA-1 to BEN-1. This is an instruction-request reference, not a completed payment -- check the outcome via get_instruction_request.
structuredContent, envelope {"data": {...}} with a single field on a
fresh submission:| Field | Type | Description |
|---|
externalReference | string | The instruction request's reference. Pass it to get_instruction_request for the outcome |
That is the whole upstream create response model. Everything about the
payment's actual business outcome is asynchronous, so nothing else is
available yet.A malformed upstream response is a hard error. A 202 whose body lacks
externalReference is rejected as a synthesized 502 reading "API
returned an unexpected response shape for the payment submission, whether
it was accepted is unknown; check get_instruction_request before
retrying." After a POST that may have landed, "unknown" is the only honest
verdict, and it is never rendered as a failure.Full response adds: the envelope's message field on a fresh
submission, which the compact response strips. On the replay branch below,
message is already passed through in compact mode.What the gate refuses, and when. Every refusal below happens before the
server acquires an access token, so a refusal never depends on
authentication being healthy, and an auth outage cannot mask one behind a
generic client error. None of these reach the B2B API, and none create a
payment:| Situation | Result |
|---|
| Key unknown, fabricated, or expired | Error: unknown idempotency key -- payments are prepared via create_payment_instruction, which mints the key and renders the review widget. If a prepared draft was lost, prepare it again. |
| Arguments differ from the reviewed draft | Error: these arguments do not match the draft the user reviewed under this idempotency key. Only the exact reviewed draft can be submitted |
| Key already used, with different values | Error: this idempotency key was already used to submit a different draft (IR-...). Nothing was submitted for these arguments. Answering with the original's state would read as "your new values were handled" |
| Identical draft already submitted, under any key | Error: an identical payment was already submitted as IR-... at ... -- this submission was refused, no new payment was created. Permanent, not windowed |
| Identical draft being submitted right now | Error: an identical payment is being submitted right now ... Wait a moment, then check the payment's state with get_instruction_request. Concurrent approvals are resolved atomically, so exactly one can win |
The duplicate block is checked at submit time, not only at prepare time, so
it also catches two identical drafts that were both prepared before either
was approved. It is content-addressed over the normalized draft, so it
survives a fresh key and a days-old restored session.A replay is answered, not re-executed. Re-submitting the same draft
under the same key (a double click, a widget remount, a model retry, or a
non-UI client calling this a second time) sends nothing upstream. Instead
the tool reads the original payment's live state and reports it, which a
blind retry against the backend's own dedupe could not do:This draft was already submitted -- no new payment was created. Instruction request IR-Payment-77 was accepted: instruction INS-9 was created. Use get_instruction for its full detail.
On that branch structuredContent is get_instruction_request's shape
(externalReference, status, createdInstructionReference,
failureReason, errorCategory), not the one-field submission shape. Code
that parses this tool's result must tolerate both. If the original's state
cannot be read, the tool says so rather than guessing.What the gate does not guarantee. It constrains what can be submitted:
only the prepared draft, only once, only under a key the server minted. It
does not, and within MCP cannot, prove who approved it. There is no
widget-only channel, so idempotencyKey necessarily reaches the model in
create_payment_instruction's structuredContent regardless of whether a
widget rendered. The human step rests on either the review card plus the
host's confirmation prompt, or -- for a non-UI client -- the explicit
in-chat approval create_payment_instruction's own text asks for, plus that
same host confirmation prompt on this tool's destructive annotation either
way. If you are embedding this server in your own agent, treat that prompt
as load-bearing, not optional, in both cases.The backend independently dedupes forever on (idempotencyKey, type,
account), so duplicate money on one key is impossible even if the
server's own draft registry were lost entirely. The gate documented here is
an availability and integrity layer on top of that; the backend remains
the correctness layer.Outcome tracking. When a review widget is present, it polls
get_instruction_request and then get_instruction, rendering the
payment's journey as a timeline and distinguishing created-and-processing,
created-but-awaiting-your-team's-approval (corporate approval rules), and
rejected or failed. On supporting hosts it also pushes a self-contained
summary into the model's context at each terminal state via
ui/update-model-context (MCP SEP-1865), so the assistant knows the
outcome without re-querying. Hosts without support, and non-UI clients
entirely, get the same information from get_instruction_request directly.get_fx_quote#
Get FX Quote · read-only · touches an external systemRequests an FX quote for a buy/sell currency pair. Quoting only: this tool
never books a trade and never moves money itself. What it returns depends
on whether your client declared MCP UI support at initialize time. This
is the only tool on this server whose response shape (not merely its text)
branches on that declaration.This tool ships its own MCP UI embed (ui://sokin-mcp/fx-quote), an
interactive card covering the whole request, quote, and settle flow, with a
live countdown to expiry. Hosts that declared MCP UI support render it in
place of plain text.Why this branches: a real FX quote is short-lived, and how short is set
by the provider that priced it rather than by anything the caller asks for
(see create_fx_quote). Minting one before a human ever sees a review
card — the straightforward approach — burns part of that window before
anyone can act on it, and on the shortest-lived pairs most of it. So this
tool never mints a quote itself; it only renders the interactive card, and
the card's own buttons call the two tools below at the moment a person
actually clicks:Client declared MCP UI support: this call makes no upstream call and
mints no quote. It renders an empty, unpriced card echoing the requested
pair and amount. The card's "Get Quote" button calls create_fx_quote;
its "Settle" button calls settle_fx_quote. Do not tell the user a rate
exists from this call alone.
Client did not declare MCP UI support, or the declaration is unknown:
falls back to the legacy indicative-rate endpoint and returns a real
(non-committing) current rate plus a link to the Sokin portal for
executing the trade there. That link is deployment-configured and
differs per environment; where it is not configured the sentence still
tells the user to execute the trade in the Sokin portal, just with no
URL. A session in this state can never reach create_fx_quote or
settle_fx_quote; see settle_fx_quote's hard refusal below. (This is
the one pair on this server that still hard-refuses a non-UI session --
unlike the payment pairs above, see "The two payment pairs no longer
require an MCP UI host.")
| Parameter | Type | Required | Description |
|---|
buy_currency | string | yes | ISO 4217 code being bought (e.g. "USD") |
sell_currency | string | yes | ISO 4217 code being sold (e.g. "GBP") |
amount | string | yes | The amount of whichever side fixed_side names |
fixed_side | "BUY" or "SELL" | no | Which side amount refers to. Default "SELL" |
corporate_reference | string | no | The corporate to quote for. Required in practice; it resolves automatically only for a single-corporate caller |
corporate_currency_account | string | no | The account to eventually settle from. Only used by the card's later Settle step, never sent anywhere by this call itself |
When MCP UI is supported:content is a one-sentence acknowledgement, not a quote:Ready to quote GBP -> USD, 1000.00 on the sell side. No quote has been created yet -- the card's Get Quote button mints one.
structuredContent is a flat echo of the request, not upstream data:| Field | Type | Description |
|---|
buyCurrency | string | Echoed request |
sellCurrency | string | Echoed request |
amount | string | Echoed request |
fixedSide | string | Echoed request |
corporateReference | string, or null | Echoed request |
corporateCurrencyAccount | string, or null | Echoed request |
No full-response delta in this branch: there is no upstream call, so there
is nothing to widen.When MCP UI is not supported (or unknown):content, one sentence, built entirely from the response's own buy/sell
fields rather than the request's, since fixed_side: "BUY" means the input
amount is the buy side:FX quote QT-88213: 1000.00 GBP -> 1187.40 USD at rate 1.18740 (GBPUSD). Fees: 2.50 FX. Valid until 2026-08-27T14:32:10+00:00. Cut-off time: 2026-08-27T16:00:00+00:00. This rate is indicative and short-lived (see the expiry above) -- go to https://portal.uat.sokin.com/v2/transfer/currency-exchange to actually execute this trade; it cannot be settled through this assistant.
structuredContent, flat with no data envelope, unlike every other tool
here:| Field | Type | Description |
|---|
quoteId | string | Quote identifier |
currencyPair | string | e.g. GBPUSD |
fxRate | string | The quoted rate |
fixedSide | string | Which side the amount was fixed on |
buyAmount | string | Amount bought |
buyCurrency | string | ISO 4217 |
sellAmount | string | Amount sold |
sellCurrency | string | ISO 4217 |
fxFees | string, or null | FX fee, when charged |
transactionFees | string, or null | Transaction fee. The newer pricing path sets this to null unconditionally |
currentTime | string | Quote time, normalized to UTC ISO-8601 with an explicit offset |
expiryTime | string | Expiry, normalized the same way |
cutOffTime | string, or null | Settlement cut-off, when applicable |
holidays | array, or null | Holidays affecting this rate |
currentTime and expiryTime are re-rendered as unambiguous UTC before
they leave the server. The upstream API documents them only as "string"
with no confirmed offset, and a value with no offset parses as the
viewer's local time in a browser, silently wrong and never raising.Full response adds six fields, two of which you should not trust:| Field | Type | Note |
|---|
quoteType | string | Quote classification |
exchangeRateBid | string | Bid side of the raw rate |
exchangeRateAsk | string | Ask side of the raw rate |
fxSellAmount | string | Sell amount on the FX leg |
isTradable | boolean | Unreliable on this path. The legacy pricing code that builds this response has its tradable assignment commented out entirely, so it always defaults to false here regardless of the real state. It is only meaningfully populated on a separate, feature-flagged pricing path this tool does not use |
openTime | string | Never populated by either code path this tool can reach |
Those two are excluded from the compact response deliberately, not merely
left unadded: surfacing isTradable would show a misleading "not tradable"
for the common case.create_fx_quote#
Create FX Quote · not read-only, not destructive · touches an external
systemMints a real, short-lived FX quote and reserves a settlement draft for it.
Called by the FX quote card's own "Get Quote" button after get_fx_quote
has rendered the empty card; do not call this directly, call get_fx_quote
first.Unlike get_fx_quote's display step, this is a genuine write: every call
persists a real quote and calls a live external rate provider.How long a quote lasts is decided by the provider that priced it, and
it can vary by roughly a factor of five between currency pairs. The window
this server requests (MIN_2, the longest option the API's own
FXValidityPeriod enum offers) is only a fallback that applies when a
provider declares no expiry of its own; a provider that returns its own
valid_until overrides it in either direction. In practice the two live
providers sit at opposite ends: one pins its quotes to exactly the
requested MIN_2 window, the other returns its own valid_until
(observed around ten minutes). Which provider prices a given pair is
resolved upstream and is not exposed in the response.So the quote's own expiresAt is the only figure to trust, never the
requested window, and never a hardcoded assumption about how long a quote
lasts. Never call this speculatively or more than once for the same
request.Annotated readOnlyHint: false, destructiveHint: false, idempotentHint:
false: a genuine third state, distinct from a typical write. Additive (it
only ever creates a new quote and draft pair, never mutates or removes
anything) but not idempotent (each call mints a fresh quote and a fresh
idempotency key, even for identical arguments).Hard-refused for any session that has not declared MCP UI support --
there is no card for such a session to show a quote in, so minting one
would be a real cost (a live provider call, a persisted record) with no
legitimate use:Error: this session has not declared MCP UI support, so creating a real FX quote is refused here -- there is no card to display it in. Direct the user to the Sokin dashboard to get a live rate and execute a trade instead.
| Parameter | Type | Required | Description |
|---|
buy_currency | string | yes | ISO 4217 code being bought |
sell_currency | string | yes | ISO 4217 code being sold |
amount | string | yes | The amount of whichever side fixed_side names |
fixed_side | "BUY" or "SELL" | no | Default "SELL" |
corporate_reference | string | no | Same resolution rule as get_fx_quote |
corporate_currency_account | string | no | The account settlement would debit. Never sent to the quote-creation endpoint itself, only carried into the reserved draft for the later Settle call. When omitted, the corporate's own account in the sell currency is resolved automatically if there is exactly one match; with zero or several candidates, no account is reserved and the card renders with no Settle option |
reseller_fee | string | no | Carried into the reserved draft only, never sent to the quote-creation endpoint |
beneficiary_external_reference | string | no | Only needed if quote creation itself asks for one; some rate providers require it even though this quote never pays a beneficiary |
content, one sentence naming the quote and its real expiry:Quote QT-88213 created: 1000.00 GBP -> 1187.40 USD at rate 1.18740. Expires at 2026-08-27T14:34:00+00:00 -- settle it before then, or ask for a fresh quote once it expires.
structuredContent, flat, no data envelope:| Field | Type | Description |
|---|
quoteId | string | Quote identifier, required by settle_fx_quote |
idempotencyKey | string | Minted here, not an upstream field — the exact key settle_fx_quote requires |
currencyPair | string | Sell/buy with a separator, e.g. GBP/USD — the V2 quote domain's format. The legacy indicative-rate path in get_fx_quote returns it unseparated (GBPUSD) |
fxRate | string | The quoted rate |
fixedSide | string | Which side the amount was fixed on |
buyAmount | string | Amount bought |
buyCurrency | string | ISO 4217 |
sellAmount | string | Amount sold |
sellCurrency | string | ISO 4217 |
fxFees | string | FX fee |
createdAt | string | Normalized to UTC ISO-8601 with an explicit offset |
expiresAt | string | The real expiry, normalized the same way. The only expiry to trust; provider-set, so it may be far longer or shorter than the requested window (see above) |
corporateCurrencyAccount | string, or null | The resolved (or supplied) settlement account, or null if none could be resolved |
resellerFee | string, or null | Echoed from the request, or null |
Full response adds nothing beyond the upstream envelope's message
field. The reverse direction matters too: idempotencyKey,
corporateCurrencyAccount and resellerFee are synthesized by this tool,
not upstream fields. They are absent from the full/raw payload and only
ever appear in the compact response.Errors: a B2B API rejection (e.g. an invalid currency pair) surfaces as
a standard B2B API error. If the quote is minted upstream but the
server-side draft registration fails afterward (a store blip), this is
reported explicitly rather than as a bare failure:Error: quote QT-88213 was created but could not be tracked -- it cannot be settled through this assistant. Do not report a usable quote to the user; ask for a fresh one via get_fx_quote.
No tool-local input validation beyond the standard schema checks and the
capability refusal above.settle_fx_quote#
Settle FX Quote · destructive, idempotent · touches an external systemExecutes a previously created FX quote: moves real money between two of
the corporate's own currency accounts. Called by the FX quote card's own
Settle button after the user's explicit click; do not call this directly,
and never fabricate or reuse an idempotency key for a different quote. A
reused key returns the original conversion's outcome and ignores changed
arguments.Hard-refused for any session that has not positively declared MCP UI
support. A client that never declared support at initialize time also
will not see this tool in tools/list at all, but that omission is hygiene
only. The real enforcement is this check, which runs first, before any
upstream call:Error: this session has not declared MCP UI support, so real-money FX settlement is refused here. Direct the user to the Sokin dashboard to execute this trade instead.
Unlike the two payment pairs (create_payment_instruction/submit_payment_instruction
and create_fx_payment_instruction/submit_fx_payment_instruction), this
refusal was NOT removed by the ARC-455 review, and it is not expected to be:
FX settlement executes instantly, with no B2B portal approval step behind
it, so the widget's own Approve click really is the only human control this
pair has. server.py's UI_ONLY_TOOL_NAMES set names exactly this pair
plus create_fx_quote.| Parameter | Type | Required | Description |
|---|
idempotency_key | string | yes | The key minted by create_fx_quote for this exact quote, its idempotencyKey field |
corporate_currency_account | string | yes | Must match the quote the user reviewed |
quote_id | string | yes | Must match the quote the user reviewed |
reseller_fee | string | no | Must match the quote the user reviewed |
Arguments are checked against the draft create_fx_quote reserved under
idempotency_key. Any mismatch is refused, never silently corrected.Draft lifetime. The draft create_fx_quote registers is held for 24
hours, then the key is refused as unknown and a fresh quote is needed. That
window is deliberately much longer than a quote actually lives, for two
reasons worth knowing as an integrator:Quote expiry is not enforced by that TTL. How long a quote stays
valid is decided by the provider that priced it (see create_fx_quote),
so no local timer could get it right. Settling a quote whose window has
passed reaches a real synchronous pre-check and comes back as Error:
this quote could not be settled: <reason>, the true reason, rather than
a misleading unknown-key refusal. Read the quote's own expiresAt, and
expect the expiry answer to come from the settle call.
A replay stays answerable. A settled key returns the original
conversion's outcome for as long as the draft record lives, so a retry
after a lost response is reported rather than mistaken for a new
settlement.
While a settlement is in flight, the key is claimed for up to 10 minutes: a
second call on the same key during that time is refused as in-flight
rather than executed, and a definite rejection releases the claim
immediately so a corrected retry does not have to wait it out. Submitted
records themselves are kept with no expiry, mirroring the backend's
permanent dedupe.For an outcome check, prefer get_instruction_request over a repeat
settle_fx_quote call. It is a read, and it answers regardless of how
long ago the settlement happened.Upstream deduplication. idempotencyKey is also sent on the wire, and
the transaction core dedupes on (idempotencyKey, instructionType,
corporateCurrencyAccount). A replayed key returns the first request and
silently ignores changed values. That is the correctness layer; the gate
described here is an availability layer in front of it, which turns a
replay into an explicit, readable answer instead of a blind repeat.What the gate does not guarantee. It constrains what can be submitted,
not who approved it. The idempotency key necessarily reaches the calling
model in create_fx_quote's structuredContent, because MCP has no
widget-only channel through which the card could hold a secret, so a model
that has seen a quote can call this tool directly rather than through the
card's Settle button. Three things stand between that and a conversion,
and all three matter: the arguments must byte-match the reviewed quote,
the session must have positively declared MCP UI support (the hard
refusal above), and the host's own confirmation prompt for destructive
tools is load-bearing, not decorative. Beyond that, this tool creates and
submits an instruction request; it never approves or completes one.
Whether a submitted request needs a further human approval step is
determined by the corporate's own approval configuration in the Sokin
platform, not by anything here. A corporate with no approval rules
configured has its instruction requests approved on creation.FX conversion submitted: instruction request IR-FX-9911 for account CCA-1002. This is a submission receipt, not confirmation the conversion completed -- check get_instruction_request for its actual outcome.
structuredContent, a data envelope, the same shape the upstream
create-instruction-request call returns:| Field | Type | Description |
|---|
data.externalReference | string | The instruction request reference. Pass to get_instruction_request for its actual outcome |
data.status | string | Initial status, e.g. "Created" |
Full response adds the upstream envelope's message field back.| Condition | Message |
|---|
| Session not MCP-UI-capable | Error: this session has not declared MCP UI support, so real-money FX settlement is refused here. Direct the user to the Sokin dashboard to execute this trade instead. |
Empty idempotency_key | Error: idempotency_key must not be empty. |
| Unknown key (never registered, or expired) | Error: unknown idempotency key -- quotes are prepared via create_fx_quote, which mints the key and renders the review card. If a quote was lost, ask for a fresh one via get_fx_quote. |
| Arguments do not match the reserved draft | Error: these arguments do not match the quote the user reviewed under this idempotency key. Only the exact reviewed quote can be settled -- for a different conversion, get a fresh quote. |
| Another settlement for the same key is already in flight | Error: another settlement attempt for this exact quote is already being processed -- its outcome is not yet known. Do not retry immediately; check get_instruction_request shortly, or wait a moment before trying again. |
| Same key already settled | Not an error: returns the original get_instruction_request result again, prefixed "This quote was already submitted, no new conversion was created." |
| Quote fails a synchronous pre-check (e.g. expired) | Error: this quote could not be settled: <reason>. Adds Ask for a fresh quote via get_fx_quote. when the failure category is the quote itself |
| B2B API rejects with 4xx | Standard B2B API error shape. The reservation is released, so a corrected retry does not have to wait out its TTL |
| B2B API fails with 5xx, or times out | Error: the settlement submission's outcome is unknown (API error <status>: <message>) -- it may or may not have been accepted. Do not report this conversion as settled or as failed, and do not retry immediately; check the account's recent instruction requests first. The reservation is deliberately kept, not released, so a blind retry cannot double-submit |
create_fx_payment_instruction#
Create FX Payment Instruction · read-only · touches an external systemPrepares an FX payment for a human to review. It submits nothing.
Despite the name, no payment exists after this call and no money has
moved. Converts and pays a beneficiary in one motion against a quote this
tool mints for you, via POST /instruction-requests/fx-payment on submit.
This follows the same prepare/submit-with-draft-gate pattern as
create_payment_instruction, combining the account/beneficiary side of a
payment with the quote side of an FX conversion.This tool no longer takes a quote_id. Earlier it required a live
quote minted beforehand by a separate create_fx_quote call. Per Vuk's
review of ARC-455 (finding #1), that shape is gone: there was no reliable
way for the calling model to receive a quote_id minted by an earlier
create_fx_quote call back into its own context (that depends on
ui/update-model-context reaching it, which is not guaranteed for a
non-UI client), so the two-call flow just looped back on get_fx_quote.
Instead this tool now takes the raw pricing inputs directly (buy_currency,
sell_currency, amount, fixed_side, corporate_reference) and mints a
real, live quote itself, internally, via the same FX quote adapter
create_fx_quote uses (POST /fx/quotes/, the V2 quote domain). The B2B
API does not care who mints a quote or when -- it only checks
existence/expiry/corporate/sell-currency at instruction time -- so minting
it here, with the beneficiary passed straight through as the quote's
beneficiaryExternalReference, is equivalent to the old two-call flow and
removes that failure mode entirely.Minting the quote here is a genuine write, exactly like create_fx_quote:
every call to this tool persists a real quote and calls a live external
rate provider. Never call it speculatively or more than once for the same
request -- show the user a rate via get_fx_quote first if they have not
already agreed to one, and only call this once they are ready to actually
prepare the payment.What it actually does, in order: mints the quote as described above; runs
the FX-payment validate dry run (the same instructionType: FXPayment the
real create uses, carrying the account, beneficiary and the freshly-minted
quote) so the review can show server-computed fees and catch an expired
quote, an unusable beneficiary or an insufficient balance in one round
trip; resolves the source account's currency with one read-only account
lookup; mints an idempotency key server-side; and registers the normalized
draft against that key. When the calling session has declared MCP UI
support it also renders its own MCP UI embed
(ui://sokin-mcp/fx-payment-review), a review card with Approve and
Cancel. Only the user's Approve click there submits, by invoking
submit_fx_payment_instruction from inside the widget. This tool renders
its own interactive card when one is available, so it should not be
followed with the visualize tool.A closer look at the read-only badge. The tool is annotated
readOnlyHint: true, and its own source comment justifies that by saying
its "only upstream call is the validate dry-run" -- the same reasoning
create_payment_instruction uses. That comment predates the quote-mint
step above and is no longer accurate: this tool makes three upstream calls,
not one -- the FX quote mint (a genuine write: a persisted quote record and
a real call to an external rate provider, upstream of anything a human has
approved), the FX-payment validate dry run (side-effect-free), and the
account currency lookup (side-effect-free). create_fx_quote itself is
annotated readOnlyHint: false for exactly this kind of upstream write;
this tool performs the identical write internally while staying on the
read-only default. In practice this means calling create_fx_payment_instruction
always has a real, non-idempotent upstream side effect -- a live quote and
a persisted quote record -- even on a call whose dry run then fails and
produces no approvable draft at all. Treat it accordingly: never call this
speculatively, the same discipline create_fx_quote already asks for.
Its own draft-registration write is unaffected by this and remains gate
bookkeeping, not a business write against the corporate's money, same as
create_payment_instruction's.The dry run declares the same instruction type as the real create, so the
beneficiary is checked before the user is asked to approve: that it
exists, that it is not an internal beneficiary (which this instruction
type does not accept), that it can receive the quote's buy currency, and
that it passes verification of payee. A beneficiary problem is therefore
reported by this tool rather than surfacing after the Approve click.One thing this tool still cannot guarantee is that the quote survives to
settlement. The backend re-checks quote validity at the moment a human
approves the instruction in the Sokin portal, which is a human-paced wait,
so a quote valid here can still expire before then. That rejection arrives
through the instruction's status (Rejected, with a failure reason), not
as an error from either tool in this pair.MCP UI support changes only which text this tool shows you, not whether
it will run -- the identical reasoning create_payment_instruction
documents at length above. In short: no capability check refuses this
tool any more (ARC-455 finding #2), the session's declared capability only
selects the wording (widget-flow text vs. an idempotency key spelled out
for chat confirmation), and structuredContent carries idempotencyKey
either way.| Parameter | Type | Required | Description |
|---|
corporate_currency_account | string | yes | Source account reference from list_accounts |
beneficiary_reference | string | yes | The payee, from list_beneficiaries. Must already exist; there is no tool that creates beneficiaries. Passed to the quote provider (as beneficiaryExternalReference) as well as the payment itself |
buy_currency | string | yes | ISO 4217 code being bought (e.g. "USD") |
sell_currency | string | yes | ISO 4217 code being sold (e.g. "GBP") |
amount | string | yes | The amount of whichever side fixed_side names |
fixed_side | "BUY" or "SELL" | no | Which side amount refers to. Default "SELL" |
corporate_reference | string | no | The corporate to quote for. Required in practice; resolves automatically only for a single-corporate caller. Used only for minting the quote, never sent to the payment itself |
reseller_fee | string | no | Plain decimal string, zero or greater |
sender_reference | string | no | Shown to the recipient, max 18 characters |
payment_purpose | string | no | Purpose code such as ACCOUNTS_PAYABLE, case-insensitive, uppercased before sending. Same catalogue as create_payment_instruction; see the enums appendix |
content, when MCP UI is declared -- a not-submitted notice:FX payment prepared and validated -- awaiting the user's confirmation in the review widget. Nothing has been submitted; do not report this payment as made, and do not call submit_fx_payment_instruction yourself.
content, when MCP UI is not declared (or unknown) -- unlike
create_payment_instruction, submit's arguments here are NOT the same as
create's (submit needs quote_id, which is not one of create's own
parameters), so the idempotency key, the quote_id, and the real
server-computed fee numbers are all spelled out in the text -- none of it
can rely on structuredContent, which a non-UI client is not guaranteed to
show the model:FX payment prepared and validated -- nothing has been submitted yet. Total debit 100.00 (0.50 Sokin fee, 0.00 reseller fee); beneficiary would receive 125.00 (GBP debited, beneficiary receives USD). idempotency key: 9c21ab4f-... . quote_id: FXQ-1a2b3c... . Read these figures back to the user and get their explicit approval. Only after they approve, call submit_fx_payment_instruction with this idempotency_key, this quote_id, and the same corporate_currency_account, beneficiary_reference, reseller_fee, sender_reference, and payment_purpose you used here -- it does not take buy_currency, sell_currency, amount, fixed_side, or corporate_reference; those only mint the quote. Never submit without that approval, and never tell the user the payment was made until submit_fx_payment_instruction succeeds -- it may still need approval in the Sokin portal afterward.
When the underlying dry run fails outright (either branch, same text):The FX payment could not be validated: <reason>. Nothing has been submitted.
When the quote itself has expired or is unknown by the time the dry run
runs (the validate response's errorCategory is "quote" -- a defensive
branch, since the quote was only just minted by this same call, not the
expected path):Error: the quote minted for this request is no longer valid (<reason>). Call create_fx_payment_instruction again to mint a fresh one. Nothing has been submitted.
Despite the leading word Error:, none of the three texts above set
isError: true -- they are reports from the validation dry run, delivered
through the normal (non-error) result channel, exactly like
create_payment_instruction's own validation-failure text. See the top-level
Errors section's note on this.structuredContent: this tool builds its own payload, no
{"data": ...} envelope:| Field | Type | Description |
|---|
draft | object | The normalized FX payment exactly as it would be submitted: corporate_currency_account, beneficiary_reference, quote_id, reseller_fee, sender_reference, payment_purpose. No amount, buy_currency, sell_currency, or fixed_side field -- the ledger legs derive entirely from the quote, and the pricing inputs exist only to mint that quote, never to submit the payment. quote_id here is the id of the quote THIS call minted for you, not one you supplied -- pass it through unchanged to submit_fx_payment_instruction |
currency | string, or null | The source account's currency, for the card's debit and fee rows. Null when the account lookup failed; display only, it never blocks a valid draft |
buyCurrency | string | The beneficiary's currency (this call's own buy_currency parameter, normalized), labelling what the beneficiary actually receives. Always present, unlike currency -- there is no lookup that can fail |
quoteExpiresAt | string, or null | The minted quote's normalized expiry timestamp, straight from the adapter -- drives the card's countdown and disables Approve once it passes. Null only if the upstream response omitted it |
quoteCreatedAt | string, or null | The minted quote's normalized creation timestamp, paired with quoteExpiresAt for the countdown's total span |
validation | object | The FX-payment validate dry-run verdict (instructionType: FXPayment), same envelope shape as validate_payment_instruction's, including a resolved feeBreakdown |
idempotencyKey | string, or null | Server-minted, registered to this exact draft. Null when validation failed |
duplicateWarning | object | Present only when an identical draft was already submitted: instructionRequestReference and submittedAt |
duplicateWarning is not an error, with the same semantics as
create_payment_instruction's: when a widget is present it renders an
already-submitted card with no Approve button either way, and a deliberate
second identical payment needs a distinguishing sender_reference.Full response adds: nothing. This tool assembles its own payload.Errors: input validation (blank corporate_currency_account or
beneficiary_reference, a malformed reseller_fee, an over-length
sender_reference) is checked and rejected with isError: true before
the quote is minted, deliberately -- a blank reference or a malformed fee
must never burn a real, provider-priced quote's validity window for
nothing. A B2B API rejection while minting the quote itself (an invalid
currency pair, for instance) surfaces as a standard B2B API error. Unlike
create_fx_quote, a failure registering the draft after the quote has
already been minted (a store blip) is not given its own explicit message
here -- it falls through to the generic unexpected-failure fallback, so a
real, live, unexpired quote can in that rare case be reported to the caller
as an opaque failure with no reference to hold onto.Draft lifetime: pending drafts expire after 24 hours; submitted records
are kept with no expiry, mirroring the backend's permanent dedupe. This
draft has its own namespace in the server's session store, separate from a
plain payment's draft, so an FX payment and a plain payment can never
collide on the same idempotency key.submit_fx_payment_instruction#
Submit FX Payment Instruction · destructive, idempotent · touches an
external systemSubmits a prepared FX payment. When the calling session declared MCP UI
support, this tool is invoked by the FX payment review widget after the
user's explicit Approve, and is not meant to be called directly in that
case. When it did not, create_fx_payment_instruction's own text hands
back the idempotency key and instructs the calling model to get the user's
explicit approval in chat first -- calling this tool directly at that point
is the intended path, not a bypass; there is no separate capability check
here that would refuse it. Either way, always call
create_fx_payment_instruction first -- it mints the quote and the key
this tool requires, and renders the widget when one is available.Moves real money: converts and pays a beneficiary in one motion via POST
/instruction-requests/fx-payment, and cannot be cancelled once submitted.
Only accepts an idempotency_key minted by create_fx_payment_instruction,
and only with arguments identical to the draft the user reviewed. A reused
key with the same draft returns the original payment's state; a reused key
with different values is refused.| Parameter | Type | Required | Description |
|---|
idempotency_key | string | yes | The key minted by create_fx_payment_instruction for this exact draft |
corporate_currency_account | string | yes | Must match the reviewed draft |
beneficiary_reference | string | yes | Must match the reviewed draft |
quote_id | string | yes | Must match the reviewed draft -- this is the id of the quote create_fx_payment_instruction minted for you, from its draft.quote_id field, not a value you choose |
reseller_fee | string | no | Must match the reviewed draft |
sender_reference | string | no | Must match the reviewed draft |
payment_purpose | string | no | Must match the reviewed draft |
FX payment submitted: instruction request IR-FXPayment-77 from CCA-1 to BEN-1. This is an instruction-request reference, not a completed payment -- depending on the corporate's approval rules it may now be awaiting a human approval in the Sokin portal before it is processed. Check the outcome via get_instruction_request.
The approval clause is deliberately conditional. Whether an instruction
needs a human approval depends on the corporate's approval rules, and a
corporate with none configured has it approved on creation — so asserting
that approval is pending would be as wrong as omitting the possibility. The
review card, when one is present, tracks whichever path this instruction
actually takes and renders the portal-approval step only when the backend
reports it; a non-UI client gets the same information from
get_instruction_request.structuredContent, {"data": {"externalReference": ...}} on a fresh
submission (the same single-field shape submit_payment_instruction
returns, from the create endpoint's own response model, which carries only
externalReference); on a replay of an already-submitted draft, the same
get_instruction_request shape (externalReference, status,
createdInstructionReference, failureReason, errorCategory) instead of
re-posting.Full response adds: the envelope's message field on a fresh
submission; nothing further on the replay branch, which already passes
message through in compact mode.A malformed upstream response is a hard error, the same discipline as
submit_payment_instruction: a 202 whose body lacks externalReference
is rejected as a synthesized 502 reading "API returned an unexpected
response shape for the FX payment submission -- whether it was accepted is
unknown; check get_instruction_request before retrying." rather than being
rendered as a false success.Tool-specific errors: the same draft-gate outcome set
submit_payment_instruction lists (unknown key, submitted-draft mismatch,
pending-draft mismatch, duplicate blocked in both its in-flight and
already-submitted variants), over this pair's own draft namespace, plus the
same pre-POST client-acquisition release guard: a failure acquiring the
upstream client between the gate's reservation and the real POST is a
definite non-submission and releases the reservation, gated on the outcome
having actually been a clear-to-submit verdict so a stray release can never
delete a live duplicate block for a payment that already exists.list_beneficiaries#
List Beneficiaries · read-only · touches an external systemSearches and browses existing payees. Its primary use is checking whether a
payee already exists before creating one: there is no update endpoint and
no delete tool on this server, so this check is the only way to avoid
duplicates.| Parameter | Type | Required | Description |
|---|
corporate_reference | string | no | The corporate to list for. Required in practice |
search | string | no | Text to match against the display name. Omit to list all |
search_mode | "contains" or "starts_with" | no | How search matches. Default "contains" |
limit | integer | no | Max per page |
next_token | string | no | Cursor from the previous page |
page | integer | no | Not supported; accepted only to reject it clearly |
offset | integer | no | Not supported; accepted only to reject it clearly |
content, a markdown table:| Reference | Name | Type | Country | Currencies (rails) | Status |
|---|
| BEN-3301 | Fabrikam GmbH | company | DE | EUR (sepa/swift) | active |
| BEN-3302 | Jane Okafor | individual | GB | GBP (faster_payments) | active |
structuredContent, envelope {"data": [...], "pagination": {...}},
each item:| Field | Type | Description |
|---|
externalReference | string | The beneficiary reference |
displayName | string | Display name |
entityType | string | individual or company |
countryCode | string | ISO country code |
destinations | array | Each entry {currencyCode, paymentRail} |
status | string | active or deleted |
Each distinct (currency, rail) pair is kept rather than deduped to a bare
currency list. A beneficiary reachable in GBP over both faster_payments
and swift differs in settlement speed and cost, and collapsing that would
lose the distinction.Full response adds: isInternal (whether this is a Sokin-to-Sokin
beneficiary), supportedRails, createdAt, version, the individual and
company detail objects, address, and the complete destination payloads
(routing codes, account numbers, financial institution details,
validationStatus, autodepositEnrolled). It also adds the envelope's
message field.Error: page/offset pagination is not supported here. Use limit and next_token instead.
get_beneficiary#
Get Beneficiary · read-only · touches an external systemOne payee's full details by beneficiary id. Where list_beneficiaries
collapses each payee to its (currency, rail) pairs, this returns every
destination's actual account details — account numbers, routing/institution
data, e-Transfer email, crypto wallet address, or target corporate reference —
plus each destination's validationStatus.| Parameter | Type | Required | Description |
|---|
beneficiary_id | string | yes | The payee's externalReference from list_beneficiaries |
corporate_reference | string | no | The corporate this payee belongs to. Required in practice |
content — a header line, a destinations table, and the creation date:Acme Ltd -- company, GB, active| Currency | Rail | Validation | Account |
|---|
| GBP | faster_payments | matched | 12345678 |
| CAD | e_transfer | not_validated | payee@example.com |
Created 2026-01-01T00:00:00Z.A payee with status deleted gets an explicit trailing line: it cannot
receive payments.structuredContent — envelope {"data": {...}}:| Field | Type | Description |
|---|
externalReference | string | The beneficiary reference |
displayName | string | Display name |
entityType | string | individual or company |
countryCode | string | ISO country code |
status | string | active or deleted |
isInternal | boolean | Whether this is a Sokin-to-Sokin payee |
destinations | array | Full per-method payloads: currencyCode, paymentRail, validationStatus, and the method's own account fields. Bank-account destinations carry their stored snake_case keys (e.g. account_number, institution); e-Transfer and crypto destinations use camelCase |
individual / company | object | Identity details — only the one matching entityType, and only when set |
address | object | Payee address, when set |
createdAt | string | Creation timestamp |
Two upstream fields never reach the compact response: supportedRails,
because the details endpoint always returns it as [] (rail availability
lives in destinations[].paymentRail instead), and version, an internal
concurrency counter. For an e-Transfer destination without auto-deposit the
security question is returned but the security answer never is — it
is what the recipient must give to claim the transfer.Full response adds: the envelope's message, supportedRails (always
empty here), version, and the raw destination payloads including the
e-Transfer security answer.Error: beneficiary_id must not be empty.
Unknown beneficiary id: 'ben_missing'. Find ids via list_beneficiaries (the Reference column).
Error: Corporate not found -- check corporate_reference (the value returned by list_corporates). — the upstream route 404s for a bad corporate too (corporate resolution runs before the beneficiary lookup), and the tool tells the two apart by the API's message rather than blaming the id for both.
get_beneficiary_schema#
Get Beneficiary Schema · read-only · touches an external systemThe fields a new payee needs for a given payment method, currency, and country
— names, types, requiredness, and allowed values — for collecting details
before creating a payee. Field names are dot-paths into the beneficiary
payload (e.g. address.city, bankAccount.accountNumber).Payment method here is a different taxonomy from the payment rails
shown in list_beneficiaries destinations. Every rail belongs to exactly one
method: e_transfer → e_transfer, internal → sokin_internal, pix →
virtual_account, the on-chain networks (base, tron, ethereum,
polygon, solana) → crypto, and every other rail (faster_payments,
sepa, swift, ach, eft, …) → bank_account.| Parameter | Type | Required | Description |
|---|
payment_method | "bank_account" | "e_transfer" | "virtual_account" | "sokin_internal" | "crypto" | yes | See the rail-to-method mapping above. "crypto" is always refused locally — the upstream endpoint cannot describe crypto payees yet (its currency validation caps currencyCode at 3 characters; the only crypto currencies are the 4-character stablecoin codes USDC/USDT) |
currency_code | string | unless sokin_internal | ISO 4217 currency the payee is paid in |
country_code | string | unless sokin_internal | ISO 3166-1 alpha-2 country of the payee's account |
entity_type | "individual" | "company" | unless sokin_internal | Payee entity type |
autodeposit_enrolled | "true" | "false" | "unknown" | no | e-Transfer only: whether the recipient email has Interac auto-deposit. Passing "false" or "unknown" makes the security question/answer fields required; omitting the parameter behaves like "true" (they stay optional), so pass "unknown" rather than omitting when enrolment hasn't been checked |
corporate_reference | string | no | The corporate the payee will be created under. Required in practice |
content — a markdown table:Beneficiary schema for bank_account / GBP / GB / company -- 4 fields:| Field | Type | Required | Notes |
|---|
| displayName | string | yes | length 1-100 |
| bankAccount.accountNumber | string | only for faster_payments | |
| entityType | enum | yes | one of: individual, company |
| eTransfer.securityQuestion | string | no | conditional on eTransfer.email |
A field marked "only for rails" is required just for those payment rails; a
field marked "conditional on X" only applies once X is answered.structuredContent — envelope {"data": {"condition": {...}, "fields": [...]}}.
condition echoes the resolved query context (currencyCode, entityType,
countryCode, paymentMethod). Each field object always carries name,
fieldType (string / integer / boolean / date / enum), and
isRequired (a non-nullable boolean upstream, default true — never absent),
plus — only when set, the upstream route excludes nulls — min, max,
allowedValues, requiredForPaymentRails, conditions, visibility,
conditionalOn, revalidateOn, fixedValue.Full response adds: the envelope's message, and regional — which the
upstream processor never populates on this endpoint today.Error: currency_code, country_code, entity_type required for payment_method 'bank_account' (only sokin_internal can omit them). — mirrors the API's own rule; for sokin_internal the API defaults the entity type to company.
Error: the beneficiary schema endpoint cannot describe crypto payees yet (the API's currency validation rejects the 4-character stablecoin codes). A crypto destination takes currency_code USDC or USDT, payment_rail set to the on-chain network, and the wallet address as the account number. — refused locally because every crypto request would dead-end in an upstream 400.
A combination the corporate has no payment rails for is rejected upstream:
API error (400): No payment rails for GBP+US and bank_account.list_financial_institutions#
List Financial Institutions · read-only · touches an external systemReference data listing institutions available for beneficiary creation
(e.g. Canadian EFT). Not corporate-scoped, not paginated, not filterable:
the upstream endpoint accepts no parameters, so neither does this tool.content, a markdown table:2 financial institutions:| Id | Name | Bank Number |
|---|
| 1 | Royal Bank of Canada | 003 |
| 2 | Toronto-Dominion Bank | 004 |
structuredContent, envelope {"message": "Success.", "data": [...]},
each item:| Field | Type | Description |
|---|
id | integer | Institution id, a plain integer |
name | string | Institution name |
bankNumber | string | Institution's bank number |
Full response adds: nothing. Compact is the identity of raw.This list is a live proxy to a third-party provider that silently drops
any record failing validation, so a real, fetchable institution can be
absent from it. Do not treat it as an exhaustive allow-list.list_financial_institution_branches#
List Financial Institution Branches · read-only · touches an external
systemLists an institution's branches (routing numbers and addresses) for
completing a beneficiary. Cursor-paginated.| Parameter | Type | Required | Description |
|---|
financial_institution_id | integer or string | yes | The id from list_financial_institutions. Either type works; pass it through as is |
limit | integer | no | Max branches per page |
next_token | string | no | Cursor from the previous page |
content, a markdown table:| Id | Description | Routing Number | Address | City | State | Postal Code |
|---|
| 4412 | Main Branch | 000312345 | 200 Bay Street, Suite 400 | Toronto | ON | M5J2J2 |
structuredContent, envelope {"data": [...], "pagination": {...}},
each item carrying id, description, routingNumber, addressLineOne,
addressLineTwo, city, state, postalCode.Full response adds: the envelope's message field only.Unknown financial institution id: '999'. Call list_financial_institutions to see valid ids.
That check runs only on an empty result, never before attempting the
branches call. The branches endpoint has no not-found signal of its own; an
unrecognized id returns an empty list, indistinguishable from an
institution that genuinely has no branches. Because the institutions list
can be missing real records (see above), gating upfront would reject a real
institution's real branches whenever that institution happened to be one of
the dropped ones.get_documentation_links#
Get Documentation Links · read-only · touches no external systemReturns the URLs of Sokin's own documentation, so an agent can read the
real integration contract instead of guessing an endpoint or an API
version from memory. It returns pointers only, never documentation
content itself.Answers are specific to the deployment you are connected to, so a client on
UAT can tell it is being handed UAT's documentation.| Parameter | Type | Required | Description |
|---|
topic | "b2b_api", "mcp_server", or "all" | no | Which documentation to look up. Default "all" |
b2b_api: the Sokin B2B REST API this server calls: endpoints, auth,
API versions, request/response schemas.
mcp_server: this MCP server itself: its tools, their returns, and
how to connect. Not published on most deployments yet; the response says
so and points back at the tool list the connection already provides.
all: every topic. The default, and the right choice when unsure.
content, a header naming the deployment, a markdown table, then any
applicable notes:| Documentation | URL | What's there |
|---|
| Sokin B2B API reference | https://docs.sokin.com/llms.txt | The Sokin B2B REST API this MCP server calls: every endpoint, authentication, API versions, and request/response schemas |
| Sokin MCP server reference | (none available) | No published reference yet -- every tool on this server is self-describing, so read the tool list and tool descriptions you already have from this connection |
A topic with nothing configured renders as (none available) rather than
being omitted, and its summary column carries the reason: the row is
always there.structuredContent, flat, no data envelope:| Field | Type | Description |
|---|
mcpServerUrl | string | Public base URL of the deployment that answered, so UAT and production docs are distinguishable without inferring it from the URLs |
documentation | array | One record per requested topic, in a stable order |
Every documentation record has the same shape, so it can be iterated
without special-casing per topic:| Field | Type | Description |
|---|
topic | string | b2b_api or mcp_server |
title | string | Human-readable name of the reference |
url | string, or null | Where to read it. Null when nothing is configured for that topic |
alternateUrl | string, or null | A genuinely different second URL, when one exists; typically the human-readable page when url is an agent-oriented index |
summary | string | One line on what lives there, or on why there is no URL |
{
"mcpServerUrl": "https://mcp.uat.sokin.com",
"documentation": [
{
"topic": "b2b_api",
"title": "Sokin B2B API reference",
"url": "https://docs.sokin.com/llms.txt",
"alternateUrl": "https://docs.sokin.com",
"summary": "The Sokin B2B REST API this MCP server calls: every endpoint, authentication, API versions, and request/response schemas"
},
{
"topic": "mcp_server",
"title": "Sokin MCP server reference",
"url": null,
"alternateUrl": null,
"summary": "No published reference yet -- every tool on this server is self-describing, so read the tool list and tool descriptions you already have from this connection"
}
]
}
Where both an agent index and a human page are configured for b2b_api,
the index wins as url and the human page becomes alternateUrl. The
index is a flat list of markdown links; the human page is a client-rendered
application an agent gets little from fetching.Full response adds: nothing. There is no API call behind this tool, so
compact and raw are the same object by design.visualize#
Visualize · read-only · touches no external systemRenders Mermaid diagrams and rich markdown inside the MCP client via its
own UI embed (ui://sokin-mcp/visualize). It is a diagramming tool, not a
prettifier: the caller writes the markdown, and this only renders it.| Parameter | Type | Required | Description |
|---|
markdown | string | yes | Full CommonMark + GFM. Fenced blocks tagged mermaid render as diagrams. Raw HTML is sanitized, not rendered |
title | string | no | The card's header. Defaults to a generic heading |
Practical constraints worth knowing when generating content for it: card
width is fixed at roughly 600px, height is unconstrained. Prefer
top-to-bottom diagrams (TD, not LR), keep labels short, and keep any
single diagram to roughly 10 to 15 nodes. Do not include a %%{init:
...}%% theme directive; the card applies its own brand theme and yours
would clash.content, the markdown string, echoed verbatim.structuredContent, flat, no envelope:| Field | Type | Description |
|---|
title | string | The title as passed |
markdown | string | The markdown as passed |
Full response adds: nothing; this tool calls no API, so there is no
raw payload to widen.markdown must not be empty.
play_cheetah_dash#
Play Cheetah Dash · read-only · touches no external systemLaunches an endless-runner arcade game rendered inline via its own UI embed
(ui://sokin-mcp/cheetah-dash). It takes no arguments and calls no Sokin
API.Cheetah Dash is ready -- use Space or tap to jump, again mid-air to double jump.
structuredContent: none. This is the only tool on this server that
returns a bare string rather than going through the compact/full
machinery, so it is also the only tool whose success result carries no
structuredContent at all.Appendix: enumerations#
Every closed value set an integrator will encounter. All of these are read
from the Sokin B2B API's own source definitions, not inferred from observed
responses: a value set you have not seen in testing is still one your code
should handle.Instruction types#
instructionType on list_instructions and get_instruction, the B2B
API's InstructionType enum (8 values):| Value | Meaning |
|---|
Payment | Moves funds from a CCA to a beneficiary |
FX | Moves funds between two CCAs owned by the same corporate, in different currencies |
FXPayment | Moves funds from a CCA to a beneficiary in a different currency |
UnfundedPayment | Funds may land in the account after the instruction is created, before sending |
UnfundedFXPayment | The unfunded variant of FXPayment |
SokinDirect | Same-currency transfer between two on-platform CCAs, at zero fees |
BoostDeposit | Moves funds from a CCA, subject to treasury approval before completion |
ForwardSettlement | Settles a Forward Contract at the contracted forward rate |
Instruction display statuses#
displayStatus, the user-facing status, present in the compact response.
The values contain spaces and are shown exactly as they appear (11 values):Pending finalisation, Approval Initiated, Approval Completed,
Pending Partner, Booking FX Trade, Pending, Pending FX Settlement,
Pending Settlement, Processed, Failed, Unknown.Instruction internal statuses (full response only)#
status is dropped from the compact response and appears only when the
full-response opt-in is used. The API's own field description calls it the
internal status, though that description is known to omit several real
members; the full enum (14 values) is:Created, ApprovalInitiated, ApprovalCompleted, Processed, Failed,
PaymentInstructed, FXTradeBooked, OutboundComplianceCompleted,
InboundComplianceCompleted, LiquidityConfirmed, FXTradeSettled,
PaymentSettled, BoostApprovalRequested, BoostApprovalCompleted.Prefer displayStatus. These values track internal workflow transitions
and are not a stable public contract.Instruction request statuses#
status on get_instruction_request, the B2B API's
InstructionRequestStatus enum. Exactly 3 values, no more:Created, Accepted, Rejected.The naming is counter-intuitive and worth restating: Created means still
processing, Accepted means the instruction now exists, Rejected means
it never will. Render an unrecognized value verbatim rather than mapping it
onto one of these three.errorCategory alongside it, and on validate_payment_instruction, is
backed upstream by a real closed enum, InstructionRequestErrorCategory
(10 members): fees, amount, amount_missing_funds,
corporate_currency_account, quote, beneficiary_reference,
reseller_reference, reseller_fee, payment_details,
corporate_reference. On the wire, though, both response models declare
this field as a plain optional string, not the enum itself, so treat it as
a documented, currently-closed set rather than a contract you can rely on
never growing a new member.Ledger entry type and direction#
On get_account_ledger entries:direction takes only two values in practice, credit or debit
(populated upstream as item.type.lower() of a Credit/Debit database
enum), though it is typed as a plain string on the wire rather than an
enforced enum.type is not a short closed set, and this is the field most likely to
break a naive integration. Upstream, only four internal item types collapse
to two friendly labels; everything else is emitted under its own raw
snake_case value. The full set you can receive (11 values):| Value | Notes |
|---|
deposit | Funds in |
withdrawal | Funds out |
FX Trade | Friendly label covering both fx_trade_in and fx_trade_out legs |
Forward settlement | Friendly label covering both forward_settlement_in and forward_settlement_out legs |
sokin_direct_send | On-platform CCA-to-CCA transfer, outgoing leg |
sokin_direct_receive | On-platform CCA-to-CCA transfer, incoming leg |
boost_deposit | Move into a Boost position |
boost_maturity_payout | Boost payout at maturity |
manual_adjustment_fraud_recall | Operational adjustment |
manual_adjustment_account_offboarding | Operational adjustment |
manual_adjustment_good_will | Operational adjustment |
Do not switch-case on deposit/withdrawal/FX Trade alone: the
operational and Sokin Direct values are real values on real accounts, and
new item types can be added upstream over time. Treat an unrecognized
type as displayable rather than an error.The content table signs amounts from direction (+ for credit, - for
debit), while structuredContent.amount stays unsigned either way.Account locality statuses#
localAccountStatus and internationalAccountStatus on get_account, the
B2B API's CCALocalityStatus enum. Exactly 4 values, no others:| Value | Meaning |
|---|
ACTIVE | The account is active and ready to receive payments |
PENDING | Not active yet; still processing, or awaiting operational action |
NOT_PROVISIONED | Can be set up, but account setup or terms acceptance is incomplete |
NOT_AVAILABLE | Pay-in over this route is unavailable for this account. Also the default value when the field is otherwise unset |
Beneficiary entity type and status#
| Field | Values |
|---|
entityType | individual, company |
status | active, deleted |
Payment rails#
paymentRail on a beneficiary destination, the B2B API's PaymentRail
enum (20 values total):| Group | Values |
|---|
| Internal | internal |
| International | swift |
| Europe | sepa |
| United Kingdom | faster_payments, bacs, chaps |
| United States | ach, fedwire, fednow |
| Canada | eft, e_transfer |
| Asia-Pacific | fast_meps, npp |
| Middle East | fts_ipi |
| Latin America | pix |
| Crypto networks | base, tron, ethereum, polygon, solana |
For crypto destinations the rail is the on-chain network itself.Corporate assignment types#
assignmentType on list_corporates: direct or inherited.Payment purposes#
payment_purpose on validate_payment_instruction,
create_payment_instruction, and create_fx_payment_instruction is
validated upstream against a catalogue, and which catalogue applies is
feature-flagged per reseller or corporate. Values are case-insensitive; the
tools uppercase before sending. When omitted entirely, the create endpoints
default it to ACCOUNTS_PAYABLE server-side.The Sokin catalogue, which is the modern set:ACCOUNTS_PAYABLE, COMMISSION, CUSTOMER_REFUND, CUSTOMS_DUTIES,
DIVIDEND_DISTRIBUTION, EXPENSE_REIMBURSEMENT, FREELANCER_CONTRACTOR,
INSURANCE_PREMIUM, INTERCOMPANY_TRANSFER, LOAN_REPAYMENT,
MARKETING_ADVERTISING, OFFICE_LEASE, OTHER, PROFESSIONAL_SERVICES,
ROYALTY_LICENSE_FEE, SALARY_PAYROLL, SHIPPING_FREIGHT_LOGISTICS,
SOFTWARE_SUBSCRIPTIONS, SUPPLIER_PAYMENT, TAX_PAYMENT,
TRAVEL_EXPENSE, UTILITIES.Callers on the legacy catalogue see a much larger set, roughly 160 values
(the full upstream PaymentPurpose enum), largely Canadian
clearing-system purpose codes such as PAYROLL_DEPOSIT,
RESIDENTIAL_MORTGAGE, and COMMERCIAL_BILL_PAYMENT. ACCOUNTS_PAYABLE
is the only value present in both catalogues, which makes it the safe
default when you do not know which applies. Rather than hardcoding either
list, send a purpose and handle rejection.Request parameter enums#
Closed sets on tool inputs. An out-of-range value is rejected by schema
validation before the tool body runs:| Parameter | Tool | Values |
|---|
fixed_side | get_fx_quote, create_fx_quote, create_fx_payment_instruction | BUY, SELL (default SELL) |
search_mode | list_beneficiaries | contains, starts_with (default contains) |
topic | get_documentation_links | b2b_api, mcp_server, all (default all) |
Modified at 2026-09-16 11:05:41