axn-mcp 0.2.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 +61 -0
- data/README.md +42 -6
- data/lib/axn/mcp/invocation.rb +73 -19
- data/lib/axn/mcp/serializer.rb +6 -2
- data/lib/axn/mcp/version.rb +1 -1
- data/lib/axn/mcp/wrap.rb +6 -1
- data/lib/axn/mcp.rb +9 -5
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cca06b5f0351e071ef3d4e001e0d70059961dd00614fb25de420bb968427cf4f
|
|
4
|
+
data.tar.gz: 91d148e897c0bf146a6cc73e4fbd1dbd4f31090abe0cf7e67fc3a6ae2b27db82
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4a33316fb4ff566273b2ecb95a6d7a174a24e0157d218ae6173fe38ab74806ec717684a3a3ce9673c7ff49db3def9596041ed897733ba0de4805313bd6c7b8ce
|
|
7
|
+
data.tar.gz: b6febb6893e8bef0d83e1168b6c8657a1f84ace28c06d2b8aa58e560651b60f089d226455a8bd33416b6d18f583bcdbfe13d525959cf0ed38445e7dc9d0da746
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,66 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.2
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- **BREAKING (behavior): every MCP tool call now runs through axn core's `Axn::Tools::Invoker`
|
|
8
|
+
(PRO-2943/PRO-3332) instead of a bare `axn_class.call`, turning on three behaviors that were
|
|
9
|
+
previously off for every wrapped Axn — no opt-out.** This is the same profile `axn-ruby_llm` and
|
|
10
|
+
`axn-openapi` already use for a model/LLM-facing tool.
|
|
11
|
+
- **Input coercion is now always on.** A wire-typed argument (a JSON string `"25"` for an
|
|
12
|
+
`expects :limit, type: Integer`) is coerced to the declared type before the Axn runs — previously
|
|
13
|
+
only if the class or `Axn.config` opted into `coerce_input_types` itself. A field's own `coerce:`
|
|
14
|
+
still wins. If a tool's `expects` has no `type:` at all, this is a no-op for that field; declaring
|
|
15
|
+
`type:` on every tool input (which `input_schema` already needs to build the JSON Schema) is what
|
|
16
|
+
benefits from it. See axn core's [Tool Invoker](https://teamshares.github.io/axn/reference/tool-invoker) docs.
|
|
17
|
+
- **An inbound-contract violation (a bad or missing argument) now settles as a correctable,
|
|
18
|
+
user-facing tool error instead of a generic failure reported through `on_exception`.** Previously
|
|
19
|
+
a model sending a malformed argument reported as a dev-facing bug (paging `on_exception`, generic
|
|
20
|
+
error text) exactly like an internal error would. Now `result.error` names the specific violation
|
|
21
|
+
(e.g. which field), the call is **not** reported to `on_exception`, and the client gets an
|
|
22
|
+
actionable message instead of a wall.
|
|
23
|
+
- **A top-level tool argument the Axn never declared with `expects` is now rejected as invalid input,
|
|
24
|
+
instead of being silently dropped.** Previously an MCP client could pass an extra/misspelled
|
|
25
|
+
argument and it would just vanish (ordinary Ruby `**kwargs` behavior); now the call fails with a
|
|
26
|
+
message naming the undeclared key.
|
|
27
|
+
- Also stamps every wrapped call's tree with the `invoked_via: "mcp"` dimension (visible in axn's
|
|
28
|
+
own call-completion log line and any tracing/dashboard code keyed off it), so MCP-driven traffic
|
|
29
|
+
is now distinguishable from a direct `.call`. This part is additive/observability-only.
|
|
30
|
+
- **Unaffected:** `ambient_context` resolution/guarding (still adapter-injected, still stripped from
|
|
31
|
+
model-supplied args before this ever ran), the transport-failure guard and its diagnostic hint,
|
|
32
|
+
`present_as`, `reject_opaque_exposed_values`, and every other config/serialization behavior in
|
|
33
|
+
this release. Only the dispatch of `axn_class.call` itself changed.
|
|
34
|
+
|
|
35
|
+
- **Raised the `axn` dependency floor to `>= 0.1.0-alpha.6`** (from `>= 0.1.0-alpha.5`) — required for
|
|
36
|
+
both `Axn::Tools::AdapterSerialization` and `Axn::Tools::Invoker`'s `adapter:` kwarg, above.
|
|
37
|
+
|
|
38
|
+
### Internal
|
|
39
|
+
|
|
40
|
+
- **[INTERNAL] Routed the adapter config/serialization plumbing through axn core's shared
|
|
41
|
+
`Axn::Tools::AdapterSerialization` (PRO-2996).** `Axn::MCP` now `extend`s the mixin alongside
|
|
42
|
+
`Axn::Tools::AdapterRoots`: `reject_opaque_exposed_values` is declared via
|
|
43
|
+
`declare_reject_opaque_exposed_values! default: false` (was a hand-written `setting`), `tool_roots`
|
|
44
|
+
via `tool_roots_default %w[agent_tools]` (was a re-declared `setting` hand-copying core's
|
|
45
|
+
validation lambda), exposed-value rendering goes through `Axn::MCP.serialize_exposed(result)`, and
|
|
46
|
+
the transport-mapping guard through `Axn::MCP.guard_tool_response`. **No behavior change** —
|
|
47
|
+
identical defaults, identical per-tool override precedence (`configure(:mcp)` beats the gem-wide
|
|
48
|
+
config), identical error response, `on_exception` report and dev re-raise, and an identical
|
|
49
|
+
operator hint line. Internally, `reject_opaque_exposed_values` is now resolved off the result's own
|
|
50
|
+
action class inside `serialize_exposed` rather than threaded through the call, so
|
|
51
|
+
`Axn::MCP::Serializer.result_to_mcp_response` and `Axn::MCP::Invocation.perform` no longer take a
|
|
52
|
+
`reject_opaque_exposed_values:` kwarg. Both are internal entry points (a consumer calls
|
|
53
|
+
`Axn::MCP.wrap`/`.tools`), so this is not a user-facing signature change.
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- **The transport-failure guard now logs an operator hint when `reject_opaque_exposed_values` may be the
|
|
58
|
+
cause.** The tool-facing error stays generic (`"The tool could not produce a valid response"`), but the
|
|
59
|
+
logged line now names the offending tool and both places the setting could be set (`configure(:mcp)` /
|
|
60
|
+
`Axn::MCP.config.reject_opaque_exposed_values`) whenever the resolved value is `true` — matching
|
|
61
|
+
`axn-openapi`'s dispatcher hint. Previously an operator had to guess which knob caused a rejection since
|
|
62
|
+
the setting is per-tool overridable.
|
|
63
|
+
|
|
3
64
|
## 0.2.0
|
|
4
65
|
|
|
5
66
|
Re-architected around **author-once**: write a plain Axn and expose it as an `::MCP::Tool` with
|
data/README.md
CHANGED
|
@@ -212,6 +212,43 @@ MCP::Server.new(
|
|
|
212
212
|
`server_context` flows identically to both: native tools read it in `call(args, server_context:)`;
|
|
213
213
|
wrapped Axns get it spread into `ambient_context` (see [Server Context](#server-context)).
|
|
214
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
|
+
|
|
215
252
|
## Field declarations & schema
|
|
216
253
|
|
|
217
254
|
The schema mappings below are axn core reflection surfaced through `wrap` — declare fields on a
|
|
@@ -262,7 +299,6 @@ exposes :results, type: Array, description: "Matching records"
|
|
|
262
299
|
|
|
263
300
|
Axn types map to JSON Schema types:
|
|
264
301
|
|
|
265
|
-
|
|
266
302
|
| Ruby Type | JSON Schema |
|
|
267
303
|
| ------------------ | ------------------------------ |
|
|
268
304
|
| `String` | `string` |
|
|
@@ -277,15 +313,14 @@ Axn types map to JSON Schema types:
|
|
|
277
313
|
|
|
278
314
|
### Coercing loosely-typed inbound values with `coerce:`
|
|
279
315
|
|
|
280
|
-
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`. 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:
|
|
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:
|
|
281
317
|
|
|
282
318
|
```ruby
|
|
283
319
|
expects :starts_on, coerce: Date # "2026-01-15" -> a Date
|
|
284
320
|
expects :count, type: { klass: Integer, coerce: true } # "42" -> 42
|
|
285
321
|
```
|
|
286
322
|
|
|
287
|
-
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 —
|
|
288
|
-
|
|
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.
|
|
289
324
|
|
|
290
325
|
### Typed member contracts with `shape:`
|
|
291
326
|
|
|
@@ -594,7 +629,8 @@ The MCP error response carries the Axn's own `result.error` — the gem imposes
|
|
|
594
629
|
| Failure | Text shown to the LLM |
|
|
595
630
|
| ----------------------------------------- | ----------------------- |
|
|
596
631
|
| `fail! "User not found"` | `"User not found"` |
|
|
597
|
-
|
|
|
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`. |
|
|
598
634
|
|
|
599
635
|
Want a friendlier generic message than `"Something went wrong"`? Declare your own base `error "..."`
|
|
600
636
|
on the Axn (standard axn practice) — it's per-tool, so each tool can say something specific:
|
|
@@ -697,7 +733,7 @@ because an Axn's model is typed structured I/O:
|
|
|
697
733
|
## Requirements
|
|
698
734
|
|
|
699
735
|
- Ruby >= 3.2.1
|
|
700
|
-
- [axn](https://github.com/teamshares/axn) >= 0.1.0-alpha.
|
|
736
|
+
- [axn](https://github.com/teamshares/axn) >= 0.1.0-alpha.6, < 0.2.0
|
|
701
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.
|
|
702
738
|
|
|
703
739
|
## Development
|
data/lib/axn/mcp/invocation.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Axn
|
|
4
4
|
module MCP
|
|
5
|
-
# Shared "server_context: -> ambient_context: -> call -> MCP::Tool::Response" path used by
|
|
5
|
+
# Shared "server_context: -> ambient_context: -> Invoker#call -> MCP::Tool::Response" path used by
|
|
6
6
|
# Axn::MCP.wrap-generated classes (PRO-2844). The injected server_context is passed *as* the
|
|
7
7
|
# Axn's ambient_context (spread, not nested under a `server_context` key), so a wrapped Axn
|
|
8
8
|
# declares the data it needs generically -- `expects :user_id, on: :ambient_context` -- and stays
|
|
@@ -17,7 +17,7 @@ module Axn
|
|
|
17
17
|
module Invocation
|
|
18
18
|
module_function
|
|
19
19
|
|
|
20
|
-
def perform(axn_class, kwargs, text_content:,
|
|
20
|
+
def perform(axn_class, kwargs, text_content:, tool_name: nil)
|
|
21
21
|
# Prefer the Symbol key WHENEVER PRESENT (key?, not `||`/truthiness): an explicit
|
|
22
22
|
# server_context: nil must win over a String-keyed value, or a caller could forge
|
|
23
23
|
# server_context by appending an extra "server_context" argument alongside a real, explicit
|
|
@@ -35,8 +35,26 @@ module Axn
|
|
|
35
35
|
# (last-write-wins on the collision), so `.except` with Symbol keys alone doesn't catch it.
|
|
36
36
|
rest = kwargs.reject { |k, _| %w[server_context ambient_context].include?(k.to_s) }
|
|
37
37
|
|
|
38
|
+
# The sanctioned tool call path (axn core's Axn::Tools::Invoker, PRO-2943/PRO-3332), not a
|
|
39
|
+
# bare `axn_class.call` -- args here are untrusted, model-supplied wire data, and the Invoker
|
|
40
|
+
# applies the tool contract a trusted in-process `.call` deliberately doesn't: always-on
|
|
41
|
+
# coercion of those wire args (a JSON-string "5" for an `expects :n, type: Integer` becomes
|
|
42
|
+
# the Integer), a user-facing "Invalid tool arguments" failure instead of an on_exception page
|
|
43
|
+
# for an inbound contract violation, and rejection of a top-level key the tool never declared
|
|
44
|
+
# (previously silently dropped by plain `**kwargs`). `adapter: :mcp` stamps every call this
|
|
45
|
+
# Invoker makes with the `invoked_via` dimension for its whole call tree (Datadog can then
|
|
46
|
+
# separate tool-driven traffic from ordinary direct calls) -- matches axn-ruby_llm's and
|
|
47
|
+
# axn-openapi's own `Invoker.new(user_facing_input_errors: true, reject_undeclared_inputs:
|
|
48
|
+
# true)` profile, the established contract for an LLM/model-facing tool adapter.
|
|
49
|
+
#
|
|
50
|
+
# `rest`/`server_context` here are unchanged from before this migration: the Invoker ALSO
|
|
51
|
+
# strips a model-supplied `ambient_context` key and merges in the trusted `ambient_context:`
|
|
52
|
+
# kwarg after that guard, so this file's own stripping above is now redundant with the
|
|
53
|
+
# Invoker's -- kept as-is rather than removed, since a caller-supplied `ambient_context` was
|
|
54
|
+
# already stripped from `rest` before it ever reaches here.
|
|
55
|
+
invoker = Axn::Tools::Invoker.new(adapter: :mcp, user_facing_input_errors: true, reject_undeclared_inputs: true)
|
|
38
56
|
result = Axn::MCP.with_server_context(server_context) do
|
|
39
|
-
|
|
57
|
+
invoker.call(axn_class, rest, ambient_context: server_context || {})
|
|
40
58
|
end
|
|
41
59
|
|
|
42
60
|
# Uphold axn's non-bang "never raises" contract at the adapter boundary. The wrapped Axn's own
|
|
@@ -44,25 +62,61 @@ module Axn
|
|
|
44
62
|
# on_exception itself -- but the TRANSPORT layer that runs AFTER it (exposed-value
|
|
45
63
|
# serialization, response building) can raise outside core's executor: a value with no honest
|
|
46
64
|
# JSON form (dup keys, non-finite float, non-UTF-8 bytes, an opaque value under
|
|
47
|
-
# reject_opaque_exposed_values), a structure past JSON's max_nesting, or a gem bug.
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
# reject_opaque_exposed_values), a structure past JSON's max_nesting, or a gem bug.
|
|
66
|
+
#
|
|
67
|
+
# `guard_tool_response` (core's Axn::Tools::AdapterSerialization, PRO-2996) owns that guard for
|
|
68
|
+
# every tool adapter: it reports through axn's global on_exception hook -- inside its own
|
|
69
|
+
# best_effort, so a broken reporter can't break the guard -- honors core's
|
|
70
|
+
# `best_effort_raises_in_dev` by re-raising in development, and otherwise calls `on_error` so
|
|
71
|
+
# the adapter builds its own transport-native response. Only the MCP-shaped parts are ours: the
|
|
72
|
+
# diagnostic log line and the `MCP::Tool::Response`. Scoped to the mapping step ONLY (never
|
|
73
|
+
# axn_class.call above, which already reports its own exceptions -- wrapping both would
|
|
74
|
+
# double-report one failure).
|
|
75
|
+
Axn::MCP.guard_tool_response(axn_class, on_error: lambda { |e|
|
|
76
|
+
# The user-facing response stays generic (ADAPTER_FAILURE_MESSAGE) -- this line is an
|
|
77
|
+
# operator's only pointer to WHY. Mirrors axn-openapi's dispatcher hint: the config pointer
|
|
78
|
+
# lives HERE rather than in core's exception message, since core raises the same error for
|
|
79
|
+
# adapters with no such setting. Named as BOTH config levels, never just the gem-wide setter
|
|
80
|
+
# -- the value is resolved per-tool, so a `configure(:mcp)` override beats `config`, and core
|
|
81
|
+
# exposes no way to ask which level supplied a resolved value. Non-committal ("if this is")
|
|
82
|
+
# because reject_opaque_exposed_values being on doesn't mean THIS failure is an opaque
|
|
83
|
+
# rejection -- it could equally be a colliding key, a non-finite Float, or a gem bug.
|
|
84
|
+
#
|
|
85
|
+
# Resolved right here rather than threaded in as a kwarg: `Axn::MCP.serialize_exposed` now
|
|
86
|
+
# resolves the same override internally (off the result's own action class), so `perform` no
|
|
87
|
+
# longer receives it. A second read is fine -- this one only builds a diagnostic string, it
|
|
88
|
+
# gates no behavior.
|
|
89
|
+
#
|
|
90
|
+
# Named by the MCP-facing tool_name (from wrap's `resolved_name`), not the wrapped Axn's own
|
|
91
|
+
# class name: `Axn::MCP.wrap` lets the same Axn mount under a different name per call site
|
|
92
|
+
# (an explicit `name:`, or a per-adapter `tool mcp: { name: }` override), and an operator
|
|
93
|
+
# correlating this line with a failed MCP request has the REQUEST's tool name, not the class's.
|
|
94
|
+
# Falls back to `resolved_axn_name` (axn core) for a caller of `perform` outside `wrap` (the
|
|
95
|
+
# specs) -- never raw `#{axn_class}`: Class#to_s does NOT dispatch through an overridden
|
|
96
|
+
# `.name` (it renders the object-id form regardless), so a class with no assigned constant --
|
|
97
|
+
# e.g. one built via Axn::Factory.build -- would otherwise show as `#<Class:0x...>` instead of
|
|
98
|
+
# naming the action.
|
|
99
|
+
#
|
|
100
|
+
# Kept inside its own best_effort, rather than leaning on guard_tool_response's rescue around
|
|
101
|
+
# `on_error`: that rescue reports and then RE-RAISES, so a broken configured logger (or a
|
|
102
|
+
# hostile tool_name override) would escape as an exception on the very path whose whole job
|
|
103
|
+
# is to return an error response. Swallowing it here keeps the response guaranteed; the log
|
|
104
|
+
# line is the diagnostic, not the contract.
|
|
105
|
+
Axn::Extensions.best_effort("Axn::MCP transport-failure diagnostic log", action: axn_class) do
|
|
106
|
+
hint = if Axn::MCP.resolve_override_for(axn_class, :reject_opaque_exposed_values)
|
|
107
|
+
display_name = tool_name || axn_class.resolved_axn_name
|
|
108
|
+
" (if this is an opaque-value rejection: reject_opaque_exposed_values resolved true for " \
|
|
109
|
+
"#{display_name} — unset it on the action via `configure(:mcp)`, or " \
|
|
110
|
+
"gem-wide via `Axn::MCP.config.reject_opaque_exposed_values = false`, whichever is set)"
|
|
111
|
+
else
|
|
112
|
+
""
|
|
113
|
+
end
|
|
114
|
+
Axn.config.logger.error { "[axn-mcp] failed to serialize successful result: #{e.class}: #{e.message}#{hint}" }
|
|
62
115
|
end
|
|
63
|
-
raise if Axn::Extensions.raises_in_dev?
|
|
64
116
|
|
|
65
117
|
Serializer.error_response(Serializer::ADAPTER_FAILURE_MESSAGE)
|
|
118
|
+
}) do
|
|
119
|
+
Serializer.result_to_mcp_response(result, text_content:)
|
|
66
120
|
end
|
|
67
121
|
end
|
|
68
122
|
end
|
data/lib/axn/mcp/serializer.rb
CHANGED
|
@@ -15,9 +15,13 @@ module Axn
|
|
|
15
15
|
# user-facing message.
|
|
16
16
|
ADAPTER_FAILURE_MESSAGE = "The tool could not produce a valid response"
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
# No `reject_opaque_exposed_values:` kwarg: `Axn::MCP.serialize_exposed` (from core's
|
|
19
|
+
# Axn::Tools::AdapterSerialization) resolves it per-tool off `result.__action__`'s own class, so
|
|
20
|
+
# there is nothing left for a caller to pass -- or to pass wrong. Resolving one class's override
|
|
21
|
+
# while rendering a different class's result is now structurally impossible.
|
|
22
|
+
def result_to_mcp_response(result, text_content: :structured)
|
|
19
23
|
if result.ok?
|
|
20
|
-
exposed = Axn::
|
|
24
|
+
exposed = Axn::MCP.serialize_exposed(result)
|
|
21
25
|
success_text = success_response_text(result, exposed, text_content)
|
|
22
26
|
::MCP::Tool::Response.new(
|
|
23
27
|
[{ type: "text", text: success_text }],
|
data/lib/axn/mcp/version.rb
CHANGED
data/lib/axn/mcp/wrap.rb
CHANGED
|
@@ -94,10 +94,15 @@ module Axn
|
|
|
94
94
|
# Axn::MCP.overrides, so it may have no such method at all; resolve_override_for reads
|
|
95
95
|
# the override store directly and falls back to the gem-wide config on its own, with no
|
|
96
96
|
# dependency on axn_class having that accessor.
|
|
97
|
+
#
|
|
98
|
+
# `present_as` is the only setting resolved here now: `reject_opaque_exposed_values` is
|
|
99
|
+
# resolved inside `Axn::MCP.serialize_exposed` (core's Axn::Tools::AdapterSerialization),
|
|
100
|
+
# keyed off the result's own action class, so there is no longer a value to thread through
|
|
101
|
+
# the invocation -- and no way for a caller to thread the wrong one.
|
|
97
102
|
Axn::MCP::Invocation.perform(
|
|
98
103
|
axn_class, kwargs,
|
|
99
104
|
text_content: present_as || Axn::MCP.resolve_override_for(axn_class, :present_as),
|
|
100
|
-
|
|
105
|
+
tool_name: resolved_name
|
|
101
106
|
)
|
|
102
107
|
end
|
|
103
108
|
end
|
data/lib/axn/mcp.rb
CHANGED
|
@@ -13,6 +13,7 @@ module Axn
|
|
|
13
13
|
module MCP
|
|
14
14
|
extend Axn::Configurable
|
|
15
15
|
extend Axn::Tools::AdapterRoots
|
|
16
|
+
extend Axn::Tools::AdapterSerialization
|
|
16
17
|
|
|
17
18
|
config_namespace :mcp
|
|
18
19
|
|
|
@@ -29,10 +30,13 @@ module Axn
|
|
|
29
30
|
# []); the registry reads `Axn::MCP.config.tool_roots` for directory-based tool membership.
|
|
30
31
|
# Ship the shared `agent_tools` convention as the default root, so an Axn under `app/agent_tools/`
|
|
31
32
|
# is exposed as an MCP tool out of the box (no explicit `tool :mcp` needed) -- and, since
|
|
32
|
-
# axn-ruby_llm defaults to the same dir, the same tool is exposed over ruby_llm too.
|
|
33
|
-
# the setting
|
|
34
|
-
# (which rejects widening a
|
|
35
|
-
|
|
33
|
+
# axn-ruby_llm defaults to the same dir, the same tool is exposed over ruby_llm too.
|
|
34
|
+
# `tool_roots_default` re-declares the setting through core's own `setting` path -- overriding
|
|
35
|
+
# AdapterRoots' empty default while reusing its broad-path validation (which rejects widening a
|
|
36
|
+
# root to `app/`/`.`/`actions`/a `..` traversal), and validating EAGERLY here rather than at the
|
|
37
|
+
# registry's first read -- so an app's own assignment still wins and `config.reset!(:tool_roots)`
|
|
38
|
+
# returns to this default rather than core's `[]`.
|
|
39
|
+
tool_roots_default %w[agent_tools]
|
|
36
40
|
|
|
37
41
|
setting :present_as, default: :structured, one_of: %i[structured message], overridable: true
|
|
38
42
|
|
|
@@ -46,7 +50,7 @@ module Axn
|
|
|
46
50
|
# handling. (Values with no *honest* JSON form -- cycles, non-finite Floats, non-UTF-8-encodable
|
|
47
51
|
# bytes, colliding Hash keys -- fail regardless of this flag; it governs only the extra "was this
|
|
48
52
|
# rendering author-declared?" check.)
|
|
49
|
-
|
|
53
|
+
declare_reject_opaque_exposed_values! default: false
|
|
50
54
|
|
|
51
55
|
# Per-tool MCP metadata, declarable on the class via `configure(:mcp) { |c| c.title = "..." }`
|
|
52
56
|
# so it survives the zero-arg `Axn::MCP.tools` path (which calls `wrap` with no kwargs). Each
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: axn-mcp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kali Donovan
|
|
@@ -15,7 +15,7 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 0.1.0.pre.alpha.
|
|
18
|
+
version: 0.1.0.pre.alpha.6
|
|
19
19
|
- - "<"
|
|
20
20
|
- !ruby/object:Gem::Version
|
|
21
21
|
version: 0.2.0
|
|
@@ -25,7 +25,7 @@ dependencies:
|
|
|
25
25
|
requirements:
|
|
26
26
|
- - ">="
|
|
27
27
|
- !ruby/object:Gem::Version
|
|
28
|
-
version: 0.1.0.pre.alpha.
|
|
28
|
+
version: 0.1.0.pre.alpha.6
|
|
29
29
|
- - "<"
|
|
30
30
|
- !ruby/object:Gem::Version
|
|
31
31
|
version: 0.2.0
|