⚙ Architecture Overview
How It Works
AshAi
(ash_ai ~> 0.4) generates MCP-compliant tool schemas directly from Ash resource actions. The
AshAi.Mcp.Router
handles the MCP protocol (initialize, tools/list, tools/call) and is forwarded to at /mcp. Authentication is API-key-based via AshAuthentication.Strategy.ApiKey. All task operations are user-scoped — the API key identifies the user, and Ash policies enforce that agents can only touch that user's tasks.
Authorization: Bearer <key> → resolves to Kacom.Accounts.User actor
tools/call create_task → Kacom.Tasks.Task :create (actor scoped)
tools/call update_task → Kacom.Tasks.Task :update (actor scoped)
Protocol
MCP 2024-11-05. Single endpoint POST /mcp. JSON-RPC envelope. Stateless — each call is authenticated independently.
Auth
API key in Authorization: Bearer
header. Keys are hashed at rest. Scoped to one user. Generated and revoked at /account.
Authorization
Ash Policy Authorizer. Create: requires actor. Read/Update/Destroy:
user_id == actor.id
filter. Agents cannot touch other users' tasks.
Tool Generation
AshAi derives tool name, description, and JSON Schema from resource attributes and action
accept
lists. No hand-written tool schemas needed.
🔧 Current Tool Inventory
Tools live in two places: the tools do
block in lib/kacom/tasks.ex
(domain) and the tools:
list in the forward "/", AshAi.Mcp.Router
call in lib/kacom_web/router.ex. A tool must appear in both to be callable.
| Tool Name | Resource Action | What It Does | Exposed? |
|---|---|---|---|
list_tasks |
Task :read |
Return all tasks for the authenticated user. Supports filtering. | ✓ Yes |
create_task |
Task :create |
Create a new task. Accepts title, description, category_id, status, priority, due_date. | ✓ Yes |
update_task |
Task :update |
Update any writable field including status. Can change status to any valid value. | ✓ Yes |
delete_task |
Task :destroy |
Permanently delete a task. | ✓ Yes |
list_categories |
Category :read |
List all categories for the user. | ✓ Yes |
create_category |
Category :create |
Create a new task category with name and color. | ✓ Yes |
complete_task |
Task :complete |
Set status→done, stamp completed_at. Semantic verb — clearer to agents than update_task. | ✓ Yes |
reopen_task |
Task :reopen |
Set status→todo, clear completed_at. Semantic verb. | ✓ Yes |
archive_task |
Task :archive |
Set status→archived. Semantic verb. | ✓ Yes |
update_task
by passing status: "done"
etc. However, exposing complete_task, reopen_task, and
archive_task
as dedicated tools gives agents unambiguous verbs, correctly stamps
completed_at
via the action's built-in change, and reduces the chance of an agent passing an invalid or unexpected status string.
▶ Phases
Goal: MCP endpoint live, authentication working, core CRUD tools exposed, account UI for key management.
What Was Built
-
POST /mcp—AshAi.Mcp.Routerforwarded from the:mcppipeline inlib/kacom_web/router.ex -
:mcppipeline —AshAuthentication.Strategy.ApiKey.Plugwithresource: Kacom.Accounts.User, required?: true -
6 tools declared in
lib/kacom/tasks.extools doblock and listed in the router forward -
Kacom.Accounts.ApiKeyAsh resource —api_keystable, hashed storage, scoped to user -
/accountLiveView — Generate / Regenerate / Revoke UI; displays raw key once with copy-now warning - Protocol version
2024-11-05declared on the router forward
Key Files
-
lib/kacom/tasks.ex— domain withtools doblock -
lib/kacom_web/router.ex:25-72—:mcppipeline + scope lib/kacom/accounts/api_key.ex— ApiKey resourcelib/kacom_web/live/account_live.ex— key management UI
Deliverables
-
MCP endpoint at
/mcp - API key authentication pipeline
- 6 base tools: list_tasks, create_task, update_task, delete_task, list_categories, create_category
-
API key generation / revocation UI at
/account - Ash Policy Authorizer — tasks scoped to actor
Goal: expose complete_task, reopen_task, archive_task as dedicated MCP tools; verify actor flows correctly through the pipeline.
Step 1 — Add Tools to Domain
In lib/kacom/tasks.ex, add three entries to the
tools do
block. The :complete, :reopen, and
:archive
actions already exist on the Task
resource — they just need to be wired as tools:
# lib/kacom/tasks.ex — tools do block
tool :complete_task, Kacom.Tasks.Task, :complete
tool :reopen_task, Kacom.Tasks.Task, :reopen
tool :archive_task, Kacom.Tasks.Task, :archive
Step 2 — Add Tools to Router
In lib/kacom_web/router.ex, add the three new tool atoms to the
tools:
list on the AshAi.Mcp.Router
forward:
# lib/kacom_web/router.ex:64-71
forward "/", AshAi.Mcp.Router,
tools: [
:list_tasks, :create_task, :update_task, :delete_task,
:list_categories, :create_category,
:complete_task, :reopen_task, :archive_task, # <-- add
:list_recipes, :create_recipe, :update_recipe, :delete_recipe
],
protocol_version_statement: "2024-11-05",
otp_app: :kacom
Step 3 — Verify Actor Context ✓ Resolved
Confirmed by reading AshAuthentication.Strategy.ApiKey.Plug
source — line 123 calls Ash.PlugHelpers.set_actor(subject)
directly after a successful key lookup. No additional
plug :set_actor, :user
needed in the :mcp
pipeline. Actor is set before AshAi.Mcp.Router
is reached.
Step 4 — Smoke Test All Status Tools
Using curl
or a Claude Code MCP client, call each new tool and verify the database state:
# Initialize
curl -X POST http://localhost:4000/mcp \
-H "Authorization: Bearer kacom_..." \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}'
# List tools (confirm complete_task, reopen_task, archive_task appear)
curl -X POST http://localhost:4000/mcp \
-H "Authorization: Bearer kacom_..." \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
# Complete a task
curl -X POST http://localhost:4000/mcp \
-H "Authorization: Bearer kacom_..." \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"complete_task","arguments":{"id":"<task-uuid>"}}}'
Deliverables
-
complete_tasktool in domain + router -
reopen_tasktool in domain + router -
archive_tasktool in domain + router -
Actor context verified —
AshAuthentication.Strategy.ApiKey.PlugcallsAsh.PlugHelpers.set_actor/1directly - Smoke test passing for all 9 task tools
Goal: Claude Desktop and Claude Code can connect to the MCP server out of the box using configs documented in the repo.
Claude Code MCP Config
Claude Code reads MCP server config from ~/.claude/mcp_servers.json
(global) or .mcp.json
(project-local). Add a project-local .mcp.json
in the repo root so any Claude Code session in this project can call the local dev MCP server automatically:
// .mcp.json (project root — commit this without the real key; use env var)
{
"mcpServers": {
"kacom-tasks": {
"type": "http",
"url": "http://localhost:4000/mcp",
"headers": {
"Authorization": "Bearer ${KACOM_MCP_KEY}"
}
}
}
}
Set KACOM_MCP_KEY
in the shell before launching claude. The key is generated at /account. Production URL would be https://kyleaziz.com/mcp.
Claude Desktop Config
Claude Desktop reads from
~/Library/Application Support/Claude/claude_desktop_config.json
on macOS. Add a kacom-tasks
entry under mcpServers:
{
"mcpServers": {
"kacom-tasks": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://kyleaziz.com/mcp"],
"env": {
"MCP_REMOTE_HEADER_Authorization": "Bearer kacom_..."
}
}
}
}
Claude Desktop does not support HTTP MCP servers natively yet (as of 2024-11); the
mcp-remote
bridge translates stdio ↔ HTTP. Once Claude Desktop adds native HTTP support, the config simplifies to the same shape as Claude Code.
Both configs are also documented in deploy/README.md
in the repo root. Production URL: https://kyleaziz.com/mcp.
Tool Description Audit
AshAi auto-generates tool descriptions from resource attributes. Before going live with agents, verify the generated descriptions are clear enough for LLM tool selection. Run
tools/list
and read each description
field. If any are ambiguous, add a description:
option to the tool
declaration in lib/kacom/tasks.ex:
# lib/kacom/tasks.ex — tools do block (description override example)
tool :complete_task, Kacom.Tasks.Task, :complete,
description: "Mark a task as done and record completion time. Use this instead of update_task when the user says a task is finished."
Account Page Enhancement
The /account
page already shows the API key once on generation. Enhance it to display the exact config snippet for Claude Code so users can copy-paste directly — no docs hunting required.
Deliverables
-
.mcp.jsonin repo root with Claude Code config (env-var key, no hardcoded secret) -
Claude Desktop config snippet documented in
deploy/README.md - Tool description audit — all 9 task tools have clear descriptions
-
/accountpage shows ready-to-paste Claude Code config after key generation
Goal: the MCP endpoint is safe, observable, and resilient enough to leave running and connected to real AI agents indefinitely.
Rate Limiting
Add a rate limiter to the :mcp
pipeline to prevent runaway agents from flooding the database.
hammer
(already common in Phoenix projects) or ex_rated
work well with Plug:
# mix.exs
{:hammer, "~> 6.0"}
# lib/kacom_web/plugs/mcp_rate_limit.ex
defmodule KacomWeb.Plugs.McpRateLimit do
import Plug.Conn
def call(conn, _opts) do
user_id = conn.assigns[:current_user].id
case Hammer.check_rate("mcp:#{user_id}", 60_000, 60) do
{:allow, _} -> conn
{:deny, _} -> conn |> put_status(429) |> Phoenix.Controller.json(%{error: "rate_limited"}) |> halt()
end
end
end
Logging & Observability
Log MCP tool calls with tool name, user id, and latency. A simple Plug in the
:mcp
pipeline is sufficient for v1. Later: pipe logs to your existing telemetry pipeline.
defmodule KacomWeb.Plugs.McpLogger do
require Logger
def call(conn, _opts) do
start = System.monotonic_time(:millisecond)
conn = Plug.Conn.register_before_send(conn, fn conn ->
duration = System.monotonic_time(:millisecond) - start
user_id = get_in(conn.assigns, [:current_user, :id]) || "unauthenticated"
Logger.info("[MCP] user=#{user_id} status=#{conn.status} duration=#{duration}ms")
conn
end)
conn
end
end
HTTPS Enforcement
The API key is sent in plaintext headers — HTTPS is mandatory in production. Ensure the deploy config (Caddy / nginx / Fly.io) terminates TLS before
/mcp
traffic reaches Phoenix. Optionally add a plug that rejects non-HTTPS requests in prod:
# config/runtime.exs — already enforced by Phoenix.Endpoint force_ssl option if configured
config :kacom, KacomWeb.Endpoint,
force_ssl: [rewrite_on: [:x_forwarded_proto]]
Key Naming / Labels
If a user wants to give one key to Claude Desktop and another to Claude Code, they need per-key labels. Add an optional
label
attribute to Kacom.Accounts.ApiKey
and allow multiple keys per user. Update the /account
UI to list all keys with their labels and individual revoke buttons.
Deliverables
-
Rate limiting plug on
:mcppipeline (60 req/min per user) - MCP access log with tool name, user id, duration
-
force_sslverified in production deploy config -
labelattribute on ApiKey + multi-key support (optional, do if needed)
📋 Task Resource Reference
:todo | :in_progress | :done | :archived — default :todo, required, public
:high | :medium | :low | :none — default :none, required, public
:complete action, public
relate_actor(:user) on create, not agent-settable
Actions Exposed as MCP Tools
| Action | Accepts | Side Effects |
|---|---|---|
:create |
title, description, category_id, status, priority, due_date |
Relates actor as user. Sets completed_at
if status is :done
on create.
|
:update |
title, description, category_id, status, priority, due_date |
Sets completed_at
when status→done (if nil). Clears completed_at
when status→todo or in_progress.
|
:complete |
(none — id only for record lookup) | Sets status = :done, completed_at = now(). |
:reopen |
(none — id only for record lookup) | Sets status = :todo, clears completed_at. |
:archive |
(none — id only for record lookup) | Sets status = :archived. |
:destroy |
(none — id only for record lookup) | Permanently deletes the task. |
✓ Decisions
-
Tool generation strategy
Auto-generated from Ash resource actions via
AshAi. No hand-written JSON schemas. Tool schemas update automatically when actions change. -
Auth method
API key in
Authorization: Bearerheader. Simple, stateless, revocable. One key per user for now; multi-key + labels deferred to Phase 4. - User scoping Agents operate as a specific user — their tasks, their categories. No cross-user access possible; enforced at the Ash Policy layer, not application code.
-
Separate status-transition tools
Expose
complete_task,reopen_task,archive_taskas dedicated tools in addition toupdate_task.update_taskalso works for status changes but semantic verbs are clearer for agents and correctly invoke the action's built-in side effects. -
MCP protocol version
2024-11-05— the version supported byash_ai ~> 0.4. Upgrade when AshAi adds support for newer protocol versions. -
Claude Desktop bridge
Use
mcp-remotenpm package as a stdio→HTTP bridge until Claude Desktop supports HTTP MCP servers natively. Claude Code supports HTTP MCP directly.
? Open Questions
-
Actor context in MCP pipeline
✓ Resolved.
AshAuthentication.Strategy.ApiKey.PlugcallsAsh.PlugHelpers.set_actor(subject)directly on successful auth. No extra plug needed. - AshAi tool description quality The auto-generated tool descriptions from AshAi may be too generic for reliable agent tool selection. Audit in Phase 3 and add manual descriptions where needed.
- SSE / streaming support MCP 2024-11-05 includes an SSE channel for server-sent notifications. AshAi's current router implementation may not support SSE. Investigate if agents that require SSE (e.g., for long-running tool calls) will work with the current setup.