Skip to content
Merged
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
6 changes: 5 additions & 1 deletion .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@
# SPDX-License-Identifier: MIT

# Used by "mix format"
locals_without_parens = []
locals_without_parens = [
tool: 3,
tool: 4
]

[
plugins: [Spark.Formatter],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
import_deps: [
:diffo,
:ash,
:ash_ai,
:ash_state_machine,
:ash_neo4j,
:ash_jason,
Expand Down
73 changes: 73 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<!--
SPDX-FileCopyrightText: 2025 diffo_example contributors <https://github.com/diffo-dev/diffo_example/graphs.contributors>

SPDX-License-Identifier: MIT
-->

# Agents working in this repo

Notes for AI assistants (Claude Code, Cursor, Continue, etc.) and humans pairing with them.

## Keep `tools do` aligned with `define`d actions

Each Ash domain in this repo (`DiffoExample.Access`, `DiffoExample.Nbn`) declares two parallel lists:

1. **`resources do ... resource X do define :name, action: :foo end end`** — the code-interface surface, generating `MyDomain.name/...` functions for Elixir callers.
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).

**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.

The convention is:

- One `tool` entry per `define`-d action.
- 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`).
- Read actions exposed as their natural read names (`tool :get_cable_by_id, Cable, :read`).

## Why the discipline

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.

## When NOT to add a tool

- **Internal-only actions** that no consumer (Elixir, HTTP, MCP) should call directly. These typically don't have a `define` either.
- **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.
- **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.

## Where this matters most

If you're modifying:

- 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.
- The `tools do` block itself — make sure the resource module is aliased at the top of the domain file.

## Quick check

After changes, count alignment:

```bash
# tools declared
grep -c "tool :" lib/access/access.ex lib/nbn/nbn.ex

# actions code-interface-defined
grep -c "define :" lib/access/access.ex lib/nbn/nbn.ex
```

The two should match (modulo intentional exclusions).

## Before you commit

Two checks before any commit, every commit:

```bash
mix format # auto-format every changed file to project style
reuse lint # ensure every file has SPDX-FileCopyrightText + SPDX-License-Identifier
```

New `.ex` / `.exs` files start with a comment header matching the existing
files (see `lib/diffo_example/util.ex` for the canonical form). New markdown
files use an HTML-comment variant (see `README.md`). `reuse lint` will tell
you which files are missing copyright/license info; if you've created a
new file and haven't added the header, this is the place to catch it.

Forgetting either is the easiest way to introduce CI noise the reviewer
has to clean up. Save them both the time.
245 changes: 245 additions & 0 deletions documentation/how_to/setup_mcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
<!--
SPDX-FileCopyrightText: 2025 diffo_example contributors <https://github.com/diffo-dev/diffo_example/graphs.contributors>

SPDX-License-Identifier: MIT
-->

# Setting up the diffo_example MCP server

This how-to walks through running the diffo_example MCP server locally and
wiring AI clients (Claude Code, Claude Desktop, Cursor, custom) to it. The MCP
surface exposes every action declared in the Access and Nbn domains' `tools do`
blocks as a callable tool — 60 tools across the two domains as of writing.

See [issue #44](https://github.com/diffo-dev/diffo_example/issues/44) for the
design context, and Zach's
[Ash AI blog post](https://alembic.com.au/blog/ash-ai-comprehensive-llm-toolbox-for-ash-framework)
for the framing.

## Prerequisites

- Elixir / Erlang installed (per `mix.exs` — currently `~> 1.18`).
- Neo4j running locally with the credentials configured in `config/dev.exs`.
- Dependencies fetched: `mix deps.get`.
- Initial RSP data seeded on first start (handled automatically by
`DiffoExample.Nbn.Initializer` when the app starts in dev).

## Starting the server

In a terminal in the project root:

```bash
MIX_ENV=dev mix run --no-halt
```

This boots the supervision tree, including `Plug.Cowboy` on port 4000. The MCP
server is forwarded from `DiffoExample.Nbn.Router` at the path `/mcp`. The same
Cowboy listener also serves the JSON:API routes and the `/catalog` endpoint.

You'll see Neo4j connection logs, then the listener-bound message. Leave it
running.

## Verifying MCP is up

In a second terminal, send the three canonical MCP requests with `curl`.

### `initialize`

```bash
curl -sS -X POST -H "Content-Type: application/json" \
-d '{
"jsonrpc":"2.0",
"method":"initialize",
"id":1,
"params":{
"protocolVersion":"2024-11-05",
"capabilities":{},
"clientInfo":{"name":"curl","version":"0"}
}
}' \
http://localhost:4000/mcp
```

Expected response shape:

```json
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"capabilities": {"tools": {"listChanged": false}},
"protocolVersion": "2024-11-05",
"serverInfo": {"name": "MCP Server", "version": "0.2.1"}
}
}
```

### `tools/list`

```bash
curl -sS -X POST -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":2}' \
http://localhost:4000/mcp
```

Returns the full set of tools. Each tool entry includes `name`, `description`,
and `inputSchema` (JSON Schema generated from the underlying Ash action's
arguments). To count and peek at the first few names:

```bash
curl -sS -X POST -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":2}' \
http://localhost:4000/mcp |
python3 -c '
import json, sys
d = json.load(sys.stdin)
ts = d.get("result", {}).get("tools", [])
print("count:", len(ts))
for t in ts[:8]:
print("-", t["name"])
print("..." if len(ts) > 8 else "")'
```

### `tools/call`

Try `list_rsps` — no-arg, returns the seeded RSPs:

```bash
curl -sS -X POST -H "Content-Type: application/json" \
-d '{
"jsonrpc":"2.0",
"method":"tools/call",
"id":3,
"params":{"name":"list_rsps","arguments":{}}
}' \
http://localhost:4000/mcp
```

Should return a `result.content[0].text` containing the JSON-encoded list of
six RSPs (Wedge-tail, Quokka, Ibis, Taipan, Echidna, Dugong).

## Wiring Claude Code

In any directory, with the server running:

```bash
claude mcp add diffo --transport http http://localhost:4000/mcp
```

This adds an entry to your global `~/.claude.json`. For a project-scoped
config (writes to a `.mcp.json` next to the cwd):

```bash
claude mcp add diffo --transport http -s project http://localhost:4000/mcp
```

After adding, any Claude Code session can call the tools. Try prompts like:

- "list the RSPs"
- "qualify a DSL service for a customer with this location and these parties"
- "build a cable, define it with 60 pairs as copper, then auto-assign a pair to
the qualified service"
- "show me the path with id X and its assigned ports and cables"

Claude will discover the right tools via `tools/list`, call them with the
arguments inferred from the prompt, and explain the results.

## Wiring Claude Desktop

Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS)
or the equivalent on your OS:

```json
{
"mcpServers": {
"diffo": {
"type": "http",
"url": "http://localhost:4000/mcp"
}
}
}
```

Restart Claude Desktop. The tools appear under the hammer icon in the prompt
input area. Hover any tool to see its description and schema.

## Wiring Cursor / Continue / other MCP clients

Any MCP-aware editor or assistant follows the same shape. Point them at
`http://localhost:4000/mcp` with HTTP transport. Refer to the client's MCP
configuration docs for the exact file/UI path.

