Skip to content

Latest commit

Β 

History

11 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

aipod

aipod β€” one binary, two modes


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.

aipod architecture: MCP clients and agent platforms talk to aipod server; aipod agent talks to aipod server and to a model

Wondering why a reference MCP server and agent are worth having around? See docs/blog/contracts-and-agent-cards.md.

Server mode β€” feature surface

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:

aipod server's landing page at GET /, listing every tool, resource, and prompt

Agent mode

        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.

Requirements

  • Python β‰₯ 3.11, uv
  • Docker + a Kubernetes cluster (optional)

Run locally

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

stdio vs. HTTP (server mode)

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

Authentication (optional)

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 /mcp without Authorization: Bearer <key> get 401 + a WWW-Authenticate header pointing at GET /.well-known/oauth-protected-resource (RFC 9728);
  • that metadata document lists the authorization server(s) and scopes;
  • contract.json β†’ security switches from {"scheme":"none"} to a bearer block, and clientRequirements.authentication.required becomes true.

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

Full walkthrough in docs/testing-mcp.md.

Observability (OpenTelemetry)

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 server

AIPOD_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":"…"}).

Test

uv run pytest

Hermetic β€” server tests drive an in-memory MCP session with a stubbed sampling callback; agent tests need no running server and no API key.

Exercise the server with the MCP Inspector

@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/list

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

Container (FROM scratch)

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  # agent

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

Kubernetes

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_URL points at the server Service
  • ConfigMap/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_KEY turns on bearer auth for the server and is reused by the agent as AIPOD_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.yaml

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

On Azure Kubernetes Service (AKS)

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.

Agent platforms

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 McpServer CRD β€” OAuth, per-tool allow-lists, and sandbox selectors included β€” see examples/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.

CI / releases

.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/aipod tagged X.Y.Z + X.Y + latest + sha-<commit>, with an SBOM and build provenance attestation. Public β€” docker pull needs 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.

Governance

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.

Layout

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

About

One binary, two modes: an 🐍MCP server that exercises every MCP feature, or a 🐍pydantic-ai agent that consumes it. Ships as a ~34 MB FROM scratch container.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages