tama-dex
Index and search for operation catalogues, so an MCP server can expose just two
tools, search and execute, in place of one tool per operation.
Why
A host such as Macus exposes dozens of operations from several sources. For
example, opsmaru_incus alone provides 32 Incus operations, and Macus adds its
own runtime operations. One MCP tool per operation floods tools/list, and
each source would otherwise invent its own description format. tama-dex
replaces that with:
-
One ingestion standard. Every source publishes an OpenAPI 3.1 document in the Tama operations profile (
tama-operations/1). It uses ordinary OpenAPI plus a fewx-tama-*extensions for scopes, destructive operations and confirmation rules. -
A SQLite index. The host installs cassettes and profile documents into one index it owns. Each cassette is a namespace, such as
opsmaru_incusormacus, and is replaced atomically on its own. -
search. Full-text search over operation IDs, summaries, descriptions, tags and input fields, ranked across every cassette. It only returns operations the caller's verified scopes allow, and each result names its cassette. -
execute. The caller names the cassette and operation. It checks the caller's scopes, any confirmation rule and the input schema, then hands the call to the executor the host registered for that source: a host module implementingTama.Dex.Executor, typically an adapter around the producer's own run function. -
Cassettes. A producer's CI can build a portable, prebuilt index file (
.tama-cassette) withmix tama.dex.cassette. For example, every opsmaru_incus release publishes anopsmaru_incuscassette. A host verifies each cassette and imports it at install or start, and can install several side by side. Prebuilt rows are copied only when the host pins the cassette's SHA-256. Otherwise the index is rebuilt from the documents embedded in the cassette.
Boundary
opsmaru_incus ──┐ tama-operations/1 documents (data)
Macus runtime ──┤
v
host (e.g. Macus) ── Tama.Dex.Index (SQLite, host-supervised)
│
└── MCP server: search / execute ── Tama.Dex handlers
│
host executor per source (Tama.Dex.Executor)
tama-dex is a library. It starts nothing on load, holds no listener, never
fetches documents or cassettes over the network, and never runs an operation itself. It
doesn't depend on tama_mcp. It provides the two tool definitions as data
for the host's MCP server.
Installation
def deps do
[
{:tama_dex, "~> 0.1"}
]
end
Usage
Start an index
The host starts the index under its own supervisor, with an explicit database path. Loading the dependency starts nothing.
children = [
{Tama.Dex.Index, path: "/var/lib/my_host/tama-dex.sqlite3", name: MyHost.Dex}
]
Use path: :memory in tests. The database is derived data: one with an
unknown storage schema version is recreated empty, and the host installs its
documents again.
Install documents and cassettes
Load a profile document into a cassette, a namespace the host names. A load validates the whole document and replaces that source atomically. An unchanged document is a no-op.
{:ok, _loaded} =
Tama.Dex.load(MyHost.Dex, MyHost.Runtime.operations_document(), cassette: "macus")
Or import a cassette a producer published, from a local file the host supplies. Pinning the published SHA-256 lets the prebuilt rows be copied. Without a pin, the index is rebuilt from the documents embedded in the cassette.
{:ok, %{cassette: "opsmaru_incus", mode: :copy}} =
Tama.Dex.import(MyHost.Dex, "priv/cassettes/opsmaru_incus-1.4.0.tama-cassette",
sha256: "9f2c…"
)
To keep startup bounded, pass timeout: (the work rolls back when it runs
out) or async: true (returns {:ok, ref}, then sends
{:tama_dex_import, ref, result}). Tama.Dex.Index.status/1 and
Tama.Dex.cassettes/1 report readiness. While nothing is committed, search
and execute answer not_ready instead of waiting.
Register executors
tama-dex never runs an operation. execute validates the call, then hands it
to the executor the host registered for the operation's source:
defmodule MyHost.IncusExecutor do
@behaviour Tama.Dex.Executor
@impl Tama.Dex.Executor
def execute(%Tama.Dex.Call{} = call, input) do
OpsmaruIncus.run(call.operation_id, input,
authorizer: Keyword.fetch!(call.opts, :authorizer),
scopes: call.scopes
)
end
end
def executors do
%{"opsmaru_incus" => {MyHost.IncusExecutor, authorizer: MyHost.Authorizer}}
end
Search and execute
Both take the caller's verified scopes. Search only shows operations those scopes allow, and execute checks them again on every call.
{:ok, %{results: [%{cassette: "opsmaru_incus", operation_id: "incus_instance_list"} | _]}} =
Tama.Dex.search(MyHost.Dex, "list instances", scopes: caller.scopes)
{:ok, %{result: result}} =
Tama.Dex.execute(MyHost.Dex, "opsmaru_incus", "incus_instance_list", %{},
scopes: caller.scopes,
executors: executors(),
context: caller
)
An error is a Tama.Dex.Error with a kind and dispatched.
dispatched: true means the executor was called, so the operation may have
taken effect. tama-dex never retries.
Build a cassette
A producer's CI builds a cassette from its profile documents and publishes it with the printed SHA-256:
mix tama.dex.cassette --name acme --version 1.0.0 \
--document Acme.Operations.openapi \
--output acme-1.0.0.tama-cassette
Telemetry
tama-dex emits [:tama_dex, :load | :import | :search | :execute, ...] spans
and [:tama_dex, :index, :recreated | :status] events. Their metadata never
holds query text, inputs, results, schemas or file paths. See
Tama.Dex.Telemetry.
MCP tools
Tama.Dex.MCP.search_tool/0 and execute_tool/0 return the two tool
definitions as data: a name, title, description, JSON Schema 2020-12 input
and output schemas, and annotations. handle_search/3 and handle_execute/4
take the index, the tool arguments and the caller, and return JSON-safe maps
matching those output schemas. A caller is any map or struct with a scopes
list, such as TamaMCP.Context; every operation is checked against those
scopes, so the host's scope on each tool is only an outer gate.
TamaMCP.Tool accepts only literal schemas, so a TamaMCP host injects the
definitions with a small macro:
defmodule MyHost.DexTool do
defmacro __using__(opts) do
tool = apply(Tama.Dex.MCP, Keyword.fetch!(opts, :tool), [])
quote do
use TamaMCP.Tool,
scopes: unquote(Keyword.fetch!(opts, :scopes)),
title: unquote(tool.title),
description: unquote(tool.description),
annotations: unquote(Map.to_list(tool.annotations))
raw_input_schema(unquote(Macro.escape(tool.input_schema)))
raw_output_schema(unquote(Macro.escape(tool.output_schema)))
end
end
def respond(structured) do
failed? = Map.has_key?(structured, "error") or Map.has_key?(structured, "operation_error")
build = if failed?, do: &TamaMCP.Response.tool_error/1, else: &TamaMCP.Response.success/1
{:ok,
build.(
structured_content: structured,
content: [TamaMCP.Response.text(Jason.encode!(structured))]
)}
end
end
defmodule MyHost.Tools.Search do
use MyHost.DexTool, tool: :search_tool, scopes: ["ops.search"]
@impl TamaMCP.Tool
def call(arguments, context) do
MyHost.Dex |> Tama.Dex.MCP.handle_search(arguments, context) |> MyHost.DexTool.respond()
end
end
defmodule MyHost.Tools.Execute do
use MyHost.DexTool, tool: :execute_tool, scopes: ["ops.execute"]
@impl TamaMCP.Tool
def call(arguments, context) do
MyHost.Dex
|> Tama.Dex.MCP.handle_execute(arguments, context, MyHost.executors())
|> MyHost.DexTool.respond()
end
end
defmodule MyHost.MCP do
use TamaMCP.Server, name: "my_host", version: "1.0.0"
tool MyHost.Tools.Search, name: "search"
tool MyHost.Tools.Execute, name: "execute"
end
MyHost.executors/0 returns the host's executor registrations, such as
%{"opsmaru_incus" => {MyHost.IncusExecutor, authorizer: MyHost.Authorizer}}
(see Tama.Dex.Executor). An execute error with "dispatched" => true
means the operation may have taken effect, so the agent should reconcile
rather than retry.
Development
mise trust && mise install
mise run spec:validate
See AGENTS.md for the project rules and Git Flow.
License
Apache-2.0