axn-mcp 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 33bb333838d7dfe4375c7d66f95b0058c66330eb309271e752f8587d77de4ac7
4
+ data.tar.gz: 9bc64595d2939e420e1fa55c7cfe91b19799d56a73ca0c710dc6ced07edd643f
5
+ SHA512:
6
+ metadata.gz: 4fe4d4184f017f35677742055e7ab2f83c261ada3bf8d6f3da946d15f23d2c3b0dd0b9078d49aad17eddc6389a9d67c1f9b9e84d1d2e4e82023ff13d44b2622f
7
+ data.tar.gz: 816a6455d2f2cef47db441aa41af1d85bca24a8f6f3666a88fe5275930fdb8ad0567f3cfe5b3a602402bc1c406d9a1cbb64ec49c2c9da49c5fc3e80af76bbb9a
data/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial release
6
+ - `Axn::MCP::Tool` base class for building MCP tools with Axn
7
+ - Auto-generated JSON schemas from `expects`/`exposes` declarations
8
+ - Automatic `model:` field handling (exposes `_id` field to LLM)
9
+ - Auto-serialization of exposed values to JSON-safe structures
10
+ - Annotation shortcuts: `read_only!`, `destructive!`, `idempotent!`, `open_world!`, `closed_world!`
11
+ - Factory-style `Tool.define` for quick one-off tools
12
+ - Dual-use: returns `Axn::Result` for direct calls, `MCP::Tool::Response` when called via MCP server
13
+ - Typed array element schemas: `Array` fields with `of:` emit a machine-readable `items:` entry in JSON Schema rather than a bare `array` type — scalar types, `:boolean`/`:uuid` shorthands, `Data.define` structs (bare member names), and union types (`anyOf`) are all supported
14
+ - Structured field contracts via `shape:` block: annotate individual element/member types and validations inline; `required` is derived automatically from optional vs non-optional members; blocks recurse for nested objects
15
+ - When `of: <Data.define>` and a `shape:` block are combined, Data members provide the bare-name baseline and block-declared members overlay typed properties (enrich)
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Teamshares, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,441 @@
1
+ # Axn::MCP
2
+
3
+ Build [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) tools using [Axn](https://github.com/teamshares/axn)'s declarative `expects`/`exposes` contract. This gem wraps the official [MCP Ruby SDK](https://github.com/modelcontextprotocol/ruby-sdk) and auto-generates JSON schemas from your Axn field declarations.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "axn-mcp"
11
+ ```
12
+
13
+ Then run:
14
+
15
+ ```bash
16
+ bundle install
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ Define an MCP tool by inheriting from `Axn::MCP::Tool`:
22
+
23
+ ```ruby
24
+ class GreetUser < Axn::MCP::Tool
25
+ description "Greet a user by name"
26
+
27
+ expects :name, type: String, description: "The user's name"
28
+ exposes :greeting, type: String, description: "The greeting message"
29
+
30
+ def call
31
+ expose greeting: "Hello, #{name}!"
32
+ end
33
+ end
34
+ ```
35
+
36
+ That's it. The gem automatically:
37
+
38
+ - Generates `inputSchema` from your `expects` declarations
39
+ - Generates `outputSchema` from your `exposes` declarations
40
+ - Converts `Axn::Result` to `MCP::Tool::Response`
41
+ - Serializes exposed data to JSON-safe `structured_content`
42
+
43
+ ## Usage
44
+
45
+ ### Basic Tool Definition
46
+
47
+ ```ruby
48
+ class CreateNote < Axn::MCP::Tool
49
+ description "Create a new note"
50
+
51
+ expects :title, type: String, description: "Note title"
52
+ expects :content, type: String, description: "Note body"
53
+ expects :tags, type: Array, optional: true, description: "Optional tags"
54
+
55
+ exposes :note_id, type: Integer, description: "ID of the created note"
56
+
57
+ def call
58
+ note = Note.create!(title:, content:, tags: tags || [])
59
+ expose note_id: note.id
60
+ end
61
+ end
62
+ ```
63
+
64
+ ### Field Descriptions
65
+
66
+ Use `description:` directly as a kwarg on `expects` and `exposes`:
67
+
68
+ ```ruby
69
+ expects :start_date, type: Date, optional: true, description: "Inclusive lower bound (YYYY-MM-DD)"
70
+ exposes :results, type: Array, description: "Matching records"
71
+ ```
72
+
73
+ > **Note:** Do *not* wrap it in `metadata: { description: ... }`. The `metadata:` key is not recognized by `expects`/`exposes` and raises `ArgumentError` at class load time.
74
+
75
+ ### Type Mappings
76
+
77
+ Axn types map to JSON Schema types:
78
+
79
+
80
+ | Ruby Type | JSON Schema |
81
+ | ------------------ | ------------------------------ |
82
+ | `String` | `string` |
83
+ | `Integer` | `integer` |
84
+ | `Float`, `Numeric` | `number` |
85
+ | `Hash` | `object` |
86
+ | `Array` | `array` |
87
+ | `:boolean` | `boolean` |
88
+ | `:uuid` | `string` (format: `uuid`) |
89
+ | `Date` | `string` (format: `date`) |
90
+ | `DateTime`, `Time` | `string` (format: `date-time`) |
91
+
92
+
93
+ ### Typed member contracts with `shape:`
94
+
95
+ Add a `shape:` block to a `Hash` or `Data.define` field to declare types and validations for its members. `required` is derived automatically; unannotated members on a `Data.define` type appear as bare `{}`. The block syntax is the same on both `expects` and `exposes`. (For `Array` fields, combine `shape:` with `of:` — see the next section.)
96
+
97
+ **Hash field:**
98
+
99
+ ```ruby
100
+ exposes :config, type: Hash do
101
+ field :region, type: String
102
+ field :timeout, type: Integer, optional: true
103
+ end
104
+ ```
105
+
106
+ ```json
107
+ {
108
+ "type": "object",
109
+ "required": ["region"],
110
+ "properties": {
111
+ "region": { "type": "string" },
112
+ "timeout": { "type": "integer" }
113
+ }
114
+ }
115
+ ```
116
+
117
+ **`Data.define` struct:**
118
+
119
+ ```ruby
120
+ IntegrationRecord = Data.define(:source, :provider_name, :active, :status)
121
+
122
+ exposes :integration, type: IntegrationRecord do
123
+ field :status, type: String, inclusion: { in: %w[connected error needs_reconnect] }
124
+ field :active, type: :boolean, optional: true
125
+ end
126
+ ```
127
+
128
+ ```json
129
+ {
130
+ "type": "object",
131
+ "required": ["status"],
132
+ "properties": {
133
+ "status": { "type": "string", "enum": ["connected", "error", "needs_reconnect"] },
134
+ "active": { "type": "boolean" },
135
+ "source": {},
136
+ "provider_name": {}
137
+ }
138
+ }
139
+ ```
140
+
141
+ Blocks recurse naturally for nested objects:
142
+
143
+ ```ruby
144
+ exposes :config, type: Hash do
145
+ field :region, type: String
146
+ field :retention, type: Hash do
147
+ field :days, type: Integer
148
+ end
149
+ end
150
+ ```
151
+
152
+ ### Typed array elements with `of:`
153
+
154
+ When an `Array` field carries an `of:` declaration, the generated JSON Schema includes a machine-readable `items:` entry rather than a bare `array` type.
155
+
156
+ **Scalar element type:**
157
+
158
+ ```ruby
159
+ exposes :tags, type: Array, of: String
160
+ ```
161
+
162
+ ```json
163
+ { "type": "array", "items": { "type": "string" } }
164
+ ```
165
+
166
+ Other supported forms: `of: Integer`, `of: :boolean`, `of: :uuid`, and union types:
167
+
168
+ ```ruby
169
+ exposes :values, type: Array, of: [String, Numeric]
170
+ ```
171
+
172
+ ```json
173
+ { "type": "array", "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } }
174
+ ```
175
+
176
+ **`Data.define` struct — bare member names as baseline:**
177
+
178
+ ```ruby
179
+ exposes :integrations, type: Array, of: IntegrationRecord
180
+ ```
181
+
182
+ ```json
183
+ {
184
+ "type": "array",
185
+ "items": {
186
+ "type": "object",
187
+ "properties": { "source": {}, "provider_name": {}, "active": {}, "status": {} }
188
+ }
189
+ }
190
+ ```
191
+
192
+ **Combine `of:` with a `shape:` block to annotate element members:**
193
+
194
+ ```ruby
195
+ exposes :integrations, type: Array, of: IntegrationRecord do
196
+ field :status, type: String, inclusion: { in: %w[connected error needs_reconnect] }
197
+ field :active, type: :boolean, optional: true
198
+ end
199
+ ```
200
+
201
+ ```json
202
+ {
203
+ "type": "array",
204
+ "items": {
205
+ "type": "object",
206
+ "required": ["status"],
207
+ "properties": {
208
+ "status": { "type": "string", "enum": ["connected", "error", "needs_reconnect"] },
209
+ "active": { "type": "boolean" },
210
+ "source": {},
211
+ "provider_name": {}
212
+ }
213
+ }
214
+ }
215
+ ```
216
+
217
+ Annotated members are fully typed; unannotated `Data.define` members (`source`, `provider_name`) remain as bare `{}`.
218
+
219
+ ### ActiveRecord Model Fields
220
+
221
+ When using `model: true`, the schema automatically generates an `_id` field with an appropriate description:
222
+
223
+ ```ruby
224
+ class UpdateUser < Axn::MCP::Tool
225
+ description "Update a user's profile"
226
+
227
+ expects :user, model: true
228
+ expects :name, type: String, optional: true
229
+
230
+ def call
231
+ user.update!(name:) if name
232
+ end
233
+ end
234
+ ```
235
+
236
+ Generates schema:
237
+
238
+ ```json
239
+ {
240
+ "properties": {
241
+ "user_id": {
242
+ "type": "integer",
243
+ "description": "ID of the User record"
244
+ }
245
+ }
246
+ }
247
+ ```
248
+
249
+ ### Enums via Inclusion
250
+
251
+ ```ruby
252
+ expects :status, inclusion: { in: %w[active inactive pending] }
253
+ ```
254
+
255
+ Generates:
256
+
257
+ ```json
258
+ {
259
+ "status": {
260
+ "type": "string",
261
+ "enum": ["active", "inactive", "pending"]
262
+ }
263
+ }
264
+ ```
265
+
266
+ ### Annotations
267
+
268
+ Use convenience methods or the `annotations` DSL:
269
+
270
+ ```ruby
271
+ class ReadOnlyTool < Axn::MCP::Tool
272
+ description "Fetch data without side effects"
273
+ read_only!
274
+
275
+ # ...
276
+ end
277
+
278
+ class DangerousTool < Axn::MCP::Tool
279
+ description "Delete all the things"
280
+ destructive!
281
+ idempotent!
282
+
283
+ # ...
284
+ end
285
+
286
+ class CustomAnnotations < Axn::MCP::Tool
287
+ annotations(
288
+ read_only_hint: true,
289
+ idempotent_hint: true,
290
+ title: "My Custom Tool",
291
+ )
292
+
293
+ # ...
294
+ end
295
+ ```
296
+
297
+ Available shortcuts:
298
+
299
+
300
+ | Method | Effect |
301
+ | --------------- | ------------------------------------------------- |
302
+ | `read_only!` | `read_only_hint: true`, `destructive_hint: false` |
303
+ | `destructive!` | `destructive_hint: true`, `read_only_hint: false` |
304
+ | `idempotent!` | `idempotent_hint: true` |
305
+ | `open_world!` | `open_world_hint: true` |
306
+ | `closed_world!` | `open_world_hint: false` |
307
+
308
+
309
+ ### Factory-Style Definition
310
+
311
+ For quick one-off tools:
312
+
313
+ ```ruby
314
+ SearchTool = Axn::MCP::Tool.define(
315
+ description: "Search for items",
316
+ expects: { query: { type: String, description: "Search query" } },
317
+ exposes: { results: { type: Array } },
318
+ annotations: { read_only_hint: true },
319
+ ) do
320
+ expose results: Item.search(query)
321
+ end
322
+ ```
323
+
324
+ ### Server Context
325
+
326
+ `server_context` is automatically available in all tools (no declaration needed):
327
+
328
+ ```ruby
329
+ class AuthenticatedTool < Axn::MCP::Tool
330
+ description "Do something with the current user"
331
+
332
+ def call
333
+ current_user = server_context&.dig(:user)
334
+ # ...
335
+ end
336
+ end
337
+ ```
338
+
339
+ Note the safe navigation (`&.dig`): `server_context` may be `nil` if the tool is invoked directly as a standard Axn action rather than through the MCP server.
340
+
341
+ The `server_context` field is excluded from the generated `inputSchema` since it's injected by the MCP server, not provided by the LLM.
342
+
343
+ ### Dual-Use: MCP Server vs Direct Invocation
344
+
345
+ Tools automatically adapt their return type based on how they're called:
346
+
347
+ ```ruby
348
+ # Called FROM MCP server (server_context injected) → returns MCP::Tool::Response
349
+ # This happens automatically when registered with MCP::Server
350
+
351
+ # Called DIRECTLY without server_context → returns Axn::Result
352
+ result = MyTool.call(name: "Alice")
353
+ if result.ok?
354
+ puts result.greeting
355
+ else
356
+ puts "Error: #{result.message}"
357
+ end
358
+
359
+ # Or use call! to raise on failure
360
+ result = MyTool.call!(name: "Bob")
361
+ puts result.greeting
362
+ ```
363
+
364
+ The branching is based on presence of `server_context`:
365
+
366
+ - **With `server_context`**: Returns `MCP::Tool::Response` (for MCP server compatibility)
367
+ - **Without `server_context`**: Returns `Axn::Result` (standard Axn semantics)
368
+
369
+ This allows you to test tools or call them from non-MCP contexts using standard Axn patterns.
370
+
371
+ ## Error Handling
372
+
373
+ Use Axn's standard `fail!` method for controlled failures:
374
+
375
+ ```ruby
376
+ def call
377
+ fail! "User not found" unless user
378
+ fail! "Unauthorized" unless authorized?
379
+
380
+ # success path...
381
+ end
382
+ ```
383
+
384
+ Unhandled exceptions are also caught automatically. When an exception occurs:
385
+
386
+ 1. The error is recorded on the result
387
+ 2. Any configured `on_exception` handlers are triggered (see [Axn configuration](https://github.com/teamshares/axn))
388
+ 3. An `MCP::Tool::Response` is returned with `error: true`
389
+
390
+ Both `fail!` calls and unhandled exceptions result in error responses to the LLM.
391
+
392
+ ## Integration with MCP Server
393
+
394
+ Register your tools with an MCP server:
395
+
396
+ ```ruby
397
+ require "mcp"
398
+ require "axn-mcp"
399
+
400
+ server = MCP::Server.new(
401
+ name: "my-server",
402
+ version: "1.0.0",
403
+ tools: [GreetUser, CreateNote, SearchTool],
404
+ )
405
+
406
+ # Use with stdio transport
407
+ transport = MCP::Server::Transports::StdioTransport.new(server)
408
+ transport.open
409
+ ```
410
+
411
+ For complete server setup, transport options, and advanced configuration, see the [MCP Ruby SDK documentation](https://github.com/modelcontextprotocol/ruby-sdk).
412
+
413
+ ### Success response text: config and per-tool
414
+
415
+ By default, successful responses contain a text block with the JSON-serialized `structured_content` (a SHOULD per [MCP spec](https://modelcontextprotocol.io/specification/draft/server/tools#structured-content)). To use the Axn success message instead, set **central config** once (`Axn::MCP.config.mcp_text_content = :message`) or override **per tool** with `mcp_text_content :message`. Valid values are `:structured` (default) and `:message`; per-tool overrides config.
416
+
417
+ ## Requirements
418
+
419
+ - Ruby >= 3.2.1
420
+ - [axn](https://github.com/teamshares/axn) >= 0.1.0-alpha.4.3
421
+ - [mcp](https://github.com/modelcontextprotocol/ruby-sdk) >= 0.4
422
+
423
+ ## Development
424
+
425
+ ```bash
426
+ bundle install
427
+ bundle exec rspec
428
+ bundle exec rubocop
429
+ ```
430
+
431
+ ## License
432
+
433
+ MIT License. See [LICENSE](LICENSE) for details.
434
+
435
+ ## Contributing
436
+
437
+ Bug reports and pull requests are welcome on GitHub at [https://github.com/teamshares/axn-mcp](https://github.com/teamshares/axn-mcp).
438
+
439
+ ## Acknowledgments
440
+
441
+ This gem wraps the excellent [MCP Ruby SDK](https://github.com/modelcontextprotocol/ruby-sdk) from the Model Context Protocol team.
data/Rakefile ADDED
@@ -0,0 +1,13 @@
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])
@@ -0,0 +1,27 @@
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
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,224 @@
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
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "active_support/core_ext/object/blank"
5
+
6
+ module Axn
7
+ module MCP
8
+ module Serializer
9
+ module_function
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
36
+
37
+ def result_to_mcp_response(result, field_configs, text_content: :structured)
38
+ if result.ok?
39
+ exposed = serialize_exposed(result, field_configs)
40
+ success_text = success_response_text(result, exposed, text_content)
41
+ ::MCP::Tool::Response.new(
42
+ [{ type: "text", text: success_text }],
43
+ structured_content: exposed.presence,
44
+ )
45
+ else
46
+ ::MCP::Tool::Response.new(
47
+ [{ type: "text", text: result.error }],
48
+ error: true,
49
+ )
50
+ end
51
+ end
52
+
53
+ def success_response_text(result, exposed, text_content)
54
+ use_message = text_content == :message
55
+ success_message = result.respond_to?(:success) ? result.success : result.message
56
+ if use_message || exposed.blank?
57
+ success_message
58
+ else
59
+ JSON.generate(exposed)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
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),
56
+ )
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
+
84
+ def call!(**)
85
+ result = call(**)
86
+
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
145
+
146
+ tool_class
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module MCP
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/axn/mcp.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "axn"
5
+ require "mcp"
6
+
7
+ 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"
13
+
14
+ module Axn
15
+ module MCP
16
+ class SchemaError < StandardError; end
17
+
18
+ def self.config
19
+ @config ||= Config.new
20
+ end
21
+ end
22
+ end
data/lib/axn-mcp.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "axn/mcp"
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: axn-mcp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kali Donovan
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: axn
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 0.1.0.pre.alpha.4.3
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: 0.2.0
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: 0.1.0.pre.alpha.4.3
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: 0.2.0
32
+ - !ruby/object:Gem::Dependency
33
+ name: mcp
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '0.4'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '1.0'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0.4'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '1.0'
52
+ description: Build MCP tools using Axn's expects/exposes contract with auto-generated
53
+ JSON schemas and responses.
54
+ email:
55
+ - kali@teamshares.com
56
+ executables: []
57
+ extensions: []
58
+ extra_rdoc_files: []
59
+ files:
60
+ - CHANGELOG.md
61
+ - LICENSE
62
+ - README.md
63
+ - Rakefile
64
+ - lib/axn-mcp.rb
65
+ - lib/axn/mcp.rb
66
+ - lib/axn/mcp/config.rb
67
+ - lib/axn/mcp/field_declarations.rb
68
+ - lib/axn/mcp/schema_builder.rb
69
+ - lib/axn/mcp/serializer.rb
70
+ - lib/axn/mcp/tool.rb
71
+ - lib/axn/mcp/version.rb
72
+ homepage: https://github.com/teamshares/axn-mcp
73
+ licenses:
74
+ - MIT
75
+ metadata:
76
+ homepage_uri: https://github.com/teamshares/axn-mcp
77
+ source_code_uri: https://github.com/teamshares/axn-mcp
78
+ changelog_uri: https://github.com/teamshares/axn-mcp/blob/main/CHANGELOG.md
79
+ rubygems_mfa_required: 'true'
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: 3.2.1
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 3.6.8
95
+ specification_version: 4
96
+ summary: MCP Tool wrapper for Axn actions
97
+ test_files: []