Skip to content

Commit 664521f

Browse files
hyperpolymathclaude
andcommitted
feat(elixir): Phase 1 REST skeleton — closes the boj_health lie
Cowboy+Plug listener on port 7700 serving the five endpoints that mcp-bridge/lib/api-clients.js actually calls. Loads all 99 cartridge.json files into an ETS-backed catalog at boot. Working: GET /health — {"status":"ok","mode":"skeleton","cartridges_loaded":99} GET /menu — per-cartridge name/domain/tier/description GET /cartridges — name list GET /cartridge/:name — full cartridge.json, 404 if unknown POST /cartridge/:name/invoke — 501 invocation-not-yet-wired (Phase 2) 5/5 tests pass (mix test). 99 cartridges parse cleanly. This is "start", not "finish": dispatch to the Zig FFI, http-capability -gateway attachment, and mTLS are Phase 2-4 per elixir/README.adoc. Addresses DOGFOOD-LOG.adoc 2026-04-18 "REST API unreachable" finding. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3dfa0ba commit 664521f

10 files changed

Lines changed: 409 additions & 0 deletions

File tree

elixir/.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
_build/
3+
deps/
4+
*.ez
5+
erl_crash.dump
6+
.elixir_ls/

elixir/README.adoc

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
= BoJ Server — Elixir REST skeleton
4+
:orientation: shallow
5+
6+
== Purpose
7+
8+
Serves the five HTTP endpoints `mcp-bridge/lib/api-clients.js` calls, on
9+
port **7700** by default. This is a **skeleton**, not the finished REST
10+
server:
11+
12+
* `GET /health` — returns `{"status": "ok", "mode": "skeleton", ...}` with
13+
live cartridge-load count.
14+
* `GET /menu` — summarises each cartridge (name / domain / tier /
15+
description).
16+
* `GET /cartridges` — cartridge-name list.
17+
* `GET /cartridge/:name` — full `cartridge.json` for that cartridge, or
18+
404 if unknown.
19+
* `POST /cartridge/:name/invoke` — **returns 501** with
20+
`{"error": "invocation-not-yet-wired"}`. Dispatch to the Zig FFI is
21+
Phase 2.
22+
23+
== Relationship to the stdio bridge
24+
25+
The Node.js stdio bridge at `mcp-bridge/` is what Claude Code and Glama
26+
talk to. When the REST server behind it was missing, `boj_health` and
27+
`boj_research` returned misleading errors pointing at a nonexistent
28+
listener. This skeleton closes that gap:
29+
30+
[cols="1,3"]
31+
|===
32+
| Endpoint | Bridge tool
33+
34+
| `/health` | `boj_health`
35+
| `/menu` | `boj_menu`
36+
| `/cartridges` | `boj_cartridges`
37+
| `/cartridge/:name` | `boj_cartridge_info`
38+
| `/cartridge/:name/invoke` | `boj_cartridge_invoke` (501 until Phase 2)
39+
|===
40+
41+
== Build
42+
43+
[source,bash]
44+
----
45+
cd elixir
46+
mix deps.get
47+
mix compile
48+
mix test # five endpoint tests
49+
----
50+
51+
== Run
52+
53+
[source,bash]
54+
----
55+
cd elixir
56+
mix run --no-halt
57+
# listener binds http://localhost:7700
58+
----
59+
60+
Configure via env vars:
61+
62+
* `BOJ_PORT` — listener port (default `7700`).
63+
* `BOJ_CARTRIDGES_ROOT` — where to scan for `cartridge.json` (default:
64+
`../cartridges` relative to the Elixir project).
65+
66+
== Design notes
67+
68+
* **Cowboy + Plug** — matches the http-capability-gateway decision
69+
(`docs/decisions/0004-adopt-http-capability-gateway.md`), so this
70+
listener is a natural attachment point for that gateway's verb-governance
71+
and trust-level plugs in a later phase.
72+
* **ETS-backed catalog, read-only.** The cartridge catalog is loaded once
73+
at boot and never mutated. Re-scanning requires a restart. This matches
74+
the current mount lifecycle (`cartridge.json` changes are already
75+
restart-gated).
76+
* **Best-effort load.** A malformed `cartridge.json` is logged and
77+
skipped; the listener still comes up with whatever parsed cleanly.
78+
* **Skeleton only.** Invocation routing, federation, auth, rate-limiting,
79+
TLS — all out of scope for this file. Those land with the Elixir port
80+
phases declared in the dogfood log.
81+
82+
== Phase plan
83+
84+
. **Phase 1 — this.** REST listener serves cartridge metadata. `boj_health`
85+
stops lying. `boj_research` still returns 501 but with a structured,
86+
explainable error. **DONE 2026-04-18.**
87+
. **Phase 2.** Wire `POST /cartridge/:name/invoke` into the Zig FFI via
88+
the loader (`ffi/zig/src/loader.zig`). `boj_cartridge_invoke` becomes
89+
live.
90+
. **Phase 3.** Attach http-capability-gateway per ADR-0004 at tier 2.
91+
. **Phase 4.** mTLS / SDP (see `Trustfile.a2ml` `[SDP_RULES]`).

