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/README.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
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
4
 
5
+ **Author once, expose anywhere.** You write a plain Axn — a normal action, usable by any caller — and this gem exposes it as an `::MCP::Tool` with `Axn::MCP.wrap` (one tool) or `Axn::MCP.tools` (every registered tool at once). The Axn stays a plain Axn: called directly it returns an `Axn::Result`, with no MCP awareness. The same action can be exposed to other adapters (e.g. an `axn-ruby_llm`) the same way, from the same class.
6
+
7
+ This gem is scoped to MCP **Tools** only. `MCP::Server` also supports `resources`, `resource_templates`, and `prompts` as first-class concepts — `Axn::MCP.wrap` doesn't adapt an Axn into any of those, and there's no `Axn::MCP.wrap_as_resource` or equivalent. If you need those, register them with `MCP::Server` directly per the [MCP Ruby SDK documentation](https://github.com/modelcontextprotocol/ruby-sdk).
8
+
5
9
  ## Installation
6
10
 
7
11
  Add to your Gemfile:
@@ -18,10 +22,12 @@ bundle install
18
22
 
19
23
  ## Quick Start
20
24
 
21
- Define an MCP tool by inheriting from `Axn::MCP::Tool`:
25
+ Write a plain Axn, then expose it with `Axn::MCP.wrap`:
22
26
 
23
27
  ```ruby
24
- class GreetUser < Axn::MCP::Tool
28
+ class GreetUser
29
+ include Axn
30
+
25
31
  description "Greet a user by name"
26
32
 
27
33
  expects :name, type: String, description: "The user's name"
@@ -31,36 +37,253 @@ class GreetUser < Axn::MCP::Tool
31
37
  expose greeting: "Hello, #{name}!"
32
38
  end
33
39
  end
40
+
41
+ GreetUserTool = Axn::MCP.wrap(GreetUser) # => an ::MCP::Tool subclass, ready to register
34
42
  ```
35
43
 
36
- That's it. The gem automatically:
44
+ `Axn::MCP.wrap` returns a genuine `::MCP::Tool` subclass. The gem automatically:
37
45
 
38
46
  - Generates `inputSchema` from your `expects` declarations
39
47
  - Generates `outputSchema` from your `exposes` declarations
40
48
  - Converts `Axn::Result` to `MCP::Tool::Response`
41
49
  - Serializes exposed data to JSON-safe `structured_content`
42
50
 
43
- ## Usage
51
+ `GreetUser` itself is untouched — `GreetUser.call(name: "Alice")` still returns a plain `Axn::Result`.
52
+
53
+ `inputSchema`/`outputSchema` are generated by `axn` core from your `expects`/`exposes` declarations,
54
+ and exposed values are serialized through its `Axn::Extensions::Serialization` facade. See
55
+ [Field declarations & schema](#field-declarations--schema) for the type mappings and how the schema
56
+ handles values whose wire form isn't knowable from the declaration.
57
+
58
+ ## Exposing tools
59
+
60
+ An Axn is just an action; the gem turns it into an `::MCP::Tool` at the edge. There are three ways
61
+ in, all producing the same kind of `::MCP::Tool` subclass.
62
+
63
+ ### One tool: `Axn::MCP.wrap`
44
64
 
45
- ### Basic Tool Definition
65
+ ```ruby
66
+ GreetUserTool = Axn::MCP.wrap(GreetUser)
67
+ ```
68
+
69
+ `wrap(axn_class, description: nil, name: nil, title: nil, icons: nil, meta: nil, annotations: nil, present_as: nil)`:
70
+
71
+ - **`description:`** defaults to the Axn's own `.description`. Pass it to override.
72
+ - **`name:`** defaults to `axn_class.tool_name` — axn core's canonical, provider-safe name, which
73
+ honors a `tool name: "..."` override on the Axn and any configured `tool_name_stripped_prefixes`
74
+ (e.g. `GreetUser` → `"greet_user"`). Pass `name:` to override. This matters if you register the
75
+ tool inline (`tools: [Axn::MCP.wrap(GreetUser)]`) rather than assigning it to a constant. If the
76
+ wrapped Axn is *truly* anonymous (no class name, no `axn_name`), `wrap` raises `ArgumentError`
77
+ rather than ship an unusable, unnamed tool — pass `name:` in that case.
78
+ - **`annotations:`** / **`present_as:`** / **`title:`** / **`icons:`** / **`meta:`** — all
79
+ optional; see the sections below. Omitted values fall through to the Axn's own declarations
80
+ (`semantic_hints`, `configure(:mcp)`) or `::MCP::Tool`'s defaults.
81
+
82
+ The original class is never modified — the transport concerns (schema, `server_context` routing,
83
+ response mapping) live entirely on the generated subclass:
46
84
 
47
85
  ```ruby
48
- class CreateNote < Axn::MCP::Tool
49
- description "Create a new note"
86
+ GreetUserTool.input_schema_value.to_h[:properties].keys # => [:name]
87
+ GreetUserTool.call(name: "Bob", server_context: { user_id: 42 }) # => MCP::Tool::Response
88
+ GreetUser.call(name: "Alice") # => Axn::Result (untouched)
89
+ ```
50
90
 
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"
91
+ The generated subclass's `.call` **always** returns `MCP::Tool::Response`, and it has no `.call!` —
92
+ if you want raise-on-failure semantics, call the original Axn's `.call!` directly (unwrapped).
54
93
 
55
- exposes :note_id, type: Integer, description: "ID of the created note"
94
+ #### Never-raises contract
56
95
 
57
- def call
58
- note = Note.create!(title:, content:, tags: tags || [])
59
- expose note_id: note.id
60
- end
96
+ `.call` extends axn's non-bang "never raises" contract to the transport boundary. A tool's own
97
+ failures come back as an error `MCP::Tool::Response` (axn already catches exceptions *inside* the
98
+ action into a failed `Axn::Result` — see [Error Handling](#error-handling)). And if the transport
99
+ layer *around* the action raises — exposed-value serialization hitting a value with no honest JSON
100
+ form, a structure past JSON's `max_nesting`, or a bug — the wrapper catches that too: it reports the
101
+ exception through axn's global `on_exception` hook (so it's never silent) and returns a generic
102
+ error response, rather than letting an exception escape `.call` on a direct or custom transport.
103
+ The one exception is axn's dev mode (`Axn.config.best_effort_raises_in_dev` in `development`): there
104
+ it re-raises instead, so a real bug surfaces loudly rather than being masked.
105
+
106
+ ### Every registered tool: `Axn::MCP.tools`
107
+
108
+ Mark an Axn as an MCP tool with `tool :mcp` (axn core's tool-registry DSL), and `Axn::MCP.tools`
109
+ returns them all, already wrapped — no hand-maintained array:
110
+
111
+ ```ruby
112
+ class ListCompanies
113
+ include Axn
114
+ tool :mcp
115
+ description "List companies"
116
+ # ...
61
117
  end
118
+
119
+ MCP::Server.new(name: "my-server", version: "1.0.0", tools: Axn::MCP.tools)
62
120
  ```
63
121
 
122
+ `Axn::MCP.tools` is `Axn::Tools.for(:mcp).map { |axn| Axn::MCP.wrap(axn) }` — zero-arg by design.
123
+ Per-tool customization comes from each class's own declarations (`tool name:`, `description`,
124
+ `semantic_hints`, `configure(:mcp)`), all honored inside `wrap`. It's symmetric with the same
125
+ pattern in sibling adapter gems (e.g. `Axn::RubyLLM.tools`).
126
+
127
+ **Versioning.** When two Axns share a `tool_name` but declare different `tool_version`s (axn core's
128
+ DSL — identity is `(tool_name, tool_version)`), `Axn::Tools.for(:mcp)` returns only the latest, so
129
+ `Axn::MCP.tools` exposes a single tool for that name, resolving to the highest version. For a
130
+ versioned tool (`tool_version > 1`), `wrap` also surfaces the resolved revision as `tool_version` in
131
+ the tool's `_meta` — visible to an operator/model without touching the tool's `name` (its
132
+ cross-adapter identity). Unversioned tools (the default `tool_version` of 1) get no such `_meta`.
133
+
134
+ ### Membership: directory roots + the `tool` DSL
135
+
136
+ A class's `:mcp` membership is **`(directory-root grant ∪ tool declaration) − except`**:
137
+
138
+ - **Directory roots** — every Axn whose file lives under one of this adapter's `tool_roots` is granted, no `tool` declaration needed. `Axn::MCP` ships a default root of **`agent_tools`** (i.e. `app/agent_tools/` in a Rails app), a dir shared with `axn-ruby_llm` — so a tool dropped there is exposed over both surfaces at once. Configure the roots with `Axn::MCP.config.tool_roots = ["agent_tools", "actions/mcp_tools"]` (relative to `Rails.root/app`, or absolute). Roots are validated: a broad entry (`app`, `.`, `actions`, a `..` traversal) is rejected, so you can't bulk-expose every business action.
139
+ - **`tool :mcp`** — explicitly **adds** `:mcp` (on top of any directory grant), for a tool that lives *outside* the roots. Bare **`tool`** grants every registered adapter.
140
+ - **`configure(:mcp) { … }`** — declaring MCP config implies `:mcp` membership.
141
+ - **`tool except: :mcp`** — narrows: keeps the directory grant but removes `:mcp`. **`tool false`** opts out of every adapter.
142
+
143
+ > **Union, not replacement:** `tool :mcp` **adds** to the directory grant rather than replacing it — a tool under a root *and* declaring `tool :ruby_llm` belongs to both. Declare all adapters/`except:`/per-adapter options in a single `tool` call (a second `tool` on the same class raises).
144
+
145
+ ```ruby
146
+ # In app/agent_tools/ — no `tool` needed; granted to every adapter whose roots include agent_tools:
147
+ class ListCompanies
148
+ include Axn
149
+ description "List companies"
150
+ # ...
151
+ end
152
+
153
+ # Anywhere else — opt in explicitly:
154
+ class Ping
155
+ include Axn
156
+ tool :mcp
157
+ description "Ping"
158
+ # ...
159
+ end
160
+
161
+ MCP::Server.new(name: "my-server", version: "1.0.0", tools: Axn::MCP.tools)
162
+ ```
163
+
164
+ **The class must be loaded for `Axn::MCP.tools` to see it** — the registry only enumerates
165
+ currently-defined classes. `tool_roots` directories are eager-loaded on demand (and by Rails
166
+ eager-loading); a `tool :mcp` class that lives *outside* a root and isn't otherwise required won't
167
+ appear until its file is loaded. Enumerate from `config.after_initialize` / a `to_prepare` block
168
+ (not a `config/initializers` file) for reliable results under Rails.
169
+
170
+ For a curated subset instead of all of them, filter the registry yourself:
171
+ `Axn::Tools.for(:mcp).select { ... }.map { |a| Axn::MCP.wrap(a) }`.
172
+
173
+ ### One-off inline tools
174
+
175
+ For a throwaway tool, build a plain Axn inline with `Axn::Factory.build` (block-as-`#call`, no named
176
+ class needed) and wrap it:
177
+
178
+ ```ruby
179
+ # The block is the Axn's #call body — pass it to Axn::Factory.build.
180
+ search = Axn::Factory.build(
181
+ expects: { query: { type: String, description: "Search query" } },
182
+ exposes: { results: { type: Array } },
183
+ ) do
184
+ expose results: Item.search(query)
185
+ end
186
+
187
+ SearchTool = Axn::MCP.wrap(search, name: "search", description: "Search for items", annotations: { read_only_hint: true })
188
+ ```
189
+
190
+ `Axn::Factory.build` carries the action's behavior — the `#call` block plus
191
+ `expects`/`exposes`/`success`/`error`/hooks/… — while the MCP-facing bits
192
+ (`name:`/`description:`/`annotations:`/`present_as:`) go to `wrap`. For a multi-adapter one-off,
193
+ build the Axn once and hand it to each adapter's `wrap`.
194
+
195
+ ### Mixing with native `::MCP::Tool` tools
196
+
197
+ Because `wrap` returns a real `::MCP::Tool` subclass, wrapped Axns and hand-written MCP tools
198
+ compose in one array — splat `Axn::MCP.tools` alongside anything else:
199
+
200
+ ```ruby
201
+ MCP::Server.new(
202
+ name: "my-server", version: "1.0.0",
203
+ server_context: { user_id: current_user.id },
204
+ tools: [
205
+ *Axn::MCP.tools, # every registered :mcp Axn, wrapped
206
+ NativeSearchTool, # a plain MCP::Tool subclass
207
+ MCP::Tool.define(name: "ping", description: "…") { |server_context:, **_args| MCP::Tool::Response.new([...]) },
208
+ ],
209
+ )
210
+ ```
211
+
212
+ `server_context` flows identically to both: native tools read it in `call(args, server_context:)`;
213
+ wrapped Axns get it spread into `ambient_context` (see [Server Context](#server-context)).
214
+
215
+ ## Tool call contract
216
+
217
+ A wrapped Axn's `.call` doesn't dispatch to the plain `axn_class.call` a direct in-process caller
218
+ gets — it runs through axn core's [`Axn::Tools::Invoker`](https://teamshares.github.io/axn/reference/tool-invoker),
219
+ the sanctioned path for treating model-supplied arguments as untrusted wire data. This applies to
220
+ every wrapped tool call, with no opt-out:
221
+
222
+ - **Input types are always coerced.** An `expects` field with a coercible `type:` (`Date`,
223
+ `DateTime`, `Time`, `Symbol`, `Integer`, `Float`, `:boolean`) accepts a wire-typed String and
224
+ converts it before validation runs — `expects :limit, type: Integer` already accepts `"25"`, with
225
+ no separate `coerce:` needed. `coerce:`/`type: { coerce: true }` still exist (see below), and a
226
+ field can explicitly opt **out** with `type: { klass: Integer, coerce: false }` if you need this
227
+ particular field to reject a wire string rather than convert it.
228
+ - **A bad or missing argument surfaces as a specific, correctable client error** — not axn's generic
229
+ `"Something went wrong"` — and is **not** reported through `on_exception` (a model sending a
230
+ malformed argument isn't a bug in your code):
231
+
232
+ ```ruby
233
+ expects :limit, type: Integer
234
+ # missing entirely -> result.error == "Limit is not a Integer"
235
+ # limit: "abc" -> result.error == "Limit could not be coerced to a Integer"
236
+ ```
237
+
238
+ - **An argument the tool never declared with `expects` is rejected**, rather than silently dropped:
239
+ `extra: "value"` on a tool with no such field -> `result.error == "unknown input: extra"`.
240
+
241
+ None of this touches `exposes`/output serialization, `ambient_context` resolution (still
242
+ adapter-injected and guarded against a model-supplied override — see
243
+ [Server Context](#server-context)), or a direct, unwrapped `TheAxn.call(...)` — a direct call keeps
244
+ whatever `coerce_input_types`/undeclared-key behavior it already had. `wrap`'s never-raises contract
245
+ (above) still governs the transport step *after* this — `result.error` still flows through
246
+ `Serializer`/`guard_tool_response` exactly as any other failure does.
247
+
248
+ Every wrapped call is also stamped with the `invoked_via: "mcp"` dimension for its whole call tree
249
+ (visible in axn's own call-completion log line, and to any tracing/dashboard code keyed off it), so
250
+ MCP-driven traffic is distinguishable from a direct `.call` with no extra work on your part.
251
+
252
+ ## Field declarations & schema
253
+
254
+ The schema mappings below are axn core reflection surfaced through `wrap` — declare fields on a
255
+ plain Axn (`include Axn`), and the shapes appear on `Axn::MCP.wrap(TheAxn).input_schema_value` /
256
+ `.output_schema`.
257
+
258
+ ### How reflection derives schemas
259
+
260
+ **Reflection is best-effort and deliberately biased stricter-than-runtime.** Schemas are built
261
+ *statically* from your `expects`/`exposes` declarations — reflection is side-effect-free and never
262
+ runs your validators. Where a value's wire form isn't *provable* from the declared type, the schema
263
+ reflects the conservative answer (untyped, required, or non-null) rather than guess. The net
264
+ contract: **a client that follows the schema will not be rejected by schema validation; the schema
265
+ may occasionally be more restrictive than what the tool would actually accept at runtime.**
266
+ Concretely, you may see: `type` omitted entirely (an untyped `{}`) when the wire form isn't knowable
267
+ (e.g. a `Numeric`/`Complex` field, or a reader-only/custom-serialized object); `not: { type: "null"
268
+ }` on a required `model:`-generated `_id` (a primary key has no fixed JSON type); `enum:
269
+ [true]`/`[false]` for a `TrueClass`/`FalseClass` field; and `anyOf` for union types.
270
+
271
+ This isn't an absolute "schema-valid implies success" guarantee, though: a schema-following call can
272
+ still fail axn's own runtime validation in the narrow case where a field's contract is
273
+ self-contradictory — e.g. `expects :name, type: String, default: 123` reflects `name` as *optional*
274
+ (a default is present), but omitting it applies the invalid default and then fails runtime
275
+ validation. Reflection derives requiredness from the declared *signals* (a present default,
276
+ `optional:`/`allow_nil:`/`allow_blank:`) without evaluating whether the default itself is valid —
277
+ catching that would only cover literal defaults, not custom validators, callable defaults, or model
278
+ lookups, so the caveat exists either way. This is the one documented spot where the input schema is
279
+ *looser* than runtime rather than stricter (a known, deliberate gap, not a bug).
280
+
281
+ **Required fields also carry a presence constraint.** axn requires non-blank values by default, so a
282
+ required `String` reflects `minLength: 1` and a required `Array` reflects `minItems: 1` (an *optional*
283
+ field is nullable instead — e.g. `["string", "null"]` — and carries no minimum). The per-feature
284
+ examples below omit these for focus, but real `inputSchema`/`outputSchema` output includes them on
285
+ every required string/array field.
286
+
64
287
  ### Field Descriptions
65
288
 
66
289
  Use `description:` directly as a kwarg on `expects` and `exposes`:
@@ -76,7 +299,6 @@ exposes :results, type: Array, description: "Matching records"
76
299
 
77
300
  Axn types map to JSON Schema types:
78
301
 
79
-
80
302
  | Ruby Type | JSON Schema |
81
303
  | ------------------ | ------------------------------ |
82
304
  | `String` | `string` |
@@ -89,6 +311,16 @@ Axn types map to JSON Schema types:
89
311
  | `Date` | `string` (format: `date`) |
90
312
  | `DateTime`, `Time` | `string` (format: `date-time`) |
91
313
 
314
+ ### Coercing loosely-typed inbound values with `coerce:`
315
+
316
+ An LLM (or a client that stringifies its JSON) doesn't always send a value in the exact Ruby type your `expects` field declares — a `Date`/`Integer`/`Float`/`Symbol`/`Time`/`DateTime` field can arrive as a `String`. **Through a wrapped tool call this already happens automatically** — see [Tool call contract](#tool-call-contract) above; `expects :count, type: Integer` alone accepts `"42"`. `coerce:`/`type: { coerce: true }` below matters for a *direct*, unwrapped `TheAxn.call(...)` (which doesn't get the Invoker's coercion), or to force coercion **on** a field even outside a tool call. Add the `coerce: <Type>` shorthand (or `type: { klass: <Type>, coerce: true }` when you also need other type options alongside it — `coerce:` can't be combined with a sibling top-level `type:`) to have `axn` core convert a well-formed string to the declared type *before* validation runs:
317
+
318
+ ```ruby
319
+ expects :starts_on, coerce: Date # "2026-01-15" -> a Date
320
+ expects :count, type: { klass: Integer, coerce: true } # "42" -> 42
321
+ ```
322
+
323
+ Coercion applies to inbound `expects` fields — top-level **and** subfields declared with `on:` (including ambient ones, e.g. a value spread from the MCP server context). It is **not** available on `exposes` (outbound values are serialized, not coerced) or on `shape:` block members (a shape member only constrains its parent's structure and has no reader of its own for a coerced value to resolve onto — coercion is a read-path transform) — both *raise at class-definition* if given `coerce:`. Only a non-blank `String` is converted. An unparseable string doesn't silently fall through to a generic type-mismatch: coercion raises `Axn::InboundValidationError` carrying a specific `"<field> could not be coerced to a <Type>"` message. **Through a wrapped tool call**, that specific message *is* `result.error` (e.g. `"Limit could not be coerced to a Integer"`, combined with the tool's own base `error "…"` if it has one), and it is **not** reported to `on_exception` — see [Tool call contract](#tool-call-contract). On a *direct*, unwrapped call, it stays dev-facing as any inbound violation always has: the detail rides on the exception (logs / `on_exception`), and the caller sees axn's generic `result.error` (`"Something went wrong"` by default, or the tool's own base `error "…"`). `inputSchema`/`outputSchema` output is identical with or without `coerce:` — only accepted inbound values change, not the field's advertised JSON type.
92
324
 
93
325
  ### Typed member contracts with `shape:`
94
326
 
@@ -109,18 +341,20 @@ end
109
341
  "required": ["region"],
110
342
  "properties": {
111
343
  "region": { "type": "string" },
112
- "timeout": { "type": "integer" }
344
+ "timeout": { "type": ["integer", "null"] }
113
345
  }
114
346
  }
115
347
  ```
116
348
 
349
+ Requiredness and nullability are two orthogonal JSON Schema signals, not a redundancy: a member's *requiredness* is tracked solely by the `required` array (`region` is listed, so it's required); an *optional* member is omitted from `required` **and** additionally reflects a nullable `type` array (`["integer", "null"]`) — because an omittable field may resolve to null. So a required member shows up in `required` with a bare type, while an optional one is absent from `required` with a nullable type; that's why the `of:`/`shape:` examples below show `status` (required) in `required` but `active` (optional) as `["boolean", "null"]`.
350
+
117
351
  **`Data.define` struct:**
118
352
 
119
353
  ```ruby
120
354
  IntegrationRecord = Data.define(:source, :provider_name, :active, :status)
121
355
 
122
356
  exposes :integration, type: IntegrationRecord do
123
- field :status, type: String, inclusion: { in: %w[connected error needs_reconnect] }
357
+ field :status, type: String, inclusion: %w[connected error needs_reconnect]
124
358
  field :active, type: :boolean, optional: true
125
359
  end
126
360
  ```
@@ -131,7 +365,7 @@ end
131
365
  "required": ["status"],
132
366
  "properties": {
133
367
  "status": { "type": "string", "enum": ["connected", "error", "needs_reconnect"] },
134
- "active": { "type": "boolean" },
368
+ "active": { "type": ["boolean", "null"] },
135
369
  "source": {},
136
370
  "provider_name": {}
137
371
  }
@@ -170,9 +404,11 @@ exposes :values, type: Array, of: [String, Numeric]
170
404
  ```
171
405
 
172
406
  ```json
173
- { "type": "array", "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } }
407
+ { "type": "array", "items": { "anyOf": [{ "type": "string" }, {}] } }
174
408
  ```
175
409
 
410
+ (The `Numeric` member is left untyped: it admits `Complex`, whose serialized wire form isn't knowable from the declaration alone.)
411
+
176
412
  **`Data.define` struct — bare member names as baseline:**
177
413
 
178
414
  ```ruby
@@ -193,7 +429,7 @@ exposes :integrations, type: Array, of: IntegrationRecord
193
429
 
194
430
  ```ruby
195
431
  exposes :integrations, type: Array, of: IntegrationRecord do
196
- field :status, type: String, inclusion: { in: %w[connected error needs_reconnect] }
432
+ field :status, type: String, inclusion: %w[connected error needs_reconnect]
197
433
  field :active, type: :boolean, optional: true
198
434
  end
199
435
  ```
@@ -206,7 +442,7 @@ end
206
442
  "required": ["status"],
207
443
  "properties": {
208
444
  "status": { "type": "string", "enum": ["connected", "error", "needs_reconnect"] },
209
- "active": { "type": "boolean" },
445
+ "active": { "type": ["boolean", "null"] },
210
446
  "source": {},
211
447
  "provider_name": {}
212
448
  }
@@ -221,7 +457,9 @@ Annotated members are fully typed; unannotated `Data.define` members (`source`,
221
457
  When using `model: true`, the schema automatically generates an `_id` field with an appropriate description:
222
458
 
223
459
  ```ruby
224
- class UpdateUser < Axn::MCP::Tool
460
+ class UpdateUser
461
+ include Axn
462
+
225
463
  description "Update a user's profile"
226
464
 
227
465
  expects :user, model: true
@@ -239,17 +477,22 @@ Generates schema:
239
477
  {
240
478
  "properties": {
241
479
  "user_id": {
242
- "type": "integer",
243
- "description": "ID of the User record"
480
+ "description": "ID of the User record",
481
+ "not": { "type": "null" }
244
482
  }
245
483
  }
246
484
  }
247
485
  ```
248
486
 
487
+ The generated id field's JSON type is intentionally left unconstrained — a model's primary key isn't
488
+ knowable from the declaration (it could be an integer, UUID, string, etc.), and inferring it would
489
+ require a database lookup. A required model field's id still forbids `null` (a null token can never
490
+ resolve to a record).
491
+
249
492
  ### Enums via Inclusion
250
493
 
251
494
  ```ruby
252
- expects :status, inclusion: { in: %w[active inactive pending] }
495
+ expects :status, inclusion: %w[active inactive pending]
253
496
  ```
254
497
 
255
498
  Generates:
@@ -263,110 +506,110 @@ Generates:
263
506
  }
264
507
  ```
265
508
 
266
- ### Annotations
509
+ ## Annotations
267
510
 
268
- Use convenience methods or the `annotations` DSL:
511
+ Declare `axn` core's generic `semantic_hints` DSL (`semantic_hints :read_only, :idempotent, ...`) on your Axn, and `Axn::MCP.wrap` maps them to MCP annotations automatically. This gem registers `:open_world`/`:closed_world` as additional semantic hints (via `Axn::Extensions.config.register_semantic_hint` — no core change needed for MCP-only vocabulary):
512
+
513
+ | Declared `semantic_hints` | Default annotation |
514
+ | -------------------------- | ------------------------ |
515
+ | `:read_only` | `read_only_hint: true`, `destructive_hint: false` |
516
+ | `:idempotent` | `idempotent_hint: true` |
517
+ | `:destructive` | `destructive_hint: true` |
518
+ | `:open_world` | `open_world_hint: true` |
519
+ | `:closed_world` | `open_world_hint: false` |
269
520
 
270
521
  ```ruby
271
- class ReadOnlyTool < Axn::MCP::Tool
522
+ class FetchData
523
+ include Axn
272
524
  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
-
525
+ semantic_hints :read_only, :closed_world
526
+ # Axn::MCP.wrap(FetchData) => annotations include read_only_hint: true, destructive_hint: false, open_world_hint: false
283
527
  # ...
284
528
  end
529
+ ```
285
530
 
286
- class CustomAnnotations < Axn::MCP::Tool
287
- annotations(
288
- read_only_hint: true,
289
- idempotent_hint: true,
290
- title: "My Custom Tool",
291
- )
531
+ For anything `semantic_hints` doesn't cover (a custom annotation `title`, or an annotation with no corresponding hint), set `annotations` directly — either as a `wrap` kwarg or via `configure(:mcp)` (the latter survives `Axn::MCP.tools`). Precedence: `wrap` kwarg → `configure(:mcp)` → the `semantic_hints`-derived default.
292
532
 
293
- # ...
533
+ ```ruby
534
+ # At wrap time:
535
+ Axn::MCP.wrap(MyAxn, annotations: { read_only_hint: true, idempotent_hint: true, title: "My Custom Tool" })
536
+
537
+ # Or declaratively (picked up by Axn::MCP.tools):
538
+ class MyAxn
539
+ include Axn
540
+ tool :mcp
541
+ configure(:mcp) { |c| c.annotations = { read_only_hint: true, idempotent_hint: true, title: "My Custom Tool" } }
542
+ # …
294
543
  end
295
544
  ```
296
545
 
297
- Available shortcuts:
546
+ ## Title, Icons, and Metadata
298
547
 
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:
548
+ `::MCP::Tool` supports `title`/`icons`/`meta` alongside `description`/`annotations`. Set them either as `wrap` kwargs, or declaratively on the Axn via `configure(:mcp)` — the latter survives the zero-arg `Axn::MCP.tools` path (which calls `wrap` with no kwargs), so it's how you attach this metadata to a registry-enumerated tool. An explicit `wrap` kwarg wins over the `configure(:mcp)` value; both are omitted (left at `::MCP::Tool`'s own defaults) unless set.
312
549
 
313
550
  ```ruby
314
- SearchTool = Axn::MCP::Tool.define(
551
+ # At wrap time:
552
+ Axn::MCP.wrap(
553
+ SearchAxn,
315
554
  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)
555
+ title: "Item Search",
556
+ icons: [{ src: "https://example.com/icon.png", mimeType: "image/png" }],
557
+ meta: { version: "1.0" },
558
+ )
559
+
560
+ # Or declaratively (picked up by Axn::MCP.tools):
561
+ class SearchAxn
562
+ include Axn
563
+ tool :mcp
564
+ configure(:mcp) do |c|
565
+ c.title = "Item Search"
566
+ c.icons = [{ src: "https://example.com/icon.png", mimeType: "image/png" }]
567
+ c.meta = { version: "1.0" }
568
+ end
569
+ # …
321
570
  end
322
571
  ```
323
572
 
324
- ### Server Context
573
+ ## Server Context
574
+
575
+ Server-injected **data** and server-side **capabilities** are two different things, reached two different ways.
576
+
577
+ ### Data — declare it on `ambient_context`
325
578
 
326
- `server_context` is automatically available in all tools (no declaration needed):
579
+ A tool declares each value it needs directly on `ambient_context`, and reads it like any other input:
327
580
 
328
581
  ```ruby
329
- class AuthenticatedTool < Axn::MCP::Tool
582
+ class AuthenticatedAction
583
+ include Axn
584
+
330
585
  description "Do something with the current user"
331
586
 
587
+ expects :user_id, on: :ambient_context, type: Object, optional: true
588
+
332
589
  def call
333
- current_user = server_context&.dig(:user)
590
+ current_user = User.find(user_id) if user_id
334
591
  # ...
335
592
  end
336
593
  end
337
594
  ```
338
595
 
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.
596
+ `Axn::MCP.wrap` passes the `server_context:` given to `.call` **as** the Axn's `ambient_context` (spread, not nested under a `server_context` key), and axn extracts each declared field from it via `#[]`/`#dig` — working whether the value is a plain `Hash` (direct/test calls) or an `MCP::ServerContext` object (a real-server round-trip). This is deliberately generic: the *same* Axn resolves `user_id` from the MCP server context here, from `ActiveSupport::CurrentAttributes` on a direct call, and from whatever `Axn::RubyLLM.wrap` provides — no MCP-specific `server_context` intermediate to declare, so the class stays reusable across adapters (the whole point of `ambient_context`).
340
597
 
341
- The `server_context` field is excluded from the generated `inputSchema` since it's injected by the MCP server, not provided by the LLM.
598
+ `on: :ambient_context` fields are excluded from `inputSchema` automatically (not via a hand-rolled list), and the explicit ambient context `wrap` passes **replaces** any process-wide `Current`-derived default for that call — so no server-side state leaks into an MCP invocation, and the field is `nil` when the Axn is called directly with none provided.
342
599
 
343
- ### Dual-Use: MCP Server vs Direct Invocation
600
+ ### Capabilities — `Axn::MCP.server_context`
344
601
 
345
- Tools automatically adapt their return type based on how they're called:
602
+ Over a real `MCP::Server`, the context also offers session-scoped operations that talk back to the client — `report_progress`, `cancelled?`, etc. Those are transport **capabilities**, not data: they live on the `MCP::ServerContext` object itself and don't survive `ambient_context`'s declared-key filtering. Reach the live object with **`Axn::MCP.server_context`** (an MCP-specific handle; `nil` outside a wrapped tool call):
346
603
 
347
604
  ```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}"
605
+ def call
606
+ Axn::MCP.server_context&.report_progress(50, total: 100, message: "Halfway done")
607
+ fail!("cancelled") if Axn::MCP.server_context&.cancelled?
608
+ # ...
357
609
  end
358
-
359
- # Or use call! to raise on failure
360
- result = MyTool.call!(name: "Bob")
361
- puts result.greeting
362
610
  ```
363
611
 
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.
612
+ A tool using this is knowingly MCP-coupled — appropriate, since these operations are MCP-transport-only (there's no equivalent on a direct call, where `Axn::MCP.server_context` returns the raw value passed as `server_context:`, or `nil`). The exact method set is the `mcp` gem's own surface and evolves with it (check `MCP::ServerContext`'s source for your installed version — some methods there are themselves SDK-deprecated); consult it directly rather than treating any list here as authoritative.
370
613
 
371
614
  ## Error Handling
372
615
 
@@ -381,13 +624,71 @@ def call
381
624
  end
382
625
  ```
383
626
 
627
+ The MCP error response carries the Axn's own `result.error` — the gem imposes no headline of its own:
628
+
629
+ | Failure | Text shown to the LLM |
630
+ | ----------------------------------------- | ----------------------- |
631
+ | `fail! "User not found"` | `"User not found"` |
632
+ | An `expects` (inbound argument) violation — missing, wrong-type, undeclared | The specific violation, e.g. `"Limit is not a Integer"`, `"unknown input: extra"` — see [Tool call contract](#tool-call-contract). Not reported to `on_exception`. |
633
+ | Bare `fail!`, an `ambient_context`/`exposes` (outbound) violation, or an unhandled exception | `"Something went wrong"` (axn's generic default). Reported to `on_exception`. |
634
+
635
+ Want a friendlier generic message than `"Something went wrong"`? Declare your own base `error "..."`
636
+ on the Axn (standard axn practice) — it's per-tool, so each tool can say something specific:
637
+
638
+ ```ruby
639
+ class ChargeCard
640
+ include Axn
641
+ error "Could not charge the card"
642
+ # a bare fail! / validation error / exception now surfaces "Could not charge the card"
643
+ end
644
+ ```
645
+
646
+ With a base `error` declared, an explicit `fail!("reason")` **combines** rather than replaces — by default the base prefixes the reason: `fail!("card declined")` → `"Could not charge the card: card declined"`. (A bare `fail!`, validation error, or exception still surfaces the base alone.) To emit a specific reason *without* the prefix, opt out per-call with `fail!("card declined", standalone: true)` → `"card declined"`. So the table above (reason shown verbatim) reflects a tool with **no** base `error`; add one and reasons are prefixed unless `standalone:`.
647
+
384
648
  Unhandled exceptions are also caught automatically. When an exception occurs:
385
649
 
386
650
  1. The error is recorded on the result
387
651
  2. Any configured `on_exception` handlers are triggered (see [Axn configuration](https://github.com/teamshares/axn))
388
652
  3. An `MCP::Tool::Response` is returned with `error: true`
389
653
 
390
- Both `fail!` calls and unhandled exceptions result in error responses to the LLM.
654
+ ## Success response text: config and per-tool
655
+
656
+ 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 **gem-wide config** once (`Axn::MCP.config.present_as = :message`), override **per tool** via `configure(:mcp)`, or pass it to `wrap`. Valid values are `:structured` (default) and `:message`. Precedence (most local wins): `wrap`'s `present_as:` kwarg → the Axn's own `configure(:mcp)` override → the gem-wide config.
657
+
658
+ ```ruby
659
+ # per-tool, on the Axn:
660
+ class MyAction
661
+ include Axn
662
+ configure(:mcp) { |c| c.present_as = :message }
663
+ end
664
+
665
+ # or at wrap time:
666
+ Axn::MCP.wrap(MyAction, present_as: :message)
667
+ ```
668
+
669
+ `configure(:mcp)` uses axn core's namespaced config DSL. The same base Axn can be composed with another adapter (e.g. an `axn-ruby_llm` gem) via its own `config_namespace` — each adapter's settings live in their own namespace, so `configure(:mcp)` and `configure(:other_adapter)` on the same class never collide.
670
+
671
+ ## Rejecting opaque exposed values (`reject_opaque_exposed_values`)
672
+
673
+ When a successful result's `exposes` values are serialized into the response, most values have an obvious JSON form (a `String`, a `Hash`, a `Data.define`, …). But an exposed value can be **opaque** — it has no JSON rendering *its author declared*, so it falls back to a generic one that leaks Ruby internals: a bare object serializes to the string `"#<User:0x000055…>"`, and in a Rails app ActiveSupport's generic `as_json` instead dumps the object's instance variables. Concretely, a value is opaque when its only `to_s` is the one inherited from `Object` and it defines no `to_h`/`to_hash`/custom `as_json` — i.e. it never told the serializer how it wants to look as JSON.
674
+
675
+ `reject_opaque_exposed_values` decides what happens when that occurs:
676
+
677
+ - **`false` (default)** — the opaque rendering ships. For an LLM tool result an ugly-but-honest string is usually better than a failed call, so this is the default.
678
+ - **`true`** — the value is rejected: instead of shipping the blob, the call returns an error response and reports the failure via axn's `on_exception` (with the exact path, e.g. `records[3].owner`, for your logs/error tracker). Use it when a leaked `#<…>` string in a result is worse than a failed call. (See [Never-raises contract](#never-raises-contract) for how the raise becomes an error response.)
679
+
680
+ Set it **gem-wide** (`Axn::MCP.config.reject_opaque_exposed_values = true`) or **per tool** via `configure(:mcp)`; the per-class value wins over the gem-wide one.
681
+
682
+ ```ruby
683
+ class ListOwners
684
+ include Axn
685
+ tool :mcp
686
+ configure(:mcp) { |c| c.reject_opaque_exposed_values = true }
687
+ # ...
688
+ end
689
+ ```
690
+
691
+ **Scope — this is an output-side check only.** It governs `exposes` serialization, *not* inbound argument handling (that's [`coerce:`](#coercing-loosely-typed-inbound-values-with-coerce)). And it is *narrow*: it toggles only the extra "was this rendering author-declared?" test. Values with no **honest** JSON form at all — a reference cycle, a non-finite `Float` (`Infinity`/`NaN`), bytes with no UTF-8 rendering, or two `Hash` keys that collapse to one property — always fail the call regardless of this setting (surfaced the same way: an error response + an `on_exception` report), because shipping them would produce a wrong or malformed body. `reject_opaque_exposed_values` only additionally rejects the *honest-but-undeclared* rendering above.
391
692
 
392
693
  ## Integration with MCP Server
393
694
 
@@ -400,7 +701,7 @@ require "axn-mcp"
400
701
  server = MCP::Server.new(
401
702
  name: "my-server",
402
703
  version: "1.0.0",
403
- tools: [GreetUser, CreateNote, SearchTool],
704
+ tools: Axn::MCP.tools, # or an explicit list: [GreetUserTool, SearchTool, ...]
404
705
  )
405
706
 
406
707
  # Use with stdio transport
@@ -410,15 +711,30 @@ transport.open
410
711
 
411
712
  For complete server setup, transport options, and advanced configuration, see the [MCP Ruby SDK documentation](https://github.com/modelcontextprotocol/ruby-sdk).
412
713
 
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.
714
+ ## Divergences from the raw MCP SDK
715
+
716
+ `Axn::MCP.wrap` covers the **full `MCP::Tool` configuration surface** — `tool_name`, `title`,
717
+ `description`, `icons`, `inputSchema`, `outputSchema`, tool-level `_meta`, and every annotation hint
718
+ (`read_only_hint`/`destructive_hint`/`idempotent_hint`/`open_world_hint` + annotation `title`) — plus
719
+ `server_context` routing and `MCP::ServerContext`'s session capabilities (`report_progress`,
720
+ `cancelled?`, …). Two `MCP::Tool::Response` **output** affordances are intentionally *not* mapped,
721
+ because an Axn's model is typed structured I/O:
722
+
723
+ - **Non-text content.** A wrapped tool's response is always a single text block plus
724
+ `structuredContent` (the JSON of your `exposes`). Image / audio / embedded-resource content
725
+ (`MCP::Content::Image` / `Audio` / `EmbeddedResource`) and multi-block content are not produced —
726
+ an Axn has no convention for declaring "this exposed value is binary/media." For a tool that must
727
+ return media content, register a hand-written `MCP::Tool` directly and splat it alongside
728
+ `Axn::MCP.tools` (see [Mixing with native tools](#mixing-with-native-mcptool-tools)).
729
+ - **Response-level `_meta`.** The per-response `_meta` channel isn't populated — there's no Axn
730
+ convention for per-call response metadata; your `exposes` become `structuredContent`. (Tool-*definition*
731
+ `_meta`, in the tools/list entry, *is* supported — via `wrap`'s `meta:` kwarg.)
416
732
 
417
733
  ## Requirements
418
734
 
419
735
  - 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
736
+ - [axn](https://github.com/teamshares/axn) >= 0.1.0-alpha.6, < 0.2.0
737
+ - [mcp](https://github.com/modelcontextprotocol/ruby-sdk) >= 0.5.0, < 2.0 — the floor is `0.5.0`, the first SDK version with the `icons` setter (and full JSON-Schema tool-schema handling, so conditional `allOf` constraints survive). Across that range the SDK varies in `server_context` shape (see [Server Context](#server-context)) and in how it surfaces a mid-serialization raise (a top-level JSON-RPC error vs. an `isError` tool result); this gem is written to be correct across all of it, not just the version you happen to have installed locally. The upper bound tracks the SDK's own semver: `1.0` declared its public API stable (breaking changes only in a future major), so `1.x` is in range.
422
738
 
423
739
  ## Development
424
740
 
@@ -428,6 +744,9 @@ bundle exec rspec
428
744
  bundle exec rubocop
429
745
  ```
430
746
 
747
+ Working on this gem with a coding agent? Read [`AGENTS.md`](AGENTS.md) first (`CLAUDE.md` is a
748
+ symlink to it).
749
+
431
750
  ## License
432
751
 
433
752
  MIT License. See [LICENSE](LICENSE) for details.
@@ -439,3 +758,43 @@ Bug reports and pull requests are welcome on GitHub at [https://github.com/teams
439
758
  ## Acknowledgments
440
759
 
441
760
  This gem wraps the excellent [MCP Ruby SDK](https://github.com/modelcontextprotocol/ruby-sdk) from the Model Context Protocol team.
761
+
762
+ <!-- TEMPORARY: transitional upgrade guide — remove this section at 1.0 (tracked in DEPRECATIONS.md). -->
763
+ ## Upgrading from 0.1.x
764
+
765
+ `0.2.0` re-architects the gem around author-once (a plain Axn exposed via `Axn::MCP.wrap` / `Axn::MCP.tools`) and retires the `Axn::MCP::Tool` subclass base. The migration is mechanical — mostly one-liners:
766
+
767
+ | 0.1.x | 0.2.0 |
768
+ | --- | --- |
769
+ | `class T < Axn::MCP::Tool` … `end` | a plain Axn (`class T; include Axn; … end`), then `Axn::MCP.wrap(T)` |
770
+ | `Axn::MCP::Tool.define(description:, expects:, exposes:, …) { … }` | `Axn::MCP.wrap(Axn::Factory.build(expects:, exposes:) { … }, name: "…", description: "…")` |
771
+ | `mcp_text_content :message` / `Axn::MCP.config.mcp_text_content` | `present_as` (same `:structured`/`:message` values) — on `configure(:mcp)`, `Axn::MCP.config`, or `wrap(present_as:)` |
772
+ | `Axn::MCP.config.error_headline = "…"` | declare a per-tool base `error "…"` on the Axn (MCP errors now surface `result.error`) |
773
+ | `read_only!` / `destructive!` / `idempotent!` / `open_world` / `closed_world` | `semantic_hints :read_only, :open_world, …` on the plain Axn |
774
+ | a hand-maintained `tools: [T1, T2, …]` array | `tool :mcp` on each Axn + `MCP::Server.new(tools: Axn::MCP.tools)` |
775
+
776
+ The most common case, before and after:
777
+
778
+ ```ruby
779
+ # 0.1.x
780
+ class GreetUser < Axn::MCP::Tool
781
+ description "Greet a user"
782
+ expects :name, type: String
783
+ exposes :greeting, type: String
784
+ def call = expose(greeting: "Hello, #{name}!")
785
+ end
786
+ # registered as: tools: [GreetUser]
787
+
788
+ # 0.2.0
789
+ class GreetUser
790
+ include Axn
791
+ tool :mcp # opt into Axn::MCP.tools discovery
792
+ description "Greet a user"
793
+ expects :name, type: String
794
+ exposes :greeting, type: String
795
+ def call = expose(greeting: "Hello, #{name}!")
796
+ end
797
+ # registered as: tools: Axn::MCP.tools # or explicitly: [Axn::MCP.wrap(GreetUser)]
798
+ ```
799
+
800
+ The retired `Axn::MCP::Tool` / `.define` and the renamed `wrap(mcp_text_content:)` kwarg **raise** with migration messages rather than failing silently, so anything you miss surfaces loudly. See [`DEPRECATIONS.md`](DEPRECATIONS.md) for what's slated for removal at 1.0.