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.
@@ -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
@@ -8,48 +8,34 @@ module Axn
8
8
  module Serializer
9
9
  module_function
10
10
 
11
- def serialize_exposed(result, field_configs)
12
- field_configs.each_with_object({}) do |config, hash|
13
- value = result.public_send(config.field)
14
- hash[config.field.to_s] = serialize_value(value)
15
- end
16
- end
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
- def result_to_mcp_response(result, field_configs, text_content: :structured)
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, field_configs)
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
- ::MCP::Tool::Response.new(
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
- class Tool < ::MCP::Tool
6
- include Axn
7
-
8
- expects :server_context, optional: true, description: "MCP server context (injected automatically)"
9
-
10
- class << self
11
- NOT_SET = Object.new.freeze
12
-
13
- def mcp_text_content(value = NOT_SET)
14
- if value == NOT_SET
15
- resolved_mcp_text_content
16
- else
17
- Config.validate_mcp_text_content!(value)
18
- @mcp_text_content = value
19
- end
20
- end
21
-
22
- def resolved_mcp_text_content
23
- if instance_variable_defined?(:@mcp_text_content) && !@mcp_text_content.nil?
24
- @mcp_text_content
25
- else
26
- Axn::MCP.config.mcp_text_content
27
- end
28
- end
29
-
30
- def input_schema(value = NOT_SET)
31
- if value != NOT_SET
32
- super
33
- elsif @input_schema_value
34
- @input_schema_value
35
- else
36
- @input_schema_value = ::MCP::Tool::InputSchema.new(
37
- SchemaBuilder.build_input(internal_field_configs, subfield_configs),
38
- )
39
- end
40
- end
41
-
42
- def input_schema_value
43
- @input_schema_value || input_schema
44
- end
45
-
46
- def output_schema(value = NOT_SET)
47
- if value != NOT_SET
48
- super
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
- def call!(**)
85
- result = call(**)
51
+ See DEPRECATIONS.md.
52
+ MSG
86
53
 
87
- # For MCP calls (with server_context), just return the response
88
- return result if result.is_a?(::MCP::Tool::Response)
89
-
90
- # For direct Axn calls, raise on failure
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
- tool_class
147
- end
59
+ def self.define(*, **, &)
60
+ raise NotImplementedError, MIGRATION_MESSAGE
148
61
  end
149
62
  end
150
63
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Axn
4
4
  module MCP
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.2"
6
6
  end
7
7
  end
@@ -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