Skip to content

Add amber mcp serve: stateless Streamable HTTP MCP server - #32

Draft
crimson-knight wants to merge 1 commit into
mainfrom
feature/mcp-serve
Draft

Add amber mcp serve: stateless Streamable HTTP MCP server#32
crimson-knight wants to merge 1 commit into
mainfrom
feature/mcp-serve

Conversation

@crimson-knight

Copy link
Copy Markdown
Member

Adds amber mcp serve, a Model Context Protocol server that exposes the CLI to agents (Claude Desktop first) over Streamable HTTP on a single endpoint, POST /mcp.

Design

Dual-era on one endpoint, stateless in both paths. A client that opens with initialize gets the 2025-11-25 handshake Claude Desktop speaks today. A client that sends header-versioned 2026-07-28 requests is served statelessly, with the mirrored-header validation, per-request _meta and server/discover that revision requires. Neither path mints or echoes an Mcp-Session-Id; GET/DELETE on the endpoint return 405, and Last-Event-ID is ignored.

Era is decided per request from the version the client declares (body _meta first, then the MCP-Protocol-Version header — the body is the source of truth and a header that disagrees is rejected, not preferred). A client that declares nothing is treated as legacy rather than rejected: the mirrored-header rules only exist from 2026-07-28, so assuming the modern era for a silent client would fail it with an error it cannot act on.

Dispatch is on the method string before any params are decoded. MCP request types are almost all all-optional objects, so decoding into a union picks whichever member parses first and silently runs the wrong handler.

The mutating tools re-invoke the amber binary as a child process. They cannot be called in-process: NewCommand and GenerateCommand report every validation failure with exit(1) (11 call sites between them), which inside a long-running server would terminate it and drop every other client. A child process also gives each call its own working directory, so concurrent scaffolding of different applications cannot interfere — Dir.cd is process-global and would race between in-flight fibers.

Every application-scoped tool takes an explicit absolute path. Nothing reads the process working directory, which is wherever the user happened to launch the server.

Tools

Tool Kind Notes
amber_version read-only CLI, Crystal and supported protocol revisions
project_info read-only Amber detection, amber dep version, database adapter, key files
list_routes read-only Reuses RoutesCommand's parser; static, never boots the app
list_generators read-only Types, options and preview status from the generate command's own constants
search_docs / read_doc read-only Documentation embedded at compile time
create_new_app mutating Refuses an existing target path; runs non-interactively; --no-deps by default
generate_component mutating Refuses a directory that is not an Amber application

All eight carry readOnlyHint/destructiveHint annotations so a host can auto-approve introspection and gate the writers. Results are text content blocks with the JSON payload fenced in the text and repeated in structuredContent. Tool failures come back as isError: true results, not protocol errors, so the model can see them and self-correct.

Security model (v1)

There is no authentication. The model is localhost-only:

  • Binds 127.0.0.1:5757 by default.
  • Refuses a non-loopback bind (0.0.0.0, ::, *) unless --allow-remote is passed, and warns that remote exposure is unsupported when it is.
  • Validates the Origin header and returns 403 for a non-loopback origin, closing the DNS-rebinding path the specification calls out.
  • 4 MiB request body ceiling.

Documentation is embedded at compile time

amber ships as a single binary through Homebrew, so a docs directory resolved at runtime would be absent on every installed copy. DocumentIndex reads the Markdown into the binary with read_file.

Dependency

mcprotocol:
  github: crimson-knight/mcprotocol
  branch: feature/mcp-2026-07-28

Branch pin. This is the branch that adds 2026-07-28 / 2025-11-25 support, server/discover, MRTR types and cache hints. Re-pin to a version tag once mcprotocol PR #1 merges and tags. The shard is bindings-only; the HTTP transport in this PR is ours.

One change outside src/amber_cli/mcp/

RoutesCommand#parse_routes gains an explicit routes-file argument (defaulting to the original relative path, so amber routes is unchanged) so list_routes can reuse the parser without Dir.cd. Deliberately avoids helpers/process_runner.cr to stay clear of #24.

Validation

crystal spec

469 examples, 0 failures, 0 errors, 0 pending

63 new examples: 24 HTTP-level integration, 31 tool, 8 command.

