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