elixir/config/config.exs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
import Config
4+
5+
config :boj_rest,
6+
port: String.to_integer(System.get_env("BOJ_PORT") || "7700"),
7+
cartridges_root:
8+
System.get_env("BOJ_CARTRIDGES_ROOT") ||
9+
Path.expand("../../cartridges", __DIR__)

elixir/lib/boj_rest/application.ex

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
defmodule BojRest.Application do
4+
@moduledoc """
5+
OTP application for the BoJ REST skeleton.
6+
7+
Boots the cartridge catalog (reads every `cartridges/*/cartridge.json`
8+
into an ETS-backed cache) and the Cowboy HTTP listener on the configured
9+
port (default 7700).
10+
"""
11+
use Application
12+
13+
@impl true
14+
def start(_type, _args) do
15+
port = Application.get_env(:boj_rest, :port, 7700)
16+
cartridges_root = Application.get_env(:boj_rest, :cartridges_root)
17+
18+
children = [
19+
{BojRest.Catalog, cartridges_root: cartridges_root},
20+
{Plug.Cowboy, scheme: :http, plug: BojRest.Router, options: [port: port]}
21+
]
22+
23+
opts = [strategy: :one_for_one, name: BojRest.Supervisor]
24+
Supervisor.start_link(children, opts)
25+
end
26+
end

elixir/lib/boj_rest/catalog.ex

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
defmodule BojRest.Catalog do
4+
@moduledoc """
5+
Reads every `cartridge.json` under `cartridges/*/` once at boot and caches the
6+
parsed structs in ETS keyed by cartridge name. Read-only: no reload, no
7+
mutation. A cartridge fleet change requires a restart, which matches the
8+
current mount lifecycle.
9+
10+
`cartridge.json` missing or malformed is a log warning, not a crash — the
11+
catalog is best-effort.
12+
"""
13+
use GenServer
14+
require Logger
15+
16+
@table :boj_cartridge_catalog
17+
18+
def start_link(opts) do
19+
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
20+
end
21+
22+
def list, do: :ets.tab2list(@table) |> Enum.map(&elem(&1, 1))
23+
24+
def get(name) do
25+
case :ets.lookup(@table, name) do
26+
[{^name, cart}] -> {:ok, cart}
27+
[] -> :not_found
28+
end
29+
end
30+
31+
@impl true
32+
def init(opts) do
33+
:ets.new(@table, [:named_table, :protected, read_concurrency: true])
34+
root = Keyword.fetch!(opts, :cartridges_root)
35+
count = load_all(root)
36+
Logger.info("BoJ catalog: loaded #{count} cartridges from #{root}")
37+
{:ok, %{root: root, count: count}}
38+
end
39+
40+
defp load_all(root) do
41+
root
42+
|> Path.join("*/cartridge.json")
43+
|> Path.wildcard()
44+
|> Enum.reduce(0, fn path, acc ->
45+
case load_one(path) do
46+
{:ok, cart} ->
47+
:ets.insert(@table, {Map.get(cart, "name"), cart})
48+
acc + 1
49+
50+
{:error, reason} ->
51+
Logger.warning("skip #{path}: #{inspect(reason)}")
52+
acc
53+
end
54+
end)
55+
end
56+
57+
defp load_one(path) do
58+
with {:ok, bytes} <- File.read(path),
59+
{:ok, json} <- Jason.decode(bytes),
60+
name when is_binary(name) <- Map.get(json, "name") do
61+
{:ok, json}
62+
else
63+
nil -> {:error, :missing_name}
64+
err -> err
65+
end
66+
end
67+
end

