Skip to content

Hosted streamable-HTTP MCP at mcp.cosmicjs.com#2

Merged
tonyspiro merged 7 commits into
mainfrom
staging
May 1, 2026
Merged

Hosted streamable-HTTP MCP at mcp.cosmicjs.com#2
tonyspiro merged 7 commits into
mainfrom
staging

Conversation

@tonyspiro

@tonyspiro tonyspiro commented May 1, 2026

Copy link
Copy Markdown
Member

Summary

Adds a hosted Cosmic MCP endpoint deployed on AWS ECS Fargate, alongside the existing stdio binary. Single MCP server, two transports.

  • Hosted: https://mcp.cosmicjs.com/v1/buckets/{bucket-slug}
  • Local stdio: unchanged, still npx @cosmicjs/mcp driven by env vars

Authenticate with Authorization: Bearer <read_key> for read-only tools or Authorization: Bearer <read_key>:<write_key> for full access. The write key may also be sent via X-Cosmic-Write-Key.

What changed

Code

  • Refactored into a createServer() factory (src/server.ts) consumed by both src/stdio.ts (existing npx binary) and the new src/http.ts HTTP entry.
  • Per-request multi-tenancy via AsyncLocalStorage: bucket key parsed from Authorization, fresh Cosmic SDK client per request, tool handlers unchanged.
  • New auth model: bearer is <read_key> (read-only) or <read_key>:<write_key> (full); X-Cosmic-Write-Key accepted as a fallback.
  • apiEnvironment is now wired through to the SDK; COSMIC_API_URL + COSMIC_UPLOAD_URL provide a custom override.
  • Real error surfacing: shared formatToolError extracts { status, message } from Cosmic SDK plain-object throws, logs the raw error, and returns the actual message to the client. 404s on list endpoints (objects, media, object types) are coerced to empty pages instead of being treated as errors.
  • HTTP routes: GET / descriptor, GET /healthz, GET /.well-known/oauth-protected-resource (RFC 9728), POST/GET/DELETE /v1/buckets/:slug, 404 stub for the reserved /v1/account namespace.
  • Hardening: Origin allowlist (DNS-rebinding guard), WWW-Authenticate on 401s, SIGTERM graceful shutdown, per-request transport in stateless mode.

Build / deploy

  • Dockerfile (multi-stage, oven/bun build, node:20-alpine runtime, tini PID 1).
  • docker-bake.hcl + .dockerignore.
  • .github/workflows/deploy.yml builds on every push and deploys to ECS on push to main. Auto-scales the service from 0 if needed and waits for service stability before smoke-testing /healthz.

Validation

End-to-end JSON-RPC suite executed against an internal pre-production deployment:

Check Result
GET / descriptor 200, lists 18 tools
GET /healthz 200
GET /.well-known/oauth-protected-resource RFC 9728 JSON
GET /v1/account 404 reserved stub
POST /v1/buckets/foo no auth 401 + WWW-Authenticate
MCP initialize + tools/list 18 tools enumerated
cosmic_types_list (read key only) empty list, not error
cosmic_types_create + cosmic_objects_create + cosmic_objects_list round-trip created, listed, contents match
cosmic_ai_generate_text long call succeeded
Write tool with read-only token properly blocked with clear message

Test plan after merge

  • Deploy workflow on main finishes green
  • curl https://mcp.cosmicjs.com/ returns the descriptor
  • curl https://mcp.cosmicjs.com/healthz returns ok
  • Add mcp.cosmicjs.com to Cursor / Claude Desktop with a real bucket and confirm tools/list and a read tool work

Deferred to v1.1

Rate limiting per bearer/bucket, dashboards +

tonyspiro and others added 5 commits May 1, 2026 09:45
Refactors cosmic-mcp into a shared createServer() factory consumed by both
the existing stdio binary (src/stdio.ts) and a new HTTP entry (src/http.ts)
that hosts the MCP over streamable-HTTP at /v1/buckets/{slug}.

Per-request multi-tenancy via AsyncLocalStorage: the bucket key is taken
from the Authorization header, a per-request Cosmic SDK client is built,
and tool handlers resolve it via getCosmicClient() unchanged.

HTTP entry adds spec items: GET / descriptor, GET /healthz, RFC 9728
/.well-known/oauth-protected-resource, 401 with WWW-Authenticate,
Origin allowlist for DNS-rebinding protection, 404 stub for the reserved
/v1/account namespace, and SIGTERM graceful shutdown.

Adds Dockerfile + docker-bake.hcl and a GitHub Actions deploy workflow
mirroring cosmic-backend's pattern: push to staging deploys to
mcp.cosmic-staging.com, push to main deploys to mcp.cosmicjs.com.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…l override

Hosted MCP needs to talk to the staging Cosmic backend from the staging
ECS task and the prod backend from the prod task. Reads
COSMIC_API_ENVIRONMENT (production|staging) from env, falls back to
production. If COSMIC_API_URL + COSMIC_UPLOAD_URL are both set, those
override as a custom escape hatch (useful for migrations or pointing the
hosted MCP at a non-default workers URL).

Co-authored-by: Cursor <cursoragent@cursor.com>
…list

The Cosmic SDK throws plain objects (e.g. { status, message }) rather
than Error instances, so the existing 'instanceof Error ? message :
"Unknown error"' pattern collapsed every SDK failure to a useless
"Unknown error" with no signal in the response or in CloudWatch.

Adds src/errors.ts with:
 - formatToolError: extracts message/status from plain-object throws,
   falls back to JSON.stringify, and unconditionally console.errors
   the raw payload so we can debug from logs even when the client only
   sees the surface message.
 - isNotFoundError: detects Cosmic's 404 plain-object throws so list
   tools (objects, media, object types) can return an empty page
   instead of an isError result. Get/update/delete tools still treat
   404 as a real error since the caller asked for a specific resource.

Co-authored-by: Cursor <cursoragent@cursor.com>
…header)

Cosmic enforces separate read and write keys: the read key is rejected
on writes, the write key is rejected on reads. The previous design used
a single bearer as both, which 401'd on every read tool when callers
sent a write key (and vice versa).

Bearer is now `<read_key>` for read-only access or
`<read_key>:<write_key>` for full access. Clients that can't colon-pack
their token can send the write key via X-Cosmic-Write-Key. Updates the
GET / descriptor, the OAuth PRM auth_model field, and the README.

Drops the old X-Cosmic-Key-Scope header which was meaningless given the
two-key model.

Co-authored-by: Cursor <cursoragent@cursor.com>
@tonyspiro tonyspiro changed the title Hosted streamable-HTTP MCP at mcp.cosmicjs.com / mcp.cosmic-staging.com Hosted streamable-HTTP MCP at mcp.cosmicjs.com May 1, 2026
tonyspiro and others added 2 commits May 1, 2026 11:02
Keep the staging deploy automation in the workflow, but the README is
the public-facing surface and should advertise only the production
endpoint.

Co-authored-by: Cursor <cursoragent@cursor.com>
Pushes the @cosmicjs/mcp package to npm whenever a vX.Y.Z tag lands
on main (or via workflow_dispatch with an explicit tag). The job
validates the tag matches package.json version, builds, and runs
`npm publish --provenance --access public`.

Requires an NPM_TOKEN repo secret with publish access.

Co-authored-by: Cursor <cursoragent@cursor.com>
@tonyspiro tonyspiro merged commit 43f1c06 into main May 1, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant