Server

Define a server

defmodule MyApp.MCP do
  use Noizu.MCP.Server,
    name: "myapp",
    version: "1.0.0",
    instructions: "..."

  tool MyApp.Tools.Search
  tool MyApp.Tools.Search, name: "alias"
  resource MyApp.Resources.Config
  resource_template MyApp.Resources.Table
  prompt MyApp.Prompts.Review
end

Capabilities are derived — never declared.

Run it

# stdio
children = [{MyApp.MCP, transport: :stdio}]

# HTTP (Phoenix)
forward "/mcp", Noizu.MCP.Transport.StreamableHTTP.Plug,
  server: MyApp.MCP

# HTTP (standalone)
children = [
  MyApp.MCP,
  {Bandit, plug: {Noizu.MCP.Transport.StreamableHTTP.Plug,
                  server: MyApp.MCP}, port: 4040}
]

Tool

defmodule MyApp.Tools.Search do
  use Noizu.MCP.Server.Tool,
    name: "search",
    description: "...",
    annotations: [read_only_hint: true]

  input do
    field :q, :string, required: true, min_length: 2
    field :limit, :integer, min: 1, max: 50, default: 10
    field :scope, :enum, values: [:all, :docs], default: :all
    field :filters, :object do
      field :tags, {:array, :string}
    end
  end

  output do
    field :count, :integer, required: true
  end

  @impl true
  def call(%{q: q, limit: limit}, ctx) do
    Noizu.MCP.Ctx.report_progress(ctx, 0.5)
    {:ok, %{count: 7}}
  end
end

Args arrive atom-keyed, defaults applied, enums cast.

Tool return values

{:ok, "text"}                 # text block
{:ok, %{key: "val"}}          # structuredContent
{:ok, [%Content{...}]}        # explicit blocks
{:ok, %ToolResult{...}}       # verbatim
{:error, "msg"}               # isError result
{:error, %Noizu.MCP.Error{}}  # protocol error
raise "boom"                  # sanitized isError

Raw schema escape hatch

input_schema %{
  "type" => "object",
  "properties" => %{"q" => %{"type" => "string"}},
  "required" => ["q"]
}
# call/2 then receives string keys

input_schema """
{"type": "object", "required": ["q"],
 "properties": {"q": {"type": "string"}}}
"""
# JSON text — decoded at compile time

Toolkit (many tools, one module)

defmodule MyApp.Toolkit do
  use Noizu.MCP.Server.Toolkit,
    category: "Utility"          # default category

  @mcp name: "files.read", category: "Files",
       description: "Read a file",
       input: [path: [type: :string, required: true]]
  def read_file(%{path: path}, _ctx),
    do: {:ok, File.read!(path)}

  @mcp visible: false            # hidden, still callable
  @mcp input: """
  {"type": "object",
   "properties": {"q": {"type": "string"}}}
  """
  def lookup(args, _ctx), do: {:ok, args["q"] || ""}
end

Arity 0–2 (args, ctx trimmed); multiple @mcp lines merge (later wins); data-form input: ⇒ atom keys, map/JSON text ⇒ string keys. category rides in _meta.category.

Hidden tools & discovery

use Noizu.MCP.Server.Tool, hidden: true, ...
# registration overrides (kit-wide for toolkits):
tool MyApp.Toolkit                  # all @mcp tools
tool MyApp.Tools.X, hidden: true
tool MyApp.Tools.Y, visible: false  # alias
tool MyApp.Toolkit, category: "Admin"
# built-in discovery tool (lists hidden too):
tool Noizu.MCP.Server.Tools.Catalog, hidden: true

Hidden items skip tools/list but stay callable by name. Catalog args: type, query, category, include_hidden.

Resource / template / prompt

use Noizu.MCP.Server.Resource,
  uri: "config://app", name: "Config",
  mime_type: "application/json", subscribable: true
def read("config://app", _ctx), do: {:ok, json}
# binary: {:ok, {:blob, bytes}}

use Noizu.MCP.Server.ResourceTemplate,
  uri_template: "db://{table}/schema", name: "Schema"
def read(_uri, %{table: t}, _ctx), do: {:ok, ...}
def complete(:table, prefix, _ctx), do: {:ok, [...]}
def list(_ctx), do: {:ok, [%Types.Resource{...}]}

use Noizu.MCP.Server.Prompt, name: "review"
arguments do
  arg :code, required: true
  arg :style, complete: ["strict", "friendly"]
