Manual mode gives you full control over callback implementations and JSON Schema definitions. No compile-time macros — you build everything yourself.

Setup

defmodule MyApp.MCPServer do
  use ConduitMcp.Server, dsl: false

  # You implement callbacks directly
end

This gives you default implementations for all 6 callbacks. The list callbacks return empty lists; handle_call_tool/3 and handle_get_prompt/3 answer -32602 ("Unknown tool"/"Unknown prompt"), and handle_read_resource/2 answers the legacy resource-not-found code -32002. Override the ones you need.

use ConduitMcp.Server, dsl: false, instructions: "..." sets the server's instructions, returned by initialize and server/discover (a transport's :instructions option overrides it):

use ConduitMcp.Server, dsl: false, instructions: "Call search before fetch."

Callbacks

CallbackPurpose
handle_list_tools(conn)Return tool schemas
handle_call_tool(conn, name, params)Execute a tool
handle_list_resources(conn)Return resource schemas
handle_read_resource(conn, uri)Read a resource
handle_list_prompts(conn)Return prompt schemas
handle_get_prompt(conn, name, args)Get prompt messages

Optional callbacks: handle_complete/3, handle_set_log_level/2, handle_subscribe_resource/2, handle_unsubscribe_resource/2. handle_set_log_level/2 and the subscribe callbacks serve the legacy era only; modern clients send a per-request logLevel and use subscriptions/listen (see Protocol Eras and Subscriptions).

Example

defmodule MyApp.MCPServer do
  use ConduitMcp.Server, dsl: false

  @tools [
    %{
      "name" => "greet",
      "description" => "Greet someone",
      "inputSchema" => %{
        "type" => "object",
        "properties" => %{
          "name" => %{"type" => "string", "description" => "Person's name"},
          "formal" => %{"type" => "boolean", "description" => "Use formal greeting"}
        },
        "required" => ["name"]
      }
    },
    %{
      "name" => "add",
      "description" => "Add two numbers",
      "inputSchema" => %{
        "type" => "object",
        "properties" => %{
          "a" => %{"type" => "number"},
          "b" => %{"type" => "number"}
        },
        "required" => ["a", "b"]
      }
    }
  ]

  @impl true
  def handle_list_tools(_conn), do: {:ok, %{"tools" => @tools}}

  @impl true
  def handle_call_tool(_conn, "greet", %{"name" => name} = params) do
    greeting = if params["formal"], do: "Good day", else: "Hey"
    {:ok, %{"content" => [%{"type" => "text", "text" => "#{greeting}, #{name}!"}]}}
  end

  def handle_call_tool(_conn, "add", %{"a" => a, "b" => b}) do
    {:ok, %{"content" => [%{"type" => "text", "text" => "#{a + b}"}]}}
  end

  def handle_call_tool(_conn, name, _params) do
    {:error, %{"code" => ConduitMcp.Errors.invalid_params(), "message" => "Unknown tool: #{name}"}}
  end
end

An unknown tool is -32602, as the MCP specification prescribes. Don't use -32601: on the modern era it means "method not found" and is answered with HTTP 404.

Return Format

All callbacks must return {:ok, map()} or {:error, map()} with string keys:

# Success
{:ok, %{"content" => [%{"type" => "text", "text" => "Hello!"}]}}

# Error
{:error, %{"code" => ConduitMcp.Errors.invalid_params(), "message" => "Bad input"}}

On a modern-era request, two more returns are accepted:

ReturnFromSee
{:input_required, input_requests, state}handle_call_tool/3, handle_read_resource/2, handle_get_prompt/3MRTR
{:task, attrs}handle_call_tool/3Tasks Extension

On a legacy request, and from any other callback, both are answered with -32603.

For a missing resource, return ConduitMcp.Protocol.Legacy.resource_not_found() (-32002). Modern clients get -32602, because ConduitMcp.Protocol.Modern rewrites the code.

Header annotations

To mirror a tool argument into an Mcp-Param-* header, add "x-mcp-header" to its property:

"properties" => %{
  "region" => %{"type" => "string", "x-mcp-header" => "Region"}
}

ConduitMcp.Plugs.HeaderValidation reads the annotations from your handle_list_tools (following nextCursor) on the first tools/call, and caches them per server module in :persistent_term. The cache is built from the list an unauthenticated caller sees: your callback gets a conn with no principal, assigns, headers or session, so it must not crash on one. A tool you list only to some callers (absent from that list) is looked up in the calling user's own list on every call instead, uncached. Keep a tool's inputSchema the same for every caller who can see it. Unlike DSL and Component servers, manual servers aren't checked at compile time. A tool whose annotations are invalid is logged at :error when the cache is built (naming the tool and header, never values) and is left out of header validation.

If your tool list changes at runtime, drop the cache after the change:

ConduitMcp.Plugs.HeaderValidation.invalidate(MyApp.MCPServer)

The next tools/call rebuilds it. :persistent_term updates trigger a global GC pass, so this suits tool lists that change rarely. To tell connected modern clients, call ConduitMcp.notify_tools_changed/0.

Cache hints

Modern tools/list, prompts/list, resources/list, resources/templates/list, resources/read and server/discover results carry ttlMs and cacheScope. A manual server gets the global defaults:

config :conduit_mcp, cache_ttl_ms: 60_000, cache_scope: :private

Scoped items are still kept private. To override per item, define __cache_hints__/0:

def __cache_hints__ do
  %{
    tools: %{"catalog" => %{ttl_ms: 5_000, scope: :public}},
    prompts: %{},
    resources: [{"docs://{page}", %{ttl_ms: 300_000, scope: nil}}]
  }
end

A nil value falls back to the default. Resources are listed in dispatch order, as {uri_or_template, hint}. See ConduitMcp.CacheHints.

Calling the handler directly

Transports call ConduitMcp.Handler.dispatch/3. To drive a server without HTTP (tests, a custom transport), you have two entry points:

dispatch/3handle_request/3
Returns{:reply, response}, {:stream, %ConduitMcp.StreamSpec{}} or :noreplya response map, or :ok for a notification or a JSON-RPC response
Streamed tools/call (progressToken / logLevel){:stream, spec}, which you runrun to completion in the calling process; notifications dropped
subscriptions/listen{:stream, %StreamSpec{kind: :listen}}-32601: a map can't hold a stream open
request = %{
  "jsonrpc" => "2.0",
  "id" => 1,
  "method" => "tools/call",
  "params" => %{"name" => "greet", "arguments" => %{"name" => "Ada"}}
}

ConduitMcp.Handler.handle_request(request, MyApp.MCPServer, conn)
#=> %{"jsonrpc" => "2.0", "id" => 1, "result" => %{"content" => [...]}}

case ConduitMcp.Handler.dispatch(request, MyApp.MCPServer, conn) do
  {:reply, response} -> response
  {:stream, %ConduitMcp.StreamSpec{kind: :response} = spec} -> ConduitMcp.StreamSpec.run(spec)
  {:stream, %ConduitMcp.StreamSpec{kind: :listen}} -> :needs_a_streaming_transport
  :noreply -> :ok
end

Both classify the request's era themselves when given a conn. ConduitMcp.Progress.report/3 and ConduitMcp.Log.log/4 do nothing under handle_request/3 or StreamSpec.run/1, so tools can call them unconditionally.

When to Use Manual Mode

  • You need JSON Schema features not supported by the DSL (e.g., oneOf, allOf, $ref)
  • You want to load tool definitions dynamically at runtime
  • You need precise control over every response map
  • You're wrapping an existing API and generating schemas programmatically