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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec6cf38e884f59bab8f659b2b845d23dfc2880189944281874c904629b7bb95e
4
- data.tar.gz: 6c0763ba60b3f7626d7685b2d6d66e80e1c5092341213ea7b26713fc87b5429e
3
+ metadata.gz: cca06b5f0351e071ef3d4e001e0d70059961dd00614fb25de420bb968427cf4f
4
+ data.tar.gz: 91d148e897c0bf146a6cc73e4fbd1dbd4f31090abe0cf7e67fc3a6ae2b27db82
5
5
  SHA512:
6
- metadata.gz: 353cad80a6cb312fab01146ed245e938cd210ab68c1b511ef75c40c6e15501ba12b0c6fcf12c1b1d4878246eb2c5c0f50aac83584745191477a80d936b168210
7
- data.tar.gz: b0fb52cf792300f749d54fdd57766d0f1c32c0d17086d44bd1edb1e8af5cc9debca8e670c492b847c9f43baa0aa216319a98e6e7fe1e644a53300c9f2fd29c6a
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 — but, like any validation failure, that detail rides on the exception (logs / `on_exception`), while the tool's response to the client carries axn's user-facing `result.error` (`"Something went wrong"` by default, or the tool's own base `error "…"` — see [Error Handling](#error-handling)). `inputSchema`/`outputSchema` output is identical with or without `coerce:` — only accepted inbound values change, not the field's advertised JSON type.
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
- | Bare `fail!`, a validation error, or an unhandled exception | `"Something went wrong"` (axn's generic default) |
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.5, < 0.2.0
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
@@ -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:, reject_opaque_exposed_values: false)
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
- axn_class.call(ambient_context: server_context || {}, **rest)
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. Scope the
48
- # guard to just that step (NOT axn_class.call, which already handles its own exceptions, to
49
- # avoid double-reporting), report through axn's global on_exception hook for observability,
50
- # then -- honoring core's `best_effort_raises_in_dev` so a real bug surfaces loudly rather
51
- # than being masked -- re-raise in dev, otherwise return an error response so `.call` ALWAYS
52
- # yields an MCP::Tool::Response on every transport (not an escaped exception).
53
- begin
54
- Serializer.result_to_mcp_response(result, text_content:, reject_opaque_exposed_values:)
55
- rescue StandardError => e
56
- # Report best-effort: a broken `on_exception` reporter must not itself break this guard's
57
- # own never-raises contract on the very path it exists to normalize (mirrors how core wraps
58
- # its own on_exception). best_effort swallows a reporter failure outside dev; in dev it, like
59
- # the `raise` below, surfaces loudly.
60
- Axn::Extensions.best_effort("Axn::MCP transport-failure report", action: axn_class) do
61
- Axn.config.on_exception(e, action: axn_class, context: { source: "Axn::MCP" })
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
@@ -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
- def result_to_mcp_response(result, text_content: :structured, reject_opaque_exposed_values: false)
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::Extensions::Serialization.render(result, reject_opaque: reject_opaque_exposed_values)
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 }],
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Axn
4
4
  module MCP
5
- VERSION = "0.2.0"
5
+ VERSION = "0.2.2"
6
6
  end
7
7
  end
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
- reject_opaque_exposed_values: Axn::MCP.resolve_override_for(axn_class, :reject_opaque_exposed_values)
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. Re-declaring
33
- # the setting overrides AdapterRoots' empty default while reusing its broad-path validation
34
- # (which rejects widening a root to `app/`/`.`/`actions`/a `..` traversal).
35
- setting :tool_roots, default: %w[agent_tools], validate: ->(value) { Axn::Tools::AdapterRoots.validate!(value) }
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
- setting :reject_opaque_exposed_values, default: false, one_of: [true, false], overridable: true
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.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kali Donovan
@@ -15,7 +15,7 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 0.1.0.pre.alpha.5
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.5
28
+ version: 0.1.0.pre.alpha.6
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: 0.2.0