Note for reviewers: on a fresh checkout the suite reports 6 failures in spec/amber_lsp/integration/ until bin/amber-lsp is built — those specs assert against the binary and are unrelated to this branch. With both binaries built the suite is fully green.

crystal tool format --check src spec

(no output — clean)

ameba on the new files

Inspecting 24 files
........................
24 inspected, 0 failures

Manual E2E

Built binary, amber mcp serve in the background, driven with curl.

Startup

$ ./bin/amber mcp serve --port 5757
Amber MCP server listening on http://127.0.0.1:5757/mcp
Health check: http://127.0.0.1:5757/healthz
Protocol revisions: 2026-07-28, 2025-11-25, 2025-06-18
Press Ctrl-C to stop.

Flow A — classic 2025-11-25 (what Claude Desktop speaks)

$ curl -s -X POST http://127.0.0.1:5757/mcp -H 'Content-Type: application/json' \
    -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"claude-desktop","version":"1.0.0"}}}'
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "capabilities": { "tools": { "listChanged": false } },
    "instructions": "Amber CLI tools for inspecting and scaffolding Amber V2 applications....",
    "protocolVersion": "2025-11-25",
    "serverInfo": { "name": "amber-cli", "title": "Amber CLI", "version": "2.0.2" },
    "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "amber-cli", "version": "2.0.2" } }
  }
}

resultType is correctly absent on the legacy result, and no Mcp-Session-Id header is returned.

$ curl -s -i -X POST ... -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
HTTP/1.1 202 Accepted
Content-Length: 0

$ curl -s -X POST ... -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
['amber_version', 'project_info', 'list_routes', 'list_generators', 'search_docs', 'read_doc', 'create_new_app', 'generate_component']

$ curl -s -X POST ... -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"amber_version","arguments":{}}}'
{
  "jsonrpc": "2.0", "id": 3,
  "result": {
    "content": [{ "text": "Amber CLI v2.0.2\n\n```json\n{...}\n```", "type": "text" }],
    "structuredContent": {
      "cliVersion": "2.0.2", "crystalVersion": "1.21.0", "mcpServerName": "amber-cli",
      "supportedProtocolVersions": ["2026-07-28", "2025-11-25", "2025-06-18"]
    },
    "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "amber-cli", "version": "2.0.2" } }
  }
}

Flow B — stateless 2026-07-28, no handshake at all

$ curl -s -X POST http://127.0.0.1:5757/mcp \
    -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: server/discover' \
    -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"8"},"io.modelcontextprotocol/clientCapabilities":{}}}}'
{
  "supportedVersions": ["2026-07-28", "2025-11-25", "2025-06-18"],
  "resultType": "complete",
  "ttlMs": 3600000,
  "cacheScope": "public",
  "capabilities": { "tools": { "listChanged": false } }
}

tools/call with no prior initialize, self-describing via _meta + Mcp-Method/Mcp-Name:

$ curl -s -X POST ... -H 'Mcp-Method: tools/call' -H 'Mcp-Name: amber_version' -d '{...,"_meta":{...}}'
resultType: complete
serverInfo: {'name': 'amber-cli', 'version': '2.0.2'}
structured:  2.0.2

Full mutating chain, over the stateless flow

$ # create_new_app -> real child process
isError: None
text head: Created demo_app at /tmp/amber_mcp_e2e/demo_app
$ ls /tmp/amber_mcp_e2e/demo_app
bin  config  db  public  shard.yml  spec  src

$ # same path again -> refused
isError: True
Refusing to scaffold into an existing path: /tmp/amber_mcp_e2e/demo_app already exists. Choose a path that does not exist yet.

$ # project_info on what was just created
{
  "path": "/tmp/amber_mcp_e2e/demo_app", "exists": true, "amberApplication": true,
  "name": "demo_app", "version": "0.1.0", "amberDependency": "2.0.0-beta.2",
  "databaseAdapter": "sqlite", "modelLayer": "none", "dependencies": ["amber"],
  "files": { "shardYml": true, "amberYml": true, "configRoutes": true, "srcDirectory": true, "specDirectory": true }
}

