Skip to content

Use an MCP server's tools

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>. Agents and flow steps then call it like a tool of your pack.

An MCP server that speaks Streamable HTTP works. This one, written with the Python MCP SDK (1.x), has two tools:

# server.py: an MCP server with two tools, over Streamable HTTP.
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")
Terminal window
uv init --bare --no-workspace
uv add 'mcp<2'
uv run python server.py

It serves at http://localhost:8791/mcp. Under kindgi dev the runtime reaches your machine's localhost.

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.items.map((e) => `${e.endpointId} (${e.transport})`));
[ 'inventory (streamable-http)' ]

Or with curl (kindgi dev prints the API's URL and token):

Terminal window
curl -X POST "$KINDGI_API_URL/v1/mcp/endpoints" \
-H "authorization: Bearer $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] [mcp-bridge] MCP endpoint "inventory" synced — 2 published, 0 reinstated, 0 unchanged, 0 retired

The tools are now your tenant's:

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

Kindgi registers MCP tools as version 1.0.0. A tool's input schema is the one the server gives. Its answer is the server's structured content, checked against the tool's outputSchema, when the server declares one; otherwise the answer's 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").

An agent lists an MCP tool by id, with a version range:

// 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' },
],

With a model that calls tools (here Llama 3.1 on Ollama, see Models):

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

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

Add secretRef to the registration. Kindgi resolves the secret in the tenant's secrets when it connects, and sends it as Authorization: Bearer <value>:

"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] [mcp-bridge] WARN 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 at /pack

Store the secret (kindgi secrets set INVENTORY_TOKEN --env=local --scope=tenant), then register the endpoint again, as below.

The runtime lists an endpoint's tools when the endpoint is registered and each time the runtime starts. It doesn't watch the server. To pick up new or changed tools, unregister the endpoint and register it again, or restart the runtime:

Terminal window
curl -X POST "$KINDGI_API_URL/v1/mcp/endpoints/inventory/unregister" \
-H "authorization: Bearer $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)",
  • streamable-http: { transport: 'streamable-http', url, headers? }. Use it wherever you can. headers are sent as given, and the API returns them as given: put a credential in secretRef, not here.

  • 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 (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.", …}}

    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 they declare ("$schema"): draft-06, draft-07 (what servers built with the TypeScript MCP SDK declare), 2019-09 or 2020-12, 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 ("scopeKind": "project", "scopeId": "<project id>"). See the API reference.