Powered by AppSignal & Oban Pro

Jidoka: MCP Tool Sync

livebook/11_mcp_tool_sync.livemd

Jidoka: MCP Tool Sync

Run in Livebook

MCP endpoints are tool sources. Jidoka registers endpoints, prefixes synced tools, and keeps sync failures inside the turn boundary.

Setup

Mix.install(
  [
    {:jidoka, git: "https://github.com/mikehostetler/jidoka.git", ref: "924a486f3c1b7e7a943cb3d5ceee0de65f158467"},
    {:kino, "~> 0.19.0"}
  ],
  config: [
    jidoka: [
      model_aliases: %{fast: "anthropic:claude-haiku-4-5"}
    ]
  ]
)
Jidoka.Kino.setup()

Define An Inline MCP Endpoint

defmodule LivebookDemo.MCP.Agent do
  use Jidoka.Agent

  agent do
    id :livebook_mcp_agent
  end

  defaults do
    model :fast
    instructions "You can use synced MCP tools."
  end

  capabilities do
    mcp_tools endpoint: :livebook_inline_mcp,
              prefix: "demo_",
              transport: {:stdio, command: "echo"},
              client_info: %{name: "jidoka-livebook", version: "0.1.0"},
              timeouts: %{request_ms: 15_000}
  end
end
LivebookDemo.MCP.Agent.mcp_tools()

Fake A Successful Sync

This notebook uses a fake sync module so the shape is testable without starting an external MCP server.

defmodule LivebookDemo.MCP.FakeSync do
  def run(params, _context) do
    send(self(), {:mcp_sync_called, params})
    {:ok, %{registered_count: 2, registered_tools: ["demo_read_file", "demo_list_files"]}}
  end
end

previous_sync = Application.get_env(:jidoka, :mcp_sync_module)
Application.put_env(:jidoka, :mcp_sync_module, LivebookDemo.MCP.FakeSync)

try do
  runtime = LivebookDemo.MCP.Agent.runtime_module()
  agent = runtime.new(id: "livebook-mcp-runtime")

  {:ok, _agent, {:ai_react_start, _params}} =
    Jidoka.MCP.on_before_cmd(agent, {:ai_react_start, %{tool_context: %{}}}, LivebookDemo.MCP.Agent.mcp_tools())

  receive do
    {:mcp_sync_called, params} ->
      Map.take(params, [:endpoint_id, :prefix, :replace_existing])
  after
    100 -> :sync_not_called
  end
after
  if previous_sync do
    Application.put_env(:jidoka, :mcp_sync_module, previous_sync)
  else
    Application.delete_env(:jidoka, :mcp_sync_module)
  end
end

Keep Failures Bounded

defmodule LivebookDemo.MCP.FailingSync do
  def run(params, _context) do
    send(self(), {:mcp_sync_called, params})
    {:error, :server_capabilities_not_set}
  end
end

previous_sync = Application.get_env(:jidoka, :mcp_sync_module)
Application.put_env(:jidoka, :mcp_sync_module, LivebookDemo.MCP.FailingSync)

try do
  runtime = LivebookDemo.MCP.Agent.runtime_module()
  agent = runtime.new(id: "livebook-mcp-failure-runtime")

  {:ok, updated_agent, {:ai_react_start, %{tool_context: context}}} =
    Jidoka.MCP.on_before_cmd(agent, {:ai_react_start, %{tool_context: %{}}}, LivebookDemo.MCP.Agent.mcp_tools())

  error =
    updated_agent.state
    |> get_in([:__jidoka_mcp__, :last_errors])
    |> List.first()

  %{
    turn_continued?: context == %{},
    formatted_error: error && error.message
  }
after
  if previous_sync do
    Application.put_env(:jidoka, :mcp_sync_module, previous_sync)
  else
    Application.delete_env(:jidoka, :mcp_sync_module)
  end
end

Optional Provider Turn

{:ok, pid} =
  Jidoka.Kino.start_or_reuse("livebook-mcp-agent", fn ->
    LivebookDemo.MCP.Agent.start_link(id: "livebook-mcp-agent")
  end)

Jidoka.Kino.chat("MCP-aware chat", fn ->
  LivebookDemo.MCP.Agent.chat(pid, "Explain MCP sync in one sentence.")
end)