@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.
Maintainers
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 readonlyCredentials 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 buildThe 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 browserThe 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; defaulthttps://admin-api.cellarnode.com(the dedicated host that routes/to the admin service on the internal gateway), orhttp://localhost:4001whenCELLARNODE_MCP_API_URLpoints at localhost.https://admin.cellarnode.comalso 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 answersdevice login disabled on this serverwhen 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 withredirect: "error". - No env token: the admin profile refuses to start when
CELLARNODE_MCP_TOKENis set (device login is the only admin credential). serve-httpunder the admin profile requiresCELLARNODE_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_usagefollows 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 — arevealargument 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 carrytruncatedandpiiMasked: true. - Wire types: generated from the minimal admin snapshot
(
openapi/admin-api.json→src/api/admin.gen.ts; refresh withnpm 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-writetoolset, admin profile only; never remote; FAIL-CLOSED:createCellarNodeMcplists them only whenadminWrites: trueis 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), andreadOnlyHint:false+destructiveHint:true; the ones that email, publish or spend (approve_org_request,reject_org_request,reanalyze_matches,approve_tender) are alsoopenWorldHint:true, which makes them commit tools: the host confirms and the model is told to confirm with you first.approve_org_requestrequires all four review-checklist attestations to betrue(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-httpand the remote/adminsurface 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,expectedVersionoptimistic lock),list_claim_evidence/get_claim_evidence_policy/create_claim_evidence/delete_claim_evidence.lab-reports(needs theelabelentitlement):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 afailedreport, capped per organisation per month:RATE_LIMITED, not retryable, withdetails.limit,usedandresetsAt).- Uploading a lab report is not available to the assistant yet (file intake, CEL-2293 G02).
- Also on-demand, in the
elabeltoolset (e-label API; the house-style, policy and preview routes need theelabelentitlement and a finished e-label registration, elseNOT_FOUNDwith 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) andexport_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 jointools/list, the server sends ONEnotifications/tools/list_changed(debounced), and the result lists each tool's parameters inline. Unknown toolset →NOT_FOUNDnaming 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_FOUNDlisting the callable tools and a "did you mean".- Remote mount, writes:
?toolsets=a,b. Append?toolsets=automation,notificationto a surface URL (/producer,/elabel, …; bothserve-httpandserve-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: [...]oncreateCellarNodeMcp. - Opt out:
CELLARNODE_MCP_DISCOVERY=off(ordiscovery: "off") lists every tool. An explicittoolsallowlist also disables it. Note: enabling every on-demand toolset (ordiscovery: "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:smokeCI (.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.
