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.
data/lib/axn/mcp.rb CHANGED
@@ -3,20 +3,113 @@
3
3
  require "date"
4
4
  require "axn"
5
5
  require "mcp"
6
+ require "active_support/deprecation"
7
+ require "active_support/isolated_execution_state"
6
8
 
7
9
  require_relative "mcp/version"
8
- require_relative "mcp/config"
9
- require_relative "mcp/serializer"
10
- require_relative "mcp/schema_builder"
11
- require_relative "mcp/field_declarations"
12
- require_relative "mcp/tool"
10
+ Axn::Extensions.config.register_semantic_hint(:open_world, :closed_world)
13
11
 
14
12
  module Axn
15
13
  module MCP
16
- class SchemaError < StandardError; end
14
+ extend Axn::Configurable
15
+ extend Axn::Tools::AdapterRoots
16
+ extend Axn::Tools::AdapterSerialization
17
+
18
+ config_namespace :mcp
19
+
20
+ # Base error for this gem, marked with core's `Axn::Error` boundary (PRO-2997) so a consumer's
21
+ # `rescue Axn::Error` catches it alongside axn core's errors and the other adapter gems'.
22
+ # `Axn::Error` is a marker module -- including it adds no ancestry. New gem errors subclass this.
23
+ class Error < StandardError
24
+ include Axn::Error
25
+ end
26
+
27
+ class SchemaError < Error; end
28
+
29
+ # `extend Axn::Tools::AdapterRoots` declares a validated `tool_roots` directory list (default
30
+ # []); the registry reads `Axn::MCP.config.tool_roots` for directory-based tool membership.
31
+ # Ship the shared `agent_tools` convention as the default root, so an Axn under `app/agent_tools/`
32
+ # is exposed as an MCP tool out of the box (no explicit `tool :mcp` needed) -- and, since
33
+ # axn-ruby_llm defaults to the same dir, the same tool is exposed over ruby_llm too.
34
+ # `tool_roots_default` re-declares the setting through core's own `setting` path -- overriding
35
+ # AdapterRoots' empty default while reusing its broad-path validation (which rejects widening a
36
+ # root to `app/`/`.`/`actions`/a `..` traversal), and validating EAGERLY here rather than at the
37
+ # registry's first read -- so an app's own assignment still wins and `config.reset!(:tool_roots)`
38
+ # returns to this default rather than core's `[]`.
39
+ tool_roots_default %w[agent_tools]
40
+
41
+ setting :present_as, default: :structured, one_of: %i[structured message], overridable: true
42
+
43
+ # When true, serializing a successful result's `exposes` values rejects any value that has no
44
+ # JSON rendering its author declared -- one that would otherwise ship as an opaque blob like
45
+ # `"#<User:0x...>"` (or, in Rails, ActiveSupport's generic instance-variable dump). The rejection
46
+ # raises during serialization; Invocation's guard turns that into an error response (and pages
47
+ # `on_exception`), so the tool call fails cleanly rather than shipping the blob. Default `false`:
48
+ # for an LLM tool result an ugly-but-honest string usually beats a failed call, so the opaque
49
+ # rendering ships. Applies ONLY to outbound `exposes` serialization, not to inbound argument
50
+ # handling. (Values with no *honest* JSON form -- cycles, non-finite Floats, non-UTF-8-encodable
51
+ # bytes, colliding Hash keys -- fail regardless of this flag; it governs only the extra "was this
52
+ # rendering author-declared?" check.)
53
+ declare_reject_opaque_exposed_values! default: false
17
54
 
