Skip to content

Commit bac8da5

Browse files
Merge pull request #45 from diffo-dev/44-mcp-interface
44 mcp interface
2 parents 9539ef7 + 3fb0655 commit bac8da5

8 files changed

Lines changed: 430 additions & 4 deletions

File tree

.formatter.exs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@
33
# SPDX-License-Identifier: MIT
44

55
# Used by "mix format"
6-
locals_without_parens = []
6+
locals_without_parens = [
7+
tool: 3,
8+
tool: 4
9+
]
710

811
[
912
plugins: [Spark.Formatter],
1013
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
1114
import_deps: [
1215
:diffo,
1316
:ash,
17+
:ash_ai,
1418
:ash_state_machine,
1519
:ash_neo4j,
1620
:ash_jason,

AGENTS.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<!--
2+
SPDX-FileCopyrightText: 2025 diffo_example contributors <https://github.com/diffo-dev/diffo_example/graphs.contributors>
3+
4+
SPDX-License-Identifier: MIT
5+
-->
6+
7+
# Agents working in this repo
8+
9+
Notes for AI assistants (Claude Code, Cursor, Continue, etc.) and humans pairing with them.
10+
11+
## Keep `tools do` aligned with `define`d actions
12+
13+
Each Ash domain in this repo (`DiffoExample.Access`, `DiffoExample.Nbn`) declares two parallel lists:
14+
15+
1. **`resources do ... resource X do define :name, action: :foo end end`** — the code-interface surface, generating `MyDomain.name/...` functions for Elixir callers.
16+
2. **`tools do tool :name, X, :foo end`** — the MCP surface, exposing the same actions to AI agents via [`ash_ai`](https://hexdocs.pm/ash_ai).
17+
18+
**When you add a new action to a resource, add it to BOTH places.** Forgetting to add the `tool` entry leaves the action invisible to MCP clients — the test suite won't catch it, the code compiles, the action works for Elixir callers, and the AI silently can't reach it.
19+
20+
The convention is:
21+
22+
- One `tool` entry per `define`-d action.
23+
- Tool name matches the code-interface name where possible (e.g. `define :build_cable``tool :build_cable, Cable, :build`). Disambiguate with a suffix where two resources have actions of the same name (e.g. `tool :assign_port_on_card` and `tool :assign_port_on_ntd`).
24+
- Read actions exposed as their natural read names (`tool :get_cable_by_id, Cable, :read`).
25+
26+
## Why the discipline
27+
28+
Tool-via-existing-action is the WWZD-shaped pattern: authorisation, validation, after-action choreography, multi-tenancy, polymorphism — all inherited from the action without writing AI-specific logic. The AI gets the same access an RSP-actor consumer has, no more and no less. That property only holds if every action the AI should be able to reach is declared as a tool.
29+
30+
## When NOT to add a tool
31+
32+
- **Internal-only actions** that no consumer (Elixir, HTTP, MCP) should call directly. These typically don't have a `define` either.
33+
- **Provider primitives** (`Diffo.Provider.create_party`, `Diffo.Provider.create_place`) — deliberately not exposed via Access/Nbn MCP. When Access/Nbn grow their own party/place relationship-management actions, expose those instead, and let authorisation gate what each actor can do.
34+
- **Duplicate `define`s wrapping the same action** — e.g. `define :get_rsp_by_epid, action: :read, get_by: :id` and `define :get_rsp_by_short_name, action: :read, get_by: :short_name` both wrap the `:read` action with different filters. One tool (`tool :get_rsp_by_epid, Rsp, :read`) covers both — the AI can supply the filter shape it needs via tool arguments. The code-interface defines are an Elixir convenience; the tool exposes the underlying action.
35+
36+
## Where this matters most
37+
38+
If you're modifying:
39+
40+
- A `*.ex` file under `lib/access/resources/`, `lib/access/services/`, or `lib/nbn/resources/` that adds/renames a `define` in its domain — also touch `lib/access/access.ex` or `lib/nbn/nbn.ex` and update the `tools do` block.
41+
- The `tools do` block itself — make sure the resource module is aliased at the top of the domain file.
42+
43+
## Quick check
44+
45+
After changes, count alignment:
46+
47+
```bash
48+
# tools declared
49+
grep -c "tool :" lib/access/access.ex lib/nbn/nbn.ex
50+
51+
# actions code-interface-defined
52+
grep -c "define :" lib/access/access.ex lib/nbn/nbn.ex
53+
```
54+
55+
The two should match (modulo intentional exclusions).
56+
57+
## Before you commit
58+
59+
Two checks before any commit, every commit:
60+
61+
```bash
62+
mix format # auto-format every changed file to project style
63+
reuse lint # ensure every file has SPDX-FileCopyrightText + SPDX-License-Identifier
64+
```
65+
66+
New `.ex` / `.exs` files start with a comment header matching the existing
67+
files (see `lib/diffo_example/util.ex` for the canonical form). New markdown
68+
files use an HTML-comment variant (see `README.md`). `reuse lint` will tell
69+
you which files are missing copyright/license info; if you've created a
70+
new file and haven't added the header, this is the place to catch it.
71+
72+
Forgetting either is the easiest way to introduce CI noise the reviewer
73+
has to clean up. Save them both the time.

documentation/how_to/setup_mcp.md

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
<!--
2+
SPDX-FileCopyrightText: 2025 diffo_example contributors <https://github.com/diffo-dev/diffo_example/graphs.contributors>
3+
4+
SPDX-License-Identifier: MIT
5+
-->
6+
7+
# Setting up the diffo_example MCP server
8+
9+
This how-to walks through running the diffo_example MCP server locally and
10+
wiring AI clients (Claude Code, Claude Desktop, Cursor, custom) to it. The MCP
11+
surface exposes every action declared in the Access and Nbn domains' `tools do`
12+
blocks as a callable tool — 60 tools across the two domains as of writing.
13+
14+
See [issue #44](https://github.com/diffo-dev/diffo_example/issues/44) for the
15+
design context, and Zach's
16+
[Ash AI blog post](https://alembic.com.au/blog/ash-ai-comprehensive-llm-toolbox-for-ash-framework)
17+
for the framing.
18+
19+
## Prerequisites
20+
21+
- Elixir / Erlang installed (per `mix.exs` — currently `~> 1.18`).
22+
- Neo4j running locally with the credentials configured in `config/dev.exs`.
23+
- Dependencies fetched: `mix deps.get`.
24+
- Initial RSP data seeded on first start (handled automatically by
25+
`DiffoExample.Nbn.Initializer` when the app starts in dev).
26+
27+
## Starting the server
28+
29+
In a terminal in the project root:
30+
31+
```bash
32+
MIX_ENV=dev mix run --no-halt
33+
```
34+
35+
This boots the supervision tree, including `Plug.Cowboy` on port 4000. The MCP
36+
server is forwarded from `DiffoExample.Nbn.Router` at the path `/mcp`. The same
37+
Cowboy listener also serves the JSON:API routes and the `/catalog` endpoint.
38+
39+
You'll see Neo4j connection logs, then the listener-bound message. Leave it
40+
running.
41+
42+
## Verifying MCP is up
43+
44+
In a second terminal, send the three canonical MCP requests with `curl`.
45+
46+
### `initialize`
47+
48+
```bash
49+
curl -sS -X POST -H "Content-Type: application/json" \
50+
-d '{
51+
"jsonrpc":"2.0",
52+
"method":"initialize",
53+
"id":1,
54+
"params":{
55+
"protocolVersion":"2024-11-05",
56+
"capabilities":{},
57+
"clientInfo":{"name":"curl","version":"0"}
58+
}
59+
}' \
60+
http://localhost:4000/mcp
61+
```
62+
63+
Expected response shape:
64+
65+
```json
66+
{
67+
"id": 1,
68+
"jsonrpc": "2.0",
69+
"result": {
70+
"capabilities": {"tools": {"listChanged": false}},
71+
"protocolVersion": "2024-11-05",
72+
"serverInfo": {"name": "MCP Server", "version": "0.2.1"}
73+
}
74+
}
75+
```
76+
77+
### `tools/list`
78+
79+
```bash
80+
curl -sS -X POST -H "Content-Type: application/json" \
81+
-d '{"jsonrpc":"2.0","method":"tools/list","id":2}' \
82+
http://localhost:4000/mcp
83+
```
84+
85+
Returns the full set of tools. Each tool entry includes `name`, `description`,
86+
and `inputSchema` (JSON Schema generated from the underlying Ash action's
87+
arguments). To count and peek at the first few names:
88+
89+
```bash
90+
curl -sS -X POST -H "Content-Type: application/json" \
91+
-d '{"jsonrpc":"2.0","method":"tools/list","id":2}' \
92+
http://localhost:4000/mcp |
93+
python3 -c '
94+
import json, sys
95+
d = json.load(sys.stdin)
96+
ts = d.get("result", {}).get("tools", [])
97+
print("count:", len(ts))
98+
for t in ts[:8]:
99+
print("-", t["name"])
100+
print("..." if len(ts) > 8 else "")'
101+
```
102+
103+
### `tools/call`
104+
105+
Try `list_rsps` — no-arg, returns the seeded RSPs:
106+
107+
```bash
108+
curl -sS -X POST -H "Content-Type: application/json" \
109+
-d '{
110+
"jsonrpc":"2.0",
111+
"method":"tools/call",
112+
"id":3,
113+
"params":{"name":"list_rsps","arguments":{}}
114+
}' \
115+
http://localhost:4000/mcp
116+
```
117+
118+
Should return a `result.content[0].text` containing the JSON-encoded list of
119+
six RSPs (Wedge-tail, Quokka, Ibis, Taipan, Echidna, Dugong).
120+
121+
## Wiring Claude Code
122+
123+
In any directory, with the server running:
124+
125+
```bash
126+
claude mcp add diffo --transport http http://localhost:4000/mcp
127+
```
128+
129+
This adds an entry to your global `~/.claude.json`. For a project-scoped
130+
config (writes to a `.mcp.json` next to the cwd):
131+
132+
```bash
133+
claude mcp add diffo --transport http -s project http://localhost:4000/mcp
134+
```
135+
136+
After adding, any Claude Code session can call the tools. Try prompts like:
137+
138+
- "list the RSPs"
139+
- "qualify a DSL service for a customer with this location and these parties"
140+
- "build a cable, define it with 60 pairs as copper, then auto-assign a pair to
141+
the qualified service"
142+
- "show me the path with id X and its assigned ports and cables"
143+
144+
Claude will discover the right tools via `tools/list`, call them with the
145+
arguments inferred from the prompt, and explain the results.
146+
147+
## Wiring Claude Desktop
148+
149+
Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS)
150+
or the equivalent on your OS:
151+
152+
```json
153+
{
154+
"mcpServers": {
155+
"diffo": {
156+
"type": "http",
157+
"url": "http://localhost:4000/mcp"
158+
}
159+
}
160+
}
161+
```
162+
163+
Restart Claude Desktop. The tools appear under the hammer icon in the prompt
164+
input area. Hover any tool to see its description and schema.
165+
166+
## Wiring Cursor / Continue / other MCP clients
167+
168+
Any MCP-aware editor or assistant follows the same shape. Point them at
169+
`http://localhost:4000/mcp` with HTTP transport. Refer to the client's MCP
170+
configuration docs for the exact file/UI path.
171+
172+
## What tools are available
173+
174+
The full list is discoverable via `tools/list` (see above). At a glance:
175+
176+
**Access (~23 tools)**
177+
- DslAccess: read, qualify, qualify_result, design_result
178+
- Shelf: read, build, define, relate, assign_slot
179+
- Card: read, build, define, relate, assign_port (`:assign_port_on_card`)
180+
- Cable: read, build, define, relate, assign_pair
181+
- Path: read, build, define, relate
182+
183+
**Nbn (~37 tools)**
184+
- NbnEthernet, Uni, Avc, Nni: read, build, define, relate (each)
185+
- Ntd: read, build, define, assign_port (`:assign_port_on_ntd`), relate
186+
- Cvc: read, build, define, assign_cvlan, relate
187+
- NniGroup: read, build, define, assign_svlan, relate
188+
- Rsp: inventory (`:list_rsps`), read, build, activate, suspend, deactivate
189+
190+
The tool name in MCP matches the `tools do` declaration in
191+
`lib/access/access.ex` and `lib/nbn/nbn.ex`.
192+
193+
## Adjusting the surface
194+
195+
The router is wired with `tools: true`, which exposes every tool declared on
196+
every domain in the configured `:diffo_example, :ash_domains` list. To narrow
197+
the surface, edit the `forward "/mcp"` block in `lib/nbn/router.ex` and
198+
replace `tools: true` with an explicit list of tool atoms — e.g.
199+
`tools: [:list_rsps, :get_path_by_id, :qualify_dsl]`.
200+
201+
For separate scopes (read-only vs full-surface, public vs authenticated), add
202+
a second `forward "/mcp_admin"` with its own tool list.
203+
204+
## Adding new tools
205+
206+
When you add a new action to a resource, add it to the appropriate domain's
207+
`tools do` block as well. See [AGENTS.md](../../AGENTS.md) for the
208+
keep-in-alignment convention. The compile won't catch a missing tool entry;
209+
the action will simply be invisible to MCP clients.
210+
211+
## Authorisation
212+
213+
The current router is unauthenticated — local-dev use case. Tools execute
214+
with no actor, which means:
215+
216+
- Resources with `bypass DiffoExample.Nbn.Checks.NoActor do authorize_if always() end`
217+
(every NBN resource — see `lib/nbn/rsp_ownership.ex`) execute as a
218+
bypass — Perentie-internal access.
219+
- Actions without that bypass policy may fail or behave differently with no
220+
actor.
221+
222+
For multi-tenant or production deployments, add
223+
`AshAuthentication.Strategy.ApiKey.Plug` (or similar) in a pipeline ahead of
224+
the MCP forward, and pass the resolved actor through to the tool calls. The
225+
existing RSP-actor multi-tenancy machinery will then bound what each MCP
226+
client can do based on its principal.
227+
228+
## Troubleshooting
229+
230+
- **`tools/list` returns count: 0** — the `forward "/mcp"` block isn't passing
231+
`tools: true` (or a tool list) and `otp_app: :diffo_example`. Check
232+
`lib/nbn/router.ex`.
233+
- **`Connection refused` on `http://localhost:4000/mcp`** — the server isn't
234+
running, or it crashed at startup. Restart with `MIX_ENV=dev mix run --no-halt`
235+
and watch the startup logs.
236+
- **A specific tool call errors with policy/auth message** — the action requires
237+
an actor that the unauthenticated MCP request can't supply. Either run with
238+
`MIX_ENV=test` (some test bypasses), use a tool that doesn't need an actor,
239+
or wire up auth as above.
240+
- **`tools/call` works in curl but Claude can't find the tool** — restart the
241+
Claude client after adding the MCP server. Many MCP clients only refresh
242+
the tools list on session start.
243+
- **Schema mismatches in tool args**`inputSchema` in `tools/list` is the
244+
source of truth. The Ash action's arguments (and their `public?` and types)
245+
determine the schema; private arguments aren't exposed.

lib/access/access.ex

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ defmodule DiffoExample.Access do
1010
"""
1111
use Ash.Domain,
1212
otp_app: :diffo,
13-
fragments: [Diffo.Provider.DomainFragment]
13+
fragments: [Diffo.Provider.DomainFragment],
14+
extensions: [AshAi]
1415

1516
alias DiffoExample.Access.DslAccess
1617
alias DiffoExample.Access.Shelf
@@ -31,6 +32,36 @@ defmodule DiffoExample.Access do
3132
description "An example showing how TMF Services and Resources for a fictional Access domain can be extended from the Provider domain"
3233
end
3334

35+
tools do
36+
tool :get_dsl_by_id, DslAccess, :read
37+
tool :qualify_dsl, DslAccess, :qualify
38+
tool :qualify_dsl_result, DslAccess, :qualify_result
39+
tool :design_dsl_result, DslAccess, :design_result
40+
41+
tool :get_shelf_by_id, Shelf, :read
42+
tool :build_shelf, Shelf, :build
43+
tool :define_shelf, Shelf, :define
44+
tool :relate_shelf, Shelf, :relate
45+
tool :assign_slot, Shelf, :assign_slot
46+
47+
tool :get_card_by_id, Card, :read
48+
tool :build_card, Card, :build
49+
tool :define_card, Card, :define
50+
tool :relate_card, Card, :relate
51+
tool :assign_port_on_card, Card, :assign_port
52+
53+
tool :get_cable_by_id, Cable, :read
54+
tool :build_cable, Cable, :build
55+
tool :define_cable, Cable, :define
56+
tool :relate_cable, Cable, :relate
57+
tool :assign_pair, Cable, :assign_pair
58+
59+
tool :get_path_by_id, Path, :read
60+
tool :build_path, Path, :build
61+
tool :define_path, Path, :define
62+
tool :relate_path, Path, :relate
63+
end
64+
3465
resources do
3566
resource DslAccess do
3667
define :get_dsl_by_id, action: :read, get_by: :id

0 commit comments

Comments
 (0)