## What tools are available

The full list is discoverable via `tools/list` (see above). At a glance:

**Access (~23 tools)**
- DslAccess: read, qualify, qualify_result, design_result
- Shelf: read, build, define, relate, assign_slot
- Card: read, build, define, relate, assign_port (`:assign_port_on_card`)
- Cable: read, build, define, relate, assign_pair
- Path: read, build, define, relate

**Nbn (~37 tools)**
- NbnEthernet, Uni, Avc, Nni: read, build, define, relate (each)
- Ntd: read, build, define, assign_port (`:assign_port_on_ntd`), relate
- Cvc: read, build, define, assign_cvlan, relate
- NniGroup: read, build, define, assign_svlan, relate
- Rsp: inventory (`:list_rsps`), read, build, activate, suspend, deactivate

The tool name in MCP matches the `tools do` declaration in
`lib/access/access.ex` and `lib/nbn/nbn.ex`.

## Adjusting the surface

The router is wired with `tools: true`, which exposes every tool declared on
every domain in the configured `:diffo_example, :ash_domains` list. To narrow
the surface, edit the `forward "/mcp"` block in `lib/nbn/router.ex` and
replace `tools: true` with an explicit list of tool atoms — e.g.
`tools: [:list_rsps, :get_path_by_id, :qualify_dsl]`.