18
- def self.config
19
- @config ||= Config.new
55
+ # Per-tool MCP metadata, declarable on the class via `configure(:mcp) { |c| c.title = "..." }`
56
+ # so it survives the zero-arg `Axn::MCP.tools` path (which calls `wrap` with no kwargs). Each
57
+ # defaults to nil -- left at `::MCP::Tool`'s own default unless set -- and an explicit `wrap`
58
+ # kwarg still wins over the configured value. Loosely typed (no validation here); `::MCP::Tool`
59
+ # validates the shapes when they're applied.
60
+ setting :title, default: nil, overridable: true
61
+ setting :icons, default: nil, overridable: true
62
+ setting :meta, default: nil, overridable: true
63
+ setting :annotations, default: nil, overridable: true
64
+
65
+ # Shared deprecator for this gem's own deprecated API (e.g. the legacy annotation
66
+ # bang-methods -- see lib/axn/mcp/tool.rb). A dedicated ActiveSupport::Deprecation instance,
67
+ # not the old global ActiveSupport::Deprecation.warn, so a consuming Rails app can register it
68
+ # (`Rails.application.deprecators[:axn_mcp] = Axn::MCP.deprecator`) and govern its behavior
69
+ # (silence in test, raise in CI, etc.) the same way it already does for its own deprecations.
70
+ def self.deprecator
71
+ @deprecator ||= ActiveSupport::Deprecation.new("1.0", "axn-mcp")
72
+ end
73
+
74
+ # The live MCP server context for the current wrapped-tool call -- an `MCP::ServerContext` over a
75
+ # real `MCP::Server`, or whatever raw value was passed as `server_context:` to a direct call --
76
+ # or `nil` outside a wrapped `#call`. This is the MCP-specific handle for transport *capabilities*
77
+ # (`Axn::MCP.server_context.report_progress(...)`, `.cancelled?`), which are not ambient data and
78
+ # don't survive `ambient_context`'s declared-key filtering. Ambient *data* still belongs in
79
+ # `ambient_context` (`expects :user_id, on: :ambient_context`), which `wrap` spreads from the same
80
+ # server_context and which stays adapter-agnostic. A tool reaching for this is knowingly
81
+ # MCP-coupled -- appropriate, since these operations are MCP-transport-only.
82
+ def self.server_context
83
+ ActiveSupport::IsolatedExecutionState[:__axn_mcp_server_context]
84
+ end
85
+
86
+ # Sets Axn::MCP.server_context for the duration of the block. Uses ActiveSupport's
87
+ # IsolatedExecutionState (thread- or fiber-scoped per the configured isolation_level), matching
88
+ # how axn core scopes its own per-execution state and CurrentAttributes -- so this stays correct
89
+ # under a Fiber scheduler rather than silently leaking across fibers a raw Thread-local would.
90
+ # Restores the previous value on the way out so nested wrapped calls compose.
91
+ def self.with_server_context(value)
92
+ previous = ActiveSupport::IsolatedExecutionState[:__axn_mcp_server_context]
93
+ ActiveSupport::IsolatedExecutionState[:__axn_mcp_server_context] = value
94
+ yield
95
+ ensure
96
+ ActiveSupport::IsolatedExecutionState[:__axn_mcp_server_context] = previous
20
97
  end
98
+
99
+ # Register :mcp with axn core's process-global tool registry, passing this module as the config
100
+ # source (PRO-2943/PRO-2944) so the registry reads `Axn::MCP.config.tool_roots` for
101
+ # directory-based membership. A consumer builds its server tool list from `Axn::MCP.tools`
102
+ # (`Axn::Tools.for(:mcp)`) -- resolving directory-root grants, an explicit `tool`/`tool :mcp`
103
+ # declaration, and implicit `configure(:mcp)` membership -- instead of a hand-maintained array.
104
+ Axn::Tools.register_adapter(:mcp, self)
21
105
  end
22
106
  end
107
+
108
+ # Required after the module above extends Axn::Configurable, since Tool calls
109
+ # Axn::MCP.overrides at class-definition time. (Setting declaration order is
110
+ # free, but the extend must happen first.)
111
+ require_relative "mcp/annotations"
112
+ require_relative "mcp/serializer"
113
+ require_relative "mcp/invocation"
114
+ require_relative "mcp/tool"
115
+ require_relative "mcp/wrap"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: axn-mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kali Donovan
@@ -15,7 +15,7 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 0.1.0.pre.alpha.4.3
18
+ version: 0.1.0.pre.alpha.6
19
19
  - - "<"
