├── test
├── test_helper.exs
├── support
│ ├── fixtures
│ │ ├── bar.png
│ │ ├── foo.png
│ │ ├── test.png
│ │ └── game-over.wav
│ ├── app
│ │ ├── router.ex
│ │ ├── endpoint.ex
│ │ ├── plug_router.ex
│ │ └── mcp
│ │ │ └── router.ex
│ └── dispatcher.ex
├── start.exs
└── phantom
│ ├── dynamic_tool_test.exs
│ ├── router_test.exs
│ └── plug_test.exs
├── .claude
└── settings.json
├── lib
├── phantom.ex
└── phantom
│ ├── utils.ex
│ ├── prompt_argument.ex
│ ├── plug_wrapper_error.ex
│ ├── tool_json_schema.ex
│ ├── resource_plug.ex
│ ├── tool_annotation.ex
│ ├── client_logger.ex
│ ├── resource_template.ex
│ ├── cache.ex
│ ├── elicit.ex
│ ├── request.ex
│ ├── resource.ex
│ ├── prompt.ex
│ ├── tracker.ex
│ ├── tool.ex
│ ├── session.ex
│ └── plug.ex
├── .formatter.exs
├── .gitignore
├── LICENSE
├── CHANGELOG.md
├── mix.exs
├── CLAUDE.md
├── mix.lock
└── README.md
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 |
--------------------------------------------------------------------------------
/test/support/fixtures/bar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbernheisel/phantom_mcp/HEAD/test/support/fixtures/bar.png
--------------------------------------------------------------------------------
/test/support/fixtures/foo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbernheisel/phantom_mcp/HEAD/test/support/fixtures/foo.png
--------------------------------------------------------------------------------
/test/support/fixtures/test.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbernheisel/phantom_mcp/HEAD/test/support/fixtures/test.png
--------------------------------------------------------------------------------
/test/support/fixtures/game-over.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dbernheisel/phantom_mcp/HEAD/test/support/fixtures/game-over.wav
--------------------------------------------------------------------------------
/.claude/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "mcpServers": {
3 | "tidewave": {
4 | "command": "mcp-proxy",
5 | "args": ["http://localhost:4000/tidewave/mcp"]
6 | }
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/lib/phantom.ex:
--------------------------------------------------------------------------------
1 | defmodule Phantom do
2 | @external_resource "README.md"
3 |
4 | @moduledoc @external_resource
5 | |> File.read!()
6 | |> String.split("")
7 | |> List.last()
8 | end
9 |
--------------------------------------------------------------------------------
/.formatter.exs:
--------------------------------------------------------------------------------
1 | locals_without_parens = [
2 | tool: 2,
3 | tool: 3,
4 | prompt: 2,
5 | prompt: 3,
6 | resource: 2,
7 | resource: 3,
8 | resource: 4
9 | ]
10 |
11 | [
12 | locals_without_parens: locals_without_parens,
13 | export: [locals_without_parens: locals_without_parens],
14 | import_deps: [:phoenix],
15 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
16 | ]
17 |
--------------------------------------------------------------------------------
/test/support/app/router.ex:
--------------------------------------------------------------------------------
1 | defmodule Test.Router do
2 | use Phoenix.Router, helpers: false
3 | import Plug.Conn
4 |
5 | pipeline :mcp do
6 | plug :accepts, ["json", "sse"]
7 | end
8 |
9 | scope "/mcp" do
10 | pipe_through :mcp
11 |
12 | forward "/", Phantom.Plug,
13 | router: Test.MCP.Router,
14 | pubsub: Test.PubSub,
15 | validate_origin: false
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/test/support/app/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule Test.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :phantom_mcp
3 |
4 | if Code.ensure_loaded?(Tidewave) do
5 | plug Tidewave
6 | end
7 |
8 | plug Plug.Parsers,
9 | parsers: [{:json, length: 1_000_000}],
10 | pass: ["application/json"],
11 | json_decoder: JSON
12 |
13 | plug Test.Router
14 | end
15 |
16 | defmodule Test.ErrorJSON do
17 | def render(template, _assigns) do
18 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/test/support/app/plug_router.ex:
--------------------------------------------------------------------------------
1 | defmodule Test.PlugRouter do
2 | use Plug.Router
3 |
4 | plug :match
5 |
6 | if Code.ensure_loaded?(Tidewave) do
7 | plug Tidewave
8 | end
9 |
10 | plug Plug.Parsers,
11 | parsers: [{:json, length: 1_000_000}],
12 | pass: ["application/json"],
13 | json_decoder: JSON
14 |
15 | plug :dispatch
16 |
17 | forward "/mcp",
18 | to: Phantom.Plug,
19 | init_opts: [
20 | validate_origin: false,
21 | pubsub: Test.PubSub,
22 | router: Test.MCP.Router
23 | ]
24 |
25 | match _ do
26 | send_resp(conn, 404, "Not found")
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where third-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Ignore package tarball (built via "mix hex.build").
23 | phantom_mcp-*.tar
24 |
25 | # Temporary files, for example, from tests.
26 | /tmp/
27 | /.claude/*.local.*
28 | .tool-versions
29 |
--------------------------------------------------------------------------------
/lib/phantom/utils.ex:
--------------------------------------------------------------------------------
1 | defmodule Phantom.Utils do
2 | @moduledoc false
3 |
4 | def remove_nils(map) do
5 | for {k, v} when not is_nil(v) <- map, into: %{}, do: {k, v}
6 | end
7 |
8 | @doc false
9 | def get_var(attrs, field, keypath, env, default \\ nil) do
10 | case attrs[field] do
11 | nil ->
12 | if not Macro.Env.has_var?(env, {:session, nil}) do
13 | raise "#{inspect(field)} was not supplied to the response, and to fetch the default from the specification, Phantom requires the variable named `session` to exist."
14 | end
15 |
16 | case keypath do
17 | [:spec, source] ->
18 | quote generated: true do
19 | Map.get(var!(session).request.spec, unquote(source), unquote(default))
20 | end
21 |
22 | [:params | keypath] ->
23 | quote generated: true do
24 | get_in(var!(session).request.params, unquote(keypath)) || unquote(default)
25 | end
26 | end
27 |
28 | ast ->
29 | ast
30 | end
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 David Bernheisel
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/lib/phantom/prompt_argument.ex:
--------------------------------------------------------------------------------
1 | defmodule Phantom.Prompt.Argument do
2 | @moduledoc """
3 |
4 | """
5 | import Phantom.Utils
6 |
7 | defstruct [:name, :description, required: false]
8 |
9 | @type t :: %__MODULE__{
10 | name: String.t(),
11 | description: String.t(),
12 | required: boolean()
13 | }
14 |
15 | @type json :: %{
16 | name: String.t(),
17 | description: String.t(),
18 | required: boolean()
19 | }
20 |
21 | @spec build(map() | Keyword.t()) :: t()
22 | @doc """
23 | Build a prompt argument spec
24 |
25 | When building a prompt with `Phantom.Prompt.build/1`, arguments will
26 | be built automatically.
27 | """
28 | def build(attrs), do: struct!(__MODULE__, attrs)
29 |
30 | @spec to_json(t()) :: json()
31 | @doc """
32 | Represent a Prompt argument spec as json when listing the available prompts to clients.
33 | """
34 | def to_json(%__MODULE__{} = argument) do
35 | remove_nils(%{
36 | name: argument.name,
37 | description: argument.description,
38 | required: argument.required
39 | })
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/phantom/plug_wrapper_error.ex:
--------------------------------------------------------------------------------
1 | defmodule Phantom.ErrorWrapper do
2 | @moduledoc """
3 | Wraps errors that occur during a request or batch or requests.
4 | This allows the connection to finish, and then reraises with this error
5 | containing the exceptions by request.
6 | """
7 |
8 | defexception [:message, :exceptions_by_request]
9 |
10 | def new(message, exceptions_by_request) do
11 | %__MODULE__{
12 | exceptions_by_request: exceptions_by_request,
13 | message:
14 | message <>
15 | "\n\n" <>
16 | Enum.map_join(exceptions_by_request, "\n\n", fn {request, exception, stacktrace} ->
17 | exception = unwrap_plug_wrapper(exception)
18 |
19 | """
20 | Error:
21 | #{inspect(exception)}
22 |
23 | Request:
24 | #{inspect(request)}
25 |
26 | Stacktrace:
27 | #{Exception.format_stacktrace(stacktrace)}
28 | """
29 | end)
30 | }
31 | end
32 |
33 | defp unwrap_plug_wrapper(%Plug.Conn.WrapperError{} = error) do
34 | Exception.normalize(error.kind, error.reason, error.stack)
35 | end
36 |
37 | defp unwrap_plug_wrapper(error), do: error
38 | end
39 |
--------------------------------------------------------------------------------
/lib/phantom/tool_json_schema.ex:
--------------------------------------------------------------------------------
1 | defmodule Phantom.Tool.JSONSchema do
2 | @moduledoc """
3 | JSON Schema representing the arguments for the tool, either as `input_schema`
4 | or `output_schema`.
5 |
6 | Learn more at https://json-schema.org/learn/getting-started-step-by-step
7 |
8 | Example:
9 |
10 | %{
11 | type: "object",
12 | properties: %{
13 | productId: %{
14 | description: "The unique identifier for a product",
15 | type: "integer"
16 | },
17 | productName: %{
18 | description: "Name of the product",
19 | type: "string"
20 | }
21 | }
22 | }
23 |
24 | """
25 | import Phantom.Utils
26 |
27 | @type t :: %__MODULE__{
28 | required: boolean(),
29 | type: String.t(),
30 | properties: map()
31 | }
32 |
33 | @type json :: %{
34 | required(:required) => boolean(),
35 | required(:type) => String.t(),
36 | required(:properties) => map()
37 | }
38 |
39 | defstruct required: [], type: "object", properties: %{}
40 |
41 | def build(nil), do: nil
42 | def build(attrs), do: struct!(__MODULE__, attrs)
43 |
44 | def to_json(nil), do: %{required: [], type: "object", properties: %{}}
45 |
46 | def to_json(%__MODULE__{} = json_schema) do
47 | remove_nils(%{
48 | required: json_schema.required,
49 | type: json_schema.type,
50 | properties: json_schema.properties
51 | })
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/lib/phantom/resource_plug.ex:
--------------------------------------------------------------------------------
1 | defmodule Phantom.ResourcePlug do
2 | @moduledoc false
3 |
4 | @behaviour Plug
5 | import Plug.Conn
6 |
7 | alias Phantom.Request
8 | alias Phantom.Resource
9 | alias Phantom.Session
10 |
11 | @impl Plug
12 | def init(opts), do: opts
13 |
14 | @impl Plug
15 | def call(fake_conn, _opts) do
16 | session = %{
17 | fake_conn.assigns.session
18 | | request: %{fake_conn.assigns.session.request | spec: fake_conn.assigns.resource_template}
19 | }
20 |
21 | result =
22 | try do
23 | apply(
24 | fake_conn.assigns.resource_template.handler,
25 | fake_conn.assigns.resource_template.function,
26 | [fake_conn.path_params, session]
27 | )
28 | rescue
29 | _e in FunctionClauseError ->
30 | {:error, Phantom.Request.resource_not_found(%{uri: fake_conn.assigns.uri}),
31 | fake_conn.assigns.session}
32 | end
33 |
34 | assign(
35 | fake_conn,
36 | :result,
37 | wrap(result, fake_conn.assigns.uri, fake_conn.assigns.session)
38 | )
39 | end
40 |
41 | defp wrap({:error, reason}, _uri, session) do
42 | {:error, reason, session}
43 | end
44 |
45 | defp wrap({:error, _reason, %Session{}} = result, _uri, _session), do: result
46 |
47 | defp wrap(nil, uri, session) do
48 | {:error, Request.resource_not_found(%{uri: uri}), session}
49 | end
50 |
51 | defp wrap({:noreply, %Session{}} = result, _uri, _session), do: result
52 |
53 | defp wrap({:reply, nil, %Session{} = session}, uri, _session) do
54 | {:error, Request.resource_not_found(%{uri: uri}), session}
55 | end
56 |
57 | defp wrap({:reply, results, %Session{} = session}, _uri, _session) do
58 | {:reply, Resource.response(results), session}
59 | end
60 |
61 | defmodule NotFound do
62 | @moduledoc false
63 |
64 | @behaviour Plug
65 | import Plug.Conn
66 |
67 | def init(opts), do: opts
68 |
69 | def call(conn, _opts) do
70 | result = Phantom.Request.resource_not_found(%{uri: conn.assigns.uri})
71 | assign(conn, :result, {:reply, result, conn.assigns.session})
72 | end
73 | end
74 | end
75 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## 0.3.2
2 |
3 | - Fix error message referring to wrong arity.
4 | - Allow nil origin when Plug options is set to `origins: :all`.
5 | - Better error handling when `Phantom.Tracker` is not in the supervision tree. Phantom.MCP will now emit a Logger warning when Phantom.Tracker can be used, but is not in the supervision tree.
6 | - Fix terminate bug introduced in 0.3.1
7 |
8 | ## 0.3.1
9 |
10 | - Add `[:phantom, :plug, :request, :terminate]` telemetry event.
11 | - Improve docs
12 |
13 | ## 0.3.0
14 |
15 | - Move logging functions from `Phantom.Session` into `Phantom.ClientLogger`.
16 | - Rename `Phantom.Tracker` functions to be clearer and more straightforward.
17 | - Consolidate distributed logic into `Phantom.Tracker` such as PubSub topics.
18 | - Add ability to add tools, prompts, resources in runtime easily. You can call
19 | `Phantom.Cache.add_tool(router_module, tool_spec)`. The spec can be built with
20 | `Phantom.Tool.build/1`, the function takes a very similar shape to the corresponding macro from `Phantom.MCP.Router`. This will also trigger notifications to clients of tool or prompt list updates.
21 | - Handle paginatin for 100+ tools and prompts.
22 | - Change `connect/2` callback to receive request headers and query params from the Plug adapter. The signature is now `%{headers: list({header, value}), params: map()}` where before it was just `list({header, value})`.
23 | - `Phantom.Tool.build`, `Phantom.Prompt.build` and `Phantom.ResourceTemplate.build` now do more and the `Phantom.Router` macros do less. This is so runtime can have a consistent experience with compiled declarations. For example, you may `Phantom.ResourceTemplate.build(...)` with the same arguments as you would with the router macros, and then call `Phantom.Cache.add_resource_template(...)` and have the same affect as using the `resource ...` macro in a `Phantom.Router` router.
24 | - Fixed building tool annontations.
25 | - Fixed resource subscription response and implemented unsubscribe method.
26 | - Improve documentation
27 |
28 | ## 0.2.3
29 |
30 | - Fix the `initialize` request status code and headers. In 0.2.2 it worked
31 | with mcp-inspector but not with Claude Desktop or Zed. Now it works with all.
32 |
33 | ## 0.2.2
34 |
35 | - Fix the `initialize` request. It should have kept the SSE stream open.
36 | - Fix bugs
37 |
38 | ## 0.2.1
39 |
40 | - Fix default `list_resources/2` callback and default implementation.
41 |
42 | ## 0.2.0
43 |
44 | Phantom MCP released!
45 |
--------------------------------------------------------------------------------
/lib/phantom/tool_annotation.ex:
--------------------------------------------------------------------------------
1 | defmodule Phantom.Tool.Annotation do
2 | @moduledoc """
3 | Tool annotations provide additional metadata about a
4 | tool’s behavior, helping clients understand how to present
5 | and manage tools. These annotations are hints that describe
6 | the nature and impact of a tool, but should not be relied
7 | upon for security decisions
8 |
9 | - `:title` A human-readable title for the tool, useful for UI display
10 | - `:read_only_hint` If true, indicates the tool does not modify its environment
11 | - `:destructive_hint` If true, the tool may perform destructive updates (only meaningful when `:read_only_hint` is false)
12 | - `:idempotent_hint` If true, calling the tool repeatedly with the same arguments has no additional effect (only meaningful when readOnlyHint is false)
13 | - `:open_world_hint` If true, the tool may interact with an “open world” of external entities
14 |
15 | https://modelcontextprotocol.io/docs/concepts/tools#tool-annotations
16 | """
17 |
18 | import Phantom.Utils
19 |
20 | defstruct [
21 | :title,
22 | :idempotent_hint,
23 | :destructive_hint,
24 | :read_only_hint,
25 | :open_world_hint
26 | ]
27 |
28 | @type t :: %__MODULE__{
29 | title: String.t(),
30 | idempotent_hint: boolean(),
31 | destructive_hint: boolean(),
32 | read_only_hint: boolean(),
33 | open_world_hint: boolean()
34 | }
35 |
36 | @type json :: %{
37 | optional(:title) => String.t(),
38 | optional(:idempotentHint) => boolean(),
39 | optional(:destructiveHint) => boolean(),
40 | optional(:readOnlyHint) => boolean(),
41 | optional(:openWorldHint) => boolean()
42 | }
43 |
44 | def build(attrs \\ []) do
45 | attrs =
46 | Enum.reduce(attrs, %{}, fn
47 | {:idempotent, v}, acc -> Map.put(acc, :idempotent_hint, v)
48 | {:destructive, v}, acc -> Map.put(acc, :destructive_hint, v)
49 | {:read_only, v}, acc -> Map.put(acc, :read_only_hint, v)
50 | {:open_world, v}, acc -> Map.put(acc, :open_world_hint, v)
51 | {k, v}, acc -> Map.put(acc, k, v)
52 | end)
53 |
54 | struct!(__MODULE__, attrs)
55 | end
56 |
57 | def to_json(%__MODULE__{} = annotation) do
58 | result =
59 | remove_nils(%{
60 | title: annotation.title,
61 | idempotentHint: annotation.idempotent_hint,
62 | destructiveHint: annotation.destructive_hint,
63 | readOnlyHint: annotation.read_only_hint,
64 | openWorldHint: annotation.open_world_hint
65 | })
66 |
67 | if map_size(result) == 0, do: nil, else: result
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Phantom.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | aliases: aliases(),
7 | app: :phantom_mcp,
8 | description: "Elixir MCP (Model Context Protocol) server library with Plug",
9 | deps: deps(),
10 | docs: docs(),
11 | elixir: "~> 1.18",
12 | elixirc_paths: elixirc_paths(Mix.env()),
13 | package: package(),
14 | start_permanent: Mix.env() == :prod,
15 | version: "0.3.2",
16 | source_url: "https://github.com/dbernheisel/phantom_mcp"
17 | ]
18 | end
19 |
20 | def cli do
21 | [
22 | preferred_envs: [format: :test, dialyzer: :test]
23 | ]
24 | end
25 |
26 | def application do
27 | [
28 | extra_applications: [:logger]
29 | ]
30 | end
31 |
32 | defp elixirc_paths(:test), do: ["lib", "test/support"]
33 | defp elixirc_paths(_), do: ["lib"]
34 |
35 | defp deps do
36 | [
37 | {:plug, "~> 1.0"},
38 | {:telemetry, "~> 1.0"},
39 | {:phoenix_pubsub, "~> 2.0", optional: true},
40 | {:uuidv7, "~> 1.0"},
41 | ## Test
42 | {:phoenix, "~> 1.7", only: [:test]},
43 | {:ex_doc, "~> 0.31", only: :dev, runtime: false},
44 | {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}
45 | ]
46 | end
47 |
48 | defp package do
49 | [
50 | name: :phantom_mcp,
51 | maintainers: ["David Bernheisel"],
52 | licenses: ["MIT"],
53 | links: %{
54 | "MCP Specification" => "https://modelcontextprotocol.io",
55 | "MCP Inspector" => "https://github.com/modelcontextprotocol/inspector",
56 | "GitHub" => "https://github.com/dbernheisel/phantom_mcp"
57 | }
58 | ]
59 | end
60 |
61 | @mermaidjs """
62 |
63 |
89 | """
90 |
91 | defp docs do
92 | [
93 | main: "Phantom",
94 | extras: ~w[CHANGELOG.md],
95 | before_closing_body_tag: %{html: @mermaidjs}
96 | ]
97 | end
98 |
99 | defp aliases do
100 | []
101 | end
102 | end
103 |
--------------------------------------------------------------------------------
/test/start.exs:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env iex
2 | Application.put_env(:phoenix, :json_library, JSON)
3 | Application.put_env(:phoenix, :plug_init_mode, :runtime)
4 | Application.put_env(:phoenix, :serve_endpoints, true, persistent: true)
5 |
6 | Application.put_env(:phantom_mcp, :timeout, 1000)
7 | Application.put_env(:phantom_mcp, :debug, true)
8 |
9 | Application.put_env(:phantom_mcp, Test.Endpoint,
10 | url: [host: "localhost"],
11 | adapter: Bandit.PhoenixAdapter,
12 | render_errors: [
13 | formats: [sse: Test.ErrorJSON, json: Test.ErrorJSON],
14 | layout: false
15 | ],
16 | pubsub_server: Test.PubSub,
17 | code_reloader: true,
18 | http: [ip: {127, 0, 0, 1}, port: 4000],
19 | server: true,
20 | secret_key_base: String.duplicate("a", 64)
21 | )
22 |
23 | Mix.install(
24 | [
25 | {:plug_cowboy, "~> 2.7"},
26 | {:bandit, "~> 1.7"},
27 | {:tidewave, "~> 0.1.9"},
28 | {:phoenix, "~> 1.7"},
29 | {:phantom_mcp, path: "."}
30 | ],
31 | config: [
32 | mime: [
33 | types: %{
34 | "text/event-stream" => ["sse"]
35 | }
36 | ]
37 | ]
38 | )
39 |
40 | Enum.each(
41 | ~w[
42 | test/support/app/mcp/router.ex
43 | test/support/app/router.ex
44 | test/support/app/endpoint.ex
45 | test/support/app/plug_router.ex
46 | ],
47 | &Code.require_file(&1, File.cwd!())
48 | )
49 |
50 | defmodule SessionChecker do
51 | use GenServer
52 |
53 | def start_link(opts), do: GenServer.start_link(__MODULE__, opts)
54 |
55 | def init(state) do
56 | Process.send_after(self(), :check, 5000)
57 | {:ok, state}
58 | end
59 |
60 | def handle_info(:check, state) do
61 | case Phantom.Tracker.list_sessions() do
62 | [] ->
63 | :ok
64 |
65 | sessions ->
66 | sessions
67 | |> Enum.flat_map(fn {session_id, meta} ->
68 | if pid = Phantom.Tracker.get_session(session_id) do
69 | [{session_id, pid, Process.alive?(pid), meta}]
70 | else
71 | []
72 | end
73 | end)
74 | |> tap(&if &1 != [], do: IO.inspect(&1, label: "SESSIONS"))
75 | end
76 |
77 | case Phantom.Tracker.list_requests() do
78 | [] ->
79 | :ok
80 |
81 | requests ->
82 | requests
83 | |> Enum.flat_map(fn {request_id, meta} ->
84 | if pid = Phantom.Tracker.get_request(request_id) do
85 | [{request_id, pid, Process.alive?(pid), meta}]
86 | else
87 | []
88 | end
89 | end)
90 | |> tap(&if &1 != [], do: IO.inspect(&1, label: "REQUESTS"))
91 | end
92 |
93 | case Phantom.Tracker.list_resource_listeners() do
94 | [] ->
95 | :ok
96 |
97 | uris ->
98 | uris
99 | |> Enum.map(fn {uri, _meta} -> uri end)
100 | |> IO.inspect(label: "RESOURCE SUBSCRIPTIONS")
101 | end
102 |
103 | Process.send_after(self(), :check, 10_000)
104 | {:noreply, state}
105 | end
106 | end
107 |
108 | {:ok, _} =
109 | Supervisor.start_link(
110 | [
111 | {Phoenix.PubSub, name: Test.PubSub},
112 | {Phantom.Tracker, [name: Phantom.Tracker, pubsub_server: Test.PubSub]},
113 | {Plug.Cowboy, scheme: :http, plug: Test.PlugRouter, port: 4001},
114 | Test.Endpoint,
115 | SessionChecker
116 | ],
117 | strategy: :one_for_one
118 | )
119 |
120 | IEx.Server.run(env: __ENV__, binding: binding(), register: false)
121 |
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | ## Project Overview
2 |
3 | This is an Elixir library that implements the Model Context Protocol (MCP)
4 | server specification using Plug. It provides a complete MCP server
5 | implementation with JSON-RPC 2.0 support, SSE streaming, session management,
6 | and security features. See @README.md
7 |
8 | ## Development Commands
9 |
10 | ### Dependencies and Setup
11 | - `mix deps.get` - Install dependencies
12 | - `mix deps.compile` - Compile dependencies
13 |
14 | ### Testing
15 | - `mix test` - Run all tests
16 | - `mix test test/specific_test.exs` - Run a specific test file
17 | - `mix test --trace` - Run tests with detailed output
18 |
19 | ### Code Quality
20 | - `mix format` - Format code according to Elixir style guidelines.
21 | - `mix compile --warnings-as-errors` - Compile with strict warnings
22 |
23 | ### Documentation
24 | - `mix docs` - Generate documentation with ExDoc.
25 | - `h {Module}` - Lookup documentation in IEX for the module.
26 | - `h {Module.function}` - Lookup documentation in IEX for a function
27 |
28 | When getting documentation on an Elixir function, lookup both the module
29 | and the function to get more context on its appropriate usage.
30 |
31 | ### Development Server
32 | - `iex -S mix` - Start a server and IEX session
33 |
34 | ## Architecture
35 |
36 | The library is organized into several key modules:
37 |
38 | ### Core Protocol (`lib/phantom/`)
39 | - `Phantom.Request` - JSON-RPC 2.0 message parsing, encoding, and validation
40 | - `Phantom.Cache` - Manages `:persistent_term` that loads available MCP
41 | tools, prompts, and resources defined at compile time and added or removed
42 | at runtime.
43 | - `Phantom.Session` - Session management with timeouts and data storage
44 | - `Phantom.Router` - Behaviour for implementing MCP methods
45 | - `Phantom.Plug` - Main Plug implementation for HTTP transport with SSE support
46 |
47 | ### Test Structure (`test/`)
48 | - Comprehensive test coverage for all modules
49 | - Integration tests for HTTP requests including stateful SSE connections with
50 | sessions
51 | - Handler behavior testing
52 | - Protocol compliance verification
53 |
54 | Session Management is optional and should use Elixir's Supervisor and have each
55 | MCP session be its own GenServer supervised by a DynamicSupervisor. Elixir can
56 | have many remote nodes with MCP Sessions on another node, so use Phoenix.Tracker
57 | from the hex package phoenix_pubsub to allow Session management to work across
58 | nodes transparently. It should be inspired by Phoenix.Socket which can be found
59 | with the Tidewave MCP tool project_eval with `h Phoenix.Socket`.
60 |
61 | The Handler provides an interface like Plug.Router with new macros that compiles
62 | tools, resources, and prompts that are available, however these should be
63 | configurable in runtime as well. The syntax should be inspired by
64 | `Ecto.Schema` which can be found with the Tidewave MCP tool project_eval with
65 | `h Ecto.Schema`.
66 |
67 | The MCP.Server is optional and designed for extending non-web applications to be
68 | exposed via a slim Bandit and Plug server. Normal usage of Phantom should be
69 | through Phoenix applications that already have a web server.
70 |
71 | The MCP.Console is optional and designed for using the MCP server locally only.
72 |
73 | You write code using Test-Driven-Development method, which means you write the tests first, run the tests to verify they fail,
74 | and then write the code to make the tests pass. Finally you run `mix format`
75 | when everything is done.
76 |
77 | ## Key Features Implemented
78 |
79 | 1. **MCP Specification Compliance**: Full JSON-RPC 2.0 with streamable HTTP transport
80 | 2. **Plug Integration**: First-class Plug support
81 | 3. **Security**: Origin validation, CORS handling, localhost binding, session management
82 | 4. **Performance**: Bandit HTTP server, efficient message handling
83 | 5. **Configuration**: Application config, environment-specific settings, runtime configuration
84 | 6. **Testing**: Complete test suite with Phoenix integration tests
85 |
86 | ## Phoenix Integration Features
87 |
88 | - **Phantom.Plug**: Dedicated Plug with CORS and authentication support
89 | - **Multiple Integration Patterns**: Standalone server, Phoenix Endpoint integration
90 | - **Security**: Origin validation, remote access control, preflight request handling
91 |
--------------------------------------------------------------------------------
/lib/phantom/client_logger.ex:
--------------------------------------------------------------------------------
1 | defmodule Phantom.ClientLogger do
2 | @moduledoc """
3 | Notify the client of logs.
4 | """
5 |
6 | alias Phantom.Session
7 |
8 | @type log_level ::
9 | :emergency | :alert | :critical | :error | :warning | :notice | :info | :debug
10 |
11 | @log_grades [
12 | emergency: 1,
13 | alert: 2,
14 | critical: 3,
15 | error: 4,
16 | warning: 5,
17 | notice: 6,
18 | info: 7,
19 | debug: 8
20 | ]
21 | @log_levels Keyword.keys(@log_grades)
22 |
23 | @doc false
24 | def log_levels, do: @log_grades
25 |
26 | @doc false
27 | def do_log(%Session{pubsub: nil}, _level_num, _name, _domain, _payload), do: :ok
28 |
29 | def do_log(%Session{pid: pid, id: id}, level_num, level_name, domain, payload) do
30 | payload = if is_binary(payload), do: %{message: payload}, else: payload
31 | pid = Phantom.Tracker.get_session(id) || pid
32 | GenServer.cast(pid, {:log, level_num, level_name, domain, payload})
33 | end
34 |
35 | @doc "Notify the client for the provided session and domain at level with a payload"
36 | def log(%Session{} = session, level_name, payload, domain)
37 | when level_name in @log_levels do
38 | do_log(session, Keyword.fetch!(log_levels(), level_name), level_name, domain, payload)
39 | end
40 |
41 | @doc """
42 | Notify the client at the provided level for domain with the payload.
43 |
44 | Note: this requires the `session` to be within scope.
45 | """
46 | defmacro log(log_level, payload, domain) when log_level in @log_levels do
47 | if not Macro.Env.has_var?(__CALLER__, {:session, nil}) do
48 | raise """
49 | session was not supplied to `log`. To send a log, either
50 | use log/3 or log/4 and supply the session, or have the session available
51 | in the scope
52 | """
53 | end
54 |
55 | quote bind_quoted: [log_level: log_level, domain: domain, payload: payload],
56 | generated: true do
57 | Phantom.ClientLogger.do_log(
58 | var!(session),
59 | Map.fetch!(log_levels(), log_name),
60 | log_level,
61 | domain,
62 | payload
63 | )
64 | end
65 | end
66 |
67 | @doc """
68 | Notify the client with a log at the provided level with the provided domain.
69 |
70 | The log contents may be structured (eg, a map) or not. If not, it will be
71 | wrapped into one: `%{message: your_string}`.
72 |
73 | Note: this requires the `session` variable to be within scope
74 | """
75 | defmacro log(log_level, payload) when log_level in @log_levels do
76 | quote do
77 | Phantom.ClientLogger.do_log(
78 | var!(session),
79 | Map.fetch!(log_levels(), unquote(log_level)),
80 | log_level,
81 | "server",
82 | unquote(payload)
83 | )
84 | end
85 | end
86 |
87 | for {name, level} <- @log_grades do
88 | @doc "Notify the client with a log at level \"#{name}\""
89 | @spec unquote(name)(Session.t(), structured_log :: map(), domain :: String.t()) ::
90 | :ok
91 | def unquote(name)(%Session{} = session, payload, domain) do
92 | quote bind_quoted: [
93 | level: unquote(level),
94 | name: unquote(name),
95 | domain: domain,
96 | session: session,
97 | payload: payload
98 | ],
99 | generated: true do
100 | Phantom.ClientLogger.do_log(session, level, name, domain, payload)
101 | end
102 | end
103 |
104 | @doc """
105 | Notify the client with a log at level \"#{name}\" with default domain "server".
106 | Note: this requires the `session` variable to be within scope
107 | """
108 | @spec unquote(name)(structured_log :: map(), domain :: String.t()) :: :ok
109 | defmacro unquote(name)(payload, domain \\ "server") do
110 | if not Macro.Env.has_var?(__CALLER__, {:session, nil}) do
111 | raise """
112 | session was not supplied to `log_#{unquote(name)}`. To send a log, either
113 | use log_#{unquote(name)}/4 and supply the session, or have the session available
114 | in the scope
115 | """
116 | end
117 |
118 | quote bind_quoted: [
119 | level: unquote(level),
120 | name: unquote(name),
121 | domain: domain,
122 | payload: payload
123 | ],
124 | generated: true do
125 | Phantom.ClientLogger.do_log(var!(session), level, name, domain, payload)
126 | end
127 | end
128 | end
129 | end
130 |
--------------------------------------------------------------------------------
/lib/phantom/resource_template.ex:
--------------------------------------------------------------------------------
1 | defmodule Phantom.ResourceTemplate do
2 | @moduledoc """
3 | The Model Context Protocol (MCP) provides a standardized way for
4 | servers to expose resources to clients. Resources allow servers to
5 | share data that provides context to language models, such as files,
6 | database schemas, or application-specific information. Each resource
7 | is uniquely identified by a URI.
8 |
9 | https://modelcontextprotocol.io/specification/2025-03-26/server/resources
10 | """
11 |
12 | import Phantom.Utils
13 |
14 | @enforce_keys ~w[name handler function path router scheme uri uri_template]a
15 | defstruct [
16 | :name,
17 | :description,
18 | :function,
19 | :completion_function,
20 | :handler,
21 | :mime_type,
22 | :path,
23 | :router,
24 | :scheme,
25 | :size,
26 | :uri,
27 | :uri_template,
28 | meta: %{}
29 | ]
30 |
31 | @type t :: %__MODULE__{
32 | name: String.t(),
33 | description: String.t(),
34 | function: atom(),
35 | completion_function: atom(),
36 | handler: module(),
37 | mime_type: String.t(),
38 | path: String.t(),
39 | router: module(),
40 | scheme: String.t(),
41 | size: pos_integer(),
42 | meta: map(),
43 | uri: URI.t(),
44 | uri_template: String.t()
45 | }
46 |
47 | @type json :: %{
48 | required(:uri) => String.t(),
49 | required(:name) => String.t(),
50 | optional(:description) => String.t(),
51 | optional(:mimeType) => String.t(),
52 | optional(:size) => pos_integer()
53 | }
54 |
55 | @spec build(map() | Keyword.t()) :: t()
56 | @doc """
57 | Build a resource_template spec
58 |
59 | The `Phantom.Router.resource/3` macro will build these specs.
60 |
61 | - `:name` - The name of the resource template.
62 | - `:uri` - The URI template of the resource in the style of `Plug.Router`, including the scheme.
63 | For example, you can define a path like `"myapp:///some/path/:project_id/:id` which
64 | will be parsed to include path params `%{"project_id" => _, "id" => _}`. The scheme can be
65 | `"https"`, `"git"`, `"file"`, or custom, eg `"myapp"`.
66 | - `:description` - The description of the resource and when to use it.
67 | - `:handler` - The module to call.
68 | - `:function` - The function to call on the handler module.
69 | - `:completion_function` - The function to call on the handler module that will provide possible completion results.
70 | - `:mime_type` - the MIME type of the results.
71 | - `:router` - The Router module that will capture the URIs and route resources by URI to functions.
72 | This is constructed by the `Phantom.Router.resource/3` macro automatically as
73 | `MyApp.MyMCPRouter.ResourceRouter.{Scheme}`. The module does not need to exist at the time of
74 | building it-- it will be generated when added by `Phantom.Cache.add_resource_template/2`
75 | or by the `Phantom.Router.resource/3` macro.
76 |
77 | """
78 | def build(attrs) do
79 | uri =
80 | case attrs[:uri] do
81 | %URI{} = uri -> {:ok, uri}
82 | uri when is_binary(uri) -> URI.new(uri)
83 | end
84 |
85 | uri =
86 | case uri do
87 | {:ok, %{path: path, scheme: scheme} = uri}
88 | when is_binary(path) and is_binary(scheme) ->
89 | uri
90 |
91 | {:ok, uri} ->
92 | raise "Provided an invalid URI.\nResource URIs must contain a path and a scheme.\nProvided: #{URI.to_string(uri)}"
93 |
94 | {:error, invalid} ->
95 | raise "Provided an invalid URI.\nProvided: #{inspect(attrs[:uri])}\nError at: #{inspect(invalid)}"
96 | end
97 |
98 | struct!(
99 | __MODULE__,
100 | attrs
101 | |> Map.new()
102 | |> Map.merge(%{
103 | name: attrs[:name] || to_string(attrs[:function]),
104 | scheme: attrs[:scheme] || uri.scheme,
105 | path: attrs[:path] || uri.path,
106 | uri_template: "#{uri.scheme}://#{to_uri_6570(uri.path)}"
107 | })
108 | )
109 | end
110 |
111 | @spec to_json(t()) :: json()
112 | @doc """
113 | Represent a ResourceTemplate spec as json when listing the available resources to clients.
114 | """
115 | def to_json(%__MODULE__{} = resource) do
116 | remove_nils(%{
117 | uriTemplate: resource.uri_template,
118 | name: resource.name,
119 | size: resource.size,
120 | description: resource.description,
121 | mimeType: resource.mime_type
122 | })
123 | end
124 |
125 | defp to_uri_6570(str) do
126 | # this is not a total 6570-compliant URI template.
127 | String.replace(str, ~r/:\w*/, fn ":" <> var -> "{#{var}}" end)
128 | end
129 | end
130 |
--------------------------------------------------------------------------------
/lib/phantom/cache.ex:
--------------------------------------------------------------------------------
1 | defmodule Phantom.Cache do
2 | @moduledoc """
3 | Storage for tooling specifications. The backend is `:persistent_term`.
4 |
5 | You typically won't need to interact with `Phantom.Cache` directly, but
6 | if you're registering new tooling within the runtime, then you may want
7 | to use the functions herein.
8 | """
9 |
10 | @doc """
11 | Initialize the cache with compiled tooling for the given router.
12 |
13 | You likely don't need to call this yourself, as `Phantom.Plug` will call it if needed.
14 | """
15 | def register(router) do
16 | if not :persistent_term.get({Phantom, router, :initialized}, false) do
17 | info = router.__phantom__(:info)
18 |
19 | compiled_resource_templates =
20 | info |> Map.get(:resource_templates, []) |> Enum.sort_by(& &1.name)
21 |
22 | compiled_prompts = info |> Map.get(:prompts, []) |> Enum.sort_by(& &1.name)
23 | compiled_tools = info |> Map.get(:tools, []) |> Enum.sort_by(& &1.name)
24 |
25 | :persistent_term.put({Phantom, router, :tools}, compiled_tools)
26 | :persistent_term.put({Phantom, router, :prompts}, compiled_prompts)
27 | :persistent_term.put({Phantom, router, :resource_templates}, compiled_resource_templates)
28 | :persistent_term.put({Phantom, router, :initialized}, true)
29 | end
30 |
31 | :ok
32 | end
33 |
34 | @doc "Add an MCP Tool for the given router."
35 | def add_tool(router, tool_spec) do
36 | tools = tool_spec |> List.wrap() |> Enum.map(&Phantom.Tool.build/1)
37 | existing = :persistent_term.get({Phantom, router, :tools}, [])
38 | tools = Enum.sort_by(Enum.uniq(tools ++ existing), & &1.name)
39 | validate!(tools)
40 | raise_if_duplicates(tools)
41 | Phantom.Tracker.notify_tool_list()
42 | :persistent_term.put({Phantom, router, :tools}, tools)
43 | end
44 |
45 | @doc "Add an MCP Prompt for the given router."
46 | def add_prompt(router, prompt_spec) do
47 | prompts = prompt_spec |> List.wrap() |> Enum.map(&Phantom.Prompt.build/1)
48 | existing = :persistent_term.get({Phantom, router, :prompts}, [])
49 | prompts = Enum.sort_by(Enum.uniq(prompts ++ existing), & &1.name)
50 | validate!(prompts)
51 | raise_if_duplicates(prompts)
52 | Phantom.Tracker.notify_prompt_list()
53 | :persistent_term.put({Phantom, router, :prompts}, prompts)
54 | end
55 |
56 | @doc """
57 | Add an MCP Resource Template for the given router.
58 |
59 | This will also purge and generate a ResourceRouter module for each scheme
60 | provided.
61 | """
62 | defmacro add_resource_template(router, resource_template_spec) do
63 | resource_templates =
64 | resource_template_spec |> List.wrap() |> Enum.map(&Phantom.ResourceTemplate.build/1)
65 |
66 | existing = :persistent_term.get({Phantom, router, :resource_templates}, [])
67 |
68 | resource_templates =
69 | Enum.sort_by(Enum.uniq(resource_templates ++ existing), &{&1.scheme, &1.name})
70 |
71 | validate!(resource_templates)
72 | raise_if_duplicates(resource_templates)
73 | :persistent_term.put({Phantom, router, :resource_templates}, resource_templates)
74 | require Phantom.Router
75 | Phantom.Router.__create_resource_routers__(resource_templates, __CALLER__)
76 | end
77 |
78 | @doc """
79 | List all the entities for the given type.
80 | """
81 | @spec list(Session.t() | nil, module(), :tools | :prompts | :resource_templates) ::
82 | list(Phantom.Tool.t() | Phantom.Prompt.t() | Phantom.ResourceTemplate.t())
83 | def list(nil, module, type) do
84 | :persistent_term.get({Phantom, module, type}, [])
85 | end
86 |
87 | def list(session, module, type) do
88 | available = :persistent_term.get({Phantom, module, type}, [])
89 |
90 | case Map.get(session, :"allowed_#{type}") do
91 | nil -> available
92 | authorized -> Enum.filter(available, &(&1.name in authorized))
93 | end
94 | end
95 |
96 | @doc false
97 | def initialized?(router) do
98 | :persistent_term.get({__MODULE__, router, :initialized}, false) == true
99 | end
100 |
101 | @doc false
102 | def validate!(entities) do
103 | Enum.each(entities, fn %mod{handler: handler, function: function} = entity ->
104 | Code.ensure_loaded!(handler)
105 |
106 | if not function_exported?(handler, function, 2) do
107 | func = mod |> to_string() |> String.split(".") |> List.last() |> Macro.underscore()
108 | file = Path.relative_to_cwd(entity.meta.file)
109 |
110 | raise "#{func} was defined in #{file}:#{entity.meta.line} to call #{inspect(handler)}.#{function}/2 but that module and function does not exist."
111 | end
112 | end)
113 | end
114 |
115 | @doc false
116 | def raise_if_duplicates([]), do: :ok
117 |
118 | def raise_if_duplicates([%mod{} | _] = entities) do
119 | entities
120 | |> Enum.group_by(& &1.name)
121 | |> Enum.each(fn
122 | {_name, [_entity]} ->
123 | :ok
124 |
125 | {name, entities} ->
126 | entity = mod |> to_string() |> String.split(".") |> List.last() |> Macro.underscore()
127 |
128 | raise """
129 | There are conflicting #{entity}s with the name #{inspect(name)}.
130 | Please distinguish them by providing a `:name` option.
131 |
132 | #{inspect(Enum.map(entities, &{&1.handler, &1.function}), pretty: true)}
133 | """
134 | end)
135 | end
136 | end
137 |
--------------------------------------------------------------------------------
/lib/phantom/elicit.ex:
--------------------------------------------------------------------------------
1 | defmodule Phantom.Elicit do
2 | @moduledoc """
3 | The Model Context Protocol (MCP) provides a standardized way for
4 | servers to request additional information from users through the
5 | client during interactions. This flow allows clients to maintain
6 | control over user interactions and data sharing while enabling
7 | servers to gather necessary information dynamically. Servers
8 | request structured data from users with JSON schemas to validate
9 | responses.
10 |
11 | > #### Error {: .error}
12 | >
13 | > Note: this is not yet tested
14 |
15 | https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation
16 |
17 | ```mermaid
18 | sequenceDiagram
19 | participant User
20 | participant Client
21 | participant Server
22 |
23 | Note over Server,Client: Server initiates elicitation
24 | Server->>Client: elicitation/create
25 |
26 | Note over Client,User: Human interaction
27 | Client->>User: Present elicitation UI
28 | User-->>Client: Provide requested information
29 |
30 | Note over Server,Client: Complete request
31 | Client-->>Server: Return user response
32 |
33 | Note over Server: Continue processing with new information
34 | ```
35 | """
36 |
37 | @enforce_keys ~w[message requested_schema]a
38 |
39 | defstruct [
40 | :message,
41 | :requested_schema
42 | ]
43 |
44 | @type string_property :: %{
45 | name: String.t(),
46 | required: boolean(),
47 | type: :string,
48 | title: String.t(),
49 | description: String.t(),
50 | min_length: pos_integer(),
51 | max_length: pos_integer(),
52 | pattern: String.t() | Regex.t(),
53 | format: :email | :uri | :date | :datetime
54 | }
55 |
56 | @type enum_property :: %{
57 | name: String.t(),
58 | required: boolean(),
59 | type: :string,
60 | title: String.t(),
61 | description: String.t(),
62 | enum: [{value :: String.t(), name :: String.t()}]
63 | }
64 |
65 | @type boolean_property :: %{
66 | name: String.t(),
67 | required: boolean(),
68 | type: :boolean,
69 | title: String.t(),
70 | description: String.t(),
71 | default: boolean()
72 | }
73 |
74 | @type number_property :: %{
75 | name: String.t(),
76 | required: boolean(),
77 | type: :number | :integer,
78 | title: String.t(),
79 | description: String.t(),
80 | minimum: pos_integer(),
81 | maximum: pos_integer()
82 | }
83 |
84 | @type t :: %__MODULE__{
85 | message: String.t(),
86 | requested_schema: [
87 | number_property() | boolean_property() | enum_property() | string_property()
88 | ]
89 | }
90 |
91 | @type json :: %{
92 | message: String.t(),
93 | requestedSchema: %{
94 | type: String.t(),
95 | required: [String.t()],
96 | properties: %{String.t() => map()}
97 | }
98 | }
99 |
100 | @spec build(%{
101 | message: String.t(),
102 | requested_schema: [
103 | number_property() | boolean_property() | enum_property() | string_property()
104 | ]
105 | }) :: t
106 | def build(attrs) do
107 | %{
108 | struct!(__MODULE__, attrs)
109 | | requested_schema: attrs[:requested_schema] |> List.wrap() |> Enum.map(&build_property/1)
110 | }
111 | end
112 |
113 | defp build_property(%{type: :string} = attrs) do
114 | attrs =
115 | Map.take(
116 | attrs,
117 | ~w[name required type title description min_length max_length pattern format]a
118 | )
119 |
120 | if format = attrs[:format] do
121 | format in ~w[email uri date date_time]a || raise "Invalid format in string property"
122 | end
123 |
124 | attrs
125 | end
126 |
127 | defp build_property(%{enum: enum} = attrs) when is_list(enum) do
128 | attrs
129 | |> Map.take(~w[name required type title description enum]a)
130 | |> Map.put(:type, :string)
131 | end
132 |
133 | defp build_property(%{type: :boolean} = attrs) do
134 | Map.take(
135 | attrs,
136 | ~w[name required type title description default]a
137 | )
138 | end
139 |
140 | @integer ~w[integer number]a
141 | defp build_property(%{type: type} = attrs) when type in @integer do
142 | Map.take(
143 | attrs,
144 | ~w[name required type title description minimum maximum]a
145 | )
146 | end
147 |
148 | def to_json(%__MODULE__{} = elicit) do
149 | %{
150 | message: elicit.message,
151 | requestedSchema: %{
152 | type: "object",
153 | required:
154 | Enum.reduce(elicit.requested_schema, [], fn property, acc ->
155 | if property.required, do: [property.name | acc], else: acc
156 | end),
157 | properties:
158 | Enum.reduce(elicit.requested_schema, %{}, fn property, acc ->
159 | property = Map.drop(property, [:required])
160 | {name, attrs} = Map.pop(property, :name)
161 |
162 | Map.put(
163 | acc,
164 | name,
165 | Enum.reduce(attrs, %{}, fn
166 | {:min_length, v}, acc ->
167 | Map.put(acc, :minLength, v)
168 |
169 | {:max_length, v}, acc ->
170 | Map.put(acc, :maxLength, v)
171 |
172 | {:pattern, %Regex{} = v}, acc ->
173 | Map.put(acc, :pattern, Regex.source(v))
174 |
175 | {:format, :date_time}, acc ->
176 | Map.put(acc, :format, "date-time")
177 |
178 | {:enum, v}, acc ->
179 | acc
180 | |> Map.put(:enum, Enum.map(v, &elem(&1, 0)))
181 | |> Map.put(:enumNames, Enum.map(v, &elem(&1, 1)))
182 |
183 | {k, v}, acc ->
184 | Map.put(acc, k, v)
185 | end)
186 | )
187 | end)
188 | }
189 | }
190 | end
191 | end
192 |
--------------------------------------------------------------------------------
/lib/phantom/request.ex:
--------------------------------------------------------------------------------
1 | defmodule Phantom.Request do
2 | @moduledoc "Standard requests and responses for the MCP protocol"
3 | defstruct [:id, :type, :method, :params, :response, :spec]
4 |
5 | @opaque t :: %__MODULE__{
6 | id: String.t(),
7 | type: String.t(),
8 | method: String.t(),
9 | params: map(),
10 | response: map(),
11 | spec: Phantom.ResourceTemplate.t() | Phantom.Tool.t() | Phantom.Prompt.t()
12 | }
13 |
14 | @connection -32000
15 | @resource_not_found -32002
16 | @invalid_request -32600
17 | @method_not_found -32601
18 | @invalid_params -32602
19 | @internal_error -32603
20 | @parse_error -32700
21 |
22 | import Phantom.Utils
23 | alias Phantom.Session
24 |
25 | @doc "Invalid request"
26 | def invalid(message \\ nil) do
27 | %{code: @invalid_request, message: message || "Invalid request"}
28 | end
29 |
30 | @doc "Invalid request due to bad parameters"
31 | def invalid_params(data), do: %{code: @invalid_params, message: "Invalid Params", data: data}
32 | def invalid_params, do: %{code: @invalid_params, message: "Invalid Params"}
33 |
34 | @doc "Invalid request due to parsing error"
35 | def parse_error(message \\ nil) do
36 | %{code: @parse_error, message: message || "Parsing error"}
37 | end
38 |
39 | @doc "Invalid request due to no streaming connection being available"
40 | def closed(message \\ nil) do
41 | %{code: @connection, message: message || "Connection closed"}
42 | end
43 |
44 | @doc "Server encountered an issue"
45 | def internal_error(message \\ nil) do
46 | %{code: @internal_error, message: message || "Internal server error"}
47 | end
48 |
49 | @doc "The method is not implemented or found"
50 | def not_found(message \\ nil),
51 | do: %{code: @method_not_found, message: message || "Method not found"}
52 |
53 | @doc "The resource is not found"
54 | def resource_not_found(data),
55 | do: %{code: @resource_not_found, data: data, message: "Resource not found"}
56 |
57 | @doc false
58 | def build(nil), do: nil
59 |
60 | def build(%{"jsonrpc" => "2.0", "method" => method} = request)
61 | when is_binary(method) do
62 | {:ok,
63 | struct!(__MODULE__,
64 | params: request["params"] || %{},
65 | method: method,
66 | id: request["id"]
67 | )}
68 | end
69 |
70 | def build(%{"jsonrpc" => "2.0", "result" => result} = response)
71 | when is_map(result) do
72 | {:ok,
73 | struct!(__MODULE__,
74 | response: result,
75 | id: response["id"]
76 | )}
77 | end
78 |
79 | def build(request) do
80 | {:error, struct!(__MODULE__, id: request["id"], response: error(request["id"], invalid()))}
81 | end
82 |
83 | @doc false
84 | def to_json(%__MODULE__{} = request) do
85 | %{
86 | "jsonrpc" => "2.0",
87 | "method" => request.method,
88 | "id" => request.id,
89 | "params" => request.params
90 | }
91 | end
92 |
93 | @doc "Ping request"
94 | def ping() do
95 | %{jsonrpc: "2.0", method: "ping", id: UUIDv7.generate()}
96 | end
97 |
98 | @doc "An empty response"
99 | def empty() do
100 | %{jsonrpc: "2.0", result: ""}
101 | end
102 |
103 | @doc false
104 | def result(%__MODULE__{} = request, type, result) do
105 | %{request | type: type, response: %{id: request.id, jsonrpc: "2.0", result: result}}
106 | end
107 |
108 | @doc "Response error"
109 | def error(id \\ nil, error) do
110 | %{jsonrpc: "2.0", error: error, id: id}
111 | end
112 |
113 | @doc false
114 | def completion_response({:reply, results, session}, _session) do
115 | {:reply, completion_response(results), session}
116 | end
117 |
118 | def completion_response({:error, error}, session) do
119 | {:error, error, session}
120 | end
121 |
122 | def completion_response({:noreply, session}, _session) do
123 | {:noreply, session}
124 | end
125 |
126 | def completion_response(results) when is_list(results) do
127 | %{
128 | completion: %{
129 | values: Enum.take(List.wrap(results), 100),
130 | hasMore: length(results) > 100
131 | }
132 | }
133 | end
134 |
135 | def completion_response(%{} = results) do
136 | %{
137 | completion:
138 | remove_nils(%{
139 | values: Enum.take(List.wrap(results[:values]), 100),
140 | total: results[:total],
141 | hasMore: results[:has_more] || false
142 | })
143 | }
144 | end
145 |
146 | @doc false
147 | def resource_response({:error, reason}, _uri, session) do
148 | {:error, reason, session}
149 | end
150 |
151 | def resource_response({:noreply, _} = result, _uri, _session), do: result
152 |
153 | def resource_response({:error, _reason, %Session{}} = result, _uri, _session) do
154 | result
155 | end
156 |
157 | def resource_response(nil, uri, session) do
158 | {:error, resource_not_found(%{uri: uri}), session}
159 | end
160 |
161 | def resource_response({:reply, nil, %Session{} = session}, uri, _session) do
162 | {:error, resource_not_found(%{uri: uri}), session}
163 | end
164 |
165 | def resource_response({:reply, results, %Session{} = session}, _uri, _session) do
166 | {:reply, Phantom.Resource.response(results), session}
167 | end
168 |
169 | @doc "Resource updated notification"
170 | def resource_updated(content) do
171 | %{jsonrpc: "2.0", method: "notifications/resources/updated", params: content}
172 | end
173 |
174 | @doc "Tools List updated notification"
175 | def tools_updated do
176 | %{jsonrpc: "2.0", method: "notifications/tools/list_changed"}
177 | end
178 |
179 | @doc "Prompts List updated notification"
180 | def prompts_updated do
181 | %{jsonrpc: "2.0", method: "notifications/prompts/list_changed"}
182 | end
183 |
184 | @doc "Resources List updated notification"
185 | def resources_updated do
186 | %{jsonrpc: "2.0", method: "notifications/resources/list_changed"}
187 | end
188 |
189 | @doc "A generic notifiation"
190 | def notify(content) do
191 | %{jsonrpc: "2.0", method: "notifications/message", params: content}
192 | end
193 |
194 | @doc "Progress notifiation"
195 | def notify_progress(progress_token, progress, total) do
196 | %{
197 | jsonrpc: "2.0",
198 | method: "notifications/progress",
199 | params:
200 | remove_nils(%{
201 | progressToken: progress_token,
202 | progress: progress,
203 | total: total
204 | })
205 | }
206 | end
207 | end
208 |
--------------------------------------------------------------------------------
/lib/phantom/resource.ex:
--------------------------------------------------------------------------------
1 | defmodule Phantom.Resource do
2 | @moduledoc """
3 | The Model Context Protocol (MCP) provides a standardized way for
4 | servers to expose resources to clients. Resources allow servers to
5 | share data that provides context to language models, such as files,
6 | database schemas, or application-specific information. Each resource
7 | is uniquely identified by a URI.
8 |
9 | https://modelcontextprotocol.io/specification/2025-03-26/server/resources
10 |
11 | ```mermaid
12 | sequenceDiagram
13 | participant Client
14 | participant Server
15 |
16 | Note over Client,Server: Resource Discovery
17 | Client->>Server: resources/list
18 | Server-->>Client: List of resources
19 |
20 | Note over Client,Server: Resource Access
21 | Client->>Server: resources/read
22 | Server-->>Client: Resource contents
23 |
24 | Note over Client,Server: Subscriptions
25 | Client->>Server: resources/subscribe
26 | Server-->>Client: Subscription confirmed
27 |
28 | Note over Client,Server: Updates
29 | Server--)Client: notifications/resources/updated
30 | Client->>Server: resources/read
31 | Server-->>Client: Updated contents
32 | ```
33 | """
34 |
35 | import Phantom.Utils
36 |
37 | @type response :: %{
38 | contents: [blob_content() | text_content()]
39 | }
40 |
41 | @type blob_content :: %{
42 | required(:blob) => binary(),
43 | required(:uri) => String.t(),
44 | optional(:mimeType) => String.t(),
45 | optional(:name) => String.t(),
46 | optional(:title) => String.t()
47 | }
48 |
49 | @type text_content :: %{
50 | required(:text) => binary(),
51 | required(:uri) => String.t(),
52 | optional(:mimeType) => String.t(),
53 | optional(:name) => String.t(),
54 | optional(:title) => String.t()
55 | }
56 |
57 | @type resource_link :: %{
58 | uri: String.t(),
59 | name: String.t(),
60 | description: String.t(),
61 | mimeType: String.t()
62 | }
63 |
64 | # TODO: not ready
65 | @doc false
66 | def updated(uri), do: %{uri: uri}
67 |
68 | @doc """
69 | Resource as binary content
70 |
71 | The `:uri` and `:mime_type` will be fetched from the current resource template
72 | within the scope of the request if not provided, but you will need to provide
73 | the rest.
74 |
75 | - `binary` - Binary data. This will be base64-encoded by Phantom.
76 | - `:uri` (required) Unique identifier for the resource
77 | - `:name` (optional) The name of the resource.
78 | - `:title` (optional) human-readable name of the resource for display purposes.
79 | - `:description` (optional) Description
80 | - `:mime_type` (optional) MIME type
81 | - `:size` (optional) Size in bytes
82 |
83 | For example:
84 |
85 | Phantom.Resource.blob(
86 | File.read!("foo.png"),
87 | uri: "test://my-foos/123",
88 | mime_type: "image/png"
89 | )
90 | """
91 | @spec blob(binary(), Keyword.t() | map()) :: blob_content()
92 | defmacro blob(binary, attrs \\ []) do
93 | mime_type =
94 | get_var(attrs, :mime_type, [:spec, :mime_type], __CALLER__, "application/octet-stream")
95 |
96 | uri = get_var(attrs, :uri, [:params, "uri"], __CALLER__)
97 |
98 | quote bind_quoted: [
99 | uri: uri,
100 | mime_type: mime_type,
101 | binary: binary,
102 | attrs: Macro.escape(attrs)
103 | ] do
104 | remove_nils(%{
105 | blob: Base.encode64(binary),
106 | uri: uri,
107 | mimeType: mime_type,
108 | name: attrs[:name],
109 | title: attrs[:title],
110 | size: attrs[:size]
111 | })
112 | end
113 | end
114 |
115 | @doc """
116 | Resource as text content
117 |
118 | The `:uri` and `:mime_type` will be fetched from the current resource template
119 | within the scope of the request if not provided, but you will need to provide
120 | the rest.
121 |
122 | - `text` - Text data. If a map, then it will be encoded into JSON and `:mime_type` will be set accordingly unless provided.
123 | - `:uri` (required) Unique identifier for the resource
124 | - `:name` (optional) The name of the resource.
125 | - `:title` (optional) human-readable name of the resource for display purposes.
126 | - `:description` (optional) Description
127 | - `:mime_type` (optional) MIME type. Defaults to `"text/plain"`
128 | - `:size` (optional) Size in bytes
129 |
130 | For example:
131 |
132 | Phantom.ResourceTemplate.text(
133 | "## Why hello there",
134 | uri: "test://obi-wan-quotes/hello-there",
135 | mime_type: "text/markdown"
136 | )
137 |
138 | Phantom.ResourceTemplate.text(
139 | %{why: "hello there"},
140 | uri: "test://my-foos/json",
141 | # mime_type: "application/json" # set by Phantom
142 | )
143 | """
144 | @spec text(String.t() | map, Keyword.t() | map()) :: text_content()
145 | defmacro text(text, attrs \\ %{}) do
146 | mime_type = get_var(attrs, :mime_type, [:spec, :mime_type], __CALLER__, "text/plain")
147 | uri = get_var(attrs, :uri, [:params, "uri"], __CALLER__)
148 |
149 | quote bind_quoted: [
150 | text: text,
151 | uri: uri,
152 | mime_type: mime_type,
153 | attrs: Macro.escape(attrs)
154 | ] do
155 | tmp_text = if is_map(t = text), do: JSON.encode!(t), else: t
156 | json_mime = if is_map(text), do: "application/json"
157 |
158 | remove_nils(%{
159 | text: tmp_text,
160 | uri: uri,
161 | mimeType: json_mime || mime_type || "text/plain",
162 | name: attrs[:name],
163 | title: attrs[:title],
164 | size: attrs[:size]
165 | })
166 | end
167 | end
168 |
169 | @doc "Formats the response from an MCP Router to the MCP specification"
170 | def response(%{contents: _} = results), do: results
171 |
172 | def response(results) do
173 | %{contents: List.wrap(results)}
174 | end
175 |
176 | @type list_response :: %{nextCursor: String.t() | nil, resources: [resource_link()]}
177 | @spec list([resource_link()], cursor :: String.t() | nil) :: list_response()
178 | @doc "Formats the response from the MCP Router to the MCP specification for listing resources"
179 | def list(resource_links, next_cursor) do
180 | %{resources: List.wrap(resource_links), nextCursor: next_cursor}
181 | end
182 |
183 | @doc """
184 | Formats a resource_template and the provided attributes as a resource link. This is
185 | typicaly used when listing resources or when tools embed a resource_link within its result.
186 | """
187 | @spec resource_link(string_uri :: String.t(), Phantom.ResourceTemplate.t(), map()) ::
188 | resource_link()
189 | def resource_link(uri, %Phantom.ResourceTemplate{} = resource_template, attrs \\ %{}) do
190 | remove_nils(%{
191 | uri: uri,
192 | mimeType: attrs[:mime_type] || resource_template.mime_type,
193 | description: attrs[:description] || resource_template.description,
194 | name: attrs[:name]
195 | })
196 | end
197 | end
198 |
--------------------------------------------------------------------------------
/lib/phantom/prompt.ex:
--------------------------------------------------------------------------------
1 | defmodule Phantom.Prompt do
2 | @moduledoc """
3 | The Model Context Protocol (MCP) provides a standardized way
4 | for servers to expose prompt templates to clients. Prompts
5 | allow servers to provide structured messages and instructions
6 | for interacting with language models. Clients can discover
7 | available prompts, retrieve their contents, and provide arguments
8 | to customize them.
9 |
10 | ```mermaid
11 | sequenceDiagram
12 | participant Client
13 | participant Server
14 |
15 | Note over Client,Server: Discovery
16 | Client->>Server: prompts/list
17 | Server-->>Client: List of prompts
18 |
19 | Note over Client,Server: Usage
20 | Client->>Server: prompts/get
21 | Server-->>Client: Prompt content
22 |
23 | opt listChanged
24 | Note over Client,Server: Changes
25 | Server--)Client: prompts/list_changed
26 | Client->>Server: prompts/list
27 | Server-->>Client: Updated prompts
28 | end
29 | ```
30 |
31 | https://modelcontextprotocol.io/specification/2025-03-26/server/prompts
32 | """
33 |
34 | import Phantom.Utils
35 | alias Phantom.Prompt.Argument
36 |
37 | @enforce_keys ~w[name handler function]a
38 | defstruct [
39 | :name,
40 | :description,
41 | :handler,
42 | :completion_function,
43 | :function,
44 | meta: %{},
45 | arguments: []
46 | ]
47 |
48 | @type t :: %__MODULE__{
49 | name: String.t(),
50 | handler: module(),
51 | function: atom(),
52 | completion_function: atom(),
53 | description: String.t(),
54 | meta: map(),
55 | arguments: [Argument.t()]
56 | }
57 |
58 | @type json :: %{
59 | required(:name) => String.t(),
60 | optional(:description) => String.t(),
61 | optional(:arguments) => %{
62 | String.t() => String.t()
63 | }
64 | }
65 |
66 | @type text_content :: %{
67 | type: :text,
68 | data: String.t()
69 | }
70 |
71 | @type image_content :: %{
72 | type: :image,
73 | data: base64_encoded :: String.t(),
74 | mimeType: String.t()
75 | }
76 |
77 | @type audio_content :: %{
78 | type: :audio,
79 | data: base64_encoded :: String.t(),
80 | mimeType: String.t()
81 | }
82 |
83 | @type embedded_resource_content :: %{
84 | type: :resource,
85 | resource: Phantom.Resource.response()
86 | }
87 | @type message :: %{
88 | role: :assistant | :user,
89 | content:
90 | text_content()
91 | | image_content()
92 | | audio_content()
93 | | embedded_resource_content()
94 | }
95 |
96 | @type response :: %{
97 | description: String.t(),
98 | messages: [message()]
99 | }
100 |
101 | @spec build(map() | Keyword.t()) :: t()
102 | @doc """
103 | Build a prompt spec
104 |
105 | The `Phantom.Router.prompt/3` macro will build these specs.
106 |
107 | Fields:
108 | - `:name` - The name of the prompt.
109 | - `:description` - The description of the resource and when to use it.
110 | - `:handler` - The module to call.
111 | - `:function` - The function to call on the handler module.
112 | - `:completion_function` - The function to call on the handler module that will provide possible completion results.
113 | - `:arguments` - A list of arguments that the prompt takes.
114 |
115 | Argument fields:
116 | - `:name` - the name of the argument, eg: "username"
117 | - `:description` - description of the argument, eg, "Your Github username"
118 | - `:required` - whether the argument is required in order to be called, ie: `true` or `false`
119 | """
120 | def build(attrs) do
121 | attrs =
122 | attrs
123 | |> Map.new()
124 | |> Map.update(:name, to_string(attrs[:function]), &to_string/1)
125 |
126 | struct!(
127 | __MODULE__,
128 | Map.put(attrs, :arguments, Enum.map(attrs[:arguments] || [], &Argument.build/1))
129 | )
130 | end
131 |
132 | @spec to_json(t()) :: json()
133 | @doc """
134 | Represent a Prompt spec as json when listing the available prompts to clients.
135 | """
136 | def to_json(%__MODULE__{} = prompt) do
137 | remove_nils(%{
138 | name: prompt.name,
139 | description: prompt.description,
140 | arguments: Enum.map(prompt.arguments, &Argument.to_json/1)
141 | })
142 | end
143 |
144 | @doc """
145 | Formats the response from an MCP Router to the MCP specification
146 |
147 | Provide a keyword list of messages with a keyword list. The key
148 | should contain the role, and the value contain the message.
149 |
150 | For example:
151 |
152 | require Phantom.Prompt, as: Prompt
153 | {:ok, uri, resource} = MyApp.MCP.Router.read_resource(session, :my_resource, 123)
154 |
155 | Prompt.response([
156 | assistant: Prompt.audio(File.read!("foo.wav"), "audio/wav"),
157 | user: Prompt.text("Wow that was interesting"),
158 | assistant: Prompt.image(File.read!("bar.png"), "image/png"),
159 | user: Prompt.text("amazing"),
160 | assistant: Prompt.embedded_resource(uri, resource)
161 | ])
162 | """
163 |
164 | defmacro response(%{messages: _} = response), do: response
165 |
166 | defmacro response(messages) when is_list(messages) do
167 | if not Macro.Env.has_var?(__CALLER__, {:session, nil}) do
168 | raise "session was not supplied to the response. Phantom requires the variable named `session` to exist, or use response/2."
169 | end
170 |
171 | quote do
172 | prompt = var!(session, nil).request.spec
173 | Phantom.Prompt.response(unquote(messages), prompt)
174 | end
175 | end
176 |
177 | def response(%{messages: _} = response, _prompt), do: response
178 |
179 | @doc """
180 | Construct a prompt response with the provided messages for the given prompt
181 |
182 | See `response/1` macro version that do the same thing but will fetch the
183 | prompt spec from the current session.
184 | """
185 | @spec response([message()], Phantom.Prompt.t()) :: response()
186 | def response(messages, prompt) when is_list(messages) do
187 | %{
188 | description: prompt.description,
189 | messages:
190 | Enum.map(messages, fn {role, content} ->
191 | %{role: role, content: content}
192 | end)
193 | }
194 | end
195 |
196 | @spec text(String.t()) :: text_content()
197 | @doc """
198 | Build a text message for the prompt
199 | """
200 | def text(data), do: %{type: :text, text: data || ""}
201 |
202 | @spec audio(binary(), String.t()) :: audio_content()
203 | @doc """
204 | Build an audio message for the prompt
205 |
206 | The provided binary will be base64-encoded.
207 | """
208 | def audio(data, mime_type) do
209 | %{type: :audio, data: Base.encode64(data || <<>>), mimeType: mime_type}
210 | end
211 |
212 | @spec image(binary(), String.t()) :: image_content()
213 | @doc """
214 | Build an image message for the prompt
215 |
216 | The provided binary will be base64-encoded.
217 | """
218 | def image(binary, mime_type) do
219 | %{type: :image, data: Base.encode64(binary || <<>>), mimeType: mime_type}
220 | end
221 |
222 | @spec embedded_resource(string_uri :: String.t(), map()) :: embedded_resource_content()
223 | @doc """
224 | Embedded resource reponse.
225 | """
226 | def embedded_resource(uri, resource) do
227 | %{type: :resource, resource: Map.put(resource, :uri, uri)}
228 | end
229 | end
230 |
--------------------------------------------------------------------------------
/test/support/dispatcher.ex:
--------------------------------------------------------------------------------
1 | defmodule Phantom.TestDispatcher do
2 | import Plug.Conn
3 | import Plug.Test
4 |
5 | @opts Phantom.Plug.init(
6 | pubsub: Test.PubSub,
7 | router: Test.MCP.Router,
8 | validate_origin: false
9 | )
10 |
11 | @parser Plug.Parsers.init(
12 | parsers: [{:json, length: 1_000_000}],
13 | pass: ["application/json"],
14 | json_decoder: JSON
15 | )
16 |
17 | defmacro assert_response(id \\ 1, payload) do
18 | quote do
19 | assert_receive {:response, unquote(id), "message", unquote(payload)}
20 | assert_receive {:response, nil, "closed", "finished"}
21 | end
22 | end
23 |
24 | defmacro assert_exception_response(id \\ 1, payload, exception) do
25 | quote do
26 | assert_receive {:response, unquote(id), "message", unquote(payload)}
27 | assert_receive {:EXIT, _pid, unquote(exception)}
28 | end
29 | end
30 |
31 | defmacro assert_sse_connected() do
32 | quote do
33 | assert_receive {:plug_conn, :sent}
34 | refute_receive {:conn, _}
35 | end
36 | end
37 |
38 | defmacro assert_connected(conn) do
39 | quote do
40 | assert_receive {:plug_conn, :sent}
41 | assert_receive {:conn, unquote(conn)}
42 | end
43 | end
44 |
45 | defmacro assert_notify(payload) do
46 | quote do
47 | assert_receive {:response, nil, "message", unquote(payload)}
48 | end
49 | end
50 |
51 | def call(conn, opts \\ %{}) do
52 | opts = Map.new(opts)
53 | opts = Map.put_new(opts, :listener, self())
54 | {fun, opts} = Map.pop(opts, :before_call, & &1)
55 | {session_id, opts} = Map.pop(opts, :session_id)
56 | opts = Map.merge(@opts, opts)
57 |
58 | :proc_lib.spawn_link(fn ->
59 | result =
60 | conn
61 | |> Plug.Parsers.call(@parser)
62 | |> put_session_id(session_id)
63 | |> fun.()
64 | |> Phantom.Plug.call(opts)
65 |
66 | if pid = opts[:listener], do: send(pid, {:conn, result})
67 | end)
68 | end
69 |
70 | defp put_session_id(conn, nil), do: conn
71 | defp put_session_id(conn, id), do: put_req_header(conn, "mcp-session-id", id)
72 |
73 | def request_ping(init_opts \\ []) do
74 | :post
75 | |> conn("/mcp", %{jsonrpc: "2.0", method: "ping", id: 1})
76 | |> put_req_header("content-type", "application/json")
77 | |> call(Map.merge(@opts, Map.new(init_opts)))
78 | end
79 |
80 | def request_tool(name, args \\ %{}, init_opts \\ []) do
81 | {id, init_opts} = Keyword.pop(init_opts, :id, 1)
82 |
83 | :post
84 | |> conn("/mcp", %{
85 | jsonrpc: "2.0",
86 | id: id,
87 | method: "tools/call",
88 | params: %{"name" => name, "arguments" => args}
89 | })
90 | |> put_req_header("content-type", "application/json")
91 | |> call(Map.merge(@opts, Map.new(init_opts)))
92 | end
93 |
94 | def request_prompt_complete(name, attrs \\ []) do
95 | {id, attrs} = Keyword.pop(attrs, :id, 1)
96 | {arg, attrs} = Keyword.pop(attrs, :arg)
97 | {value, attrs} = Keyword.pop(attrs, :value)
98 | {context, attrs} = Keyword.pop(attrs, :context, %{})
99 | {init_opts, _attrs} = Keyword.pop(attrs, :init_opts, [])
100 |
101 | :post
102 | |> conn("/mcp", %{
103 | jsonrpc: "2.0",
104 | id: id,
105 | method: "completion/complete",
106 | params: %{
107 | ref: %{type: "ref/prompt", name: name},
108 | argument: %{name: arg, value: value},
109 | context: context
110 | }
111 | })
112 | |> put_req_header("content-type", "application/json")
113 | |> call(Map.merge(@opts, Map.new(init_opts)))
114 | end
115 |
116 | def request_resource_complete(name, attrs \\ []) do
117 | {id, attrs} = Keyword.pop(attrs, :id, 1)
118 | {arg, attrs} = Keyword.pop(attrs, :arg)
119 | {value, attrs} = Keyword.pop(attrs, :value)
120 | {context, attrs} = Keyword.pop(attrs, :context, %{})
121 | {init_opts, _attrs} = Keyword.pop(attrs, :init_opts, [])
122 |
123 | :post
124 | |> conn("/mcp", %{
125 | jsonrpc: "2.0",
126 | id: id,
127 | method: "completion/complete",
128 | params: %{
129 | ref: %{type: "ref/resource", name: name},
130 | argument: %{name: arg, value: value},
131 | context: context
132 | }
133 | })
134 | |> put_req_header("content-type", "application/json")
135 | |> call(Map.merge(@opts, Map.new(init_opts)))
136 | end
137 |
138 | def request_tool_list(cursor \\ nil, init_opts \\ []) do
139 | {id, init_opts} = Keyword.pop(init_opts, :id, 1)
140 |
141 | :post
142 | |> conn("/mcp", %{jsonrpc: "2.0", id: id, method: "tools/list", params: %{"cursor" => cursor}})
143 | |> put_req_header("content-type", "application/json")
144 | |> call(Map.merge(@opts, Map.new(init_opts)))
145 | end
146 |
147 | def request_prompt(name, args \\ %{}, init_opts \\ []) do
148 | {id, init_opts} = Keyword.pop(init_opts, :id, 1)
149 |
150 | :post
151 | |> conn("/mcp", %{
152 | jsonrpc: "2.0",
153 | id: id,
154 | method: "prompts/get",
155 | params: %{"name" => name, "arguments" => args}
156 | })
157 | |> put_req_header("content-type", "application/json")
158 | |> call(Map.merge(@opts, Map.new(init_opts)))
159 | end
160 |
161 | def request_prompt_list(cursor \\ nil, init_opts \\ []) do
162 | {id, init_opts} = Keyword.pop(init_opts, :id, 1)
163 |
164 | :post
165 | |> conn("/mcp", %{
166 | jsonrpc: "2.0",
167 | id: id,
168 | method: "prompts/list",
169 | params: %{"cursor" => cursor}
170 | })
171 | |> put_req_header("content-type", "application/json")
172 | |> call(Map.merge(@opts, Map.new(init_opts)))
173 | end
174 |
175 | def request_resource_list(cursor \\ nil, init_opts \\ []) do
176 | {id, init_opts} = Keyword.pop(init_opts, :id, 1)
177 |
178 | :post
179 | |> conn("/mcp", %{
180 | jsonrpc: "2.0",
181 | id: id,
182 | method: "resources/list",
183 | params: %{"cursor" => cursor}
184 | })
185 | |> put_req_header("content-type", "application/json")
186 | |> call(Map.merge(@opts, Map.new(init_opts)))
187 | end
188 |
189 | def request_resource_read(uri, init_opts \\ []) do
190 | {id, init_opts} = Keyword.pop(init_opts, :id, 1)
191 |
192 | :post
193 | |> conn("/mcp", %{jsonrpc: "2.0", id: id, method: "resources/read", params: %{"uri" => uri}})
194 | |> put_req_header("content-type", "application/json")
195 | |> call(Map.merge(@opts, Map.new(init_opts)))
196 | end
197 |
198 | def request_resource_subscribe(uri, init_opts \\ []) do
199 | {id, init_opts} = Keyword.pop(init_opts, :id, 1)
200 |
201 | :post
202 | |> conn("/mcp", %{
203 | jsonrpc: "2.0",
204 | id: id,
205 | method: "resources/subscribe",
206 | params: %{"uri" => uri}
207 | })
208 | |> put_req_header("content-type", "application/json")
209 | |> call(Map.merge(@opts, Map.new(init_opts)))
210 | end
211 |
212 | def request_set_log_level(level, init_opts \\ []) do
213 | {id, init_opts} = Keyword.pop(init_opts, :id, 1)
214 |
215 | :post
216 | |> conn("/mcp", %{
217 | jsonrpc: "2.0",
218 | id: id,
219 | method: "logging/setLevel",
220 | params: %{"level" => level}
221 | })
222 | |> put_req_header("content-type", "application/json")
223 | |> call(Map.merge(@opts, Map.new(init_opts)))
224 | end
225 |
226 | def request_initialize do
227 | :post
228 | |> conn("/mcp", %{
229 | jsonrpc: "2.0",
230 | id: 1,
231 | method: "initialize",
232 | params: %{
233 | protocolVersion: "2024-11-05",
234 | capabilities: %{
235 | roots: %{
236 | listChanged: true
237 | },
238 | sampling: %{},
239 | elicitation: %{}
240 | },
241 | clientInfo: %{
242 | name: "ExampleClient",
243 | title: "Example Client Display Name",
244 | version: "1.0.0"
245 | }
246 | }
247 | })
248 | |> put_req_header("content-type", "application/json")
249 | |> call(@opts)
250 | end
251 |
252 | def request_sse_stream(init_opts \\ []) do
253 | :get
254 | |> conn("/mcp")
255 | |> put_req_header("content-type", "event-stream/sse")
256 | |> call(Map.merge(@opts, Map.new(init_opts)))
257 | end
258 | end
259 |
--------------------------------------------------------------------------------
/test/phantom/dynamic_tool_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Phantom.DynamicToolTest do
2 | use ExUnit.Case
3 | import Phantom.TestDispatcher
4 |
5 | alias Phantom.Tool
6 | alias Phantom.Cache
7 |
8 | defmodule TestHandler do
9 | @moduledoc """
10 | Test handler module for dynamic tools
11 | """
12 | require Phantom.Tool, as: Tool
13 |
14 | def dynamic_echo_tool(params, session) do
15 | message = Map.get(params, "message", "default message")
16 | {:reply, Tool.text("Dynamic echo: #{message}"), session}
17 | end
18 |
19 | def dynamic_math_tool(params, session) do
20 | a = Map.get(params, "a", 0)
21 | b = Map.get(params, "b", 0)
22 | result = a + b
23 | {:reply, Tool.text(%{result: result, operation: "addition"}), session}
24 | end
25 |
26 | def dynamic_error_tool(_params, session) do
27 | {:reply, Tool.error("This is a dynamic error"), session}
28 | end
29 | end
30 |
31 | defmodule TestRouter do
32 | @moduledoc """
33 | A minimal test router for dynamic tool testing
34 | """
35 |
36 | use Phantom.Router,
37 | name: "DynamicTest",
38 | vsn: "1.0",
39 | validate_origin: false,
40 | instructions: @moduledoc
41 | end
42 |
43 | setup do
44 | Cache.register(TestRouter)
45 | start_supervised({Phoenix.PubSub, name: Test.PubSub})
46 | start_supervised({Phantom.Tracker, [name: Phantom.Tracker, pubsub_server: Test.PubSub]})
47 | :ok
48 | end
49 |
50 | describe "Phantom.Tool.build/1" do
51 | test "builds a basic tool specification" do
52 | tool_spec = %{
53 | name: "test_tool",
54 | description: "A test tool",
55 | handler: TestHandler,
56 | function: :dynamic_echo_tool,
57 | title: "Annotated Tool",
58 | read_only: true,
59 | idempotent: true,
60 | input_schema: %{
61 | type: "object",
62 | properties: %{
63 | message: %{type: "string", description: "Message to echo"}
64 | },
65 | required: ["message"]
66 | },
67 | output_schema: %{
68 | type: "object",
69 | properties: %{
70 | result: %{type: "number"},
71 | operation: %{type: "string"}
72 | }
73 | }
74 | }
75 |
76 | tool = Tool.build(tool_spec)
77 |
78 | assert tool.name == "test_tool"
79 | assert tool.description == "A test tool"
80 | assert tool.handler == TestHandler
81 | assert tool.function == :dynamic_echo_tool
82 | assert tool.input_schema != nil
83 | assert tool.annotations != nil
84 | assert tool.annotations.title == "Annotated Tool"
85 | assert tool.annotations.read_only_hint == true
86 | assert tool.annotations.idempotent_hint == true
87 | assert tool.annotations.destructive_hint == nil
88 | assert is_map(tool.output_schema.properties)
89 | end
90 | end
91 |
92 | describe "Phantom.Cache.add_tool/2" do
93 | test "adds a single tool to the cache", context do
94 | tool_spec = %{
95 | name: "cached_echo_tool",
96 | description: "A cached echo tool",
97 | handler: TestHandler,
98 | function: :dynamic_echo_tool,
99 | input_schema: %{
100 | type: "object",
101 | properties: %{
102 | message: %{type: "string"}
103 | }
104 | }
105 | }
106 |
107 | session_id = to_string(context.test)
108 | request_sse_stream(session_id: session_id)
109 | assert_sse_connected()
110 |
111 | Cache.add_tool(TestRouter, tool_spec)
112 | tools = Cache.list(nil, TestRouter, :tools)
113 | tool_names = Enum.map(tools, & &1.name)
114 |
115 | assert "cached_echo_tool" in tool_names
116 |
117 | assert_notify(%{
118 | method: "notifications/tools/list_changed"
119 | })
120 | end
121 |
122 | test "adds multiple tools to the cache", context do
123 | tool_specs = [
124 | %{
125 | name: "multi_tool_1",
126 | description: "First multi tool",
127 | handler: TestHandler,
128 | function: :dynamic_echo_tool
129 | },
130 | %{
131 | name: "multi_tool_2",
132 | description: "Second multi tool",
133 | handler: TestHandler,
134 | function: :dynamic_math_tool
135 | }
136 | ]
137 |
138 | session_id = to_string(context.test)
139 | request_sse_stream(session_id: session_id)
140 | assert_sse_connected()
141 |
142 | Cache.add_tool(TestRouter, tool_specs)
143 | tools = Cache.list(nil, TestRouter, :tools)
144 | tool_names = Enum.map(tools, & &1.name)
145 |
146 | assert "multi_tool_1" in tool_names
147 | assert "multi_tool_2" in tool_names
148 |
149 | assert_notify(%{
150 | method: "notifications/tools/list_changed"
151 | })
152 | end
153 |
154 | test "maintains existing tools when adding new ones" do
155 | # Add first tool
156 | tool_spec_1 = %{
157 | name: "existing_tool",
158 | description: "An existing tool",
159 | handler: TestHandler,
160 | function: :dynamic_echo_tool
161 | }
162 |
163 | Cache.add_tool(TestRouter, tool_spec_1)
164 |
165 | # Add second tool
166 | tool_spec_2 = %{
167 | name: "new_tool",
168 | description: "A new tool",
169 | handler: TestHandler,
170 | function: :dynamic_math_tool
171 | }
172 |
173 | Cache.add_tool(TestRouter, tool_spec_2)
174 | tools = Cache.list(nil, TestRouter, :tools)
175 | tool_names = Enum.map(tools, & &1.name)
176 |
177 | assert "existing_tool" in tool_names
178 | assert "new_tool" in tool_names
179 | assert length(tools) >= 2
180 | end
181 |
182 | test "raises error for duplicate tool names" do
183 | tool_spec = %{
184 | name: "duplicate_tool",
185 | description: "A duplicate tool",
186 | handler: TestHandler,
187 | function: :dynamic_echo_tool
188 | }
189 |
190 | tool_spec_dupe = %{
191 | name: "duplicate_tool",
192 | description: "A different description",
193 | handler: TestHandler,
194 | function: :dynamic_echo_tool
195 | }
196 |
197 | Cache.add_tool(TestRouter, tool_spec)
198 |
199 | assert_raise RuntimeError, fn ->
200 | Cache.add_tool(TestRouter, tool_spec_dupe)
201 | end
202 | end
203 |
204 | test "validates that handler module and function exist" do
205 | tool_spec = %{
206 | name: "invalid_tool",
207 | description: "A tool with invalid handler",
208 | handler: NonExistentModule,
209 | function: :non_existent_function
210 | }
211 |
212 | assert_raise ArgumentError,
213 | ~r/could not load module NonExistentModule/,
214 | fn ->
215 | Cache.add_tool(TestRouter, tool_spec)
216 | end
217 | end
218 | end
219 |
220 | describe "router integration" do
221 | setup do
222 | # Add a dynamic tool for testing
223 | tool_spec = %{
224 | name: "dynamic_echo_tool",
225 | description: "An echo tool for router testing",
226 | handler: TestHandler,
227 | function: :dynamic_echo_tool,
228 | input_schema: %{
229 | type: "object",
230 | properties: %{
231 | message: %{type: "string", description: "Message to echo"}
232 | },
233 | required: ["message"]
234 | }
235 | }
236 |
237 | Cache.add_tool(TestRouter, tool_spec)
238 | :ok
239 | end
240 |
241 | test "dynamically added tool appears in tools/list" do
242 | request_tool_list(nil, router: TestRouter)
243 |
244 | assert_receive {:conn, conn}
245 | assert conn.status == 200
246 |
247 | assert_receive {:response, 1, "message", response}
248 | assert %{jsonrpc: "2.0", id: 1, result: %{tools: tools}} = response
249 |
250 | tool_names = Enum.map(tools, & &1.name)
251 | assert "dynamic_echo_tool" in tool_names
252 | end
253 |
254 | test "can invoke dynamically added tool via tools/call" do
255 | request_tool("dynamic_echo_tool", %{message: "hello"}, router: TestRouter)
256 |
257 | assert_receive {:conn, conn}
258 | assert conn.status == 200
259 |
260 | assert_receive {:response, 1, "message", response}
261 |
262 | assert %{
263 | jsonrpc: "2.0",
264 | id: 1,
265 | result: %{
266 | content: [%{type: "text", text: "Dynamic echo: hello"}]
267 | }
268 | } = response
269 | end
270 | end
271 | end
272 |
--------------------------------------------------------------------------------
/test/phantom/router_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Phantom.RouterTest do
2 | use ExUnit.Case
3 | import ExUnit.CaptureLog
4 | import Phantom.TestDispatcher
5 | import Plug.Conn
6 | import Plug.Test
7 |
8 | doctest Phantom.Plug
9 |
10 | setup do
11 | start_supervised({Phoenix.PubSub, name: Test.PubSub})
12 | start_supervised({Phantom.Tracker, [name: Phantom.Tracker, pubsub_server: Test.PubSub]})
13 | Phantom.Cache.register(Test.MCP.Router)
14 | :ok
15 | end
16 |
17 | test "dispatches to tools" do
18 | request_tool("echo_tool", %{message: "hello world"})
19 |
20 | assert_receive {:conn, conn}
21 | assert conn.status == 200
22 |
23 | response = %{
24 | id: 1,
25 | jsonrpc: "2.0",
26 | result: %{
27 | content: [%{text: "hello world", type: "text"}]
28 | }
29 | }
30 |
31 | assert_receive {:response, _id, _event, ^response}
32 | end
33 |
34 | test "handles notifications (no id)" do
35 | :post
36 | |> conn("/mcp", JSON.encode!(%{jsonrpc: "2.0", method: "notification"}))
37 | |> put_req_header("content-type", "application/json")
38 | |> call()
39 |
40 | assert_receive {:conn, conn}
41 | assert conn.status == 200
42 | assert_receive {:response, nil, "message", %{id: nil, result: nil, jsonrpc: "2.0"}}
43 | end
44 |
45 | test "returns error for unknown method" do
46 | :post
47 | |> conn("/mcp", JSON.encode!(%{jsonrpc: "2.0", method: "unknown", id: 1}))
48 | |> put_req_header("content-type", "application/json")
49 | |> call()
50 |
51 | assert_receive {:conn, conn}
52 | assert conn.status == 200
53 |
54 | assert_receive {:response, 1, "message", error}
55 | assert error[:error][:code] == -32601
56 | assert error[:error][:message] == "Method not found"
57 | end
58 |
59 | test "handles router errors when there's batched calls" do
60 | Process.flag(:trap_exit, true)
61 |
62 | capture_log(fn ->
63 | pid =
64 | :post
65 | |> conn("/mcp", %{
66 | "_json" => [
67 | %{
68 | jsonrpc: "2.0",
69 | id: 1,
70 | method: "tools/call",
71 | params: %{"name" => "explode_tool"}
72 | },
73 | %{
74 | jsonrpc: "2.0",
75 | id: 2,
76 | method: "tools/call",
77 | params: %{"name" => "explode_tool"}
78 | }
79 | ]
80 | })
81 | |> put_req_header("content-type", "application/json")
82 | |> call()
83 |
84 | assert_receive {:response, 1, "message", error}
85 | assert_receive {:EXIT, ^pid, {exception, _stacktrace}}
86 |
87 | assert %{
88 | error: %{code: -32603, message: "boom"},
89 | id: 1,
90 | jsonrpc: "2.0"
91 | } = error
92 |
93 | assert %Phantom.ErrorWrapper{} = exception
94 |
95 | assert [
96 | {
97 | %{params: %{"name" => "explode_tool"}},
98 | %RuntimeError{message: "boom"},
99 | _stacktrace_one
100 | },
101 | {
102 | %{params: %{"name" => "explode_tool"}},
103 | %RuntimeError{message: "boom"},
104 | _stacktrace_two
105 | }
106 | ] = exception.exceptions_by_request
107 | end)
108 | end
109 |
110 | test "handles router errors when there's a single request" do
111 | Process.flag(:trap_exit, true)
112 |
113 | capture_log(fn ->
114 | request_tool("explode_tool", %{}, id: 4)
115 | assert_exception_response(4, error, {exception, _stacktrace})
116 |
117 | assert %{
118 | error: %{code: -32603, message: "boom"},
119 | id: 4,
120 | jsonrpc: "2.0"
121 | } = error
122 |
123 | assert %RuntimeError{message: "boom"} = exception
124 | end)
125 | end
126 |
127 | describe "resource URI matching" do
128 | test "routes the requested resource to the correct remote handler" do
129 | request_resource_read("test:///text/many/1")
130 |
131 | assert_receive {:conn, conn}
132 | assert conn.status == 200
133 | assert_receive {:response, 1, "message", response}
134 |
135 | assert %{
136 | jsonrpc: "2.0",
137 | id: 1,
138 | result: %{contents: contents}
139 | } = response
140 |
141 | assert %{
142 | uri: "test:///text/many/1",
143 | mimeType: "text/plain",
144 | text: "1"
145 | } in contents
146 |
147 | assert length(contents) == 10
148 | end
149 |
150 | test "routes the requested resource to the correct function handler" do
151 | request_resource_read("test:///text/1")
152 |
153 | assert_receive {:conn, conn}
154 | assert conn.status == 200
155 | assert_receive {:response, 1, "message", response}
156 |
157 | assert %{
158 | jsonrpc: "2.0",
159 | id: 1,
160 | result: %{
161 | contents: [
162 | %{
163 | uri: "test:///text/1",
164 | text: ~S|{"id":"1"}|
165 | }
166 | ]
167 | }
168 | } = response
169 | end
170 | end
171 |
172 | describe "authentication" do
173 | defmodule Test.UnauthorizedRouter do
174 | @instructions """
175 | A test MCP router that requires authentication and returns unauthorized responses.
176 | """
177 |
178 | use Phantom.Router,
179 | name: "UnauthorizedTest",
180 | vsn: "1.0",
181 | validate_origin: false,
182 | instructions: @instructions
183 |
184 | @doc """
185 | Connect callback that always returns unauthorized with WWW-Authenticate header
186 | """
187 | def connect(_session, _headers) do
188 | www_authenticate = %{
189 | method: "Bearer",
190 | realm: "mcp-server",
191 | scope: "read write"
192 | }
193 |
194 | {:unauthorized, www_authenticate}
195 | end
196 | end
197 |
198 | test "connect callback responds with unauthorized and www-authenticate header (map format)" do
199 | Phantom.Cache.register(Test.UnauthorizedRouter)
200 |
201 | :post
202 | |> conn("/mcp", %{jsonrpc: "2.0", method: "ping", id: 1})
203 | |> put_req_header("content-type", "application/json")
204 | |> call(router: Test.UnauthorizedRouter)
205 |
206 | assert_receive {:conn, conn}
207 | assert conn.status == 401
208 |
209 | # Verify WWW-Authenticate header is present and properly formatted
210 | [www_auth_header] = get_resp_header(conn, "www-authenticate")
211 | assert www_auth_header =~ "Bearer"
212 | assert www_auth_header =~ ~s|realm="mcp-server"|
213 | assert www_auth_header =~ ~s|scope="read write"|
214 |
215 | # Verify the response body contains the correct error
216 | response_body = JSON.decode!(conn.resp_body)
217 | assert response_body["error"]["code"] == -32000
218 | assert response_body["error"]["message"] == "Unauthorized"
219 | assert response_body["jsonrpc"] == "2.0"
220 | # ID may be nil in error responses during connection phase
221 | refute response_body["id"]
222 | end
223 |
224 | test "connect callback responds with unauthorized and www-authenticate header (string format)" do
225 | # Create a router that returns a string www-authenticate header
226 | defmodule Test.UnauthorizedStringRouter do
227 | use Phantom.Router,
228 | name: "UnauthorizedStringTest",
229 | vsn: "1.0",
230 | validate_origin: false,
231 | instructions: "Test router with string www-authenticate"
232 |
233 | def connect(_session, _headers) do
234 | {:unauthorized, "Bearer realm=\"string-test\", scope=\"read\""}
235 | end
236 | end
237 |
238 | Phantom.Cache.register(Test.UnauthorizedStringRouter)
239 |
240 | :post
241 | |> conn("/mcp", %{
242 | jsonrpc: "2.0",
243 | method: "initialize",
244 | id: 2,
245 | params: %{
246 | protocolVersion: "2024-11-05",
247 | capabilities: %{},
248 | clientInfo: %{name: "TestClient", version: "1.0.0"}
249 | }
250 | })
251 | |> put_req_header("content-type", "application/json")
252 | |> call(router: Test.UnauthorizedStringRouter)
253 |
254 | assert_receive {:conn, conn}
255 | assert conn.status == 401
256 |
257 | # Verify string WWW-Authenticate header
258 | [www_auth_header] = get_resp_header(conn, "www-authenticate")
259 | assert www_auth_header == "Bearer realm=\"string-test\", scope=\"read\""
260 |
261 | # Verify error response
262 | response_body = JSON.decode!(conn.resp_body)
263 | assert response_body["error"]["code"] == -32000
264 | assert response_body["error"]["message"] == "Unauthorized"
265 | # ID may be nil in error responses during connection phase
266 | refute response_body["id"]
267 | end
268 | end
269 | end
270 |
--------------------------------------------------------------------------------
/lib/phantom/tracker.ex:
--------------------------------------------------------------------------------
1 | defmodule Phantom.Tracker do
2 | @moduledoc """
3 | Track open streams so that notifications and requests can be sent to clients.
4 |
5 | Add to your supervision tree:
6 |
7 | {Phoenix.PubSub, name: MyApp.PubSub},
8 | {Phantom.Tracker, [name: Phantom.Tracker, pubsub_server: MyApp.PubSub]},
9 |
10 | For example, a request may need to elicit more input from the client, so the
11 | first request stream will remain open, and the notification stream will send
12 | and new request to the client, and the client will POST its response. The
13 | new response connection will notify the first request connection with the result
14 | and the tool can continue with the elicited information.
15 |
16 | See `m:Phantom#module-persistent-streams` section for more information.
17 | """
18 |
19 | use Phoenix.Tracker
20 |
21 | @sessions "phantom:sessions"
22 | @requests "phantom:requests"
23 | @resources "phantom:resources"
24 |
25 | @available Code.ensure_loaded?(Phoenix.Tracker) and Code.ensure_loaded?(Phoenix.PubSub)
26 |
27 | def resource_subscription_topic, do: @resources
28 | def requests_topic, do: @requests
29 | def sessions_topic, do: @sessions
30 |
31 | @doc false
32 | if @available do
33 | def start_link(opts) do
34 | opts = Keyword.merge([name: __MODULE__], opts)
35 | Phoenix.Tracker.start_link(__MODULE__, opts, opts)
36 | end
37 | else
38 | def start_link(_opts), do: :error
39 | end
40 |
41 | @doc false
42 | if @available do
43 | def init(opts) do
44 | server = Keyword.fetch!(opts, :pubsub_server)
45 | {:ok, %{pubsub_server: server, node_name: Phoenix.PubSub.node_name(server)}}
46 | end
47 | else
48 | def init(_opts), do: :ignore
49 | end
50 |
51 | @doc "Track a request PID"
52 | if @available do
53 | def track_request(pid, request_id, meta \\ %{}) do
54 | Phoenix.Tracker.track(__MODULE__, pid, @requests, request_id, meta)
55 | rescue
56 | _ -> {:error, :tracker_not_in_supervision_tree}
57 | end
58 | else
59 | def track_request(_pid, _request_id, _meta \\ %{}), do: {:error, :not_available}
60 | end
61 |
62 | @doc "Track a session PID"
63 | if @available do
64 | require Logger
65 |
66 | def track_session(pid, session_id, meta \\ %{}) do
67 | Phoenix.Tracker.track(__MODULE__, pid, @sessions, session_id, meta)
68 | rescue
69 | _ ->
70 | if not warned?() do
71 | Logger.warning(
72 | "Phoenix.PubSub is available, but Phantom.Tracker is not in the supervision tree. Please add this to your supervision tree: `{Phantom.Tracker, [name: Phantom.Tracker, pubsub_server: MyApp.PubSub]}`"
73 | )
74 | end
75 |
76 | {:error, :tracker_not_in_supervision_tree}
77 | end
78 | else
79 | def track_session(pid, session_id, meta \\ %{}), do: {:error, :not_available}
80 | end
81 |
82 | defp warned? do
83 | :persistent_term.get({Phantom.Tracker, :warned}, false) == true ||
84 | :persistent_term.put({Phantom.Tracker, :warned}, true) != :ok
85 | end
86 |
87 | @doc "Return a list of all open sessions"
88 | if @available do
89 | def list_sessions do
90 | Phoenix.Tracker.list(__MODULE__, @sessions)
91 | rescue
92 | _ -> []
93 | end
94 | else
95 | def list_sessions, do: []
96 | end
97 |
98 | @doc "Return a list of all open requests"
99 | if @available do
100 | def list_requests do
101 | Phoenix.Tracker.list(__MODULE__, @requests)
102 | rescue
103 | _ -> []
104 | end
105 | else
106 | def list_requests, do: []
107 | end
108 |
109 | @doc "Return a list of all listening for resources"
110 | if @available do
111 | def list_resource_listeners do
112 | Phoenix.Tracker.list(__MODULE__, @resources)
113 | rescue
114 | _ -> []
115 | end
116 | else
117 | def list_resource_listeners, do: []
118 | end
119 |
120 | @doc "Fetch the PID of the open request by ID"
121 | def get_request(%Phantom.Request{id: request_id}), do: get_request(request_id)
122 |
123 | if @available do
124 | def get_request(request_id) do
125 | case Phoenix.Tracker.get_by_key(__MODULE__, @requests, request_id) do
126 | [{pid, _} | _] ->
127 | if Process.alive?(pid) do
128 | pid
129 | else
130 | Phoenix.Tracker.untrack(__MODULE__, pid)
131 | nil
132 | end
133 |
134 | _ ->
135 | nil
136 | end
137 | rescue
138 | _ -> nil
139 | end
140 | else
141 | def get_request(_request_id), do: nil
142 | end
143 |
144 | @doc "Fetch the PID of the open session by ID"
145 | def get_session(%Phantom.Session{id: session_id}), do: get_session(session_id)
146 |
147 | if @available do
148 | def get_session(session_id) do
149 | case Phoenix.Tracker.get_by_key(__MODULE__, @sessions, session_id) do
150 | [{pid, _} | _] ->
151 | if Process.alive?(pid) do
152 | pid
153 | else
154 | Phoenix.Tracker.untrack(__MODULE__, pid)
155 | nil
156 | end
157 |
158 | _ ->
159 | nil
160 | end
161 | rescue
162 | _ -> nil
163 | end
164 | else
165 | def get_session(_session_id), do: nil
166 | end
167 |
168 | @doc "Untrack the processe for everything"
169 | if @available do
170 | def untrack(pid), do: Phoenix.Tracker.untrack(__MODULE__, pid)
171 | else
172 | def untrack(_pid), do: :ok
173 | end
174 |
175 | @doc "Untrack any processes for the session"
176 | def untrack_session(%Phantom.Session{id: session_id}), do: untrack_session(session_id)
177 |
178 | if @available do
179 | def untrack_session(session_id) do
180 | tracked =
181 | try do
182 | Phoenix.Tracker.get_by_key(__MODULE__, @sessions, session_id)
183 | rescue
184 | _ -> []
185 | end
186 |
187 | Enum.each(tracked, fn {pid, _} -> Phoenix.Tracker.untrack(__MODULE__, pid) end)
188 |
189 | :ok
190 | rescue
191 | _ -> :ok
192 | end
193 | else
194 | def untrack_session(_session_id), do: :ok
195 | end
196 |
197 | @doc "Untrack any processes for the request"
198 | def untrack_request(%Phantom.Request{id: request_id}), do: untrack_request(request_id)
199 |
200 | if @available do
201 | def untrack_request(request_id) do
202 | tracked =
203 | try do
204 | Phoenix.Tracker.get_by_key(__MODULE__, @requests, request_id)
205 | rescue
206 | _ -> []
207 | end
208 |
209 | Enum.each(tracked, fn {pid, _meta} -> Phoenix.Tracker.untrack(__MODULE__, pid) end)
210 |
211 | :ok
212 | rescue
213 | _ -> :ok
214 | end
215 | else
216 | def untrack_request(_request_id), do: :ok
217 | end
218 |
219 | @doc "Subscribe the process to resource notifications from the PubSub on topic #{inspect(@resources)}"
220 | if @available do
221 | def subscribe_resource(uri) do
222 | Phoenix.Tracker.track(__MODULE__, self(), @resources, uri, %{})
223 | rescue
224 | _ -> {:error, :tracker_not_in_supervision_tree}
225 | end
226 | else
227 | def subscribe_resource(pubsub, uri), do: {:error, :not_available}
228 | end
229 |
230 | @doc "Unsubscribe the process to resource notifications from the PubSub on topic #{inspect(@resources)}"
231 | if @available do
232 | def unsubscribe_resource(uri) do
233 | Phoenix.Tracker.untrack(__MODULE__, self(), @resources, uri)
234 | rescue
235 | _ -> :ok
236 | end
237 | else
238 | def unsubscribe_resource(pubsub, uri), do: {:error, :not_available}
239 | end
240 |
241 | @doc "Notify any listening MCP sessions that the resource has updated"
242 | if @available do
243 | def notify_resource_updated(uri) do
244 | tracked =
245 | try do
246 | Phoenix.Tracker.get_by_key(__MODULE__, @resources, uri)
247 | rescue
248 | _ -> []
249 | end
250 |
251 | {:ok,
252 | Enum.count(tracked, fn {pid, _meta} -> GenServer.cast(pid, {:resource_updated, uri}) end)}
253 | rescue
254 | _ -> {:error, :tracker_not_in_supervision_tree}
255 | end
256 | else
257 | def notify_resource_updated(_), do: {:ok, 0}
258 | end
259 |
260 | @doc "Notify any listening MCP sessions that the list of tools has updated"
261 | if @available do
262 | def notify_tool_list do
263 | {:ok,
264 | Enum.count(list_sessions(), fn {session_id, _} ->
265 | if pid = get_session(session_id), do: GenServer.cast(pid, :tools_updated)
266 | end)}
267 | end
268 | else
269 | def notify_tool_list(_), do: {:ok, 0}
270 | end
271 |
272 | @doc "Notify any listening MCP sessions that the list of prompts has updated"
273 | if @available do
274 | def notify_prompt_list do
275 | {:ok,
276 | Enum.count(list_sessions(), fn {session_id, _} ->
277 | if pid = get_session(session_id), do: GenServer.cast(pid, :prompts_updated)
278 | end)}
279 | end
280 | else
281 | def notify_prompt_list(_), do: {:ok, 0}
282 | end
283 |
284 | @doc "Notify any listening MCP sessions that the list of prompts has updated"
285 | if @available do
286 | def notify_resource_list do
287 | {:ok,
288 | Enum.count(list_sessions(), fn {session_id, _} ->
289 | if pid = get_session(session_id), do: GenServer.cast(pid, :resources_updated)
290 | end)}
291 | end
292 | else
293 | def notify_resource_list(_), do: {:ok, 0}
294 | end
295 |
296 | @doc false
297 | if @available do
298 | def handle_diff(diff, state) do
299 | for {topic, {joins, leaves}} <- diff do
300 | for {key, meta} <- joins do
301 | msg = {:join, key, meta}
302 | Phoenix.PubSub.direct_broadcast!(state.node_name, state.pubsub_server, topic, msg)
303 | end
304 |
305 | for {key, meta} <- leaves do
306 | msg = {:leave, key, meta}
307 | Phoenix.PubSub.direct_broadcast!(state.node_name, state.pubsub_server, topic, msg)
308 | end
309 | end
310 |
311 | {:ok, state}
312 | end
313 | else
314 | def handle_diff(_diff, state), do: {:ok, state}
315 | end
316 | end
317 |
--------------------------------------------------------------------------------
/lib/phantom/tool.ex:
--------------------------------------------------------------------------------
1 | defmodule Phantom.Tool do
2 | @moduledoc """
3 | The Model Context Protocol (MCP) allows servers to expose tools
4 | that can be invoked by language models. Tools enable models to
5 | interact with external systems, such as querying databases,
6 | calling APIs, or performing computations. Each tool is uniquely
7 | identified by a name and includes metadata describing its schema.
8 |
9 | https://modelcontextprotocol.io/specification/2025-03-26/server/tools
10 |
11 | ```mermaid
12 | sequenceDiagram
13 | participant LLM
14 | participant Client
15 | participant Server
16 |
17 | Note over Client,Server: Discovery
18 | Client->>Server: tools/list
19 | Server-->>Client: List of tools
20 |
21 | Note over Client,LLM: Tool Selection
22 | LLM->>Client: Select tool to use
23 |
24 | Note over Client,Server: Invocation
25 | Client->>Server: tools/call
26 | Server-->>Client: Tool result
27 | Client->>LLM: Process result
28 |
29 | Note over Client,Server: Updates
30 | Server--)Client: tools/list_changed
31 | Client->>Server: tools/list
32 | Server-->>Client: Updated tools
33 | ```
34 | """
35 |
36 | import Phantom.Utils
37 |
38 | alias Phantom.Tool.Annotation
39 | alias Phantom.Tool.JSONSchema
40 |
41 | @enforce_keys ~w[name handler function]a
42 | defstruct [
43 | :name,
44 | :description,
45 | :mime_type,
46 | :handler,
47 | :function,
48 | :output_schema,
49 | :input_schema,
50 | :annotations,
51 | meta: %{}
52 | ]
53 |
54 | @type t :: %__MODULE__{
55 | name: String.t(),
56 | description: String.t(),
57 | handler: module(),
58 | function: atom(),
59 | mime_type: String.t(),
60 | meta: map(),
61 | input_schema: JSONSchema.t(),
62 | output_schema: JSONSchema.t(),
63 | annotations: Annotation.t()
64 | }
65 |
66 | @type json :: %{
67 | required(:name) => String.t(),
68 | required(:description) => String.t(),
69 | required(:inputSchema) => JSONSchema.json(),
70 | optional(:outputSchema) => JSONSchema.json(),
71 | optional(:annotations) => Annotation.json()
72 | }
73 |
74 | @type image_response :: %{
75 | content: [
76 | type: :image,
77 | data: base64_binary :: binary(),
78 | mimeType: String.t()
79 | ]
80 | }
81 |
82 | @type audio_response :: %{
83 | content: [
84 | type: :audio,
85 | data: base64_binary :: binary(),
86 | mimeType: String.t()
87 | ]
88 | }
89 |
90 | @type embedded_text_resource :: %{
91 | required(:uri) => String.t(),
92 | required(:text) => String.t(),
93 | optional(:mimeType) => String.t(),
94 | optional(:title) => String.t(),
95 | optional(:description) => String.t()
96 | }
97 | @type embedded_blob_resource :: %{
98 | required(:uri) => String.t(),
99 | required(:data) => String.t(),
100 | optional(:mimeType) => String.t(),
101 | optional(:title) => String.t(),
102 | optional(:description) => String.t()
103 | }
104 |
105 | @type embedded_resource_response :: %{
106 | content: [
107 | type: :resource,
108 | resource:
109 | embedded_text_resource()
110 | | embedded_blob_resource()
111 | ]
112 | }
113 |
114 | @type resource_link_response :: %{
115 | content: [
116 | type: :resource_link,
117 | uri: String.t(),
118 | name: String.t(),
119 | description: String.t(),
120 | mimeType: String.t()
121 | ]
122 | }
123 |
124 | @type text_response :: %{
125 | content: [
126 | type: :text,
127 | text: String.t()
128 | ]
129 | }
130 |
131 | @type structured_response :: %{
132 | structuredContent: map(),
133 | content: [
134 | type: :text,
135 | text: json_encoded :: String.t()
136 | ]
137 | }
138 |
139 | @type error_response :: %{
140 | isError: true,
141 | content: [
142 | type: :text,
143 | text: String.t()
144 | ]
145 | }
146 |
147 | @type response ::
148 | image_response()
149 | | audio_response()
150 | | text_response()
151 | | structured_response()
152 | | embedded_resource_response()
153 | | resource_link_response()
154 |
155 | @doc """
156 | Build a tool spec. Be intentional with the name and description when defining
157 | the tool since it will inform the LLM when to use the tool.
158 |
159 | The `Phantom.Router.tool/3` macro will build these specs.
160 |
161 | Fields:
162 |
163 | - `:name` - The name of the tool.
164 | - `:title` A human-readable title for the tool, useful for UI display.
165 | - `:description` - The description of the tool and when to use it.
166 | - `:mime_type` - the MIME type of the results.
167 | - `:handler` - The module to call.
168 | - `:function` - The function to call on the handler module.
169 | - `:output_schema` - the JSON schema of the results.
170 | - `:input_schema` - The JSON schema of the input arguments.
171 | - `:read_only` If `true`, indicates the tool does not modify its environment.
172 | - `:destructive` If `true`, the tool may perform destructive updates (only meaningful when `:read_only` is `false`).
173 | - `:idempotent` If `true`, calling the tool repeatedly with the same arguments has no additional effect (only meaningful when `:read_only` is `false`).
174 | - `:open_world` If `true`, the tool may interact with an "open world" of external entities.
175 |
176 | """
177 | def build(attrs) do
178 | attrs = Map.new(attrs)
179 |
180 | {annotation_attrs, attrs} =
181 | Map.split(attrs, ~w[title idempotent destructive read_only open_world]a)
182 |
183 | attrs =
184 | Map.merge(attrs, %{
185 | name: attrs[:name] || to_string(attrs[:function])
186 | })
187 |
188 | %{
189 | struct!(__MODULE__, attrs)
190 | | annotations: Annotation.build(annotation_attrs),
191 | output_schema: JSONSchema.build(attrs[:output_schema]),
192 | input_schema: JSONSchema.build(attrs[:input_schema])
193 | }
194 | end
195 |
196 | @doc """
197 | Represent a Tool spec as json when listing the available tools to clients.
198 | """
199 | def to_json(%__MODULE__{} = tool) do
200 | remove_nils(%{
201 | name: tool.name,
202 | description: tool.description,
203 | inputSchema: JSONSchema.to_json(tool.input_schema),
204 | outputSchema: if(tool.output_schema, do: JSONSchema.to_json(tool.output_schema)),
205 | annotations: Annotation.to_json(tool.annotations)
206 | })
207 | end
208 |
209 | @spec text(map) :: structured_response()
210 | def text(data) when is_map(data) do
211 | %{
212 | structuredContent: data,
213 | content: [%{type: "text", text: JSON.encode!(data)}]
214 | }
215 | end
216 |
217 | @spec text(String.t()) :: text_response()
218 | def text(data) do
219 | %{content: [%{type: "text", text: data || ""}]}
220 | end
221 |
222 | @spec error(message :: String.t()) :: error_response()
223 | def error(message) do
224 | %{content: [%{type: "text", text: message}], isError: true}
225 | end
226 |
227 | @spec audio(binary()) :: audio_response()
228 | @doc """
229 | Tool response as audio content
230 |
231 | The `:mime_type` will be fetched from the current tool within the scope of
232 | the request if not provided, but you will need to provide the rest.
233 |
234 | - `binary` - Binary data.
235 | - `:mime_type` (optional) MIME type. Defaults to `"application/octet-stream"`
236 |
237 | For example:
238 |
239 | Phantom.Tool.audio(File.read!("game-over.wav"))
240 |
241 | Phantom.Tool.audio(
242 | File.read!("game-over.wav"),
243 | mime_type: "audio/wav"
244 | )
245 | """
246 | defmacro audio(binary, attrs \\ []) do
247 | mime_type =
248 | get_var(
249 | attrs,
250 | :mime_type,
251 | [:spec, :mime_type],
252 | __CALLER__,
253 | "application/octet-stream"
254 | )
255 |
256 | quote do
257 | %{
258 | content: [
259 | %{
260 | type: "audio",
261 | data: Base.encode64(unquote(binary) || <<>>),
262 | mimeType: unquote(mime_type)
263 | }
264 | ]
265 | }
266 | end
267 | end
268 |
269 | @spec image(binary()) :: image_response()
270 | @doc """
271 | Tool response as image content
272 |
273 | The `:mime_type` will be fetched from the current tool within the scope of
274 | the request if not provided, but you will need to provide the rest.
275 |
276 | - `binary` - Binary data.
277 | - `:mime_type` (optional) MIME type. Defaults to `"application/octet-stream"`
278 |
279 | For example:
280 |
281 | Phantom.Tool.image(File.read!("tower.png"))
282 |
283 | Phantom.Tool.audio(
284 | File.read!("tower.png"),
285 | mime_type: "image/png"
286 | )
287 | """
288 | defmacro image(binary, attrs \\ []) do
289 | mime_type =
290 | get_var(
291 | attrs,
292 | :mime_type,
293 | [:spec, :mime_type],
294 | __CALLER__,
295 | "application/octet-stream"
296 | )
297 |
298 | quote do
299 | %{
300 | content: [
301 | %{
302 | type: "image",
303 | data: Base.encode64(unquote(binary) || <<>>),
304 | mimeType: unquote(mime_type)
305 | }
306 | ]
307 | }
308 | end
309 | end
310 |
311 | @doc """
312 | Embedded resource response.
313 |
314 | Typically used with your router's `read_resource/3` function.
315 | See `Phantom.Router.read_resource/3` for more information
316 | """
317 | @spec embedded_resource(string_uri :: String.t(), map()) :: embedded_resource_response()
318 | def embedded_resource(uri, resource) do
319 | %{
320 | content: [%{type: :resource, resource: Map.put(resource, :uri, uri)}]
321 | }
322 | end
323 |
324 | @doc """
325 | Resource link reponse.
326 |
327 | Typically used with your router's `resource_uri/3` function.
328 | See `Phantom.Router.resource_uri/3` for more information.
329 | """
330 | @spec resource_link(string_uri :: String.t(), Phantom.ResourceTemplate.t(), map()) ::
331 | resource_link_response()
332 | def resource_link(uri, resource_template, resource_attrs \\ %{}) do
333 | resource_attrs = Map.new(resource_attrs)
334 | resource_link = Phantom.Resource.resource_link(uri, resource_template, resource_attrs)
335 |
336 | %{
337 | content: [
338 | remove_nils(Map.put(resource_link, :type, :resource_link))
339 | ]
340 | }
341 | end
342 |
343 | @doc "Formats the response from an MCP Router to the MCP specification"
344 | def response(%{content: _} = results), do: results
345 |
346 | def response(results) do
347 | %{content: List.wrap(results)}
348 | end
349 | end
350 |
--------------------------------------------------------------------------------
/test/support/app/mcp/router.ex:
--------------------------------------------------------------------------------
1 | defmodule Test.MCP.Router do
2 | @base "test/support/fixtures"
3 |
4 | @instructions """
5 | A test MCP server for the purpose of end-to-end tests.
6 | Please call all available tools, prompts, and resources.
7 | """
8 |
9 | use Phantom.Router,
10 | name: "Test",
11 | vsn: "1.0",
12 | instructions: @instructions
13 |
14 | require Phantom.Session, as: Session
15 | require Phantom.Tool, as: Tool
16 | require Phantom.Prompt, as: Prompt
17 | require Phantom.Resource, as: Resource
18 |
19 | require Logger
20 |
21 | def connect(session, %{headers: _headers, params: _params}) do
22 | {:ok, session}
23 | end
24 |
25 | @salt "cursor"
26 | def list_resources(cursor, session) do
27 | {:ok, uri1, _spec} = resource_for(session, :binary_resource, id: "foo")
28 | {:ok, uri2, spec} = resource_for(session, :binary_resource, id: "bar")
29 |
30 | binary_resources = [
31 | Resource.resource_link(uri1, spec, name: "Binary Foo"),
32 | Resource.resource_link(uri2, spec, name: "Binary Bar")
33 | ]
34 |
35 | cursor =
36 | if cursor do
37 | {:ok, cursor} = Phoenix.Token.verify(Test.Endpoint, @salt, cursor)
38 | cursor
39 | else
40 | 0
41 | end
42 |
43 | resource_links =
44 | binary_resources ++
45 | Enum.map(3..1000, fn i ->
46 | {:ok, uri, spec} = resource_for(session, :text_resource, id: i)
47 | Resource.resource_link(uri, spec, name: "Resource #{i}")
48 | end)
49 |
50 | {_before_cursor, after_cursor} =
51 | resource_links |> Enum.with_index() |> Enum.split_while(fn {_, i} -> i < cursor end)
52 |
53 | {page, [{next, _} | _drop]} = Enum.split(after_cursor, 100)
54 | next_cursor = Phoenix.Token.sign(Test.Endpoint, @salt, next)
55 | page = Enum.map(page, &elem(&1, 0))
56 |
57 | {:reply, Resource.list(page, next_cursor), session}
58 | end
59 |
60 | tool :explode_tool, description: "Always throws an exception"
61 | tool :binary_tool, mime_type: "image/png", description: "A binary tool"
62 | tool :audio_tool, description: "An audio tool"
63 | tool :with_error_tool, description: "A test tool with an error"
64 | tool :elicit_tool, description: "A tool that always needs info"
65 |
66 | for i <- 0..200 do
67 | tool :"zzz_tool_#{String.pad_leading(to_string(i), 3, "0")}",
68 | description: "do not use",
69 | function: :zzz_tool
70 | end
71 |
72 | def zzz_tool(_params, session) do
73 | {:reply, Tool.text("foo"), session}
74 | end
75 |
76 | for i <- 0..200 do
77 | prompt :"zzz_prompt_#{String.pad_leading(to_string(i), 3, "0")}",
78 | description: "do not use",
79 | function: :zzz_prompt
80 | end
81 |
82 | def zzz_prompt(_params, session) do
83 | {:reply, Prompt.response(user: Prompt.text("foo")), session}
84 | end
85 |
86 | tool :async_embedded_resource_tool, AsyncModule,
87 | description: "An asyncronous embedded resource tool"
88 |
89 | tool :embedded_resource_tool, AsyncModule, description: "A embedded resource tool"
90 | tool :embedded_resource_link_tool, AsyncModule, description: "A embedded resource link tool"
91 |
92 | tool :echo_tool,
93 | description: "A test that echos your message",
94 | input_schema: %{
95 | required: [:message],
96 | properties: %{
97 | message: %{
98 | type: "string",
99 | description: "message to echo"
100 | }
101 | }
102 | }
103 |
104 | tool :structured_echo_tool,
105 | description: "A test that echos your message",
106 | input_schema: %{
107 | required: [:message],
108 | properties: %{
109 | message: %{
110 | type: "string",
111 | description: "message to echo"
112 | }
113 | }
114 | },
115 | output_schema: %{
116 | required: [:message],
117 | properties: %{
118 | message: %{
119 | type: "string",
120 | description: "echo"
121 | }
122 | }
123 | }
124 |
125 | tool :really_long_async_tool, AsyncModule, description: "this will notify of progress"
126 | tool :timeout_async_tool, AsyncModule, description: "this will timeout!"
127 |
128 | @audio File.read!(@base <> "/game-over.wav")
129 | def audio_tool(_params, session) do
130 | {:reply, Tool.audio(@audio, mime_type: "audio/wav"), session}
131 | end
132 |
133 | @description "A test that echos your message slowly"
134 | tool :async_echo_tool, AsyncModule,
135 | input_schema: %{
136 | required: [:message],
137 | properties: %{
138 | message: %{
139 | type: "string",
140 | description: "message to echo"
141 | }
142 | }
143 | }
144 |
145 | prompt :explode_prompt, description: "Always throws an exception"
146 | prompt :binary_prompt, description: "An image prompt"
147 | prompt :resource_prompt, description: "A resource prompt"
148 |
149 | prompt :async_resource_prompt, AsyncModule,
150 | description: "A resource prompt that has an async read"
151 |
152 | prompt :text_prompt,
153 | description: "A text prompt",
154 | completion_function: :text_prompt_complete,
155 | arguments: [
156 | %{
157 | name: "code",
158 | description: "The code to review",
159 | required: true
160 | }
161 | ]
162 |
163 | resource "myapp:///binary/:id", AsyncModule, :binary_resource,
164 | completion_function: :binary_resource_complete,
165 | description: "An image resource",
166 | mime_type: "image/png"
167 |
168 | @description "Many text resources"
169 | resource "test:///text/many/:id", :text_resource_many
170 | resource "test:///text/:id", :text_resource, description: "A text resource"
171 | resource "explode:///:id", :explode_resource, description: "One that explodes!"
172 |
173 | @description "These are not the resources you are looking for"
174 | resource "test:///unfound/:id", :resource_unfound
175 |
176 | @test_png File.read!(@base <> "/test.png")
177 | def binary_tool(_params, session) do
178 | {:reply, Phantom.Tool.image(@test_png, foo: :bar), session}
179 | end
180 |
181 | def explode_tool(_params, _session), do: raise("boom")
182 |
183 | def echo_tool(params, session) do
184 | {:reply, Phantom.Tool.text(params["message"] || ""), session}
185 | end
186 |
187 | @elicit_name Phantom.Elicit.build(%{
188 | message: "What is your info?",
189 | requested_schema: [
190 | %{
191 | type: :string,
192 | name: "name",
193 | required: true,
194 | title: "Your name",
195 | description: "for real"
196 | },
197 | %{
198 | type: :string,
199 | name: "email",
200 | required: true,
201 | title: "Your email",
202 | description: "for realisies",
203 | pattern: Regex.source(~r/^[^@]+@[^@]+\.[^@]+$/),
204 | format: :email
205 | }
206 | ]
207 | })
208 | def elicit_tool(_params, session) do
209 | with {:ok, request_id} <- Session.elicit(session, @elicit_name),
210 | {:ok, response} <- await_elicitation(request_id) do
211 | case response["name"] do
212 | :error -> {:reply, Phantom.Request.invalid_params("Did not receive name"), session}
213 | name -> {:reply, Tool.text(%{hello: "my name is #{name}"}), session}
214 | end
215 | else
216 | :not_supported ->
217 | {:reply, Tool.text(%{hello: "my name is Joe Schmoe"}), session}
218 | end
219 | end
220 |
221 | defp await_elicitation(request_id) do
222 | receive do
223 | {:response, ^request_id, response} ->
224 | if response["action"] == "accept" do
225 | {:ok, response["content"]}
226 | else
227 | :error
228 | end
229 | after
230 | 10_000 -> :error
231 | end
232 | end
233 |
234 | def structured_echo_tool(params, session) do
235 | {:reply, Phantom.Tool.text(%{message: params["message"] || ""}), session}
236 | end
237 |
238 | def with_error_tool(_params, session) do
239 | {:reply, Tool.error("an error"), session}
240 | end
241 |
242 | def explode_prompt(_params, _session), do: raise("boom")
243 |
244 | def binary_prompt(_params, session) do
245 | {:reply, Phantom.Prompt.response(assistant: Phantom.Prompt.image(@test_png, "image/png")),
246 | session}
247 | end
248 |
249 | def resource_prompt(_params, session) do
250 | case read_resource(session, :text_resource, id: 321) do
251 | {:ok, uri, resource} ->
252 | {:reply,
253 | Phantom.Prompt.response(
254 | assistant: Phantom.Prompt.embedded_resource(uri, resource),
255 | user: Phantom.Prompt.text("Wowzers")
256 | ), session}
257 |
258 | error ->
259 | error
260 | end
261 | end
262 |
263 | def text_prompt_complete("code", _value, session) do
264 | {:reply,
265 | %{
266 | values: ~w[one two],
267 | has_more: false,
268 | total: 2
269 | }, session}
270 | end
271 |
272 | def text_prompt(params, session) do
273 | {:reply,
274 | Prompt.response(
275 | assistant: Prompt.text("You are an Elixir expert"),
276 | user: Prompt.text("Please review this Elixir code:\n#{params["code"]}")
277 | ), session}
278 | end
279 |
280 | def text_resource(params, session) do
281 | {:reply, Phantom.Resource.text(params), session}
282 | end
283 |
284 | def explode_resource(_params, _session), do: raise("boom")
285 |
286 | def resource_unfound(_params, session) do
287 | {:reply, nil, session}
288 | end
289 |
290 | def text_resource_many(_params, session) do
291 | data =
292 | for i <- 1..10 do
293 | Phantom.Resource.text(to_string(i))
294 | end
295 |
296 | {:reply, data, session}
297 | end
298 | end
299 |
300 | defmodule AsyncModule do
301 | @timeout Application.compile_env(:phantom_mcp, :timeout, 0)
302 | @base "test/support/fixtures"
303 |
304 | require Phantom.Tool, as: Tool
305 | require Phantom.Prompt, as: Prompt
306 | require Phantom.Resource, as: Resource
307 | require Phantom.Session, as: Session
308 | require Phantom.ClientLogger, as: ClientLogger
309 |
310 | @foo_png File.read!(@base <> "/foo.png")
311 | def binary_resource(%{"id" => "foo"}, session) do
312 | {:reply, Resource.blob(@foo_png, mime_type: "image/png"), session}
313 | end
314 |
315 | @bar_png File.read!(@base <> "/bar.png")
316 | def binary_resource(%{"id" => "bar"}, session) do
317 | ClientLogger.info("An info log")
318 | pid = session.pid
319 | request_id = session.request.id
320 |
321 | Task.async(fn ->
322 | Process.sleep(@timeout)
323 |
324 | Session.respond(
325 | pid,
326 | request_id,
327 | Resource.response(Resource.blob(@bar_png, mime_type: "image/png"))
328 | )
329 | end)
330 |
331 | {:noreply, session}
332 | end
333 |
334 | def binary_resource_complete("id", _value, session) do
335 | {:reply, ~w[foo bar], session}
336 | end
337 |
338 | def async_echo_tool(params, session) do
339 | request_id = session.request.id
340 | pid = session.pid
341 |
342 | Task.async(fn ->
343 | Process.sleep(@timeout)
344 |
345 | Session.respond(
346 | pid,
347 | request_id,
348 | Phantom.Tool.text(params["message"] || "")
349 | )
350 | end)
351 |
352 | {:noreply, session}
353 | end
354 |
355 | def really_long_async_tool(params, session) do
356 | request_id = session.request.id
357 | progress_token = Session.progress_token(session)
358 | pid = session.pid
359 |
360 | Task.async(fn ->
361 | for i <- 1..4 do
362 | Process.sleep(@timeout * 5)
363 | Session.notify_progress(pid, progress_token, i, 4)
364 | end
365 |
366 | Session.respond(
367 | pid,
368 | request_id,
369 | Phantom.Tool.text(params["message"] || "")
370 | )
371 | end)
372 |
373 | {:noreply, session}
374 | end
375 |
376 | def timeout_async_tool(params, session) do
377 | ClientLogger.log(session, :warning, %{message: "This will timeout"}, "database")
378 |
379 | Task.async(fn ->
380 | Process.sleep(@timeout * 15)
381 | Session.respond(session, Phantom.Tool.text(params["message"] || ""))
382 | end)
383 |
384 | {:noreply, session}
385 | end
386 |
387 | def embedded_resource_tool(_params, session) do
388 | with {:ok, uri, resource} <-
389 | Test.MCP.Router.read_resource(session, :binary_resource, id: "bar") do
390 | {:reply, Tool.embedded_resource(uri, resource), session}
391 | end
392 | end
393 |
394 | def embedded_resource_link_tool(_params, session) do
395 | with {:ok, uri, resource_template} <-
396 | Test.MCP.Router.resource_for(session, :binary_resource, id: "foo") do
397 | {:reply, Tool.resource_link(uri, resource_template), session}
398 | end
399 | end
400 |
401 | def async_embedded_resource_tool(_params, session) do
402 | with {:ok, uri, resource} <-
403 | Test.MCP.Router.read_resource(session, :binary_resource, id: "foo") do
404 | {:reply, Tool.embedded_resource(uri, resource), session}
405 | end
406 | end
407 |
408 | def async_resource_prompt(_params, session) do
409 | case Test.MCP.Router.read_resource(session, :binary_resource, id: "foo") do
410 | {:ok, uri, resource} ->
411 | {:reply,
412 | Phantom.Prompt.response(
413 | assistant: Phantom.Prompt.embedded_resource(uri, resource),
414 | user: Phantom.Prompt.text("Wowzers")
415 | ), session}
416 |
417 | error ->
418 | error
419 | end
420 | end
421 | end
422 |
--------------------------------------------------------------------------------
/mix.lock:
--------------------------------------------------------------------------------
1 | %{
2 | "bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"},
3 | "castore": {:hex, :castore, "1.0.14", "4582dd7d630b48cf5e1ca8d3d42494db51e406b7ba704e81fbd401866366896a", [:mix], [], "hexpm", "7bc1b65249d31701393edaaac18ec8398d8974d52c647b7904d01b964137b9f4"},
4 | "circular_buffer": {:hex, :circular_buffer, "0.4.1", "477f370fd8cfe1787b0a1bade6208bbd274b34f1610e41f1180ba756a7679839", [:mix], [], "hexpm", "633ef2e059dde0d7b89bbab13b1da9d04c6685e80e68fbdf41282d4fae746b72"},
5 | "cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"},
6 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
7 | "cowlib": {:hex, :cowlib, "2.15.0", "3c97a318a933962d1c12b96ab7c1d728267d2c523c25a5b57b0f93392b6e9e25", [:make, :rebar3], [], "hexpm", "4f00c879a64b4fe7c8fcb42a4281925e9ffdb928820b03c3ad325a617e857532"},
8 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
9 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"},
10 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
11 | "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"},
12 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"},
13 | "ex_doc": {:hex, :ex_doc, "0.38.2", "504d25eef296b4dec3b8e33e810bc8b5344d565998cd83914ffe1b8503737c02", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "732f2d972e42c116a70802f9898c51b54916e542cc50968ac6980512ec90f42b"},
14 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
15 | "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"},
16 | "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
17 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
18 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
19 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
20 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"},
21 | "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
22 | "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
23 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
24 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
25 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
26 | "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"},
27 | "phoenix_html": {:hex, :phoenix_html, "4.2.1", "35279e2a39140068fc03f8874408d58eef734e488fc142153f055c5454fd1c08", [:mix], [], "hexpm", "cff108100ae2715dd959ae8f2a8cef8e20b593f8dfd031c9cba92702cf23e053"},
28 | "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
29 | "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.17", "beeb16d83a7d3760f7ad463df94e83b087577665d2acc0bf2987cd7d9778068f", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a4ca05c1eb6922c4d07a508a75bfa12c45e5f4d8f77ae83283465f02c53741e1"},
30 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
31 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
32 | "plug": {:hex, :plug, "1.18.0", "d78df36c41f7e798f2edf1f33e1727eae438e9dd5d809a9997c463a108244042", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "819f9e176d51e44dc38132e132fe0accaf6767eab7f0303431e404da8476cfa2"},
33 | "plug_cowboy": {:hex, :plug_cowboy, "2.7.3", "1304d36752e8bdde213cea59ef424ca932910a91a07ef9f3874be709c4ddb94b", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "77c95524b2aa5364b247fa17089029e73b951ebc1adeef429361eab0bb55819d"},
34 | "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
35 | "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
36 | "req": {:hex, :req, "0.5.10", "a3a063eab8b7510785a467f03d30a8d95f66f5c3d9495be3474b61459c54376c", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "8a604815743f8a2d3b5de0659fa3137fa4b1cffd636ecb69b30b2b9b2c2559be"},
37 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
38 | "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
39 | "thousand_island": {:hex, :thousand_island, "1.3.14", "ad45ebed2577b5437582bcc79c5eccd1e2a8c326abf6a3464ab6c06e2055a34a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0d24a929d31cdd1d7903a4fe7f2409afeedff092d277be604966cd6aa4307ef"},
40 | "tidewave": {:hex, :tidewave, "0.1.7", "a93c500a414cfd211c7058a2b4b22759fb8cde5d72c471a34f7046cd66a5a5e6", [:mix], [{:circular_buffer, "~> 0.4", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.47 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "2cfe9c0c3295132cc682b3cd1c859f801bf2e4d02816618d0659f4d765d26435"},
41 | "uuidv7": {:hex, :uuidv7, "1.0.0", "659179b2e248b98f96e7e988b882d369c055b6ae7a836237ccca52cd4d0f6988", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "0ecd337108456f7d8b1a9a54ef435443d3f8c10a5b685bd866ef9e396b444cbc"},
42 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
43 | "websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"},
44 | }
45 |
--------------------------------------------------------------------------------
/lib/phantom/session.ex:
--------------------------------------------------------------------------------
1 | defmodule Phantom.Session do
2 | @moduledoc """
3 | Represents the state of the MCP session. This is the state across the conversation
4 | and is the bridge between the various transports (HTTP, stdio) to persistence,
5 | even if stateless.
6 | """
7 |
8 | require Logger
9 |
10 | alias Phantom.Request
11 |
12 | @enforce_keys [:id]
13 | defstruct [
14 | :allowed_prompts,
15 | :allowed_resource_templates,
16 | :allowed_tools,
17 | :id,
18 | :last_event_id,
19 | :pid,
20 | :pubsub,
21 | :request,
22 | :router,
23 | :stream_fun,
24 | :tracker,
25 | :transport_pid,
26 | assigns: %{},
27 | client_info: %{},
28 | client_capabilities: %{
29 | roots: false,
30 | sampling: false,
31 | elicitation: false
32 | },
33 | close_after_complete: true,
34 | requests: %{}
35 | ]
36 |
37 | @type t :: %__MODULE__{
38 | allowed_prompts: [String.t()],
39 | allowed_resource_templates: [String.t()],
40 | allowed_tools: [String.t()],
41 | assigns: map(),
42 | close_after_complete: boolean(),
43 | id: binary(),
44 | last_event_id: String.t() | nil,
45 | pid: pid() | nil,
46 | pubsub: module(),
47 | request: Phantom.Request.t() | nil,
48 | requests: map(),
49 | router: module(),
50 | stream_fun: fun(),
51 | client_info: map(),
52 | client_capabilities: %{
53 | elicitation: false | map(),
54 | sampling: false | map(),
55 | roots: false | map()
56 | },
57 | transport_pid: pid()
58 | }
59 |
60 | @spec new(String.t() | nil, Keyword.t() | map) :: t()
61 | @doc """
62 | Builds a new session with the provided session ID.
63 |
64 | This is used for adapters such as `Phantom.Plug`. If a
65 | session ID is not provided, it will generate one using `UUIDv7`.
66 | """
67 | def new(session_id, opts \\ []) do
68 | struct!(__MODULE__, [id: session_id || UUIDv7.generate()] ++ opts)
69 | end
70 |
71 | @doc "Set an allow-list of usable Tools for the session"
72 | def allow_tools(%__MODULE__{} = session, tools) do
73 | %{session | allowed_tools: tools}
74 | end
75 |
76 | @doc "Set an allow-list of usable Resource Templates for the session"
77 | def allow_resource_templates(%__MODULE__{} = session, resource_templates) do
78 | %{session | allowed_resource_templates: resource_templates}
79 | end
80 |
81 | @doc "Set an allow-list of usable Prompts for the session"
82 | def allow_prompts(%__MODULE__{} = session, prompts) do
83 | %{session | allowed_prompts: prompts}
84 | end
85 |
86 | @doc "Fetch the current progress token if provided by the client"
87 | def progress_token(%__MODULE__{request: %{params: params}}) do
88 | params["_meta"]["progressToken"]
89 | end
90 |
91 | @doc "Elicit input from the client"
92 | @spec elicit(t, Phantom.Elicit.t()) ::
93 | {:ok, request_id :: String.t()}
94 | | :not_supported
95 | def elicit(session, elicitation) do
96 | if session.client_capabilities.elicitation do
97 | case Phantom.Tracker.get_session(session) do
98 | nil -> :error
99 | pid -> GenServer.call(pid, {:elicit, elicitation})
100 | end
101 | else
102 | :not_supported
103 | end
104 | end
105 |
106 | @spec assign(t(), atom(), any()) :: t()
107 | @doc "Assign state to the session."
108 | def assign(session, key, value) do
109 | %{session | assigns: Map.put(session.assigns, key, value)}
110 | end
111 |
112 | @doc "Assign state to the session."
113 | @spec assign(t(), map()) :: t()
114 | def assign(session, map) do
115 | %{session | assigns: Map.merge(session.assigns, Map.new(map))}
116 | end
117 |
118 | @doc """
119 | Subscribe the session to a resource.
120 |
121 | This is used by the MCP Router when the client requests to subscribe to the provided resource.
122 | """
123 | @spec subscribe_to_resource(t(), string_uri :: String.t()) :: :ok | :error
124 | def subscribe_to_resource(%__MODULE__{pubsub: nil}, _uri), do: :error
125 |
126 | def subscribe_to_resource(session, uri) do
127 | case Phantom.Tracker.get_session(session) do
128 | nil -> :error
129 | pid -> GenServer.cast(pid, {:subscribe_resource, uri})
130 | end
131 | end
132 |
133 | @doc """
134 | Unsubscribe the session to a resource.
135 |
136 | This is used by the MCP Router when the client requests to subscribe to the provided resource.
137 | """
138 | @spec unsubscribe_to_resource(t(), string_uri :: String.t()) :: :ok | :error
139 | def unsubscribe_to_resource(%__MODULE__{pubsub: nil}, _uri), do: :error
140 |
141 | def unsubscribe_to_resource(session, uri) do
142 | case Phantom.Tracker.get_session(session) do
143 | nil -> :error
144 | pid -> GenServer.cast(pid, {:unsubscribe_resource, uri})
145 | end
146 | end
147 |
148 | def list_resource_subscriptions(session) do
149 | case Phantom.Tracker.get_session(session) do
150 | nil -> []
151 | pid -> GenServer.call(pid, :list_resource_subscriptions)
152 | end
153 | end
154 |
155 | @doc """
156 | Sets the log level for the SSE stream.
157 | Sets both for the current request for async tasks and the SSE stream
158 | """
159 | @spec set_log_level(Session.t(), Request.t(), String.t()) :: :ok
160 | def set_log_level(%__MODULE__{} = session, request, level) do
161 | case Phantom.Tracker.get_session(session) do
162 | nil -> :error
163 | pid -> GenServer.cast(pid, {:set_log_level, request, level})
164 | end
165 | end
166 |
167 | @doc "Closes the connection for the session"
168 | @spec finish(Session.t() | pid) :: :ok
169 | def finish(%__MODULE__{pid: pid}), do: finish(pid)
170 | def finish(pid) when is_pid(pid), do: GenServer.cast(pid, :finish)
171 |
172 | @doc """
173 | Sends response back to the stream
174 |
175 | This should likely be used in conjunction with:
176 |
177 | - `Phantom.Tool.response(payload)`
178 | - `Phantom.Resource.response(payload)`
179 | - `Phantom.Prompt.response(payload)`
180 |
181 | For example:
182 |
183 | ```elixir
184 | session_pid = session.pid
185 | request_id = request.id
186 |
187 | Task.async(fn ->
188 | Session.respond(
189 | session_pid,
190 | request_id,
191 | Phantom.Tool.audio(
192 | File.read!("priv/static/game-over.wav"),
193 | mime_type: "audio/wav"
194 | )
195 | )
196 | end)
197 | ```
198 | """
199 | def respond(%__MODULE__{pid: pid, request: %{id: request_id}}, payload),
200 | do: respond(pid, request_id, payload)
201 |
202 | @doc "See `respond/2`"
203 | def respond(%__MODULE__{pid: pid}, request_id, payload), do: respond(pid, request_id, payload)
204 | def respond(pid, %Request{id: id}, payload), do: respond(pid, id, payload)
205 |
206 | def respond(pid, request_id, payload) when is_pid(pid) do
207 | GenServer.cast(
208 | pid,
209 | {:respond, request_id,
210 | %{
211 | id: request_id,
212 | jsonrpc: "2.0",
213 | result: payload
214 | }}
215 | )
216 | end
217 |
218 | @doc "Send a notification to the client"
219 | @spec notify(t | pid(), payload :: any()) :: :ok
220 | def notify(%__MODULE__{pid: pid}, payload), do: notify(pid, payload)
221 |
222 | def notify(pid, payload) when is_pid(pid) do
223 | GenServer.cast(pid, {:notify, payload})
224 | end
225 |
226 | @doc "Send a ping to the client"
227 | @spec ping(t | pid()) :: :ok
228 | def ping(%__MODULE__{pid: pid}), do: ping(pid)
229 | def ping(pid) when is_pid(pid), do: GenServer.cast(pid, :ping)
230 |
231 | @doc """
232 | Send a progress notification to the client
233 |
234 | the `progress` and `total` can be a integer or float, but must be ever-increasing.
235 | the `total` is optional.
236 |
237 | https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress
238 | """
239 | @spec notify_progress(t, number(), nil | number()) :: :ok
240 | def notify_progress(session, progress, total \\ nil)
241 |
242 | def notify_progress(%__MODULE__{pid: pid} = session, progress, total) do
243 | notify_progress(pid, progress_token(session), progress, total)
244 | end
245 |
246 | def notify_progress(pid, nil, _progress, _total), do: ping(pid)
247 |
248 | def notify_progress(pid, progress_token, progress, total) do
249 | GenServer.cast(pid, {:send, Request.notify_progress(progress_token, progress, total)})
250 | end
251 |
252 | @doc false
253 | def start_loop(opts) do
254 | session = Keyword.fetch!(opts, :session)
255 | timeout = Keyword.fetch!(opts, :timeout)
256 | opts = Keyword.put_new(opts, :log_level, 5)
257 | {cb, opts} = Keyword.pop(opts, :continue_fun)
258 | Process.set_label({__MODULE__, session.id})
259 |
260 | :gen_server.enter_loop(
261 | __MODULE__,
262 | [],
263 | Map.new(
264 | opts ++
265 | [
266 | timeout: timeout,
267 | last_activity: System.system_time()
268 | ]
269 | ),
270 | self(),
271 | {:continue, cb}
272 | )
273 | end
274 |
275 | @doc false
276 | def handle_continue(cb, state) do
277 | timer = Process.send_after(self(), :inactivity, state.timeout)
278 | state = Map.put(state, :timer, timer)
279 |
280 | if is_function(cb, 1) do
281 | maybe_finish(cb.(state))
282 | else
283 | {:noreply, state}
284 | end
285 | end
286 |
287 | @doc false
288 | def handle_call(:list_resource_subscriptions, _from, state) do
289 | {:reply, {:ok, Map.keys(state.subscriptions)}, state}
290 | end
291 |
292 | def handle_call({:elicit, elicitation}, _from, state) do
293 | cancel_inactivity(state)
294 |
295 | {:ok, request} =
296 | Request.build(%{
297 | "id" => UUIDv7.generate(),
298 | "jsonrpc" => "2.0",
299 | "method" => "elicitation/create",
300 | "params" => Phantom.Elicit.to_json(elicitation)
301 | })
302 |
303 | state = state.stream_fun.(state, request.id, "message", Request.to_json(request))
304 | {:reply, {:ok, request.id}, state |> set_activity() |> schedule_inactivity()}
305 | end
306 |
307 | @doc false
308 | def handle_cast(:finish, state) do
309 | state = state.stream_fun.(state, nil, "closed", "finished")
310 | {:stop, {:shutdown, :closed}, state}
311 | end
312 |
313 | @doc false
314 | def handle_cast({:log, level, level_name, domain, payload}, state)
315 | when level <= state.log_level do
316 | cancel_inactivity(state)
317 |
318 | {:noreply,
319 | state
320 | |> state.stream_fun.(
321 | nil,
322 | "message",
323 | Request.notify(%{level: level_name, logger: domain, data: payload})
324 | )
325 | |> set_activity()
326 | |> schedule_inactivity()}
327 | end
328 |
329 | def handle_cast({:log, _level, _level_name, _domain, _payload}, state) do
330 | {:noreply, state}
331 | end
332 |
333 | def handle_cast(:ping, state) do
334 | cancel_inactivity(state)
335 | state = state.stream_fun.(state, nil, "message", Request.ping())
336 | {:noreply, state |> set_activity() |> schedule_inactivity()}
337 | end
338 |
339 | def handle_cast({:send, payload}, state) do
340 | cancel_inactivity(state)
341 | state = state.stream_fun.(state, nil, "message", payload)
342 | {:noreply, state |> set_activity() |> schedule_inactivity()}
343 | end
344 |
345 | def handle_cast({:respond, request_id, payload}, state) do
346 | cancel_inactivity(state)
347 | state = state.stream_fun.(state, request_id, "message", payload)
348 | requests = Map.delete(state.session.requests, request_id)
349 | state = put_in(state.session.requests, requests)
350 | maybe_finish(state)
351 | end
352 |
353 | def handle_cast({:subscribe_resource, uri}, state) do
354 | cancel_inactivity(state)
355 | Phantom.Tracker.subscribe_resource(uri)
356 | {:noreply, state |> set_activity() |> schedule_inactivity()}
357 | end
358 |
359 | def handle_cast({:unsubscribe_resource, uri}, state) do
360 | cancel_inactivity(state)
361 | Phantom.Tracker.unsubscribe_resource(uri)
362 | {:noreply, state |> set_activity() |> schedule_inactivity()}
363 | end
364 |
365 | def handle_cast({:resource_updated, uri}, state) do
366 | cancel_inactivity(state)
367 | state.stream_fun.(state, nil, "message", Request.resource_updated(%{uri: uri}))
368 | {:noreply, state |> set_activity() |> schedule_inactivity()}
369 | end
370 |
371 | def handle_cast(:tools_updated, state) do
372 | notify? = state.session.allowed_tools == nil
373 |
374 | if notify? do
375 | cancel_inactivity(state)
376 | state.stream_fun.(state, nil, "message", Request.tools_updated())
377 | {:noreply, state |> set_activity() |> schedule_inactivity()}
378 | else
379 | {:noreply, state}
380 | end
381 | end
382 |
383 | def handle_cast(:prompts_updated, state) do
384 | notify? = state.session.allowed_prompts == nil
385 |
386 | if notify? do
387 | cancel_inactivity(state)
388 | state.stream_fun.(state, nil, "message", Request.prompts_updated())
389 | {:noreply, state |> set_activity() |> schedule_inactivity()}
390 | else
391 | {:noreply, state}
392 | end
393 | end
394 |
395 | def handle_cast(:resources_updated, state) do
396 | notify? = state.session.allowed_resource_templates == nil
397 |
398 | if notify? do
399 | cancel_inactivity(state)
400 | state.stream_fun.(state, nil, "message", Request.resources_updated())
401 | {:noreply, state |> set_activity() |> schedule_inactivity()}
402 | else
403 | {:noreply, state}
404 | end
405 | end
406 |
407 | def handle_cast({:set_log_level, request, log_level}, state) do
408 | state = state.stream_fun.(state, request.id, "message", %{})
409 | {:noreply, %{state | log_level: log_level}}
410 | end
411 |
412 | defp maybe_finish(state) do
413 | if Enum.any?(Map.keys(state.session.requests)) or not state.session.close_after_complete do
414 | {:noreply, state |> set_activity() |> schedule_inactivity()}
415 | else
416 | handle_cast(:finish, state)
417 | end
418 | end
419 |
420 | @doc false
421 | # eat this message since we send once the stream loop is over
422 | def handle_info({:plug_conn, :sent}, state), do: {:noreply, state}
423 |
424 | def handle_info(:inactivity, state) do
425 | cond do
426 | not state.session.close_after_complete ->
427 | state = state.stream_fun.(state, nil, "message", Request.ping())
428 | {:noreply, state |> set_activity() |> schedule_inactivity()}
429 |
430 | System.system_time() - state.last_activity > state.timeout ->
431 | state = state.stream_fun.(state, nil, "closed", "inactivity")
432 | {:stop, {:shutdown, :closed}, state}
433 |
434 | true ->
435 | {:noreply, state}
436 | end
437 | end
438 |
439 | def handle_info(_what, state) do
440 | {:noreply, state}
441 | end
442 |
443 | defp cancel_inactivity(%{timer: ref}) when is_reference(ref), do: Process.cancel_timer(ref)
444 | defp cancel_inactivity(_), do: :ok
445 |
446 | defp set_activity(state), do: %{state | last_activity: System.system_time()}
447 |
448 | defp schedule_inactivity(state) do
449 | %{state | timer: Process.send_after(self(), :inactivity, state.timeout)}
450 | end
451 | end
452 |
--------------------------------------------------------------------------------
/test/phantom/plug_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Phantom.PlugTest do
2 | use ExUnit.Case
3 |
4 | import Phantom.TestDispatcher
5 | import Plug.Conn
6 | import Plug.Test
7 |
8 | doctest Phantom.Plug
9 |
10 | @cors_opts Phantom.Plug.init(
11 | router: Test.MCP.Router,
12 | origins: ["http://localhost:4000"],
13 | validate_origin: true
14 | )
15 |
16 | setup do
17 | start_supervised({Phoenix.PubSub, name: Test.PubSub})
18 | start_supervised({Phantom.Tracker, [name: Phantom.Tracker, pubsub_server: Test.PubSub]})
19 | Phantom.Cache.register(Test.MCP.Router)
20 | :ok
21 | end
22 |
23 | describe "plug initialization" do
24 | test "initializes with valid router" do
25 | opts = Phantom.Plug.init(router: Test.MCP.Router)
26 | assert opts[:router] == Test.MCP.Router
27 | assert opts[:validate_origin] == true
28 | assert opts[:origins] == ["http://localhost:4000"]
29 | end
30 | end
31 |
32 | describe "CORS preflight requests" do
33 | test "handles valid preflight request" do
34 | :options
35 | |> conn("/mcp")
36 | |> put_req_header("origin", "http://localhost:4000")
37 | |> call(@cors_opts)
38 |
39 | assert_receive {:conn, conn}
40 | assert conn.status == 204
41 |
42 | assert get_resp_header(conn, "access-control-allow-origin") == ["http://localhost:4000"]
43 | assert get_resp_header(conn, "access-control-allow-methods") == ["GET, POST, OPTIONS"]
44 | assert get_resp_header(conn, "access-control-allow-credentials") == ["true"]
45 |
46 | allowed_headers =
47 | String.split(
48 | get_resp_header(conn, "access-control-allow-headers") |> List.first(),
49 | ", "
50 | )
51 |
52 | expose_headers =
53 | String.split(
54 | get_resp_header(conn, "access-control-expose-headers") |> List.first(),
55 | ", "
56 | )
57 |
58 | assert "content-type" in allowed_headers
59 | assert "authorization" in allowed_headers
60 | assert "mcp-session-id" in allowed_headers
61 | assert "last-event-id" in allowed_headers
62 |
63 | assert "mcp-session-id" in expose_headers
64 | assert "last-event-id" in expose_headers
65 |
66 | assert conn.halted
67 | end
68 |
69 | test "rejects preflight with invalid origin" do
70 | :options
71 | |> conn("/mcp")
72 | |> put_req_header("origin", "http://evil.example")
73 | |> call(@cors_opts)
74 |
75 | assert_receive {:conn, conn}
76 | assert conn.status == 403
77 | assert conn.halted
78 |
79 | error = conn.resp_body |> JSON.decode!()
80 | assert error["error"]["code"] == -32000
81 | assert error["error"]["message"] == "Origin not allowed"
82 | end
83 | end
84 |
85 | describe "CORS headers" do
86 | test "sets CORS headers for valid origin" do
87 | request_ping(
88 | origins: ["http://localhost:4000"],
89 | validate_origin: true,
90 | before_call: fn conn ->
91 | put_req_header(conn, "origin", "http://localhost:4000")
92 | end
93 | )
94 |
95 | assert_receive {:conn, conn}
96 | assert conn.status == 200
97 |
98 | assert get_resp_header(conn, "access-control-allow-origin") == ["http://localhost:4000"]
99 | assert get_resp_header(conn, "access-control-allow-credentials") == ["true"]
100 | assert get_resp_header(conn, "access-control-allow-methods") == ["GET, POST, OPTIONS"]
101 |
102 | allowed_headers =
103 | String.split(
104 | get_resp_header(conn, "access-control-allow-headers") |> List.first(),
105 | ", "
106 | )
107 |
108 | expose_headers =
109 | String.split(
110 | get_resp_header(conn, "access-control-expose-headers") |> List.first(),
111 | ", "
112 | )
113 |
114 | assert "content-type" in allowed_headers
115 | assert "authorization" in allowed_headers
116 | assert "mcp-session-id" in allowed_headers
117 | assert "last-event-id" in allowed_headers
118 |
119 | assert "mcp-session-id" in expose_headers
120 | assert "last-event-id" in expose_headers
121 | end
122 |
123 | test "does not set CORS headers for invalid origin" do
124 | request_ping(
125 | origins: ["http://localhost:4000"],
126 | validate_origin: true,
127 | before_call: fn conn ->
128 | put_req_header(conn, "origin", "http://evil.example")
129 | end
130 | )
131 |
132 | assert_receive {:conn, conn}
133 | assert conn.status == 403
134 | headers = Enum.map(conn.resp_headers, &elem(&1, 0))
135 |
136 | for header <- ~w[access-control-expose-headers
137 | access-control-allow-origin
138 | access-control-allow-credentials
139 | access-control-allow-methods
140 | access-control-allow-headers
141 | access-control-max-age],
142 | do: assert(header not in headers)
143 | end
144 | end
145 |
146 | describe "origin validation" do
147 | test "allows all origins when validate_origin is false" do
148 | request_ping(
149 | origins: ["http://localhost:4000"],
150 | validate_origin: false,
151 | before_call: fn conn ->
152 | put_req_header(conn, "origin", "http://evil.example:4000")
153 | end
154 | )
155 |
156 | assert_receive {:conn, conn}
157 | assert conn.status == 200
158 | end
159 |
160 | test "validates origin when enabled" do
161 | request_ping(
162 | origins: :all,
163 | validate_origin: true,
164 | before_call: fn conn ->
165 | put_req_header(conn, "origin", "http://any-origin.example")
166 | end
167 | )
168 |
169 | assert_receive {:conn, conn}
170 | assert conn.status == 200
171 | end
172 | end
173 |
174 | describe "content length validation" do
175 | test "rejects requests that exceed max_request_size" do
176 | request_ping(
177 | validate_origin: false,
178 | router: Test.MCP.Router,
179 | max_request_size: 10,
180 | before_call: fn conn ->
181 | put_req_header(conn, "content-length", "100")
182 | end
183 | )
184 |
185 | assert_receive {:conn, conn}
186 | assert conn.status == 413
187 |
188 | error = JSON.decode!(conn.resp_body)
189 | assert error["error"]["code"] == -32600
190 | assert error["error"]["message"] == "Request too large"
191 | end
192 |
193 | test "sets correct content-type headers" do
194 | request_ping()
195 |
196 | assert_receive {:conn, conn}
197 | assert conn.status == 200
198 |
199 | assert get_resp_header(conn, "content-type") == ["text/event-stream; charset=utf-8"]
200 | end
201 | end
202 |
203 | describe "malformed requests" do
204 | test "handles missing body" do
205 | :post
206 | |> conn("/mcp")
207 | |> call()
208 |
209 | assert_receive {:conn, conn}
210 | assert conn.status == 400
211 | error = JSON.decode!(conn.resp_body)
212 |
213 | assert error["error"]["code"] == -32700
214 | assert error["error"]["message"] == "Parse error: Invalid JSON"
215 | end
216 |
217 | test "handles valid JSON-RPC request" do
218 | request_ping()
219 | assert_receive {:conn, conn}
220 | assert conn.status == 200
221 | assert_receive {:response, _id, _event, %{id: 1, jsonrpc: "2.0", result: %{}}}
222 | end
223 |
224 | test "returns error for invalid JSON-RPC" do
225 | :post
226 | |> conn("/mcp", %{method: "foo", id: 1})
227 | |> put_req_header("content-type", "application/json")
228 | |> call()
229 |
230 | assert_connected(conn)
231 | assert conn.status == 200
232 |
233 | assert_receive {:response, 1, "message", error}
234 | assert error[:error][:code] == -32600
235 | assert error[:error][:message] == "Invalid request"
236 | end
237 |
238 | test "rejects unsupported methods" do
239 | :put
240 | |> conn("/mcp")
241 | |> call()
242 |
243 | assert_receive {:conn, conn}
244 | assert conn.status == 405
245 |
246 | assert_receive {_, {405, _headers, body}}
247 | error = JSON.decode!(body)
248 | assert error["error"]["code"] == -32601
249 | assert error["error"]["message"] == "Method not allowed"
250 | end
251 |
252 | test "handles batch requests" do
253 | batch = [
254 | %{jsonrpc: "2.0", method: "ping", id: 1},
255 | %{
256 | jsonrpc: "2.0",
257 | method: "tools/call",
258 | id: 2,
259 | params: %{name: "echo_tool", arguments: %{message: "test"}}
260 | }
261 | ]
262 |
263 | :post
264 | |> conn("/mcp", JSON.encode!(batch))
265 | |> put_req_header("content-type", "application/json")
266 | |> call()
267 |
268 | assert_connected(_conn)
269 | assert_receive {:response, 1, "message", %{}}
270 |
271 | assert_receive {:response, 2, "message",
272 | %{result: %{content: [%{text: "test", type: "text"}]}}}
273 |
274 | assert_receive {:response, nil, "closed", "finished"}
275 | end
276 | end
277 |
278 | describe "SSE handling" do
279 | test "GET request returns error" do
280 | :get
281 | |> conn("/mcp")
282 | |> put_req_header("accept", "text/event-stream")
283 | |> call(pubsub: nil)
284 |
285 | assert_receive {:conn, conn}
286 | assert conn.status == 405
287 | error = JSON.decode!(conn.resp_body)
288 |
289 | assert error["error"]["code"] == -32601
290 | assert error["error"]["message"] == "SSE not supported"
291 | end
292 |
293 | test "GET request tracks connection in Tracker" do
294 | :get
295 | |> conn("/mcp")
296 | |> put_req_header("accept", "text/event-stream")
297 | |> call()
298 |
299 | assert_sse_connected()
300 | assert Phantom.Tracker.list_sessions() != []
301 | end
302 | end
303 |
304 | test "handles prompt responses" do
305 | request_prompt("resource_prompt", id: 1)
306 | assert_response(1, response)
307 |
308 | assert %{
309 | description: "A resource prompt",
310 | messages: [
311 | %{
312 | role: :assistant,
313 | content: %{
314 | type: :resource,
315 | resource: %{
316 | uri: "test:///text/321",
317 | mimeType: "application/json",
318 | text: ~s|{"id":"321"}|
319 | }
320 | }
321 | },
322 | %{role: :user, content: %{type: :text, text: "Wowzers"}}
323 | ]
324 | } = response[:result]
325 | end
326 |
327 | test "handles asyncronous prompt responses" do
328 | request_prompt("async_resource_prompt", %{}, id: 4)
329 |
330 | assert_response(4, response)
331 |
332 | assert %{
333 | description: "A resource prompt that has an async read",
334 | messages: [
335 | %{
336 | role: :assistant,
337 | content: %{
338 | type: :resource,
339 | resource: %{
340 | uri: "myapp:///binary/foo",
341 | mimeType: "image/png",
342 | blob: blob
343 | }
344 | }
345 | },
346 | %{role: :user, content: %{type: :text, text: "Wowzers"}}
347 | ]
348 | } = response[:result]
349 |
350 | # Verify it's valid base64 encoded data
351 | assert is_binary(blob)
352 | assert {:ok, decoded} = Base.decode64(blob)
353 | assert File.read!("test/support/fixtures/foo.png") == decoded
354 | end
355 |
356 | test "handles embedded resource link" do
357 | request_tool("embedded_resource_link_tool", %{}, id: 42)
358 | assert_response(42, response)
359 |
360 | assert %{
361 | content: [
362 | %{
363 | type: :resource_link,
364 | description: "An image resource",
365 | uri: "myapp:///binary/foo"
366 | }
367 | ]
368 | } = response[:result]
369 | end
370 |
371 | test "handles asynchronous tool responses" do
372 | request_tool("async_embedded_resource_tool", %{}, id: 43)
373 | assert_response(43, response)
374 |
375 | assert %{
376 | content: [
377 | %{
378 | type: :resource,
379 | resource: %{
380 | uri: "myapp:///binary/foo",
381 | mimeType: "image/png",
382 | blob: blob
383 | }
384 | }
385 | ]
386 | } = response[:result]
387 |
388 | # Verify it's valid base64 encoded data
389 | assert is_binary(blob)
390 | assert {:ok, decoded} = Base.decode64(blob)
391 | assert File.read!("test/support/fixtures/foo.png") == decoded
392 | end
393 |
394 | test "handles asynchronous resource responses" do
395 | request_resource_read("myapp:///binary/bar", id: 2)
396 | assert_response(2, response)
397 |
398 | assert %{
399 | contents: [
400 | %{
401 | uri: "myapp:///binary/bar",
402 | mimeType: "image/png",
403 | blob: blob
404 | }
405 | ]
406 | } = response[:result]
407 |
408 | # Verify it's valid base64 encoded data
409 | assert is_binary(blob)
410 | assert {:ok, decoded} = Base.decode64(blob)
411 | assert File.read!("test/support/fixtures/bar.png") == decoded
412 | end
413 |
414 | test "handles resource not found" do
415 | # Test reading a resource that doesn't exist
416 | not_found_message = %{
417 | jsonrpc: "2.0",
418 | method: "resources/read",
419 | id: 5,
420 | params: %{uri: "nonexistent:///missing/resource"}
421 | }
422 |
423 | :post
424 | |> conn("/mcp", not_found_message)
425 | |> put_req_header("content-type", "application/json")
426 | |> call()
427 |
428 | assert_connected(conn)
429 | assert conn.status == 200
430 |
431 | assert_response(5, response)
432 | assert response[:jsonrpc] == "2.0"
433 | assert response[:id] == 5
434 | assert is_map(response[:error])
435 |
436 | error = response[:error]
437 | assert error[:code] == -32602
438 | assert error[:message] == "Invalid Params"
439 | end
440 |
441 | test "handles sending logs", context do
442 | session_id = to_string(context.test)
443 |
444 | request_sse_stream(session_id: session_id)
445 | assert_sse_connected()
446 |
447 | request_set_log_level("debug", id: 2, session_id: session_id)
448 | assert_connected(%{status: 200})
449 | assert_response(2, %{})
450 |
451 | request_resource_read("myapp:///binary/bar",
452 | id: 3,
453 | session_id: session_id
454 | )
455 |
456 | assert_connected(%{status: 200})
457 | assert_response(3, _)
458 |
459 | assert_notify(%{
460 | method: "notifications/message",
461 | params: %{
462 | data: %{message: "An info log"},
463 | logger: "server",
464 | level: :info
465 | }
466 | })
467 | end
468 |
469 | test "handles resource subscriptions", context do
470 | session_id = to_string(context.test)
471 |
472 | request_sse_stream(session_id: session_id)
473 | assert_sse_connected()
474 |
475 | {:ok, uri} = Phantom.Router.resource_uri(Test.MCP.Router, :text_resource, id: 100)
476 |
477 | request_resource_subscribe(uri, id: 2, session_id: session_id)
478 | assert_connected(_conn)
479 | assert_response(2, %{result: %{result: "", jsonrpc: "2.0"}})
480 |
481 | Phantom.Tracker.notify_resource_updated(uri)
482 |
483 | assert_notify(%{
484 | method: "notifications/resources/updated",
485 | params: %{uri: ^uri}
486 | })
487 | end
488 | end
489 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Phantom MCP
2 |
3 | [](https://hex.pm/packages/phantom_mcp)
4 | [](https://hexdocs.pm/phantom_mcp)
5 |
6 |
7 |
8 | MCP (Model Context Protocol) framework for Elixir Plug.
9 |
10 | This library provides a complete implementation of the [MCP server specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports) with Plug.
11 |
12 | ## Installation
13 |
14 | Add Phantom to your dependencies:
15 |
16 | ```elixir
17 | {:phantom_mcp, "~> 0.3.2"},
18 | ```
19 |
20 | When using with Plug/Phoenix, configure MIME to accept SSE:
21 |
22 | ```elixir
23 | # config/config.exs
24 | config :mime, :types, %{
25 | "text/event-stream" => ["sse"]
26 | }
27 | ```
28 |
29 | For Streamable HTTP access to your MCP server, forward
30 | a path from your Plug or Phoenix Router to your MCP router.
31 |
32 |
33 |
34 | ### Phoenix
35 |
36 | ```elixir
37 | defmodule MyAppWeb.Router do
38 | use MyAppWeb, :router
39 | # ...
40 |
41 | pipeline :mcp do
42 | plug :accepts, ["json", "sse"]
43 |
44 | plug Plug.Parsers,
45 | parsers: [{:json, length: 1_000_000}],
46 | pass: ["application/json"],
47 | json_decoder: JSON
48 | end
49 |
50 | scope "/mcp" do
51 | pipe_through :mcp
52 |
53 | forward "/", Phantom.Plug,
54 | # Uncomment for remote access from anywhere:
55 | # origins: :all,
56 | # Uncomment for remote access from a specified list:
57 | # origins: ["https://myapp.example"],
58 | validate_origin: Mix.env() == :prod,
59 | router: MyApp.MCPRouter
60 | end
61 | end
62 | ```
63 |
64 | ### Plug.Router
65 |
66 | ```elixir
67 | defmodule MyAppWeb.Router do
68 | use Plug.Router
69 |
70 | plug :match
71 |
72 | plug Plug.Parsers,
73 | parsers: [{:json, length: 1_000_000}],
74 | pass: ["application/json"],
75 | json_decoder: JSON
76 |
77 | plug :dispatch
78 |
79 | forward "/mcp",
80 | to: Phantom.Plug,
81 | init_opts: [
82 | router: MyApp.MCP.Router
83 | ]
84 | end
85 | ```
86 |
87 | Finally, import the formatter settings in your `.formatter.exs`. Below is using Phoenix's
88 | generated example as a starting point.
89 |
90 | ```elixir
91 | [
92 | import_deps: [:ecto, :ecto_sql, :phoenix, :phantom_mcp],
93 | # ...
94 | ]
95 | ```
96 |
97 |
98 |
99 | Now the fun begins: it's time to define your MCP router that catalogs all your tools, prompts, and resources. When you're creating your MCP server, make sure
100 | you test it with the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) or your client of choice.
101 |
102 | For local testing, you can use [`mcp-remote`](https://github.com/geelen/mcp-remote) to proxy local-only clients to your Phantom-powered MCP server, either while it's hosted locally or remotely. Don't use `mcp-proxy` since it's designed for older SSE-based MCP servers (Phantom is using the newer Streamable HTTP behavior).
103 |
104 | First we're define the MCP router:
105 |
106 | ```elixir
107 | defmodule MyApp.MCP.Router do
108 | @moduledoc """
109 | Provides tools, prompts, and resources to aide in researching
110 | topics and creating research studies using the platform {MyApp}.
111 | """
112 |
113 | use Phantom.Router,
114 | name: "MyApp",
115 | vsn: "1.0",
116 | instructions: @moduledoc
117 | end
118 | ```
119 |
120 | I used the `@moduledoc` as the same documentation for the client; feel free to separate
121 | the instructions from internal documentation.
122 |
123 | > #### Instructions and descriptions are important! {: .neutral}
124 | >
125 | > These instructions and descriptions for tools, prompts, and resources
126 | > will be used by the LLM to determine when and how to use your tooling.
127 | > Don't be too verbose, but also don't have vague instructions.
128 |
129 | You likely need to consider authentication, so look at `m:Phantom#module-authentication-and-authorization`
130 | for how to implement it; tldr, implement the `c:Phantom.Router.connect/2` callback and return `{:ok, session}`
131 | upon success.
132 |
133 | Now we'll go through each one and show how to respond synchronously or asynchronously.
134 |
135 | ## Defining Tools
136 |
137 | You can define tools that have an optional `input_schema` and an optional `output_schema`.
138 | If no `input_schema` is provided, then the client will not know to send arguments to your handlers.
139 |
140 | ```elixir
141 | defmodule MyApp.MCP.Router do
142 | # ...
143 |
144 | # Defining available tools
145 | # the `@description` attribute will automatically be read, or you can provide `:description` directly.
146 | @description """
147 | Create a question for the provided Study.
148 | """
149 | tool :create_question,
150 | # Provide a handler. If not provided, the current module will be assumed.
151 | MyApp.MCP,
152 | # Provide an `input_schema`.
153 | input_schema: %{
154 | required: ~w[description label study_id],
155 | properties: %{
156 | study_id: %{
157 | type: "integer",
158 | description: "The unique identifier for the Study"
159 | },
160 | label: %{
161 | type: "string",
162 | description: "The title of the Question. The first thing the participant will see when presented with the question"
163 | },
164 | description: %{
165 | type: "string",
166 | description: "The contents of the question. About one paragraph of detail that defines one question or task for the participant to perform or answer"
167 | }
168 | }
169 | }
170 | end
171 | ```
172 |
173 | Then implement it:
174 |
175 |
176 |
177 | ### Synchronously
178 |
179 | ```elixir
180 | # If outside of the Router, you'll want to `require Phantom.Tool`.
181 | # If implementing in the router, this will already be required.
182 | require Phantom.Tool, as: Tool
183 |
184 | def create_question(%{"study_id" => study_id} = params, session) do
185 | changeset = MyApp.Question.changeset(%Question{}, params)
186 | with {:ok, question} <- MyApp.Repo.insert(changeset),
187 | {:ok, uri, resource_template} <-
188 | MyApp.MCP.Router.resource_for(session, :question, id: question.id) do
189 | {:reply, Tool.resource_link(uri, resource_template), session}
190 | else
191 | _ -> {:reply, Tool.error("Invalid paramaters"), session}
192 | end
193 | end
194 | ```
195 |
196 | ### Asynchronously
197 |
198 | ```elixir
199 | # If outside of the Router, you'll want to `require Phantom.Tool`.
200 | # If implementing in the router, this will already be required.
201 | require Phantom.Tool, as: Tool
202 |
203 | def create_question(%{"study_id" => study_id} = params, session) do
204 | Task.async(fn ->
205 | Process.sleep(1000)
206 | changeset = MyApp.Question.changeset(%Question{}, params)
207 | with {:ok, question} <- MyApp.Repo.insert(changeset),
208 | {:ok, uri, resource_template} <-
209 | MyApp.MCP.Router.resource_for(session, :question, id: question.id) do
210 |
211 | Session.respond(session, Tool.resource_link(uri, resource_template))
212 | else
213 | _ -> Session.respond(session, Tool.error("Invalid paramaters")))
214 | end
215 | end)
216 | {:noreply, session}
217 | end
218 | ```
219 |
220 |
221 |
222 | ## Defining Prompts
223 |
224 | ```elixir
225 | defmodule MyApp.MCP.Router do
226 | # ...
227 |
228 | # Prompts may contain arguments. If there are arguments
229 | # you may want to also provide a completion function to
230 | # help the client fill in the argument.
231 |
232 | @description """
233 | Review the provided Study and provide meaningful feedback about the
234 | study and let me know if there are gaps or missing questions. We want
235 | a meaningful study that can provide insight to the research goals stated
236 | in the study.
237 | """
238 | prompt :suggest_questions,
239 | completion_function: :study_complete,
240 | arguments: [
241 | %{
242 | name: "study_id",
243 | description: "The study to review",
244 | required: true
245 | }
246 | ]
247 | end
248 | ```
249 |
250 | Then implement it
251 |
252 |
253 |
254 | ### Synchronously
255 |
256 | ```elixir
257 | require Phantom.Prompt, as: Prompt
258 |
259 | def suggest_questions(%{"study_id" => study_id}, session) do
260 | case MyApp.MCP.Router.read_resource(session, :study, id: study_id) do
261 | {:ok, uri, resource} ->
262 | {:reply,
263 | Prompt.response(
264 | assistant: Prompt.embedded_resource(uri, resource),
265 | user: Prompt.text("Wowzers"),
266 | assistant: Prompt.image(File.read!("foo.png")),
267 | user: Prompt.text("Seriously, wowzers")
268 | ), session}
269 |
270 | error ->
271 | {:error, Phantom.Request.internal_error(), session}
272 | end
273 | end
274 | ```
275 |
276 | ### Asynchronously
277 |
278 | ```elixir
279 | require Phantom.Prompt, as: Prompt
280 |
281 | def suggest_questions(%{"study_id" => study_id}, session) do
282 | Task.async(fn ->
283 | case MyApp.MCP.Router.read_resource(session, :study, id: study_id) do
284 | {:ok, uri, resource} ->
285 | Session.respond(session, Prompt.response(
286 | assistant: Prompt.embedded_resource(uri, resource),
287 | user: Prompt.text("Wowzers"),
288 | assistant: Prompt.image(File.read!("foo.png")),
289 | user: Prompt.text("Seriously, wowzers")
290 | ))
291 |
292 | error ->
293 | Session.respond(session, Phantom.Request.internal_error())
294 | end
295 | end)
296 | {:noreply, sessin}
297 | end
298 | ```
299 |
300 |
301 |
302 | ## Defining Resources
303 |
304 | Let's define a resource with a resource template:
305 |
306 | ```elixir
307 | @description """
308 | Read the cover image of a Study to gain some context of the
309 | audience, research goals, and questions.
310 | """
311 | resource "myapp:///studies/:study_id/cover", :study_cover,
312 | completion_function: :study_complete,
313 | mime_type: "image/png"
314 |
315 | @description """
316 | Read the contents of a study. This includes the questions and general
317 | context, which is helpful for understanding research goals.
318 | """
319 | resource "https://example.com/studies/:study_id/md", :study,
320 | completion_function: :study_complete,
321 | mime_type: "text/markdown"
322 | ```
323 |
324 | Then implement them:
325 |
326 |
327 |
328 | ### Synchronously
329 |
330 | ```elixir
331 | require Phantom.Resource, as: Resource
332 |
333 | def study(%{"study_id" => id} = params, session) do
334 | study = Repo.get(Study, id)
335 | text = Study.to_markdown(study)
336 | {:reply, Resource.text(text), session}
337 | end
338 |
339 | def study_cover(%{"study_id" => id} = params, session) do
340 | study = Repo.get(Study, id)
341 | blob = File.read!(study.cover)
342 | {:reply, Resource.blob(blob), session}
343 | end
344 |
345 | ## Implement the completion handler:
346 | import Ecto.Query
347 |
348 | def study_complete("study_id", value, session) do
349 | study_ids = Repo.all(
350 | from s in Study,
351 | select: s.id,
352 | where: like(type(:id, :string), "#{value}%"),
353 | where: s.account_id == ^session.user.account_id,
354 | order_by: s.id,
355 | limit: 101
356 | )
357 |
358 | # You may also return a map with more info:
359 | # `%{values: study_ids, has_more: true, total: 1_000_000}`
360 | # If you return more than 100, then Phantom will set `has_more: true`
361 | # and only return the first 100.
362 | {:reply, study_ids, session}
363 | end
364 | ```
365 |
366 | ### Asynchronously
367 |
368 | ```elixir
369 | require Phantom.Resource, as: Resource
370 |
371 | def study(%{"study_id" => id} = params, session) do
372 | Task.async(fn ->
373 | Process.sleep(1000)
374 | study = Repo.get(Study, id)
375 | text = Study.to_markdown(study)
376 | Session.respond(session, Resource.response(Resource.text(text)))
377 | end)
378 |
379 | {:noreply, session}
380 | end
381 |
382 | def study_cover(%{"study_id" => id} = params, session) do
383 | Task.async(fn ->
384 | Process.sleep(1000)
385 | study = Repo.get(Study, id)
386 | blob = File.read!(study.cover)
387 | Session.respond(session, Resource.response(Resource.blob(blob)))
388 | end)
389 |
390 | {:noreply, session}
391 | end
392 |
393 | ## Implement the completion handler:
394 | import Ecto.Query
395 |
396 | def study_complete("study_id", value, session) do
397 | study_ids = Repo.all(
398 | from s in Study,
399 | select: s.id,
400 | where: like(type(:id, :string), "#{value}%"),
401 | where: s.account_id == ^session.user.account_id,
402 | order_by: s.id,
403 | limit: 101
404 | )
405 |
406 | # You may also return a map with more info:
407 | # `%{values: study_ids, has_more: true, total: 1_000_000}`
408 | # If you return more than 100, then Phantom will set `has_more: true`
409 | # and only return the first 100.
410 | {:reply, study_ids, session}
411 | end
412 | ```
413 |
414 |
415 |
416 | You'll also want to implement `list_resources/2` in your router which is
417 | to provide a list of all available resources in your system and return
418 | resource links to them.
419 |
420 | ```elixir
421 | @salt "cursor"
422 | def list_resources(cursor, session) do
423 | # Remember to check for allowed resources according to `session.allowed_resource_templates`
424 | # Below is a toy implementation for illustrative purposes.
425 | cursor =
426 | if cursor do
427 | {:ok, cursor} = Phoenix.Token.verify(MyApp.Endpoint, @salt, cursor)
428 | cursor
429 | else
430 | 0
431 | end
432 |
433 | {_before_cursor, after_cursor} = Enum.split_while(1..1000, fn i -> i < cursor end)
434 | {page, [next | _drop]} = Enum.split(after_cursor, 100)
435 | next_cursor = Phoenix.Token.sign(MyApp.Endpoint, @salt, next)
436 |
437 | resource_links =
438 | Enum.map(page, fn i ->
439 | {:ok, uri, spec} = resource_for(session, :study, id: i)
440 | Resource.resource_link(uri, spec, name: "Study #{i}")
441 | end)
442 |
443 | {:reply,
444 | Resource.list(resource_links, next_cursor),
445 | session}
446 | end
447 | ```
448 |
449 | You can notify the client of resource updates in case they have subscribed
450 | to any updates for the resource.
451 |
452 | ```elixir
453 | # Do some work and update some underlying resource,
454 | # then notify any listeners:
455 | {:ok, uri} = MyApp.MCP.Router.resource_uri(:my_resource, id: "foo")
456 | Phantom.Tracker.notify_resource_updated(uri)
457 | ```
458 |
459 | ## What PhantomMCP supports
460 |
461 | Phantom will implement these MCP requests on your behalf:
462 |
463 | - `initialize`. Phantom will detect what capabilities are available to the client based on the provided tooling defined in the Phantom router.
464 | - `prompts/list` list either the allowed prompts provided in the `connect/2` callback, or all prompts by default. To disable, return `allow_prompts(session, [])` in the `connect/2` callback.
465 | - `prompts/get` dispatch the request to your handler if allowed. Read more in `Phantom.Prompt`.
466 | - `resources/list` dispatch to your MCP router. By default it will be an empty list until you implement it. Read more in `Phantom.Resource`.
467 | - `resource/templates/list` list either the allowed resources as provided in the `connect/2` callback or all resource templates by default. To disable, return `allow_resource_templates(session, [])` in the `connect/2` callback. Read more in `Phantom.ResourceTemplate`.
468 | - `resources/read` dispatch the request to your handler. `Phantom.Resource`.
469 | - `resources/subscribe` available if the MCP router is configured with `pubsub`. To notify of updates for the resource, use `Phantom.Tracker.notify_resource_updated(uri)`.
470 | - `resources/unsubscribe` see above.
471 | - `logging/setLevel` available if the MCP router is configured with `pubsub`. Logs can be sent to client with `Session.log_{level}(session, map_content)`. [See docs](https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/logging#log-levels).
472 | - `tools/list` list either the allowed tools as provided in the `connect/2` callback or all tools by default. To disable, return `allow_tools(session, [])` in the `connect/2` callback.
473 | - `tools/call` dispatch the request to your handler. Read more in `Phantom.Tool`.
474 | - `completion/complete` dispatch the request to your completion handler for the given prompt or resource.
475 | - `notification/*` no-op.
476 | - `ping` pong
477 | - `notifications/resources/list_changed` - The server informs the client the list of resources has updated. This is not done automatically; you will need to trigger this with `Phantom.Tracker.notify_resource_list/0`, but also be mindful of what resources the session may have access to.
478 | - `notifications/prompts/list_changed` - The server informs the client the list of prompts has updated. This is triggered when `Phantom.Cache.add_prompt/2` is called.
479 | - `notifications/tools/list_changed` - The server informs the client the list of tools has updated. This is triggered when `Phantom.Cache.add_tool/2` is called.
480 |
481 | Phantom **does not yet support these methods**:
482 |
483 | - `roots/list` - The server requests the client to provide a list of files available for interaction. This is like `resources/list` but for the client.
484 | - `sampling/createMessage` - The server requests the client to query their LLM and provide its response. This is for human-in-the-loop agentic actions and could be leveraged when the client requests a prompt from the server.
485 | - `elicitation/create` - The server requests input from
486 | the client in order to complete a request the client has made of it.
487 |
488 | ## Batched Requests
489 |
490 | Batched requests will also be handled transparently. **please note** there is not an abstraction for efficiently providing these as a group to your handler. Since the MCP specification is deprecating batched request support in the next version, there is no plan to make this more efficient.
491 |
492 | ## Authentication and Authorization
493 |
494 | Phantom does not implement authentication on its own. MCP applications needing authentication should investigate OAuth provider solutions like [Oidcc](https://hex.pm/packages/oidcc) or [Boruta](https://hex.pm/packages/boruta) or [ExOauth2Provider](https://hex.pm/packages/ex_oauth2_provider) and configure the route to serve a discovery endpoint.
495 |
496 | 1. [MCP authentication and discovery](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) is not handled by Phantom itself. You will need to implement OAuth2 and provide the discovery mechanisms as described in the specification. In the `connect/2` callback you can return `{:unauthorized, www_authenticate_info}` or `{:forbidden, "error message"}` to inform the client of how to move forward. An `{:ok, session}` result will imply successful auth.
497 |
498 | 2. Once the authentication flow has been completed, a request to the MCP router should land with an authorization header that can be received and verified in the `connect/2` callback of your MCP router.
499 |
500 | 3. You may also decide to limit the available tools, prompts, or resources depending on your authorization rules. An example is below.
501 |
502 | ```elixir
503 | defmodule MyApp.MCP.Router do
504 | use Phantom.Router,
505 | name: "MyApp",
506 | vsn: "1.0"
507 |
508 | require Logger
509 |
510 | def connect(session, %{headers: auth_info}) do
511 | # The `auth_info` will depend on the adapter, in this case it's from
512 | # Plug, so it will contain query parameters and request headers.
513 | with {:ok, user} <- MyApp.authenticate(conn, auth_info),
514 | {:ok, my_session_state} <- MyApp.load_session(session.id) do
515 | {:ok,
516 | session
517 | |> assign(some_state: my_session_state, user: user)
518 | |> limit_for_plan(user.plan)}
519 | else
520 | :not_found ->
521 | # See `Phantom.Plug.www_authenticate/1`
522 | {:unauthorized, %{
523 | method: "Bearer",
524 | resource_metadata: "https://myapp.com/.well-known/oauth-protected-resource"
525 | }}
526 | :not_allowed ->
527 | {:forbidden, "Please upgrade plan to use MCP server"}
528 | end
529 | end
530 |
531 | defp limit_for_plan(session, :ultra), do: session
532 | defp limit_for_plan(session, :basic) do
533 | # allow-list tools by stringified name. The name is either supplied as the `name` when defining it, or the stringified function name.
534 | session
535 | |> Phantom.Session.allowed_tools(~w[create_question])
536 | |> Phantom.Session.allowed_resource_templates(~w[study])
537 | end
538 | ```
539 |
540 | ## Optional callbacks
541 |
542 | There are several optional callbacks to help you hook into the lifecycle of the connections.
543 |
544 | - `c:Phantom.Router.disconnect/1` means the request has closed, not that the session is finished.
545 | - `c:Phantom.Router.terminate/1` means the session has finished and the client doesn't intend to resume it.
546 |
547 | For Telemetry, please see `m:Phantom.Plug#module-telemetry` and `m:Phantom.Router#module-telemetry` for emitted telemetry hooks.
548 |
549 | ## Persistent Streams
550 |
551 | MCP supports SSE streams to get notifications allow resource subscriptions.
552 | To support this, Phantom needs to track connection pids in your cluster and uses
553 | Phoenix.Tracker (`phoenix_pubsub`) to do this.
554 |
555 | MCP defines a "Streamable HTTP" protocol, which is typical HTTP and SSE connections but with a certain behavior. MCP will typically have multiple connections to facilitate requests:
556 |
557 | 1. `POST` for every command, such as `tools/call` or `resources/read`. Phantom will open an SSE stream and then immediately close the connection once work has completed.
558 | 2. `GET` to start an SSE stream for any events such as logs and notifications. There is no work to complete with these requests, and therefore is just a "channel" for receiving server requests and notifications. The connection will remain open until either the client or server closes it.
559 |
560 | All connections may provide an `mcp-session-id` header to resume a session.
561 |
562 | **Not yet supported** is the ability to resume broken connections with missed messages with the `last-event-id` header, however this is planned to be supported with a ttl-expiring distributed circular buffer.
563 |
564 | To make Phantom distributed, start the `Phantom.Tracker` and pass in your pubsub module to the `Phantom.Plug` options:
565 |
566 | ```elixir
567 | # Add to your application supervision tree:
568 |
569 | {Phoenix.PubSub, name: MyApp.PubSub},
570 | {Phantom.Tracker, [name: Phantom.Tracker, pubsub_server: MyApp.PubSub]},
571 | ```
572 |
573 | Adjust the Phoenix router or Plug.Router options to include the PubSub server
574 |
575 |
576 |
577 | ### Phoenix
578 |
579 | ```elixir
580 | forward "/", Phantom.Plug,
581 | router: MyApp.MCP.Router,
582 | pubsub: MyApp.PubSub
583 | ```
584 |
585 | ### Plug.Router
586 |
587 | ```elixir
588 | forward "/mcp", to: Phantom.Plug, init_opts: [
589 | router: MyApp.MCP.Router,
590 | pubsub: MyApp.PubSub
591 | ]
592 | ```
593 |
594 |
595 |
--------------------------------------------------------------------------------
/lib/phantom/plug.ex:
--------------------------------------------------------------------------------
1 | defmodule Phantom.Plug do
2 | @default_opts [
3 | pubsub: nil,
4 | origins: ["http://localhost:4000"],
5 | validate_origin: true,
6 | session_timeout: :timer.seconds(30),
7 | max_request_size: 1_048_576
8 | ]
9 |
10 | @moduledoc """
11 | Main Plug implementation for MCP HTTP transport with SSE support.
12 |
13 | This module provides a complete MCP server implementation with:
14 | - JSON-RPC 2.0 message handling
15 | - Server-Sent Events (SSE) streaming
16 | - CORS handling and security features
17 | - Session management integration
18 | - Origin validation
19 |
20 |
21 |
22 | ### Phoenix
23 |
24 | ```elixir
25 | defmodule MyAppWeb.Router do
26 | use MyAppWeb, :router
27 | # ...
28 |
29 | pipeline :mcp do
30 | plug :accepts, ["json", "sse"]
31 |
32 | plug Plug.Parsers,
33 | parsers: [{:json, length: 1_000_000}],
34 | pass: ["application/json"],
35 | json_decoder: JSON
36 | end
37 |
38 | scope "/mcp" do
39 | pipe_through :mcp
40 |
41 | forward "/", Phantom.Plug,
42 | router: MyApp.MCPRouter,
43 | pubsub: MyApp.PubSub
44 | end
45 | end
46 | ```
47 |
48 | ### Plug.Router
49 |
50 | ```elixir
51 | defmodule MyAppWeb.Router do
52 | use Plug.Router
53 |
54 | plug :match
55 |
56 | plug Plug.Parsers,
57 | parsers: [{:json, length: 1_000_000}],
58 | pass: ["application/json"],
59 | json_decoder: JSON
60 |
61 | plug :dispatch
62 |
63 | forward "/mcp",
64 | to: Phantom.Plug,
65 | init_opts: [
66 | router: MyApp.MCP.Router,
67 | pubsub: MyApp.PubSub
68 | ]
69 | end
70 | ```
71 |
72 |
73 |
74 | Here are the defaults:
75 |
76 | ```elixir
77 | #{inspect(@default_opts, pretty: true)}
78 | ```
79 |
80 | ## Telemetry
81 |
82 | Telemetry is provided with these events:
83 |
84 | - `[:phantom, :plug, :request, :connect]` with meta: `~w[session router conn opts]a`
85 | - `[:phantom, :plug, :request, :disconnect]` with meta: `~w[session router conn]a`
86 | - `[:phantom, :plug, :request, :terminate]` with meta: `~w[session router conn]a`
87 | - `[:phantom, :plug, :request, :exception]` with meta: `~w[session router conn stacktrace request exception]a`
88 | """
89 |
90 | @behaviour Plug
91 |
92 | import Plug.Conn
93 |
94 | alias Phantom.Cache
95 | alias Phantom.Request
96 | alias Phantom.Session
97 |
98 | @type opts :: [
99 | router: module(),
100 | origins: [String.t()] | :all | mfa(),
101 | validate_origin: boolean(),
102 | session_timeout: pos_integer(),
103 | max_request_size: pos_integer()
104 | ]
105 |
106 | @doc """
107 | Initializes the plug with the given options.
108 |
109 | ## Options
110 |
111 | - `:router` - The MCP router module (required)
112 | - `:origins` - List of allowed origins or `:all` (default: localhost)
113 | - `:validate_origin` - Whether to validate Origin header (default: true)
114 | - `:session_timeout` - Session timeout in milliseconds (default: 30s)
115 | - `:max_request_size` - Maximum request size in bytes (default: 1MB)
116 | """
117 | def init(opts) do
118 | @default_opts
119 | |> Keyword.merge(opts)
120 | |> Map.new()
121 | end
122 |
123 | def call(conn, opts) do
124 | app_config = Map.new(Application.get_all_env(opts.router))
125 | config = Map.merge(opts, app_config)
126 |
127 | conn
128 | |> put_private(:phantom, %{router: config.router, session: nil, requests: %{}})
129 | |> validate_request(config)
130 | |> cors_preflight(config)
131 | |> cors_headers(config)
132 | |> connect(config)
133 | |> dispatch(config)
134 | end
135 |
136 | defp connect(conn, opts) do
137 | router = opts[:router]
138 | if not Cache.initialized?(router), do: Cache.register(router)
139 |
140 | session =
141 | Session.new(get_req_header(conn, "mcp-session-id") |> List.first(),
142 | pubsub: opts.pubsub,
143 | transport_pid: conn.owner,
144 | pid: self(),
145 | router: router
146 | )
147 |
148 | try do
149 | case router.connect(session, %{
150 | params: conn.query_params,
151 | headers: conn.req_headers
152 | }) do
153 | {:ok, session} ->
154 | :telemetry.execute(
155 | [:phantom, :plug, :request, :connect],
156 | %{},
157 | %{
158 | session: session,
159 | router: router,
160 | opts: opts,
161 | conn: conn
162 | }
163 | )
164 |
165 | put_in(conn.private.phantom.session, session)
166 |
167 | {unauthorized, www_authenticate}
168 | when (unauthorized in [401, :unauthorized] and
169 | is_map(www_authenticate)) or is_binary(www_authenticate) ->
170 | www_authenticate =
171 | if is_map(www_authenticate),
172 | do: www_authenticate(www_authenticate),
173 | else: www_authenticate
174 |
175 | conn
176 | |> put_status(401)
177 | |> put_resp_header("www-authenticate", www_authenticate)
178 | |> json_error(Request.error(Request.closed("Unauthorized")))
179 |
180 | {forbidden, message}
181 | when forbidden in [403, :forbidden] and is_binary(message) ->
182 | conn
183 | |> put_status(403)
184 | |> json_error(Request.error(Request.closed(message)))
185 |
186 | {:error, error} when is_map(error) ->
187 | json_error(conn, Request.error(Request.closed(JSON.encode!(error))))
188 |
189 | {:error, reason} ->
190 | json_error(
191 | conn,
192 | Request.error(Request.closed("Connection failed: #{reason}"))
193 | )
194 | end
195 | rescue
196 | e ->
197 | :telemetry.execute(
198 | [:phantom, :plug, :request, :exception],
199 | %{},
200 | %{conn: conn, stacktrace: __STACKTRACE__, exception: e}
201 | )
202 |
203 | json_error(conn, Request.internal_error())
204 | reraise(e, __STACKTRACE__)
205 | end
206 | end
207 |
208 | defp validate_request(conn, opts) do
209 | cond do
210 | opts[:validate_origin] && not valid_origin?(get_origin(conn), opts) ->
211 | conn
212 | |> put_status(403)
213 | |> json_error(Request.error(Request.closed("Origin not allowed")))
214 |
215 | reported_content_length_exceeded?(conn, opts) ->
216 | conn
217 | |> put_status(413)
218 | |> json_error(Request.error(Request.invalid("Request too large")))
219 |
220 | conn.method not in ~w[DELETE GET OPTIONS POST] ->
221 | conn
222 | |> put_status(405)
223 | |> json_error(Request.error(Request.not_found("Method not allowed")))
224 |
225 | conn.method not in ~w[DELETE GET OPTIONS] and map_size(conn.body_params) == 0 ->
226 | conn
227 | |> put_status(400)
228 | |> json_error(Request.error(Request.parse_error("Parse error: Invalid JSON")))
229 |
230 | conn.body_params["_json"] == [] ->
231 | conn
232 | |> put_status(400)
233 | |> json_error(Request.error(Request.parse_error("No requests")))
234 |
235 | true ->
236 | conn
237 | end
238 | end
239 |
240 | defp dispatch(%Plug.Conn{halted: true} = conn, _opts), do: conn
241 |
242 | defp dispatch(
243 | %Plug.Conn{body_params: %Plug.Conn.Unfetched{}, method: "POST"} = conn,
244 | _opts
245 | ) do
246 | conn
247 | |> put_status(500)
248 | |> json_error(Request.error(Request.internal_error()))
249 |
250 | raise """
251 | #{inspect(__MODULE__)} encounted unfetched body parameters, usually meaning
252 | that the router does not have a body parser before it, such as `Plug.Parsers`.
253 | """
254 | end
255 |
256 | defp dispatch(%Plug.Conn{method: "GET"} = conn, opts) do
257 | if opts.pubsub do
258 | conn = maybe_track_session_stream(conn)
259 | session = conn.private.phantom.session
260 |
261 | conn
262 | |> put_resp_header("mcp-session-id", session.id)
263 | |> put_resp_header("cache-control", "no-cache, no-transform")
264 | |> put_resp_content_type("text/event-stream")
265 | |> put_resp_header("connection", "keep-alive")
266 | |> put_resp_header("x-accel-buffering", "no")
267 | |> send_chunked(202)
268 | |> stream_loop(opts)
269 | else
270 | conn
271 | |> put_status(405)
272 | |> json_error(Request.error(Request.not_found("SSE not supported")))
273 | end
274 | end
275 |
276 | defp dispatch(%Plug.Conn{body_params: params, method: "POST"} = conn, opts)
277 | when is_map(params) or is_map_key(params, "_json") do
278 | session = conn.private.phantom.session
279 |
280 | conn
281 | |> put_resp_header("mcp-session-id", session.id)
282 | |> put_resp_header("cache-control", "no-cache")
283 | |> put_resp_content_type("text/event-stream")
284 | |> put_resp_header("connection", "keep-alive")
285 | |> put_resp_header("x-accel-buffering", "no")
286 | |> send_chunked(200)
287 | |> stream_loop(opts)
288 | end
289 |
290 | defp dispatch(%Plug.Conn{method: "DELETE"} = conn, _opts) do
291 | session = conn.private.phantom.session
292 | Phantom.Tracker.untrack_session(session.id)
293 |
294 | conn =
295 | case conn.private.phantom.router.terminate(session) do
296 | {:ok, _} -> send_resp(conn, 200, "")
297 | _ -> send_resp(conn, 204, "")
298 | end
299 |
300 | :telemetry.execute(
301 | [:phantom, :plug, :request, :terminate],
302 | %{},
303 | %{
304 | router: conn.private.phantom.router,
305 | session: conn.private.phantom.session,
306 | conn: conn
307 | }
308 | )
309 |
310 | conn
311 | end
312 |
313 | defp dispatch(%Plug.Conn{method: "POST"} = conn, _opts) do
314 | conn
315 | |> put_status(400)
316 | |> json_error(Request.error(Request.invalid()))
317 | end
318 |
319 | defp dispatch(conn, _opts) do
320 | conn
321 | |> put_status(405)
322 | |> json_error(
323 | Request.error(
324 | Request.not_found("Method not allowed. Use POST for JSON-RPC or GET for SSE.")
325 | )
326 | )
327 | end
328 |
329 | defp continue(state) do
330 | stream_fun = state.stream_fun
331 |
332 | params =
333 | cond do
334 | state.conn.method == "GET" -> []
335 | is_map_key(state.conn.body_params, "_json") -> state.conn.body_params["_json"]
336 | true -> List.wrap(state.conn.body_params)
337 | end
338 |
339 | {state, exceptions} =
340 | Enum.reduce(
341 | params,
342 | {state, []},
343 | fn
344 | _request, {%{conn: %{halted: true}} = state_acc, exceptions_acc} ->
345 | {state_acc, exceptions_acc}
346 |
347 | request, {state_acc, exceptions_acc} ->
348 | case Request.build(request) do
349 | {:ok, request} ->
350 | state_acc = maybe_track_response(state_acc, request)
351 | state_acc = put_in(state_acc.conn, maybe_track_session_stream(state_acc.conn))
352 | state_acc = put_in(state_acc.session, state_acc.conn.private.phantom.session)
353 |
354 | try do
355 | case state_acc.session.router.dispatch_method([
356 | request.method,
357 | request.params,
358 | request,
359 | state_acc.session
360 | ]) do
361 | {:noreply, %Session{} = session_acc} ->
362 | requests = Map.put(session_acc.requests, request.id, request.response)
363 | state_acc = put_in(state_acc.session, %{session_acc | requests: requests})
364 | {state_acc, exceptions_acc}
365 |
366 | {:reply, result, %Session{} = session_acc} ->
367 | request = Request.result(request, "message", result)
368 | state_acc = put_in(state_acc.session, session_acc)
369 |
370 | state_acc =
371 | stream_fun.(state_acc, request.id, request.type, request.response)
372 |
373 | {state_acc, exceptions_acc}
374 |
375 | {:error, error, %Session{} = session_acc} ->
376 | error = Request.error(request.id, error)
377 | state_acc = put_in(state_acc.session, session_acc)
378 | state_acc = stream_fun.(state_acc, error[:id], "message", error)
379 | {state_acc, exceptions_acc}
380 |
381 | {:error, error} ->
382 | error = Request.error(request.id, error)
383 | state_acc = stream_fun.(state_acc, error[:id], "message", error)
384 | {state_acc, exceptions_acc}
385 |
386 | _response ->
387 | error = Request.error(request.id, Request.internal_error())
388 | state_acc = stream_fun.(state_acc, error[:id], "message", error)
389 | {state_acc, exceptions_acc}
390 | end
391 | rescue
392 | exception ->
393 | error =
394 | Request.error(
395 | request.id,
396 | Request.internal_error(Exception.message(exception))
397 | )
398 |
399 | exceptions_acc = [{request, exception, __STACKTRACE__} | exceptions_acc]
400 | state_acc = stream_fun.(state_acc, request.id, "message", error)
401 | {state_acc, exceptions_acc}
402 | end
403 |
404 | {:error, error} ->
405 | state_acc = stream_fun.(state_acc, error.id, "message", error.response)
406 | {state_acc, exceptions_acc}
407 | end
408 | end
409 | )
410 |
411 | maybe_reraise(state, exceptions)
412 | end
413 |
414 | defp cors_preflight(%Plug.Conn{halted: true} = conn, _opts), do: conn
415 |
416 | defp cors_preflight(%Plug.Conn{method: "OPTIONS"} = conn, opts) do
417 | origin = get_req_header(conn, "origin") |> List.first()
418 |
419 | if valid_origin?(origin, opts) do
420 | conn
421 | |> put_cors_headers(origin)
422 | |> send_resp(204, "")
423 | |> halt()
424 | else
425 | conn
426 | |> put_status(403)
427 | |> json_error(Request.error(Request.invalid("Origin not allowed")))
428 | end
429 | end
430 |
431 | defp cors_preflight(conn, _opts), do: conn
432 |
433 | defp cors_headers(%Plug.Conn{halted: true} = conn, _opts), do: conn
434 |
435 | defp cors_headers(conn, opts) do
436 | origin = get_req_header(conn, "origin") |> List.first()
437 |
438 | if valid_origin?(origin, opts) do
439 | put_cors_headers(conn, origin)
440 | else
441 | conn
442 | end
443 | end
444 |
445 | defp put_cors_headers(conn, origin) do
446 | conn
447 | |> put_resp_header("access-control-expose-headers", "last-event-id, mcp-session-id")
448 | |> put_resp_header("access-control-allow-origin", origin || "*")
449 | |> put_resp_header("access-control-allow-credentials", "true")
450 | |> put_resp_header("access-control-allow-methods", "GET, POST, OPTIONS")
451 | |> put_resp_header(
452 | "access-control-allow-headers",
453 | "content-type, authorization, mcp-session-id, last-event-id"
454 | )
455 | |> put_resp_header("access-control-max-age", "86400")
456 | end
457 |
458 | defp stream_fun(%{conn: %{halted: false} = conn} = state, id, event, payload) do
459 | conn = send_sse_event(conn, id, event, payload)
460 | put_in(state.conn, conn)
461 | end
462 |
463 | defp stream_fun(%{session: %{pubsub: pubsub}} = state, _id, _event, _payload)
464 | when is_atom(pubsub) do
465 | state
466 | end
467 |
468 | if Mix.env() == :test do
469 | defp do_stream_fun(fun, listener) when is_pid(listener) do
470 | fn state, id, event, payload ->
471 | send(listener, {:response, id, event, payload})
472 | fun.(state, id, event, payload)
473 | end
474 | end
475 |
476 | defp do_stream_fun(fun, _listener), do: fun
477 | else
478 | defp do_stream_fun(fun, _), do: fun
479 | end
480 |
481 | defp stream_loop(conn, opts) do
482 | try do
483 | Session.start_loop(
484 | conn: conn,
485 | pubsub: opts.pubsub,
486 | continue_fun: &continue/1,
487 | session: conn.private.phantom.session,
488 | timeout: opts.session_timeout,
489 | stream_fun: do_stream_fun(&stream_fun/4, opts[:listener])
490 | )
491 | catch
492 | :exit, :normal -> conn
493 | :exit, :shutdown -> conn
494 | :exit, {:shutdown, _} -> conn
495 | after
496 | untrack(opts)
497 |
498 | # Bandit re-uses the same process for new requests,
499 | # therefore we need to unregister manually and clear
500 | # any pending messages from the inbox
501 | clear_inbox()
502 | send(self(), {:plug_conn, :sent})
503 | disconnect(conn)
504 | end
505 | end
506 |
507 | defp untrack(opts) do
508 | if opts.pubsub do
509 | Phantom.Tracker.untrack(self())
510 | end
511 | end
512 |
513 | defp clear_inbox do
514 | receive do
515 | _ -> clear_inbox()
516 | after
517 | 0 -> :ok
518 | end
519 | end
520 |
521 | defp send_sse_event(conn, id, _event_type, nil) do
522 | id = if id, do: ["id: #{id}\n"], else: []
523 | data = id ++ ["event: message\n", "data: \"\"\n\n"]
524 |
525 | case chunk(conn, data) do
526 | {:ok, conn} -> conn
527 | {:error, _} -> disconnect(conn)
528 | end
529 | end
530 |
531 | defp send_sse_event(conn, id, event_type, %{} = data) do
532 | send_sse_event(conn, id, event_type, JSON.encode!(data))
533 | end
534 |
535 | defp send_sse_event(conn, id, event_type, data) when is_binary(data) do
536 | id = if id, do: ["id: #{id}\n"], else: []
537 | data = id ++ ["event: #{event_type}\n", "data: #{data}\n\n"]
538 |
539 | case chunk(conn, data) do
540 | {:ok, conn} -> conn
541 | {:error, _} -> disconnect(conn)
542 | end
543 | end
544 |
545 | defp valid_origin?(_origin, %{validate_origin: false}), do: true
546 | defp valid_origin?(_origin, %{origins: :all}), do: true
547 | defp valid_origin?(nil, _opts), do: false
548 |
549 | defp valid_origin?(origin, opts) do
550 | case opts[:origins] do
551 | :all -> true
552 | origins when is_list(origins) -> origin in origins
553 | {m, f, a} -> apply(m, f, [origin | a])
554 | _ -> false
555 | end
556 | end
557 |
558 | defp get_origin(conn) do
559 | get_req_header(conn, "origin") |> List.first()
560 | end
561 |
562 | defp reported_content_length_exceeded?(conn, opts) do
563 | case get_req_header(conn, "content-length") do
564 | [length_str] ->
565 | case Integer.parse(length_str) do
566 | {length, ""} -> length > opts[:max_request_size]
567 | _ -> false
568 | end
569 |
570 | _ ->
571 | false
572 | end
573 | end
574 |
575 | defp json_error(conn, error) do
576 | conn
577 | |> put_resp_content_type("application/json")
578 | |> send_resp(conn.status || 400, JSON.encode!(error))
579 | |> disconnect()
580 | end
581 |
582 | defp disconnect(conn) do
583 | conn =
584 | case conn.private.phantom.router.disconnect(conn.private.phantom.session) do
585 | {:ok, session} -> put_in(conn.private.phantom.session, session)
586 | _ -> conn
587 | end
588 |
589 | :telemetry.execute(
590 | [:phantom, :plug, :request, :disconnect],
591 | %{},
592 | %{
593 | router: conn.private.phantom.router,
594 | session: conn.private.phantom.session,
595 | conn: conn
596 | }
597 | )
598 |
599 | halt(conn)
600 | end
601 |
602 | defp maybe_reraise(state, []), do: state
603 |
604 | defp maybe_reraise(state, exceptions) do
605 | for {request, exception, stacktrace} <- exceptions do
606 | :telemetry.execute(
607 | [:phantom, :plug, :request, :exception],
608 | %{},
609 | %{
610 | session: state.conn.private.phantom.session,
611 | conn: state.conn,
612 | stacktrace: stacktrace,
613 | request: request,
614 | exception: exception
615 | }
616 | )
617 | end
618 |
619 | case exceptions do
620 | [{_, exception, stacktrace}] ->
621 | reraise exception, stacktrace
622 |
623 | exceptions ->
624 | raise Phantom.ErrorWrapper.new(
625 | "Exceptions while processing MCP requests",
626 | exceptions
627 | )
628 | end
629 | end
630 |
631 | @doc """
632 | Construct a WWW-Authenticate header as defined by RFC 9728 from the map.
633 |
634 | This requires the map to contain a `:method` to indicate acceptable authentication
635 | methods, typically `"Bearer"`, and then rest of the attributes will be serialized
636 | into the header as key=value.
637 |
638 | For example,
639 |
640 | iex> Phantom.Plug.www_authenticate(%{
641 | ...> method: "Bearer",
642 | ...> resource_metadata: "https://myapp.com/.well-known/oauth-protected-resource",
643 | ...> max_age: 42000
644 | ...> })
645 | ~s|Bearer max_age="42000", resource_metadata="https://myapp.com/.well-known/oauth-protected-resource"|
646 |
647 | https://datatracker.ietf.org/doc/html/rfc9728#name-use-of-www-authenticate-for
648 | """
649 | @type www_authenticate :: %{
650 | required(:method) => String.t(),
651 | optional(String.t() | atom()) => atom() | String.t()
652 | }
653 | @spec www_authenticate(map()) :: String.t()
654 | def www_authenticate(info) do
655 | info = Map.new(info)
656 | {method, info} = Map.pop(info, :method)
657 |
658 | info =
659 | Enum.map_join(info, ", ", fn {key, value} ->
660 | "#{key}=#{inspect(to_string(value))}"
661 | end)
662 |
663 | "#{method} #{info}"
664 | end
665 |
666 | defp maybe_track_response(state, %{response: %{}, id: id}) when is_binary(id) do
667 | Phantom.Tracker.track_request(self(), id)
668 | state
669 | end
670 |
671 | defp maybe_track_response(state, _), do: state
672 |
673 | defp maybe_track_session_stream(conn) do
674 | existing_stream = Phantom.Tracker.get_session(conn.private.phantom.session.id)
675 | track? = conn.body_params["method"] == "initialize" || conn.method == "GET"
676 |
677 | case {track?, existing_stream} do
678 | {true, nil} ->
679 | session = %{conn.private.phantom.session | close_after_complete: !track?}
680 |
681 | Phantom.Tracker.track_session(
682 | self(),
683 | session.id,
684 | conn.body_params["params"]["clientInfo"] || %{}
685 | )
686 |
687 | put_in(conn.private.phantom.session, session)
688 |
689 | {true, _} ->
690 | conn
691 | |> put_status(409)
692 | |> json_error(
693 | Request.error(%{
694 | code: -32000,
695 | message: "Only one SSE stream is allowed per session"
696 | })
697 | )
698 |
699 | _ ->
700 | conn
701 | end
702 | end
703 | end
704 |
--------------------------------------------------------------------------------