npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@cellarnode/mcp

v0.10.0

Published

CellarNode platform capabilities as an MCP server — curated tools over the V2 public API for producer/importer agents and local development.

Readme

@cellarnode/mcp

The CellarNode platform's capabilities as an MCP server — curated, agent-facing tools over the platform's APIs, covering every role (producer, importer, admin) rather than any single surface. For local-dev coding agents today (stdio + .mcp.json), the future e-label agent runtime later (Pydantic AI / LangGraph over MCP), and admin/ops agents via GitHub device flow (M7) — plus fast product testing: point an agent at your local stack and act on it directly.

Status: M1 foundations merged, @cellarnode/mcp publishing to npm via OIDC (Linear P-CEL-49). M2 started: the read-only catalog (CEL-2213) — identity, portfolios, beverages, variants (+awards/certificates/attachments), offers (sent/received), tenders, opportunity search, matches (incl. the free-tier teaser) — write tools and extraction lifecycle arrive with the rest of M2/M3.

Use

cellarnode-mcp login --dev --email you@org   # once, against the local stack (POST /test/login)
cellarnode-mcp login --email [email protected]     # real OTP flow (code prompt; refresh cookie persisted)
cellarnode-mcp status                        # api base, profile, store backend, credential state
cellarnode-mcp whoami                        # proves the stored credential through the real adapter
cellarnode-mcp                               # serve stdio; default profile readonly

