Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion lib/doc_pointers/mcp.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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
Expand Down
16 changes: 16 additions & 0 deletions lib/doc_pointers/mcp/runtime.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

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
40 changes: 30 additions & 10 deletions lib/doc_pointers/mcp/tools/generate.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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} ->
Expand Down
159 changes: 159 additions & 0 deletions lib/doc_pointers/mcp/tools/generate_batch.ex
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions lib/doc_pointers/store.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion lib/mix/tasks/doc_pointers.mcp.server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ 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)
end
end
Loading