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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +209 -0
- data/README.md +459 -100
- data/lib/axn/mcp/annotations.rb +30 -0
- data/lib/axn/mcp/invocation.rb +124 -0
- data/lib/axn/mcp/serializer.rb +17 -31
- data/lib/axn/mcp/tool.rb +52 -139
- data/lib/axn/mcp/version.rb +1 -1
- data/lib/axn/mcp/wrap.rb +173 -0
- data/lib/axn/mcp.rb +101 -8
- metadata +10 -11
- data/Rakefile +0 -13
- data/lib/axn/mcp/config.rb +0 -27
- data/lib/axn/mcp/field_declarations.rb +0 -21
- data/lib/axn/mcp/schema_builder.rb +0 -224
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
|
-
|
|
25
|
+
Write a plain Axn, then expose it with `Axn::MCP.wrap`:
|
|
22
26
|
|
|
23
27
|
```ruby
|
|
24
|
-
class GreetUser
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
94
|
+
#### Never-raises contract
|
|
56
95
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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:
|
|
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" }, {
|
|
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:
|
|
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
|
|
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
|
-
"
|
|
243
|
-
"
|
|
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:
|
|
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
|
-
|
|
509
|
+
## Annotations
|
|
267
510
|
|
|
268
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
551
|
+
# At wrap time:
|
|
552
|
+
Axn::MCP.wrap(
|
|
553
|
+
SearchAxn,
|
|
315
554
|
description: "Search for items",
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
)
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
590
|
+
current_user = User.find(user_id) if user_id
|
|
334
591
|
# ...
|
|
335
592
|
end
|
|
336
593
|
end
|
|
337
594
|
```
|
|
338
595
|
|
|
339
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
600
|
+
### Capabilities — `Axn::MCP.server_context`
|
|
344
601
|
|
|
345
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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.
|
|
421
|
-
- [mcp](https://github.com/modelcontextprotocol/ruby-sdk) >= 0.
|
|
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.
|