├── 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 | [![Hex.pm](https://img.shields.io/hexpm/v/phantom_mcp.svg)](https://hex.pm/packages/phantom_mcp) 4 | [![Documentation](https://img.shields.io/badge/docs-hexpm-blue.svg)](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 | --------------------------------------------------------------------------------