core-mcp
core-mcp is the door the LLM agent walks through to reach the tool fleet. It is a
Go service that speaks the Model Context Protocol
(streamable-HTTP over h2c) and does two jobs: it advertises the generation tools to an
agent as MCP tools, and it runs a full billing pipeline around every tool call —
scope, rate-limit, price, reserve, queue, relay progress, settle. When
core-tool-agent decides to call song_gen_v5,
the call lands here.
It is the Go twin of the Python consumer gateway: the same authenticate → price → reserve → queue pipeline, re-implemented for the agent path. That duplication is deliberate today and tracked debt — see Known limitations.
Where it sits
Section titled “Where it sits”flowchart LR
agent["core-tool-agent<br/>(LLM loop)"]
mcp["core-mcp<br/>/mcp · scope→price→reserve→queue→settle"]
db["core-database<br/>tasks · tools · tokens · chat"]
redis[("Redis<br/>rate-limit + task events")]
worker["generation worker<br/>(e.g. core-tool-sansaarm)"]
storage["core-storage<br/>→ Azure Blob"]
agent -->|"MCP tools/call"| mcp
mcp -->|"Connect/gRPC + Bearer"| db
mcp <-->|"windows + event stream"| redis
mcp -->|"QueueTask"| db
worker -->|"ClaimNextTask"| db
worker -->|"task events"| redis
redis -->|"progress + terminal"| mcp
worker --> storage
classDef gateway fill:#0ea5e9,color:#fff,stroke:#0369a1
classDef data fill:#8b5cf6,color:#fff,stroke:#6d28d9
classDef worker fill:#10b981,color:#fff,stroke:#047857
classDef external fill:#f59e0b,color:#111,stroke:#b45309
class mcp gateway
class db,redis data
class agent,worker worker
class storage external
core-mcp holds no Postgres credentials — like every other service it reaches data only
through core-database over Connect/gRPC, with an
Authorization: Bearer header carrying INTERNAL_RPC_SECRET. It reads the task event
stream and rate-limit windows straight from Redis.
What it does
Section titled “What it does”- Registry sync — a loop polls
GetActiveTools(ENVIRONMENT)on an interval andAddTool/RemoveToolson the live MCP server so the agent always sees the current tool set, each with its DB-derived input/output JSON schema. - The tool-call pipeline — turns one MCP
tools/callinto a scoped, rate-limited, billed, queued generation and relays the worker’s progress back as MCP notifications. - Event relay — consumes the
task:{id}:eventsRedis stream and forwardsPROGRESS,STREAMING_URL, and terminal events; races it against an authoritativeGetTaskpoll so a missed stream entry can’t stall the call. - Async completion — a built-in
get_generation_resulttool (registered directly on the server, never synced from the DB) lets a caller poll a long render bytask_idinstead of holding a/mcpPOST open. - Reconciler — a background sweep settles reservations orphaned when a client disconnects before the generation terminates.
The tool-call pipeline
Section titled “The tool-call pipeline”Everything below lives in
internal/toolcall/handler.go.
Each tools/call runs, in order:
-
Resolve scope. The
scopeMiddlewarestashes aScopeContext(user / workspace / project) on the request context; the handler rejects the call ifUserIDis empty. -
Clamp the license to the plan. Entitlements resolve (fail-closed to FREE) and the agent’s default license is clamped down to what the subscription allows — the agent path never lets a user type a license, so it clamps silently rather than reject.
-
Rate-limit.
FetchResolvedRateLimitsnumbers are enforced as Redis fixed windows, scaled by the plan multiplier. The counter key isuser:workspace:project— never tier-keyed, so a resolver flap can’t split counters and double the quota. -
Price.
FetchResolvedPricingreturns the estimate. ANotFoundhere means the tool was never priced — the handler returns an actionable tool error, not a 500. An explicit zero-cost row resolves and flows through as a free call; only a missing price is blocked. See Add tool pricing. -
Reserve. Hold the estimate against the token balance. For a
DYNAMICtool that declaresusage_field, the hold is quantity-aware (base + increment × n) so the balance check runs before the task is queued. A non-OK status (e.g.INSUFFICIENT_FUNDS) rejects the call cleanly. -
Queue.
QueueTaskpersists scope + billing context for durable settlement, injects the W3Ctraceparentso the child generation continues this trace, and setsMaxRetriesexplicitly (leaving it0would makeClaimNextTask’sretry_count < max_retriespredicate false and strand the task QUEUED forever). -
Await terminal. Two observers race against
CALL_TIMEOUT: the Redis event stream (instant fast-path + live progress relay) and a periodic authoritativeGetTaskpoll. Whichever sees a terminal DB state first wins. -
Settle. On
COMPLETED, settle the actual against the reservation (deduct the shortfall or refund the surplus) and, if bound to a chat message, project the result. OnFAILED, refund the whole reservation. If the call times out with the worker still running, it returns a{status:"running", task_id}handle to poll — and does not settle; the reconciler owns that.
Registry sync & the schema cache Restart no longer required
Section titled “Registry sync & the schema cache ”Historically core-mcp cached each known tool’s schema in memory and skipped any tool it had already seen, so a changed input/output schema never reached the MCP layer until the process was restarted — a real trap when adding or editing a tool worker.
That is resolved in code. The sync loop now fingerprints every tool with a
toolSignature (a SHA-256 over id + description + input schema + output schema). When the
signature changes between syncs, the tool is RemoveTools’d and re-added with the new
schema, and the SDK emits notifications/tools/list_changed — so a schema update propagates
within one REGISTRY_SYNC_INTERVAL (default 20s) with no restart. Shipped to staging
in internal/registry/registry.go
(2026-07-04).
Stateless sessions & scaling Deployed
Section titled “Stateless sessions & scaling ”The go-sdk stores MCP sessions in an in-memory, per-process map with no shared event store. In the SDK’s default stateful mode a POST that load-balances to a replica which doesn’t own the session returns HTTP 404, which the Python MCP client surfaces as “Session terminated” — killing every agent sub-tool call the moment ACA scaled past one replica (the agent opens one client and fires a turn’s calls concurrently, so they round-robin across replicas).
core-mcp now runs the transport in Stateless mode
(&mcp.StreamableHTTPOptions{Stateless: true} in
cmd/server/main.go),
which skips session validation and builds a per-request session. Per-call progress
notifications still ride each POST’s own SSE response stream, and core-mcp keeps no
session-scoped server state, so nothing breaks — and the service can safely run
maxReplicas > 1.
Identity is header-based today P0 — not yet hardened
Section titled “Identity is header-based today ”The two billing pipelines
Section titled “The two billing pipelines”Pricing, rate-limiting, and token settlement are implemented twice — here in Go and in
core-gateway-consumer in Python — with idempotency-key suffixes that must agree byte-for-byte.
This is the second (agent) billing pipeline; the consumer path is the first. Keeping the two
in lockstep is fragile and flagged as tracked debt. The
open architectural question — should nested agent tool-calls bill independently or once at
the outer generation — is unresolved.
Run locally
Section titled “Run locally”Needs Redis + core-database reachable. Listens on
:8090 locally (LISTEN_ADDR); the container image overrides that to :8080 to match the
ACA ingress target port.
./update-proto.sh # go get ...@staging && go mod tidygo build ./...go run ./cmd/serverKey files
Section titled “Key files”Directorycore-mcp/
- cmd/server/main.go wiring: config, redis, clients, MCP server, registry loop, scope middleware, h2c transport,
/healthz Directoryinternal/
- registry/registry.go sync loop + signature-based re-exposure
- toolcall/handler.go the scope→price→reserve→queue→settle pipeline
- billing/billing.go reserve / settle / refund + idempotency keys
- scope/scope.go
Resolver/HeaderResolver - reconciler/reconciler.go orphaned-reservation sweep
- events/events.go task event-stream consumer
- config/config.go env +
resolveServiceURL
- cmd/server/main.go wiring: config, redis, clients, MCP server, registry loop, scope middleware, h2c transport,
Key environment variables
Section titled “Key environment variables”Names and purpose only — never commit values.
| Variable | Purpose |
|---|---|
INTERNAL_RPC_SECRET |
Required. Bearer on every downstream core-database call; boot fails if unset. |
ENVIRONMENT |
staging | prod — selects which tool registry (GetActiveTools) is exposed. |
CORE_DATABASE_URL / CORE_DATABASE_GRPC |
core-database address. Full URL wins, else built from host:port (https unless the port is :80). |
CORE_STORAGE_URL / CORE_STORAGE_GRPC |
core-storage address, same resolution rule. |
REDIS_ADDR / REDIS_PASSWORD |
Rate-limit windows + task event streams. Must be the same instance + logical DB as the workers. |
REGISTRY_SYNC_INTERVAL |
Tool-registry refresh cadence (default 20s). |
CALL_TIMEOUT |
Max time a blocking call holds the /mcp POST open (default 180s). Must stay below the ACA ingress idle timeout (~240s). |
COMPLETION_POLL_INTERVAL |
Authoritative GetTask poll cadence (default 4s). Must stay well below CALL_TIMEOUT. |
RECONCILE_INTERVAL |
Billing reconciler sweep cadence (default 60s). |
LISTEN_ADDR |
Listen address. Compiled default :8090; the image sets :8080 for ACA. |
See the full environment variable catalog and environment & service-discovery convention.
Go tests, go test ./.... Redis-backed paths use miniredis (see
internal/testsupport/) — no real Redis needed.
Related
Section titled “Related”- The agent + MCP path — the end-to-end sequence this service anchors
- core-tool-agent — the LLM worker that calls in here
- The token ledger & the money path — settle keys and reconciliation
- Task queue on Postgres — the
QueueTask/ claim / lease mechanics - Add a new tool worker — why the schema-cache used to need a restart
- Known limitations — the open-auth P0 and dup-billing debt