Guide: tools in Python
npm run new-domain -- bookings --lang python # marg new bookings --lang python
python3 -m venv .venv && .venv/bin/pip install -r domains/bookings/mcp/requirements.txt
source .venv/bin/activate && npm run gateway # the gateway spawns python3 → the venv'sThe runtime is Node; the tools can be Python. --lang python scaffolds the same domain as the TypeScript scaffold — manifest, skill, schema registry, steps, mock policy, evals, probe — with one difference: the tool server is mcp/server.py on the official mcp SDK (2.x, MCPServer over stdio) with mcp/requirements.txt beside it, and the manifest points at it:
"mcp": { "command": "python3", "args": ["domains/bookings/mcp/server.py"] }The gateway spawns that command as a child process with the repository as its working directory, exactly as it spawns npx tsx …/server.ts. python3 resolves on the gateway's PATH at boot: activate the venv first, or set command to .venv/bin/python (or an absolute path). Python 3.10 or newer.
The contract is the same
Everything in the new-domain guide and the playbook holds unchanged; only the language of one file differs.
- The triad:
validate_<entity>_slots(write-free, called after every slot change; returnsproblems,from_record,defaults),preview_<entity>,create_<entity>(refuses withoutconfirmed=TrueandAGENT_ALLOW_WRITES == "true"; returns{"created": true, "entity_id": …}). Slots are flat tool arguments, one parameter each, typed withAnnotated[…, Field(description=…)]; the SDK publishes the JSON schema the model sees and validates calls against it, so a value outside aLiteralcomes back as a tool error before your code runs. - Auth and session ride MCP
_meta, never arguments:_meta["agent/auth"]is the user's bearer,_meta["agent/session"]the chat's session id. The template'sreg()wrapper reads them fromctx.request_context.metaand puts them in a context variable (who()reads it), so no handler takes a token as an argument. - Trace context: the gateway injects W3C
traceparent/tracestateinto_meta;reg()extracts it and opens amcp.tool <name>SERVER span with the same attributes as the TypeScript server (rpc.system,rpc.method,gen_ai.tool.name,session.id,agent.tool.ok,agent.tool.ms).opentelemetry-apialone makes that a no-op; see below to export. - Results are text:
ok(data)returns a text result holding compact JSON,fail(msg)anisErrorresult whose text starts withERROR:— the runtime marks the span, the model reads the reason, the mock policy and the evals see the same shapes they see from TypeScript. - stdout is the protocol channel.
log()writes one JSON line per event to stderr, in the shape the gateway's logger writes (ts,level,service,msg, fields); the trace line per call logs counts and shapes of the arguments, never a user's text. Python'sloggingis pointed at stderr too. A strayprint()to stdout breaks the connection. - Handlers may be
deforasync def;reg()wraps both. Declare*, ctx: Contexton every handler — the SDK fills it, the wrapper needs it. - Internal tools (
internal_upload_mediaand friends) are registered the same way; the gateway hides any tool whose name starts withinternal_from the model.
Exporting the spans
The template depends on the OpenTelemetry API only, so it runs anywhere. To ship the spans to the same backend as the gateway, install an SDK and the OTLP exporter and configure a provider before the tools are registered — the same endpoint and credentials the gateway uses (observability):
.venv/bin/pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-http# at the top of mcp/server.py, after the imports
import os
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
base = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") or (os.environ.get("AGENT_O2_URL", "").rstrip("/") + "/api/" + os.environ.get("AGENT_O2_ORG", "default"))
if base.startswith("http"):
headers = {}
if os.environ.get("AGENT_O2_USER"):
import base64
headers["Authorization"] = "Basic " + base64.b64encode(f"{os.environ['AGENT_O2_USER']}:{os.environ.get('AGENT_O2_PASSWORD', '')}".encode()).decode()
provider = TracerProvider(resource=Resource.create({"service.name": "marg-bookings-mcp", "service.namespace": "marg"}))
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=f"{base}/v1/traces", headers=headers)))
trace.set_tracer_provider(provider)Or leave the file alone and run it under the auto-instrumentation wrapper: pip install opentelemetry-distro opentelemetry-exporter-otlp and set the manifest's command to .venv/bin/opentelemetry-instrument with args ["python3", "domains/bookings/mcp/server.py"]; the standard OTEL_* variables configure it.
Proving it
SCAFFOLD_LANG=python npm run check:scaffold # scaffold, typecheck, a venv under .data/, py_compile, gateway, evalsThe check scaffolds a Python domain, installs its requirements into .data/venv-scaffold, compiles the server, boots a gateway with only that domain (mock model, writes off) and runs the scaffold's own eval suite — so a template that drifts from the contract fails here. CI runs it on Python 3.11 as the scaffold (python 3.11) job; a change to the template, the scaffolder or the runtime's _meta contract has to keep it green. SCAFFOLD_PYTHON=/path/to/python3.11 picks the interpreter when python3 on PATH is older than 3.10.
Your own domain's suite runs the same way as any other: npm run gateway:evals & then npx tsx domains/bookings/evals/run-evals.ts.
What stays TypeScript
The skill, the steps, the mock policy, the evals and the probe are the runtime's side and stay in TypeScript — they run inside the gateway and the eval runner, not in your server. A Python backend can of course sit behind any of them; the probe is a thin HTTP script and reads fine as a model for a Python one if you prefer.
Source: docs/guides/python-tools.md — edit it there; the site mirrors the repository.