end
def get(%{"code" => code}, _ctx),
  do: {:ok, [Types.PromptMessage.user(code)]}

Notify from anywhere

MyApp.MCP.notify_resource_updated("config://app")
MyApp.MCP.notify_changed(:tools)   # :resources :prompts

Handler context (Noizu.MCP.Ctx)

Outbound

Ctx.report_progress(ctx, 0.5, total: 1.0, message: "...")
Ctx.info(ctx, "cache miss")          # debug..emergency
Ctx.log(ctx, :warning, %{...}, logger: "myapp")

State & cancellation

Ctx.assign(ctx, :key, v)        # this handler / init/2
Ctx.put_session(ctx, :key, v)   # future requests
ctx.assigns.auth_claims         # verified OAuth claims
Ctx.cancelled?(ctx)             # poll in long loops

Call the client back

{:ok, r} = Ctx.sample(ctx, %{"messages" => [...],
            "maxTokens" => 200}, timeout: 30_000)

case Ctx.elicit(ctx, "Proceed?", schema, timeout: 60_000) do
  {:ok, {:accept, fields}} -> ...
  {:ok, :decline} -> ...
  {:ok, :cancel} -> ...
end

{:ok, roots} = Ctx.list_roots(ctx)

Client

Connect

{Noizu.MCP.Client,
 name: MyApp.FS,
 transport: {:stdio, command: "npx", args: [...]},
 # transport: {:streamable_http, url: "https://...",
 #   auth: {Noizu.MCP.Auth.Static, token: t}},
 handler: MyApp.Handler,
 client_info: %{name: "myapp", version: "1.0"}}

:ok = Client.await_ready(MyApp.FS, 15_000)

Call

{:ok, tools} = Client.list_tools(c)
{:ok, r} = Client.call_tool(c, "name", %{"k" => "v"},
             timeout: 60_000, progress: fn p -> ... end)
{:ok, contents} = Client.read_resource(c, uri)
:ok = Client.subscribe_resource(c, uri)
{:ok, %{messages: m}} = Client.get_prompt(c, "name", %{})
{:ok, %{values: v}} = Client.complete(c, {:prompt, "name"}, "arg", "pre")
:ok = Client.set_log_level(c, :warning)
:ok = Client.set_roots(c, [%Types.Root{uri: "file:///w"}])

Async

ref = Client.async(c, "tools/call", %{...})
{:ok, r} = Client.await(c, ref, 5_000)
Client.cancel(c, ref, "reason")

Handler (sampling / elicitation)

@behaviour Noizu.MCP.Client.Handler
def handle_sampling(params, _s),
  do: {:ok, %{"role" => "assistant", "model" => "m",
        "content" => %{"type" => "text", "text" => "..."}}}
def handle_elicitation(_params, _s),
  do: {:ok, :accept, %{"confirm" => true}}
  # | {:ok, :decline} | {:ok, :cancel}
def handle_notification(method, params, _s), do: :ok

Testing

Connect & call

import Noizu.MCP.Test

client = connect(MyApp.MCP)             # async-safe
client = connect(MyApp.MCP, handler: StubHandler)

{:ok, r} = call_tool(client, "search", %{"q" => "x"})
{:ok, r} = request(client, "ping")

Notifications

params = assert_notification(client, "notifications/resources/updated")
params = assert_progress(client)
refute_notification(client, "notifications/progress")

Cancellation race

id = send_request(client, "tools/call", %{...})
cancel(client, id, "changed my mind")

OAuth 2.1

Server (resource server)

forward "/mcp", Noizu.MCP.Transport.StreamableHTTP.Plug,
  server: MyApp.MCP,
  auth: [verifier: {MyVerifier, []},
         resource_metadata: "https://.../.well-known/oauth-protected-resource"]

# behaviour:
def verify(token, _conn_info, _opts) do
  {:ok, claims}
  # | {:error, :invalid_token}            → 401
  # | {:error, :insufficient_scope, %{scope: "s"}}  → 403
end

Client

auth: {Noizu.MCP.Auth.Static, token: token}

auth: {Noizu.MCP.Auth.OAuth,
  client_id: "...", redirect_uri: "http://localhost:8914/cb",
  scope: "mcp", authorize_user: &MyApp.Browser.run/1}
# authorize_user.(url) → {:ok, %{"code" => c, "state" => s}}