Skip to content

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.

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.

  • Registry sync — a loop polls GetActiveTools(ENVIRONMENT) on an interval and AddTool/RemoveTools on 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/call into a scoped, rate-limited, billed, queued generation and relays the worker’s progress back as MCP notifications.
  • Event relay — consumes the task:{id}:events Redis stream and forwards PROGRESS, STREAMING_URL, and terminal events; races it against an authoritative GetTask poll so a missed stream entry can’t stall the call.
  • Async completion — a built-in get_generation_result tool (registered directly on the server, never synced from the DB) lets a caller poll a long render by task_id instead of holding a /mcp POST open.
  • Reconciler — a background sweep settles reservations orphaned when a client disconnects before the generation terminates.

Everything below lives in internal/toolcall/handler.go. Each tools/call runs, in order:

  1. Resolve scope. The scopeMiddleware stashes a ScopeContext (user / workspace / project) on the request context; the handler rejects the call if UserID is empty.

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

  3. Rate-limit. FetchResolvedRateLimits numbers are enforced as Redis fixed windows, scaled by the plan multiplier. The counter key is user:workspace:project — never tier-keyed, so a resolver flap can’t split counters and double the quota.

  4. Price. FetchResolvedPricing returns the estimate. A NotFound here 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.

  5. Reserve. Hold the estimate against the token balance. For a DYNAMIC tool that declares usage_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.

  6. Queue. QueueTask persists scope + billing context for durable settlement, injects the W3C traceparent so the child generation continues this trace, and sets MaxRetries explicitly (leaving it 0 would make ClaimNextTask’s retry_count < max_retries predicate false and strand the task QUEUED forever).

  7. Await terminal. Two observers race against CALL_TIMEOUT: the Redis event stream (instant fast-path + live progress relay) and a periodic authoritative GetTask poll. Whichever sees a terminal DB state first wins.

  8. 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. On FAILED, 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 ”

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.

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.

core-mcp
./update-proto.sh # go get ...@staging && go mod tidy
go build ./...
go run ./cmd/server
  • 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

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.