Module: Axn::MCP

Extended by:
Configurable, Tools::AdapterRoots
Defined in:
lib/axn/mcp.rb,
lib/axn/mcp/tool.rb,
lib/axn/mcp/wrap.rb,
lib/axn/mcp/version.rb,
lib/axn/mcp/invocation.rb,
lib/axn/mcp/serializer.rb,
lib/axn/mcp/annotations.rb

Defined Under Namespace

Modules: Annotations, Invocation, Serializer Classes: Error, SchemaError, Tool

Constant Summary collapse

VALID_PRESENT_AS =

Exposes any Axn (whether or not it subclasses Axn::MCP::Tool) as an ::MCP::Tool subclass. The wrapped class's own .call/.call! are untouched -- direct callers keep getting a plain Axn::Result. All MCP transport concerns (schema, server_context routing, response mapping) live entirely on the generated subclass, via the same Axn::MCP::Invocation path Axn::MCP::Tool#call uses -- proves the "author once" story from PRO-2844/PRO-2842.

The generated subclass is MCP-transport-only, unlike Axn::MCP::Tool: its .call always returns MCP::Tool::Response (never a raw Axn::Result), and it deliberately has no .call! -- MCP::Server itself only ever calls .call, and a consumer wanting real bang/raise-on-failure semantics can call the original wrapped class's own .call! directly (unwrapped).

%i[structured message].freeze
VERSION =
"0.2.0"

Class Method Summary collapse

Class Method Details

.deprecatorObject

Shared deprecator for this gem's own deprecated API (e.g. the legacy annotation bang-methods -- see lib/axn/mcp/tool.rb). A dedicated ActiveSupport::Deprecation instance, not the old global ActiveSupport::Deprecation.warn, so a consuming Rails app can register it (Rails.application.deprecators[:axn_mcp] = Axn::MCP.deprecator) and govern its behavior (silence in test, raise in CI, etc.) the same way it already does for its own deprecations.



66
67
68
# File 'lib/axn/mcp.rb', line 66

def self.deprecator
  @deprecator ||= ActiveSupport::Deprecation.new("1.0", "axn-mcp")
end

.server_contextObject

The live MCP server context for the current wrapped-tool call -- an MCP::ServerContext over a real MCP::Server, or whatever raw value was passed as server_context: to a direct call -- or nil outside a wrapped #call. This is the MCP-specific handle for transport capabilities (Axn::MCP.server_context.report_progress(...), .cancelled?), which are not ambient data and don't survive ambient_context's declared-key filtering. Ambient data still belongs in ambient_context (expects :user_id, on: :ambient_context), which wrap spreads from the same server_context and which stays adapter-agnostic. A tool reaching for this is knowingly MCP-coupled -- appropriate, since these operations are MCP-transport-only.



78
79
80
# File 'lib/axn/mcp.rb', line 78

def self.server_context
  ActiveSupport::IsolatedExecutionState[:__axn_mcp_server_context]
end

.toolsObject

