From d683921b391018dc5fc426617b48c1afbb5fe76e Mon Sep 17 00:00:00 2001 From: crimson-knight Date: Sat, 1 Aug 2026 15:48:08 -0400 Subject: [PATCH] Add `amber mcp serve`: stateless Streamable HTTP MCP server 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. --- shard.lock | 4 + shard.yml | 6 + spec/commands/mcp_command_spec.cr | 73 +++ spec/mcp/server_spec.cr | 446 ++++++++++++++++++ spec/mcp/spec_helper.cr | 113 +++++ spec/mcp/tools_spec.cr | 384 +++++++++++++++ src/amber_cli.cr | 2 + src/amber_cli/commands/mcp.cr | 169 +++++++ src/amber_cli/commands/routes.cr | 10 +- src/amber_cli/mcp/application_inspector.cr | 156 ++++++ src/amber_cli/mcp/base_tool.cr | 83 ++++ src/amber_cli/mcp/cli_runner.cr | 100 ++++ src/amber_cli/mcp/dispatcher.cr | 195 ++++++++ src/amber_cli/mcp/document_index.cr | 83 ++++ src/amber_cli/mcp/json_rpc_error.cr | 101 ++++ src/amber_cli/mcp/protocol.cr | 106 +++++ src/amber_cli/mcp/request_envelope.cr | 211 +++++++++ src/amber_cli/mcp/server.cr | 229 +++++++++ src/amber_cli/mcp/tool_outcome.cr | 43 ++ src/amber_cli/mcp/tool_registry.cr | 59 +++ src/amber_cli/mcp/tools/amber_version_tool.cr | 44 ++ .../mcp/tools/create_new_app_tool.cr | 143 ++++++ .../mcp/tools/generate_component_tool.cr | 131 +++++ .../mcp/tools/list_generators_tool.cr | 73 +++ src/amber_cli/mcp/tools/list_routes_tool.cr | 79 ++++ src/amber_cli/mcp/tools/project_info_tool.cr | 61 +++ src/amber_cli/mcp/tools/read_doc_tool.cr | 58 +++ src/amber_cli/mcp/tools/search_docs_tool.cr | 75 +++ 28 files changed, 3235 insertions(+), 2 deletions(-) create mode 100644 spec/commands/mcp_command_spec.cr create mode 100644 spec/mcp/server_spec.cr create mode 100644 spec/mcp/spec_helper.cr create mode 100644 spec/mcp/tools_spec.cr create mode 100644 src/amber_cli/commands/mcp.cr create mode 100644 src/amber_cli/mcp/application_inspector.cr create mode 100644 src/amber_cli/mcp/base_tool.cr create mode 100644 src/amber_cli/mcp/cli_runner.cr create mode 100644 src/amber_cli/mcp/dispatcher.cr create mode 100644 src/amber_cli/mcp/document_index.cr create mode 100644 src/amber_cli/mcp/json_rpc_error.cr create mode 100644 src/amber_cli/mcp/protocol.cr create mode 100644 src/amber_cli/mcp/request_envelope.cr create mode 100644 src/amber_cli/mcp/server.cr create mode 100644 src/amber_cli/mcp/tool_outcome.cr create mode 100644 src/amber_cli/mcp/tool_registry.cr create mode 100644 src/amber_cli/mcp/tools/amber_version_tool.cr create mode 100644 src/amber_cli/mcp/tools/create_new_app_tool.cr create mode 100644 src/amber_cli/mcp/tools/generate_component_tool.cr create mode 100644 src/amber_cli/mcp/tools/list_generators_tool.cr create mode 100644 src/amber_cli/mcp/tools/list_routes_tool.cr create mode 100644 src/amber_cli/mcp/tools/project_info_tool.cr create mode 100644 src/amber_cli/mcp/tools/read_doc_tool.cr create mode 100644 src/amber_cli/mcp/tools/search_docs_tool.cr diff --git a/shard.lock b/shard.lock index b49cf0e..37edbf5 100644 --- a/shard.lock +++ b/shard.lock @@ -24,6 +24,10 @@ shards: git: https://github.com/crystal-loot/exception_page.git version: 0.2.2 + mcprotocol: + git: https://github.com/crimson-knight/mcprotocol.git + version: 1.0.0+git.commit.f4a52b03c768471f823e813a82281d6bc6f959b7 + micrate: git: https://github.com/amberframework/micrate.git version: 0.15.1+git.commit.647fac0490a522956fef305c720e8ebf1422a87d diff --git a/shard.yml b/shard.yml index b133b29..029e307 100644 --- a/shard.yml +++ b/shard.yml @@ -47,6 +47,12 @@ dependencies: github: elorest/compiled_license version: ~> 1.2.2 + # MCP schema bindings for `amber mcp serve`. Branch pin until the + # 2026-07-28 / 2025-11-25 revision support lands in a tagged release. + mcprotocol: + github: crimson-knight/mcprotocol + branch: feature/mcp-2026-07-28 + development_dependencies: ameba: github: crystal-ameba/ameba diff --git a/spec/commands/mcp_command_spec.cr b/spec/commands/mcp_command_spec.cr new file mode 100644 index 0000000..ea99794 --- /dev/null +++ b/spec/commands/mcp_command_spec.cr @@ -0,0 +1,73 @@ +require "spec" +require "../../src/amber_cli" + +private def parsed_command(args : Array(String)) : AmberCLI::Commands::McpCommand + command = AmberCLI::Commands::McpCommand.new("mcp") + command.option_parser.unknown_args do |unknown_args, _| + command.remaining_arguments.concat(unknown_args) + end + command.option_parser.parse(args) + command +end + +describe AmberCLI::Commands::McpCommand do + describe "registration" do + it "is reachable as `amber mcp`" do + AmberCLI::Core::CommandRegistry.find_command("mcp").should eq(AmberCLI::Commands::McpCommand) + end + end + + describe "#setup_command_options" do + it "defaults to the loopback endpoint on port 5757" do + command = parsed_command(["serve"]) + + command.host.should eq("127.0.0.1") + command.port.should eq(5757) + command.allow_remote?.should be_false + command.remaining_arguments.should eq(["serve"]) + end + + it "accepts --host and --port" do + command = parsed_command(["serve", "--host=127.0.0.1", "--port=9999"]) + + command.host.should eq("127.0.0.1") + command.port.should eq(9999) + end + + it "accepts --allow-remote" do + parsed_command(["serve", "--allow-remote"]).allow_remote?.should be_true + end + end + + describe "#validate_bind!" do + it "refuses --host 0.0.0.0 without --allow-remote" do + command = parsed_command(["serve", "--host=0.0.0.0"]) + + error = expect_raises(AmberCLI::Commands::McpCommand::RemoteBindRefusedError) do + command.validate_bind! + end + + error.message.to_s.should contain("Refusing to bind 0.0.0.0") + error.message.to_s.should contain("no authentication") + error.message.to_s.should contain("--allow-remote") + end + + it "refuses a wildcard IPv6 bind without --allow-remote" do + command = parsed_command(["serve", "--host=::"]) + + expect_raises(AmberCLI::Commands::McpCommand::RemoteBindRefusedError) do + command.validate_bind! + end + end + + it "permits 0.0.0.0 once --allow-remote is given" do + command = parsed_command(["serve", "--host=0.0.0.0", "--allow-remote"]) + + command.validate_bind! + end + + it "permits the loopback default" do + parsed_command(["serve"]).validate_bind! + end + end +end diff --git a/spec/mcp/server_spec.cr b/spec/mcp/server_spec.cr new file mode 100644 index 0000000..9c95f98 --- /dev/null +++ b/spec/mcp/server_spec.cr @@ -0,0 +1,446 @@ +require "./spec_helper" + +describe AmberCLI::MCP::Server do + describe "classic 2025-11-25 flow" do + it "completes initialize -> initialized -> tools/list -> tools/call" do + with_mcp_server do |client| + initialize_response = client.post( + "/mcp", + headers: legacy_headers, + body: { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {} of String => String, + clientInfo: {name: "claude-desktop-spec", version: "1.0.0"}, + }, + }.to_json + ) + + initialize_response.status_code.should eq(200) + initialize_response.headers["Content-Type"].should eq("application/json") + + body = parse_body(initialize_response) + body["jsonrpc"].as_s.should eq("2.0") + body["id"].as_i.should eq(1) + body["result"]["protocolVersion"].as_s.should eq("2025-11-25") + body["result"]["serverInfo"]["name"].as_s.should eq("amber-cli") + body["result"]["serverInfo"]["version"].as_s.should eq(AmberCLI::VERSION) + body["result"]["capabilities"]["tools"].should_not be_nil + # `resultType` belongs to 2026-07-28; a legacy result must not carry it. + body["result"]["resultType"]?.should be_nil + + initialized = client.post( + "/mcp", + headers: legacy_headers, + body: {jsonrpc: "2.0", method: "notifications/initialized"}.to_json + ) + initialized.status_code.should eq(202) + initialized.body.should be_empty + + list_response = client.post( + "/mcp", + headers: legacy_headers, + body: {jsonrpc: "2.0", id: 2, method: "tools/list", params: {} of String => String}.to_json + ) + list_response.status_code.should eq(200) + + tools = parse_body(list_response)["result"]["tools"].as_a + names = tools.map(&.["name"].as_s) + names.should contain("amber_version") + names.should contain("project_info") + names.should contain("list_routes") + names.should contain("list_generators") + names.should contain("search_docs") + names.should contain("read_doc") + names.should contain("create_new_app") + names.should contain("generate_component") + + call_response = client.post( + "/mcp", + headers: legacy_headers, + body: { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: {name: "amber_version", arguments: {} of String => String}, + }.to_json + ) + call_response.status_code.should eq(200) + + result = parse_body(call_response)["result"] + result["isError"]?.try(&.as_bool?).should be_falsey + result["content"][0]["type"].as_s.should eq("text") + result["content"][0]["text"].as_s.should contain(AmberCLI::VERSION) + result["structuredContent"]["cliVersion"].as_s.should eq(AmberCLI::VERSION) + end + end + + it "does not mint a session id" do + with_mcp_server do |client| + response = client.post( + "/mcp", + headers: legacy_headers, + body: { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: {protocolVersion: "2025-11-25", capabilities: {} of String => String}, + }.to_json + ) + + response.headers["Mcp-Session-Id"]?.should be_nil + end + end + end + + describe "stateless 2026-07-28 flow" do + it "serves tools/call with no prior initialize" do + with_mcp_server do |client| + response = client.post( + "/mcp", + headers: modern_headers("tools/call", "amber_version"), + body: { + jsonrpc: "2.0", + id: 7, + method: "tools/call", + params: { + name: "amber_version", + arguments: {} of String => String, + _meta: modern_meta, + }, + }.to_json + ) + + response.status_code.should eq(200) + body = parse_body(response) + body["id"].as_i.should eq(7) + # REQUIRED on every 2026-07-28 result. + body["result"]["resultType"].as_s.should eq("complete") + body["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"].as_s.should eq("amber-cli") + body["result"]["content"][0]["text"].as_s.should contain("Amber CLI") + end + end + + it "answers server/discover with every supported version and cache hints" do + with_mcp_server do |client| + response = client.post( + "/mcp", + headers: modern_headers("server/discover"), + body: { + jsonrpc: "2.0", + id: 1, + method: "server/discover", + params: {_meta: modern_meta}, + }.to_json + ) + + response.status_code.should eq(200) + result = parse_body(response)["result"] + result["supportedVersions"].as_a.map(&.as_s).should eq(["2026-07-28", "2025-11-25", "2025-06-18"]) + result["resultType"].as_s.should eq("complete") + result["cacheScope"].as_s.should eq("public") + result["ttlMs"].as_i64.should be > 0 + result["capabilities"]["tools"].should_not be_nil + end + end + + it "puts cache hints on tools/list" do + with_mcp_server do |client| + response = client.post( + "/mcp", + headers: modern_headers("tools/list"), + body: {jsonrpc: "2.0", id: 2, method: "tools/list", params: {_meta: modern_meta}}.to_json + ) + + result = parse_body(response)["result"] + result["resultType"].as_s.should eq("complete") + result["ttlMs"].as_i64.should eq(3_600_000) + result["cacheScope"].as_s.should eq("public") + end + end + + it "rejects a request whose Mcp-Method header disagrees with the body" do + with_mcp_server do |client| + headers = modern_headers("tools/list") + response = client.post( + "/mcp", + headers: headers, + body: { + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: {name: "amber_version", arguments: {} of String => String, _meta: modern_meta}, + }.to_json + ) + + response.status_code.should eq(400) + parse_body(response)["error"]["code"].as_i.should eq(-32020) + end + end + + it "rejects a request whose Mcp-Name header disagrees with the body" do + with_mcp_server do |client| + response = client.post( + "/mcp", + headers: modern_headers("tools/call", "project_info"), + body: { + jsonrpc: "2.0", + id: 4, + method: "tools/call", + params: {name: "amber_version", arguments: {} of String => String, _meta: modern_meta}, + }.to_json + ) + + response.status_code.should eq(400) + error = parse_body(response)["error"] + error["code"].as_i.should eq(-32020) + error["message"].as_s.should contain("Mcp-Name") + end + end + + it "rejects a modern request missing the required _meta fields" do + with_mcp_server do |client| + response = client.post( + "/mcp", + headers: modern_headers("tools/list"), + body: {jsonrpc: "2.0", id: 5, method: "tools/list", params: {} of String => String}.to_json + ) + + response.status_code.should eq(400) + parse_body(response)["error"]["code"].as_i.should eq(-32602) + end + end + + it "accepts a Base64 sentinel encoded Mcp-Name" do + with_mcp_server do |client| + headers = modern_headers("tools/call") + headers["Mcp-Name"] = "=?base64?#{Base64.strict_encode("amber_version")}?=" + + response = client.post( + "/mcp", + headers: headers, + body: { + jsonrpc: "2.0", + id: 6, + method: "tools/call", + params: {name: "amber_version", arguments: {} of String => String, _meta: modern_meta}, + }.to_json + ) + + response.status_code.should eq(200) + end + end + + it "returns 404 with -32601 for an unimplemented method" do + with_mcp_server do |client| + response = client.post( + "/mcp", + headers: modern_headers("resources/list"), + body: {jsonrpc: "2.0", id: 8, method: "resources/list", params: {_meta: modern_meta}}.to_json + ) + + response.status_code.should eq(404) + parse_body(response)["error"]["code"].as_i.should eq(-32601) + end + end + end + + describe "version negotiation" do + it "rejects an unknown protocol version with -32022 and lists what it supports" do + with_mcp_server do |client| + headers = legacy_headers + headers["MCP-Protocol-Version"] = "1900-01-01" + + response = client.post( + "/mcp", + headers: headers, + body: {jsonrpc: "2.0", id: 9, method: "tools/list", params: {} of String => String}.to_json + ) + + response.status_code.should eq(400) + error = parse_body(response)["error"] + error["code"].as_i.should eq(-32022) + error["data"]["requested"].as_s.should eq("1900-01-01") + error["data"]["supported"].as_a.map(&.as_s).should eq(["2026-07-28", "2025-11-25", "2025-06-18"]) + end + end + + it "rejects a version that the shard names but no longer negotiates" do + with_mcp_server do |client| + headers = legacy_headers + headers["MCP-Protocol-Version"] = "2025-03-26" + + response = client.post( + "/mcp", + headers: headers, + body: {jsonrpc: "2.0", id: 10, method: "tools/list", params: {} of String => String}.to_json + ) + + response.status_code.should eq(400) + parse_body(response)["error"]["code"].as_i.should eq(-32022) + end + end + + it "rejects a header that disagrees with the body version" do + with_mcp_server do |client| + headers = modern_headers("tools/list") + headers["MCP-Protocol-Version"] = "2025-11-25" + + response = client.post( + "/mcp", + headers: headers, + body: {jsonrpc: "2.0", id: 11, method: "tools/list", params: {_meta: modern_meta}}.to_json + ) + + response.status_code.should eq(400) + parse_body(response)["error"]["code"].as_i.should eq(-32020) + end + end + + it "answers initialize on the newest handshake revision when asked for the stateless one" do + with_mcp_server do |client| + response = client.post( + "/mcp", + headers: legacy_headers, + body: { + jsonrpc: "2.0", + id: 12, + method: "initialize", + params: {protocolVersion: "2026-07-28", capabilities: {} of String => String}, + }.to_json + ) + + response.status_code.should eq(200) + parse_body(response)["result"]["protocolVersion"].as_s.should eq("2025-11-25") + end + end + end + + describe "transport rules" do + it "refuses GET on the MCP endpoint" do + with_mcp_server do |client| + response = client.get("/mcp") + response.status_code.should eq(405) + response.headers["Allow"].should eq("POST") + end + end + + it "refuses DELETE on the MCP endpoint" do + with_mcp_server do |client| + response = client.delete("/mcp") + response.status_code.should eq(405) + end + end + + it "serves the health check" do + with_mcp_server do |client| + response = client.get("/healthz") + response.status_code.should eq(200) + response.body.should eq("ok") + end + end + + it "wraps the response in SSE when the client asks only for an event stream" do + with_mcp_server do |client| + headers = modern_headers("tools/list") + headers["Accept"] = "text/event-stream" + + response = client.post( + "/mcp", + headers: headers, + body: {jsonrpc: "2.0", id: 13, method: "tools/list", params: {_meta: modern_meta}}.to_json + ) + + response.status_code.should eq(200) + response.headers["Content-Type"].should eq("text/event-stream") + response.headers["X-Accel-Buffering"].should eq("no") + response.body.should start_with("event: message\ndata: ") + + payload = JSON.parse(response.body.lines[1].lchop("data: ")) + payload["result"]["tools"].as_a.size.should eq(8) + end + end + + it "returns JSON when the client accepts both, as conforming clients do" do + with_mcp_server do |client| + response = client.post( + "/mcp", + headers: modern_headers("tools/list"), + body: {jsonrpc: "2.0", id: 14, method: "tools/list", params: {_meta: modern_meta}}.to_json + ) + + response.headers["Content-Type"].should eq("application/json") + end + end + + it "ignores a session id from an older client instead of echoing it" do + with_mcp_server do |client| + headers = legacy_headers + headers["Mcp-Session-Id"] = "abc123" + + response = client.post( + "/mcp", + headers: headers, + body: {jsonrpc: "2.0", id: 15, method: "tools/list", params: {} of String => String}.to_json + ) + + response.status_code.should eq(200) + response.headers["Mcp-Session-Id"]?.should be_nil + end + end + + it "rejects a cross-origin browser request" do + with_mcp_server do |client| + headers = legacy_headers + headers["Origin"] = "https://evil.example.com" + + response = client.post( + "/mcp", + headers: headers, + body: {jsonrpc: "2.0", id: 16, method: "tools/list", params: {} of String => String}.to_json + ) + + response.status_code.should eq(403) + end + end + + it "accepts a localhost origin" do + with_mcp_server do |client, port| + headers = legacy_headers + headers["Origin"] = "http://localhost:#{port}" + + response = client.post( + "/mcp", + headers: headers, + body: {jsonrpc: "2.0", id: 17, method: "tools/list", params: {} of String => String}.to_json + ) + + response.status_code.should eq(200) + end + end + + it "reports a malformed body as a parse error" do + with_mcp_server do |client| + response = client.post("/mcp", headers: legacy_headers, body: "{not json") + response.status_code.should eq(400) + parse_body(response)["error"]["code"].as_i.should eq(-32700) + end + end + + it "correlates an error response with the request id" do + with_mcp_server do |client| + response = client.post( + "/mcp", + headers: modern_headers("tools/list"), + body: {jsonrpc: "2.0", id: 99, method: "tools/list", params: {} of String => String}.to_json + ) + + response.status_code.should eq(400) + parse_body(response)["id"].as_i.should eq(99) + end + end + end +end diff --git a/spec/mcp/spec_helper.cr b/spec/mcp/spec_helper.cr new file mode 100644 index 0000000..e525a34 --- /dev/null +++ b/spec/mcp/spec_helper.cr @@ -0,0 +1,113 @@ +require "spec" +require "file_utils" +require "http/client" +require "json" +require "../../src/amber_cli" + +# A directory that exists only for the duration of the block. +def with_mcp_tempdir(&) + dir = File.join(Dir.tempdir, "amber_mcp_spec_#{Random::Secure.hex(8)}") + Dir.mkdir_p(dir) + begin + yield dir + ensure + FileUtils.rm_rf(dir) + end +end + +# Writes a minimal Amber application into *dir* and yields its path. +# +# The fixture is written directly rather than produced by `amber new`: running +# the real generator would need a compiled binary and a `shards install`, which +# would make every tool spec depend on the network. +def with_fixture_app(name : String = "fixture_app", &) + with_mcp_tempdir do |dir| + app = File.join(dir, name) + Dir.mkdir_p(File.join(app, "config")) + Dir.mkdir_p(File.join(app, "src")) + Dir.mkdir_p(File.join(app, "spec")) + + File.write(File.join(app, "shard.yml"), <<-YAML) + name: #{name} + version: 0.1.0 + + dependencies: + amber: + github: amberframework/amber + version: 2.0.0-beta.2 + YAML + + File.write(File.join(app, ".amber.yml"), <<-YAML) + database: sqlite + language: ecr + model: none + YAML + + File.write(File.join(app, "config", "routes.cr"), <<-CRYSTAL) + Amber::Server.configure do |app| + pipeline :web do + plug Amber::Pipe::Logger.new + end + + routes :web do + get "/", HomeController, :index + post "/sessions", SessionsController, :create + resources "/posts", PostsController + end + end + CRYSTAL + + yield app + end +end + +# Boots the MCP server on an ephemeral port, yields a client bound to it, and +# shuts it down afterwards. +def with_mcp_server(allow_remote : Bool = false, &) + server = AmberCLI::MCP::Server.new(host: "127.0.0.1", port: 0, allow_remote: allow_remote) + address = server.bind + spawn { server.listen } + # Yield to the scheduler so the accept loop is running before the first request. + Fiber.yield + + client = HTTP::Client.new("127.0.0.1", address.port) + begin + yield client, address.port + ensure + client.close + server.close + end +end + +# Headers a conforming 2026-07-28 client sends. +def modern_headers(method : String, name : String? = nil) : HTTP::Headers + headers = HTTP::Headers{ + "Content-Type" => "application/json", + "Accept" => "application/json, text/event-stream", + "MCP-Protocol-Version" => "2026-07-28", + "Mcp-Method" => method, + } + headers["Mcp-Name"] = name if name + headers +end + +# The `_meta` block every modern request must carry. +def modern_meta(version : String = "2026-07-28") : Hash(String, JSON::Any) + { + "io.modelcontextprotocol/protocolVersion" => JSON::Any.new(version), + "io.modelcontextprotocol/clientInfo" => JSON.parse({"name" => "amber-spec", "version" => "1.0.0"}.to_json), + "io.modelcontextprotocol/clientCapabilities" => JSON.parse("{}"), + } +end + +# Headers a 2025-11-25 (Claude Desktop era) client sends after the handshake. +def legacy_headers : HTTP::Headers + HTTP::Headers{ + "Content-Type" => "application/json", + "Accept" => "application/json, text/event-stream", + } +end + +def parse_body(response : HTTP::Client::Response) : JSON::Any + JSON.parse(response.body) +end diff --git a/spec/mcp/tools_spec.cr b/spec/mcp/tools_spec.cr new file mode 100644 index 0000000..9d4e3c2 --- /dev/null +++ b/spec/mcp/tools_spec.cr @@ -0,0 +1,384 @@ +require "./spec_helper" + +# Records the arguments it was asked to run instead of launching a process, so a +# mutating tool's validation can be specced without touching the filesystem. +class RecordingCliRunner < AmberCLI::MCP::CliRunner + getter invocations = [] of NamedTuple(args: Array(String), chdir: String) + property outcome : AmberCLI::MCP::CliRunner::Outcome + + def initialize(@outcome = AmberCLI::MCP::CliRunner::Outcome.new(0, "done", "")) + super(executable: "/nonexistent/amber") + end + + def run(args : Array(String), chdir : String) : AmberCLI::MCP::CliRunner::Outcome + @invocations << {args: args, chdir: chdir} + @outcome + end +end + +private def arguments(pairs) + JSON.parse(pairs.to_json).as_h +end + +# Narrows a tool's structured payload, failing the example with a readable +# message rather than a nil assertion when a tool unexpectedly returns none. +private def structured_of(outcome : AmberCLI::MCP::ToolOutcome) : JSON::Any + payload = outcome.structured + fail("expected structured content, got none: #{outcome.text}") if payload.nil? + payload.as(JSON::Any) +end + +# :ditto: +private def annotations_of(tool : MCProtocol::Tool) : Hash(String, JSON::Any) + meta = tool.meta + fail("expected annotations on #{tool.name}") if meta.nil? + meta.as(Hash(String, JSON::Any)) +end + +describe AmberCLI::MCP::Tools::AmberVersionTool do + it "reports the CLI version and the protocol revisions it speaks" do + outcome = AmberCLI::MCP::Tools::AmberVersionTool.new.call({} of String => JSON::Any) + + outcome.error?.should be_false + outcome.text.should contain(AmberCLI::VERSION) + structured = structured_of(outcome) + structured["cliVersion"].as_s.should eq(AmberCLI::VERSION) + structured["supportedProtocolVersions"].as_a.map(&.as_s).should contain("2026-07-28") + end +end + +describe AmberCLI::MCP::Tools::ProjectInfoTool do + it "identifies a scaffolded application and reports its configuration" do + with_fixture_app do |app| + outcome = AmberCLI::MCP::Tools::ProjectInfoTool.new.call(arguments({"path" => app})) + + outcome.error?.should be_false + structured = structured_of(outcome) + structured["amberApplication"].as_bool.should be_true + structured["name"].as_s.should eq("fixture_app") + structured["databaseAdapter"].as_s.should eq("sqlite") + structured["templateLanguage"].as_s.should eq("ecr") + structured["amberDependency"].as_s.should eq("2.0.0-beta.2") + structured["files"]["configRoutes"].as_bool.should be_true + end + end + + it "reports a plain directory as not an Amber application" do + with_mcp_tempdir do |dir| + outcome = AmberCLI::MCP::Tools::ProjectInfoTool.new.call(arguments({"path" => dir})) + + outcome.error?.should be_false + structured_of(outcome)["amberApplication"].as_bool.should be_false + end + end + + it "fails a relative path rather than resolving it against the server's cwd" do + outcome = AmberCLI::MCP::Tools::ProjectInfoTool.new.call(arguments({"path" => "some/relative/path"})) + + outcome.error?.should be_true + outcome.text.should contain("must be absolute") + end + + it "fails a missing directory" do + outcome = AmberCLI::MCP::Tools::ProjectInfoTool.new.call(arguments({"path" => "/nonexistent/amber/app"})) + + outcome.error?.should be_true + outcome.text.should contain("No such directory") + end +end + +describe AmberCLI::MCP::Tools::ListRoutesTool do + it "lists verb routes and expands resources for the app at the given path" do + with_fixture_app do |app| + outcome = AmberCLI::MCP::Tools::ListRoutesTool.new.call(arguments({"path" => app})) + + outcome.error?.should be_false + routes = structured_of(outcome).as_a + routes.size.should be > 0 + + home = routes.find! { |route| route["uri_pattern"].as_s == "/" } + home["verb"].as_s.should eq("get") + home["controller"].as_s.should eq("HomeController") + home["action"].as_s.should eq("index") + home["pipeline"].as_s.should eq("web") + + routes.any? { |route| route["controller"].as_s == "SessionsController" }.should be_true + # `resources` expands into the full CRUD set. + routes.count { |route| route["controller"].as_s == "PostsController" }.should be > 4 + end + end + + it "filters by substring" do + with_fixture_app do |app| + outcome = AmberCLI::MCP::Tools::ListRoutesTool.new.call( + arguments({"path" => app, "filter" => "posts"}) + ) + + routes = structured_of(outcome).as_a + routes.should_not be_empty + routes.all? { |route| route["controller"].as_s == "PostsController" }.should be_true + end + end + + it "does not depend on the process working directory" do + with_fixture_app do |app| + # Proves the path argument is what is used: the server's own cwd is the + # repository, which has no config/routes.cr of its own. + before = Dir.current + outcome = AmberCLI::MCP::Tools::ListRoutesTool.new.call(arguments({"path" => app})) + + Dir.current.should eq(before) + outcome.error?.should be_false + end + end + + it "fails when the directory holds no routes file" do + with_mcp_tempdir do |dir| + outcome = AmberCLI::MCP::Tools::ListRoutesTool.new.call(arguments({"path" => dir})) + + outcome.error?.should be_true + outcome.text.should contain("No routes file") + end + end +end + +describe AmberCLI::MCP::Tools::ListGeneratorsTool do + it "enumerates every generator the generate command accepts" do + outcome = AmberCLI::MCP::Tools::ListGeneratorsTool.new.call({} of String => JSON::Any) + + outcome.error?.should be_false + structured = structured_of(outcome) + types = structured["generators"].as_a.map(&.["type"].as_s) + types.should eq(AmberCLI::Commands::GenerateCommand::VALID_TYPES) + structured["fieldTypes"].as_a.map(&.as_s).should contain("string") + + scaffold = structured["generators"].as_a.find! { |generator| generator["type"].as_s == "scaffold" } + scaffold["preview"].as_bool.should be_true + end +end + +describe AmberCLI::MCP::Tools::SearchDocsTool do + it "finds a term in the bundled documentation" do + outcome = AmberCLI::MCP::Tools::SearchDocsTool.new.call(arguments({"query" => "amber"})) + + outcome.error?.should be_false + matches = structured_of(outcome)["matches"].as_a + matches.should_not be_empty + matches.first["document"].as_s.should_not be_empty + matches.first["lineNumber"].as_i.should be > 0 + end + + it "reports no matches without failing" do + outcome = AmberCLI::MCP::Tools::SearchDocsTool.new.call( + arguments({"query" => "zzz-no-such-term-zzz"}) + ) + + outcome.error?.should be_false + structured_of(outcome)["matches"].as_a.should be_empty + end + + it "rejects a blank query" do + outcome = AmberCLI::MCP::Tools::SearchDocsTool.new.call(arguments({"query" => " "})) + outcome.error?.should be_true + end +end + +describe AmberCLI::MCP::Tools::ReadDocTool do + it "lists the corpus when called with no id" do + outcome = AmberCLI::MCP::Tools::ReadDocTool.new.call({} of String => JSON::Any) + + outcome.error?.should be_false + structured_of(outcome).as_a.map(&.["id"].as_s).should contain("README.md") + end + + it "returns a document's full text" do + outcome = AmberCLI::MCP::Tools::ReadDocTool.new.call(arguments({"id" => "README.md"})) + + outcome.error?.should be_false + outcome.text.should contain("Amber") + structured_of(outcome)["id"].as_s.should eq("README.md") + end + + it "fails an unknown id" do + outcome = AmberCLI::MCP::Tools::ReadDocTool.new.call(arguments({"id" => "docs/nope.md"})) + + outcome.error?.should be_true + outcome.text.should contain("Unknown document") + end +end + +describe AmberCLI::MCP::Tools::CreateNewAppTool do + it "is advertised as mutating" do + AmberCLI::MCP::Tools::CreateNewAppTool.new.mutating?.should be_true + end + + it "refuses a target directory that already exists" do + with_mcp_tempdir do |dir| + runner = RecordingCliRunner.new + outcome = AmberCLI::MCP::Tools::CreateNewAppTool.new(runner).call(arguments({"path" => dir})) + + outcome.error?.should be_true + outcome.text.should contain("already exists") + runner.invocations.should be_empty + end + end + + it "refuses a target file that already exists" do + with_mcp_tempdir do |dir| + target = File.join(dir, "taken") + File.write(target, "occupied") + + runner = RecordingCliRunner.new + outcome = AmberCLI::MCP::Tools::CreateNewAppTool.new(runner).call(arguments({"path" => target})) + + outcome.error?.should be_true + outcome.text.should contain("already exists") + runner.invocations.should be_empty + end + end + + it "refuses a relative path" do + runner = RecordingCliRunner.new + outcome = AmberCLI::MCP::Tools::CreateNewAppTool.new(runner).call(arguments({"path" => "my_app"})) + + outcome.error?.should be_true + outcome.text.should contain("must be absolute") + runner.invocations.should be_empty + end + + it "refuses a parent directory that does not exist" do + runner = RecordingCliRunner.new + outcome = AmberCLI::MCP::Tools::CreateNewAppTool.new(runner).call( + arguments({"path" => "/nonexistent/parent/my_app"}) + ) + + outcome.error?.should be_true + outcome.text.should contain("Parent directory does not exist") + runner.invocations.should be_empty + end + + it "refuses an unknown database" do + with_mcp_tempdir do |dir| + runner = RecordingCliRunner.new + outcome = AmberCLI::MCP::Tools::CreateNewAppTool.new(runner).call( + arguments({"path" => File.join(dir, "app"), "database" => "oracle"}) + ) + + outcome.error?.should be_true + outcome.text.should contain("Invalid `database`") + runner.invocations.should be_empty + end + end + + it "runs the CLI non-interactively and without dependency installation by default" do + with_mcp_tempdir do |dir| + runner = RecordingCliRunner.new + outcome = AmberCLI::MCP::Tools::CreateNewAppTool.new(runner).call( + arguments({"path" => File.join(dir, "my_app"), "database" => "sqlite"}) + ) + + outcome.error?.should be_false + runner.invocations.size.should eq(1) + invocation = runner.invocations.first + invocation[:args].should eq(["new", "my_app", "--assume-yes", "--database=sqlite", "--no-deps"]) + invocation[:chdir].should eq(dir) + end + end + + it "reports a failing child process as a tool failure rather than raising" do + with_mcp_tempdir do |dir| + runner = RecordingCliRunner.new( + AmberCLI::MCP::CliRunner::Outcome.new(1, "", "boom") + ) + outcome = AmberCLI::MCP::Tools::CreateNewAppTool.new(runner).call( + arguments({"path" => File.join(dir, "my_app")}) + ) + + outcome.error?.should be_true + outcome.text.should contain("exit code 1") + outcome.text.should contain("boom") + end + end +end + +describe AmberCLI::MCP::Tools::GenerateComponentTool do + it "is advertised as mutating" do + AmberCLI::MCP::Tools::GenerateComponentTool.new.mutating?.should be_true + end + + it "runs the generator in the application directory" do + with_fixture_app do |app| + runner = RecordingCliRunner.new + outcome = AmberCLI::MCP::Tools::GenerateComponentTool.new(runner).call( + arguments({"path" => app, "type" => "model", "name" => "User", "fields" => ["name:string"]}) + ) + + outcome.error?.should be_false + invocation = runner.invocations.first + invocation[:args].should eq(["generate", "model", "User", "name:string"]) + invocation[:chdir].should eq(File.expand_path(app)) + end + end + + it "refuses to generate into a directory that is not an Amber application" do + with_mcp_tempdir do |dir| + runner = RecordingCliRunner.new + outcome = AmberCLI::MCP::Tools::GenerateComponentTool.new(runner).call( + arguments({"path" => dir, "type" => "model", "name" => "User"}) + ) + + outcome.error?.should be_true + outcome.text.should contain("does not look like an Amber application") + runner.invocations.should be_empty + end + end + + it "refuses an unknown generator" do + with_fixture_app do |app| + runner = RecordingCliRunner.new + outcome = AmberCLI::MCP::Tools::GenerateComponentTool.new(runner).call( + arguments({"path" => app, "type" => "widget", "name" => "User"}) + ) + + outcome.error?.should be_true + outcome.text.should contain("Unknown generator") + runner.invocations.should be_empty + end + end + + it "refuses a name that is not a single token" do + with_fixture_app do |app| + runner = RecordingCliRunner.new + outcome = AmberCLI::MCP::Tools::GenerateComponentTool.new(runner).call( + arguments({"path" => app, "type" => "model", "name" => "User Profile"}) + ) + + outcome.error?.should be_true + runner.invocations.should be_empty + end + end +end + +describe AmberCLI::MCP::ToolRegistry do + it "advertises read-only and destructive hints so a host can gate the writers" do + registry = AmberCLI::MCP::ToolRegistry.default + + definitions = registry.definitions.index_by(&.name) + read_only = annotations_of(definitions["list_routes"])["annotations"] + read_only["readOnlyHint"].as_bool.should be_true + read_only["destructiveHint"].as_bool.should be_false + + mutating = annotations_of(definitions["create_new_app"])["annotations"] + mutating["readOnlyHint"].as_bool.should be_false + mutating["destructiveHint"].as_bool.should be_true + end + + it "requires an explicit path on every application-scoped tool" do + registry = AmberCLI::MCP::ToolRegistry.default + + ["project_info", "list_routes", "create_new_app", "generate_component"].each do |name| + tool = registry[name]?.as(AmberCLI::MCP::BaseTool) + tool.input_schema.required.as(Array(String)).should contain("path") + end + end +end diff --git a/src/amber_cli.cr b/src/amber_cli.cr index 3d816a0..4acdec7 100644 --- a/src/amber_cli.cr +++ b/src/amber_cli.cr @@ -25,6 +25,7 @@ require "./amber_cli/commands/plugin" require "./amber_cli/commands/pipelines" require "./amber_cli/commands/generate" require "./amber_cli/commands/setup_lsp" +require "./amber_cli/commands/mcp" backend = Log::IOBackend.new backend.formatter = Log::Formatter.new do |entry, io| @@ -75,6 +76,7 @@ module AmberCLI plugin (pl) Generate application plugins pipelines Show application pipelines and plugs setup:lsp (lsp) Set up Amber LSP for Claude Code integration + mcp Run an MCP server exposing Amber tools to agents Options: --version, -v Show version number diff --git a/src/amber_cli/commands/mcp.cr b/src/amber_cli/commands/mcp.cr new file mode 100644 index 0000000..9dceb26 --- /dev/null +++ b/src/amber_cli/commands/mcp.cr @@ -0,0 +1,169 @@ +require "../core/base_command" +require "../mcp/server" + +# The `mcp` command runs an MCP (Model Context Protocol) server so agents can +# introspect and scaffold Amber applications. +# +# ## Usage +# ``` +# amber mcp serve [options] +# ``` +# +# ## Options +# - `--host HOST` - Address to bind (default: 127.0.0.1) +# - `--port PORT` - Port to bind (default: 5757) +# - `--allow-remote` - Permit binding to a non-loopback address +# +# ## Examples +# ``` +# # Serve on the default loopback endpoint +# amber mcp serve +# +# # Serve on a different port +# amber mcp serve --port 8080 +# ``` +# +# ## Security +# The server has no authentication. It binds to loopback only, and refuses a +# non-loopback bind unless `--allow-remote` is given explicitly. +module AmberCLI::Commands + class McpCommand < AmberCLI::Core::BaseCommand + # Raised when the requested bind would expose the server beyond this machine + # without the operator having asked for it. + # + # The refusal raises rather than exiting inline so that it is a behavior + # something can assert against; a guard whose only expression is `exit` can + # only be tested by launching a process. + class RemoteBindRefusedError < Exception + end + + SUBCOMMANDS = ["serve"] + + # Addresses that expose the server beyond this machine. + NON_LOOPBACK_HOSTS = ["0.0.0.0", "::", "*"] + + getter host : String = AmberCLI::MCP::Server::DEFAULT_HOST + getter port : Int32 = AmberCLI::MCP::Server::DEFAULT_PORT + getter? allow_remote : Bool = false + + def help_description : String + <<-HELP + Run an MCP server exposing Amber CLI tools to agents + + Usage: amber mcp serve [options] + + Serves the Model Context Protocol over Streamable HTTP on a single + endpoint, POST /mcp. Both the stateless 2026-07-28 revision and the + initialize-handshake revisions (2025-11-25, 2025-06-18) are answered on + that endpoint; no session state is kept in either case. + + Tools: + amber_version Report CLI, Crystal and protocol versions + project_info Inspect a directory for an Amber application + list_routes List routes declared in config/routes.cr + list_generators List available generators and their arguments + search_docs Search the bundled documentation + read_doc Read one bundled documentation file + create_new_app Scaffold a new application (writes to disk) + generate_component Run a generator in an application (writes to disk) + + Security: there is no authentication. The server binds to 127.0.0.1 and + refuses a non-loopback bind unless --allow-remote is passed. + + Examples: + amber mcp serve + amber mcp serve --port 8080 + HELP + end + + def setup_command_options + option_parser.on("--host=HOST", "Address to bind (default: #{AmberCLI::MCP::Server::DEFAULT_HOST})") do |value| + @parsed_options["host"] = value + @host = value + end + + option_parser.on("--port=PORT", "Port to bind (default: #{AmberCLI::MCP::Server::DEFAULT_PORT})") do |value| + parsed = value.to_i? + unless parsed && (1..65_535).includes?(parsed) + error "Invalid port '#{value}'. Expected an integer between 1 and 65535." + exit(1) + end + @parsed_options["port"] = value + @port = parsed + end + + option_parser.on("--allow-remote", "Permit binding to a non-loopback address (no authentication is performed)") do + @parsed_options["allow_remote"] = true + @allow_remote = true + end + + option_parser.separator "" + option_parser.separator "Usage: amber mcp serve [options]" + end + + def execute + subcommand = remaining_arguments.first? || "serve" + + unless SUBCOMMANDS.includes?(subcommand) + error "Unknown mcp subcommand '#{subcommand}'. Available: #{SUBCOMMANDS.join(", ")}" + puts option_parser + exit!(error: true) + end + + serve + end + + # Refuses a remote bind that was not asked for. + # + # v1 has no authentication, so a non-loopback bind publishes filesystem-writing + # tools to every host that can reach the port. The refusal is the security + # model, not a warning. + def validate_bind! + return unless NON_LOOPBACK_HOSTS.includes?(@host) + return if @allow_remote + + raise RemoteBindRefusedError.new( + "Refusing to bind #{@host}: `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." + ) + end + + private def serve + begin + validate_bind! + rescue ex : RemoteBindRefusedError + error ex.message.to_s + exit!(error: true) + end + + server = AmberCLI::MCP::Server.new(host: @host, port: @port, allow_remote: @allow_remote) + address = server.bind + + if @allow_remote && NON_LOOPBACK_HOSTS.includes?(@host) + warning "Serving on #{address} with no authentication. Remote exposure is unsupported:" + warning "there is no OAuth in this release, and every reachable client can write files." + end + + info "Amber MCP server listening on #{server.endpoint_url}" + info "Health check: http://#{@host}:#{server.port}#{AmberCLI::MCP::Server::HEALTH_PATH}" + info "Protocol revisions: #{AmberCLI::MCP::Protocol::SUPPORTED_VERSIONS.join(", ")}" + info "Press Ctrl-C to stop." + + Process.on_terminate do + puts "" + info "Shutting down." + server.close + end + + server.listen + end + end +end + +# Register the command. +# +# The alias list is built rather than written as `[] of String`, which the parser +# reads as the start of a proc type once a second argument follows it. +AmberCLI::Core::CommandRegistry.register("mcp", Array(String).new, AmberCLI::Commands::McpCommand) diff --git a/src/amber_cli/commands/routes.cr b/src/amber_cli/commands/routes.cr index f43fa33..33fd57b 100644 --- a/src/amber_cli/commands/routes.cr +++ b/src/amber_cli/commands/routes.cr @@ -76,8 +76,14 @@ module AmberCLI::Commands exit!(error: true) end - private def parse_routes - File.read_lines("config/routes.cr").each do |line| + # Parses the routes file into `routes`. + # + # *routes_file* is explicit so path-scoped callers — the MCP `list_routes` + # tool, which serves concurrent requests for different applications from one + # process — can read an application's routes without changing the working + # directory. `amber routes` keeps its original relative default. + def parse_routes(routes_file : String = File.join("config", "routes.cr")) + File.read_lines(routes_file).each do |line| case line.strip when .starts_with?("routes") set_pipe(line) diff --git a/src/amber_cli/mcp/application_inspector.cr b/src/amber_cli/mcp/application_inspector.cr new file mode 100644 index 0000000..de56d09 --- /dev/null +++ b/src/amber_cli/mcp/application_inspector.cr @@ -0,0 +1,156 @@ +# :nodoc: +require "json" +require "yaml" + +module AmberCLI::MCP + # Reads facts about an Amber application from an explicit directory. + # + # Every method takes the application root as an argument. The MCP server answers + # concurrent requests for different applications from one process, so nothing + # here may consult or change the working directory — `Dir.cd` is process-global + # and two in-flight requests would corrupt each other's view of the filesystem. + # + # Nothing here calls `exit` either. `Amber::CLI.config` exits the process on a + # malformed `.amber.yml`, which inside a long-running server would take the + # server down with it, so this reads and rescues instead. + class ApplicationInspector + # Files that, taken together, identify a directory as an Amber application. + SHARD_FILE = "shard.yml" + AMBER_FILE = ".amber.yml" + ROUTES_FILE = File.join("config", "routes.cr") + + getter path : String + + @shard_yaml : YAML::Any? + @amber_yaml : YAML::Any? + + def initialize(path : String) + @path = File.expand_path(path) + # Parsed once up front: memoizing a nullable result needs a separate + # loaded-flag to avoid re-reading a missing file on every accessor. + @shard_yaml = read_yaml(File.join(@path, SHARD_FILE)) + @amber_yaml = read_yaml(File.join(@path, AMBER_FILE)) + end + + def exists? : Bool + Dir.exists?(@path) + end + + def shard_path : String + File.join(@path, SHARD_FILE) + end + + def amber_config_path : String + File.join(@path, AMBER_FILE) + end + + def routes_path : String + File.join(@path, ROUTES_FILE) + end + + # Whether this looks like an Amber application: a shard that depends on + # `amber`, or the `.amber.yml` the CLI writes. + def amber_application? : Bool + return false unless exists? + return true if File.exists?(amber_config_path) + + !amber_dependency.nil? + end + + # The declared `amber` dependency, as a version or branch string. + def amber_dependency : String? + dependencies = shard_yaml.try(&.["dependencies"]?).try(&.as_h?) + return unless dependencies + + entry = dependencies.find { |key, _| key.as_s? == "amber" } + return unless entry + + spec = entry[1].as_h? + return "unspecified" unless spec + + if version = spec["version"]?.try(&.as_s?) + version + elsif branch = spec["branch"]?.try(&.as_s?) + "branch: #{branch}" + elsif commit = spec["commit"]?.try(&.as_s?) + "commit: #{commit}" + else + "unspecified" + end + end + + def application_name : String? + shard_yaml.try(&.["name"]?).try(&.as_s?) + end + + def application_version : String? + shard_yaml.try(&.["version"]?).try(&.to_s) + end + + # Database adapter recorded by `amber new`, defaulting the way `Amber::CLI::Config` does. + def database_adapter : String + amber_yaml.try(&.["database"]?).try(&.as_s?) || "pg" + end + + def template_language : String + amber_yaml.try(&.["language"]?).try(&.as_s?) || "ecr" + end + + def model_layer : String + amber_yaml.try(&.["model"]?).try(&.as_s?) || "none" + end + + # Every dependency name declared in the shard, for orientation. + def dependency_names : Array(String) + shard_yaml.try(&.["dependencies"]?).try(&.as_h?) + .try(&.keys.compact_map(&.as_s?)) || [] of String + end + + # A structured summary suitable for a tool result. + def to_json_payload : JSON::Any + unless exists? + return JSON.parse({ + "path" => @path, + "exists" => false, + "amberApplication" => false, + }.to_json) + end + + JSON.parse({ + "path" => @path, + "exists" => true, + "amberApplication" => amber_application?, + "name" => application_name, + "version" => application_version, + "amberDependency" => amber_dependency, + "databaseAdapter" => database_adapter, + "templateLanguage" => template_language, + "modelLayer" => model_layer, + "dependencies" => dependency_names, + "files" => { + "shardYml" => File.exists?(shard_path), + "amberYml" => File.exists?(amber_config_path), + "configRoutes" => File.exists?(routes_path), + "srcDirectory" => Dir.exists?(File.join(@path, "src")), + "specDirectory" => Dir.exists?(File.join(@path, "spec")), + }, + }.to_json) + end + + private def shard_yaml : YAML::Any? + @shard_yaml + end + + private def amber_yaml : YAML::Any? + @amber_yaml + end + + # A malformed YAML file is a fact about the application, not a server fault. + private def read_yaml(file : String) : YAML::Any? + return unless File.exists?(file) + YAML.parse(File.read(file)) + rescue YAML::ParseException + nil + end + end +end diff --git a/src/amber_cli/mcp/base_tool.cr b/src/amber_cli/mcp/base_tool.cr new file mode 100644 index 0000000..bc165e8 --- /dev/null +++ b/src/amber_cli/mcp/base_tool.cr @@ -0,0 +1,83 @@ +# :nodoc: +require "json" +require "mcprotocol" +require "./tool_outcome" + +module AmberCLI::MCP + # Base class for every tool `amber mcp serve` exposes. + # + # One tool per file, mirroring the one-command-per-file layout under + # `src/amber_cli/commands/`. + abstract class BaseTool + # The programmatic identifier the client calls. + abstract def name : String + + # What the tool does, in the terms a model needs to decide whether to call it. + # Mutating tools state plainly that they write to disk. + abstract def description : String + + # JSON Schema (2020-12) for the tool's arguments. + abstract def input_schema : ::MCProtocol::ToolInputSchema + + # Runs the tool. Implementations return a `ToolOutcome` rather than raising; + # anything that escapes is caught by the dispatcher and reported as a failed + # tool result. + abstract def call(arguments : Hash(String, JSON::Any)) : ToolOutcome + + # Whether the tool writes to the filesystem. Advertised to clients through + # the `destructiveHint` annotation so a host can gate it behind confirmation. + def mutating? : Bool + false + end + + # Human-readable label; falls back to `name` when absent. + def title : String? + nil + end + + # The wire representation sent in `tools/list`. + def definition : ::MCProtocol::Tool + ::MCProtocol::Tool.new( + inputSchema: input_schema, + name: name, + description: description, + title: title, + meta: annotations + ) + end + + # Behavioral hints. `readOnlyHint` and `destructiveHint` are what let a host + # auto-approve introspection while prompting for scaffolding. + private def annotations : Hash(String, JSON::Any) + { + "annotations" => JSON.parse({ + "readOnlyHint" => !mutating?, + "destructiveHint" => mutating?, + "idempotentHint" => !mutating?, + "openWorldHint" => false, + }.to_json), + } + end + + # Reads a required string argument, or returns `nil` when it is absent or of + # the wrong type. + protected def string_argument(arguments : Hash(String, JSON::Any), key : String) : String? + arguments[key]?.try(&.as_s?) + end + + # Whether *value* is an absolute path. + # + # Application-scoped tools require absolute paths: a relative path would be + # resolved against the server's working directory, which is wherever the user + # happened to launch `amber mcp serve` and has nothing to do with the + # application the caller means. + protected def absolute_path?(value : String) : Bool + Path[value].absolute? + end + + # Reads an optional array-of-strings argument. + protected def string_list_argument(arguments : Hash(String, JSON::Any), key : String) : Array(String) + arguments[key]?.try(&.as_a?).try(&.compact_map(&.as_s?)) || [] of String + end + end +end diff --git a/src/amber_cli/mcp/cli_runner.cr b/src/amber_cli/mcp/cli_runner.cr new file mode 100644 index 0000000..9af8b0e --- /dev/null +++ b/src/amber_cli/mcp/cli_runner.cr @@ -0,0 +1,100 @@ +# :nodoc: +require "process" + +module AmberCLI::MCP + # Runs the `amber` CLI as a child process on behalf of the mutating tools. + # + # The scaffolding commands cannot be called in-process. `NewCommand` and + # `GenerateCommand` report every validation failure with `exit(1)` — fine for a + # one-shot CLI, fatal for a long-running server, where a single bad tool call + # would terminate the process and drop every other client. Re-invoking the same + # binary as a child also gives each call its own working directory, so + # concurrent requests scaffolding different applications cannot interfere. + class CliRunner + # `amber new` shells out to `shards install` unless told otherwise, so the + # ceiling has to accommodate a cold dependency fetch. + DEFAULT_TIMEOUT = 10.minutes + + # Reported when the child is killed for exceeding its deadline. + TIMEOUT_EXIT_CODE = -1 + + # The outcome of one child invocation. + struct Outcome + getter exit_code : Int32 + getter stdout : String + getter stderr : String + getter? timed_out : Bool + + def initialize(@exit_code : Int32, @stdout : String, @stderr : String, @timed_out : Bool = false) + end + + def success? : Bool + @exit_code == 0 + end + + # stdout and stderr joined for display, blank sections dropped. + def combined_output : String + [@stdout, @stderr].reject(&.blank?).join("\n").strip + end + end + + getter executable : String + + # *executable* defaults to the running binary so the server always drives the + # same CLI build it was launched from. Specs inject a stub instead. + def initialize(executable : String? = nil, @timeout : Time::Span = DEFAULT_TIMEOUT) + @executable = executable || Process.executable_path || "amber" + end + + # Runs `amber ` with *chdir* as the working directory. + def run(args : Array(String), chdir : String) : Outcome + process = Process.new( + @executable, + args, + chdir: chdir, + input: Process::Redirect::Close, + output: Process::Redirect::Pipe, + error: Process::Redirect::Pipe, + ) + + # stdout and stderr are drained in their own fibers: a child that fills a + # pipe buffer blocks forever if nobody is reading the other end, which no + # timeout on `wait` alone would catch. + stdout_channel = Channel(String).new(1) + stderr_channel = Channel(String).new(1) + status_channel = Channel(Process::Status).new(1) + + spawn { stdout_channel.send(read_stream(process.output)) } + spawn { stderr_channel.send(read_stream(process.error)) } + spawn { status_channel.send(process.wait) } + + select + when status = status_channel.receive + Outcome.new(status.exit_code, stdout_channel.receive, stderr_channel.receive) + when timeout(@timeout) + kill(process) + status_channel.receive + Outcome.new( + TIMEOUT_EXIT_CODE, + stdout_channel.receive, + stderr_channel.receive, + timed_out: true, + ) + end + rescue ex : IO::Error | RuntimeError + Outcome.new(TIMEOUT_EXIT_CODE, "", "Could not run #{@executable}: #{ex.message}") + end + + private def read_stream(io : IO) : String + io.gets_to_end + rescue IO::Error + "" + end + + private def kill(process : Process) + process.signal(Signal::KILL) + rescue + # Already gone; `wait` still reaps it. + end + end +end diff --git a/src/amber_cli/mcp/dispatcher.cr b/src/amber_cli/mcp/dispatcher.cr new file mode 100644 index 0000000..6e106d5 --- /dev/null +++ b/src/amber_cli/mcp/dispatcher.cr @@ -0,0 +1,195 @@ +# :nodoc: +require "json" +require "http/status" +require "mcprotocol" +require "./protocol" +require "./json_rpc_error" +require "./request_envelope" +require "./tool_registry" + +module AmberCLI::MCP + # Turns a validated `RequestEnvelope` into an HTTP status and response body. + # + # Dispatch is on the `method` string, and only then are params decoded into a + # concrete type. Decoding into a union of MCP request types instead would pick + # whichever member happens to parse first: the request classes are almost all + # all-optional objects, so a `tools/list` payload decodes cleanly as several of + # them and the wrong handler runs with no error anywhere. + class Dispatcher + # Guidance handed to clients in `initialize` and `server/discover`. + INSTRUCTIONS = <<-TEXT + Amber CLI tools for inspecting and scaffolding Amber V2 applications. + + Every application-scoped tool takes an explicit absolute `path`; this server + never infers a project from its own working directory. Start with + `project_info` to confirm a directory is an Amber application, `list_routes` + and `list_generators` to introspect it, and `search_docs` / `read_doc` for + reference material. + + `create_new_app` and `generate_component` write to the filesystem. They are + marked with a destructive hint and should be confirmed with the user before + being called. + TEXT + + # What a dispatched request becomes on the wire. A `nil` body means the status + # carries the whole answer, as with the `202 Accepted` a notification gets. + struct Outcome + getter status : HTTP::Status + getter body : String? + + def initialize(@status : HTTP::Status, @body : String? = nil) + end + end + + getter registry : ToolRegistry + + def initialize(@registry : ToolRegistry = ToolRegistry.default) + end + + def dispatch(envelope : RequestEnvelope) : Outcome + # A notification expects no response body, only an acknowledgement. + return Outcome.new(HTTP::Status::ACCEPTED) if envelope.notification? + + case envelope.method + when "initialize" + handle_initialize(envelope) + when "server/discover" + handle_discover(envelope) + when "ping" + respond(envelope, JSON.parse("{}")) + when "tools/list" + handle_tools_list(envelope) + when "tools/call" + handle_tools_call(envelope) + else + raise JsonRpcError.method_not_found(envelope.method) + end + end + + # The legacy handshake. Answering it selects legacy semantics for this client, + # but changes nothing on the server: no session is created and no state is + # retained, so a client may interleave modern and legacy requests freely. + private def handle_initialize(envelope : RequestEnvelope) : Outcome + negotiated = envelope.protocol_version + + result = ::MCProtocol::InitializeResult.new( + capabilities: server_capabilities, + protocolVersion: negotiated, + serverInfo: server_info, + instructions: INSTRUCTIONS + ) + + respond(envelope, JSON.parse(result.to_json)) + end + + # The stateless replacement for the handshake: advertise every revision we + # speak and let the client pick. + private def handle_discover(envelope : RequestEnvelope) : Outcome + result = ::MCProtocol::DiscoverResult.new( + supportedVersions: Protocol::SUPPORTED_VERSIONS, + capabilities: server_capabilities, + instructions: INSTRUCTIONS, + ttlMs: Protocol::LIST_RESULT_TTL_MS, + cacheScope: Protocol::LIST_RESULT_CACHE_SCOPE + ) + + respond(envelope, JSON.parse(result.to_json)) + end + + private def handle_tools_list(envelope : RequestEnvelope) : Outcome + result = ::MCProtocol::ListToolsResult.new(tools: @registry.definitions) + + # Cache hints are REQUIRED on list results from 2026-07-28 and meaningless + # to earlier peers, which ignore unknown fields. + if envelope.modern? + result.ttlMs = Protocol::LIST_RESULT_TTL_MS + result.cacheScope = Protocol::LIST_RESULT_CACHE_SCOPE + end + + respond(envelope, JSON.parse(result.to_json)) + end + + private def handle_tools_call(envelope : RequestEnvelope) : Outcome + params = envelope.params.try(&.as_h?) + raise JsonRpcError.invalid_params("tools/call requires a params object") unless params + + tool_name = params["name"]?.try(&.as_s?) + raise JsonRpcError.invalid_params("tools/call requires params.name") unless tool_name + + tool = @registry[tool_name]? + # An unknown tool is a failure to *find* the tool, which the specification + # puts at the protocol level rather than inside the result. + raise JsonRpcError.method_not_found("tools/call: #{tool_name}") unless tool + + arguments = params["arguments"]?.try(&.as_h?) || {} of String => JSON::Any + + outcome = + begin + tool.call(arguments) + rescue ex : Exception + # A tool that raises is reported as a failed result, not a protocol + # error: the model can only correct what it can see. + ToolOutcome.failure("#{tool_name} raised #{ex.class}: #{ex.message}") + end + + content = [] of ::MCProtocol::ContentBlock + content << ::MCProtocol::TextContent.new(outcome.text) + + result = ::MCProtocol::CallToolResult.new( + content: content, + isError: outcome.error? || nil, + structuredContent: outcome.structured + ) + + respond(envelope, JSON.parse(result.to_json)) + end + + private def server_capabilities : ::MCProtocol::ServerCapabilities + ::MCProtocol::ServerCapabilities.new( + tools: ::MCProtocol::ServerCapabilitiesTools.new(listChanged: false) + ) + end + + private def server_info : ::MCProtocol::Implementation + ::MCProtocol::Implementation.new( + name: Protocol::SERVER_NAME, + version: AmberCLI::VERSION, + title: "Amber CLI" + ) + end + + # Wraps *result* in a JSON-RPC response, adding the fields the negotiated + # revision requires. + private def respond(envelope : RequestEnvelope, result : JSON::Any) : Outcome + fields = result.as_h.dup + + # REQUIRED on every result from 2026-07-28; absent means "complete" to + # earlier clients, so emitting it unconditionally would still be safe — but + # we keep pre-2026 responses byte-identical to what those clients expect. + fields["resultType"] = JSON::Any.new(::MCProtocol::ResultType::COMPLETE) if envelope.modern? + + fields["_meta"] = merged_meta(fields["_meta"]?) + + body = JSON.build do |json| + json.object do + json.field "jsonrpc", "2.0" + json.field "id" { (envelope.id || JSON::Any.new(nil)).to_json(json) } + json.field "result" { JSON::Any.new(fields).to_json(json) } + end + end + + Outcome.new(HTTP::Status::OK, body) + end + + # Servers SHOULD identify themselves in every result's `_meta` so a stateless + # client knows who answered without any prior handshake. + private def merged_meta(existing : JSON::Any?) : JSON::Any + meta = existing.try(&.as_h?).try(&.dup) || {} of String => JSON::Any + meta[Protocol::META_SERVER_INFO] = JSON.parse({ + "name" => Protocol::SERVER_NAME, + "version" => AmberCLI::VERSION, + }.to_json) + JSON::Any.new(meta) + end + end +end diff --git a/src/amber_cli/mcp/document_index.cr b/src/amber_cli/mcp/document_index.cr new file mode 100644 index 0000000..8042f6c --- /dev/null +++ b/src/amber_cli/mcp/document_index.cr @@ -0,0 +1,83 @@ +# :nodoc: +require "json" + +module AmberCLI::MCP + # The documentation corpus `search_docs` and `read_doc` serve. + # + # The documents are read into the binary at compile time. `amber` is distributed + # as a single binary through Homebrew, so anything resolved from disk at runtime + # would be absent on every installed copy — the docs directory only exists in a + # source checkout. + module DocumentIndex + # Document id => contents. Ids are the repository-relative paths, so a result + # can be traced back to the file it came from. + DOCUMENTS = { + "README.md" => {{ read_file("#{__DIR__}/../../../README.md") }}, + "docs/BETA_WEB_APP.md" => {{ read_file("#{__DIR__}/../../../docs/BETA_WEB_APP.md") }}, + "docs/GENERATOR_SUPPORT.md" => {{ read_file("#{__DIR__}/../../../docs/GENERATOR_SUPPORT.md") }}, + "docs/RELEASE_CHECKLIST.md" => {{ read_file("#{__DIR__}/../../../docs/RELEASE_CHECKLIST.md") }}, + "docs/adr/README.md" => {{ read_file("#{__DIR__}/../../../docs/adr/README.md") }}, + "docs/adr/0001-homebrew-release-distribution.md" => {{ read_file("#{__DIR__}/../../../docs/adr/0001-homebrew-release-distribution.md") }}, + } + + # Lines of context returned either side of a match. + CONTEXT_LINES = 2 + + # Ceiling on returned matches, so a one-letter query cannot return the corpus. + DEFAULT_MATCH_LIMIT = 25 + + record Match, document : String, line_number : Int32, line : String, context : String + + # Every document id, with its title and size. + def self.catalog : Array(NamedTuple(id: String, title: String, lines: Int32, bytes: Int32)) + DOCUMENTS.map do |id, body| + {id: id, title: title_of(body, id), lines: body.lines.size, bytes: body.bytesize} + end + end + + def self.ids : Array(String) + DOCUMENTS.keys.to_a + end + + def self.document?(id : String) : String? + DOCUMENTS[id]? + end + + # Case-insensitive substring search across every document. + def self.search(query : String, limit : Int32 = DEFAULT_MATCH_LIMIT) : Array(Match) + needle = query.downcase + matches = [] of Match + return matches if needle.blank? + + DOCUMENTS.each do |id, body| + lines = body.lines + lines.each_with_index do |line, index| + next unless line.downcase.includes?(needle) + + matches << Match.new( + document: id, + line_number: index + 1, + line: line.strip, + context: context_around(lines, index), + ) + return matches if matches.size >= limit + end + end + + matches + end + + private def self.context_around(lines : Array(String), index : Int32) : String + first = Math.max(0, index - CONTEXT_LINES) + last = Math.min(lines.size - 1, index + CONTEXT_LINES) + lines[first..last].join("\n") + end + + # The first Markdown heading, falling back to the id. + private def self.title_of(body : String, id : String) : String + heading = body.lines.find(&.starts_with?("#")) + return id unless heading + heading.lstrip('#').strip + end + end +end diff --git a/src/amber_cli/mcp/json_rpc_error.cr b/src/amber_cli/mcp/json_rpc_error.cr new file mode 100644 index 0000000..98aaa9a --- /dev/null +++ b/src/amber_cli/mcp/json_rpc_error.cr @@ -0,0 +1,101 @@ +# :nodoc: +require "json" +require "http/status" +require "./protocol" + +module AmberCLI::MCP + # A JSON-RPC error that the dispatcher raises and the transport renders. + # + # Carrying the HTTP status alongside the JSON-RPC code keeps the two in sync: + # the specification pairs specific codes with specific statuses (a `-32601` + # must be a `404`, a `-32022` must be a `400`), and splitting that pairing + # across two layers is how they drift. + class JsonRpcError < Exception + getter code : Int32 + getter data : JSON::Any? + getter http_status : HTTP::Status + + def initialize(@code : Int32, message : String, @http_status : HTTP::Status = HTTP::Status::BAD_REQUEST, @data : JSON::Any? = nil) + super(message) + end + + # Renders the JSON-RPC error response body. + # + # *id* is echoed from the request when it could be read; a request too + # malformed to yield an id gets a null id, as JSON-RPC requires. + def to_response(id : JSON::Any?) : String + JSON.build do |json| + json.object do + json.field "jsonrpc", "2.0" + json.field "id" { (id || JSON::Any.new(nil)).to_json(json) } + json.field "error" do + json.object do + json.field "code", code + json.field "message", message.to_s + if payload = data + json.field "data" { payload.to_json(json) } + end + end + end + end + end + end + + # The request body could not be parsed as JSON. + def self.parse_error(detail : String) : JsonRpcError + new(Protocol::PARSE_ERROR, "Parse error: #{detail}") + end + + # The payload parsed but is not a well-formed JSON-RPC request. + def self.invalid_request(detail : String) : JsonRpcError + new(Protocol::INVALID_REQUEST, "Invalid Request: #{detail}") + end + + # The method is well-formed but this server does not implement it. + # + # The specification requires `404 Not Found` here so that a client can tell a + # modern server that lacks the method from a legacy server that does not host + # the endpoint at all. + def self.method_not_found(method : String) : JsonRpcError + new( + Protocol::METHOD_NOT_FOUND, + "Method not found: #{method}", + HTTP::Status::NOT_FOUND, + JSON.parse({"method" => method}.to_json) + ) + end + + # A required parameter is missing or has the wrong shape. + def self.invalid_params(detail : String) : JsonRpcError + new(Protocol::INVALID_PARAMS, "Invalid params: #{detail}") + end + + # A mirrored HTTP header is missing, malformed, or disagrees with the body. + def self.header_mismatch(detail : String) : JsonRpcError + new(Protocol::HEADER_MISMATCH, "Header mismatch: #{detail}") + end + + # The client asked for a protocol revision this server does not implement. + # + # `data.supported` is what lets the client retry rather than give up, so it is + # not optional in practice. + def self.unsupported_protocol_version(requested : String) : JsonRpcError + new( + Protocol::UNSUPPORTED_PROTOCOL_VERSION, + "Unsupported protocol version", + HTTP::Status::BAD_REQUEST, + JSON.parse({ + "supported" => Protocol::SUPPORTED_VERSIONS, + "requested" => requested, + }.to_json) + ) + end + + # An unexpected server-side failure. Tool failures do not come through here — + # they are reported inside the tool result with `isError` so the model can see + # them and self-correct. + def self.internal_error(detail : String) : JsonRpcError + new(Protocol::INTERNAL_ERROR, "Internal error: #{detail}", HTTP::Status::INTERNAL_SERVER_ERROR) + end + end +end diff --git a/src/amber_cli/mcp/protocol.cr b/src/amber_cli/mcp/protocol.cr new file mode 100644 index 0000000..564a76a --- /dev/null +++ b/src/amber_cli/mcp/protocol.cr @@ -0,0 +1,106 @@ +# :nodoc: +require "json" +require "base64" +require "mcprotocol" + +# Protocol-level constants shared by the MCP transport, dispatcher and tools. +# +# The `mcprotocol` shard supplies the message *schema*; this module supplies the +# Streamable HTTP *binding* details that the schema deliberately leaves out — +# header names, error codes and the `_meta` keys that carry per-request protocol +# state in the stateless 2026-07-28 revision. +module AmberCLI::MCP::Protocol + # The server identity reported in `InitializeResult`, `DiscoverResult` and the + # `io.modelcontextprotocol/serverInfo` result metadata. + SERVER_NAME = "amber-cli" + + # Revisions this server speaks, newest first. Mirrors the shard so the two can + # never drift apart silently. + SUPPORTED_VERSIONS = ::MCProtocol::SUPPORTED_PROTOCOL_VERSIONS + + # The stateless revision: no `initialize` handshake, per-request `_meta`, + # mirrored HTTP headers and `server/discover`. + MODERN_VERSION = ::MCProtocol::PROTOCOL_VERSION_2026_07_28 + + # The newest revision that still uses the `initialize` handshake. This is the + # revision Claude Desktop negotiates today, so it is what we answer an + # `initialize` request with when the client asks for something newer. + LATEST_LEGACY_VERSION = ::MCProtocol::PROTOCOL_VERSION_2025_11_25 + + # Revisions reachable through the `initialize` handshake. + LEGACY_VERSIONS = [ + ::MCProtocol::PROTOCOL_VERSION_2025_11_25, + ::MCProtocol::PROTOCOL_VERSION_2025_06_18, + ] + + # HTTP headers the Streamable HTTP binding mirrors from the request body. + # Compared case-insensitively per RFC 9110; values are case-sensitive. + PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version" + METHOD_HEADER = "Mcp-Method" + NAME_HEADER = "Mcp-Name" + + # `_meta` keys reserved by the specification for per-request protocol state. + META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion" + META_CLIENT_INFO = "io.modelcontextprotocol/clientInfo" + META_CLIENT_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities" + META_SERVER_INFO = "io.modelcontextprotocol/serverInfo" + + # JSON-RPC error codes. `-32020`..`-32099` is the range the MCP specification + # reserves for itself; we emit only codes it defines. + PARSE_ERROR = -32700 + INVALID_REQUEST = -32600 + METHOD_NOT_FOUND = -32601 + INVALID_PARAMS = -32602 + INTERNAL_ERROR = -32603 + + # The HTTP headers do not match the corresponding request body values, or a + # required header is missing or malformed. + HEADER_MISMATCH = -32020 + # A capability the request needs was absent from `clientCapabilities`. + MISSING_REQUIRED_CLIENT_CAPABILITY = -32021 + # The requested protocol version is one this server does not implement. + UNSUPPORTED_PROTOCOL_VERSION = -32022 + + # Methods whose `Mcp-Name` header mirrors `params.name`. + NAMED_BY_PARAMS_NAME = ["tools/call", "prompts/get"] + # Methods whose `Mcp-Name` header mirrors `params.uri`. + NAMED_BY_PARAMS_URI = ["resources/read"] + + # How long a client may cache a list result, in milliseconds. Tool definitions + # are compiled into the binary, so they cannot change while the process lives; + # an hour is conservative rather than meaningful. + LIST_RESULT_TTL_MS = 3_600_000_i64 + + # Tool definitions carry no per-user data, so intermediaries may share them. + LIST_RESULT_CACHE_SCOPE = "public" + + # Sentinel wrapper for header values that cannot be represented as plain ASCII. + BASE64_SENTINEL_PREFIX = "=?base64?" + BASE64_SENTINEL_SUFFIX = "?=" + + # Decodes a header value that may use the Base64 sentinel format. + # + # Servers MUST decode `Mcp-Name` and `Mcp-Param-*` values before comparing them + # to the request body, otherwise any non-ASCII tool name fails validation. + # Returns the value unchanged when it is not sentinel-wrapped, and `nil` when + # it is wrapped but the payload is not valid Base64. + def self.decode_header_value(value : String) : String? + return value unless value.starts_with?(BASE64_SENTINEL_PREFIX) && value.ends_with?(BASE64_SENTINEL_SUFFIX) + + encoded = value[BASE64_SENTINEL_PREFIX.size...(value.size - BASE64_SENTINEL_SUFFIX.size)] + String.new(Base64.decode(encoded)) + rescue Base64::Error + nil + end + + # Whether *version* uses per-request metadata rather than an `initialize` + # handshake. + def self.modern?(version : String) : Bool + version == MODERN_VERSION + end + + # Whether this server implements *version*. + def self.supported?(version : String) : Bool + SUPPORTED_VERSIONS.includes?(version) + end +end diff --git a/src/amber_cli/mcp/request_envelope.cr b/src/amber_cli/mcp/request_envelope.cr new file mode 100644 index 0000000..1692994 --- /dev/null +++ b/src/amber_cli/mcp/request_envelope.cr @@ -0,0 +1,211 @@ +# :nodoc: +require "json" +require "http/headers" +require "./protocol" +require "./json_rpc_error" + +module AmberCLI::MCP + # One parsed, validated JSON-RPC message together with the transport metadata + # that governs how it must be answered. + # + # Parsing dispatches on the `method` string and never decodes the body into a + # broad union type. Crystal's JSON union deserialization picks the first member + # that parses, and MCP request types are largely all-optional objects with the + # same shape, so a union decode silently yields the wrong request class. + class RequestEnvelope + # The raw JSON-RPC `id`. `nil` for notifications. + getter id : JSON::Any? + + # The JSON-RPC method string. Dispatch happens on this, before any params are + # decoded into a concrete type. + getter method : String + + getter params : JSON::Any? + + # The `_meta` object from `params`, if any. + getter meta : JSON::Any? + + # The protocol version the client actually stated, or `nil` if it said + # nothing. Distinct from `protocol_version`, which fills in a default. + getter declared_version : String? + + # The revision governing this request. Determines the era: modern requests + # carry mirrored headers and per-request `_meta`, legacy ones do not. + getter protocol_version : String + + def initialize( + @method : String, + @protocol_version : String, + @id : JSON::Any? = nil, + @params : JSON::Any? = nil, + @meta : JSON::Any? = nil, + @declared_version : String? = nil, + ) + end + + # A message with no `id` expects no response, only a transport acknowledgement. + def notification? : Bool + @id.nil? + end + + # Whether this request uses the stateless per-request-metadata era. + def modern? : Bool + Protocol.modern?(@protocol_version) + end + + # The client's self-reported identity, for logging only. The specification is + # explicit that this is unverified and must not drive behavior. + def client_info : JSON::Any? + @meta.try(&.as_h?).try(&.[Protocol::META_CLIENT_INFO]?) + end + + # Parses *body* and validates it against *headers*. + # + # Raises `JsonRpcError` for anything malformed; the transport turns that into + # the paired HTTP status and JSON-RPC error body. + def self.parse(body : String, headers : HTTP::Headers) : RequestEnvelope + json = begin + JSON.parse(body) + rescue ex : JSON::ParseException + raise JsonRpcError.parse_error(ex.message.to_s) + end + + object = json.as_h? + raise JsonRpcError.invalid_request("body must be a JSON object") unless object + + method = object["method"]?.try(&.as_s?) + raise JsonRpcError.invalid_request("missing or non-string \"method\"") unless method + + # An absent id means notification; an explicitly null id is malformed, and + # the two are only distinguishable through the key itself. + id = nil.as(JSON::Any?) + if object.has_key?("id") + candidate = object["id"] + raise JsonRpcError.invalid_request("\"id\" must not be null") if candidate.raw.nil? + id = candidate + end + + params = object["params"]? + meta = params.try(&.as_h?).try(&.["_meta"]?) + + declared_version = resolve_declared_version(method, params, meta, headers) + protocol_version = resolve_protocol_version(method, declared_version) + + envelope = new( + method: method, + protocol_version: protocol_version, + id: id, + params: params, + meta: meta, + declared_version: declared_version, + ) + + envelope.validate_modern_request!(headers) if envelope.modern? + envelope + end + + # Reads the version the client stated, preferring the body over the header. + # + # The body is the source of truth; a header that disagrees with it is a + # validation failure rather than an alternative reading, because an + # intermediary routing on the header and a server executing on the body would + # otherwise act on different requests. + private def self.resolve_declared_version(method : String, params : JSON::Any?, meta : JSON::Any?, headers : HTTP::Headers) : String? + body_version = meta.try(&.as_h?).try(&.[Protocol::META_PROTOCOL_VERSION]?).try(&.as_s?) + header_version = headers[Protocol::PROTOCOL_VERSION_HEADER]? + + if body_version && header_version && body_version != header_version + raise JsonRpcError.header_mismatch( + "#{Protocol::PROTOCOL_VERSION_HEADER} header value #{header_version.inspect} " \ + "does not match body value #{body_version.inspect}" + ) + end + + return body_version if body_version + return header_version if header_version + + # The legacy handshake carries the version in the params, not in `_meta`. + return params.try(&.as_h?).try(&.["protocolVersion"]?).try(&.as_s?) if method == "initialize" + + nil + end + + # Resolves the revision that governs this request. + # + # A client that states 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 a header error it cannot act on. + private def self.resolve_protocol_version(method : String, declared_version : String?) : String + return Protocol::LATEST_LEGACY_VERSION if declared_version.nil? + raise JsonRpcError.unsupported_protocol_version(declared_version) unless Protocol.supported?(declared_version) + + # `initialize` does not exist in the modern era. A client that sends the + # handshake while claiming 2026-07-28 is contradicting itself, so we answer + # on the newest revision that actually has a handshake. + return Protocol::LATEST_LEGACY_VERSION if method == "initialize" && Protocol.modern?(declared_version) + + declared_version + end + + # Enforces the header mirroring the 2026-07-28 Streamable HTTP binding requires. + protected def validate_modern_request!(headers : HTTP::Headers) + unless headers[Protocol::PROTOCOL_VERSION_HEADER]? + raise JsonRpcError.header_mismatch("missing required #{Protocol::PROTOCOL_VERSION_HEADER} header") + end + + header_method = headers[Protocol::METHOD_HEADER]? + raise JsonRpcError.header_mismatch("missing required #{Protocol::METHOD_HEADER} header") unless header_method + + unless header_method == @method + raise JsonRpcError.header_mismatch( + "#{Protocol::METHOD_HEADER} header value #{header_method.inspect} does not match body value #{@method.inspect}" + ) + end + + validate_name_header!(headers) + validate_required_meta! + end + + private def validate_name_header!(headers : HTTP::Headers) + body_name = + if Protocol::NAMED_BY_PARAMS_NAME.includes?(@method) + @params.try(&.as_h?).try(&.["name"]?).try(&.as_s?) + elsif Protocol::NAMED_BY_PARAMS_URI.includes?(@method) + @params.try(&.as_h?).try(&.["uri"]?).try(&.as_s?) + end + + return unless body_name + + raw_header = headers[Protocol::NAME_HEADER]? + raise JsonRpcError.header_mismatch("missing required #{Protocol::NAME_HEADER} header") unless raw_header + + decoded = Protocol.decode_header_value(raw_header) + unless decoded + raise JsonRpcError.header_mismatch("#{Protocol::NAME_HEADER} header value is not valid Base64") + end + + unless decoded == body_name + raise JsonRpcError.header_mismatch( + "#{Protocol::NAME_HEADER} header value #{decoded.inspect} does not match body value #{body_name.inspect}" + ) + end + end + + # `protocolVersion` and `clientCapabilities` are REQUIRED on every modern + # request; a request missing either is malformed params, not a header problem. + private def validate_required_meta! + fields = @meta.try(&.as_h?) + unless fields + raise JsonRpcError.invalid_params("modern requests require a params._meta object") + end + + unless fields[Protocol::META_PROTOCOL_VERSION]?.try(&.as_s?) + raise JsonRpcError.invalid_params("params._meta.#{Protocol::META_PROTOCOL_VERSION} is required") + end + + unless fields[Protocol::META_CLIENT_CAPABILITIES]?.try(&.as_h?) + raise JsonRpcError.invalid_params("params._meta.#{Protocol::META_CLIENT_CAPABILITIES} is required") + end + end + end +end diff --git a/src/amber_cli/mcp/server.cr b/src/amber_cli/mcp/server.cr new file mode 100644 index 0000000..e14b6d1 --- /dev/null +++ b/src/amber_cli/mcp/server.cr @@ -0,0 +1,229 @@ +# :nodoc: +require "http/server" +require "json" +require "uri" +require "./protocol" +require "./json_rpc_error" +require "./request_envelope" +require "./dispatcher" + +module AmberCLI::MCP + # Streamable HTTP transport for the Amber MCP server. + # + # One endpoint, `POST /mcp`, answering each request independently. No session is + # ever created: no `Mcp-Session-Id` is minted or echoed, `Last-Event-ID` is + # ignored, and `GET`/`DELETE` on the endpoint are refused. Both eras are served + # on the same endpoint — a client that opens with `initialize` gets the classic + # lifecycle, one that sends header-versioned 2026-07-28 requests is served + # statelessly — and neither path retains anything between requests. + class Server + DEFAULT_HOST = "127.0.0.1" + DEFAULT_PORT = 5757 + + ENDPOINT_PATH = "/mcp" + HEALTH_PATH = "/healthz" + + # Ceiling on a request body. A tool call is a small JSON object; anything + # larger is a mistake or an attempt to exhaust memory. + MAX_BODY_BYTES = 4 * 1024 * 1024 + + # Hosts whose `Origin` is accepted when bound to loopback. Anything else is a + # cross-origin browser request, which is the DNS-rebinding path the + # specification requires servers to close. + LOCAL_ORIGIN_HOSTS = ["localhost", "127.0.0.1", "::1", "[::1]"] + + getter host : String + getter port : Int32 + getter dispatcher : Dispatcher + + def initialize( + @host : String = DEFAULT_HOST, + @port : Int32 = DEFAULT_PORT, + @allow_remote : Bool = false, + @dispatcher : Dispatcher = Dispatcher.new, + ) + @server = HTTP::Server.new { |context| handle(context) } + end + + # Binds the listening socket and returns the address actually assigned. + # + # Binding is separate from listening so a caller — a spec, in practice — can + # ask for port 0, learn the ephemeral port the kernel chose, and only then + # start serving. + def bind : Socket::IPAddress + address = @server.bind_tcp(@host, @port) + @port = address.port + address + end + + # Serves until `close`. Blocks the calling fiber. + def listen + @server.listen + end + + def close + @server.close + end + + def closed? : Bool + @server.closed? + end + + # The URL the endpoint is reachable at, for printing at startup. + def endpoint_url : String + displayed_host = @host.includes?(':') ? "[#{@host}]" : @host + "http://#{displayed_host}:#{@port}#{ENDPOINT_PATH}" + end + + private def handle(context : HTTP::Server::Context) + case {context.request.method, context.request.path} + when {"GET", HEALTH_PATH} + respond_health(context) + when {"POST", ENDPOINT_PATH} + handle_endpoint(context) + when {"GET", ENDPOINT_PATH}, {"DELETE", ENDPOINT_PATH} + # 2025-03-26..2025-11-25 used GET for a standalone SSE stream and DELETE + # to end a session. Neither mechanism exists in this revision. + respond_method_not_allowed(context) + else + respond_not_found(context) + end + rescue ex : Exception + respond_error(context, JsonRpcError.internal_error(ex.message.to_s), nil) + end + + private def respond_health(context : HTTP::Server::Context) + context.response.status = HTTP::Status::OK + context.response.content_type = "text/plain" + context.response.print "ok" + end + + private def respond_method_not_allowed(context : HTTP::Server::Context) + context.response.status = HTTP::Status::METHOD_NOT_ALLOWED + context.response.headers["Allow"] = "POST" + context.response.content_type = "text/plain" + context.response.print "Only POST is supported on #{ENDPOINT_PATH}." + end + + private def respond_not_found(context : HTTP::Server::Context) + context.response.status = HTTP::Status::NOT_FOUND + context.response.content_type = "text/plain" + context.response.print "Not found. The MCP endpoint is POST #{ENDPOINT_PATH}." + end + + private def handle_endpoint(context : HTTP::Server::Context) + unless origin_allowed?(context.request.headers["Origin"]?) + return respond_forbidden(context) + end + + body = read_body(context.request) + # The id is recovered before validation so that an error response can still + # be correlated with the request that caused it. A client whose request was + # rejected for a bad header cannot match a null-id error to anything. + id = peek_id(body) + + begin + envelope = RequestEnvelope.parse(body, context.request.headers) + outcome = @dispatcher.dispatch(envelope) + + context.response.status = outcome.status + if payload = outcome.body + write_payload(context, payload) + end + rescue error : JsonRpcError + respond_error(context, error, id) + end + rescue error : JsonRpcError + respond_error(context, error, nil) + end + + # Best-effort read of the JSON-RPC id. A body too malformed to yield one gets + # a null id, which JSON-RPC allows for exactly this case. + private def peek_id(body : String) : JSON::Any? + json = JSON.parse(body) + candidate = json.as_h?.try(&.["id"]?) + candidate.try { |value| value.raw.nil? ? nil : value } + rescue JSON::ParseException + nil + end + + private def respond_forbidden(context : HTTP::Server::Context) + context.response.status = HTTP::Status::FORBIDDEN + context.response.content_type = "application/json" + context.response.print JsonRpcError.new( + Protocol::INVALID_REQUEST, + "Origin not allowed", + HTTP::Status::FORBIDDEN + ).to_response(nil) + end + + private def respond_error(context : HTTP::Server::Context, error : JsonRpcError, id : JSON::Any?) + context.response.status = error.http_status + context.response.content_type = "application/json" + context.response.print error.to_response(id) + end + + # Chooses the response framing. + # + # A conforming 2026-07-28 client sends `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, i.e. when it asked for a + # stream specifically. + private def write_payload(context : HTTP::Server::Context, payload : String) + if sse_preferred?(context.request.headers["Accept"]?) + context.response.content_type = "text/event-stream" + context.response.headers["Cache-Control"] = "no-cache" + # Tells reverse proxies not to buffer, which would otherwise hold the + # event until the connection closed. + context.response.headers["X-Accel-Buffering"] = "no" + context.response.print "event: message\ndata: #{payload}\n\n" + else + context.response.content_type = "application/json" + context.response.print payload + end + end + + private def sse_preferred?(accept : String?) : Bool + return false unless accept + + normalized = accept.downcase + return false unless normalized.includes?("text/event-stream") + + !normalized.includes?("application/json") && !normalized.includes?("*/*") + end + + private def read_body(request : HTTP::Request) : String + io = request.body + raise JsonRpcError.invalid_request("empty request body") unless io + + if (length = request.content_length) && length > MAX_BODY_BYTES + raise JsonRpcError.invalid_request("request body exceeds #{MAX_BODY_BYTES} bytes") + end + + body = io.gets_to_end + raise JsonRpcError.invalid_request("empty request body") if body.empty? + raise JsonRpcError.invalid_request("request body exceeds #{MAX_BODY_BYTES} bytes") if body.bytesize > MAX_BODY_BYTES + + body + end + + # Validates `Origin` to close the DNS-rebinding path: without this, any web + # page the user visits could drive a localhost MCP server that has no auth. + private def origin_allowed?(origin : String?) : Bool + # Non-browser clients send no Origin at all; there is nothing to validate. + return true unless origin + # Once the operator has opted into remote exposure, origin filtering is not + # the control that is protecting them. + return true if @allow_remote + + uri = URI.parse(origin) + host = uri.host + return false unless host + + LOCAL_ORIGIN_HOSTS.includes?(host) + rescue URI::Error + false + end + end +end diff --git a/src/amber_cli/mcp/tool_outcome.cr b/src/amber_cli/mcp/tool_outcome.cr new file mode 100644 index 0000000..f47ce74 --- /dev/null +++ b/src/amber_cli/mcp/tool_outcome.cr @@ -0,0 +1,43 @@ +# :nodoc: +require "json" + +module AmberCLI::MCP + # What a tool returns: human-readable text, optional machine-readable data, and + # whether the call failed. + # + # Tool failures travel in the result rather than as JSON-RPC errors so that the + # model can see what went wrong and correct itself; a protocol error is invisible + # to it. + struct ToolOutcome + getter text : String + getter structured : JSON::Any? + getter? error : Bool + + def initialize(@text : String, @structured : JSON::Any? = nil, @error : Bool = false) + end + + # A successful text-only result. + def self.ok(text : String) : ToolOutcome + new(text) + end + + # A successful result carrying structured data. + # + # The payload is emitted twice on purpose: fenced in the text block for models + # reading the transcript, and in `structuredContent` for callers that parse. + def self.data(summary : String, payload : JSON::Any) : ToolOutcome + pretty = payload.to_pretty_json + new("#{summary}\n\n```json\n#{pretty}\n```", payload) + end + + # :ditto: + def self.data(summary : String, payload) : ToolOutcome + data(summary, JSON.parse(payload.to_json)) + end + + # A failed tool call. + def self.failure(text : String) : ToolOutcome + new(text, nil, true) + end + end +end diff --git a/src/amber_cli/mcp/tool_registry.cr b/src/amber_cli/mcp/tool_registry.cr new file mode 100644 index 0000000..4a09b9e --- /dev/null +++ b/src/amber_cli/mcp/tool_registry.cr @@ -0,0 +1,59 @@ +# :nodoc: +require "./base_tool" +require "./tools/amber_version_tool" +require "./tools/project_info_tool" +require "./tools/list_routes_tool" +require "./tools/list_generators_tool" +require "./tools/search_docs_tool" +require "./tools/read_doc_tool" +require "./tools/create_new_app_tool" +require "./tools/generate_component_tool" + +module AmberCLI::MCP + # The set of tools an MCP server exposes. + # + # Ordinary construction registers the v1 set; specs build empty registries and + # register stubs so a tool spec never has to boot the whole server. + class ToolRegistry + getter tools : Hash(String, BaseTool) + + def initialize + @tools = {} of String => BaseTool + end + + # The v1 tool set: read-only introspection plus the two scaffolding tools. + def self.default : ToolRegistry + registry = new + registry.register(Tools::AmberVersionTool.new) + registry.register(Tools::ProjectInfoTool.new) + registry.register(Tools::ListRoutesTool.new) + registry.register(Tools::ListGeneratorsTool.new) + registry.register(Tools::SearchDocsTool.new) + registry.register(Tools::ReadDocTool.new) + registry.register(Tools::CreateNewAppTool.new) + registry.register(Tools::GenerateComponentTool.new) + registry + end + + def register(tool : BaseTool) : BaseTool + @tools[tool.name] = tool + end + + def []?(name : String) : BaseTool? + @tools[name]? + end + + def size : Int32 + @tools.size + end + + def names : Array(String) + @tools.keys.to_a + end + + # Tool definitions in registration order, for `tools/list`. + def definitions : Array(::MCProtocol::Tool) + @tools.values.map(&.definition) + end + end +end diff --git a/src/amber_cli/mcp/tools/amber_version_tool.cr b/src/amber_cli/mcp/tools/amber_version_tool.cr new file mode 100644 index 0000000..4e07ab2 --- /dev/null +++ b/src/amber_cli/mcp/tools/amber_version_tool.cr @@ -0,0 +1,44 @@ +# :nodoc: +require "json" +require "../base_tool" +require "../protocol" + +module AmberCLI::MCP::Tools + # Reports the CLI build and the protocol revisions this server speaks. + class AmberVersionTool < AmberCLI::MCP::BaseTool + def name : String + "amber_version" + end + + def title : String? + "Amber CLI version" + end + + def description : String + <<-TEXT + Report the version of the Amber CLI serving this MCP endpoint, the Crystal + compiler it was built with, and the MCP protocol revisions it supports. + Read-only; takes no arguments. Call this first to confirm which CLI build an + agent is talking to before relying on generator or scaffolding behavior. + TEXT + end + + def input_schema : ::MCProtocol::ToolInputSchema + ::MCProtocol::ToolInputSchema.new( + properties: JSON.parse("{}"), + required: [] of String + ) + end + + def call(arguments : Hash(String, JSON::Any)) : AmberCLI::MCP::ToolOutcome + payload = { + "cliVersion" => AmberCLI::VERSION, + "crystalVersion" => Crystal::VERSION, + "mcpServerName" => AmberCLI::MCP::Protocol::SERVER_NAME, + "supportedProtocolVersions" => AmberCLI::MCP::Protocol::SUPPORTED_VERSIONS, + } + + AmberCLI::MCP::ToolOutcome.data("Amber CLI v#{AmberCLI::VERSION}", payload) + end + end +end diff --git a/src/amber_cli/mcp/tools/create_new_app_tool.cr b/src/amber_cli/mcp/tools/create_new_app_tool.cr new file mode 100644 index 0000000..b476a23 --- /dev/null +++ b/src/amber_cli/mcp/tools/create_new_app_tool.cr @@ -0,0 +1,143 @@ +# :nodoc: +require "json" +require "../base_tool" +require "../cli_runner" +require "../../commands/new" + +module AmberCLI::MCP::Tools + # Scaffolds a new Amber application. **Writes to the filesystem.** + class CreateNewAppTool < AmberCLI::MCP::BaseTool + getter runner : AmberCLI::MCP::CliRunner + + def initialize(@runner : AmberCLI::MCP::CliRunner = AmberCLI::MCP::CliRunner.new) + end + + def name : String + "create_new_app" + end + + def title : String? + "Create a new Amber application" + end + + def description : String + <<-TEXT + MUTATING: creates a new Amber application on disk, writing a full project + tree (src/, config/, db/, spec/, public/, shard.yml, .amber.yml) at the + given path. Requires an absolute `path` whose parent directory exists and + whose final component does NOT already exist — this tool refuses to write + into an existing directory rather than merging into it. Runs + non-interactively. Dependency installation is skipped unless + `install_dependencies` is true, because `shards install` needs the network + and can take minutes. + TEXT + end + + def mutating? : Bool + true + end + + def input_schema : ::MCProtocol::ToolInputSchema + ::MCProtocol::ToolInputSchema.new( + properties: JSON.parse({ + "path" => { + "type" => "string", + "description" => "Absolute path of the application directory to create. Must not already exist.", + }, + "database" => { + "type" => "string", + "enum" => AmberCLI::Commands::NewCommand::VALID_DATABASES, + "description" => "Database engine to record. Defaults to pg.", + }, + "type" => { + "type" => "string", + "enum" => AmberCLI::Commands::NewCommand::VALID_APP_TYPES, + "description" => "Application type. Defaults to web; native is a preview surface.", + }, + "install_dependencies" => { + "type" => "boolean", + "description" => "Run shards install after scaffolding. Defaults to false.", + }, + }.to_json), + required: ["path"] + ) + end + + def call(arguments : Hash(String, JSON::Any)) : AmberCLI::MCP::ToolOutcome + path = string_argument(arguments, "path") + return AmberCLI::MCP::ToolOutcome.failure("`path` is required and must be a string.") unless path + unless absolute_path?(path) + return AmberCLI::MCP::ToolOutcome.failure("`path` must be absolute, got #{path.inspect}.") + end + + target = File.expand_path(path) + if File.exists?(target) || Dir.exists?(target) + return AmberCLI::MCP::ToolOutcome.failure( + "Refusing to scaffold into an existing path: #{target} already exists. " \ + "Choose a path that does not exist yet." + ) + end + + parent = File.dirname(target) + unless Dir.exists?(parent) + return AmberCLI::MCP::ToolOutcome.failure("Parent directory does not exist: #{parent}") + end + + project_name = File.basename(target) + if project_name.matches?(/\s/) + return AmberCLI::MCP::ToolOutcome.failure("Project name must not contain whitespace: #{project_name.inspect}") + end + + if error = validate_choice(arguments, "database", AmberCLI::Commands::NewCommand::VALID_DATABASES) + return error + end + if error = validate_choice(arguments, "type", AmberCLI::Commands::NewCommand::VALID_APP_TYPES) + return error + end + + run(arguments, parent, project_name, target) + end + + private def run(arguments, parent : String, project_name : String, target : String) : AmberCLI::MCP::ToolOutcome + args = ["new", project_name, "--assume-yes"] + if database = string_argument(arguments, "database") + args << "--database=#{database}" + end + if app_type = string_argument(arguments, "type") + args << "--type=#{app_type}" + end + args << "--no-deps" unless arguments["install_dependencies"]?.try(&.as_bool?) + + outcome = @runner.run(args, chdir: parent) + + if outcome.timed_out? + return AmberCLI::MCP::ToolOutcome.failure( + "amber #{args.join(' ')} timed out and was killed. Partial output:\n#{outcome.combined_output}" + ) + end + + unless outcome.success? + return AmberCLI::MCP::ToolOutcome.failure( + "amber #{args.join(' ')} failed with exit code #{outcome.exit_code}:\n#{outcome.combined_output}" + ) + end + + AmberCLI::MCP::ToolOutcome.data( + "Created #{project_name} at #{target}", + { + "path" => target, + "name" => project_name, + "command" => "amber #{args.join(' ')}", + "output" => outcome.combined_output, + } + ) + end + + private def validate_choice(arguments, key : String, allowed : Array(String)) : AmberCLI::MCP::ToolOutcome? + value = string_argument(arguments, key) + return if value.nil? || allowed.includes?(value) + + AmberCLI::MCP::ToolOutcome.failure("Invalid `#{key}` #{value.inspect}. Allowed: #{allowed.join(", ")}") + end + end +end diff --git a/src/amber_cli/mcp/tools/generate_component_tool.cr b/src/amber_cli/mcp/tools/generate_component_tool.cr new file mode 100644 index 0000000..b55c2f2 --- /dev/null +++ b/src/amber_cli/mcp/tools/generate_component_tool.cr @@ -0,0 +1,131 @@ +# :nodoc: +require "json" +require "../base_tool" +require "../cli_runner" +require "../application_inspector" +require "../../commands/generate" + +module AmberCLI::MCP::Tools + # Runs an Amber generator inside an existing application. **Writes to the filesystem.** + class GenerateComponentTool < AmberCLI::MCP::BaseTool + getter runner : AmberCLI::MCP::CliRunner + + def initialize(@runner : AmberCLI::MCP::CliRunner = AmberCLI::MCP::CliRunner.new) + end + + def name : String + "generate_component" + end + + def title : String? + "Generate an Amber component" + end + + def description : String + <<-TEXT + MUTATING: runs an Amber generator inside an existing application, writing + new source files (and, for some generators, migrations and specs) under the + given path. Existing files may be overwritten by the generator. Requires an + absolute `path` to an Amber application root, a generator `type`, and a + component `name`. Call `list_generators` for the available types and their + arguments. Runs non-interactively. + TEXT + end + + def mutating? : Bool + true + end + + def input_schema : ::MCProtocol::ToolInputSchema + ::MCProtocol::ToolInputSchema.new( + properties: JSON.parse({ + "path" => { + "type" => "string", + "description" => "Absolute path to the Amber application root.", + }, + "type" => { + "type" => "string", + "enum" => AmberCLI::Commands::GenerateCommand::VALID_TYPES, + "description" => "Generator to run.", + }, + "name" => { + "type" => "string", + "description" => "Name of the component to generate, e.g. \"User\" or \"Posts\".", + }, + "fields" => { + "type" => "array", + "items" => {"type" => "string"}, + "description" => "Field or action arguments, e.g. [\"title:string\", \"body:text\"] for a model, or [\"index\", \"show\"] for a controller.", + }, + }.to_json), + required: ["path", "type", "name"] + ) + end + + def call(arguments : Hash(String, JSON::Any)) : AmberCLI::MCP::ToolOutcome + path = string_argument(arguments, "path") + return AmberCLI::MCP::ToolOutcome.failure("`path` is required and must be a string.") unless path + unless absolute_path?(path) + return AmberCLI::MCP::ToolOutcome.failure("`path` must be absolute, got #{path.inspect}.") + end + + generator_type = string_argument(arguments, "type") + return AmberCLI::MCP::ToolOutcome.failure("`type` is required and must be a string.") unless generator_type + + unless AmberCLI::Commands::GenerateCommand::VALID_TYPES.includes?(generator_type) + return AmberCLI::MCP::ToolOutcome.failure( + "Unknown generator #{generator_type.inspect}. Available: " \ + "#{AmberCLI::Commands::GenerateCommand::VALID_TYPES.join(", ")}" + ) + end + + component_name = string_argument(arguments, "name") + return AmberCLI::MCP::ToolOutcome.failure("`name` is required and must be a string.") unless component_name + if component_name.blank? || component_name.matches?(/\s/) + return AmberCLI::MCP::ToolOutcome.failure("`name` must be a single non-blank token, got #{component_name.inspect}.") + end + + inspector = AmberCLI::MCP::ApplicationInspector.new(path) + unless inspector.exists? + return AmberCLI::MCP::ToolOutcome.failure("No such directory: #{inspector.path}") + end + unless inspector.amber_application? + return AmberCLI::MCP::ToolOutcome.failure( + "#{inspector.path} does not look like an Amber application " \ + "(no .amber.yml and no amber dependency in shard.yml). Refusing to generate into it." + ) + end + + run(inspector.path, generator_type, component_name, string_list_argument(arguments, "fields")) + end + + private def run(app_path : String, generator_type : String, component_name : String, fields : Array(String)) : AmberCLI::MCP::ToolOutcome + args = ["generate", generator_type, component_name] + fields + outcome = @runner.run(args, chdir: app_path) + + if outcome.timed_out? + return AmberCLI::MCP::ToolOutcome.failure( + "amber #{args.join(' ')} timed out and was killed. Partial output:\n#{outcome.combined_output}" + ) + end + + unless outcome.success? + return AmberCLI::MCP::ToolOutcome.failure( + "amber #{args.join(' ')} failed with exit code #{outcome.exit_code}:\n#{outcome.combined_output}" + ) + end + + AmberCLI::MCP::ToolOutcome.data( + "Generated #{generator_type} #{component_name} in #{app_path}", + { + "path" => app_path, + "type" => generator_type, + "name" => component_name, + "fields" => fields, + "command" => "amber #{args.join(' ')}", + "output" => outcome.combined_output, + } + ) + end + end +end diff --git a/src/amber_cli/mcp/tools/list_generators_tool.cr b/src/amber_cli/mcp/tools/list_generators_tool.cr new file mode 100644 index 0000000..f5c43da --- /dev/null +++ b/src/amber_cli/mcp/tools/list_generators_tool.cr @@ -0,0 +1,73 @@ +# :nodoc: +require "json" +require "../base_tool" +require "../../commands/generate" + +module AmberCLI::MCP::Tools + # Enumerates the generators `generate_component` can invoke. + class ListGeneratorsTool < AmberCLI::MCP::BaseTool + # What each generator produces, keyed by the type `amber generate` accepts. + GENERATOR_SUMMARIES = { + "model" => "A model class plus its migration.", + "controller" => "A controller with the named actions, its views and a spec.", + "scaffold" => "A full CRUD resource: model, schema, controller, views, migration.", + "migration" => "An empty migration file.", + "mailer" => "A mailer class (Amber::Mailer::Base).", + "job" => "A background job class (Amber::Jobs::Job).", + "schema" => "A schema definition (Amber::Schema::Definition).", + "channel" => "A WebSocket channel (Amber::WebSockets::Channel).", + "api" => "An API-only controller with its model.", + "auth" => "An authentication system.", + } + + # Options that apply to specific generators only. + GENERATOR_OPTIONS = { + "job" => ["--queue=QUEUE", "--max-retries=N"], + "mailer" => ["--actions=a,b"], + "controller" => ["positional action names, e.g. index show create"], + "channel" => ["--topics=a,b"], + } + + def name : String + "list_generators" + end + + def title : String? + "List available generators" + end + + def description : String + <<-TEXT + List every generator `generate_component` can run, with what each produces, + the arguments it accepts, and whether it is a stable or preview surface in + this beta. Read-only; takes no arguments and touches no files. + TEXT + end + + def input_schema : ::MCProtocol::ToolInputSchema + ::MCProtocol::ToolInputSchema.new( + properties: JSON.parse("{}"), + required: [] of String + ) + end + + def call(arguments : Hash(String, JSON::Any)) : AmberCLI::MCP::ToolOutcome + generators = AmberCLI::Commands::GenerateCommand::VALID_TYPES.map do |type| + { + "type" => type, + "summary" => GENERATOR_SUMMARIES[type]? || "", + "preview" => AmberCLI::Commands::GenerateCommand::PREVIEW_TYPES.includes?(type), + "options" => GENERATOR_OPTIONS[type]? || [] of String, + "fieldFormat" => "name:type[:required]", + } + end + + payload = { + "generators" => generators, + "fieldTypes" => AmberCLI::Commands::GenerateCommand::FIELD_TYPE_MAP.keys.to_a, + } + + AmberCLI::MCP::ToolOutcome.data("#{generators.size} generators available", payload) + end + end +end diff --git a/src/amber_cli/mcp/tools/list_routes_tool.cr b/src/amber_cli/mcp/tools/list_routes_tool.cr new file mode 100644 index 0000000..e67cd9c --- /dev/null +++ b/src/amber_cli/mcp/tools/list_routes_tool.cr @@ -0,0 +1,79 @@ +# :nodoc: +require "json" +require "../base_tool" +require "../application_inspector" +require "../../commands/routes" + +module AmberCLI::MCP::Tools + # Lists the routes declared in an application's `config/routes.cr`. + class ListRoutesTool < AmberCLI::MCP::BaseTool + def name : String + "list_routes" + end + + def title : String? + "List application routes" + end + + def description : String + <<-TEXT + List every route declared in an Amber application's config/routes.cr, with + the HTTP verb, URI pattern, controller, action, pipeline and scope for each. + Read-only; parses the routes file statically and never boots the + application. Requires an absolute `path` to the application root. + TEXT + end + + def input_schema : ::MCProtocol::ToolInputSchema + ::MCProtocol::ToolInputSchema.new( + properties: JSON.parse({ + "path" => { + "type" => "string", + "description" => "Absolute path to the application root directory.", + }, + "filter" => { + "type" => "string", + "description" => "Optional case-insensitive substring; only routes whose URI pattern or controller contains it are returned.", + }, + }.to_json), + required: ["path"] + ) + end + + def call(arguments : Hash(String, JSON::Any)) : AmberCLI::MCP::ToolOutcome + path = string_argument(arguments, "path") + return AmberCLI::MCP::ToolOutcome.failure("`path` is required and must be a string.") unless path + unless absolute_path?(path) + return AmberCLI::MCP::ToolOutcome.failure("`path` must be absolute, got #{path.inspect}.") + end + + inspector = AmberCLI::MCP::ApplicationInspector.new(path) + unless File.exists?(inspector.routes_path) + return AmberCLI::MCP::ToolOutcome.failure( + "No routes file at #{inspector.routes_path}. Is #{inspector.path} an Amber application?" + ) + end + + routes = collect_routes(inspector.routes_path) + if filter = string_argument(arguments, "filter") + needle = filter.downcase + routes = routes.select do |route| + route["URI Pattern"].downcase.includes?(needle) || route["Controller"].downcase.includes?(needle) + end + end + + payload = routes.map { |route| route.transform_keys(&.downcase.gsub(' ', '_')) } + AmberCLI::MCP::ToolOutcome.data("#{payload.size} route(s) in #{inspector.path}", payload) + rescue ex : File::Error + AmberCLI::MCP::ToolOutcome.failure("Could not read routes: #{ex.message}") + end + + # Reuses `RoutesCommand`'s parser rather than duplicating its regexes, driving + # it with an explicit file path so nothing depends on the working directory. + private def collect_routes(routes_file : String) : Array(Hash(String, String)) + command = AmberCLI::Commands::RoutesCommand.new("routes") + command.parse_routes(routes_file) + command.routes + end + end +end diff --git a/src/amber_cli/mcp/tools/project_info_tool.cr b/src/amber_cli/mcp/tools/project_info_tool.cr new file mode 100644 index 0000000..a44a197 --- /dev/null +++ b/src/amber_cli/mcp/tools/project_info_tool.cr @@ -0,0 +1,61 @@ +# :nodoc: +require "json" +require "../base_tool" +require "../application_inspector" + +module AmberCLI::MCP::Tools + # Reports whether a directory holds an Amber application, and what kind. + class ProjectInfoTool < AmberCLI::MCP::BaseTool + def name : String + "project_info" + end + + def title : String? + "Inspect an Amber project" + end + + def description : String + <<-TEXT + Inspect a directory and report whether it is an Amber application, which + Amber version it depends on, its database adapter, template language, and + the key files present. Read-only; writes nothing. Requires an absolute + `path` to the application root — this server never infers a project from its + own working directory. + TEXT + end + + def input_schema : ::MCProtocol::ToolInputSchema + ::MCProtocol::ToolInputSchema.new( + properties: JSON.parse({ + "path" => { + "type" => "string", + "description" => "Absolute path to the application root directory.", + }, + }.to_json), + required: ["path"] + ) + end + + def call(arguments : Hash(String, JSON::Any)) : AmberCLI::MCP::ToolOutcome + path = string_argument(arguments, "path") + return AmberCLI::MCP::ToolOutcome.failure("`path` is required and must be a string.") unless path + unless absolute_path?(path) + return AmberCLI::MCP::ToolOutcome.failure("`path` must be absolute, got #{path.inspect}.") + end + + inspector = AmberCLI::MCP::ApplicationInspector.new(path) + unless inspector.exists? + return AmberCLI::MCP::ToolOutcome.failure("No such directory: #{inspector.path}") + end + + summary = + if inspector.amber_application? + "#{inspector.application_name || File.basename(inspector.path)} is an Amber application." + else + "#{inspector.path} exists but does not look like an Amber application." + end + + AmberCLI::MCP::ToolOutcome.data(summary, inspector.to_json_payload) + end + end +end diff --git a/src/amber_cli/mcp/tools/read_doc_tool.cr b/src/amber_cli/mcp/tools/read_doc_tool.cr new file mode 100644 index 0000000..07875b0 --- /dev/null +++ b/src/amber_cli/mcp/tools/read_doc_tool.cr @@ -0,0 +1,58 @@ +# :nodoc: +require "json" +require "../base_tool" +require "../document_index" + +module AmberCLI::MCP::Tools + # Reads one bundled documentation file in full. + class ReadDocTool < AmberCLI::MCP::BaseTool + def name : String + "read_doc" + end + + def title : String? + "Read an Amber documentation file" + end + + def description : String + <<-TEXT + Read one documentation file bundled into this Amber CLI binary, by id. + Read-only. Call with no `id` to list every available document. Ids are + repository-relative paths such as "README.md" or "docs/GENERATOR_SUPPORT.md"; + `search_docs` returns the id of every match. + TEXT + end + + def input_schema : ::MCProtocol::ToolInputSchema + ::MCProtocol::ToolInputSchema.new( + properties: JSON.parse({ + "id" => { + "type" => "string", + "enum" => AmberCLI::MCP::DocumentIndex.ids, + "description" => "Document id to read. Omit to list the available documents.", + }, + }.to_json), + required: [] of String + ) + end + + def call(arguments : Hash(String, JSON::Any)) : AmberCLI::MCP::ToolOutcome + id = string_argument(arguments, "id") + return catalog_outcome unless id + + body = AmberCLI::MCP::DocumentIndex.document?(id) + unless body + return AmberCLI::MCP::ToolOutcome.failure( + "Unknown document #{id.inspect}. Available: #{AmberCLI::MCP::DocumentIndex.ids.join(", ")}" + ) + end + + AmberCLI::MCP::ToolOutcome.new(body, JSON.parse({"id" => id, "content" => body}.to_json)) + end + + private def catalog_outcome : AmberCLI::MCP::ToolOutcome + catalog = AmberCLI::MCP::DocumentIndex.catalog + AmberCLI::MCP::ToolOutcome.data("#{catalog.size} documents available", catalog) + end + end +end diff --git a/src/amber_cli/mcp/tools/search_docs_tool.cr b/src/amber_cli/mcp/tools/search_docs_tool.cr new file mode 100644 index 0000000..e5f83d3 --- /dev/null +++ b/src/amber_cli/mcp/tools/search_docs_tool.cr @@ -0,0 +1,75 @@ +# :nodoc: +require "json" +require "../base_tool" +require "../document_index" + +module AmberCLI::MCP::Tools + # Full-text search across the CLI's bundled documentation. + class SearchDocsTool < AmberCLI::MCP::BaseTool + def name : String + "search_docs" + end + + def title : String? + "Search Amber documentation" + end + + def description : String + <<-TEXT + Search the Amber CLI documentation bundled into this binary for a + case-insensitive substring, returning each match with its document id, line + number and surrounding context. Read-only. Pass a matching document id to + `read_doc` to read the full text. + TEXT + end + + def input_schema : ::MCProtocol::ToolInputSchema + ::MCProtocol::ToolInputSchema.new( + properties: JSON.parse({ + "query" => { + "type" => "string", + "description" => "Case-insensitive substring to search for.", + }, + "limit" => { + "type" => "integer", + "minimum" => 1, + "maximum" => AmberCLI::MCP::DocumentIndex::DEFAULT_MATCH_LIMIT, + "description" => "Maximum number of matches to return.", + }, + }.to_json), + required: ["query"] + ) + end + + def call(arguments : Hash(String, JSON::Any)) : AmberCLI::MCP::ToolOutcome + query = string_argument(arguments, "query") + return AmberCLI::MCP::ToolOutcome.failure("`query` is required and must be a string.") unless query + return AmberCLI::MCP::ToolOutcome.failure("`query` must not be blank.") if query.blank? + + limit = arguments["limit"]?.try(&.as_i?) || AmberCLI::MCP::DocumentIndex::DEFAULT_MATCH_LIMIT + limit = limit.clamp(1, AmberCLI::MCP::DocumentIndex::DEFAULT_MATCH_LIMIT) + + matches = AmberCLI::MCP::DocumentIndex.search(query, limit) + if matches.empty? + return AmberCLI::MCP::ToolOutcome.data( + "No matches for #{query.inspect}.", + {"query" => query, "matches" => [] of String, "documents" => AmberCLI::MCP::DocumentIndex.ids} + ) + end + + payload = { + "query" => query, + "matches" => matches.map do |match| + { + "document" => match.document, + "lineNumber" => match.line_number, + "line" => match.line, + "context" => match.context, + } + end, + } + + AmberCLI::MCP::ToolOutcome.data("#{matches.size} match(es) for #{query.inspect}", payload) + end + end +end