From 80baea641843827bae313def4d266ade713ec804 Mon Sep 17 00:00:00 2001 From: Keith Brings Date: Tue, 15 Sep 2026 03:30:49 +0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(mcp):=20doc-pointer/generate-batch=20?= =?UTF-8?q?=E2=80=94=20batch=20registration=20with=20verified=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New write tool accepts entries: [{name, type, location, description}]; name drives UUID5 derivation and is stored as the pointer function, type is stored as the pointer class. - location accepts absolute or root-relative paths; verified on disk, rejected outside the project root, relativized so Store.put places metadata in the correct submodule's .meta/pointers.yaml. - Per-entry isolation: good entries persist, failures return {index, name, error} without sinking the batch. - Generate.register/1 extracted as the shared mint+persist pipeline; Store.root/0 accessor added. - MCP server startup (loopback HTTP) now prints client registration one-liners for claude / codex / grok on stderr. - Tests: batch registration, submodule metadata placement, partial failure, path validation (56 passing). Co-Authored-By: Loom --- lib/doc_pointers/mcp.ex | 4 +- lib/doc_pointers/mcp/runtime.ex | 16 ++ lib/doc_pointers/mcp/tools/generate.ex | 40 +++-- lib/doc_pointers/mcp/tools/generate_batch.ex | 159 +++++++++++++++++ lib/doc_pointers/store.ex | 5 + lib/mix/tasks/doc_pointers.mcp.server.ex | 1 + .../mcp/tools/generate_batch_test.exs | 162 ++++++++++++++++++ 7 files changed, 376 insertions(+), 11 deletions(-) create mode 100644 lib/doc_pointers/mcp/tools/generate_batch.ex create mode 100644 test/doc_pointers/mcp/tools/generate_batch_test.exs diff --git a/lib/doc_pointers/mcp.ex b/lib/doc_pointers/mcp.ex index 07a3069..b539109 100644 --- a/lib/doc_pointers/mcp.ex +++ b/lib/doc_pointers/mcp.ex @@ -8,7 +8,8 @@ defmodule DocPointers.MCP do tokens from Egyptian, Meroitic, and Anatolian Unicode blocks. Default tools are read-only: doc-pointer/lookup and doc-pointer/list. - doc-pointer/generate and doc-pointer/update persist to .meta/pointers.yaml. + doc-pointer/generate, doc-pointer/generate-batch and doc-pointer/update + persist to .meta/pointers.yaml. They are listed when the server is started with --write (or DOC_POINTERS_MCP_WRITES=1); otherwise they require confirm=true (or a client confirmation prompt). @@ -18,6 +19,7 @@ defmodule DocPointers.MCP do tool(DocPointers.MCP.Tools.Lookup, category: "Pointers") tool(DocPointers.MCP.Tools.List, category: "Pointers") tool(DocPointers.MCP.Tools.Generate, category: "Pointers", hidden: true) + tool(DocPointers.MCP.Tools.GenerateBatch, category: "Pointers", hidden: true) tool(DocPointers.MCP.Tools.Update, category: "Pointers", hidden: true) @impl true diff --git a/lib/doc_pointers/mcp/runtime.ex b/lib/doc_pointers/mcp/runtime.ex index 2fa5104..8de9fdc 100644 --- a/lib/doc_pointers/mcp/runtime.ex +++ b/lib/doc_pointers/mcp/runtime.ex @@ -69,4 +69,20 @@ defmodule DocPointers.MCP.Runtime do :ok end + + @doc """ + Print client registration instructions (stderr — safe for all transports). + """ + def print_client_setup(port) do + url = "http://127.0.0.1:#{port}/mcp" + + IO.puts(:stderr, """ + + Add this MCP server to your client: + + claude: claude mcp add --transport http doc-pointers #{url} + codex: codex mcp add doc-pointers --url #{url} + grok: grok mcp add doc-pointers --url #{url} + """) + end end diff --git a/lib/doc_pointers/mcp/tools/generate.ex b/lib/doc_pointers/mcp/tools/generate.ex index 629075b..d43f8df 100644 --- a/lib/doc_pointers/mcp/tools/generate.ex +++ b/lib/doc_pointers/mcp/tools/generate.ex @@ -50,17 +50,37 @@ defmodule DocPointers.MCP.Tools.Generate do args[:name_override] || DocPointers.UUID5.build_annotation_name(args.file_path, args.function_name) - case generate_with_collision_check(base_name, args[:salt], 0) do + register(%{ + base_name: base_name, + file_path: args.file_path, + class: args[:class], + function: args.function_name, + line: args[:line], + description: args.description, + salt: args[:salt] + }) + end + + @doc """ + Shared registration pipeline: mint a UUIDv5 + hieroglyph token for `base_name`, + persist the pointer, and return its metadata map. Used by doc-pointer/generate + and doc-pointer/generate-batch. + + Attrs: base_name (required), function (required), file_path, class, line, + description, salt. + """ + def register(attrs) do + case generate_with_collision_check(attrs.base_name, attrs[:salt], 0) do {:ok, uuid_string, token} -> pointer = DocPointers.Pointer.new(%{ uuid: uuid_string, token: token, - file_path: args.file_path, - class: args[:class], - function: args.function_name, - line: args[:line], - description: args.description + file_path: attrs[:file_path], + class: attrs[:class], + function: attrs.function, + line: attrs[:line], + description: attrs[:description] || "" }) DocPointers.Store.put(pointer) @@ -71,10 +91,10 @@ defmodule DocPointers.MCP.Tools.Generate do token: token, marker: DocPointers.Hieroglyph.marker(token), declaration: - DocPointers.Hieroglyph.declaration(token, args.function_name, args.description), - file_path: args.file_path, - function: args.function_name, - class: args[:class] + DocPointers.Hieroglyph.declaration(token, attrs.function, attrs[:description] || ""), + file_path: attrs[:file_path], + function: attrs.function, + class: attrs[:class] }} {:error, :max_attempts} -> diff --git a/lib/doc_pointers/mcp/tools/generate_batch.ex b/lib/doc_pointers/mcp/tools/generate_batch.ex new file mode 100644 index 0000000..1668719 --- /dev/null +++ b/lib/doc_pointers/mcp/tools/generate_batch.ex @@ -0,0 +1,159 @@ +defmodule DocPointers.MCP.Tools.GenerateBatch do + use Noizu.MCP.Server.Tool, + name: "doc-pointer/generate-batch", + description: """ + Register multiple doc-pointers in one call. Each entry provides + {name, type, location, description}; `name` drives UUID derivation and is + recorded as the pointer function, `type` (e.g. "function", "module") is + stored as the pointer class. + + `location` may be an absolute path or a path relative to the project root. + It is verified on disk and normalized before storage, so metadata lands in + the correct (submodule-aware) .meta/pointers.yaml. + + Returns one record per entry: {name, uuid, token, marker, location, status} + plus a `failed` list for entries that could not be registered. + """, + annotations: [destructive_hint: true] + + input do + field(:entries, {:array, :object}, required: true) do + field(:name, :string, + required: true, + description: + "Pointer name — drives UUID derivation and is recorded as the pointer function" + ) + + field(:type, :string, + description: "Entity type (e.g. function, module) — stored as the pointer class" + ) + + field(:location, :string, + required: true, + description: + "Absolute path or path relative to project root; verified on disk and normalized " <> + "so metadata lands in the correct (submodule-aware) .meta/pointers.yaml" + ) + + field(:description, :string, + description: "Human-readable description of the code location" + ) + end + + field(:confirm, :boolean, + description: "Required true unless the server was started with --write" + ) + end + + @impl true + def call(args, ctx) do + with :ok <- DocPointers.MCP.Writes.authorize(args, ctx) do + do_call(args) + end + end + + defp do_call(args) do + entries = args[:entries] || args["entries"] + + if is_list(entries) and entries != [] do + root = DocPointers.Store.root() + + {registered, failed} = + entries + |> Enum.with_index() + |> Enum.reduce({[], []}, fn {entry, idx}, {ok, bad} -> + case process_entry(entry, idx, root) do + {:ok, result} -> {[result | ok], bad} + {:error, reason} -> {ok, [reason | bad]} + end + end) + + {:ok, + %{ + registered: Enum.reverse(registered), + failed: Enum.reverse(failed), + total: length(entries), + count: length(registered) + }} + else + {:error, "entries must be a non-empty array of {name, type, location, description} objects"} + end + end + + defp process_entry(entry, idx, root) when is_map(entry) do + name = fetch(entry, :name) + location = fetch(entry, :location) + + cond do + blank?(name) -> + {:error, entry_error(idx, name, "missing required field: name")} + + blank?(location) -> + {:error, entry_error(idx, name, "missing required field: location")} + + true -> + register_entry(entry, idx, name, location, root) + end + end + + defp process_entry(_entry, idx, _root) do + {:error, entry_error(idx, nil, "entry must be an object")} + end + + defp register_entry(entry, idx, name, location, root) do + with {:ok, rel_path} <- normalize_location(location, root) do + case DocPointers.MCP.Tools.Generate.register(%{ + base_name: name, + file_path: rel_path, + class: fetch(entry, :type), + function: name, + description: fetch(entry, :description) + }) do + {:ok, result} -> + {:ok, + result + |> Map.merge(%{name: name, type: fetch(entry, :type), location: rel_path}) + |> Map.put(:status, :ok)} + + {:error, reason} -> + {:error, entry_error(idx, name, reason)} + end + else + {:error, reason} -> {:error, entry_error(idx, name, reason)} + end + end + + # Absolute paths must exist and live under the project root; they are + # relativized so Store.put/1 can place metadata in the owning + # submodule's .meta/pointers.yaml. Relative paths are verified against root. + defp normalize_location(location, root) when is_binary(location) do + path = Path.expand(location, root) + + if File.exists?(path) do + if Path.type(path) == :absolute do + case Path.relative_to(path, root) do + ^path -> {:error, "path is outside the project root (#{root}): #{location}"} + rel -> {:ok, rel} + end + else + {:ok, location} + end + else + {:error, "file not found: #{location}"} + end + end + + defp normalize_location(_location, _root) do + {:error, "location must be a string path"} + end + + defp fetch(entry, key) when is_map(entry) do + entry[key] || entry[Atom.to_string(key)] + end + + defp blank?(value), do: is_nil(value) or value == "" + + defp entry_error(idx, name, reason) do + %{index: idx, name: name, status: :error, error: reason} + end +end diff --git a/lib/doc_pointers/store.ex b/lib/doc_pointers/store.ex index 0371b40..1f90cdf 100644 --- a/lib/doc_pointers/store.ex +++ b/lib/doc_pointers/store.ex @@ -9,6 +9,7 @@ defmodule DocPointers.Store do end def set_root(root), do: GenServer.call(__MODULE__, {:set_root, root}) + def root, do: GenServer.call(__MODULE__, :root) def get(uuid), do: GenServer.call(__MODULE__, {:get, uuid}) def get_by_token(token), do: GenServer.call(__MODULE__, {:get_by_token, token}) def put(pointer), do: GenServer.call(__MODULE__, {:put, pointer}) @@ -55,6 +56,10 @@ defmodule DocPointers.Store do {:reply, :ok, state} end + def handle_call(:root, _from, state) do + {:reply, state.root, state} + end + def handle_call({:get, uuid}, _from, state) do {:reply, Map.get(state.pointers, uuid), state} end diff --git a/lib/mix/tasks/doc_pointers.mcp.server.ex b/lib/mix/tasks/doc_pointers.mcp.server.ex index 8cb6ec8..84750c4 100644 --- a/lib/mix/tasks/doc_pointers.mcp.server.ex +++ b/lib/mix/tasks/doc_pointers.mcp.server.ex @@ -33,6 +33,7 @@ defmodule Mix.Tasks.DocPointers.Mcp.Server do Mix.shell().info("doc-pointers MCP (loopback HTTP) → http://127.0.0.1:#{port}/mcp") Mix.shell().info("Prefer stdio for local clients: mix doc_pointers.mcp.stdio") + DocPointers.MCP.Runtime.print_client_setup(port) Process.sleep(:infinity) end end diff --git a/test/doc_pointers/mcp/tools/generate_batch_test.exs b/test/doc_pointers/mcp/tools/generate_batch_test.exs new file mode 100644 index 0000000..40b25e2 --- /dev/null +++ b/test/doc_pointers/mcp/tools/generate_batch_test.exs @@ -0,0 +1,162 @@ +defmodule DocPointers.MCP.Tools.GenerateBatchTest do + use ExUnit.Case + + alias DocPointers.MCP.Tools.GenerateBatch + alias DocPointers.Store + + setup do + tmp_dir = System.tmp_dir!() |> Path.join("doc_pointers_batch_test_#{:rand.uniform(100_000)}") + File.mkdir_p!(tmp_dir) + Store.set_root(tmp_dir) + Application.put_env(:doc_pointers, :mcp_writes, true) + + on_exit(fn -> + Application.delete_env(:doc_pointers, :mcp_writes) + File.rm_rf!(tmp_dir) + end) + + {:ok, root: tmp_dir} + end + + defp touch!(path) do + path |> Path.dirname() |> File.mkdir_p!() + File.touch!(path) + path + end + + describe "call/2" do + test "registers multiple entries and returns name/uuid pairs" do + touch!(Path.join(Store.root(), "lib/my_app/auth.ex")) + touch!(Path.join(Store.root(), "lib/my_app/repo.ex")) + + args = %{ + entries: [ + %{ + name: "MyMethod", + type: "function", + location: "lib/my_app/auth.ex", + description: "A test method for exercising doc-pointer meta data" + }, + %{ + name: "MyRepo", + type: "module", + location: "lib/my_app/repo.ex", + description: "The repo module" + } + ] + } + + {:ok, result} = GenerateBatch.call(args, nil) + + assert result.count == 2 + assert result.total == 2 + assert result.failed == [] + + [first, second] = result.registered + assert %{name: "MyMethod", uuid: uuid1, token: token1, marker: marker1} = first + assert %{name: "MyRepo", uuid: uuid2} = second + + assert String.length(uuid1) == 36 + assert String.length(token1) == 4 + assert marker1 == "⟦#{token1}⟧" + assert first.status == :ok + assert first.type == "function" + + # Persisted and retrievable + stored = Store.get(uuid1) + assert stored.function == "MyMethod" + assert stored.description == "A test method for exercising doc-pointer meta data" + assert Store.get(uuid2).function == "MyRepo" + end + + test "absolute paths are verified and relativized into submodule metadata" do + root = Store.root() + File.write!(Path.join(root, ".gitmodules"), "[submodule \"mysub\"]\n\tpath = mysub\n") + # Store snapshots .gitmodules at root-config time — rescan like a fresh server start + Store.set_root(root) + abs_path = touch!(Path.join(root, "mysub/lib/inner.ex")) + + args = %{ + entries: [ + %{ + name: "InnerFn", + type: "function", + location: abs_path, + description: "Lives inside a submodule" + } + ] + } + + {:ok, result} = GenerateBatch.call(args, nil) + + assert result.failed == [] + [entry] = result.registered + # Response location is project-root-relative... + assert entry.location == "mysub/lib/inner.ex" + + # ...while the store strips the submodule prefix and files the pointer + # into the submodule's own .meta/pointers.yaml + stored = Store.get(entry.uuid) + assert stored.file_path == "lib/inner.ex" + assert File.exists?(Path.join([root, "mysub", ".meta", "pointers.yaml"])) + refute File.exists?(Path.join([root, ".meta", "pointers.yaml"])) + end + + test "missing file fails that entry only" do + touch!(Path.join(Store.root(), "lib/exists.ex")) + + args = %{ + entries: [ + %{name: "Good", type: "function", location: "lib/exists.ex", description: "ok"}, + %{name: "Bad", type: "function", location: "lib/nope.ex", description: "missing"} + ] + } + + {:ok, result} = GenerateBatch.call(args, nil) + + assert result.count == 1 + assert result.total == 2 + assert [%{name: "Good"}] = result.registered + + assert [%{name: "Bad", status: :error, error: error}] = result.failed + assert error =~ "file not found" + end + + test "entry missing name fails validation" do + touch!(Path.join(Store.root(), "lib/x.ex")) + + args = %{ + entries: [%{type: "function", location: "lib/x.ex", description: "no name"}] + } + + {:ok, result} = GenerateBatch.call(args, nil) + assert result.count == 0 + assert [%{status: :error, error: error}] = result.failed + assert error =~ "missing required field: name" + end + + test "absolute path outside project root is rejected" do + outside = System.tmp_dir!() |> Path.join("doc_pointers_outside_#{:rand.uniform(100_000)}") + touch!(outside) + + on_exit(fn -> File.rm_rf!(outside) end) + + args = %{ + entries: [%{name: "Far", location: outside, description: "not under root"}] + } + + {:ok, result} = GenerateBatch.call(args, nil) + assert result.count == 0 + assert [%{error: error}] = result.failed + assert error =~ "outside the project root" + end + + test "rejects empty or missing entries" do + {:error, msg} = GenerateBatch.call(%{entries: []}, nil) + assert msg =~ "non-empty array" + + {:error, msg} = GenerateBatch.call(%{}, nil) + assert msg =~ "non-empty array" + end + end +end From 62ce90a1312ada3187d80f3fd6cef760af5fd2b5 Mon Sep 17 00:00:00 2001 From: Keith Brings Date: Tue, 15 Sep 2026 03:43:31 +0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(mcp):=20client-setup=20banner=20?= =?UTF-8?q?=E2=80=94=20serve=20at=20root=20path,=20not=20/mcp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The StreamableHTTP plug routes MCP at / (POST /mcp → 404); registration one-liners and the startup line now advertise http://127.0.0.1:. Co-Authored-By: Loom --- lib/doc_pointers/mcp/runtime.ex | 2 +- lib/mix/tasks/doc_pointers.mcp.server.ex | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/doc_pointers/mcp/runtime.ex b/lib/doc_pointers/mcp/runtime.ex index 8de9fdc..6c95b5b 100644 --- a/lib/doc_pointers/mcp/runtime.ex +++ b/lib/doc_pointers/mcp/runtime.ex @@ -74,7 +74,7 @@ defmodule DocPointers.MCP.Runtime do Print client registration instructions (stderr — safe for all transports). """ def print_client_setup(port) do - url = "http://127.0.0.1:#{port}/mcp" + url = "http://127.0.0.1:#{port}" IO.puts(:stderr, """ diff --git a/lib/mix/tasks/doc_pointers.mcp.server.ex b/lib/mix/tasks/doc_pointers.mcp.server.ex index 84750c4..931e094 100644 --- a/lib/mix/tasks/doc_pointers.mcp.server.ex +++ b/lib/mix/tasks/doc_pointers.mcp.server.ex @@ -31,7 +31,7 @@ defmodule Mix.Tasks.DocPointers.Mcp.Server do port = DocPointers.MCP.Runtime.port(opts) DocPointers.MCP.Runtime.start_http!(port) - Mix.shell().info("doc-pointers MCP (loopback HTTP) → http://127.0.0.1:#{port}/mcp") + Mix.shell().info("doc-pointers MCP (loopback HTTP) → http://127.0.0.1:#{port}") Mix.shell().info("Prefer stdio for local clients: mix doc_pointers.mcp.stdio") DocPointers.MCP.Runtime.print_client_setup(port) Process.sleep(:infinity)