{
  "markdown": "# Gaggiuino MCP Server\n\nA Remote [MCP](https://modelcontextprotocol.io) server for integrating a [Gaggiuino](https://gaggiuino.github.io/) espresso machine with AI tools. Ask your AI assistant to check machine status, analyze shot data, and get dial-in guidance.\n\n## Features\n\n### MCP Tools\n\n**Shot Analysis**\n- `get_status` - Current machine status (temperature, pressure, weight, water level)\n- `get_latest_shot_id` - Most recent shot, id and headline numbers in one call\n- `list_recent_shots` - The last few shots summarised, for trends over a session\n- `get_shot_data` - Structured shot summary with metrics\n- `get_shot_raw_data` - Complete time-series data\n- `view_shot_graph` - Interactive shot graph rendered in MCP-compatible hosts (pressure, flow, weight over time with target overlays and optional shot comparison)\n\n**Profiles and Settings**\n- `list_profiles` - Profiles on the machine, merged with this server's documentation\n- `get_profile_info` - Everything known about one profile\n- `get_machine_settings` - Boiler, steam, and scale configuration as the machine reports it\n- `get_maintenance_status` - Descale and backflush history the machine tracks itself, with shots since each\n- `get_dial_in_guidance` - Expert guidance for analyzing espresso shots\n- `select_profile` - Switch the active profile (changes the machine; requires an authenticated server)\n- `upload_profile` - Save a new brew profile to the machine (changes the machine; requires an authenticated server). Creates only — it never updates, and the machine assigns a fresh id every time, so uploading twice leaves two profiles\n- `delete_profile` - Delete a profile from the machine (destructive and irreversible; requires an authenticated server). It demands the profile's exact name back as confirmation, refuses to delete the currently selected profile, and always prompts for approval in the host — even under a stored \"always allow\"\n\n**MCP Prompts** - workflow templates your host surfaces as slash commands or menu items:\n\n- `dial_in_new_bag` - first shots on a coffee you have not pulled before (bean, and optionally roast level, dose, and what you want in the cup)\n- `diagnose_last_shot` - read the shot you just pulled against how it tasted (what was wrong, and optionally what you changed)\n- `choose_profile` - pick a profile the machine actually holds for a coffee (roast level, and optionally drink and notes)\n- `espresso_shot_analyst` - the dial-in guidance as a system prompt (same content as `get_dial_in_guidance`)\n\nEach workflow prompt lays out the tools to call in order, so the analysis starts from the machine's own data rather than a guess.\n\n**MCP Resources** - `gaggiuino://profiles` and `gaggiuino://profiles/{id}` for profile data\n\n## Quick Start\n\nThe server is published as a multi-arch image (linux/amd64, linux/arm64) at\n[`ghcr.io/ljcl/gaggiuino-mcp`](https://github.com/ljcl/gaggiuino-mcp/pkgs/container/gaggiuino-mcp),\nso there is nothing to clone or build. It is also listed in the\n[MCP Registry](https://registry.modelcontextprotocol.io/v0/servers?search=io.github.ljcl/gaggiuino-mcp)\nas `io.github.ljcl/gaggiuino-mcp`.\n\n### 1. Download and Configure\n\n```bash\nmkdir gaggiuino-mcp && cd gaggiuino-mcp\n\ncurl -O https://raw.githubusercontent.com/ljcl/gaggiuino-mcp/main/docker-compose.yml\ncurl -o .env https://raw.githubusercontent.com/ljcl/gaggiuino-mcp/main/.env.example\n```\n\nEdit `.env` with your Gaggiuino machine's address:\n\n```bash\n# Use the IP directly (recommended)\nGAGGIUINO_URL=http://192.168.1.100\n\n# Or if mDNS works on your network\nGAGGIUINO_URL=http://gaggiuino.local\n```\n\n### 2. Start the Server\n\n```bash\ndocker compose up -d\n```\n\n### 3. Verify\n\n```bash\ncurl http://localhost:8000/health\n```\n\nThe server is available at `http://<your-docker-host>:8000/mcp`.\n\n### Choosing a Version\n\nThe compose file tracks `latest`. To pin a release, set `GAGGIUINO_MCP_TAG` in `.env`:\n\n```bash\nGAGGIUINO_MCP_TAG=3.2    # latest 3.2.x patch\nGAGGIUINO_MCP_TAG=3.2.0  # exact release\n```\n\nUpgrade with:\n\n```bash\ndocker compose pull && docker compose up -d\n```\n\n## Configuration\n\n### Environment Variables\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `GAGGIUINO_URL` | `http://gaggiuino.local` | URL of your Gaggiuino machine |\n| `PORT` | `8000` | Port for the MCP server |\n| `HOST` | `0.0.0.0` | Host to bind to |\n| `MCP_PUBLIC_URL` | _(unset)_ | Public `https` origin clients reach this server on, with no path (e.g. `https://box.tailnet.ts.net`). Set together with `MCP_OAUTH_SECRET` to enable OAuth. It is advertised as the OAuth `resource`, so it must match the URL you enter in the client exactly. |\n| `MCP_OAUTH_SECRET` | _(unset)_ | Signing key for self-issued OAuth tokens, at least 32 characters (`openssl rand -hex 32`). Keep it stable across restarts so clients stay signed in. Setting only one of these two fails at startup. |\n| `MCP_OAUTH_PASSPHRASE_HASH` | _(unset)_ | scrypt hash of the passphrase you type on the consent page when connecting a client. Required whenever the built-in authorization server is on — without it the consent page would grant a token to anyone who reached it, so the server refuses to start. Generate with `cd apps/server && bun run hash-passphrase`; never store the passphrase itself. |\n| `MCP_OAUTH_ISSUER` | _(unset)_ | Delegate token issuing to an identity provider you already run (Authentik, Keycloak, Authelia, Zitadel, Kanidm, `tsidp`). Set it and this server serves no OAuth endpoints of its own, verifying RS256/ES256 tokens against the issuer's JWKS instead. Requires `MCP_PUBLIC_URL`; refuses to start alongside `MCP_OAUTH_SECRET` or `MCP_OAUTH_PASSPHRASE_HASH`. See [Using an external identity provider](#using-an-external-identity-provider). |\n| `MCP_ALLOWED_ORIGINS` | _(empty)_ | Comma-separated browser origins allowed to call `/mcp`. `*` allows any (unsafe). |\n| `MCP_ALLOWED_HOSTS` | _(empty)_ | Comma-separated `Host` header values to accept. Empty disables the check. |\n| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error`, or `silent`. Logs are one JSON object per line on stderr. |\n\n### Health and logs\n\n`GET /health` returns JSON:\n\n```json\n{\n  \"status\": \"ok\",\n  \"version\": \"3.2.0\",\n  \"uptimeSec\": 3412,\n  \"machine\": {\n    \"url\": \"http://gaggiuino.local\",\n    \"state\": \"unreachable\",\n    \"lastCheckedAt\": \"2026-07-27T21:11:15.274Z\",\n    \"lastError\": \"Unable to connect. Is the computer able to access the url?\",\n    \"versions\": {\n      \"coreVersion\": \"a06f97fd\",\n      \"frontVersion\": \"a06f97fd\",\n      \"staticVersion\": \"a06f97fd\"\n    }\n  }\n}\n```\n\nIt answers `200` whenever the process is alive, **including while the machine is\nunreachable** — your espresso machine is off most of the day, and the container\nhealthcheck reads the status code. `machine.state` is `ok`, `unreachable`, or\n`unknown`, observed from the requests the server already makes rather than from\na probe, so `/health` puts no extra load on the machine. `machine.versions` is\nobserved the same way — remembered the first time anything reads the machine's\nsettings (`get_machine_settings`), never fetched by `/health` itself — so `null`\nthere means \"this server has not read the settings yet\", not \"the machine\nreports no version\". Only those three documented fields are published: `/health`\nis unauthenticated, so anything a future firmware adds under `versions` stays\nout of it until someone decides otherwise.\n\nLogs are one JSON object per line, so you can pick out what you need:\n\n```bash\ndocker compose logs -f | jq -c 'select(.event == \"tool.call\" and .outcome != \"ok\")'\n```\n\n### Securing the endpoint\n\n**Turn on OAuth before exposing this server beyond your LAN.** Every tunnel\noption below puts `/mcp` on the public internet, and without authentication\nanyone who learns the URL gets the full tool surface against a machine in your\nkitchen. The server prints a warning at startup while nothing is configured.\n\nThree variables, all required together:\n\n```bash\n# 1. The public origin clients will reach the server on — no path, https only.\n#    This is advertised as the OAuth `resource`, so it must match the URL you\n#    type into Claude exactly.\nMCP_PUBLIC_URL=https://your-machine.tail-scale.ts.net\n\n# 2. The key your tokens are signed with. Keep it stable across restarts, or\n#    every restart signs you out of your phone.\nopenssl rand -hex 32\n\n# 3. The passphrase you will type on the consent page. Store the hash, never\n#    the passphrase — this prompts and prints the whole line to paste in.\ncd apps/server && bun run hash-passphrase >> ../../.env\n```\n\n**Then recreate the container — a plain restart is not enough:**\n\n```bash\ndocker compose up -d --force-recreate\n```\n\nCompose tracks the *list* of `env_file` paths, not their contents, so editing\n`.env` and running `docker compose up -d` (or `restart`) can reuse the existing\ncontainer along with the environment it was created with. The new variables\nnever reach the process and the server comes up unauthenticated exactly as if\nyou had not set them — with no error, because from its point of view nothing is\nconfigured. Check what actually arrived:\n\n```bash\ndocker inspect gaggiuino-mcp --format '{{range .Config.Env}}{{println .}}{{end}}' | grep MCP_\n```\n\nSetting only some of them fails at startup and names the missing one. That is\ndeliberate: silently falling back to an open endpoint is how somebody exposes a\ntunnel believing it is protected. **The corollary is worth knowing when\ndiagnosing:** a server that is *running* and unauthenticated has seen none of\nthe three — if you believe you set them, the container is stale, not the config\nwrong.\n\n#### Why OAuth and not a shared token\n\nOAuth is the only credential this server accepts, because **it is the only one\na Claude connector can present.** A connector is added at the account level so\none entry has to work on claude.ai, Claude Desktop and iOS, and on a personal\nplan the \"Add custom connector\" dialog offers an OAuth Client ID and Secret and\nno request-header field. A local stdio bridge is not a way around it either —\nit cannot run on iOS. A shared-secret header would sit in `.env` with no client\nable to send it, leaving the write tools permanently refused on exactly the\ndeployment this project is built for. (If a long-lived `.env` still carries\n`MCP_AUTH_TOKEN` from a pre-2.0 install, delete the line: nothing reads it, and\nit does not gate `/mcp`.)\n\nWhen you connect, Claude discovers the endpoint, sends you to a consent page\nserved by this server, and you type the passphrase. There is nothing to\nregister and no client secret to store.\n\n#### What each part protects\n\n`/health` and the `/.well-known/*` discovery documents are deliberately\nunauthenticated — the container's healthcheck presents no credential, and a\ndocument a client fetches *in order to* authenticate cannot itself require\nauthentication.\n\n`select_profile`, `upload_profile` and `delete_profile` — the tools that change\nthe machine — need the `espresso:write` scope. A token without it gets a `403`\nthat prompts Claude to ask you for the extra permission rather than failing\nsilently. With\nnothing configured at all they refuse to run and say so, which is why an open\nserver is a defensible default for a LAN and a machine-control tool on one is\nnot.\n\nRequests carrying an `Origin` header are rejected unless the origin is listed in\n`MCP_ALLOWED_ORIGINS`. This is what stops any web page you happen to visit from\nPOSTing to a server running on your own network — a token does not help there,\nbecause the browser sends it for you. Requests with no `Origin` (Claude Desktop,\n`curl`, anything that is not a browser) are unaffected, so the default empty list\nis the right setting for almost everyone.\n\nListing an origin also makes `/mcp` answer that origin's CORS preflight and\necho `Access-Control-Allow-Origin` (plus `Access-Control-Expose-Headers:\nmcp-session-id`, without which a browser client can read the handshake but not\nthe session it needs to continue with). Allowing an origin the browser then\nblocks would be an allowlist that allows nothing.\n\n`scripts/test-auth.sh` probes a running server for all of the above — the\ndiscovery chain, the shape of the `401`, cross-host redirects and origin\nvalidation. **Run it from outside your LAN.** Every failure it catches is a\nfailure of the URL *as Claude reaches it*, and the one that bites most often —\n`MCP_PUBLIC_URL` disagreeing with the URL you typed into Claude — is invisible\nfrom localhost.\n\n```bash\nBASE_URL=https://your-machine.tail-scale.ts.net ./scripts/test-auth.sh\n```\n\n### Customization\n\nThe server ships with generic profiles and prompts. Two local override files are merged on top of the defaults at startup:\n\n1. **`prompts.local.yaml`** - equipment-specific dial-in guidance (your grinder model, basket, and other equipment details).\n\n2. **`profiles.local.yaml`** - your own profiles, or overrides/removals of the defaults (set a profile ID to `null` to remove it).\n\nStart from the examples:\n\n```bash\ncurl -O https://raw.githubusercontent.com/ljcl/gaggiuino-mcp/main/apps/server/src/data/prompts.example-local.yaml\ncurl -O https://raw.githubusercontent.com/ljcl/gaggiuino-mcp/main/apps/server/src/data/profiles.example-local.yaml\n```\n\nThese hold personal equipment configuration, so they are deliberately never baked into the published image. To use them, uncomment the `volumes:` block in `docker-compose.yml`:\n\n```yaml\nvolumes:\n  - ./profiles.local.yaml:/app/apps/server/src/data/profiles.local.yaml:ro\n  - ./prompts.local.yaml:/app/apps/server/src/data/prompts.local.yaml:ro\n```\n\nFrom a repo checkout, copy each `*.example-local.yaml` to `*.local.yaml` alongside it in `apps/server/src/data/` instead - they are gitignored and picked up automatically.\n\n## Connecting to AI Tools\n\nMany AI tools (like Claude Desktop) route MCP requests through their own servers, not from your local machine. This means your MCP server needs to be accessible via a public HTTPS URL.\n\n> Every option in this section publishes `/mcp` to the internet. Turn on OAuth\n> first — see [Securing the endpoint](#securing-the-endpoint) — and set\n> `MCP_PUBLIC_URL` to the exact origin you are about to publish.\n\nWhichever ingress you pick, three things decide whether it works:\n\n- **One origin serves everything.** `/mcp`, `/.well-known/*` and `/oauth/*` all\n  have to be reachable at `MCP_PUBLIC_URL`. That is what keeps this one\n  container.\n- **Use a stable hostname.** A quick-tunnel hostname that rotates on restart\n  changes the advertised `resource`, and the connector breaks every time.\n- **No cross-host redirects.** If the registered URL `301`/`302`/`307`/`308`s to\n  a different host, the `Authorization` header is dropped on the way. This is\n  the usual cause of \"works in MCP Inspector or Claude Code but not claude.ai\" —\n  apex-to-`www` canonicalisation in front of the server is the common way to hit\n  it. `scripts/test-auth.sh` checks for it.\n\nClaude caches discovery documents globally by URL for about five minutes, so a\nmetadata change is not live immediately — and a broken deploy's metadata can be\nserved for a few minutes after you fix it.\n\n### Tailscale Funnel (Recommended)\n\n[Tailscale Funnel](https://tailscale.com/kb/1223/funnel) exposes your server to the internet via a secure HTTPS URL:\n\n```bash\ntailscale funnel --bg 8000\n# URL: https://your-machine.tail-scale.ts.net/mcp\n\n# then, in your .env\nMCP_PUBLIC_URL=https://your-machine.tail-scale.ts.net\n```\n\n**Funnel, not Serve.** Claude reaches your connector from Anthropic's own\ninfrastructure, and it refuses a hostname before sending a byte if any resolved\naddress is not globally routable — explicitly including `100.64.0.0/10`, which\nis the tailnet's own range. `tailscale serve` publishes exactly those addresses.\nFunnel publishes public records pointing at Tailscale's relays instead.\nConnectors are also IPv4-only, so a hostname publishing only `AAAA` records\ncannot be reached. One check covers both:\n\n```bash\ndig +short your-machine.tail-scale.ts.net   # must return a routable IPv4 address\n```\n\nTwo things people reach for here and should not:\n\n- **Do not allowlist Anthropic's egress range (`160.79.104.0/21`) as an access\n  control.** Funnel does not forward the client IP, and `/oauth/authorize` is\n  reached by *your own browser*, not by Anthropic — so an IP allowlist breaks\n  the login while protecting nothing.\n- **Do not trust the `Tailscale-User-Login` header.** `tailscaled` does strip\n  forged copies, but only on traffic it proxies. This project ships\n  `network_mode: host` with `HOST=0.0.0.0`, so the listener is directly\n  reachable and anything on the host can set that header itself. Believing it\n  would be a write-scoped authentication bypass.\n\n### Cloudflare Tunnel\n\nUse [cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) to create a persistent tunnel to your server. Use a **named** tunnel with a\nhostname you own, not a quick tunnel — a `trycloudflare.com` hostname changes on\nevery restart, and `MCP_PUBLIC_URL` has to change with it.\n\n### ngrok\n\n```bash\nngrok http 8000\n```\n\nSame caveat: a free ngrok hostname rotates. Reserve a domain, or expect to\nre-add the connector each time.\n\n### Behind your own reverse proxy\n\nProxy all three paths — `/mcp`, `/.well-known/*` and `/oauth/*` — to the same\nupstream, and set `MCP_PUBLIC_URL` to the public origin. The proxy must not\ncanonicalise across hosts (see the redirect note above), and it must pass the\n`Authorization` header through untouched.\n\n### Using an external identity provider\n\nIf you already run Authentik, Authelia, Keycloak, Zitadel, Kanidm or `tsidp`,\nset `MCP_OAUTH_ISSUER` to its issuer URL and this server stops being an\nauthorization server at all:\n\n```bash\nMCP_PUBLIC_URL=https://gaggiuino.tail1234.ts.net\nMCP_OAUTH_ISSUER=https://idp.example.com/realms/home\n```\n\n`/oauth/authorize`, `/oauth/token` and\n`/.well-known/oauth-authorization-server` stop being served entirely, and\nprotected-resource metadata points Claude at your issuer instead. Nothing is\nsigned here any more, so `MCP_OAUTH_SECRET` and `MCP_OAUTH_PASSPHRASE_HASH`\nhave no purpose — setting either alongside `MCP_OAUTH_ISSUER` is **refused at\nstartup** rather than ignored, because ignoring it would leave you believing in\na consent page that is never rendered.\n\n`MCP_PUBLIC_URL` is still required: it is what the token's audience is checked\nagainst.\n\nFour things have to be true on the identity provider, whatever the product:\n\n1. **S256 PKCE** advertised in `code_challenge_methods_supported`.\n2. **Discovery reachable from Anthropic's egress range**, `160.79.104.0/21`. A\n   WAF in front of your identity provider breaks the flow even when this server\n   is perfectly reachable.\n3. **Token audience equal to `MCP_PUBLIC_URL` + `/mcp`.** Keycloak needs an\n   Audience-mapper client scope for this — it has no RFC 8707 support for the\n   current spec versions, so the property the spec insists on hardest is manual\n   configuration there.\n4. **Redirect URI `https://claude.ai/api/mcp/auth_callback`**, plus\n   port-agnostic `http://localhost/callback` and `http://127.0.0.1/callback` if\n   you use Claude Code.\n\n**Dynamic client registration is not required**, which is worth stating\nplainly because it changes the compatibility story: Claude accepts a\npre-registered client ID pasted into the connector dialog, so Authelia,\nAuthentik, Zitadel and Kanidm — none of which do DCR — are all perfectly usable\nwith one manually created client.\n\nTokens must be signed **RS256 or ES256**. No other algorithm is verified, and\nthat is a deliberate limit rather than an omission: accepting `HS256` against a\nkey fetched from a JWKS is the classic algorithm-confusion attack, in which an\nattacker signs a token with the issuer's *public* key.\n\nDiscovery follows RFC 8414 first and falls back to\n`/.well-known/openid-configuration`, so issuers that publish only the OpenID\ndocument work unchanged. `scripts/test-auth.sh` already probes that chain\nagainst whatever `authorization_servers` points at, so it diagnoses an external\nissuer as readily as the built-in one.\n\n`tsidp` deserves a note on cost rather than a recommendation: it means a second\ncontainer, a persistent `/data` volume, a tailnet ACL grant for `allow_dcr`,\n`TAILSCALE_USE_WIP_CODE=1`, and an upstream that describes itself as\nexperimental and pre-1.0. Reach for it if you are already deep in Tailscale;\notherwise the built-in authorization server is less moving parts.\n\n### Local Network Only\n\nIf your AI tool connects directly (e.g. local MCP server config), use the direct address:\n\n```\nhttp://<docker-host-ip>:8000/mcp\n```\n\n### Adding to Claude Desktop\n\n1. Go to **Settings** > **Integrations** > **Add More** > **Add Remote MCP Server**\n2. Set the URL to your public HTTPS endpoint (e.g. `https://your-machine.tail-scale.ts.net/mcp`)\n3. Save and enable\n\n## Architecture\n\n```\nAI Tool (Claude Desktop, etc.)\n    |\n    |  HTTPS\n    v\n+-----------------------------+\n|  HTTPS Tunnel               |\n|  (Tailscale / Cloudflare /  |\n|   ngrok / reverse proxy)    |\n+-----------------------------+\n    |\n    |  HTTP (localhost:8000)\n    v\n+-----------------------------+\n|  Gaggiuino MCP Server       |\n|  (Docker container)         |\n|  Bun + Streamable HTTP      |\n+-----------------------------+\n    |\n    |  HTTP (local network)\n    v\n+-----------------------------+\n|  Gaggiuino                  |\n+-----------------------------+\n```\n\nThe `/mcp` endpoint is **dual-era**, via the official v2 MCP SDK: it serves\nthe stateless 2026-07-28 revision (per-request metadata, `server/discover`, no\nsessions) and the legacy `initialize`-handshake revisions (2025-03-26 through\n2025-11-25) concurrently, so clients keep working while hosts migrate to the\nnew specification. Both eras are served statelessly — no session ids are\nminted (the 2025 spec always made them optional), and the legacy GET/DELETE\nsession operations answer 405.\n\n## Development\n\n```bash\ngit clone https://github.com/ljcl/gaggiuino-mcp.git\ncd gaggiuino-mcp\n\nbun install          # Install all dependencies\n\nbun run build        # Build all packages (Turborepo)\nbun run test         # Run all tests\nbun run lint         # Lint all packages\nbun run check        # lint + test + typecheck + build + knip + boundaries + size\n\n# Server\ncd apps/server\nbun run dev          # Watch mode\nbun run test         # Server tests only\n\n# Shot graph UI (run from the repo root)\nbun run storybook    # Storybook on port 6006\n\n# Regenerate JSON schemas (after changing Zod schemas in loader.ts)\ncd apps/server\nbun run generate-schemas\n```\n\nThe `main` branch Storybook is published to GitHub Pages at\n[ljcl.github.io/gaggiuino-mcp](https://ljcl.github.io/gaggiuino-mcp/) — a static build for\nbrowsing the shot-graph and UI components without running anything locally.\n\n### Running without a machine\n\nThe espresso machine is switched off most of the day, and you may not be on its\nnetwork at all. `bun run fake-machine` serves recorded `/api/*` payloads so the\nserver has something to talk to:\n\n```bash\nbun run fake-machine                  # port 8080; --port N to change it\n\n# in another shell\nGAGGIUINO_URL=http://localhost:8080 bun run dev\n```\n\nThat gives you a working server: `get_status`, `list_profiles`,\n`get_profile_info`, `get_maintenance_status`, and two real ~190-sample shot\ncaptures for `get_shot_data` and `view_shot_graph`. `/health` reports\n`machine.state: \"ok\"` once a tool has made a request, and fills in\n`machine.versions` after anything reads the settings.\n\nThe payloads are recorded rather than invented — the status response is a\nverbatim capture off real hardware, the settings and profile definitions come\nfrom the vendored reference, and the shots are real captures. A test drives the\n**real client** over the same route table, so a payload the client would reject\nfails CI rather than quietly teaching the fake a wire format the firmware does\nnot use.\n\n**What it is not.** It does not replace hardware verification, and a few things\nare out of reach by construction:\n\n- **Writes are refused with a 501.** It holds no state, so acknowledging a\n  `select_profile` would be contradicted by the very next `list_profiles`.\n- It cannot reproduce anything genuinely firmware-shaped: the 503 an ESP32\n  returns while writing a shot to flash, its one-request-at-a-time\n  serialisation, the WebSocket-only profile update path, or the type\n  inconsistencies of a firmware revision nobody has captured yet.\n- Shot ids are offset into a `900000000+` range so a fixture shot can never be\n  mistaken for one of yours.\n\nIt never ships: the executable is at repo-root `scripts/`, which is not part of\nthe Docker build context, and its payloads are excluded from the image along\nwith the rest of the test scaffolding.\n\n### Docker\n\n`docker-compose.yml` pulls the published image. To build and run the image from your\ncheckout instead, layer the build override on top of it:\n\n```bash\ndocker compose -f docker-compose.yml -f docker-compose.build.yml build\ndocker compose -f docker-compose.yml -f docker-compose.build.yml up -d\ndocker compose logs -f\n```\n\nThe override tags the result `ghcr.io/ljcl/gaggiuino-mcp:dev` so a local build is never\nmistaken for a published release.\n\n## Troubleshooting\n\n### Can't connect to gaggiuino.local\n\nmDNS (`.local` hostnames) may not work inside Docker containers. Use the IP address directly in `GAGGIUINO_URL`.\n\n### AI tool can't reach the server\n\nEnsure your server is publicly accessible via HTTPS. Tools like Claude Desktop route requests through their own servers, so Tailnet-only access (e.g. `tailscale serve`) won't work - you need a public tunnel (e.g. `tailscale funnel`).\n\n### Server starts but can't reach Gaggiuino\n\nThe container uses `network_mode: host` to share the host's network stack. If your Gaggiuino is on a different network segment, adjust your Docker networking configuration.\n\n## License\n\n[MIT](LICENSE) © Luke Clark\n",
  "bytes": 25907,
  "sha": "8c3cdcdde1907f088b86a34b48b2a53e2627e099e1993e28377498f1e5a1bdbf",
  "repo_slug": "ljcl/gaggiuino-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_ljcl_gaggiuino_mcp_3114bf65/readme"
}