Purpose. aipod is a single program you can start as an MCP server or
as an agent, chosen by a subcommand:
| Command | Mode | What it is | Publishes |
|---|---|---|---|
aipod server |
MCP server | a reference implementation of every MCP feature, so client / gateway / runtime authors have one endpoint to test against | a service contract (GET /contract.json) |
aipod agent |
agent | a pydantic-ai agent that connects to an aipod server over MCP and exposes its tools to a model |
an agent card (GET /.well-known/agent-card.json) |
Built on FastMCP (server
mode) and pydantic-ai's MCP client (agent mode). Packaged as a single
FROM scratch container; the same image runs either mode.
Repo: https://github.com/bigg01/aipod Β Β·Β
Latest release: v0.1.2 Β Β·Β
Image: ghcr.io/bigg01/aipod Β Β·Β
Chart: oci://ghcr.io/bigg01/charts/aipod
Live instance for remote MCP testing: a public aipod server runs at
https://aipod.guggenbuehl.net/ β MCP endpoint https://aipod.guggenbuehl.net/mcp,
contract at /contract.json. Open by default; point any MCP client at it without
installing anything. Shared and best-effort β treat state (incidents,
deployments) as scratch.
Wondering why a reference MCP server and agent are worth having around? See
docs/blog/contracts-and-agent-cards.md.
| Area | Details |
|---|---|
| Tools | echo, add, get_tiny_image, get_annotated_message, get_structured_weather, get_resource_reference, get_resource_links, trigger_long_running_operation, toggle_simulated_logging, toggle_subscriber_updates |
| Marvel roster | list_heroes, get_hero, find_heroes_by_power, assemble_team β typed Hero / HeroRoster / MissionTeam output over a small fixed dataset |
| SRE / IT-application | list_services, get_service, check_service_health, error_budget, search_logs, list_incidents, open_incident, update_incident, list_deployments, rollback_deployment, get_oncall, get_runbook β a toy service estate with mutable incident / deployment state and deterministic synthetic metrics & logs |
| pydantic-ai tools | poet, summarize (structured output), weather_report, hero_bio, incident_postmortem β model supplied by the client via MCP sampling (no server-side key) |
| Structured output | get_structured_weather / summarize / the roster & SRE tools return typed Pydantic models β output schema + structuredContent |
| Side effects | open_incident, update_incident, rollback_deployment (+ the toggle_* / trigger_* demo tools) are flagged sideEffects: true in the contract |
| Content blocks | text, image, embedded resource, resource links, priority / audience annotations |
| Resources | static docs + templated demo://resource/dynamic/{text,blob}/{resource_id}, hero://roster/{codename}, service://catalog/{name}, runbook://{service} |
| Prompts | simple_prompt, args_prompt, completable_prompt, resource_prompt |
| Auth | open by default; add a key and /mcp becomes an OAuth 2.1 protected resource (bearer token + /.well-known/oauth-protected-resource) |
| Metrics | on by default (Prometheus GET /metrics); OpenTelemetry per-MCP-method + per-tool counters/histograms, sampling count, inventory gauges. AIPOD_METRICS=otlp|console|none to change or disable |
| Also | argument completion, resource subscriptions, progress, logging/setLevel |
HTTP routes: GET / (landing), GET /health, GET|POST /mcp, GET /contract.json,
GET /metrics (Prometheus, on by default), and β when auth is enabled β
GET /.well-known/oauth-protected-resource.
GET / itself is a plain landing page listing every tool, resource, and
prompt above β open it in a browser once the server is running:
HTTP + JSON MCP (Streamable HTTP)
client ββββββββββββββΆ aipod agent βββββββββββββββββββββββββΆ aipod server
pydantic-ai Agent + model provider tools / resources / prompts
HTTP routes: GET /, GET /health, GET /.well-known/agent-card.json,
POST /ask ({"prompt": "..."} β {"output": "..."}).
Agent mode needs a model β AIPOD_MODEL (e.g. anthropic:claude-haiku-4-5) plus
the provider key. Without one it still serves the card and /health; /ask
returns 503.
- Python β₯ 3.11, uv
- Docker + a Kubernetes cluster (optional)
uv sync
# server mode
uv run aipod server # http://127.0.0.1:8000 (MCP at /mcp)
uv run aipod server --transport stdio # for subprocess clients (Claude Desktop, editors)
uv run aipod server --print contract # emit the service contract as JSON
uv run aipod server --auth-token s3cret # require 'Authorization: Bearer s3cret' on /mcp
# agent mode (needs a running server + a model)
export AIPOD_MCP_URL=http://127.0.0.1:8000/mcp
export AIPOD_MODEL=anthropic:claude-haiku-4-5
export ANTHROPIC_API_KEY=...
uv run aipod agent # http://127.0.0.1:8080
uv run aipod agent --ask "Write a poem about sockets, then summarise it."
uv run aipod agent --print agent-card # emit the agent card as JSON- Streamable HTTP (default) β a long-running network service; clients connect
to
/mcp, responses and notifications stream back as SSE. Use for anything shared or deployed. - stdio β no network listener. The client launches
aipod serveras a child process and talks to it over that process's stdin/stdout. "Subprocess clients" are desktop / editor MCP hosts (Claude Desktop, Cursor, the VS Code MCP extension) that work this way; you never start the server yourself.
The server runs open by default. Give it a key and the Streamable HTTP
/mcp route becomes an OAuth 2.1 protected resource:
uv run aipod server --auth-token s3cret # or: AIPOD_API_KEY=s3cret
export AIPOD_API_KEYS="key-a,key-b" # multiple keys (rotation / per-client)| Env var | Effect |
|---|---|
AIPOD_API_KEY / AIPOD_API_KEYS |
keys the server accepts (--auth-token wins) |
AIPOD_AUTH_SCOPES |
CSV of scopes a caller must hold (default: none) |
AIPOD_AUTH_ISSUER |
authorization-server URL advertised in metadata (default: this server) |
AIPOD_PUBLIC_URL |
externally reachable base URL when behind a proxy / ingress |
With auth on:
- requests to
/mcpwithoutAuthorization: Bearer <key>get401+ aWWW-Authenticateheader pointing atGET /.well-known/oauth-protected-resource(RFC 9728); - that metadata document lists the authorization server(s) and scopes;
contract.jsonβsecurityswitches from{"scheme":"none"}to abearerblock, andclientRequirements.authentication.requiredbecomestrue.
Tokens are checked against the static key list β the resource-server half of the
spec without an identity provider. For full OAuth 2.1, point
AIPOD_AUTH_ISSUER at a real authorization server and replace
StaticTokenVerifier in src/aipod/server/auth.py with a JWT-validating one.
aipod agent reaches a protected server by setting AIPOD_MCP_TOKEN.
curl -s http://127.0.0.1:8000/mcp -X POST ... -H 'Authorization: Bearer s3cret'
curl -s http://127.0.0.1:8000/.well-known/oauth-protected-resource | jqFull walkthrough in docs/testing-mcp.md.
Both modes emit OpenTelemetry metrics, on by default with the Prometheus
exporter β aipod server serves GET /metrics with no configuration. The server
instruments its own MCP internals, not just the Python process:
| Instrument | Type | Attributes |
|---|---|---|
mcp.server.requests |
counter | mcp.method (tools/call, resources/read, prompts/get, completion/complete, resources/subscribe, logging/setLevel, β¦), outcome |
mcp.server.request.duration |
histogram (s) | same |
mcp.server.tool.calls |
counter | mcp.tool.name, outcome (ok/error), mcp.tool.sampling |
mcp.server.tool.duration |
histogram (s) | per tool name |
mcp.server.sampling.requests |
counter | server β client sampling round-trips |
mcp.server.tools / .resources / .resource_templates / .prompts |
gauges | the registered inventory |
mcp.server.resource_subscriptions.active / mcp.server.background_tasks.active |
gauges | live per-connection state |
aipod.agent.ask.calls / aipod.agent.ask.duration |
counter / histogram | outcome (agent mode) |
# default: Prometheus scrape endpoint on the mode's HTTP port
uv run aipod server
curl -s localhost:8000/metrics | grep mcp_server_
# push to an OTLP/HTTP collector instead
AIPOD_METRICS=otlp OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 uv run aipod agent
# stdout, for a quick look
AIPOD_METRICS=console uv run aipod server
# turn it off
AIPOD_METRICS=none uv run aipod serverAIPOD_METRICS = prometheus (default) | otlp | console | none;
OTEL_METRICS_EXPORTER=none or OTEL_SDK_DISABLED=true also disable it;
a bare OTEL_EXPORTER_OTLP_ENDPOINT selects otlp. OTEL_SERVICE_NAME /
OTEL_RESOURCE_ATTRIBUTES set the resource. In k8s: metrics.exporter in the
Helm values, or AIPOD_METRICS in k8s/configmap.yaml.
Grafana dashboard β dashboards/aipod.json (import
it directly) covers the inventory gauges, per-method + per-tool rate / errors /
latency, sampling, and the agent /ask. On a kube-prometheus-stack cluster,
kubectl apply -k dashboards ships it as a sidecar-loaded ConfigMap.
The running version shows on the landing page (GET /) and in GET /health
({"status":"ok","version":"β¦"}).
uv run pytestHermetic β server tests drive an in-memory MCP session with a stubbed sampling callback; agent tests need no running server and no API key.
@modelcontextprotocol/inspector
is the reference MCP client β it speaks the raw protocol, so no model or API key
is needed (except for the sampling-backed tools).
uv run aipod server # start a server (MCP at :8000/mcp)
# interactive UI (http://127.0.0.1:6274)
npx -y @modelcontextprotocol/inspector # or: make inspect
# scripted / CI β one request per call (transport auto-detected from /mcp)
npx -y @modelcontextprotocol/inspector --cli \
http://127.0.0.1:8000/mcp --method tools/list # or: make inspect-cli
npx -y @modelcontextprotocol/inspector --cli \
http://127.0.0.1:8000/mcp --method tools/call --tool-name add --tool-arg a=2 --tool-arg b=3
# or skip the local server and hit the live instance
npx -y @modelcontextprotocol/inspector --cli \
https://aipod.guggenbuehl.net/mcp --method tools/listFull walkthrough β every feature (structured output, resource templates,
completion, subscriptions, logging, progress, sampling), the --cli vs UI split,
stdio via an mcp.json, and a CI gate example β in
docs/testing-mcp.md.
One image, either mode. PyInstaller bundles the app, staticx folds in libc,
the final image is FROM scratch (binary + /tmp + CA certs + /etc/passwd),
~34 MB.
Every release publishes it to the GitHub Container Registry (public, no login):
docker pull ghcr.io/bigg01/aipod:0.1.1 # or :latest, :0.1, :sha-<commit>
docker run --rm -p 8000:8000 ghcr.io/bigg01/aipod:latest # server (default CMD)
docker run --rm -p 8080:8080 \
-e AIPOD_MCP_URL=http://host.docker.internal:8000/mcp \
-e AIPOD_MODEL=anthropic:claude-haiku-4-5 -e ANTHROPIC_API_KEY=... \
ghcr.io/bigg01/aipod:latest agent --host 0.0.0.0 --port 8080 # agentOr build it yourself: docker build -t aipod:latest . (same result, make docker).
The binary self-extracts into TMPDIR (/tmp) on start, so the runtime needs a
writable /tmp even with a read-only root filesystem.
Both modes deploy from the one image, two ways:
Kustomize β k8s/
kubectl apply -k k8s:
Deployment/aipod-server(+Service/aipod-server) βreplicas: 1(per-session state + background tasks live in memory)Deployment/aipod-agent(+Service/aipod-agent,Ingress) βreplicas: 2, stateless;AIPOD_MCP_URLpoints at the server ServiceConfigMap/aipod-configβ governance labels +AIPOD_MODEL; provider key from a Secret you create (kubectl create secret generic aipod-model --from-literal=ANTHROPIC_API_KEY=...)Secret/aipod-auth(optional) βAIPOD_API_KEYturns on bearer auth for the server and is reused by the agent asAIPOD_MCP_TOKEN(kubectl create secret generic aipod-auth --from-literal=AIPOD_API_KEY=$(openssl rand -hex 16))
Helm β charts/aipod/
# from a checkout
helm install aipod ./charts/aipod
# or the published OCI chart
helm install aipod oci://ghcr.io/bigg01/charts/aipod --version 0.1.0 \
-f examples/helm-values.yamlSame objects, parameterised: server.enabled / agent.enabled, *.replicas,
*.ingress.*, *.resources, the config map, and auth / model (inline key β
the chart makes the Secret, or point at *.existingSecret). Full list in
charts/aipod/values.yaml;
examples/helm-values.yaml is a TLS-ingress + bearer-auth
override. make helm-lint / helm-template / helm-install.
Both pods run non-root, no capabilities, read-only rootfs, RuntimeDefault
seccomp, with an emptyDir at /tmp.
Same manifests, no Azure-specific changes needed beyond getting the image into a registry AKS can pull from:
az acr create -g my-rg -n myacr --sku Basic
az acr build -r myacr -t aipod:latest . # builds in ACR, no local push needed
az aks create -g my-rg -n my-aks --attach-acr myacr
az aks get-credentials -g my-rg -n my-aks
# point k8s/kustomization.yaml's `images:` entry at myacr.azurecr.io/aipod, then:
kubectl apply -k k8s/--attach-acr wires AKS's kubelet identity to pull from that registry
without a separate imagePullSecret.
Same server, same /mcp endpoint β different runtimes just point at it
differently.
- kagent registers a remote MCP server as its own
CRD β see
examples/kagent-remotemcpserver.yaml. Apply it and kagent discovers every tool the same way it discovers its own built-in tool server (kubectl get remotemcpserver aipod -o yamlβstatus.discoveredTools). - kars (Microsoft's Kubernetes-native
agent runtime) has its own
McpServerCRD β OAuth, per-tool allow-lists, and sandbox selectors included β seeexamples/kars-mcpserver.yaml. - Azure AI Foundry (and anything else using the same
Responses-API-shaped MCP tool) takes the endpoint straight in the
agent/tool definition, no separate resource β see
examples/azure-ai-foundry-mcp-tool.json.
.github/workflows/ci.yml runs on every push / PR:
pytest on Python 3.11β3.13, uv build, uv lock --check, a check that
examples/ is in sync, helm lint + kubeconform on the rendered manifests, and
a FROM scratch image build with a /health + /contract.json smoke test.
.github/workflows/release.yml runs on a
vX.Y.Z tag (which must match the pyproject.toml version) and produces, for
that version:
- Container β
ghcr.io/bigg01/aipodtaggedX.Y.Z+X.Y+latest+sha-<commit>, with an SBOM and build provenance attestation. Public βdocker pullneeds no login. - Helm chart β
oci://ghcr.io/bigg01/charts/aipod, version pinned to the tag. - Binary β the static
aipod-linux-x86_64. - GitHub Release β notes plus the binary and chart tarball attached.
Both modes carry the same labels from AIPOD_* env vars β a governance block in
the server contract, an x-governance block (plus a dependencies link to the
server's contract) in the agent card:
| Env var | Field |
|---|---|
AIPOD_OWNER |
owner / provider.organization |
AIPOD_DOMAIN |
domain |
AIPOD_DATA_CLASSIFICATION |
dataClassification (PUBLICβ¦RESTRICTED) |
AIPOD_DATA_RESIDENCY |
dataResidency |
AIPOD_REGULATORY_SCOPE |
regulatoryScope (CSV) |
AIPOD_AUTH_SCHEMES |
authenticationSchemes (CSV) |
AIPOD_CONTAINS_PII |
containsPII |
Per-tool the contract also has requiresSampling, sideEffects, and dataEgress
so a router / gateway can gate calls on data movement and state changes rather
than on tool names.
src/aipod/
__main__.py CLI - `aipod server` | `aipod agent`
governance.py shared AIPOD_* governance labels
telemetry.py OpenTelemetry metrics (both modes)
server/
build.py every MCP feature on one FastMCP instance
sampling_tools.py pydantic-ai tools (model via MCP sampling)
heroes.py Marvel roster data + models for the roster tools
sre.py IT-application / SRE estate: catalogue, incidents, deploys, metrics
auth.py optional bearer-token / OAuth 2.1 protected-resource auth
contract.py service contract builder
data.py, landing.py
agent/
runtime.py pydantic-ai Agent + MCP toolset -> the server
card.py agent card builder
http.py Starlette app: card, /health, /ask, /metrics
config.py AIPOD_MCP_URL, AIPOD_MODEL, ...
packaging/ PyInstaller entry + spec
examples/ generated contract.json + agent-card.json + helm-values.yaml
k8s/ both Deployments, Services, Ingress, ConfigMap, kustomization
charts/aipod/ Helm chart (same objects, parameterised)
.github/workflows/ ci.yml (test + build) + release.yml (image + chart + binary)
docs/ testing-mcp.md (Inspector walkthrough) + blog/ (contracts & agent cards)
tests/ test_server.py + test_agent.py
