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 inputSchema from your expects declarations
  • Generates outputSchema from your exposes declarations
  • Converts Axn::Result to MCP::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 to axn_class.tool_name — axn core's canonical, provider-safe name, which honors a tool name: "..." override on the Axn and any configured tool_name_stripped_prefixes (e.g. GreetUser"greet_user"). Pass name: 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, no axn_name), wrap raises ArgumentError rather than ship an unusable, unnamed tool — pass name: 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_roots is granted, no tool declaration needed. Axn::MCP ships a default root of agent_tools (i.e. app/agent_tools/ in a Rails app), a dir shared with axn-ruby_llm — so a tool dropped there is exposed over both surfaces at once. Configure the roots with Axn::MCP.config.tool_roots = ["agent_tools", "actions/mcp_tools"] (relative to Rails.root/app, or absolute). Roots are validated: a broad entry (app, ., actions, a .. traversal) is rejected, so you can't bulk-expose every business action.
  • tool :mcp — explicitly adds :mcp (on top of any directory grant), for a tool that lives outside the roots. Bare tool grants every registered adapter.
  • configure(:mcp) { … } — declaring MCP config implies :mcp membership.
  • tool except: :mcp — narrows: keeps the directory grant but removes :mcp. tool false opts out of every adapter.

Union, not replacement: tool :mcp adds to the directory grant rather than replacing it — a tool under a root and declaring tool :ruby_llm belongs to both. Declare all adapters/except:/per-adapter options in a single tool call (a second tool on the same class raises).

# 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: ... }. The metadata: key is not recognized by expects/exposes and raises ArgumentError at 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.meta = { 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 authorized?

  # 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:

  1. The error is recorded on the result
  2. Any configured on_exception handlers are triggered (see Axn configuration)
  3. An MCP::Tool::Response is returned with error: 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's on_exception (with the exact path, e.g. records[3].owner, for your logs/error tracker). Use it when a leaked #<…> string in a result is worse than a failed call. (See Never-raises contract 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 surfacetool_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 your exposes). 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-written MCP::Tool directly and splat it alongside Axn::MCP.tools (see Mixing with native tools).
  • Response-level _meta. The per-response _meta channel isn't populated — there's no Axn convention for per-call response metadata; your exposes become structuredContent. (Tool-definition _meta, in the tools/list entry, is supported — via wrap's meta: 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 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) 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.

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::Toolend 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.