The gem-level convenience for building a ready-to-register MCP tool list (PRO-2923), symmetric with Axn::RubyLLM.tools: enumerate every Axn that belongs to the :mcp adapter (via axn core's process-global registry) and wrap each one. Zero-arg by design -- per-tool customization comes from each class's own configure(:mcp) { ... } (honored inside wrap) and its tool name:/description, not per-call kwargs -- so a consumer registers tools with just MCP::Server.new(tools: Axn::MCP.tools, ...) instead of a hand-maintained array.



38
39
40
# File 'lib/axn/mcp/wrap.rb', line 38

def tools
  Axn::Tools.for(:mcp).map { |axn_class| wrap(axn_class) }
end

.with_server_context(value) ⇒ Object

Sets Axn::MCP.server_context for the duration of the block. Uses ActiveSupport's IsolatedExecutionState (thread- or fiber-scoped per the configured isolation_level), matching how axn core scopes its own per-execution state and CurrentAttributes -- so this stays correct under a Fiber scheduler rather than silently leaking across fibers a raw Thread-local would. Restores the previous value on the way out so nested wrapped calls compose.



87
88
89
90
91
92
93
# File 'lib/axn/mcp.rb', line 87

def self.with_server_context(value)
  previous = ActiveSupport::IsolatedExecutionState[:__axn_mcp_server_context]
  ActiveSupport::IsolatedExecutionState[:__axn_mcp_server_context] = value
  yield
ensure
  ActiveSupport::IsolatedExecutionState[:__axn_mcp_server_context] = previous
end

.wrap(axn_class, description: nil, name: nil, title: nil, icons: nil, meta: nil, annotations: nil, present_as: nil, mcp_text_content: RENAMED_MCP_TEXT_CONTENT) ⇒ Object



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/axn/mcp/wrap.rb', line 42

def wrap(axn_class, description: nil, name: nil, title: nil, icons: nil, meta: nil, annotations: nil,
         present_as: nil, mcp_text_content: RENAMED_MCP_TEXT_CONTENT)
  reject_renamed_mcp_text_content!(mcp_text_content)
  validate_present_as!(present_as)
  reject_reserved_input_fields!(axn_class)
  resolved_name = resolve_wrap_tool_name(axn_class, name)
  resolved_description = description || axn_class.description

  # Each metadata kwarg falls back to the wrapped Axn's own `configure(:mcp)` value (via axn
  # core's shadow-proof resolver), so it's carried through the zero-arg `Axn::MCP.tools` path
  # too; an explicit `wrap` kwarg still wins. `annotations` also falls back further, to the
  # `semantic_hints`-derived defaults -- precedence: kwarg > configure(:mcp) > semantic_hints.
  resolved_title = title || Axn::MCP.resolve_override_for(axn_class, :title)
  resolved_icons = icons || Axn::MCP.resolve_override_for(axn_class, :icons)
  resolved_meta  = meta  || Axn::MCP.resolve_override_for(axn_class, :meta)
  configured_annotations = annotations || Axn::MCP.resolve_override_for(axn_class, :annotations)

  # Surface the resolved revision in `_meta` (never the name -- name is the cross-adapter
  # identity), so an operator/model can see which version `Axn::MCP.tools` collapsed to. Only
  # for actually-versioned tools; an unversioned tool (default `tool_version` 1) stays
  # meta-free. Keyed `tool_version` to mirror the DSL and avoid clobbering a consumer's own
  # `version` meta key.
  resolved_meta = { **(resolved_meta || {}), tool_version: axn_class.tool_version } if axn_class.tool_version > 1

  Class.new(::MCP::Tool) do
    tool_name(resolved_name)
    description(resolved_description)
    title(resolved_title) if resolved_title
    icons(resolved_icons) if resolved_icons
    meta(resolved_meta) if resolved_meta

    # axn_class.input_schema/.output_schema are axn core's own public reflection entry points --
    # wiring to those directly, rather than reaching past them for the lower-level builder the
    # wrapped Axn's own methods already call, keeps this on the documented surface.
    input_schema(axn_class.input_schema)
    output_schema(axn_class.output_schema) unless axn_class.external_field_configs.empty?

    hint_annotations = Axn::MCP::Annotations.annotations_for(axn_class._semantic_hints)
    resolved_annotations = configured_annotations || (hint_annotations if hint_annotations.any?)
    # Skip the setter for an EMPTY hash, not just nil: `annotations: {}` (or a `configure(:mcp)`
    # empty override) is how a caller suppresses the semantic-hint-derived annotations, and
    # `MCP::Tool.annotations()` with no kwargs would instead advertise the SDK's own defaults
    # (destructiveHint: true, openWorldHint: true, ...) -- the opposite of what they asked for.
    self.annotations(**resolved_annotations) if resolved_annotations.present?

    define_singleton_method(:call) do |**kwargs|
      # Resolved fresh on every call, not captured once at wrap-time, so a gem-wide config
      # change takes effect immediately, even for tools already wrapped before the change.
      #
      # Reads the wrapped Axn's own per-action override (e.g. `axn_class.configure(:mcp) { |c|
      # c.present_as = :message }`) via Axn::MCP.resolve_override_for rather than
      # axn_class.present_as -- axn_class is a plain Axn that may never have included
      # Axn::MCP.overrides, so it may have no such method at all; resolve_override_for reads
      # the override store directly and falls back to the gem-wide config on its own, with no
      # dependency on axn_class having that accessor.
      Axn::MCP::Invocation.perform(
        axn_class, kwargs,
        text_content: present_as || Axn::MCP.resolve_override_for(axn_class, :present_as),
        reject_opaque_exposed_values: Axn::MCP.resolve_override_for(axn_class, :reject_opaque_exposed_values)
      )
    end
  end
end