Skip to content

Commit 6ac49d1

Browse files
sglyonclaude
andauthored
feat: add _meta support to Tool struct and JSON encoder (#108)
## Problem I have an Elixir app that provides an MCP server and I wanted to utilize the new MCP Apps extension (https://modelcontextprotocol.io/extensions/apps/overview). To do this, I needed support for adding the _meta field to tool declarations so clients know which visual resource to render alongside a tool's output. ## Solution I added a meta field to the Tool struct and wired it through the JSON encoder so _meta appears in the serialized MCP tool object. Tools can now declare `meta: %{"ui" => %{"resourceUri" => "ui://my-app/dashboard"}}` via `use Anubis.Server.Component`. ## Rationale This is the minimal change needed to support the MCP spec's _meta field — I only touched the Tool struct and its serialization path. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Tool components now support optional metadata that can be provided during tool definition and is automatically included in exported tool information and JSON responses. * **Tests** * Added comprehensive tests validating metadata functionality, including JSON encoding and callback optionality. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 89c5405 commit 6ac49d1

6 files changed

Lines changed: 88 additions & 1 deletion

File tree

lib/anubis/server.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ defmodule Anubis.Server do
334334

335335
def parse_components({:tool, name, mod}) do
336336
annotations = if Anubis.exported?(mod, :annotations, 0), do: mod.annotations()
337+
meta = if Anubis.exported?(mod, :meta, 0), do: mod.meta()
337338
output_schema = if Anubis.exported?(mod, :output_schema, 0), do: mod.output_schema()
338339
title = if Anubis.exported?(mod, :title, 0), do: mod.title(), else: name
339340
title = determine_tool_title(annotations, title)
@@ -362,6 +363,7 @@ defmodule Anubis.Server do
362363
input_schema: mod.input_schema(),
363364
output_schema: output_schema,
364365
annotations: annotations,
366+
meta: meta,
365367
handler: mod,
366368
validate_input: validate_input,
367369
validate_output: validate_output

lib/anubis/server/component.ex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ defmodule Anubis.Server.Component do
2525
name = Keyword.get(opts, :name, basename)
2626
mime_type = Keyword.get(opts, :mime_type, "text/plain")
2727
annotations = Keyword.get(opts, :annotations)
28+
meta = Keyword.get(opts, :meta)
2829

2930
quote do
3031
@behaviour unquote(behaviour_module)
@@ -66,6 +67,11 @@ defmodule Anubis.Server.Component do
6667
@impl true
6768
def annotations, do: unquote(annotations)
6869
end
70+
71+
if unquote(meta) != nil do
72+
@impl true
73+
def meta, do: unquote(meta)
74+
end
6975
end
7076

7177
if unquote(type) == :prompt do

lib/anubis/server/component/tool.ex

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ defmodule Anubis.Server.Component.Tool do
7171
input_schema: map | nil,
7272
output_schema: map | nil,
7373
annotations: map | nil,
74+
meta: map | nil,
7475
handler: module | nil,
7576
validate_input: (map -> {:ok, map} | {:error, [Peri.Error.t()]}) | nil,
7677
validate_output: (map -> {:ok, map} | {:error, [Peri.Error.t()]}) | nil
@@ -83,6 +84,7 @@ defmodule Anubis.Server.Component.Tool do
8384
input_schema: nil,
8485
output_schema: nil,
8586
annotations: nil,
87+
meta: nil,
8688
handler: nil,
8789
validate_input: nil,
8890
validate_output: nil
@@ -154,6 +156,14 @@ defmodule Anubis.Server.Component.Tool do
154156
"""
155157
@callback annotations() :: annotations()
156158

159+
@doc """
160+
Returns optional metadata for the tool.
161+
162+
The _meta field allows tools to carry arbitrary metadata that is not
163+
part of the core MCP protocol. This is an optional callback.
164+
"""
165+
@callback meta() :: map()
166+
157167
@doc """
158168
Executes the tool with the given parameters.
159169
@@ -190,7 +200,7 @@ defmodule Anubis.Server.Component.Tool do
190200
| {:noreply, new_state :: Frame.t()}
191201
| {:error, error :: Error.t(), new_state :: Frame.t()}
192202

193-
@optional_callbacks annotations: 0, output_schema: 0, title: 0, description: 0
203+
@optional_callbacks annotations: 0, output_schema: 0, title: 0, description: 0, meta: 0
194204

195205
defimpl JSON.Encoder, for: __MODULE__ do
196206
alias Anubis.Server.Component.Tool
@@ -204,6 +214,7 @@ defmodule Anubis.Server.Component.Tool do
204214
|> then(&if t = tool.title, do: Map.put(&1, "title", t), else: &1)
205215
|> then(&if os = tool.output_schema, do: Map.put(&1, "outputSchema", os), else: &1)
206216
|> then(&if a = tool.annotations, do: Map.put(&1, "annotations", a), else: &1)
217+
|> then(&if m = tool.meta, do: Map.put(&1, "_meta", m), else: &1)
207218
|> JSON.encode!()
208219
end
209220
end

lib/anubis/server/frame.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ defmodule Anubis.Server.Frame do
151151
input_schema: Schema.to_json_schema(input_schema),
152152
output_schema: if(output_schema, do: Schema.to_json_schema(output_schema)),
153153
annotations: annotations,
154+
meta: opts[:meta],
154155
title: title,
155156
validate_input: validate_input,
156157
validate_output: validate_output
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
defmodule Anubis.Server.Component.ToolMetaTest do
2+
use Anubis.MCP.Case, async: true
3+
4+
alias Anubis.Server.Component.Tool
5+
6+
describe "tool _meta JSON encoding" do
7+
test "tool with meta includes _meta in JSON output" do
8+
tool = %Tool{
9+
name: "test_tool",
10+
description: "A test tool",
11+
input_schema: %{"type" => "object", "properties" => %{}},
12+
meta: %{"source" => "test", "version" => 2}
13+
}
14+
15+
encoded = JSON.encode!(tool)
16+
decoded = JSON.decode!(encoded)
17+
18+
assert decoded["_meta"] == %{"source" => "test", "version" => 2}
19+
assert decoded["name"] == "test_tool"
20+
assert decoded["description"] == "A test tool"
21+
end
22+
23+
test "tool without meta does not include _meta in JSON output" do
24+
tool = %Tool{
25+
name: "test_tool",
26+
description: "A test tool",
27+
input_schema: %{"type" => "object", "properties" => %{}}
28+
}
29+
30+
encoded = JSON.encode!(tool)
31+
decoded = JSON.decode!(encoded)
32+
33+
refute Map.has_key?(decoded, "_meta")
34+
assert decoded["name"] == "test_tool"
35+
end
36+
end
37+
38+
describe "meta callback" do
39+
test "meta callback is optional" do
40+
assert function_exported?(ToolWithMeta, :meta, 0)
41+
refute function_exported?(ToolWithoutAnnotations, :meta, 0)
42+
end
43+
44+
test "meta returns expected value" do
45+
assert ToolWithMeta.meta() == %{"source" => "test", "custom_key" => 42}
46+
end
47+
end
48+
end

test/support/test_tools.ex

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,25 @@ defmodule TestPrompts.LegacyPrompt do
179179
end
180180
end
181181

182+
defmodule ToolWithMeta do
183+
@moduledoc "A tool with _meta support"
184+
185+
use Anubis.Server.Component,
186+
type: :tool,
187+
meta: %{"source" => "test", "custom_key" => 42}
188+
189+
alias Anubis.Server.Response
190+
191+
schema do
192+
field(:input, {:required, :string}, description: "Input value")
193+
end
194+
195+
@impl true
196+
def execute(%{input: input}, frame) do
197+
{:reply, Response.text(Response.tool(), "Meta: #{input}"), frame}
198+
end
199+
end
200+
182201
defmodule ToolWithAnnotations do
183202
@moduledoc "A tool with annotations"
184203

0 commit comments

Comments
 (0)