MCP Server reference
Per-item reference for the avo-mcp_server add-on. For installation, connecting a client, and worked examples, see the MCP Server guide.
All options live under config.mcp_server inside Avo.configure, in config/initializers/avo.rb:
Avo.configure do |config|
config.mcp_server.enabled = true
config.mcp_server.resource_identifier = "https://app.example.com/avo/mcp"
config.mcp_server.tool_calls_per_minute = 300
config.mcp_server.connection_log = true
config.mcp_server.connection_log_size = 500
endConfiguration
-> enabled
Turns the server on. While false, every protocol endpoint and the consent screen answer 404 and no new connection can be authorized. The MCP connections resource belongs to the panel rather than to the server, so it stays available — existing connections can be reviewed and revoked while the server is off.
config.mcp_server.enabled = true- Type:
Boolean - Default:
false
-> resource_identifier
The public URL of the MCP server — the string an admin pastes into their AI client. Left unset, the address is derived per request from the origin the request arrived on plus the mount path. Once set, it is the single value read by the discovery document's resource field, the audience stamped on every issued token, and the audience checked on every incoming call; a request arriving at any other scheme, host, or port is answered 421 Misdirected Request.
config.mcp_server.resource_identifier = "https://app.example.com/avo/mcp"- Type:
String - Default:
nil— derived from the request origin plus the mount path - Validation: raises
Avo::McpServer::ConfigurationErrorat boot when its path differs from the path passed tomount_avo_mcp_server, or when it useshttp://on a host other thanlocalhostor127.0.0.1.
Changing it is a migration
Tokens are bound to the address they were minted under. After a change, every live connection's next call is answered 421, and admins reconnect through the authorize page.
-> tool_calls_per_minute
How many JSON-RPC requests one connection may make in a minute before the endpoint answers 429 with a Retry-After header. The counter runs through Rails.cache: on :null_store nothing is counted, and on a per-process store each worker keeps its own count.
config.mcp_server.tool_calls_per_minute = 600- Type:
Integer - Default:
300 - Values:
nilturns the ceiling off
-> connection_log
Whether the JSON-RPC endpoint records every request a connection makes — tool calls, list requests, the handshake, and the ones it refused — for the Log card on the connection's page. Rows are written after the endpoint has rendered its answer and never fail a request.
config.mcp_server.connection_log = false- Type:
Boolean - Default:
true
-> connection_log_size
How many log rows each connection keeps. Older rows are dropped as new ones arrive.
config.mcp_server.connection_log_size = 2_000- Type:
Integer - Default:
500 - Values:
nilkeeps every row
-> extra_tools
Tools of the app's own, served beside the ten. Each entry is a class name — never a constant, since the initializer runs before the app's classes are loadable — or a Hash whose tool: key names the class. The class is either an Avo::McpServer::Tool, which the avo:mcp_server:tool generator scaffolds, or a RubyLLM::Tool written for Avo AI that declares def self.capability; the second is served through an adapter. Either shape names the resources it touches (resources "Ticket", "Project", or def self.resources on a RubyLLM::Tool), which a connection narrowed to named resources must all grant; a call that names none is refused on such a connection. Names are resolved on every request, and an entry that can't be served is dropped with a log line naming the reason while the rest keep serving. See Add tools of your own.
A Hash entry's remaining keys are passed to the tool's initializer, so the Hash form is for a RubyLLM::Tool. An Avo::McpServer::Tool runs as class methods and is never instantiated; an entry naming one and carrying settings is dropped rather than served without them.
config.mcp_server.extra_tools = [
"IssueInvoiceTool", # an Avo::McpServer::Tool takes no settings
{tool: "CrmTool", api_key: ENV["CRM_API_KEY"]} # a RubyLLM::Tool receives them in its initializer
]user: is reserved: the key is dropped from an entry, and the admin the connection was authorized by is passed instead to a tool whose initializer accepts one.
| Entry dropped because | Applies to |
|---|---|
| The class name doesn't resolve | both |
The class is neither an Avo::McpServer::Tool nor a RubyLLM::Tool | both |
It declares no capability, or one that isn't avo:read, avo:write or avo:actions | both |
| Its tool name is already taken by a shipped tool or by an earlier entry | both |
The entry carries keys besides tool: | Avo::McpServer::Tool |
It overrides call, where the capability gate runs | Avo::McpServer::Tool |
| Reading its capability, name, description, resources or argument schema raised | RubyLLM::Tool |
| Its argument schema doesn't resolve to an object | RubyLLM::Tool |
- Type:
ArrayofStringorHashwith a Stringtool:key - Default:
[] - Validation: raises
ArgumentErrorat boot for an entry that is neither a String nor a Hash with a Stringtool:key. Everything else is checked per request, and a failure drops that entry alone.
Routing
-> mount_avo_mcp_server
The routing method that draws every endpoint an AI client calls. Available inside Rails.application.routes.draw.
# config/routes.rb
mount_avo_mcp_server # JSON-RPC endpoint at /avo/mcp
mount_avo_mcp_server at: "/admin/mcp" # moved| Endpoint | Path | Authentication |
|---|---|---|
| Protected resource metadata | /.well-known/oauth-protected-resource (origin root) | None |
| Authorization server metadata | /.well-known/oauth-authorization-server (origin root) | None |
| Token endpoint | <mount path>/token | None |
| Client registration (RFC 7591) | <mount path>/register | None |
| Token revocation (RFC 7009) | <mount path>/revoke | None |
| JSON-RPC endpoint | <mount path> | Bearer token |
The discovery documents are always at the origin root, where the protocol requires them; at: moves only the endpoints under the mount path. The consent screen and the MCP connections resource are mounted with the panel, not here.
- Type: routing method
- Values:
at:— the JSON-RPC endpoint's path. Default"/avo/mcp". - Validation: raises
Avo::McpServer::ConfigurationErrorat boot when drawn inside anauthenticateorconstraintsblock, when drawn aftermount_avoat a path under Avo's mount prefix, or whenat:differs from the path in a pinnedresource_identifier.
Generators
-> avo:mcp_server install
Writes the migration and appends the configuration block to config/initializers/avo.rb.
bin/rails generate avo:mcp_server install
bin/rails db:migrate| Migration | Tables |
|---|---|
create_avo_mcp_server | avo_mcp_server_connections, avo_mcp_server_access_grants (single-use codes), avo_mcp_server_access_tokens (digests), avo_mcp_server_clients (registered clients), avo_mcp_server_events (the connection log) |
add_slug_to_avo_mcp_server_connections | Only for an app whose create_avo_mcp_server predates the slug column on avo_mcp_server_connections — the connection's name. Names every existing connection. A fresh install has the column in the first migration. |
The installer is additive. A migration the app already has is skipped, and the configuration block is appended only when config.mcp_server.enabled appears nowhere in the initializer. Run it again after upgrading the gem: it reads the app's own create_avo_mcp_server migration and adds the upgrade migration only when that one lacks the columns.
- Values:
--skip-avo-version— don't print the installed Avo version
-> avo:mcp_server:tool
Scaffolds a tool of the app's own under app/tools/: an Avo::McpServer::Tool with the wire name pinned, a capability, a description, an input schema and a perform skeleton. Register the class with extra_tools.
bin/rails generate avo:mcp_server:tool issue_invoiceWrites app/tools/issue_invoice_tool.rb, defining IssueInvoiceTool, called issue_invoice by a client. issue_invoice, issue_invoice_tool and IssueInvoiceTool name the same tool; the suffix is added once. An app that also runs Avo AI scaffolds with avo:ai:tool instead and registers the one class with both gems.
Tools
The ten tools a connection can call, each gated by the capability it declares. Tools the app registers through extra_tools are served beside them, gated the same way by the capability each declares, and by the resources each declares. Tool names are what a client prints in its transcript and are never translated.
| Tool | Capability | Does |
|---|---|---|
list_resources | avo:read | Lists the resources this panel serves |
list_records | avo:read | Pages through a resource's records |
count_records | avo:read | Counts a resource's records without returning any |
show_record | avo:read | Opens one record in full, with its associations |
search_records | avo:read | Searches records by text |
list_actions | avo:read | Lists the actions a resource registers |
create_record | avo:write | Creates a record |
update_record | avo:write | Changes a record's fields |
delete_record | avo:write | Deletes a record |
run_action | avo:actions | Runs one of this app's actions on records |
Arguments
| Tool | Required | Optional |
|---|---|---|
list_resources | — | — |
list_records | resource | page, per_page, sort_by, sort_direction |
count_records | resource | — |
show_record | resource, id | — |
search_records | query | resource, limit |
list_actions | resource | — |
create_record | resource, attributes | — |
update_record | resource, id, attributes | — |
delete_record | resource, id | — |
run_action | resource, action | record_ids, fields |
resourceis a resource name aslist_resourcesreports it ("Post"), never a table name.idand each ofrecord_idsis the record's id as the tools return it: itsto_param, the id in the panel's URL (a slug or a hashid where the model hides its primary key), always a string. It is found through the resource'sfind_record_method, within the admin's policy scope.- Paging is 1-based.
per_pagedefaults to25and is capped at100;search_records'limitbehaves the same way, per resource searched. sort_bymust name a column the resource exposes to the connecting admin, orcreated_at, which the panel sorts by even when no field declares it; anything else is refused withsortableColumns.sort_directionisascordesc. Left out, the resource's default sorting applies, falling back tocreated_atand then the primary key, as the panel does.attributesmaps the names the resource's edit form takes to values: a field name (aslist_resourcesreports it) or the param the form submits. Abelongs_tois written asuseroruser_id, a polymorphic one ascommentable_typeandcommentable_idtogether. The record is filled through the resource's fields (fill_record), soupdate_using, abelongs_to's finder and attach scope, and a key-value field's locks all apply. A column a field writes under another name (a money field'sprice_cents) and a field the form doesn't render (a badge) are refused, and so areid,created_at,updated_at, and names matchingpassword,token,secret, ordigest. Values are JSON types: an object for a key-value field,{option: true}for a boolean group, ISO 8601 with an offset for a date-time. A refused attribute fails the call withfieldsnaming it.update_recordchanges only the attributes passed.run_actiontakes theactionid fromlist_actions(the class name) andfieldskeyed by the input names it reports. A standalone action takes norecord_idsand is refused if given any; every other action needs at least one. Every id is authorized individually, so a partly-allowed batch is refused rather than partly run. An action whoseself.visibleorself.authorizeblock hides it from the admin, as the panel would, is left out oflist_actionsand refused byrun_action.count_recordsreturns{resource, count}and nothing else. The count islist_records'totalfor the same connection: the resource'sindex_querynarrowed by the admin's policy scope, with no default filter or scope from the index screen applied.- Every id a tool returns is a
to_param: a record's, abelongs_to's, and ahas_many's.show_recordreturns abelongs_toas the record's id and title, ahas_oneas the record's id and title read through the associated resource's policy scope (nullwhen the scope hides it), and ahas_manyas a count plus up to 25 ids, narrowed by the associated resource's policy scope. An association the record's policy refuses withview_<association>?is left out, as its panel is on the show page. search_recordsruns each resource's ownself.searchblock. A resource without one is an error when named directly and is listed underunsearchablein an all-resource search; it never falls back to listing the resource's records.- The MCP connections resource is excluded from every tool.
Capabilities
The scope strings a grant is stored as. Write carries read; a per-resource grant appends the resource's name.
| Capability | Scope string | Per-resource form | Selected at consent |
|---|---|---|---|
| Read | avo:read | avo:read:Post | Yes |
| Read & write | avo:write | avo:write:Post | No |
| Run actions | avo:actions | — | No |
Run actions has no per-resource form: over a narrowed read it applies to the resources the read grant names. A connection's capabilities are set at consent and changed later from the connection's Entitlements card, by whoever the policy's edit_entitlements? allows; the change binds the connection's next tool call on the tokens the client already holds.
Error codes
Refusals are JSON-RPC errors carrying a numeric code, a message, and a data object.
| Code | Constant | Means | data carries |
|---|---|---|---|
-32000 | CAPABILITY_REQUIRED | The connection was never granted the capability the tool requires | requiredCapability, grantedCapabilities |
-32001 | UNAUTHORIZED | Capability held; the owning admin's policy said no | action, subject |
-32002 | AUTHORIZATION_UNAVAILABLE | Authorization isn't licensed, so nothing was attempted | — |
-32003 | VALIDATION_FAILED | The model refused a create, update, or destroy | validationErrors, fieldErrors, requiredAttributes, optionalAttributes, missingRequiredAttributes |
-32004 | SEARCH_UNAVAILABLE | The resource has no self.search configured | resource |
-32005 | UNSUPPORTED_RESOURCE | The resource is not backed by Active Record | resource |
-32602 | INVALID_PARAMS | Unknown tool, resource, record, or argument | writableFields, availableActions, availableInputs, or sortableColumns, depending on the argument |
-32020 | — | An Mcp-Method, Mcp-Name, or Mcp-Protocol-Version header disagrees with the body | — |
-32022 | — | A protocol revision this server doesn't implement | — |
The first six are the add-on's own, in the -32000 to -32019 band the MCP specification leaves to implementations; the rest are the specification's.
-32000 names what was withheld and what the connection holds, so a client can tell the admin what to re-authorize:
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32000,
"message": "This connection was not granted avo:write, which is required by delete_record.",
"data": {
"requiredCapability": "avo:write",
"grantedCapabilities": ["avo:read"]
}
}
}-32003 is shaped for an agent that should correct its own call. requiredAttributes is read from the schema and the model — NOT NULL columns without a default, presence validators, non-optional belongs_to — which is what makes missingRequiredAttributes worth acting on:
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32003,
"message": "Post could not be saved: Title can't be blank",
"data": {
"validationErrors": ["Title can't be blank"],
"fieldErrors": { "title": ["can't be blank"] },
"requiredAttributes": ["title", "user_id"],
"optionalAttributes": ["body", "published_at"],
"missingRequiredAttributes": ["title"]
}
}
}A record outside the admin's policy scope reports as -32602, identical to an id that never existed. A resource they may not list reports as -32001. A successful result carries "resultType": "complete".
Connection log
One row per request a connection makes, shown on the connection's page as the Log card and polled from <your-avo-path>/mcp_server/connections/:id/events. Rows are written by the JSON-RPC endpoint after it has answered, and pruned per connection to connection_log_size.
| Outcome | Recorded when |
|---|---|
ok | The request was served |
error | The answer was a JSON-RPC error; the row keeps its code and message |
tool_error | The tool's result carried isError; the row keeps its text |
refused | The request was refused for a malformed header before any tool ran |
rate_limited | The connection was over tool_calls_per_minute |
rejected | The token no longer authenticates — expired, revoked, or on a revoked connection |
A tool call's arguments are stored after the app's filter_parameters and capped at 4 KB; over that, only the keys and the size are kept. Requests that resolve to no connection — server/discover, or a token that names nothing — are not recorded.
Panel classes
| Class | What it is |
|---|---|
Avo::Resources::McpConnection | The MCP connections resource, at <your-avo-path>/resources/mcp_connections |
Avo::Actions::McpRevokeConnection | The Revoke action |
Avo::McpServer::Connection | The model behind the resource. Its identity — client, owner, name — is immutable and it refuses destroy; the grant (capabilities) is edited through the Entitlements card. |
Avo::McpServer::Event | One row of the connection log |
Avo::McpServer::ConnectionPolicy | The policy you write; the gem ships none |
Policy methods
| Method | Decides |
|---|---|
use? | Required. Whether the admin gets the server at all: every call, code exchange and refresh, the consent screen, the connect page and its buttons. Never revocation or the list. A policy without it refuses every admin. See Turn MCP on or off per admin |
Scope#resolve | Which connections are listed and findable |
index? | Whether the resource appears at all under explicit_authorization |
show? | A connection's page — and every card on it that the three methods below do not claim |
act_on? | The Revoke action. Asked once with the class, then per selected connection |
view_log? | Optional. The Log card and its poll endpoint, in place of show? |
view_audit_trail? | Optional. The Audit trail table and the endpoint that fills it, in place of show? |
view_entitlements? | Optional. The Entitlements card, in place of show? |
view_tools? | Optional. The Tools card, in place of show? |
edit_entitlements? | Optional. Changing the grant from the Entitlements card, and the request Apply sends. Denied — or undefined in a policy — the card renders read-only; it does not fall back to show?. Editing implies view_entitlements? |
create?, edit?, destroy? | The corresponding controls. Identity is immutable and the model refuses deletes regardless; the grant is changed through edit_entitlements?, not edit? |
The three card methods are separate because the cards disclose different things: the Log carries data about your own records, the Entitlements matrix is computed against the connection owner's reach (so on another admin's connection it names resources the reader's own policies may hide from them), and the Tools list is derived from the registry and the grant. Refusing one leaves the rest of the page intact.
A host with an explicit config.resources array must add "Avo::Resources::McpConnection" to it. Avo::Resources::McpConnection.visible_on_sidebar = false in a to_prepare block keeps it off the sidebar.
Translations
Every string the resource shows is translatable, in each of the nineteen languages Avo ships: field names under avo.resource_translations.mcp_connection.fields.<field id>, everything else under avo.mcp_server.connections — the Revoke action's copy, the Tools card (.tools.*), the Entitlements card (.entitlements.*), the Log card (.log.*), and the capability titles (.capabilities.read|write|actions). Tool names and descriptions are never translated, and the consent screen is English throughout.
Rate limits
| Endpoint | Ceiling | Keyed by |
|---|---|---|
| JSON-RPC | tool_calls_per_minute, 300 by default | Connection |
| Token | 60 a minute | IP |
| Authorize (consent screen) | 30 a minute | IP |
| Client registration | 10 a minute | IP |
Over a limit the endpoint answers 429 with a Retry-After header. Every counter runs through Rails.cache.
Protocol
| Item | Value |
|---|---|
| MCP revision | 2026-07-28 (the stateless lifecycle and server/discover), and the classic initialize lifecycle for clients on 2025-11-25 and earlier |
| Transport | Streamable HTTP, JSON responses. No stdio, no event stream, no shared token |
| Client authentication | Public clients only (token_endpoint_auth_methods_supported: ["none"]), PKCE required |
| Client identity | Client ID Metadata Document, or RFC 7591 dynamic registration |
| Tokens | Audience-bound to resource_identifier or the derived address; access tokens live an hour, refresh tokens rotate with family-wide revocation on reuse |
| Log filtering | code, code_verifier, refresh_token, access_token, and client_secret are filtered from request logs. _meta is not |