$ # generate_component controller Posts
isError: None
command: amber generate controller Posts index show
$ ls src/controllers/
application_controller.cr  home_controller.cr  posts_controller.cr

$ # list_routes
[ { "verb": "get", "uri_pattern": "/", "controller": "HomeController", "action": "index", "pipeline": "web", "scope": "" },
  { "verb": "get", "uri_pattern": "/*", "controller": "Amber::Controller::Static", "action": "index", "pipeline": "static", "scope": "" } ]

Protocol error paths

$ # unknown protocol version
{"jsonrpc":"2.0","id":8,"error":{"code":-32022,"message":"Unsupported protocol version","data":{"supported":["2026-07-28","2025-11-25","2025-06-18"],"requested":"1900-01-01"}}}
HTTP 400

$ # Mcp-Method header disagrees with the body
{"jsonrpc":"2.0","id":9,"error":{"code":-32020,"message":"Header mismatch: Mcp-Method header value \"tools/list\" does not match body value \"tools/call\""}}
HTTP 400

$ # unimplemented method
{"jsonrpc":"2.0","id":10,"error":{"code":-32601,"message":"Method not found: resources/list","data":{"method":"resources/list"}}}
HTTP 404

$ # cross-origin browser request
{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Origin not allowed"}}
HTTP 403

$ curl -i http://127.0.0.1:5757/mcp
HTTP/1.1 405 Method Not Allowed
Allow: POST

SSE framing

Conforming clients send Accept: application/json, text/event-stream on every request, so "mentions text/event-stream" cannot be the trigger — that would force SSE always. SSE is used only when the client accepts the event stream and not JSON:

$ curl -i -X POST ... -H 'Accept: text/event-stream' ...
HTTP/1.1 200 OK
Content-Type: text/event-stream
X-Accel-Buffering: no

event: message
data: {"jsonrpc":"2.0","id":12,"result":{...}}

Remote bind refusal

$ ./bin/amber mcp serve --host 0.0.0.0 --port 5758
Error: Refusing to bind 0.0.0.0: `amber mcp serve` performs no authentication. Binding a
non-loopback address would expose create_new_app and generate_component, which write to this
machine's filesystem, to anyone who can reach this port. Pass --allow-remote to override, and
put an authenticating proxy in front of it.
exit: 1

Graceful shutdown

$ kill -INT <pid>
Shutting down.
exit: 0

Follow-ups (out of scope here)

  • OAuth / authorization, which is what would make --allow-remote actually supportable.
  • Homebrew formula service block so the server can run as a background service.
  • MCP registry / marketplace listings.
  • stdio transport (this PR is HTTP only).
  • Mutating tools for watch, exec and database.
  • search_docs currently indexes the CLI's own docs. Once the FSDD docs generator lands on main it should index that corpus instead — it is not on main today.

Exposes the CLI to agents over the Model Context Protocol on a single
endpoint, POST /mcp, with no session state in any code path.

Dual-era on one endpoint: a client that opens with `initialize` gets the
2025-11-25 handshake Claude Desktop speaks today; a client that sends
header-versioned 2026-07-28 requests is served statelessly, with the
mirrored-header validation, per-request `_meta` and `server/discover` that
revision requires. Neither path mints or echoes an Mcp-Session-Id.

Eight tools, each taking an explicit absolute path rather than reading the
process working directory: amber_version, project_info, list_routes,
list_generators, search_docs, read_doc, and the two mutating ones,
create_new_app and generate_component.

The mutating tools re-invoke the `amber` binary as a child process. They
cannot be called in-process: NewCommand and GenerateCommand report every
validation failure with `exit(1)`, which inside a long-running server would
terminate it and drop every other client. A child process also gives each
call its own working directory, so concurrent scaffolding cannot interfere.

`routes.cr` gains an explicit routes-file argument on `parse_routes` so the
list_routes tool can reuse the existing parser without `Dir.cd`, which is
process-global and would race between in-flight requests.

Security model for v1 is localhost-only: there is no authentication, so a
non-loopback bind is refused unless --allow-remote is passed, and the Origin
header is validated to close the DNS-rebinding path.

Documentation for search_docs/read_doc is embedded at compile time, since
`amber` ships as a single binary with no docs directory beside it.
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