For separate scopes (read-only vs full-surface, public vs authenticated), add
a second `forward "/mcp_admin"` with its own tool list.

## Adding new tools

When you add a new action to a resource, add it to the appropriate domain's
`tools do` block as well. See [AGENTS.md](../../AGENTS.md) for the
keep-in-alignment convention. The compile won't catch a missing tool entry;
the action will simply be invisible to MCP clients.

## Authorisation

The current router is unauthenticated — local-dev use case. Tools execute
with no actor, which means:

- Resources with `bypass DiffoExample.Nbn.Checks.NoActor do authorize_if always() end`
(every NBN resource — see `lib/nbn/rsp_ownership.ex`) execute as a
bypass — Perentie-internal access.
- Actions without that bypass policy may fail or behave differently with no
actor.

For multi-tenant or production deployments, add
`AshAuthentication.Strategy.ApiKey.Plug` (or similar) in a pipeline ahead of
the MCP forward, and pass the resolved actor through to the tool calls. The
existing RSP-actor multi-tenancy machinery will then bound what each MCP
client can do based on its principal.

## Troubleshooting

- **`tools/list` returns count: 0** — the `forward "/mcp"` block isn't passing
`tools: true` (or a tool list) and `otp_app: :diffo_example`. Check
`lib/nbn/router.ex`.
- **`Connection refused` on `http://localhost:4000/mcp`** — the server isn't
running, or it crashed at startup. Restart with `MIX_ENV=dev mix run --no-halt`
and watch the startup logs.
- **A specific tool call errors with policy/auth message** — the action requires
an actor that the unauthenticated MCP request can't supply. Either run with
`MIX_ENV=test` (some test bypasses), use a tool that doesn't need an actor,
or wire up auth as above.
- **`tools/call` works in curl but Claude can't find the tool** — restart the
Claude client after adding the MCP server. Many MCP clients only refresh
the tools list on session start.
- **Schema mismatches in tool args** — `inputSchema` in `tools/list` is the
source of truth. The Ash action's arguments (and their `public?` and types)
determine the schema; private arguments aren't exposed.
33 changes: 32 additions & 1 deletion lib/access/access.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ defmodule DiffoExample.Access do
"""
use Ash.Domain,
otp_app: :diffo,
fragments: [Diffo.Provider.DomainFragment]
fragments: [Diffo.Provider.DomainFragment],
extensions: [AshAi]

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

tools do
tool :get_dsl_by_id, DslAccess, :read
tool :qualify_dsl, DslAccess, :qualify
tool :qualify_dsl_result, DslAccess, :qualify_result
tool :design_dsl_result, DslAccess, :design_result

tool :get_shelf_by_id, Shelf, :read
tool :build_shelf, Shelf, :build
tool :define_shelf, Shelf, :define
tool :relate_shelf, Shelf, :relate
tool :assign_slot, Shelf, :assign_slot

tool :get_card_by_id, Card, :read
tool :build_card, Card, :build
tool :define_card, Card, :define
tool :relate_card, Card, :relate
tool :assign_port_on_card, Card, :assign_port

tool :get_cable_by_id, Cable, :read
tool :build_cable, Cable, :build
tool :define_cable, Cable, :define
tool :relate_cable, Cable, :relate
tool :assign_pair, Cable, :assign_pair

tool :get_path_by_id, Path, :read
tool :build_path, Path, :build
tool :define_path, Path, :define
tool :relate_path, Path, :relate
end

resources do
resource DslAccess do
define :get_dsl_by_id, action: :read, get_by: :id
Expand Down
Loading
Loading