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:

  1. 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 few x-tama-* extensions for scopes, destructive operations and confirmation rules.

  2. A SQLite index. The host installs cassettes and profile documents into one index it owns. Each cassette is a namespace, such as opsmaru_incus or macus, and is replaced atomically on its own.

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

  4. 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 implementing Tama.Dex.Executor, typically an adapter around the producer's own run function.

  5. Cassettes. A producer's CI can build a portable, prebuilt index file (.tama-cassette) with mix tama.dex.cassette. For example, every opsmaru_incus release publishes an opsmaru_incus cassette. 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