defmodule Backplane.McpProtocol.Server.Frame do @moduledoc """ The Backplane.McpProtocol Frame — pure user state + read-only context. ## User fields * `assigns` - shared user data as a map. For HTTP transports, this inherits from `Plug.Conn.assigns`. ## Component maps Runtime-registered components are stored in typed maps keyed by name/URI: * `tools` - `%{name => %Tool{}}` * `resources` - `%{uri => %Resource{}}` * `prompts` - `%{name => %Prompt{}}` * `resource_templates` - `%{name => %Resource{uri_template: ...}}` ## Pagination * `pagination_limit` - optional limit for listing operations ## Context * `context` - read-only `%Context{}`, refreshed by Session before each callback """ alias Backplane.McpProtocol.Server.Component alias Backplane.McpProtocol.Server.Component.Prompt alias Backplane.McpProtocol.Server.Component.Resource alias Backplane.McpProtocol.Server.Component.Schema alias Backplane.McpProtocol.Server.Component.Tool alias Backplane.McpProtocol.Server.Context @type server_component_t :: Tool.t() | Resource.t() | Prompt.t() @type t :: %__MODULE__{ assigns: map(), tools: %{optional(String.t()) => Tool.t()}, resources: %{optional(String.t()) => Resource.t()}, prompts: %{optional(String.t()) => Prompt.t()}, resource_templates: %{optional(String.t()) => Resource.t()}, resource_subscriptions: MapSet.t(String.t()), pagination_limit: non_neg_integer() | nil, task_id: String.t() | nil, context: Context.t() } defstruct assigns: %{}, tools: %{}, resources: %{}, prompts: %{}, resource_templates: %{}, resource_subscriptions: MapSet.new(), pagination_limit: nil, task_id: nil, context: %Context{} @doc """ Creates a new frame with optional initial assigns. ## Examples iex> Frame.new() %Frame{assigns: %{}} iex> Frame.new(%{user: "alice"}) %Frame{assigns: %{user: "alice"}} """ @spec new :: t() @spec new(assigns :: map()) :: t() def new(assigns \\ %{}), do: struct(__MODULE__, assigns: assigns) @doc """ Assigns a value or multiple values to the frame. ## Examples frame = Frame.assign(frame, :status, :active) frame = Frame.assign(frame, %{status: :active, count: 5}) frame = Frame.assign(frame, status: :active, count: 5) """ @spec assign(t(), Enumerable.t()) :: t() @spec assign(t(), key :: atom(), value :: any()) :: t() def assign(%__MODULE__{} = frame, assigns) when is_map(assigns) or is_list(assigns) do Enum.reduce(assigns, frame, fn {key, value}, frame -> assign(frame, key, value) end) end def assign(%__MODULE__{} = frame, key, value) when is_atom(key) do %{frame | assigns: Map.put(frame.assigns, key, value)} end @doc """ Assigns a value to the frame only if the key doesn't already exist. The value is computed lazily using the provided function. ## Examples frame = Frame.assign_new(frame, :timestamp, fn -> DateTime.utc_now() end) """ @spec assign_new(t(), key :: atom(), value_fun :: (-> term())) :: t() def assign_new(%__MODULE__{} = frame, key, fun) when is_atom(key) and is_function(fun, 0) do case frame.assigns do %{^key => _} -> frame _ -> assign(frame, key, fun.()) end end @doc """ Sets the pagination limit for listing operations. ## Examples frame = Frame.put_pagination_limit(frame, 10) frame.pagination_limit # => 10 """ @spec put_pagination_limit(t(), non_neg_integer()) :: t() def put_pagination_limit(%__MODULE__{} = frame, limit) when limit > 0 do %{frame | pagination_limit: limit} end @doc """ Registers a tool definition at runtime. """ @spec register_tool(t(), String.t(), list(tool_opt)) :: t() when tool_opt: {:description, String.t() | nil} | {:input_schema, map() | nil} | {:output_schema, map() | nil} | {:title, String.t() | nil} | {:annotations, map() | nil} | {:task_support, Tool.task_support()} | {:scopes, [String.t()]} def register_tool(%__MODULE__{} = frame, name, opts) when is_binary(name) do input_schema = opts[:input_schema] || %{} raw_schema = Component.__clean_schema_for_peri__(input_schema) validate_input = fn params -> Peri.validate(raw_schema, params) end output_schema = opts[:output_schema] validate_output = if output_schema do raw_output = Component.__clean_schema_for_peri__(output_schema) fn params -> Peri.validate(raw_output, params) end end annotations = opts[:annotations] title = annotations[:title] || annotations["title"] || opts[:title] || name task_support = Keyword.get(opts, :task_support, :forbidden) scopes = validate_scopes_opt!(Keyword.get(opts, :scopes, [])) if task_support not in [:forbidden, :optional, :required] do raise ArgumentError, "Invalid :task_support value #{inspect(task_support)} — must be one of :forbidden, :optional, :required" end tool = %Tool{ name: name, description: opts[:description], input_schema: Schema.to_json_schema(input_schema), output_schema: if(output_schema, do: output_schema |> Component.__make_optional_nullable__() |> Schema.to_json_schema()), annotations: annotations, meta: opts[:meta], title: title, task_support: task_support, scopes: scopes, validate_input: validate_input, validate_output: validate_output } %{frame | tools: Map.put(frame.tools, name, tool)} end @doc """ Registers a prompt definition at runtime. """ @spec register_prompt(t(), String.t(), list(prompt_opt)) :: t() when prompt_opt: {:description, String.t() | nil} | {:arguments, map() | nil} | {:title, String.t() | nil} | {:scopes, [String.t()]} def register_prompt(%__MODULE__{} = frame, name, opts) when is_binary(name) do arguments = opts[:arguments] || %{} raw_schema = Component.__clean_schema_for_peri__(arguments) validate_input = fn params -> Peri.validate(raw_schema, params) end title = opts[:title] || name scopes = validate_scopes_opt!(Keyword.get(opts, :scopes, [])) prompt = %Prompt{ name: name, title: title, description: opts[:description], arguments: Schema.to_prompt_arguments(arguments), scopes: scopes, validate_input: validate_input } %{frame | prompts: Map.put(frame.prompts, name, prompt)} end @doc """ Registers a resource definition with a fixed URI. For parameterized resources, use `register_resource_template/3` instead. """ @spec register_resource(t(), String.t(), list(resource_opt)) :: t() when resource_opt: {:title, String.t() | nil} | {:name, String.t() | nil} | {:description, String.t() | nil} | {:mime_type, String.t() | nil} | {:scopes, [String.t()]} def register_resource(%__MODULE__{} = frame, uri, opts) when is_binary(uri) do name = opts[:name] || Path.basename(uri) scopes = validate_scopes_opt!(Keyword.get(opts, :scopes, [])) resource = %Resource{ uri: uri, title: opts[:title] || name, name: name, description: opts[:description], mime_type: opts[:mime_type] || "text/plain", scopes: scopes } %{frame | resources: Map.put(frame.resources, uri, resource)} end @doc """ Registers a resource template definition using a URI template (RFC 6570). ## Examples frame = Frame.register_resource_template(frame, "file:///{path}", name: "project_files", title: "Project Files", description: "Access files in the project directory" ) """ @spec register_resource_template(t(), String.t(), list(resource_template_opt)) :: t() when resource_template_opt: {:title, String.t() | nil} | {:name, String.t()} | {:description, String.t() | nil} | {:mime_type, String.t() | nil} | {:scopes, [String.t()]} def register_resource_template(%__MODULE__{} = frame, uri_template, opts) when is_binary(uri_template) do name = Keyword.fetch!(opts, :name) scopes = validate_scopes_opt!(Keyword.get(opts, :scopes, [])) resource = %Resource{ uri_template: uri_template, title: opts[:title] || name, name: name, description: opts[:description], mime_type: opts[:mime_type] || "text/plain", scopes: scopes } %{frame | resource_templates: Map.put(frame.resource_templates, name, resource)} end defp validate_scopes_opt!(scopes) do if is_list(scopes) and Enum.all?(scopes, &is_binary/1) do scopes else raise ArgumentError, "Component :scopes must be a list of strings, got: #{inspect(scopes)}" end end @doc """ Records that this session has subscribed to updates for the given resource URI. Idempotent — subscribing twice to the same URI is a no-op. Per the MCP spec, the URI does not need to refer to a currently-registered resource. """ @spec subscribe_resource(t(), uri :: String.t()) :: t() def subscribe_resource(%__MODULE__{} = frame, uri) when is_binary(uri) do %{frame | resource_subscriptions: MapSet.put(frame.resource_subscriptions, uri)} end @doc "Removes a previously-recorded subscription for the given URI." @spec unsubscribe_resource(t(), uri :: String.t()) :: t() def unsubscribe_resource(%__MODULE__{} = frame, uri) when is_binary(uri) do %{frame | resource_subscriptions: MapSet.delete(frame.resource_subscriptions, uri)} end @doc "Returns whether this session has an active subscription for the given URI." @spec resource_subscribed?(t(), uri :: String.t()) :: boolean() def resource_subscribed?(%__MODULE__{} = frame, uri) when is_binary(uri) do MapSet.member?(frame.resource_subscriptions, uri) end @doc "Clears all runtime-registered components" @spec clear_components(t()) :: t() def clear_components(%__MODULE__{} = frame) do %{frame | tools: %{}, resources: %{}, prompts: %{}, resource_templates: %{}} end @doc "Retrieves all runtime-registered components as a flat list" @spec get_components(t()) :: list(server_component_t()) def get_components(%__MODULE__{} = frame) do Map.values(frame.tools) ++ Map.values(frame.resources) ++ Map.values(frame.prompts) ++ Map.values(frame.resource_templates) end @doc false @spec get_tools(t()) :: list(Tool.t()) def get_tools(%__MODULE__{} = frame), do: Map.values(frame.tools) @doc false @spec get_prompts(t()) :: list(Prompt.t()) def get_prompts(%__MODULE__{} = frame), do: Map.values(frame.prompts) @doc false @spec get_resources(t()) :: list(Resource.t()) def get_resources(%__MODULE__{} = frame) do Map.values(frame.resources) ++ Map.values(frame.resource_templates) end @doc """ Returns the OAuth 2.1 claims from the current request context, or `nil` if no authorization is configured or the transport is STDIO. ## Examples case Frame.authorization(frame) do nil -> # no auth configured claims -> claims.sub end """ @spec authorization(t()) :: Context.auth_claims() | nil def authorization(%__MODULE__{context: %Context{auth: auth}}), do: auth @doc """ Returns the `sub` (subject) claim from the bearer token, or `nil`. ## Examples Frame.subject(frame) # => "user-id-123" """ @spec subject(t()) :: String.t() | nil def subject(%__MODULE__{} = frame) do case authorization(frame) do %{sub: sub} -> sub _ -> nil end end @doc """ Returns the list of granted scopes from the bearer token. Returns an empty list when no authorization is present. ## Examples Frame.scopes(frame) # => ["tools:read", "tools:write"] """ @spec scopes(t()) :: [String.t()] def scopes(%__MODULE__{} = frame) do case authorization(frame) do %{scopes: scopes} when is_list(scopes) -> scopes _ -> [] end end @doc """ Returns `true` if the bearer token grants the given scope. ## Examples Frame.has_scope?(frame, "tools:read") # => true """ @spec has_scope?(t(), String.t()) :: boolean() def has_scope?(%__MODULE__{} = frame, scope) when is_binary(scope) do scope in scopes(frame) end @doc """ Returns `true` if the bearer token grants **all** of the given scopes. ## Examples Frame.has_all_scopes?(frame, ["tools:read", "tools:write"]) # => true """ @spec has_all_scopes?(t(), [String.t()]) :: boolean() def has_all_scopes?(%__MODULE__{} = frame, required) when is_list(required) do granted = scopes(frame) Enum.all?(required, &(&1 in granted)) end @doc """ Returns `true` if the request carries validated OAuth 2.1 claims. ## Examples Frame.authenticated?(frame) # => true """ @spec authenticated?(t()) :: boolean() def authenticated?(%__MODULE__{} = frame), do: not is_nil(authorization(frame)) @doc false @spec get_component(t(), name :: String.t()) :: server_component_t() | nil def get_component(%__MODULE__{} = frame, name) do frame.tools[name] || frame.prompts[name] || frame.resource_templates[name] || Enum.find(Map.values(frame.resources), &(&1.name == name)) end @doc """ Serializes Frame for persistent storage. Only `assigns` and `pagination_limit` are persisted. The following fields are **runtime-only** and excluded from serialization: * `tools` — runtime-registered tool definitions (includes validator functions) * `resources` — runtime-registered resource definitions * `prompts` — runtime-registered prompt definitions * `resource_templates` — runtime-registered resource template definitions * `context` — rebuilt by Session before each callback invocation Compile-time components (registered via the `component` macro) are always available from the server module and do not need persistence. """ @spec to_saved(t()) :: map() def to_saved(%__MODULE__{} = frame) do %{ "assigns" => frame.assigns, "pagination_limit" => frame.pagination_limit, "resource_subscriptions" => MapSet.to_list(frame.resource_subscriptions) } end @doc """ Reconstructs Frame from a previously saved map. Restored: `assigns`, `pagination_limit`, `resource_subscriptions`. Runtime-only fields (`tools`, `resources`, `prompts`, `resource_templates`) are initialized empty — their validator functions are not serializable. `context` is left as the default struct and will be set by Session before each callback invocation. """ @spec from_saved(map()) :: t() def from_saved(map) when is_map(map) do subs = map |> Map.get("resource_subscriptions", []) |> Enum.filter(&is_binary/1) |> build_subscriptions() %__MODULE__{ assigns: Map.get(map, "assigns", %{}), pagination_limit: Map.get(map, "pagination_limit"), resource_subscriptions: subs } end def from_saved(_), do: %__MODULE__{resource_subscriptions: build_subscriptions([])} @spec build_subscriptions([String.t()]) :: MapSet.t(String.t()) defp build_subscriptions(list), do: MapSet.new(list) end defimpl Inspect, for: Backplane.McpProtocol.Server.Frame do import Inspect.Algebra def inspect(frame, opts) do info = [ assigns: frame.assigns, tools: map_size(frame.tools), resources: map_size(frame.resources), prompts: map_size(frame.prompts), resource_templates: map_size(frame.resource_templates), resource_subscriptions: MapSet.size(frame.resource_subscriptions) ] info = if session_id = frame.context.session_id, do: [{:session_id, session_id} | info], else: info concat(["#Frame<", to_doc(info, opts), ">"]) end end