20
20
  - !ruby/object:Gem::Version
21
21
  version: 0.2.0
@@ -25,7 +25,7 @@ dependencies:
25
25
  requirements:
26
26
  - - ">="
27
27
  - !ruby/object:Gem::Version
28
- version: 0.1.0.pre.alpha.4.3
28
+ version: 0.1.0.pre.alpha.6
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: 0.2.0
@@ -35,20 +35,20 @@ dependencies:
35
35
  requirements:
36
36
  - - ">="
37
37
  - !ruby/object:Gem::Version
38
- version: '0.4'
38
+ version: 0.5.0
39
39
  - - "<"
40
40
  - !ruby/object:Gem::Version
41
- version: '1.0'
41
+ version: '2.0'
42
42
  type: :runtime
43
43
  prerelease: false
44
44
  version_requirements: !ruby/object:Gem::Requirement
45
45
  requirements:
46
46
  - - ">="
47
47
  - !ruby/object:Gem::Version
48
- version: '0.4'
48
+ version: 0.5.0
49
49
  - - "<"
50
50
  - !ruby/object:Gem::Version
51
- version: '1.0'
51
+ version: '2.0'
52
52
  description: Build MCP tools using Axn's expects/exposes contract with auto-generated
53
53
  JSON schemas and responses.
54
54
  email:
@@ -60,15 +60,14 @@ files:
60
60
  - CHANGELOG.md
61
61
  - LICENSE
62
62
  - README.md
63
- - Rakefile
64
63
  - lib/axn-mcp.rb
65
64
  - lib/axn/mcp.rb
66
- - lib/axn/mcp/config.rb
67
- - lib/axn/mcp/field_declarations.rb
68
- - lib/axn/mcp/schema_builder.rb
65
+ - lib/axn/mcp/annotations.rb
66
+ - lib/axn/mcp/invocation.rb
69
67
  - lib/axn/mcp/serializer.rb
70
68
  - lib/axn/mcp/tool.rb
71
69
  - lib/axn/mcp/version.rb
70
+ - lib/axn/mcp/wrap.rb
72
71
  homepage: https://github.com/teamshares/axn-mcp
73
72
  licenses:
74
73
  - MIT
