Skip to content

Use an MCP server's tools

View as Markdown

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.

  • A runtime, such as kindgi dev, with Kindgi 0.1's CLI.
  • KINDGI_API_URL and KINDGI_API_TOKEN set in your shell: your runtime's URL and its API token. kindgi dev prints 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.

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:

server.py
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:

Terminal window
uv init --bare --no-workspace
uv add 'mcp<2'
uv run python server.py
  • uv init --bare --no-workspace makes 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 started
INFO: 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.

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:

register-inventory.ts
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.

Or register it with curl:

Terminal window
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 retired

The tools are now your tenant's:

Terminal window
pnpm exec kindgi 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",
…
}
Excerpt

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

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

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.ts
tools: [
{ id: 'my-pack.lookup-order' as ToolId, version: '^0.1.0' },
{ id: 'inventory.stock_level' as ToolId, version: '^1.0.0' },
],
Excerpt

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:

Terminal window
pnpm exec kindgi 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.",
…
}
],
Excerpt

A flow step names it in ref, like any tool: { id: 'stock', kind: 'tool', ref: 'inventory.stock_level', inputMapping: { sku: { path: 'runInput.sku' } } }.

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" }
Excerpt

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=warehouse

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

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" }
}
Excerpt

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

On WordPress's side: Application Passwords, the MCP Adapter and WooCommerce's MCP tools.

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"
}
Excerpt

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_in is used for 60 seconds.

  • The client's credentials: an Authorization: Basic header to the token endpoint by default. "clientAuth": "client_secret_post" sends client_id and client_secret in the form instead. audience is 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.1 or [::1]. A token endpoint on the runtime's own machine is reached only with KINDGI_TENANT_HOST_ACCESS=local, the default in kindgi dev. A deployed runtime's default, deployed, refuses it with host-access-denied.

  • When it fails:

    • a token request that's refused fails the tool call with mcp-auth-refused, as with invalid_client or 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-down
    20: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 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). prefix is 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, traceparent and 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

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-nosecret
Excerpt

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=0
21: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=0

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

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, beside basic or oauth2-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

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"},…}}
Excerpt

Which names count:

  • Authorization, Proxy-Authorization and Cookie;
  • a name with a part, between - or _, of token, secret, password, passwd, apikey, credential, credentials, signature, session, auth, authorization or authentication (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-key or partner-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 auth or secretRef doesn't. A server that answers 3xx fails the call with mcp-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 (WARN mcp-header-credential), by its check mcp-header-credentials, and by kindgi doctor against 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-Secret
    Fix: 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.

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.

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 SDK
const refunds = new Map<string, object>(); // stands in for a table with a unique key column
const 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 });
},
);
Excerpt

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:

Terminal window
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)",
Excerpt

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. Its details name 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 inventory with a tool inventory.stock_level, is refused the same way, with details.primitive, details.id and details.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, with endpointId, toolName and publishedVersion. Here a pack's tool inventory.stock_level 1.0.0 was deployed before the endpoint inventory was 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.0

A 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:

Terminal window
pnpm exec kindgi tools versions inventory.stock_level
pnpm exec kindgi tools unregister inventory.stock_level 1.0.0

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

  • streamable-http: { transport: 'streamable-http', url, headers? }. Use it wherever you can. headers are sent, and read back, as given, so they're for plain headers only: a credential goes in auth or secretRef (Not in config.headers).

  • http-sse: the older HTTP and Server-Sent Events transport, { transport: 'http-sse', url, sseUrl?, headers? }, with url the server's SSE endpoint (http://localhost:8793/sse for the Python SDK).

  • stdio: { transport: 'stdio', command, args?, env? }. The runtime runs the command in its own container, as its own user, under kindgi dev too. A deployment refuses it, because KINDGI_TENANT_HOST_ACCESS is deployed outside 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.", …}}
    Excerpt

    See Security.

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