Axn::MCP
Build Model Context Protocol (MCP) tools using Axn's declarative expects/exposes contract. This gem wraps the official MCP Ruby SDK and auto-generates JSON schemas from your Axn field declarations.
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.
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.
Installation
Add to your Gemfile:
gem "axn-mcp"
Then run:
bundle install
Quick Start
Write a plain Axn, then expose it with Axn::MCP.wrap:
class GreetUser
include Axn
description "Greet a user by name"
expects :name, type: String, description: "The user's name"
exposes :greeting, type: String, description: "The greeting message"
def call
expose greeting: "Hello, #{name}!"
end
end
GreetUserTool = Axn::MCP.wrap(GreetUser) # => an ::MCP::Tool subclass, ready to register
Axn::MCP.wrap returns a genuine ::MCP::Tool subclass. The gem automatically:
- Generates
inputSchemafrom yourexpectsdeclarations - Generates
outputSchemafrom yourexposesdeclarations - Converts
Axn::ResulttoMCP::Tool::Response - Serializes exposed data to JSON-safe
structured_content
GreetUser itself is untouched — GreetUser.call(name: "Alice") still returns a plain Axn::Result.
inputSchema/outputSchema are generated by axn core from your expects/exposes declarations,
and exposed values are serialized through its Axn::Extensions::Serialization facade. See
Field declarations & schema for the type mappings and how the schema
handles values whose wire form isn't knowable from the declaration.
Exposing tools
An Axn is just an action; the gem turns it into an ::MCP::Tool at the edge. There are three ways
in, all producing the same kind of ::MCP::Tool subclass.
One tool: Axn::MCP.wrap
GreetUserTool = Axn::MCP.wrap(GreetUser)
wrap(axn_class, description: nil, name: nil, title: nil, icons: nil, meta: nil, annotations: nil, present_as: nil):
description:defaults to the Axn's own.description. Pass it to override.name:defaults toaxn_class.tool_name— axn core's canonical, provider-safe name, which honors atool name: "..."override on the Axn and any configuredtool_name_stripped_prefixes(e.g.GreetUser→"greet_user"). Passname:to override. This matters if you register the tool inline (tools: [Axn::MCP.wrap(GreetUser)]) rather than assigning it to a constant. If the wrapped Axn is truly anonymous (no class name, noaxn_name),wrapraisesArgumentErrorrather than ship an unusable, unnamed tool — passname:in that case.annotations:/present_as:/title:/icons:/meta:— all optional; see the sections below. Omitted values fall through to the Axn's own declarations (semantic_hints,configure(:mcp)) or::MCP::Tool's defaults.
The original class is never modified — the transport concerns (schema, server_context routing,
response mapping) live entirely on the generated subclass:
GreetUserTool.input_schema_value.to_h[:properties].keys # => [:name]
GreetUserTool.call(name: "Bob", server_context: { user_id: 42 }) # => MCP::Tool::Response
GreetUser.call(name: "Alice") # => Axn::Result (untouched)
The generated subclass's .call always returns MCP::Tool::Response, and it has no .call! —
if you want raise-on-failure semantics, call the original Axn's .call! directly (unwrapped).
Never-raises contract
.call extends axn's non-bang "never raises" contract to the transport boundary. A tool's own
failures come back as an error MCP::Tool::Response (axn already catches exceptions inside the
action into a failed Axn::Result — see Error Handling). And if the transport
layer around the action raises — exposed-value serialization hitting a value with no honest JSON
form, a structure past JSON's max_nesting, or a bug — the wrapper catches that too: it reports the
exception through axn's global on_exception hook (so it's never silent) and returns a generic
error response, rather than letting an exception escape .call on a direct or custom transport.
The one exception is axn's dev mode (Axn.config.best_effort_raises_in_dev in development): there
it re-raises instead, so a real bug surfaces loudly rather than being masked.
Every registered tool: Axn::MCP.tools
Mark an Axn as an MCP tool with tool :mcp (axn core's tool-registry DSL), and Axn::MCP.tools
returns them all, already wrapped — no hand-maintained array:
class ListCompanies
include Axn
tool :mcp
description "List companies"
# ...
end
MCP::Server.new(name: "my-server", version: "1.0.0", tools: Axn::MCP.tools)
Axn::MCP.tools is Axn::Tools.for(:mcp).map { |axn| Axn::MCP.wrap(axn) } — zero-arg by design.
Per-tool customization comes from each class's own declarations (tool name:, description,
semantic_hints, configure(:mcp)), all honored inside wrap. It's symmetric with the same
pattern in sibling adapter gems (e.g. Axn::RubyLLM.tools).
Versioning. When two Axns share a tool_name but declare different tool_versions (axn core's
DSL — identity is (tool_name, tool_version)), Axn::Tools.for(:mcp) returns only the latest, so
Axn::MCP.tools exposes a single tool for that name, resolving to the highest version. For a
versioned tool (tool_version > 1), wrap also surfaces the resolved revision as tool_version in
the tool's _meta — visible to an operator/model without touching the tool's name (its
cross-adapter identity). Unversioned tools (the default tool_version of 1) get no such _meta.
Membership: directory roots + the tool DSL
A class's :mcp membership is (directory-root grant ∪ tool declaration) − except:
- Directory roots — every Axn whose file lives under one of this adapter's
tool_rootsis granted, notooldeclaration needed.Axn::MCPships a default root ofagent_tools(i.e.app/agent_tools/in a Rails app), a dir shared withaxn-ruby_llm— so a tool dropped there is exposed over both surfaces at once. Configure the roots withAxn::MCP.config.tool_roots = ["agent_tools", "actions/mcp_tools"](relative toRails.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. tool :mcp— explicitly adds:mcp(on top of any directory grant), for a tool that lives outside the roots. Baretoolgrants every registered adapter.configure(:mcp) { … }— declaring MCP config implies:mcpmembership.tool except: :mcp— narrows: keeps the directory grant but removes:mcp.tool falseopts out of every adapter.
Union, not replacement:
tool :mcpadds to the directory grant rather than replacing it — a tool under a root and declaringtool :ruby_llmbelongs to both. Declare all adapters/except:/per-adapter options in a singletoolcall (a secondtoolon the same class raises).
# In app/agent_tools/ — no `tool` needed; granted to every adapter whose roots include agent_tools:
class ListCompanies
include Axn
description "List companies"
# ...
end
# Anywhere else — opt in explicitly:
class Ping
include Axn
tool :mcp
description "Ping"
# ...
end
MCP::Server.new(name: "my-server", version: "1.0.0", tools: Axn::MCP.tools)
The class must be loaded for Axn::MCP.tools to see it — the registry only enumerates
currently-defined classes. tool_roots directories are eager-loaded on demand (and by Rails
eager-loading); a tool :mcp class that lives outside a root and isn't otherwise required won't
appear until its file is loaded. Enumerate from config.after_initialize / a to_prepare block
(not a config/initializers file) for reliable results under Rails.
For a curated subset instead of all of them, filter the registry yourself:
Axn::Tools.for(:mcp).select { ... }.map { |a| Axn::MCP.wrap(a) }.
One-off inline tools
For a throwaway tool, build a plain Axn inline with Axn::Factory.build (block-as-#call, no named
class needed) and wrap it:
# The block is the Axn's #call body — pass it to Axn::Factory.build.
search = Axn::Factory.build(
expects: { query: { type: String, description: "Search query" } },
exposes: { results: { type: Array } },
) do
expose results: Item.search(query)
end
SearchTool = Axn::MCP.wrap(search, name: "search", description: "Search for items", annotations: { read_only_hint: true })
Axn::Factory.build carries the action's behavior — the #call block plus
expects/exposes/success/error/hooks/… — while the MCP-facing bits
(name:/description:/annotations:/present_as:) go to wrap. For a multi-adapter one-off,
build the Axn once and hand it to each adapter's wrap.
Mixing with native ::MCP::Tool tools
Because wrap returns a real ::MCP::Tool subclass, wrapped Axns and hand-written MCP tools
compose in one array — splat Axn::MCP.tools alongside anything else:
MCP::Server.new(
name: "my-server", version: "1.0.0",
server_context: { user_id: current_user.id },
tools: [
*Axn::MCP.tools, # every registered :mcp Axn, wrapped
NativeSearchTool, # a plain MCP::Tool subclass
MCP::Tool.define(name: "ping", description: "…") { |server_context:, **_args| MCP::Tool::Response.new([...]) },
],
)
server_context flows identically to both: native tools read it in call(args, server_context:);
wrapped Axns get it spread into ambient_context (see Server Context).
Field declarations & schema
The schema mappings below are axn core reflection surfaced through wrap — declare fields on a
plain Axn (include Axn), and the shapes appear on Axn::MCP.wrap(TheAxn).input_schema_value /
.output_schema.
How reflection derives schemas
Reflection is best-effort and deliberately biased stricter-than-runtime. Schemas are built
statically from your expects/exposes declarations — reflection is side-effect-free and never
runs your validators. Where a value's wire form isn't provable from the declared type, the schema
reflects the conservative answer (untyped, required, or non-null) rather than guess. The net
contract: a client that follows the schema will not be rejected by schema validation; the schema
may occasionally be more restrictive than what the tool would actually accept at runtime.
Concretely, you may see: type omitted entirely (an untyped {}) when the wire form isn't knowable
(e.g. a Numeric/Complex field, or a reader-only/custom-serialized object); not: { type: "null" } on a required model:-generated _id (a primary key has no fixed JSON type); enum: [true]/[false] for a TrueClass/FalseClass field; and anyOf for union types.
This isn't an absolute "schema-valid implies success" guarantee, though: a schema-following call can
still fail axn's own runtime validation in the narrow case where a field's contract is
self-contradictory — e.g. expects :name, type: String, default: 123 reflects name as optional
(a default is present), but omitting it applies the invalid default and then fails runtime
validation. Reflection derives requiredness from the declared signals (a present default,
optional:/allow_nil:/allow_blank:) without evaluating whether the default itself is valid —
catching that would only cover literal defaults, not custom validators, callable defaults, or model
lookups, so the caveat exists either way. This is the one documented spot where the input schema is
looser than runtime rather than stricter (a known, deliberate gap, not a bug).
Required fields also carry a presence constraint. axn requires non-blank values by default, so a
required String reflects minLength: 1 and a required Array reflects minItems: 1 (an optional
field is nullable instead — e.g. ["string", "null"] — and carries no minimum). The per-feature
examples below omit these for focus, but real inputSchema/outputSchema output includes them on
every required string/array field.
Field Descriptions
Use description: directly as a kwarg on expects and exposes:
expects :start_date, type: Date, optional: true, description: "Inclusive lower bound (YYYY-MM-DD)"
exposes :results, type: Array, description: "Matching records"
Note: Do not wrap it in
metadata: { description: ... }. Themetadata:key is not recognized byexpects/exposesand raisesArgumentErrorat class load time.
Type Mappings
Axn types map to JSON Schema types:
| Ruby Type | JSON Schema |
|---|---|
String |
string |
Integer |
integer |
Float, Numeric |
number |
Hash |
object |
Array |
array |
:boolean |
boolean |
:uuid |
string (format: uuid) |
Date |
string (format: date) |
DateTime, Time |
string (format: date-time) |
Coercing loosely-typed inbound values with coerce:
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:
expects :starts_on, coerce: Date # "2026-01-15" -> a Date
expects :count, type: { klass: Integer, coerce: true } # "42" -> 42
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). inputSchema/outputSchema output is identical with or without coerce: — only accepted inbound values change, not the field's advertised JSON type.
Typed member contracts with shape:
Add a shape: block to a Hash or Data.define field to declare types and validations for its members. required is derived automatically; unannotated members on a Data.define type appear as bare {}. The block syntax is the same on both expects and exposes. (For Array fields, combine shape: with of: — see the next section.)
Hash field:
exposes :config, type: Hash do
field :region, type: String
field :timeout, type: Integer, optional: true
end
{
"type": "object",
"required": ["region"],
"properties": {
"region": { "type": "string" },
"timeout": { "type": ["integer", "null"] }
}
}
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"].
Data.define struct:
IntegrationRecord = Data.define(:source, :provider_name, :active, :status)
exposes :integration, type: IntegrationRecord do
field :status, type: String, inclusion: %w[connected error needs_reconnect]
field :active, type: :boolean, optional: true
end
{
"type": "object",
"required": ["status"],
"properties": {
"status": { "type": "string", "enum": ["connected", "error", "needs_reconnect"] },
"active": { "type": ["boolean", "null"] },
"source": {},
"provider_name": {}
}
}
Blocks recurse naturally for nested objects:
exposes :config, type: Hash do
field :region, type: String
field :retention, type: Hash do
field :days, type: Integer
end
end
Typed array elements with of:
When an Array field carries an of: declaration, the generated JSON Schema includes a machine-readable items: entry rather than a bare array type.
Scalar element type:
exposes :tags, type: Array, of: String
{ "type": "array", "items": { "type": "string" } }
Other supported forms: of: Integer, of: :boolean, of: :uuid, and union types:
exposes :values, type: Array, of: [String, Numeric]
{ "type": "array", "items": { "anyOf": [{ "type": "string" }, {}] } }
(The Numeric member is left untyped: it admits Complex, whose serialized wire form isn't knowable from the declaration alone.)
Data.define struct — bare member names as baseline:
exposes :integrations, type: Array, of: IntegrationRecord
{
"type": "array",
"items": {
"type": "object",
"properties": { "source": {}, "provider_name": {}, "active": {}, "status": {} }
}
}
Combine of: with a shape: block to annotate element members:
exposes :integrations, type: Array, of: IntegrationRecord do
field :status, type: String, inclusion: %w[connected error needs_reconnect]
field :active, type: :boolean, optional: true
end
{
"type": "array",
"items": {
"type": "object",
"required": ["status"],
"properties": {
"status": { "type": "string", "enum": ["connected", "error", "needs_reconnect"] },
"active": { "type": ["boolean", "null"] },
"source": {},
"provider_name": {}
}
}
}
Annotated members are fully typed; unannotated Data.define members (source, provider_name) remain as bare {}.
ActiveRecord Model Fields
When using model: true, the schema automatically generates an _id field with an appropriate description:
class UpdateUser
include Axn
description "Update a user's profile"
expects :user, model: true
expects :name, type: String, optional: true
def call
user.update!(name:) if name
end
end
Generates schema:
{
"properties": {
"user_id": {
"description": "ID of the User record",
"not": { "type": "null" }
}
}
}
The generated id field's JSON type is intentionally left unconstrained — a model's primary key isn't
knowable from the declaration (it could be an integer, UUID, string, etc.), and inferring it would
require a database lookup. A required model field's id still forbids null (a null token can never
resolve to a record).
Enums via Inclusion
expects :status, inclusion: %w[active inactive pending]
Generates:
{
"status": {
"type": "string",
"enum": ["active", "inactive", "pending"]
}
}
Annotations
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):
Declared semantic_hints |
Default annotation |
|---|---|
:read_only |
read_only_hint: true, destructive_hint: false |
:idempotent |
idempotent_hint: true |
:destructive |
destructive_hint: true |
:open_world |
open_world_hint: true |
:closed_world |
open_world_hint: false |
class FetchData
include Axn
description "Fetch data without side effects"
semantic_hints :read_only, :closed_world
# Axn::MCP.wrap(FetchData) => annotations include read_only_hint: true, destructive_hint: false, open_world_hint: false
# ...
end
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.
# At wrap time:
Axn::MCP.wrap(MyAxn, annotations: { read_only_hint: true, idempotent_hint: true, title: "My Custom Tool" })
# Or declaratively (picked up by Axn::MCP.tools):
class MyAxn
include Axn
tool :mcp
configure(:mcp) { |c| c.annotations = { read_only_hint: true, idempotent_hint: true, title: "My Custom Tool" } }
# …
end
Title, Icons, and Metadata
::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.
# At wrap time:
Axn::MCP.wrap(
SearchAxn,
description: "Search for items",
title: "Item Search",
icons: [{ src: "https://example.com/icon.png", mimeType: "image/png" }],
meta: { version: "1.0" },
)
# Or declaratively (picked up by Axn::MCP.tools):
class SearchAxn
include Axn
tool :mcp
configure(:mcp) do |c|
c.title = "Item Search"
c.icons = [{ src: "https://example.com/icon.png", mimeType: "image/png" }]
c. = { version: "1.0" }
end
# …
end
Server Context
Server-injected data and server-side capabilities are two different things, reached two different ways.
Data — declare it on ambient_context
A tool declares each value it needs directly on ambient_context, and reads it like any other input:
class AuthenticatedAction
include Axn
description "Do something with the current user"
expects :user_id, on: :ambient_context, type: Object, optional: true
def call
current_user = User.find(user_id) if user_id
# ...
end
end
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).
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.
Capabilities — Axn::MCP.server_context
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):
def call
Axn::MCP.server_context&.report_progress(50, total: 100, message: "Halfway done")
fail!("cancelled") if Axn::MCP.server_context&.cancelled?
# ...
end
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.
Error Handling
Use Axn's standard fail! method for controlled failures:
def call
fail! "User not found" unless user
fail! "Unauthorized" unless
# success path...
end
The MCP error response carries the Axn's own result.error — the gem imposes no headline of its own:
| Failure | Text shown to the LLM |
|---|---|
fail! "User not found" |
"User not found" |
Bare fail!, a validation error, or an unhandled exception |
"Something went wrong" (axn's generic default) |
Want a friendlier generic message than "Something went wrong"? Declare your own base error "..."
on the Axn (standard axn practice) — it's per-tool, so each tool can say something specific:
class ChargeCard
include Axn
error "Could not charge the card"
# a bare fail! / validation error / exception now surfaces "Could not charge the card"
end
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:.
Unhandled exceptions are also caught automatically. When an exception occurs:
- The error is recorded on the result
- Any configured
on_exceptionhandlers are triggered (see Axn configuration) - An
MCP::Tool::Responseis returned witherror: true
Success response text: config and per-tool
By default, successful responses contain a text block with the JSON-serialized structured_content (a SHOULD per MCP spec). 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.
# per-tool, on the Axn:
class MyAction
include Axn
configure(:mcp) { |c| c.present_as = :message }
end
# or at wrap time:
Axn::MCP.wrap(MyAction, present_as: :message)
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.
Rejecting opaque exposed values (reject_opaque_exposed_values)
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.
reject_opaque_exposed_values decides what happens when that occurs:
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.true— the value is rejected: instead of shipping the blob, the call returns an error response and reports the failure via axn'son_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 for how the raise becomes an error response.)
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.
class ListOwners
include Axn
tool :mcp
configure(:mcp) { |c| c.reject_opaque_exposed_values = true }
# ...
end
Scope — this is an output-side check only. It governs exposes serialization, not inbound argument handling (that's 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.
Integration with MCP Server
Register your tools with an MCP server:
require "mcp"
require "axn-mcp"
server = MCP::Server.new(
name: "my-server",
version: "1.0.0",
tools: Axn::MCP.tools, # or an explicit list: [GreetUserTool, SearchTool, ...]
)
# Use with stdio transport
transport = MCP::Server::Transports::StdioTransport.new(server)
transport.open
For complete server setup, transport options, and advanced configuration, see the MCP Ruby SDK documentation.
Divergences from the raw MCP SDK
Axn::MCP.wrap covers the full MCP::Tool configuration surface — tool_name, title,
description, icons, inputSchema, outputSchema, tool-level _meta, and every annotation hint
(read_only_hint/destructive_hint/idempotent_hint/open_world_hint + annotation title) — plus
server_context routing and MCP::ServerContext's session capabilities (report_progress,
cancelled?, …). Two MCP::Tool::Response output affordances are intentionally not mapped,
because an Axn's model is typed structured I/O:
- Non-text content. A wrapped tool's response is always a single text block plus
structuredContent(the JSON of yourexposes). Image / audio / embedded-resource content (MCP::Content::Image/Audio/EmbeddedResource) and multi-block content are not produced — an Axn has no convention for declaring "this exposed value is binary/media." For a tool that must return media content, register a hand-writtenMCP::Tooldirectly and splat it alongsideAxn::MCP.tools(see Mixing with native tools). - Response-level
_meta. The per-response_metachannel isn't populated — there's no Axn convention for per-call response metadata; yourexposesbecomestructuredContent. (Tool-definition_meta, in the tools/list entry, is supported — viawrap'smeta:kwarg.)
Requirements
- Ruby >= 3.2.1
- axn >= 0.1.0-alpha.5, < 0.2.0
- mcp >= 0.5.0, < 2.0 — the floor is
0.5.0, the first SDK version with theiconssetter (and full JSON-Schema tool-schema handling, so conditionalallOfconstraints survive). Across that range the SDK varies inserver_contextshape (see Server Context) and in how it surfaces a mid-serialization raise (a top-level JSON-RPC error vs. anisErrortool 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.0declared its public API stable (breaking changes only in a future major), so1.xis in range.
Development
bundle install
bundle exec rspec
bundle exec rubocop
Working on this gem with a coding agent? Read AGENTS.md first (CLAUDE.md is a
symlink to it).
License
MIT License. See LICENSE for details.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/teamshares/axn-mcp.
Acknowledgments
This gem wraps the excellent MCP Ruby SDK from the Model Context Protocol team.
Upgrading from 0.1.x
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:
| 0.1.x | 0.2.0 |
|---|---|
class T < Axn::MCP::Tool … end |
a plain Axn (class T; include Axn; … end), then Axn::MCP.wrap(T) |
Axn::MCP::Tool.define(description:, expects:, exposes:, …) { … } |
Axn::MCP.wrap(Axn::Factory.build(expects:, exposes:) { … }, name: "…", description: "…") |
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:) |
Axn::MCP.config.error_headline = "…" |
declare a per-tool base error "…" on the Axn (MCP errors now surface result.error) |
read_only! / destructive! / idempotent! / open_world / closed_world |
semantic_hints :read_only, :open_world, … on the plain Axn |
a hand-maintained tools: [T1, T2, …] array |
tool :mcp on each Axn + MCP::Server.new(tools: Axn::MCP.tools) |
The most common case, before and after:
# 0.1.x
class GreetUser < Axn::MCP::Tool
description "Greet a user"
expects :name, type: String
exposes :greeting, type: String
def call = expose(greeting: "Hello, #{name}!")
end
# registered as: tools: [GreetUser]
# 0.2.0
class GreetUser
include Axn
tool :mcp # opt into Axn::MCP.tools discovery
description "Greet a user"
expects :name, type: String
exposes :greeting, type: String
def call = expose(greeting: "Hello, #{name}!")
end
# registered as: tools: Axn::MCP.tools # or explicitly: [Axn::MCP.wrap(GreetUser)]
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 for what's slated for removal at 1.0.