elixir/lib/boj_rest/router.ex

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
defmodule BojRest.Router do
4+
@moduledoc """
5+
Plug router for the five endpoints `mcp-bridge/lib/api-clients.js` hits:
6+
7+
GET /health
8+
GET /menu
9+
GET /cartridges
10+
GET /cartridge/:name
11+
POST /cartridge/:name/invoke
12+
13+
Invocation is a placeholder: this skeleton does not dispatch to the Zig
14+
FFI yet. It returns `{"error": "invocation-not-yet-wired"}` so the bridge
15+
gets a structured response instead of a connection refusal.
16+
"""
17+
use Plug.Router
18+
19+
plug :match
20+
plug Plug.Parsers, parsers: [:json], pass: ["application/json"], json_decoder: Jason
21+
plug :dispatch
22+
23+
@version Mix.Project.config()[:version]
24+
25+
get "/health" do
26+
body =
27+
%{
28+
status: "ok",
29+
mode: "skeleton",
30+
note: "BoJ REST skeleton (elixir/) — endpoints respond, invocation is a placeholder",
31+
version: @version,
32+
cartridges_loaded: length(BojRest.Catalog.list())
33+
}
34+
json(conn, 200, body)
35+
end
36+
37+
get "/menu" do
38+
carts =
39+
BojRest.Catalog.list()
40+
|> Enum.map(fn c ->
41+
%{
42+
name: Map.get(c, "name"),
43+
domain: Map.get(c, "domain"),
44+
tier: Map.get(c, "tier"),
45+
description: Map.get(c, "description")
46+
}
47+
end)
48+
json(conn, 200, %{cartridges: carts, count: length(carts)})
49+
end
50+
51+
get "/cartridges" do
52+
names = BojRest.Catalog.list() |> Enum.map(&Map.get(&1, "name"))
53+
json(conn, 200, %{cartridges: names, count: length(names)})
54+
end
55+
56+
get "/cartridge/:name" do
57+
case BojRest.Catalog.get(name) do
58+
{:ok, cart} -> json(conn, 200, cart)
59+
:not_found -> json(conn, 404, %{error: "unknown-cartridge", cartridge: name})
60+
end
61+
end
62+
63+
post "/cartridge/:name/invoke" do
64+
case BojRest.Catalog.get(name) do
65+
{:ok, _cart} ->
66+
json(conn, 501, %{
67+
error: "invocation-not-yet-wired",
68+
cartridge: name,
69+
message:
70+
"The REST skeleton can find this cartridge in the catalog but does not " <>
71+
"yet dispatch to the Zig FFI. Tracked under the Elixir-port Phase 2 task."
72+
})
73+
74+
:not_found ->
75+
json(conn, 404, %{error: "unknown-cartridge", cartridge: name})
76+
end
77+
end
78+
79+
match _ do
80+
json(conn, 404, %{error: "route-not-found", path: conn.request_path})
81+
end
82+
83+
defp json(conn, status, body) do
84+
conn
85+
|> Plug.Conn.put_resp_content_type("application/json")
86+
|> Plug.Conn.send_resp(status, Jason.encode!(body))
87+
end
88+
end

elixir/mix.exs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# SPDX-License-Identifier: PMPL-1.0-or-later
2+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
defmodule BojRest.MixProject do
4+
use Mix.Project
5+
6+
def project do
7+
[
8+
app: :boj_rest,
9+
version: "0.1.0",
10+
elixir: "~> 1.15",
11+
start_permanent: Mix.env() == :prod,
12+
deps: deps(),
13+
description: "BoJ Server REST — HTTP surface for mcp-bridge",
14+
package: package()
15+
]
16+
end
17+
18+
def application do
19+
[
20+
extra_applications: [:logger],
21+
mod: {BojRest.Application, []}
22+
]
23+
end
24+
25+
defp deps do
26+
[
27+
{:plug_cowboy, "~> 2.7"},
28+
{:jason, "~> 1.4"}
29+
]
30+
end
31+
32+
defp package do
33+
[
34+
licenses: ["MPL-2.0"],
35+
links: %{"GitHub" => "https://github.com/hyperpolymath/boj-server"}
36+
]
37+
end
38+
end

elixir/mix.lock

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
%{
2+
"cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"},
3+
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
4+
"cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"},
5+
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
6+
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
7+
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
8+
"plug_cowboy": {:hex, :plug_cowboy, "2.8.0", "07789e9c03539ee51bb14a07839cc95aa96999fd8846ebfd28c97f0b50c7b612", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9cbfaaf17463334ca31aed38ea7e08a68ee37cabc077b1e9be6d2fb68e0171d0"},
9+
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
10+
"ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
11+
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
12+
}

0 commit comments

Comments
 (0)