data/Rakefile DELETED
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/gem_tasks"
4
- require "rspec/core/rake_task"
5
- require "rubocop/rake_task"
6
-
7
- RSpec::Core::RakeTask.new(:spec)
8
-
9
- RuboCop::RakeTask.new
10
-
11
- task default: %i[spec rubocop]
12
-
13
- Rake::Task["build"].enhance([:default])
@@ -1,27 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Axn
4
- module MCP
5
- class Config
6
- VALID_MCP_TEXT_CONTENT = %i[structured message].freeze
7
-
8
- attr_reader :mcp_text_content
9
-
10
- def initialize
11
- @mcp_text_content = :structured
12
- end
13
-
14
- def mcp_text_content=(value)
15
- self.class.validate_mcp_text_content!(value)
16
- @mcp_text_content = value
17
- end
18
-
19
- def self.validate_mcp_text_content!(value)
20
- return if VALID_MCP_TEXT_CONTENT.include?(value)
21
-
22
- raise ArgumentError,
23
- "mcp_text_content must be one of #{VALID_MCP_TEXT_CONTENT.map(&:inspect).join(", ")}; got #{value.inspect}"
24
- end
25
- end
26
- end
27
- end
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Axn
4
- module MCP
5
- module FieldDeclarations
6
- module_function
7
-
8
- def hydrate(declarations)
9
- return declarations if declarations.is_a?(Hash)
10
-
11
- Array(declarations).each_with_object({}) do |item, acc|
12
- if item.is_a?(Hash)
13
- item.each { |k, v| acc[k] = v }
14
- else
15
- acc[item] = {}
16
- end
17
- end
18
- end
19
- end
20
- end
21
- end
@@ -1,224 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "date"
4
-
5
- module Axn
6
- module MCP
7
- module SchemaBuilder
8
- TYPE_MAP = {
9
- String => "string",
10
- Integer => "integer",
11
- Float => "number",
12
- Numeric => "number",
13
- Hash => "object",
14
- Array => "array",
15
- TrueClass => "boolean",
16
- FalseClass => "boolean",
17
- Date => "string",
18
- DateTime => "string",
19
- Time => "string",
20
- }.freeze
21
-
22
- FORMAT_MAP = {
23
- Date => "date",
24
- DateTime => "date-time",
25
- Time => "date-time",
26
- }.freeze
27
-
28
- EXCLUDED_FROM_SCHEMA = %i[server_context].freeze
29
-
30
- module_function
31
-
32
- def build_input(field_configs, subfield_configs = [])
33
- properties = {}
34
- required = []
35
-
36
- subfields_by_parent = subfield_configs.group_by(&:on)
37
-
38
- field_configs.each do |config|
39
- next if EXCLUDED_FROM_SCHEMA.include?(config.field)
40
-
41
- if config.validations[:model]
42
- build_model_property(config, properties, required)
43
- else
44
- prop = build_property(config)
45
- nested_subfields = subfields_by_parent[config.field]
46
- if nested_subfields&.any? && prop[:type] == "object"
47
- prop[:properties] ||= {}
48
- prop[:required] ||= []
49
- nested_subfields.each do |subconfig|
50
- subprop = build_property(subconfig)
51
- prop[:properties][subconfig.field] = subprop
52
- prop[:required] << subconfig.field.to_s unless optional?(subconfig)
53
- end
54
- prop[:required] = nil if prop[:required].empty?
55
- end
56
-
57
- properties[config.field] = prop.compact
58
- required << config.field.to_s unless optional?(config)
59
- end
60
- end
61
-
62
- schema = { type: "object", properties: }
63
- schema[:required] = required unless required.empty?
64
- schema
65
- end
66
-
67
- def build_output(field_configs)
68
- properties = {}
69
- required = []
70
-
71
- field_configs.each do |config|
72
- prop = build_property(config, for_output: true)
73
- properties[config.field] = prop.compact
74
- required << config.field.to_s unless optional?(config)
75
- end
76
-
77
- schema = { type: "object", properties: }
78
- schema[:required] = required unless required.empty?
79
- schema
80
- end
81
-
82
- def build_property(config, for_output: false)
83
- prop = {}
84
- prop[:description] = config.description if config.description
85
-
86
- type_info = json_type_for(config.validations, for_output:)
87
- prop[:type] = type_info[:type] if type_info[:type]
88
- prop[:format] = type_info[:format] if type_info[:format]
89
-
90
- prop[:default] = config.default if config.respond_to?(:default) && !config.default.nil?
91
-
92
- if (inclusion = config.validations[:inclusion])
93
- enum_values = inclusion[:in] || inclusion[:within] if inclusion.is_a?(Hash)
94
- prop[:enum] = enum_values if enum_values
95
- end
96
-
97
- apply_structured_schema!(prop, config, for_output:)
98
-
99
- prop
100
- end
101
-
102
- # Combine of: (bare element baseline) and shape: (typed member contracts) into
103
- # items:/properties: schema. Precedence: shape: enriches/overrides of: baseline.
104
- def apply_structured_schema!(prop, config, for_output:)
105
- of = config.validations[:of]
106
- shape = config.validations[:shape]
107
- return unless of || shape
108
-
109
- if prop[:type] == "array"
110
- items = of ? items_schema_for(of, for_output:) : {}
111
- if shape
112
- member_props, required = member_properties(shape[:members], for_output:)
113
- base_props = items[:properties] || {}
114
- items = items.merge(type: "object", properties: base_props.merge(member_props))
115
- items[:required] = required unless required.empty?
116
- end
117
- prop[:items] = items unless items.empty?
118
- elsif shape
119
- # Hash / class field — shape: members are the object's own properties.
120
- # If the field type is a Data.define subclass, use its members as the bare
121
- # baseline so unannotated members still appear (same enrich logic as of:).
122
- member_props, required = member_properties(shape[:members], for_output:)
123
- type_klass = config.validations.dig(:type, :klass)
124
- base_props = type_klass.is_a?(Class) && type_klass < Data ? type_klass.members.to_h { |m| [m, {}] } : {}
125
- prop[:properties] = base_props.merge(member_props)
126
- prop[:required] = required unless required.empty?
127
- end
128
- end
129
-
130
- # Build a JSON Schema items: value from the of: validation hash.
131
- def items_schema_for(of_validations, for_output: false)
132
- klasses = Array(of_validations[:klass])
133
- if klasses.size == 1
134
- single_items_schema(klasses.first, for_output:)
135
- else
136
- { anyOf: klasses.map { |k| single_items_schema(k, for_output:) } }
137
- end
138
- end
139
-
140
- def single_items_schema(klass, for_output: false)
141
- if klass.is_a?(Class) && klass < Data
142
- # Data.define subclass → object with named (but untyped) properties as baseline
143
- { type: "object", properties: klass.members.to_h { |m| [m, {}] } }
144
- else
145
- json_type_for({ type: klass }, for_output:)
146
- end
147
- end
148
-
149
- # Build properties/required from a shape: block's members. Recurses for nested shape/of.
150
- def member_properties(members, for_output:)
151
- props = {}
152
- required = []
153
- members.each do |m|
154
- props[m.field] = build_property(m, for_output:).compact
155
- required << m.field.to_s unless optional?(m)
156
- end
157
- [props, required]
158
- end
159
-
160
- def build_model_property(config, properties, required)
161
- model_opts = config.validations[:model]
162
- klass = model_opts[:klass]
163
- klass_name = klass.is_a?(Class) ? klass.name : klass.to_s
164
-
165
- id_field = :"#{config.field}_id"
166
- prop = {
167
- type: "integer",
168
- description: config.description || "ID of the #{klass_name} record",
169
- }
170
-
171
- properties[id_field] = prop.compact
172
- required << id_field.to_s unless optional?(config)
173
- end
174
-
175
- def json_type_for(validations, for_output: false)
176
- if validations[:type]
177
- type_opt = validations[:type]
178
- klass = type_opt.is_a?(Hash) ? type_opt[:klass] : type_opt
179
- klasses = Array(klass)
180
-
181
- klass = klasses.first
182
- return { type: "boolean" } if klass == :boolean
183
- return { type: "string", format: "uuid" } if klass == :uuid
184
-
185
- if TYPE_MAP.key?(klass)
186
- result = { type: TYPE_MAP[klass] }
187
- result[:format] = FORMAT_MAP[klass] if FORMAT_MAP.key?(klass)
188
- return result
189
- end
190
-
191
- return { type: "object" } if for_output
192
-
193
- return { type: "string" }
194
- end
195
-
196
- if validations[:inclusion]
197
- inclusion = validations[:inclusion]
198
- enum_values = inclusion[:in] || inclusion[:within] if inclusion.is_a?(Hash)
199
- if enum_values&.any?
200
- sample = enum_values.first
201
- return { type: "string" } if sample.is_a?(String)
202
- return { type: "integer" } if sample.is_a?(Integer)
203
- return { type: "number" } if sample.is_a?(Float)
204
- end
205
- end
206
-
207
- if validations[:numericality]
208
- numericality = validations[:numericality]
209
- return { type: "integer" } if numericality.is_a?(Hash) && numericality[:only_integer]
210
-
211
- return { type: "number" }
212
- end
213
-
214
- return { type: "string" } if validations[:presence] || validations[:length]
215
-
216
- {}
217
- end
218
-
219
- def optional?(config)
220
- Axn::Internal::FieldConfig.optional?(config)
221
- end
222
- end
223
- end
224
- end