Credentials are origin-scoped (Keychain com.cellarnode.mcp on macOS, 0600 file elsewhere via CELLARNODE_MCP_STORE), auto-refreshed ~60s before the 900s expiry via the family refresh cookie. serve acts as the logged-in identity; an explicit CELLARNODE_MCP_TOKEN overrides the store (CI smoke). --dev requires the local stack with ENABLE_TEST_ENDPOINTS=true (5/min rate limit). Note: the Keychain backend passes the record via security argv (visible in ps for the call's lifetime) — a dev-laptop tradeoff to avoid native modules; use CELLARNODE_MCP_STORE=file on shared machines.

// .mcp.json
{
  "mcpServers": {
    "cellarnode": {
      "command": "cellarnode-mcp",
      "env": {
        "CELLARNODE_MCP_API_URL": "http://localhost:4000",
        "CELLARNODE_MCP_PROFILE": "producer"
      }
    }
  }
}

Profiles: readonly (default — read-only tools only), producer (portfolio, variants, extraction jobs, matches, offer drafts), importer (tenders, offers received, notifications).

Strict inputs. Tool arguments are validated strictly: an unknown or misspelled parameter is rejected with a tool error listing the valid ones (instead of being dropped), and type/required errors name the parameter and the expected type, so an agent can self-correct.

Develop

make build      # clean + typecheck + compile
make test       # vitest (MCP-loop tests through InMemoryTransport)
make inspect    # MCP Inspector against the local build

The seam: tools are pure functions over CellarNodeApiPort (src/port/index.ts); production uses the HTTP adapter, tests use createInMemoryApiPort from @cellarnode/mcp/testing. Adding a capability = port methods + adapters → catalog entry → profile membership; the factory never changes.

Design: docs/specs/2026-10-01-cellarnode-mcp.md (workspace docs/). License: UNLICENSED (private to the CellarNode org).

Tested with

Third-party agent-framework MCP clients are smoke-tested against the real server factory over stdio and stateless streamable HTTP (CEL-2229). No LLM calls: each leg discovers the producer-profile tools and calls whoami / list_beverages (the Mastra leg also calls a write tool and checks that tool annotations reach the client). An authenticated Mastra leg (tests/interop/mastra-auth.test.ts, CEL-2324) runs the remote mount in OAuth mode against a stub backend: per-request credentials (personal API key and AS access token) through one shared client, clean 401s without retries, discovery and ?toolsets=, the token exchange staying inside the server, and sentinel checks for credential leaks.

| Framework | Client layer | Version | | --- | --- | --- | | Mastra | MCPClient (@mastra/mcp, with @mastra/core) | 2.2.0 (core 1.75.0), pinned exactly | | Pydantic AI | MCPToolset (pydantic-ai-slim[mcp]) | 2.54.0 | | LangChain | langchain.mcp.MCPAdapter (beta; langchain[mcp]) | 1.4.3 (fastmcp 4.0.11) |

langchain-mcp-adapters is the older, separate package; LangChain's first-party adapter now lives in langchain.mcp (beta, so the API may change). Run everything with make interop (needs node and uv); the Python legs live in interop/python/ (versions pinned in uv.lock), the fixture server and the Mastra leg in tests/interop/. These suites are excluded from make test and run in their own, path-filtered CI job.

Agents (contracts frozen, runtimes later)

Frozen agent contracts are exported from the package root (import … from "@cellarnode/mcp"): the agent registry entry (an A2A AgentCard subset), the task lifecycle (A2A TaskState set with terminal immutability), artifacts, and the AG-UI-1.0 progress-event vocabulary with a sequence validator (run bracketing, tool-call pairing). Types + Zod validators only — no runtime, no framework. The Mastra-first orchestrator (CEL-2242) and module agents build on these; wire-level A2A stays deferred until a module agent crosses a trust boundary.

Recipes

Wire an agent to the local stack (.mcp.json)

Drop this in the repo (or user scope) running against local dev:

{
  "mcpServers": {
    "cellarnode": {
      "command": "npx",
      "args": ["-y", "@cellarnode/mcp"],
      "env": {
        "CELLARNODE_MCP_API_URL": "http://localhost:4000",
        "CELLARNODE_MCP_PROFILE": "producer"
      }
    }
  }
}

Login once first (npx -y -p @cellarnode/mcp cellarnode-mcp login --dev --email <seed-email>) — the credential store is origin-scoped and shared by every host. Set the profile to the acting role: readonly for inspection/QA, producer or importer for writes. The role profiles (producer 145, importer 49, elabel 99) list only tools their role can use. readonly (22) is the read union, so some calls return 403 or empty results depending on the session's user type. admin = whoami + 11 read-only admin tools + 6 admin write tools (see below). Fewer visible tools = better agent selection.

Admin (CELLARNODE_MCP_PROFILE=admin)

The admin profile talks to the ADMIN API (:4001, internal Envoy gateway, VPC-only: connect to the VPN / Headscale tailnet first). Sign in once:

cellarnode-mcp login --admin        # GitHub device flow: prints a code + URL, you approve in a browser

The CLI fetches /auth/device/config, runs GitHub's device flow, POSTs the resulting GitHub token to /auth/device/exchange (the only CellarNode endpoint it ever reaches), discards it, and stores ONLY the minted CellarNode admin JWE (1 h, no refresh token) in the credential store keyed by the admin origin. When it expires, or on any 401, tools fail with AUTH_REQUIRED and the exact command to re-run: a tool call never starts an interactive login. The GitHub token is never stored or logged, and the admin JWE is only ever sent to the admin origin.

  • Admin API base: CELLARNODE_MCP_ADMIN_API_URL; default https://admin-api.cellarnode.com (the dedicated host that routes / to the admin service on the internal gateway), or http://localhost:4001 when CELLARNODE_MCP_API_URL points at localhost. https://admin.cellarnode.com also serves the prefixes the MCP needs (/auth, /admin, ...) behind a 10.20.0.0/16 allow; set the env var to use it. The server answers device login disabled on this server when the exchange is switched off.
  • https only: a non-loopback admin origin must be https (--api, CELLARNODE_MCP_ADMIN_API_URL), and it must differ from the public API origin (the credential store is keyed by origin). The login CLI sends every request with redirect: "error".
  • No env token: the admin profile refuses to start when CELLARNODE_MCP_TOKEN is set (device login is the only admin credential).
  • serve-http under the admin profile requires CELLARNODE_MCP_INBOUND_TOKEN (at least 32 characters, at least 8 distinct, after trimming) even on loopback: the device token carries full admin authority, so an unauthenticated local mount would hand it to any process on the machine. Without a strong token the mount refuses to start.
  • 11 read-only admin tools (CEL-2234), all admin-only and GET-only: admin_find_organizations, admin_get_organization, admin_find_users (exact email or org id only), admin_get_user, admin_list_offers, admin_list_tenders, admin_list_extraction_jobs, admin_moderation_queue, admin_list_backfill_runs, admin_list_workflows, admin_platform_stats. (admin_get_mcp_usage follows once CEL-2283's /admin/mcp/* routes exist.) Admin writes are out of scope for v1.
  • Masked-only PII. These tools return cross-organisation customer data that then enters the admin's LLM context, which may be a third-party model vendor. v1 therefore masks every user/member name and email (stable per value, e.g. ja***@ac***.com#3fa9, so rows stay distinguishable and joinable) and has no reveal/unmask option — a reveal argument is ignored. Unmasking would be a data-egress decision for the owner, so it is a possible future, explicitly approved change, not a flag. Outputs are also allowlisted (no addresses, phone numbers, tax ids, contact objects, storage keys, signed URLs, raw job/workflow blobs), capped (default 20, max 50 rows), and carry truncated and piiMasked: true.
  • Wire types: generated from the minimal admin snapshot (openapi/admin-api.json → src/api/admin.gen.ts; refresh with npm run admin-sync) for every admin route, users included; dates are parsed defensively to ISO strings. A test runs every admin tool through the real adapter against the admin route index, so a tool mapped to a route the admin server does not serve fails CI.
  • The server CANNOT narrow a device token per route: it carries full admin authority (roles: ["admin"]), so what the profile can do is purely the tool set. Treat the stored credential like an admin session.
  • 6 admin write tools (CEL-2308, admin-write toolset, admin profile only; never remote; FAIL-CLOSED: createCellarNodeMcp lists them only when adminWrites: true is passed, which only the stdio entry point (stdioServerConfig) does; embedders and every HTTP mount get none): set_organization_verified, approve_org_request, reject_org_request, reanalyze_matches (max 50 ids, paid model calls), revoke_user_sessions, approve_tender. Each is one explicit action on one record, strict ids (UUIDs), and readOnlyHint:false + destructiveHint:true; the ones that email, publish or spend (approve_org_request, reject_org_request, reanalyze_matches, approve_tender) are also openWorldHint:true, which makes them commit tools: the host confirms and the model is told to confirm with you first. approve_org_request requires all four review-checklist attestations to be true (the backend refuses otherwise), and the requester's contact data never appears in any result. There is no org-request or match listing tool yet, so those ids come from you. Stdio only: serve-http and the remote /admin surface never expose the write tools, whatever the environment (no switch).

Importer tender lifecycle (tender-lifecycle, CEL-2344)

What the importer dashboard does with a tender after it is created, as 10 on-demand tools on /importer (reads work through call_tool; the writes need ?toolsets=tender-lifecycle or discover_tools {toolset: "tender-lifecycle"}, so the importer's listed set stays at 29). Reads: list_tender_drafts, get_tender_editor (the owner view, with the version that update_tender now requires). Private writes, confirm-first by description: add_tender_opportunity (drafts only; a published tender is never added to), update_draft_opportunity (re-sends the tender's other lines unchanged, because the API removes any line it is not sent), delete_tender_draft (drafts only), duplicate_opportunity (a new private draft; also how a closed or expired opportunity is republished). Commit tools (destructive + open-world): update_opportunity_requirements, update_opportunity_delivery_terms (frozen while live offers exist: INVALID_JOB_STATE with the count), close_opportunity, reopen_opportunity. In the listed tender-write toolset, update_tender moved to the versioned PATCH (version required, VERSION_CONFLICT on a race) and publish_tender now checks the wizard's publish rules first and lists every gap (the backend also refuses a tender with no opportunity, 422).

Automation (schedules)

Producer and importer profiles get a 5-tool automation toolset: create_schedule, list_schedules, update_schedule (also pause/resume via status), delete_schedule, list_schedule_runs. "Every morning, fetch my offers" becomes cron 0 7 * * * in the user's timezone with action offers_digest (importers) or matches_digest (producers); the digest is delivered in-app (it shows up in list_notifications). The cron grammar is deliberately restricted (one minute value, hour * | N | */N, ≥ 1 h interval); the cap is 20 schedules per org (paused ones count). Scheduled execution may not be switched on in every environment yet; if no runs appear after the due time, tell the user rather than recreating the schedule.

E-label (CELLARNODE_MCP_PROFILE=elabel)

The elabel profile is the producer-portfolio parity set (catalog, extraction, attachments, notifications: 67 tools) plus the 12-tool e-label config toolset (configs CRUD, adopt, publish, QR render/preview, design suggestions, the public published view). Config/QR/suggestion calls go to the e-label backend (CELLARNODE_ELABEL_API_URL, default http://localhost:4004) with the SAME session token as the main API (needs the elabel or producer-dashboard entitlement); get_published_elabel hits the anonymous public endpoint and sends no credential. QR tools return {mimeType, base64} and refuse results over 2 MB (streamed and counted, so a chunked body is cut off at the cap). Publishing is a commit action — agents confirm first. On top of that sit 20 on-demand compliance tools (see "Compliance data"). The producer profile serves the same toolset too: list/create/update/publish_elabel_config are always listed there, the other eight are on-demand (reads through call_tool, writes with ?toolsets=elabel or discover_tools {toolset: "elabel"}), and the tools are offered only to callers holding the elabel entitlement. When the main API URL is not loopback, CELLARNODE_ELABEL_API_URL is REQUIRED (an https origin); the server refuses to start without it, and never sends the session credential to a plaintext-http e-label origin while the main API is https. Lab-report-anchored (QR-first) configs are listed but get/update/delete/QR of them fail with INVALID_JOB_STATE until adopt_elabel_variant rekeys them onto a variant. update_elabel_config merges a partial qrStyle onto the stored one, so the centre logo is preserved.

Compliance data (CEL-2343)

What the e-label dashboard edits, as on-demand tools on /producer and /elabel (read through call_tool; writes with ?toolsets=compliance / lab-reports). Writes need the toolset enabled. review_lab_report and reparse_lab_report are COMMIT tools (destructive + open-world: hosts confirm them and the routing rules list them); update_label_data, create_claim_evidence, delete_claim_evidence and link_lab_report are confirm-first by their descriptions only.

  • compliance (needs only a signed-in org member, no entitlement): get_label_data / get_label_data_completeness / update_label_data (the FIC form; merged writes, expectedVersion optimistic lock), list_claim_evidence / get_claim_evidence_policy / create_claim_evidence / delete_claim_evidence.
  • lab-reports (needs the elabel entitlement): list_lab_reports / get_lab_report (parsed values are unreviewed drafts; document text is untrusted data), review_lab_report, link_lab_report, reparse_lab_report (a paid parse of a failed report, capped per organisation per month: RATE_LIMITED, not retryable, with details.limit, used and resetsAt).
  • Uploading a lab report is not available to the assistant yet (file intake, CEL-2293 G02).
  • Also on-demand, in the elabel toolset (e-label API; the house-style, policy and preview routes need the elabel entitlement and a finished e-label registration, else NOT_FOUND with the next step): get_house_style, update_house_style (QR colours/styles, merged so a saved logo is kept), apply_house_style (bulk, confirm-first), get_elabel_preview_snapshot (what publishing would freeze now), get_elabel_compliance_policy / set_elabel_compliance_policy (facts in, the platform's verdict out; marks and wording cannot be supplied), get_elabel_proof_sheet (PDF) and export_elabel_qr_bulk (ZIP of up to 50). Artifacts come back as {mimeType, base64, sizeBytes} and over 2 MB are refused. Not included: copying a label's page style to the house (page-from-config) and editing the house page style, which stay in the e-label app.

Progressive disclosure (discover_tools)

Profiles whose full tool list is over the cap of 30 (producer 145, importer 49, elabel 99) list a core set plus two meta-tools; the rest of their toolsets are disclosure: "on-demand" (producer/elabel: notification, automation, attachment-write, variant-write, extraction-lifecycle, compliance, lab-reports; producer also dashboard; importer: reads in dashboard, importer-overview, offer-thread, notification, automation, producer-discovery, plus the whole tender-lifecycle toolset, writes included).

  • discover_tools {} — summaries: id, purpose, tool count, read/write, enabled.
  • discover_tools {toolset} — enables the toolset for this session (stdio / stateful transports): its tools join tools/list, the server sends ONE notifications/tools/list_changed (debounced), and the result lists each tool's parameters inline. Unknown toolset → NOT_FOUND naming the valid ids. On the stateless remote mount this lasts one request only (every POST is its own server).
  • call_tool {tool, arguments} — a read-only call-through: runs a READ tool (readOnlyHint: true) of an on-demand toolset without enabling it, validated by that tool's own strict schema. It is itself annotated read-only, so a host's "always allow call_tool" can only ever allow reads. Every write tool (delete_schedule, create_variant, …) is refused with the exact way to enable it, so each write is confirmed on its own name and annotations. Unknown tool → NOT_FOUND listing the callable tools and a "did you mean".
  • Remote mount, writes: ?toolsets=a,b. Append ?toolsets=automation,notification to a surface URL (/producer, /elabel, …; both serve-http and serve-remote) and those on-demand toolsets are listed on every request on that URL. Unknown ids → HTTP 400 (-32602) naming the valid ids for that surface. Programmatic equivalent: enabledToolsets: [...] on createCellarNodeMcp.
  • Opt out: CELLARNODE_MCP_DISCOVERY=off (or discovery: "off") lists every tool. An explicit tools allowlist also disables it. Note: enabling every on-demand toolset (or discovery: "off") goes back over the 30-tool cap (producer 145, importer 49, elabel 99) — only do it for clients that cope with long lists.

Per client:

| Client | Reads | Writes | |---|---|---| | stdio hosts that honour tools/list_changed (Claude Code, Cursor) | discover_tools {toolset} then call the tools directly | same | | hosts that ignore list_changed | call_tool | ?toolsets= URL (remote) or CELLARNODE_MCP_DISCOVERY=off (stdio) | | remote connectors (claude.ai, ChatGPT, …) — stateless | call_tool | the user adds ?toolsets=<id> to the connector URL |

Remote mount (serve-http)

cellarnode-mcp serve-http [--port N] [--host H] binds 127.0.0.1:8931 and acts as ONE operator identity (env token / credential store) for every caller. A non-loopback --host refuses to start unless CELLARNODE_MCP_INBOUND_TOKEN is set; with it set, every request needs Authorization: Bearer <token> (401 otherwise). Request bodies are capped at 25 MiB (413). Per-user OAuth identity arrives with CEL-2226. The mount negotiates the SDK's latest protocol revision (2025-11-25).

Multi-surface remote mount (serve-remote, CEL-2300)

cellarnode-mcp serve-remote [--port N] [--host H] [--health-port N] [--health-host H] [--surfaces a,b] [--origin O]... runs ONE stateless process serving five endpoints, each bound to its own profile: /producer, /importer, /elabel, /readonly, /admin. Every other path (including the legacy / and /mcp, and the reserved /.well-known/oauth-protected-resource/<surface> paths) is 404. Same inbound rules as serve-http: loopback by default, a non-loopback --host requires a strong CELLARNODE_MCP_INBOUND_TOKEN, 25 MiB body cap, request/header timeouts. The admin surface has privilege separation: it is served only with its OWN strong bearer CELLARNODE_MCP_ADMIN_INBOUND_TOKEN (>= 32 chars, distinct from the shared token) and is gated only by it: the shared token cannot reach /admin, and the admin token cannot reach the other surfaces. serve-remote serves /admin by default only when that variable is set (or --surfaces asks for it, which then refuses to start without it). The admin surface also keeps its own API origin (CELLARNODE_MCP_ADMIN_API_URL, https) and device-flow credential only. Fail-closed startup. serve-remote refuses to start (exit 1) unless MCP_REMOTE_ENABLED is true (case-insensitive): it is the master switch and is never derived from anything else. It also refuses without an upstream credential: either the token exchange (CELLARNODE_MCP_OAUTH_ISSUER + MCP_EXCHANGE_TOKEN (>= 32 chars) + CELLARNODE_MCP_ADMIN_API_URL (https); all three or none, the public base defaults to https://mcp.cellarnode.com via CELLARNODE_MCP_PUBLIC_BASE_URL) or an explicit operator CELLARNODE_MCP_TOKEN. It never falls back to the on-disk credential store (in a pod that is an empty file: the pod would go Ready and then fail every call with "login"). With the exchange, a client sends an OAuth access token or a personal API key (Authorization: Bearer cnp_...); the pod exchanges it per request at the backend (POST /internal/mcp/exchange) and calls upstream with the returned short-lived JWE, never the client's credential.

Until OAuth is enabled (issuer + exchange above) every caller who passes the inbound gate acts as the single operator identity; that is the seam where per-surface protected-resource metadata, audience/scope checks and per-caller principals plug in.

Every upstream call carries x-cellarnode-mcp-surface: <surface> next to the existing x-cellarnode-client / x-cellarnode-mcp-tool attribution headers.

Health server (separate port, default 9090, MCP_HEALTH_PORT / --health-port; no gateway route may expose it). It binds 127.0.0.1 by default. In Kubernetes set MCP_HEALTH_HOST=0.0.0.0 (or --health-host): the kubelet probes and the admin API's stats pull both connect to the pod IP, which a loopback bind would refuse. Requests are GET-only with 5 s request/header timeouts. Port flags and env values are validated (integer 0-65535) with a clear error.

| Path | Purpose | |---|---| | /livez | process is up; never depends on an upstream | | /readyz | {ready, surfaces:{<s>:{catalog, jwks, upstream, exchange}}}; exchange = a token-exchange client is configured for the surface (always false for admin); 503 when a PUBLIC surface is not ready (including when the pod has no upstream credential source at all) (admin is reported but does not gate pod readiness). upstream = the backend's /readyz returned 200 within the last 15 s (cached 15 s). jwks is the literal "n.a." until OAuth (T9); the admin aggregator treats it as satisfied. | | /internal/stats | bearer MCP_STATS_TOKEN (constant-time); disabled (404) when unset. Per surface: inFlight, 60 wall-aligned 15 s buckets[{start, processed, errors, byMethod}] (15 min), p95Ms5m, masked principals5m/principals1h (cap 10 000), clientHosts5m. | | /metrics | Prometheus text (cellarnode_mcp_remote_*{surface}); aggregate counts only, nothing consumes it yet |

Stats semantics: processed counts completed tools/call requests only (not HTTP in general, tools/list, initialize, upstream calls, or requests rejected 401/403 before a tool ran). errors = tool results with isError excluding the business codes VALIDATION_FAILED|FORBIDDEN|NOT_FOUND|VERSION_CONFLICT (and the SDK's plain-text input rejections), plus JSON-RPC protocol errors on a tools/call, plus 5xx; probes such as server/discover show up in byMethod, not in errors. Principals never leave the pod raw: p_ + the first 8 bytes of HMAC-SHA256(grantId|keyId, daily pepper), the pepper derived per UTC day from MCP_STATS_PEPPER (share it across pods so unions dedupe). It is deliberately SEPARATE from MCP_STATS_TOKEN (the holder of the stats bearer must not be able to reverse masks) and there is no fallback: with no non-empty pepper the feature fails closed, no principal is recorded, and /internal/stats reports principalsEnabled: false (connections are unknown, not zero). No emails, ids, tokens or IPs appear anywhere in the health payloads.

server/discover (2026-07-28 probes) gets a normal JSON-RPC "method not found" error from SDK 1.31, with or without an MCP-Protocol-Version: 2026-07-28 header; the legacy initialize path keeps working.

Container image (serve-remote, CEL-2302)

Dockerfile builds the remote mount: multi-stage on node:22-bookworm-slim (engines >=20.19), prod dependencies only, non-root uid 1000, read-only-rootfs friendly (HOME=/tmp), EXPOSE 8080 9090 (MCP port PORT, health/stats port MCP_HEALTH_PORT; 9090 must never be routed), ENTRYPOINT ["cellarnode-mcp"], default CMD serve-remote --host 0.0.0.0 --surfaces producer,importer,elabel,readonly. Binding all interfaces means it refuses to start without a strong CELLARNODE_MCP_INBOUND_TOKEN (fail closed). No secrets are baked in. Local build + the CI smoke check:

docker build -t cellarnode-mcp-remote:smoke .
scripts/image-smoke.sh cellarnode-mcp-remote:smoke

CI (.github/workflows/image.yml): every PR/main builds the image on a GitHub-hosted runner and smoke-tests the real container (refuses to start without auth; /livez 200; MCP port 401 unauthenticated; /admin and unknown paths 404; non-root, read-only rootfs). Pushing to Artifact Registry (europe-north1-docker.pkg.dev/festive-terrain-478011-h0/mcp/mcp-remote:<sha7>, no :latest; with SLSA provenance + SBOM) runs only on main when the repo variable MCP_IMAGE_PUSH_ENABLED=true, after the dedicated identity exists in crossplane-gcloud (WIF pool mcp-image, service account mcp-image-publisher: Artifact Registry writer on the dedicated mcp (immutable tags) and mcp-cache repositories only, nothing on the shared beveriq, usable only by image.yml on main; this repo is deliberately NOT on the shared pipelines allow-list).

Read-only QA surface

Same snippet with CELLARNODE_MCP_PROFILE=readonly (the default): reads only, zero writes — safe to point at any environment. (make catalog-report prints the current census.)

MCP Inspector (manual exploration)

npx @modelcontextprotocol/inspector npx -y -p @cellarnode/mcp cellarnode-mcp

(In this repo, make inspect runs the Inspector against the local build.)

Lists tools, exercises inputs against the typed schemas, shows the structuredContent and error-contract shapes. The agent-facing skill (skill/SKILL.md — the repo copy is canonical; the live machine copy at ~/.agents/skills/cellarnode-mcp/ refreshes on release) maps the toolset by domain and coaches the job-polling etiquette.

Dashboard links instead of file URLs (G20)

Signed storage URLs are never given to the model. Tools that point a user at a file return a link into the right app instead (get_offer_file_link, and others as they adopt src/core/app-links.ts). The app hosts follow the deployment; override them with http(s) URLs (anything else is ignored with a warning):

| Variable | Default | | --- | --- | | CELLARNODE_PRODUCER_DASHBOARD_URL | https://producer.cellarnode.com | | CELLARNODE_ELABEL_APP_URL | https://elabel.cellarnode.com (its producer area is under /app) | | CELLARNODE_IMPORTER_DASHBOARD_URL | https://importer.cellarnode.com |

As a last line of defence the server removes signed-URL tokens (signature parameters of GCS, S3 and Azure SAS URLs, also when percent-encoded) from every tool result and every tool error. Code that calls a catalog tool's handler directly (for example the agents service) must go through the exported invokeTool, which applies the same redaction.