axn-mcp 0.1.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +209 -0
- data/README.md +459 -100
- data/lib/axn/mcp/annotations.rb +30 -0
- data/lib/axn/mcp/invocation.rb +124 -0
- data/lib/axn/mcp/serializer.rb +17 -31
- data/lib/axn/mcp/tool.rb +52 -139
- data/lib/axn/mcp/version.rb +1 -1
- data/lib/axn/mcp/wrap.rb +173 -0
- data/lib/axn/mcp.rb +101 -8
- metadata +10 -11
- data/Rakefile +0 -13
- data/lib/axn/mcp/config.rb +0 -27
- data/lib/axn/mcp/field_declarations.rb +0 -21
- data/lib/axn/mcp/schema_builder.rb +0 -224
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module MCP
|
|
5
|
+
# Maps axn core's `semantic_hints` vocabulary (plus the MCP-only extensions this adapter
|
|
6
|
+
# registers below) to MCP tool annotation kwargs. Registering :open_world/:closed_world via
|
|
7
|
+
# Axn::Extensions.config.register_semantic_hint is the poster-child use of PRO-2842's registry:
|
|
8
|
+
# no core change was needed to add MCP-spec-only vocabulary.
|
|
9
|
+
module Annotations
|
|
10
|
+
# read_only also asserts destructive_hint: false: MCP::Tool::Annotations defaults
|
|
11
|
+
# destructive_hint to true, so without this a read_only tool would be advertised as BOTH
|
|
12
|
+
# read-only and destructive -- the same contradiction the legacy read_only! bang method
|
|
13
|
+
# already avoided by setting both explicitly. A later :destructive in the same hints list
|
|
14
|
+
# still wins (annotations_for merges in order), for a tool that means both.
|
|
15
|
+
HINT_TO_ANNOTATION = {
|
|
16
|
+
read_only: { read_only_hint: true, destructive_hint: false },
|
|
17
|
+
idempotent: { idempotent_hint: true },
|
|
18
|
+
destructive: { destructive_hint: true },
|
|
19
|
+
open_world: { open_world_hint: true },
|
|
20
|
+
closed_world: { open_world_hint: false },
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def annotations_for(hints)
|
|
26
|
+
hints.each_with_object({}) { |hint, acc| acc.merge!(HINT_TO_ANNOTATION.fetch(hint, {})) }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Axn
|
|
4
|
+
module MCP
|
|
5
|
+
# Shared "server_context: -> ambient_context: -> Invoker#call -> MCP::Tool::Response" path used by
|
|
6
|
+
# Axn::MCP.wrap-generated classes (PRO-2844). The injected server_context is passed *as* the
|
|
7
|
+
# Axn's ambient_context (spread, not nested under a `server_context` key), so a wrapped Axn
|
|
8
|
+
# declares the data it needs generically -- `expects :user_id, on: :ambient_context` -- and stays
|
|
9
|
+
# adapter-agnostic (the same class works under axn-ruby_llm, or a direct call resolving from
|
|
10
|
+
# Current). Passing an explicit ambient_context: (even the empty {} when server_context is nil)
|
|
11
|
+
# is what replaces axn core's default Current-attributes-derived ambient_context, stopping
|
|
12
|
+
# server-side Current state from leaking into an MCP call.
|
|
13
|
+
#
|
|
14
|
+
# The raw server_context object -- carrying MCP transport *capabilities* (#report_progress,
|
|
15
|
+
# #cancelled?), which are not ambient data and don't survive ambient_context's declared-key
|
|
16
|
+
# filtering -- is exposed separately via Axn::MCP.server_context for the duration of the call.
|
|
17
|
+
module Invocation
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def perform(axn_class, kwargs, text_content:, tool_name: nil)
|
|
21
|
+
# Prefer the Symbol key WHENEVER PRESENT (key?, not `||`/truthiness): an explicit
|
|
22
|
+
# server_context: nil must win over a String-keyed value, or a caller could forge
|
|
23
|
+
# server_context by appending an extra "server_context" argument alongside a real, explicit
|
|
24
|
+
# nil. Only fall back to the String key when the Symbol key is absent entirely -- a
|
|
25
|
+
# forwarding layer that splats a parsed Hash without symbolizing first would otherwise
|
|
26
|
+
# silently lose the real value (it gets stripped below as `rest`, with no reader here to
|
|
27
|
+
# have captured it first).
|
|
28
|
+
server_context = kwargs.key?(:server_context) ? kwargs[:server_context] : kwargs["server_context"]
|
|
29
|
+
# Strip :ambient_context too, not just :server_context, and strip BOTH keys under either
|
|
30
|
+
# a Symbol or a String form: **rest is expanded after the trusted `ambient_context:`
|
|
31
|
+
# literal below, so a caller-supplied `ambient_context:`/`"ambient_context"` kwarg (e.g.
|
|
32
|
+
# smuggled in as an extra tool argument from a transport that splats parsed JSON without
|
|
33
|
+
# symbolizing keys first) would otherwise silently win -- Ruby keeps a String key distinct
|
|
34
|
+
# from the Symbol literal at the splat site, and Axn::Core::Context only symbolizes afterwards
|
|
35
|
+
# (last-write-wins on the collision), so `.except` with Symbol keys alone doesn't catch it.
|
|
36
|
+
rest = kwargs.reject { |k, _| %w[server_context ambient_context].include?(k.to_s) }
|
|
37
|
+
|
|
38
|
+
# The sanctioned tool call path (axn core's Axn::Tools::Invoker, PRO-2943/PRO-3332), not a
|
|
39
|
+
# bare `axn_class.call` -- args here are untrusted, model-supplied wire data, and the Invoker
|
|
40
|
+
# applies the tool contract a trusted in-process `.call` deliberately doesn't: always-on
|
|
41
|
+
# coercion of those wire args (a JSON-string "5" for an `expects :n, type: Integer` becomes
|
|
42
|
+
# the Integer), a user-facing "Invalid tool arguments" failure instead of an on_exception page
|
|
43
|
+
# for an inbound contract violation, and rejection of a top-level key the tool never declared
|
|
44
|
+
# (previously silently dropped by plain `**kwargs`). `adapter: :mcp` stamps every call this
|
|
45
|
+
# Invoker makes with the `invoked_via` dimension for its whole call tree (Datadog can then
|
|
46
|
+
# separate tool-driven traffic from ordinary direct calls) -- matches axn-ruby_llm's and
|
|
47
|
+
# axn-openapi's own `Invoker.new(user_facing_input_errors: true, reject_undeclared_inputs:
|
|
48
|
+
# true)` profile, the established contract for an LLM/model-facing tool adapter.
|
|
49
|
+
#
|
|
50
|
+
# `rest`/`server_context` here are unchanged from before this migration: the Invoker ALSO
|
|
51
|
+
# strips a model-supplied `ambient_context` key and merges in the trusted `ambient_context:`
|
|
52
|
+
# kwarg after that guard, so this file's own stripping above is now redundant with the
|
|
53
|
+
# Invoker's -- kept as-is rather than removed, since a caller-supplied `ambient_context` was
|
|
54
|
+
# already stripped from `rest` before it ever reaches here.
|
|
55
|
+
invoker = Axn::Tools::Invoker.new(adapter: :mcp, user_facing_input_errors: true, reject_undeclared_inputs: true)
|
|
56
|
+
result = Axn::MCP.with_server_context(server_context) do
|
|
57
|
+
invoker.call(axn_class, rest, ambient_context: server_context || {})
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Uphold axn's non-bang "never raises" contract at the adapter boundary. The wrapped Axn's own
|
|
61
|
+
# `.call` never raises -- core catches action exceptions into a failed Result and pages
|
|
62
|
+
# on_exception itself -- but the TRANSPORT layer that runs AFTER it (exposed-value
|
|
63
|
+
# serialization, response building) can raise outside core's executor: a value with no honest
|
|
64
|
+
# JSON form (dup keys, non-finite float, non-UTF-8 bytes, an opaque value under
|
|
65
|
+
# reject_opaque_exposed_values), a structure past JSON's max_nesting, or a gem bug.
|
|
66
|
+
#
|
|
67
|
+
# `guard_tool_response` (core's Axn::Tools::AdapterSerialization, PRO-2996) owns that guard for
|
|
68
|
+
# every tool adapter: it reports through axn's global on_exception hook -- inside its own
|
|
69
|
+
# best_effort, so a broken reporter can't break the guard -- honors core's
|
|
70
|
+
# `best_effort_raises_in_dev` by re-raising in development, and otherwise calls `on_error` so
|
|
71
|
+
# the adapter builds its own transport-native response. Only the MCP-shaped parts are ours: the
|
|
72
|
+
# diagnostic log line and the `MCP::Tool::Response`. Scoped to the mapping step ONLY (never
|
|
73
|
+
# axn_class.call above, which already reports its own exceptions -- wrapping both would
|
|
74
|
+
# double-report one failure).
|
|
75
|
+
Axn::MCP.guard_tool_response(axn_class, on_error: lambda { |e|
|
|
76
|
+
# The user-facing response stays generic (ADAPTER_FAILURE_MESSAGE) -- this line is an
|
|
77
|
+
# operator's only pointer to WHY. Mirrors axn-openapi's dispatcher hint: the config pointer
|
|
78
|
+
# lives HERE rather than in core's exception message, since core raises the same error for
|
|
79
|
+
# adapters with no such setting. Named as BOTH config levels, never just the gem-wide setter
|
|
80
|
+
# -- the value is resolved per-tool, so a `configure(:mcp)` override beats `config`, and core
|
|
81
|
+
# exposes no way to ask which level supplied a resolved value. Non-committal ("if this is")
|
|
82
|
+
# because reject_opaque_exposed_values being on doesn't mean THIS failure is an opaque
|
|
83
|
+
# rejection -- it could equally be a colliding key, a non-finite Float, or a gem bug.
|
|
84
|
+
#
|
|
85
|
+
# Resolved right here rather than threaded in as a kwarg: `Axn::MCP.serialize_exposed` now
|
|
86
|
+
# resolves the same override internally (off the result's own action class), so `perform` no
|
|
87
|
+
# longer receives it. A second read is fine -- this one only builds a diagnostic string, it
|
|
88
|
+
# gates no behavior.
|
|
89
|
+
#
|
|
90
|
+
# Named by the MCP-facing tool_name (from wrap's `resolved_name`), not the wrapped Axn's own
|
|
91
|
+
# class name: `Axn::MCP.wrap` lets the same Axn mount under a different name per call site
|
|
92
|
+
# (an explicit `name:`, or a per-adapter `tool mcp: { name: }` override), and an operator
|
|
93
|
+
# correlating this line with a failed MCP request has the REQUEST's tool name, not the class's.
|
|
94
|
+
# Falls back to `resolved_axn_name` (axn core) for a caller of `perform` outside `wrap` (the
|
|
95
|
+
# specs) -- never raw `#{axn_class}`: Class#to_s does NOT dispatch through an overridden
|
|
96
|
+
# `.name` (it renders the object-id form regardless), so a class with no assigned constant --
|
|
97
|
+
# e.g. one built via Axn::Factory.build -- would otherwise show as `#<Class:0x...>` instead of
|
|
98
|
+
# naming the action.
|
|
99
|
+
#
|
|
100
|
+
# Kept inside its own best_effort, rather than leaning on guard_tool_response's rescue around
|
|
101
|
+
# `on_error`: that rescue reports and then RE-RAISES, so a broken configured logger (or a
|
|
102
|
+
# hostile tool_name override) would escape as an exception on the very path whose whole job
|
|
103
|
+
# is to return an error response. Swallowing it here keeps the response guaranteed; the log
|
|
104
|
+
# line is the diagnostic, not the contract.
|
|
105
|
+
Axn::Extensions.best_effort("Axn::MCP transport-failure diagnostic log", action: axn_class) do
|
|
106
|
+
hint = if Axn::MCP.resolve_override_for(axn_class, :reject_opaque_exposed_values)
|
|
107
|
+
display_name = tool_name || axn_class.resolved_axn_name
|
|
108
|
+
" (if this is an opaque-value rejection: reject_opaque_exposed_values resolved true for " \
|
|
109
|
+
"#{display_name} — unset it on the action via `configure(:mcp)`, or " \
|
|
110
|
+
"gem-wide via `Axn::MCP.config.reject_opaque_exposed_values = false`, whichever is set)"
|
|
111
|
+
else
|
|
112
|
+
""
|
|
113
|
+
end
|
|
114
|
+
Axn.config.logger.error { "[axn-mcp] failed to serialize successful result: #{e.class}: #{e.message}#{hint}" }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
Serializer.error_response(Serializer::ADAPTER_FAILURE_MESSAGE)
|
|
118
|
+
}) do
|
|
119
|
+
Serializer.result_to_mcp_response(result, text_content:)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
data/lib/axn/mcp/serializer.rb
CHANGED
|
@@ -8,48 +8,34 @@ module Axn
|
|
|
8
8
|
module Serializer
|
|
9
9
|
module_function
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def serialize_value(value)
|
|
19
|
-
case value
|
|
20
|
-
when nil, String, Integer, Float, TrueClass, FalseClass
|
|
21
|
-
value
|
|
22
|
-
when Hash
|
|
23
|
-
value.transform_keys(&:to_s).transform_values { |v| serialize_value(v) }
|
|
24
|
-
when Array
|
|
25
|
-
value.map { |v| serialize_value(v) }
|
|
26
|
-
else
|
|
27
|
-
if value.respond_to?(:as_json)
|
|
28
|
-
value.as_json
|
|
29
|
-
elsif value.respond_to?(:to_h)
|
|
30
|
-
serialize_value(value.to_h)
|
|
31
|
-
else
|
|
32
|
-
value.to_s
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
end
|
|
11
|
+
# Client-facing text when the transport layer raises while turning a *successful* result into a
|
|
12
|
+
# response (serialization, response-building — see Invocation.perform's guard). Deliberately
|
|
13
|
+
# generic: the actionable detail is a gem/tool bug, so it rides on the reported exception (logs
|
|
14
|
+
# / on_exception), not the tool's response — mirroring how axn keeps a failure's detail off the
|
|
15
|
+
# user-facing message.
|
|
16
|
+
ADAPTER_FAILURE_MESSAGE = "The tool could not produce a valid response"
|
|
36
17
|
|
|
37
|
-
|
|
18
|
+
# No `reject_opaque_exposed_values:` kwarg: `Axn::MCP.serialize_exposed` (from core's
|
|
19
|
+
# Axn::Tools::AdapterSerialization) resolves it per-tool off `result.__action__`'s own class, so
|
|
20
|
+
# there is nothing left for a caller to pass -- or to pass wrong. Resolving one class's override
|
|
21
|
+
# while rendering a different class's result is now structurally impossible.
|
|
22
|
+
def result_to_mcp_response(result, text_content: :structured)
|
|
38
23
|
if result.ok?
|
|
39
|
-
exposed = serialize_exposed(result
|
|
24
|
+
exposed = Axn::MCP.serialize_exposed(result)
|
|
40
25
|
success_text = success_response_text(result, exposed, text_content)
|
|
41
26
|
::MCP::Tool::Response.new(
|
|
42
27
|
[{ type: "text", text: success_text }],
|
|
43
28
|
structured_content: exposed.presence,
|
|
44
29
|
)
|
|
45
30
|
else
|
|
46
|
-
|
|
47
|
-
[{ type: "text", text: result.error }],
|
|
48
|
-
error: true,
|
|
49
|
-
)
|
|
31
|
+
error_response(result.error)
|
|
50
32
|
end
|
|
51
33
|
end
|
|
52
34
|
|
|
35
|
+
def error_response(text)
|
|
36
|
+
::MCP::Tool::Response.new([{ type: "text", text: }], error: true)
|
|
37
|
+
end
|
|
38
|
+
|
|
53
39
|
def success_response_text(result, exposed, text_content)
|
|
54
40
|
use_message = text_content == :message
|
|
55
41
|
success_message = result.respond_to?(:success) ? result.success : result.message
|
data/lib/axn/mcp/tool.rb
CHANGED
|
@@ -2,149 +2,62 @@
|
|
|
2
2
|
|
|
3
3
|
module Axn
|
|
4
4
|
module MCP
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
elsif @output_schema_value
|
|
50
|
-
@output_schema_value
|
|
51
|
-
elsif external_field_configs.empty?
|
|
52
|
-
nil
|
|
53
|
-
else
|
|
54
|
-
@output_schema_value = ::MCP::Tool::OutputSchema.new(
|
|
55
|
-
SchemaBuilder.build_output(external_field_configs),
|
|
5
|
+
# RETIRED (PRO-2923): the `Axn::MCP::Tool` subclass base has been removed.
|
|
6
|
+
#
|
|
7
|
+
# It previously wrapped `include Axn` + `::MCP::Tool` into a dual-mode base whose `.call`
|
|
8
|
+
# returned either an `Axn::Result` (called directly) or an `MCP::Tool::Response` (called with
|
|
9
|
+
# `server_context:`). That dual mode also overrode `input_schema` to a non-Hash
|
|
10
|
+
# `MCP::Tool::InputSchema`, which broke `Axn::RubyLLM.wrap` on the same class. The author-once
|
|
11
|
+
# model replaces it: write a plain Axn and expose it with `Axn::MCP.wrap` (per tool) or
|
|
12
|
+
# `Axn::MCP.tools` (every registered `:mcp` tool at once).
|
|
13
|
+
#
|
|
14
|
+
# Kept only as a raising tombstone so leftover `class MyTool < Axn::MCP::Tool` / `.define` code
|
|
15
|
+
# fails with a migration message instead of a bare `NameError`/`NoMethodError`. See
|
|
16
|
+
# DEPRECATIONS.md — this constant is slated for deletion at 1.0.
|
|
17
|
+
class Tool
|
|
18
|
+
MIGRATION_MESSAGE = <<~MSG
|
|
19
|
+
Axn::MCP::Tool (subclass base) and Axn::MCP::Tool.define were retired in PRO-2923.
|
|
20
|
+
|
|
21
|
+
Instead of subclassing:
|
|
22
|
+
|
|
23
|
+
class MyTool < Axn::MCP::Tool
|
|
24
|
+
expects :query, type: String
|
|
25
|
+
exposes :results, type: Array
|
|
26
|
+
def call = expose(results: Item.search(query))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
author a plain Axn and expose it with Axn::MCP.wrap:
|
|
30
|
+
|
|
31
|
+
class MyTool
|
|
32
|
+
include Axn
|
|
33
|
+
tool :mcp # optional: discoverable via Axn::MCP.tools
|
|
34
|
+
expects :query, type: String
|
|
35
|
+
exposes :results, type: Array
|
|
36
|
+
def call = expose(results: Item.search(query))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Axn::MCP.wrap(MyTool) # -> a ready-to-register ::MCP::Tool subclass
|
|
40
|
+
# ...or register every :mcp tool at once:
|
|
41
|
+
MCP::Server.new(name: "...", version: "...", tools: Axn::MCP.tools)
|
|
42
|
+
|
|
43
|
+
For a one-off inline tool (the old .define use case), wrap an Axn::Factory.build:
|
|
44
|
+
|
|
45
|
+
Axn::MCP.wrap(
|
|
46
|
+
Axn::Factory.build(expects: { query: { type: String } },
|
|
47
|
+
exposes: { results: { type: Array } }) { expose results: Item.search(query) },
|
|
48
|
+
name: "search", description: "Search for items",
|
|
56
49
|
)
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
def output_schema_value
|
|
61
|
-
return @output_schema_value if @output_schema_value
|
|
62
|
-
return nil if external_field_configs.empty?
|
|
63
|
-
|
|
64
|
-
output_schema
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def to_h
|
|
68
|
-
input_schema
|
|
69
|
-
output_schema unless external_field_configs.empty?
|
|
70
|
-
super
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def call(**kwargs)
|
|
74
|
-
result = new(**kwargs).tap(&:_run).result
|
|
75
|
-
|
|
76
|
-
# Branch on presence of server_context:
|
|
77
|
-
# - Present: called from MCP server, return MCP::Tool::Response
|
|
78
|
-
# - Absent: called directly as Axn, return Axn::Result
|
|
79
|
-
return result unless kwargs.key?(:server_context)
|
|
80
|
-
|
|
81
|
-
Serializer.result_to_mcp_response(result, external_field_configs, text_content: resolved_mcp_text_content)
|
|
82
|
-
end
|
|
83
50
|
|
|
84
|
-
|
|
85
|
-
|
|
51
|
+
See DEPRECATIONS.md.
|
|
52
|
+
MSG
|
|
86
53
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return result if result.ok?
|
|
92
|
-
|
|
93
|
-
raise result.exception
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
# Convenience DSL for annotations
|
|
97
|
-
# See: https://github.com/modelcontextprotocol/ruby-sdk#tool-annotations
|
|
98
|
-
#
|
|
99
|
-
# Available annotations:
|
|
100
|
-
# destructive_hint: true/false - Indicates if tool performs destructive operations (default: true)
|
|
101
|
-
# idempotent_hint: true/false - Indicates if tool's operations are idempotent (default: false)
|
|
102
|
-
# open_world_hint: true/false - Indicates if tool operates in open world context (default: true)
|
|
103
|
-
# read_only_hint: true/false - Indicates if tool only reads data (default: false)
|
|
104
|
-
# title: "string" - Human-readable title for the tool
|
|
105
|
-
|
|
106
|
-
def read_only!
|
|
107
|
-
annotations(read_only_hint: true, destructive_hint: false)
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def destructive!
|
|
111
|
-
annotations(destructive_hint: true, read_only_hint: false)
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def idempotent!
|
|
115
|
-
annotations(idempotent_hint: true)
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def open_world!
|
|
119
|
-
annotations(open_world_hint: true)
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def closed_world!
|
|
123
|
-
annotations(open_world_hint: false)
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
# Factory-style tool definition for quick one-off tools
|
|
127
|
-
def define(description:, expects: [], exposes: [], annotations: nil, mcp_text_content: NOT_SET, **_opts, &block)
|
|
128
|
-
tool_class = Class.new(self) do
|
|
129
|
-
include Axn unless self < Axn
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
FieldDeclarations.hydrate(expects).each do |field, field_opts|
|
|
133
|
-
tool_class.expects(field, **field_opts)
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
FieldDeclarations.hydrate(exposes).each do |field, field_opts|
|
|
137
|
-
tool_class.exposes(field, **field_opts)
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
tool_class.description(description)
|
|
141
|
-
tool_class.annotations(annotations) if annotations
|
|
142
|
-
tool_class.mcp_text_content(mcp_text_content) if mcp_text_content != NOT_SET
|
|
143
|
-
|
|
144
|
-
tool_class.define_method(:call, &block) if block
|
|
54
|
+
def self.inherited(subclass)
|
|
55
|
+
super
|
|
56
|
+
raise NotImplementedError, MIGRATION_MESSAGE
|
|
57
|
+
end
|
|
145
58
|
|
|
146
|
-
|
|
147
|
-
|
|
59
|
+
def self.define(*, **, &)
|
|
60
|
+
raise NotImplementedError, MIGRATION_MESSAGE
|
|
148
61
|
end
|
|
149
62
|
end
|
|
150
63
|
end
|
data/lib/axn/mcp/version.rb
CHANGED
data/lib/axn/mcp/wrap.rb
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/object/blank"
|
|
4
|
+
|
|
5
|
+
module Axn
|
|
6
|
+
module MCP
|
|
7
|
+
class << self
|
|
8
|
+
# Exposes any Axn (whether or not it subclasses Axn::MCP::Tool) as an ::MCP::Tool subclass.
|
|
9
|
+
# The wrapped class's own .call/.call! are untouched -- direct callers keep getting a plain
|
|
10
|
+
# Axn::Result. All MCP transport concerns (schema, server_context routing, response mapping)
|
|
11
|
+
# live entirely on the generated subclass, via the same Axn::MCP::Invocation path
|
|
12
|
+
# Axn::MCP::Tool#call uses -- proves the "author once" story from PRO-2844/PRO-2842.
|
|
13
|
+
#
|
|
14
|
+
# The generated subclass is MCP-transport-only, unlike Axn::MCP::Tool: its .call always
|
|
15
|
+
# returns MCP::Tool::Response (never a raw Axn::Result), and it deliberately has no .call! --
|
|
16
|
+
# MCP::Server itself only ever calls .call, and a consumer wanting real bang/raise-on-failure
|
|
17
|
+
# semantics can call the original wrapped class's own .call! directly (unwrapped).
|
|
18
|
+
VALID_PRESENT_AS = %i[structured message].freeze
|
|
19
|
+
|
|
20
|
+
# `server_context` is the input name the wrapper reserves for the injected MCP context
|
|
21
|
+
# (Invocation strips it before calling the Axn), so a wrapped Axn must not declare it as a
|
|
22
|
+
# top-level `expects`. (`ambient_context`, the other stripped key, needs no entry here: axn core
|
|
23
|
+
# already reserves it and rejects `expects :ambient_context` at declaration.)
|
|
24
|
+
RESERVED_INPUT_FIELDS = %i[server_context].freeze
|
|
25
|
+
private_constant :RESERVED_INPUT_FIELDS
|
|
26
|
+
|
|
27
|
+
# Sentinel for the retired `mcp_text_content:` kwarg (renamed to `present_as:`, PRO-2923), so a
|
|
28
|
+
# caller still passing it gets a pointed migration error instead of silently having it ignored.
|
|
29
|
+
RENAMED_MCP_TEXT_CONTENT = Object.new.freeze
|
|
30
|
+
private_constant :RENAMED_MCP_TEXT_CONTENT
|
|
31
|
+
|
|
32
|
+
# The gem-level convenience for building a ready-to-register MCP tool list (PRO-2923),
|
|
33
|
+
# symmetric with `Axn::RubyLLM.tools`: enumerate every Axn that belongs to the :mcp adapter
|
|
34
|
+
# (via axn core's process-global registry) and wrap each one. Zero-arg by design -- per-tool
|
|
35
|
+
# customization comes from each class's own `configure(:mcp) { ... }` (honored inside `wrap`)
|
|
36
|
+
# and its `tool name:`/`description`, not per-call kwargs -- so a consumer registers tools with
|
|
37
|
+
# just `MCP::Server.new(tools: Axn::MCP.tools, ...)` instead of a hand-maintained array.
|
|
38
|
+
def tools
|
|
39
|
+
Axn::Tools.for(:mcp).map { |axn_class| wrap(axn_class) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def wrap(axn_class, description: nil, name: nil, title: nil, icons: nil, meta: nil, annotations: nil,
|
|
43
|
+
present_as: nil, mcp_text_content: RENAMED_MCP_TEXT_CONTENT)
|
|
44
|
+
reject_renamed_mcp_text_content!(mcp_text_content)
|
|
45
|
+
validate_present_as!(present_as)
|
|
46
|
+
reject_reserved_input_fields!(axn_class)
|
|
47
|
+
resolved_name = resolve_wrap_tool_name(axn_class, name)
|
|
48
|
+
resolved_description = description || axn_class.description
|
|
49
|
+
|
|
50
|
+
# Each metadata kwarg falls back to the wrapped Axn's own `configure(:mcp)` value (via axn
|
|
51
|
+
# core's shadow-proof resolver), so it's carried through the zero-arg `Axn::MCP.tools` path
|
|
52
|
+
# too; an explicit `wrap` kwarg still wins. `annotations` also falls back further, to the
|
|
53
|
+
# `semantic_hints`-derived defaults -- precedence: kwarg > configure(:mcp) > semantic_hints.
|
|
54
|
+
resolved_title = title || Axn::MCP.resolve_override_for(axn_class, :title)
|
|
55
|
+
resolved_icons = icons || Axn::MCP.resolve_override_for(axn_class, :icons)
|
|
56
|
+
resolved_meta = meta || Axn::MCP.resolve_override_for(axn_class, :meta)
|
|
57
|
+
configured_annotations = annotations || Axn::MCP.resolve_override_for(axn_class, :annotations)
|
|
58
|
+
|
|
59
|
+
# Surface the resolved revision in `_meta` (never the name -- name is the cross-adapter
|
|
60
|
+
# identity), so an operator/model can see which version `Axn::MCP.tools` collapsed to. Only
|
|
61
|
+
# for actually-versioned tools; an unversioned tool (default `tool_version` 1) stays
|
|
62
|
+
# meta-free. Keyed `tool_version` to mirror the DSL and avoid clobbering a consumer's own
|
|
63
|
+
# `version` meta key.
|
|
64
|
+
resolved_meta = { **(resolved_meta || {}), tool_version: axn_class.tool_version } if axn_class.tool_version > 1
|
|
65
|
+
|
|
66
|
+
Class.new(::MCP::Tool) do
|
|
67
|
+
tool_name(resolved_name)
|
|
68
|
+
description(resolved_description)
|
|
69
|
+
title(resolved_title) if resolved_title
|
|
70
|
+
icons(resolved_icons) if resolved_icons
|
|
71
|
+
meta(resolved_meta) if resolved_meta
|
|
72
|
+
|
|
73
|
+
# axn_class.input_schema/.output_schema are axn core's own public reflection entry points --
|
|
74
|
+
# wiring to those directly, rather than reaching past them for the lower-level builder the
|
|
75
|
+
# wrapped Axn's own methods already call, keeps this on the documented surface.
|
|
76
|
+
input_schema(axn_class.input_schema)
|
|
77
|
+
output_schema(axn_class.output_schema) unless axn_class.external_field_configs.empty?
|
|
78
|
+
|
|
79
|
+
hint_annotations = Axn::MCP::Annotations.annotations_for(axn_class._semantic_hints)
|
|
80
|
+
resolved_annotations = configured_annotations || (hint_annotations if hint_annotations.any?)
|
|
81
|
+
# Skip the setter for an EMPTY hash, not just nil: `annotations: {}` (or a `configure(:mcp)`
|
|
82
|
+
# empty override) is how a caller suppresses the semantic-hint-derived annotations, and
|
|
83
|
+
# `MCP::Tool.annotations()` with no kwargs would instead advertise the SDK's own defaults
|
|
84
|
+
# (destructiveHint: true, openWorldHint: true, ...) -- the opposite of what they asked for.
|
|
85
|
+
self.annotations(**resolved_annotations) if resolved_annotations.present?
|
|
86
|
+
|
|
87
|
+
define_singleton_method(:call) do |**kwargs|
|
|
88
|
+
# Resolved fresh on every call, not captured once at wrap-time, so a gem-wide config
|
|
89
|
+
# change takes effect immediately, even for tools already wrapped before the change.
|
|
90
|
+
#
|
|
91
|
+
# Reads the wrapped Axn's own per-action override (e.g. `axn_class.configure(:mcp) { |c|
|
|
92
|
+
# c.present_as = :message }`) via Axn::MCP.resolve_override_for rather than
|
|
93
|
+
# axn_class.present_as -- axn_class is a plain Axn that may never have included
|
|
94
|
+
# Axn::MCP.overrides, so it may have no such method at all; resolve_override_for reads
|
|
95
|
+
# the override store directly and falls back to the gem-wide config on its own, with no
|
|
96
|
+
# dependency on axn_class having that accessor.
|
|
97
|
+
#
|
|
98
|
+
# `present_as` is the only setting resolved here now: `reject_opaque_exposed_values` is
|
|
99
|
+
# resolved inside `Axn::MCP.serialize_exposed` (core's Axn::Tools::AdapterSerialization),
|
|
100
|
+
# keyed off the result's own action class, so there is no longer a value to thread through
|
|
101
|
+
# the invocation -- and no way for a caller to thread the wrong one.
|
|
102
|
+
Axn::MCP::Invocation.perform(
|
|
103
|
+
axn_class, kwargs,
|
|
104
|
+
text_content: present_as || Axn::MCP.resolve_override_for(axn_class, :present_as),
|
|
105
|
+
tool_name: resolved_name
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
# `mcp_text_content:` was renamed to `present_as:` (PRO-2923). It's kept as a raising alias --
|
|
114
|
+
# rather than silently dropped -- so a caller still passing it gets a pointed migration error.
|
|
115
|
+
# Detected via a sentinel (not `nil?`), so an explicit `mcp_text_content: nil` is still caught.
|
|
116
|
+
def reject_renamed_mcp_text_content!(value)
|
|
117
|
+
return if value.equal?(RENAMED_MCP_TEXT_CONTENT)
|
|
118
|
+
|
|
119
|
+
raise ArgumentError,
|
|
120
|
+
"Axn::MCP.wrap's `mcp_text_content:` was renamed to `present_as:` (PRO-2923). " \
|
|
121
|
+
"Replace `mcp_text_content: #{value.inspect}` with `present_as: #{value.inspect}`."
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Fail fast on any explicitly-provided invalid value (a typo like :mesage, or `false`)
|
|
125
|
+
# rather than silently falling through to :structured -- matches the validation the
|
|
126
|
+
# `present_as` setting already enforces. `nil` alone means "not provided" (use the resolved
|
|
127
|
+
# per-class/gem-wide value at call time) -- checked via `nil?`, not truthiness, so
|
|
128
|
+
# `present_as: false` can't skip this guard the way a truthy-only check would.
|
|
129
|
+
def validate_present_as!(present_as)
|
|
130
|
+
return if present_as.nil? || VALID_PRESENT_AS.include?(present_as)
|
|
131
|
+
|
|
132
|
+
raise ArgumentError, "present_as must be one of #{VALID_PRESENT_AS.map(&:inspect).join(", ")}; got #{present_as.inspect}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# `server_context` is reserved: Invocation injects the MCP server context under that key and
|
|
136
|
+
# strips it before calling the Axn (see Invocation.perform). A wrapped Axn declaring it as a
|
|
137
|
+
# normal TOP-LEVEL `expects` (so it surfaces in `input_schema`, unlike an `on: :ambient_context`
|
|
138
|
+
# field, which is excluded) would advertise it as a client argument yet never receive it -- a
|
|
139
|
+
# schema-valid call would then fail the Axn's own validation. Reject at wrap time rather than ship
|
|
140
|
+
# that contradiction; the data an Axn needs from the context goes on `ambient_context`
|
|
141
|
+
# (`expects :user_id, on: :ambient_context`).
|
|
142
|
+
def reject_reserved_input_fields!(axn_class)
|
|
143
|
+
reserved = RESERVED_INPUT_FIELDS & (axn_class.input_schema[:properties] || {}).keys.map(&:to_sym)
|
|
144
|
+
return if reserved.empty?
|
|
145
|
+
|
|
146
|
+
raise ArgumentError,
|
|
147
|
+
"Axn::MCP.wrap can't expose #{axn_class}: #{reserved.map(&:inspect).join(", ")} " \
|
|
148
|
+
"#{reserved.one? ? "is a reserved top-level input name" : "are reserved top-level input names"} " \
|
|
149
|
+
"(the wrapper injects the MCP server context there). Declare the data the Axn needs on " \
|
|
150
|
+
"`ambient_context` instead, e.g. `expects :user_id, on: :ambient_context`."
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Resolve the provider-facing tool name. An explicit `name:` kwarg (most local to the call
|
|
154
|
+
# site) wins; otherwise consume axn core's canonical `tool_name(:mcp)` -- passing the adapter so
|
|
155
|
+
# a per-adapter `tool mcp: { name: "..." }` override wins (PRO-2942), then a shared `tool name:`,
|
|
156
|
+
# then `tool_name_stripped_prefixes` + snake_cased class name. Passing `:mcp` also matches the
|
|
157
|
+
# name `Axn::Tools.for(:mcp)` sorts / collapses to the latest per `tool_name`, so `.tools` and a
|
|
158
|
+
# direct `wrap` agree.
|
|
159
|
+
#
|
|
160
|
+
# Core's `tool_name` never returns blank: a truly anonymous, never-named class (no class name,
|
|
161
|
+
# no `axn_name`) falls back to the generic "tool". That's a footgun for wrap's common inline
|
|
162
|
+
# `tools: [Axn::MCP.wrap(...)]` usage -- every such nameless tool would collide on "tool" and
|
|
163
|
+
# be unregisterable in practice -- so keep wrap's original fail-fast for that case rather than
|
|
164
|
+
# silently shipping a tool named "tool"; require an explicit `name:` instead.
|
|
165
|
+
def resolve_wrap_tool_name(axn_class, name)
|
|
166
|
+
return name if name
|
|
167
|
+
return axn_class.tool_name(:mcp) if axn_class.name.present? || axn_class.axn_name.present?
|
|
168
|
+
|
|
169
|
+
raise ArgumentError, "Axn::MCP.wrap requires name: when the wrapped Axn has no derivable class name (#{axn_class}.name is nil)"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|