Use an MCP server's tools
This guide registers an MCP server with the runtime, so your agents and flows call its tools like your own.
An MCP server you run, or one a vendor runs, can give your agents its tools.
Register its endpoint with the runtime. Kindgi lists the server's tools and
registers each one as a tool of your tenant, named
<endpoint id>.<tool name>. An endpoint id can't contain ., so that
name splits one way only; a tool name can. Agents and flow steps then call
it like a tool of your pack.
Before you start
Section titled “Before you start”- A runtime, such as
kindgi dev, with Kindgi 0.1's CLI. KINDGI_API_URLandKINDGI_API_TOKENset in your shell: your runtime's URL and its API token.kindgi devprints both.uv, to run the example server.- An agent and a model that calls tools, to call the server's tools.
This guide uses
my-pack.order-desk.
A server to try
Section titled “A server to try”An MCP server that speaks Streamable HTTP works. Ask your coding agent: "I want a small MCP server to try, with a tool that checks stock levels." For reference, this one, written with the Python MCP SDK, version 1.x, has two tools:
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("inventory", host="127.0.0.1", port=8791)
STOCK = {"SKU-1": 12, "SKU-2": 0}
@mcp.tool()def stock_level(sku: str) -> dict: """Returns how many units of a SKU are in stock.""" return {"sku": sku, "units": STOCK.get(sku, 0)}
@mcp.tool()def reserve(sku: str, units: int) -> dict: """Reserves units of a SKU for an order.""" return {"sku": sku, "reserved": units}
if __name__ == "__main__": mcp.run(transport="streamable-http")Run it from the folder that holds server.py:
uv init --bare --no-workspaceuv add 'mcp<2'uv run python server.pyuv init --bare --no-workspacemakes the folder a project of its own, with no sample files.uv add 'mcp<2'adds the MCP SDK, version 1.x.
uv init prints Initialized project with the folder's name, and uv add
prints the packages it installed. The server then prints:
INFO: Started server process [52998]INFO: Waiting for application startup.StreamableHTTP session manager startedINFO: Application startup complete.INFO: Uvicorn running on http://127.0.0.1:8791 (Press CTRL+C to quit)It keeps running, and serves at http://localhost:8791/mcp. Under
kindgi dev, the runtime reaches your machine's localhost.
Register the endpoint
Section titled “Register the endpoint”Registering the endpoint makes its tools your tenant's. Register it from
your app's code. Ask your coding agent: "Register my inventory MCP server
with the Kindgi runtime, so my agents can use its tools." For reference, the
code looks like this. This guide's endpoint id is inventory:
import { createClient } from '@kindgi/sdk/client';import type { TenantId } from '@kindgi/sdk/types';
const kindgi = createClient(); // KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
await kindgi.mcp.endpoints.register({ endpointId: 'inventory', name: 'Inventory', transport: 'streamable-http', config: { transport: 'streamable-http', url: 'http://localhost:8791/mcp' }, scope: { kind: 'tenant', tenantId: process.env.KINDGI_TENANT_ID as TenantId },});
const page = await kindgi.mcp.endpoints.list();console.log(page.data.map((e) => `${e.endpointId} (${e.transport})`));[ 'inventory (streamable-http)' ]KINDGI_TENANT_ID is the id of the tenant your runtime serves.
from kindgi.client import Kindgi
client = Kindgi() # KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
client.mcp.endpoints.register( { "endpointId": "inventory", "name": "Inventory", "transport": "streamable-http", "config": {"transport": "streamable-http", "url": "http://localhost:8791/mcp"}, "scopeKind": "tenant", })
for endpoint in client.mcp.endpoints.list().data: print(endpoint.endpoint_id, endpoint.transport)inventory streamable-httppackage com.acme.app;
import com.kindgi.client.Kindgi;import com.kindgi.client.models.MCPEndpoint;import com.kindgi.client.models.MCPTransport;import com.kindgi.client.models.RegisterMCPEndpointBody;import com.kindgi.client.models.ScopeKind;
public final class RegisterInventory { public static void main(String[] args) { Kindgi kindgi = Kindgi.create(); // KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
kindgi.mcp().endpoints().register(RegisterMCPEndpointBody.builder() .endpointId("inventory") .name("Inventory") .transport(MCPTransport.STREAMABLE_HTTP) .config(RegisterMCPEndpointBody.Config.StreamableHttp.builder() .url("http://localhost:8791/mcp") .build()) .scopeKind(ScopeKind.TENANT) .build());
for (MCPEndpoint endpoint : kindgi.mcp().endpoints().list().data()) { System.out.println(endpoint.endpointId() + " " + endpoint.transport()); } }}inventory streamable-httppackage acme.app
import com.kindgi.client.Kindgiimport com.kindgi.client.models.{MCPTransport, RegisterMCPEndpointBody, ScopeKind}import scala.jdk.CollectionConverters._
object RegisterInventory { def main(args: Array[String]): Unit = { val kindgi = Kindgi.create() // KINDGI_API_URL, KINDGI_API_TOKEN, or the running kindgi dev
kindgi.mcp().endpoints().register(RegisterMCPEndpointBody.builder() .endpointId("inventory") .name("Inventory") .transport(MCPTransport.STREAMABLE_HTTP) .config(RegisterMCPEndpointBody.Config.StreamableHttp.builder() .url("http://localhost:8791/mcp") .build()) .scopeKind(ScopeKind.TENANT) .build())
for (endpoint <- kindgi.mcp().endpoints().list().data.asScala) println(s"${endpoint.endpointId} ${endpoint.transport}") }}inventory streamable-httpOr register it with curl:
curl -X POST "$KINDGI_API_URL/v1/mcp/endpoints" \ -H @<(printf 'Authorization: Bearer %s\n' "$KINDGI_API_TOKEN") -H 'content-type: application/json' \ -d '{"endpointId":"inventory","name":"Inventory","transport":"streamable-http", "config":{"transport":"streamable-http","url":"http://localhost:8791/mcp"}, "scopeKind":"tenant"}'{"endpointId":"inventory"}The runtime connects, lists the server's tools and registers them. The
kindgi dev terminal says how it went:
[runtime] <time> INFO [mcp] MCP endpoint "inventory" synced — 2 published, 0 reinstated, 0 unchanged, 0 retiredThe tools are now your tenant's:
pnpm exec kindgi tools get inventory.stock_leveluv run kindgi tools get inventory.stock_level./kindgiw tools get inventory.stock_level./kindgiw tools get inventory.stock_level{ "id": "inventory.stock_level", "description": "Returns how many units of a SKU are in stock.", "version": "1.0.0", "input": { "type": "object", "title": "stock_levelArguments", "required": [ "sku" ], "properties": { "sku": { "type": "string", "title": "Sku" } } }, "output": { "type": "object", "additionalProperties": true }, "transport": "mcp", …}Kindgi registers MCP tools as version 1.0.0. A tool's input schema is
the one the server gives. When the server declares an outputSchema, the
tool's answer is the server's structured content, checked against it.
Otherwise the answer is the text, parsed as JSON when the text is JSON.
Servers built with the TypeScript MCP SDK often declare none: their echo
tool answers "Echo: hi".
Give some projects its tools
Section titled “Give some projects its tools”The endpoint's scope decides which projects' runs can call its tools.
Registered for the tenant, as above, the runs in every project can.
Register it for one org, and only the runs in that org's projects can; for
one project, only that project's runs can. In TypeScript, the scope is
{ kind: 'org', orgId: '<org-id>' } or
{ kind: 'project', projectId: '<project-id>' }. In the other languages
and the API, scopeKind is org or project, and scopeId its id.
- Registering needs admin on that scope: a tenant admin for the tenant, an admin of the org or of the project for theirs.
- An endpoint id is unique in the tenant, whatever its scope, so two
orgs' endpoints need two ids, such as
inventory-acme. - A run in another project that names one of its tools is refused with
reference-out-of-scope, as What a project's runs can use shows.
One MCP server registered for an org or the tenant gives all their projects the same tool, where a pack's own tool serves only its project (One tool for several clients).
Call its tools
Section titled “Call its tools”An agent lists an MCP tool by id, with a version range. Ask your coding agent: "Let my agent check stock levels with the inventory server's tool." For reference, it adds the tool to the agent's list:
// in agents/order-desk/index.tstools: [ { id: 'my-pack.lookup-order' as ToolId, version: '^0.1.0' }, { id: 'inventory.stock_level' as ToolId, version: '^1.0.0' },],# in agents/order_desk.pytools=[lookup_order, {"id": "inventory.stock_level", "version": "^1.0.0"}],// in src/main/java/mypack/agents/OrderDesk.java .tool(LookupOrder.TOOL) .tool("inventory.stock_level", "^1.0.0")// in src/main/scala/mypack/agents/OrderDesk.scala .tool(LookupOrder.tool) .tool("inventory.stock_level", "^1.0.0")Start a turn with a model that calls tools. Here it's Llama 3.1 on Ollama,
as Models sets up. --agent is the agent, and --input
its input:
pnpm exec kindgi runs start --agent=my-pack.order-desk --input='{"userMessage":"How many units of SKU-2 are in stock?"}'uv run kindgi runs start --agent=my-pack.order-desk --input='{"userMessage":"How many units of SKU-2 are in stock?"}'./kindgiw runs start --agent=my-pack.order-desk --input='{"userMessage":"How many units of SKU-2 are in stock?"}'./kindgiw runs start --agent=my-pack.order-desk --input='{"userMessage":"How many units of SKU-2 are in stock?"}'"appended": [ { "role": "user", "content": "How many units of SKU-2 are in stock?", … }, { "role": "agent", "content": { "text": "", "toolCalls": [{ "id": "call_v7vwbcgs", "name": "inventory.stock_level", "arguments": { "sku": "SKU-2" } }] }, … }, { "role": "tool", "content": { "sku": "SKU-2", "units": 0 }, "toolCall": { "toolId": "inventory.stock_level", "invocationId": "call_v7vwbcgs" }, … }, { "role": "agent", "content": "There are 0 units of SKU-2 in stock.", … }],A flow step names it in ref, like any tool:
{ id: 'stock', kind: 'tool', ref: 'inventory.stock_level', inputMapping: { sku: { path: 'runInput.sku' } } }.
Send a credential
Section titled “Send a credential”To send a token, ask your coding agent: "My inventory MCP server wants a
bearer token." You store the token itself, as below. For reference, it adds
secretRef to the registration.
Kindgi reads the secret when it connects, at the scope recorded when the
endpoint was registered
(Where values live),
and sends it as Authorization: Bearer <value>. For an endpoint registered
for the tenant, that's the tenant's secrets:
"secretRef": { "envName": "local", "name": "INVENTORY_TOKEN" }Under kindgi dev, local is the pack's env files. If the secret isn't
there when the endpoint is registered, no tools are registered, and the
kindgi dev terminal says why:
[runtime] 07:23:26.303 WARN [mcp] MCP endpoint "warehouse" sync failed: mcp-auth-unresolved: MCP endpoint "warehouse": its secretRef local/INVENTORY_TOKEN did not resolve: No secret "INVENTORY_TOKEN" for env "local" in .env, .env.local, .kindgi/secrets.env at /pack endpointId=warehouseStore the secret with
kindgi secrets set INVENTORY_TOKEN --env=local --scope=tenant. Then
unregister the endpoint and register it again, as
When the server changes shows.
Sign in with a user name and password
Section titled “Sign in with a user name and password”For a server that takes HTTP Basic, use auth instead of secretRef. A
WordPress site's MCP Adapter takes it with an Application Password, and so
do WooCommerce's MCP tools. Ask your coding agent: "My MCP server signs in
with a user name and password." For reference, it adds this to the
registration:
"auth": { "scheme": "basic", "username": "kindgi-agent", "secretRef": { "envName": "local", "name": "SHOP_APP_PASSWORD" }}Kindgi sends Authorization: Basic <base64 of username:password>, the password
resolved from the tenant's secrets when it connects. Give the agent its own
WordPress user, with a role that allows only what it should do, and create
the Application Password on that user's profile.
A user name or password the server turns down with a 401 fails the tool
call with mcp-auth-refused. The runtime's log names the endpoint:
20:22:48.769 WARN [mcp] MCP endpoint "shop-bad" sync failed: mcp-auth-refused: MCP endpoint "shop-bad" answered 401 to its credential endpointId=shop-badOn WordPress's side: Application Passwords, the MCP Adapter and WooCommerce's MCP tools.
Sign in with OAuth client credentials
Section titled “Sign in with OAuth client credentials”For a server behind an OAuth 2 token endpoint, such as a Drupal site's MCP Server with Simple OAuth, ask your coding agent: "My MCP server signs in with OAuth client credentials." For reference, it adds this to the registration:
"auth": { "scheme": "oauth2-client-credentials", "tokenUrl": "https://cms.example.com/oauth/token", "clientId": "kindgi", "secretRef": { "envName": "local", "name": "CMS_CLIENT_SECRET" }, "scope": "mcp_server"}Kindgi asks tokenUrl for an access token, with
grant_type=client_credentials. It sends the token as
Authorization: Bearer <token>:
-
Refreshed before it expires: a fifth of the token's lifetime early, at most a minute. A 300-second token is replaced after 240 seconds. A token answered without
expires_inis used for 60 seconds. -
The client's credentials: an
Authorization: Basicheader to the token endpoint by default."clientAuth": "client_secret_post"sendsclient_idandclient_secretin the form instead.audienceis sent when it's set. -
Never stored: tokens live in the runtime's memory only, and each runtime instance gets its own. The secret is resolved at each token request, so a rotated secret is used from the next one.
-
The token URL is https, or http to
localhost,127.0.0.1or[::1]. A token endpoint on the runtime's own machine is reached only withKINDGI_TENANT_HOST_ACCESS=local, the default inkindgi dev. A deployed runtime's default,deployed, refuses it withhost-access-denied. -
When it fails:
- a token request that's refused fails the tool call with
mcp-auth-refused, as withinvalid_clientor a scope the client may not have; - one that can't be made fails it with
mcp-auth-failed, as when the token endpoint is down or its answer is malformed.
Neither message includes the secret or a token. The runtime's log says which, and why:
20:22:48.608 WARN [mcp] MCP endpoint "cms-down" sync failed: mcp-auth-failed: MCP endpoint "cms-down": its token request to localhost:18099 failed: fetch failed (ECONNREFUSED) endpointId=cms-down20:22:49.143 WARN [mcp] MCP endpoint "cms-bad" sync failed: mcp-auth-refused: MCP endpoint "cms-bad": its token endpoint localhost:18081 refused the client (HTTP 401: invalid_client) endpointId=cms-bad - a token request that's refused fails the tool call with
-
A token the server stops taking early, with a 401: Kindgi asks for a new one and sends that request once more.
On Drupal's side: the MCP Server module, and Simple OAuth for the token endpoint: a consumer with the client credentials grant, and the user its tokens act as.
Send a key in a header of the server's own
Section titled “Send a key in a header of the server's own”For a server that takes an API key in a header such as X-Api-Key, a key and
a secret in two headers, or Authorization with a prefix other than
Bearer , use scheme: "header". Ask your coding agent, for example: "My
MCP server wants an API key and a secret, each in a header of its own." For
reference, it adds this to the registration:
"auth": { "scheme": "header", "headers": [ { "name": "X-Api-Key", "secretRef": { "envName": "local", "name": "INVENTORY_KEY" } }, { "name": "X-Api-Secret", "prefix": "Secret ", "secretRef": { "envName": "local", "name": "INVENTORY_SECRET" } } ]}Kindgi sends each header as <name>: <prefix><secret> on every request, the
secrets resolved from the tenant's secrets when it connects. Reading the
endpoint back shows the references only:
"auth":{"scheme":"header","headers":[{"name":"X-Api-Key","secretRef":{"name":"INVENTORY_KEY","envName":"local"}},{"name":"X-Api-Secret","prefix":"Secret ","secretRef":{"name":"INVENTORY_SECRET","envName":"local"}}]}-
1 to 4 headers, each name once (case doesn't matter).
prefixis optional (Token,ApiKey, …). It's not a secret: it's stored and read back as given. -
Not a header the transport sets:
Host,Content-Type,Accept,Mcp-Session-Id,traceparentand the like are refused (invalid-auth):{"error":{"code":"invalid-mcp-endpoint","message":"endpoint \"inventory-reserved\" auth.headers[0].name \"Mcp-Session-Id\" is a header the MCP transport sets itself","details":{"reason":"invalid-auth"},…}}Excerpt -
A key the server turns down (it answers 401) fails the tool call with
mcp-auth-refused, as a wrong Basic password does. -
A secret a header can't carry, such as a value pasted with a line break inside it, fails with
mcp-auth-unresolved. The log names the header, never the value:23:53:08.218 WARN [mcp] MCP endpoint "inventory-pasted" sync failed: mcp-auth-unresolved: MCP endpoint "inventory-pasted": its credential for the header "X-Api-Key" has a line break, which a header can't carry: store the secret without it endpointId=inventory-pasted
A secret that isn't set
Section titled “A secret that isn't set”With any scheme, a secret that isn't set fails the tool call with
mcp-auth-unresolved. The log names the secret and where it looked, never a
value:
21:02:11.089 WARN [mcp] MCP endpoint "cms-nosecret" sync failed: mcp-auth-unresolved: MCP endpoint "cms-nosecret": its auth client secret local/CMS_SECRET_NEVER_SET did not resolve: No secret "CMS_SECRET_NEVER_SET" for env "local" in .env, .env.local, .kindgi/secrets.env at … endpointId=cms-nosecretSigned in, but no tools
Section titled “Signed in, but no tools”A sync that signed in says what it found:
21:00:51.837 INFO [mcp] MCP endpoint "shop" synced — 3 published, 0 reinstated, 0 unchanged, 0 retired endpointId=shop published=3 reinstated=0 unchanged=0 retired=021:00:52.218 INFO [mcp] MCP endpoint "cms" synced — 1 published, 0 reinstated, 0 unchanged, 0 retired endpointId=cms published=1 reinstated=0 unchanged=0 retired=0One whose counts are all 0 signed in too:
synced — 0 published, 0 reinstated, 0 unchanged, 0 retired. The server
listed no tools to that account. Which tools a server lists, and to whom, is
set on the server's side. Check that the account the credentials stand for
may use them.
What auth can't be set with
Section titled “What auth can't be set with”secretRef stays the plain bearer form, and auth is the other schemes. A
registration with both is refused (invalid-mcp-endpoint, reason
auth-with-secret-ref), and so is auth on a stdio endpoint
(auth-on-stdio: a stdio server gets no headers). A header auth sends
can't be in config.headers too:
-
Authorization, besidebasicoroauth2-client-credentials(auth-with-authorization-header); -
a name in
auth.headers(auth-header-in-config):{"error":{"code":"invalid-mcp-endpoint","message":"endpoint \"inventory-twice\" sets the header \"x-api-key\" in both auth.headers and config.headers: auth sends it. Drop it from config.headers.","details":{"reason":"auth-header-in-config"},…}}Excerpt
Not in config.headers
Section titled “Not in config.headers”config.headers is for plain headers: they're stored, and read back, as
given. A header whose name says it carries a credential is refused
(invalid-mcp-endpoint, reason credential-in-headers). The refusal names
the header, never its value, and shows the auth that sends it by reference:
{"error":{"code":"invalid-mcp-endpoint","message":"endpoint \"inventory-plain\" puts a credential in config.headers (\"X-Api-Key\"), where it's stored and shown as given. Store each value as a secret and send it by reference: auth: { scheme: \"header\", headers: [{ name: \"X-Api-Key\", secretRef: { envName, name } }] }.","details":{"reason":"credential-in-headers"},…}}Which names count:
Authorization,Proxy-AuthorizationandCookie;- a name with a part, between
-or_, oftoken,secret,password,passwd,apikey,credential,credentials,signature,session,auth,authorizationorauthentication(X-Auth-Token,X-Session-Id,X-Client-Secret,X-Authorization); - a name with the parts
api-key,access-key,private-key,auth-key,subscription-key,functions-key,application-key,app-key,master-key,client-keyorpartner-key(X-Api-Key,Ocp-Apim-Subscription-Key,x-functions-key,DD-APPLICATION-KEY).
X-Tenant, X-Request-Id, X-Region, X-Key-Id, X-Idempotency-Key or
X-Cache-Key are plain headers: a lone key doesn't count. A
name that's refused but isn't a secret costs only this: store its value as a
secret too.
For Authorization: Bearer <token>, use secretRef. For any other header,
use auth with scheme: "header".
An endpoint registered with one by an earlier runtime
Section titled “An endpoint registered with one by an earlier runtime”It keeps working, and:
-
reads back
[redacted]for each such header, its name kept:"config":{"url":"http://127.0.0.1:18766/mcp","headers":{"X-Region":"eu","X-Api-Key":"[redacted]","X-Api-Secret":"[redacted]"},"transport":"streamable-http"} -
never follows a redirect, as an endpoint with
authorsecretRefdoesn't. A server that answers 3xx fails the call withmcp-auth-failed:23:53:20.206 WARN [mcp] MCP endpoint "inventory-moved" skipped: mcp-auth-failed: MCP endpoint "inventory-moved" redirected (HTTP 307) a request carrying a credential header in its config.headers (X-Api-Key, X-Api-Secret): register the URL it redirects to endpointId=inventory-moved transport=streamable-http -
is named, never its value: by the runtime at every start (
WARNmcp-header-credential), by itscheck mcp-header-credentials, and bykindgi doctoragainst the runtime it points at:! MCP endpoint headers: 2 MCP endpoints keep a credential in plaintext config.headers, from before that was refused. They still work, but each value is stored as given, not as a secret.✗ inventory-legacy: X-Api-Key, X-Api-Secret✗ inventory-moved: X-Api-Key, X-Api-SecretFix: Store each value as a secret (kindgi secrets set <NAME> --env=<name> --scope=tenant), then unregister the endpoint and register it again with the header in auth: { scheme: "header", headers: [{ name: "<header>", secretRef: { envName, name } }] }.
To move one: store each header's value as a secret
(kindgi secrets set INVENTORY_KEY --env=local --scope=tenant),
unregister the endpoint, and register it again
with auth in place of the header.
Send the run's trace
Section titled “Send the run's trace”Ask your coding agent: "I want to match my MCP server's logs to the runs that
called it." For reference, it adds "sendTraceparent": true to the
registration, and each tool call to the server carries the W3C traceparent
of the run that made it, as a request header. The server's logs can then be
matched to the run, as Follow a request or a
run shows. Only the ids go:
the trace id and a span id, never the tool's input or output. It's off by
default, and HTTP transports only. A stdio server gets no headers, so true
there is refused with invalid-mcp-endpoint, reason
invalid-send-traceparent.
Make a write happen once
Section titled “Make a write happen once”Each tool call a run makes sends an idempotency key in the request's _meta,
as kindgi.com/idempotency-key. It's a UUID, the same every time the same call
runs. When a flow step's retry runs a call
again, say after its answer was lost on the way back, the server gets the key
it saw before. A server that keeps the keys it has acted on answers the repeat
with the first result, so a refund, say, is made once. It's the key a pack's
own tool gets as ctx.idempotencyKey
(Make a side effect happen once).
A server that doesn't read _meta works as before.
A model that asks for a tool again, after the tool answered with an error, makes a new call, perhaps with other arguments, so it sends a new key, and the server can't tell from the key that it's the same request. So a tool that moves money also checks what's already there before it acts, such as the order's refunds.
// server.ts, with the MCP TypeScript SDKconst refunds = new Map<string, object>(); // stands in for a table with a unique key columnconst answer = (value: object) => ({ content: [{ type: 'text' as const, text: JSON.stringify(value) }],});
server.registerTool( 'refund_order', { inputSchema: { order_id: z.number(), amount: z.string() } }, async ({ order_id, amount }, extra) => { const key = extra._meta?.['kindgi.com/idempotency-key']; const made = typeof key === 'string' ? refunds.get(key) : undefined; if (made !== undefined) return answer({ ...made, repeat: true }); const refund = { refund_id: refunds.size + 1, order_id, amount }; if (typeof key === 'string') refunds.set(key, refund); return answer({ ...refund, repeat: false }); },);# server.py, with the Python MCP SDKfrom mcp.server.fastmcp import Context
REFUNDS: dict[str, dict] = {} # stands in for a table with a unique key column
@mcp.tool()def refund_order(order_id: int, amount: str, ctx: Context) -> dict: """Refunds an order, once per call.""" meta = ctx.request_context.meta key = (meta.model_extra or {}).get("kindgi.com/idempotency-key") if meta else None if key is not None and key in REFUNDS: return {**REFUNDS[key], "repeat": True} refund = {"refund_id": len(REFUNDS) + 1, "order_id": order_id, "amount": amount} if key is not None: REFUNDS[key] = refund return {**refund, "repeat": False}The WordPress MCP Adapter hands an ability only its arguments, so keep the
request's _meta with a rest_pre_dispatch filter. Then claim the key with
add_option, one insert on a unique name: of two calls with one key, one gets
past it.
add_filter( 'rest_pre_dispatch', function ( $result, $server, $request ) { $body = $request->get_json_params(); if ( 0 === strpos( $request->get_route(), '/mcp/' ) && is_array( $body ) && 'tools/call' === ( $body['method'] ?? '' ) ) { $GLOBALS['acme_mcp_meta'] = is_array( $body['params']['_meta'] ?? null ) ? $body['params']['_meta'] : array(); } return $result;}, 10, 3 );
// in the refund ability's execute_callback$key = $GLOBALS['acme_mcp_meta']['kindgi.com/idempotency-key'] ?? null;$slot = ( is_string( $key ) && preg_match( '/^[0-9a-f-]{36}$/i', $key ) ) ? 'acme_refund_' . strtolower( $key ) : null;if ( null !== $slot && ! add_option( $slot, 'pending', '', false ) ) { $made = get_option( $slot ); if ( 'pending' === $made ) { return new WP_Error( 'acme_refund_in_progress', 'A refund with this key is still being made.' ); } return array( 'refund_id' => (int) $made, 'repeat' => true );}$refund = wc_create_refund( array( 'order_id' => $order->get_id(), 'amount' => $input['amount'] ) );if ( is_wp_error( $refund ) ) { if ( null !== $slot ) { delete_option( $slot ); } return $refund;}if ( null !== $slot ) { update_option( $slot, (string) $refund->get_id(), false );}return array( 'refund_id' => $refund->get_id(), 'repeat' => false );When the server changes
Section titled “When the server changes”The runtime lists an endpoint's tools when the endpoint is registered. It
doesn't watch the server. To pick up new or changed tools, unregister the
endpoint and register it again. A runtime serving one tenant, the default,
also lists every endpoint's tools each time it starts, so restarting it works
too. A runtime serving many tenants
(KINDGI_TENANCY=multi) lists none when it starts: there, only unregistering
and registering again does. inventory in the path is the endpoint's id:
curl -X POST "$KINDGI_API_URL/v1/mcp/endpoints/inventory/unregister" \ -H @<(printf 'Authorization: Bearer %s\n' "$KINDGI_API_TOKEN"){"endpointId":"inventory","unregistered":true}Unregistering retires the endpoint's tools. Registering it again brings them back.
If the server is down when a tool is called, the call fails and the next one reconnects:
"failureMessage": "handler-error: Tool \"inventory.stock_level\" handler threw: mcp-call-failed: MCP endpoint \"inventory\" tool \"stock_level\": tools/call \"stock_level\" failed: fetch failed (the next call reconnects)",When a tool id is taken
Section titled “When a tool id is taken”An endpoint's tools are filed in your tenant's Default project, whatever
the endpoint's scope. A tool id there is one kind or the other (runtime
0.1.9 or later): an endpoint's tool, or a tool published to the runtime, by
kindgi tools publish or by a pack's deploy. Only active versions count:
once one kind's versions are unregistered, the id is free for the other.
- A publish or a reinstate of the other kind is refused with a 409,
tool-kind-mismatch, and nothing is written. Itsdetailsname the tool and the version, and the endpoint (endpointId) when the id is an endpoint's tool. A refused reinstate leaves the version unregistered. - A deploy into the Default project of a pack with a tool whose id is an
endpoint's tool's id, such as a pack
inventorywith a toolinventory.stock_level, is refused the same way, withdetails.primitive,details.idanddetails.endpointId. Nothing is deployed: the deploy rolls back what it had published. - An endpoint that lists a tool whose id a published tool holds skips
that tool and registers the others. The runtime's log says so, at
WARN, withendpointId,toolNameandpublishedVersion. Here a pack's toolinventory.stock_level1.0.0 was deployed before the endpointinventorywas registered:
07:53:33.159 WARN [mcp] MCP endpoint "inventory" tool "stock_level" skipped: its id inventory.stock_level is a tool published here (version 1.0.0), not the endpoint's; unregister that tool's versions to free the id endpointId=inventory toolName=stock_level publishedVersion=1.0.0A tool published during the sync, after it reads that id's versions and before it writes the endpoint's tool, is named without its version; the endpoint's tool is skipped all the same.
To fix it, give one of them another id: rename the pack or the tool, or register the endpoint under another id. Or free the id: unregister the endpoint, as When the server changes shows, or the published tool's active versions, then publish, deploy or register again:
pnpm exec kindgi tools versions inventory.stock_levelpnpm exec kindgi tools unregister inventory.stock_level 1.0.0uv run kindgi tools versions inventory.stock_leveluv run kindgi tools unregister inventory.stock_level 1.0.0./kindgiw tools versions inventory.stock_level./kindgiw tools unregister inventory.stock_level 1.0.0./kindgiw tools versions inventory.stock_level./kindgiw tools unregister inventory.stock_level 1.0.0A pack deployed into another project can't take an endpoint's tool's id
either, and nothing is deployed. The refusal is tool-project-mismatch,
since a tool stays in the project its first version was published into,
or tool-kind-mismatch.
An id that holds both kinds' active versions from an earlier runtime is
served as neither, as before. Until every active version of one kind is
unregistered, a new version of either kind is refused. While a published
version of the id is active, the endpoint's tool is skipped with the WARN
above.
Transports
Section titled “Transports”-
streamable-http:{ transport: 'streamable-http', url, headers? }. Use it wherever you can.headersare sent, and read back, as given, so they're for plain headers only: a credential goes inauthorsecretRef(Not inconfig.headers). -
http-sse: the older HTTP and Server-Sent Events transport,{ transport: 'http-sse', url, sseUrl?, headers? }, withurlthe server's SSE endpoint (http://localhost:8793/ssefor the Python SDK). -
stdio:{ transport: 'stdio', command, args?, env? }. The runtime runs the command in its own container, as its own user, underkindgi devtoo. A deployment refuses it, becauseKINDGI_TENANT_HOST_ACCESSisdeployedoutside development:{"error":{"code":"host-access-denied","message":"MCP endpoint \"files\" uses the stdio transport, which runs a command on the server's host; KINDGI_TENANT_HOST_ACCESS=deployed refuses that. Run the MCP server over HTTP (streamable-http) instead.", …}}ExcerptSee Security.
Good to know
Section titled “Good to know”- An MCP tool always counts as one that changes things: it doesn't run in a dry run, and an approval gate asks before it.
- A tool's schemas are read in the JSON Schema dialect their
"$schema"declares: draft-06, draft-07, 2019-09 or 2020-12. Servers built with the TypeScript MCP SDK declare draft-07. 2020-12 is the default when they declare none. Kindgi skips a tool whose schema declares another dialect or doesn't compile, and the runtime's log names the tool. - An endpoint is registered at a scope:
tenant, as here, or a project, with"scopeKind": "project", "scopeId": "<project id>". The API reference has the registration's fields.