Skip to content
Looking for every option?See the full API reference →

MCP Server ​

Your team can ask Claude, ChatGPT, or Cursor about the data in your Avo app, in plain words, from the assistant they already use all day. The avo-mcp_server add-on serves your app as a remote MCP server. A teammate pastes its URL into their client and approves a consent screen served by your own app. From then on the client acts as that person, through the same resources, policies, and field visibility the Avo app already applies to them.

It's one of two ways your team can ask the app; AI in your Avo app compares it with the chat.

ruby
# config/routes.rb
Rails.application.routes.draw do
  mount_avo_mcp_server

  authenticate :user do
    mount_avo
  end
end
ruby
# config/initializers/avo.rb
Avo.configure do |config|
  config.mcp_server.enabled = true
end

The server is off by default: adding the gem changes nothing until you mount it and enable it, and, with an authorization client under explicit_authorization = true, Avo's default, until you answer use?. With nothing else configured, its address is your panel's origin plus /avo/mcp, each connection may make 300 tool calls a minute, and every request a connection makes is kept in a log on its page.

Requirements ​

  • Avo 4.2 or newer
  • A license with both this add-on and Authorization enabled
  • The panel reachable over HTTPS at a public URL. Consent and token exchange happen in the admin's browser.
  • A client that supports remote MCP servers with OAuth. There is no stdio transport and no token to paste into a config file.

Authorization must be licensed, not just installed

Policy enforcement lives in the Authorization add-on, and it skips every policy check when it isn't licensed. Rather than run tool calls with no policies at all, the server refuses to serve until both add-ons are on the license. If clients connect but every call answers -32002, check the license first.

Install ​

1. Add the gem ​

ruby
# Gemfile
gem "avo-mcp_server", source: "https://packager.dev/avo-hq/"
bash
bundle install

2. Run the installer ​

bash
bin/rails generate avo:mcp_server install
bin/rails db:migrate

The installer writes one migration — the connections, codes, and tokens admins authorize, plus the connection log — and appends a commented configuration block to config/initializers/avo.rb. It's additive: a migration your app already has is skipped, so running it again is safe. Running it again is also how you upgrade: an app whose connections predate their Name gets that column as a migration of its own (see Tell connections apart).

If your admin model uses UUID primary keys, add type: :uuid to the migration's t.references :user line before migrating. The reference carries no foreign key, so a mismatch doesn't fail at migration time — it shows up later as connections whose owner can't be found.

3. Mount the endpoints ​

ruby
# config/routes.rb
Rails.application.routes.draw do
  mount_avo_mcp_server 

  authenticate :user do
    mount_avo
  end
end

mount_avo_mcp_server draws the endpoints AI clients talk to: the two OAuth discovery documents at your origin root, the token, client registration and token revocation endpoints, and the JSON-RPC endpoint at /avo/mcp. Pass at: to move the JSON-RPC endpoint. The consent screen and the connections resource aren't part of this — they're mounted with the panel and use your existing sign-in.

Mount it outside your authentication block, and before mount_avo

A connected client calls these endpoints with a bearer token and no browser session, so inside authenticate :user do … end every call would be answered with the sign-in redirect. And because /avo/mcp sits under Avo's own /avo, it must come before mount_avo, or Avo's engine swallows it. Both placements are refused at boot with a message naming the fix.

4. Turn it on ​

ruby
# config/initializers/avo.rb
Avo.configure do |config|
  config.mcp_server.enabled = true
end

Your server's address is the panel's origin plus the mount path — https://app.example.com/avo/mcp for a panel at https://app.example.com. That's the URL admins paste into their clients. If your panel answers at more than one origin, or a proxy hides the public one, pin the address.

5. Decide who can use it ​

With an authorization client running, the server asks use? before it serves an admin. With Pundit it asks Avo::McpServer::ConnectionPolicy, and under explicit_authorization = true, Avo's default, it stays off until that policy exists, and the consent screen says so:

ruby
# app/policies/avo/mcp_server/connection_policy.rb
class Avo::McpServer::ConnectionPolicy < ApplicationPolicy
  def use? = true
end

That turns the server on and nothing else. Under Pundit's generated ApplicationPolicy, MCP connections and Connect a client stay refused until the policy also has index?, show?, act_on? and a Scope. The Full example has a set you can copy as it is, and Decide who sees and revokes what shows the other shapes. See Turn MCP on or off per admin for use? itself. With avo-permissions as the client, no policy is asked: a role needs a use? permission instead, added from a console as the upgrade guide shows.

Connect a client ​

  1. Copy the server URL. Connect a client on the MCP connections resource shows it with a recipe per client, and so does opening the URL in a browser.
  2. Add it to the AI client as a remote MCP server.
  3. The client sends the admin to the authorize page, served by your panel. If they aren't signed in, they sign in first.
  4. They review who is asking, choose what the client may do, and approve.

Nothing is copied by hand. The client obtains a short-lived token through the redirect and refreshes it on its own; declining creates nothing. The recipes, with https://app.example.com/avo/mcp standing in for your URL:

bash
claude mcp add --transport http acme-admin https://app.example.com/avo/mcp
# then, inside Claude Code: /mcp → acme-admin → Authenticate
toml
# ~/.codex/config.toml
[mcp_servers.acme-admin]
url = "https://app.example.com/avo/mcp"

# then: codex mcp login acme-admin
json
// ~/.cursor/mcp.json — or use "Install in Cursor" on the connect page
{
  "mcpServers": {
    "acme-admin": {
      "url": "https://app.example.com/avo/mcp"
    }
  }
}
json
// ~/.config/amp/settings.json — or: amp mcp add acme-admin https://app.example.com/avo/mcp
{
  "amp.mcpServers": {
    "acme-admin": {
      "url": "https://app.example.com/avo/mcp"
    }
  }
}
json
// .vscode/mcp.json — or use "Install in VS Code" on the connect page
{
  "servers": {
    "acme-admin": {
      "type": "http",
      "url": "https://app.example.com/avo/mcp"
    }
  }
}

ChatGPT has no config file: open Settings → Connectors → Create, name it, paste the server URL, and sign in when asked (this needs developer mode, under Settings → Apps & Connectors → Advanced). Any other client works the same way: follow its setup for a remote server and use the URL.

What the admin sees on the authorize page ​

  • The client's name and logo next to your panel's own logomark, and the client's verified domain — the origin of its identifier — underneath.
  • Where the browser goes back to afterwards. A loopback address is named as what it is: a program on the admin's own machine.
  • The admin they're signed in as — the person the connection will act as.
  • What the client may do: Read, Read & write, or Choose per resource for records, and Run actions as a separate toggle.

A client that registered itself instead of publishing a metadata document has no verifiable identity. Its mark is ringed in amber, only read is preselected, and the admin has to tick I started this connection myself, from a client I recognize before the page will approve anything.

The name is the client's claim; the domain is the evidence

A client names itself, so anything can call itself "Claude Code" and ask for write access. The verified domain is the one thing an attacker would have to control to forge. Read both before approving a request for write or run actions.

Choose what a connection can do ​

Three capabilities cover the whole surface. Write means create, update, and delete, as in the REST API.

CapabilityScopeTools it unlocksAt consent
Readavo:read, or avo:read:<Resource>list_resources, list_records, count_records, show_record, search_records, list_actionsSelected
Read & writeavo:write, or avo:write:<Resource>create_record, update_record, delete_recordNot selected
Run actionsavo:actionsrun_actionNot selected

Choose per resource on the authorize page lists every resource the admin can see and lets them pick none, read, or read & write for each. A narrowed grant holds everywhere: list_resources lists only the granted resources, a tool naming any other one is refused before a record is loaded, search_records with no resource searches only the granted ones, an association to an ungranted resource is left out of show_record's payload, a tool of your own runs only when every resource it declares is granted, and run actions over a narrowed read means "on those resources". Write carries read.

The decision made at consent isn't final. An admin the policy allows widens or narrows a connection later from its Entitlements card, and the change binds the client's next call.

Write and run actions are unselected on purpose

An agent can't reliably tell your data apart from instructions aimed at it, so text in a record it reads — a signup name, a ticket body — can steer it. delete_record and run_action act immediately, with no confirmation step: granting the capability at consent is the confirmation. Grant them to clients you trust, on data you control, and keep everything else read-only.

Every call stays inside the admin's own permissions ​

Every tool call passes two gates, in this order:

  1. The capability gate. A tool the connection wasn't granted — globally, or for the resource the call names — is refused before any data is touched, and the error names the missing capability.
  2. Your authorization. The owning admin is re-resolved from your app on every request, and the call runs through the same policies the panel uses for them.

So a connection can never do anything its admin couldn't do by hand. Granting a capability is permission to try; the policy decides. And because the admin is re-resolved every time, a permission change takes effect on the next call — demote an admin to read-only and their connected client stops writing, with nothing to revoke.

An action runs on the view it would have been started from ​

view is show when run_action targets one record and index for a bulk run or one that takes no records: the page an admin would have clicked it from, never new. The action's visible and authorize blocks, its fields, and handle all read that value, so an action that branches on view.show? or view.index? answers a client as it does on a click. A bulk run also hands the action no current record, the way the panel does when several rows are checked.

Keep a field out of a client's reach ​

The visible: blocks on your fields apply to reads and writes. A field the panel hides from this admin isn't returned by any read tool, isn't listed by list_resources, and can't be written even on a record they may otherwise edit. Read-only fields are refused the same way, and so are id, created_at, updated_at, and any column whose name looks like a credential (password, token, secret, digest). A refused attribute fails the call rather than being silently dropped.

A write that is allowed is saved through your fields, the way the edit form saves it: update_using, a belongs_to's attach scope and a key-value field's locks apply to a connected client too.

The converse is the rule too: a field the panel does render this admin is returned, credentials included. There is no MCP-only redaction list — to keep something out of an AI client's reach, hide it with visible:, the same block that hides it from a person.

When the rule is about who rather than when, the resource's policy can name the fields directly with whitelisted_fields / blacklisted_fields. A field it withholds is kept from every Avo surface at once, this one included, and cannot be widened back by a visible: block. Avo::Current.interface is :mcp for every call this add-on serves, so a policy that trusts the panel more than a connected client can say so in one place. See Field authorization.

A column no field declares, and a field the user may not reach, are the same thing to a client: not serialized, not accepted as a sort key, never named in sortableColumns, writableFields or a validation error, and refused on write exactly as a nonexistent field is. Two things the panel shows without a field count as declared: created_at stays a sort key whenever the column exists, and the timestamps or id a resource lists in discreet_information are read (never written) alongside its fields. A tool of your own gets the same treatment by building its payloads with FieldSupport.record_payload and its errors with FieldSupport.validation_failed; raising ValidationFailed.new(record) by hand still works but sends every error, not only the ones the admin may see.

Add tools of your own ​

The ten tools reach any record, but a workflow an agent runs often — "issue the invoice for this order", "close these tickets and notify their reporters" — is better served as one tool than as a chain of list_records, show_record and run_action calls it has to get right every time. You can register tools of your own, and they're served beside the ten.

Scaffold one:

bash
bin/rails generate avo:mcp_server:tool issue_invoice

That writes app/tools/issue_invoice_tool.rb, defining IssueInvoiceTool — a subclass of Avo::McpServer::Tool, the same contract the shipped tools follow. A client calls it issue_invoice. Fill in five things:

ruby
# app/tools/issue_invoice_tool.rb
class IssueInvoiceTool < Avo::McpServer::Tool
  tool_name "issue_invoice"
  capability "avo:actions"
  resources "Order", "Invoice"

  description <<~DESC
    Issues the invoice for an order and emails it to the customer.
  DESC

  input_schema(
    properties: {
      order_id: {type: "string", description: "The order's id, as list_records returns it."}
    },
    required: ["order_id"]
  )

  def self.perform(identity:, order_id:, server_context: nil, **)
    _, order = Avo::McpServer::ToolSupport.find_authorized_record!(
      identity: identity, resource: "Order", id: order_id, action: :act_on
    )
    invoice = Invoicing.issue!(order, by: identity.admin)

    respond({invoiceId: invoice.to_param, number: invoice.number})
  end
end
  • capability is which of the three capabilities unlocks the tool: avo:read for one that only reads, avo:write for one that changes records, avo:actions for one that runs a workflow. A connection without it is refused before perform runs, exactly as for the shipped tools.
  • resources names every resource the tool reads or changes, as list_resources names them. A connection narrowed to named resources must grant each one, or the call is refused before perform runs and the error names the one that's missing. The connecting admin's own policy must also answer index? for each one, on any connection, so a tool can't read past a role that changed after the grant. List static names only: a tool that picks its resource at call time takes a resource argument instead. A resource not backed by Active Record can't be granted on a connection, so a tool declaring one can't run on a narrowed connection, and a name that matches no resource refuses every call, on any connection.
  • description is what the client's model reads to decide whether to call the tool. Its first sentence is also what the connection's Tools card shows.
  • input_schema is the JSON schema of the arguments. Everything in required is checked before your code runs. A resource argument declared here gets the same two checks, and a name that matches no resource is refused; a resource key the schema doesn't declare is checked the same way, but doesn't count as the tool saying what it reads.
  • perform receives the identity — the connection, and the admin it acts as — plus the arguments. Reach records through Avo::McpServer::ToolSupport, which runs the admin's own policies and reports a record they can't reach as one that doesn't exist; raise one of Avo::McpServer::ToolErrors to refuse with a structured error. With Audit Logging installed, Avo::McpServer::Audit.record puts a change the tool makes in the audit log, marked as made through the connection.

Then register the class in your initializer:

ruby
# config/initializers/avo.rb
config.mcp_server.extra_tools = ["IssueInvoiceTool"]

Entries are class names, not constants — this initializer runs before your own classes are loadable — and each is resolved when a request arrives, so editing a tool in development is served on the next call. An entry that can't be served (a class that doesn't load, one that isn't a tool, one with no capability, one claiming a shipped tool's name) is dropped with a line in the log saying why, and the rest keep serving. extra_tools lists every reason an entry is dropped.

A call that names no resource is refused on a narrowed connection

When a tool has no resources declaration and the call sends no resource argument the schema declares, the server can't tell what it reads, so a connection narrowed to named resources refuses it. The connection's Tools card still lists such a tool. Tools registered before resources existed were let through such connections; declare resources on each one when you upgrade, or connections narrowed per resource can no longer call it. A tool that reaches no resource at all, say one that only calls an outside API, has nothing to declare, so it can't run on a narrowed connection.

Custom tools share the consent capabilities

There is no per-tool grant: an admin grants Read, Read & write or Run actions, and your tool is unlocked by whichever one it declares. That cuts both ways — a connection that already holds avo:actions can call a tool you register tomorrow, without anyone re-authorizing. Declare the capability that matches what the tool really does, and put a tool that changes data behind avo:write or avo:actions, never avo:read.

Share a tool with Avo AI ​

Running Avo AI too? Write the tool once, for the chat, and serve it over MCP as well. A RubyLLM::Tool needs two more lines — the capability and the resources it touches — and the same class goes in both settings:

ruby
# app/tools/issue_invoice_tool.rb
class IssueInvoiceTool < RubyLLM::Tool
  include Avo::Ai::ToolAuthorization

  def self.capability = "avo:actions"
  def self.resources = %w[Order Invoice]

  description "Issues the invoice for an order and emails it to the customer."
  parameter :order_id, type: :integer, description: "The order to invoice."

  def execute(order_id:)
    require_acting_user!
    # ...
    {invoice_id: invoice.id}
  rescue Avo::Ai::ToolAuthorization::IdentityError => e
    {error: e.message}
  end
end
ruby
# config/initializers/avo.rb
config.ai.extra_tools = ["IssueInvoiceTool"]
config.mcp_server.extra_tools = ["IssueInvoiceTool"]

Over MCP the server reads the name, description and argument schema off the class, builds an instance per call with the connecting admin as user: (and the entry's other keys as settings, as the chat does), and translates the answer: a Hash is served as structured content, a String as text, and an {error: "..."} Hash — the shape the chat's tools use for "not allowed" — as a tool error. The capability gate runs first, resources included, exactly as for every other tool. The chat ignores both lines. An entry's user: key is ignored, and chat and inspection_tracker are never set: the admin is always the one the connection was authorized by, and there is no conversation.

The reverse isn't offered — an Avo::McpServer::Tool can't be handed to the chat. Write a tool you want in both places the RubyLLM way.

Review and revoke connections ​

Connections are an Avo resource: MCP connections in the sidebar, at <your-avo-path>/resources/mcp_connections. Each row is one client acting as one admin — its Name (three words, unique, the same name the session announces), the client's name and id, the Owner, when it was authorized, and when it was last used. A connection's page adds the status as chips by the title, an Entitlements card showing — and changing — what the grant reaches, a Tools card listing the calls it unlocks (and the ones it withholds), and the Log card described below.

What the status means ​

Every connection carries a status — a badge on the index, a chip on its page — and it's never "active". The server has no connection to watch: each call is its own HTTP request, and nothing arrives when a client is closed or removed. What the server does know is whether what it issued still works, so the status is read off that:

StatusWhat it means
OpenThe client holds credentials that still work and can call at any time. Whether it does is what Last used says. A connection whose owner use? refuses also reads Open, and its calls are rejected.
DisconnectedThe client gave up its credentials. Claude Code does this on Clear authentication in /mcp and on claude mcp logout, through the token revocation endpoint.
ExpiredNothing the client holds still works: it went 30 days without calling, or never finished connecting after you approved it.
RevokedYou revoked it.

A disconnected or expired client comes back by authorizing again — which creates a new connection. The old row stays, so you can still see what it was allowed to do and when it last ran. Hover the status, the badge on the index or the chip on the connection's page, for the moment the state was entered.

Only clients that revoke their tokens show as disconnected

Token revocation (RFC 7009) is the one signal a client can send on its way out, and only clients that implement it do. Claude Code does. A client that simply deletes its stored tokens reads as open until they expire, 30 days after its last call.

Revoke is an action on the resource, from the actions menu or a connection's page; with Custom controls it's also a button on the toolbar, on each row not yet revoked, and on the page. It takes effect on the client's next call and notifies nothing. The connection stays listed as revoked, so you can still see that it existed and when it last ran. A connection's identity — its client, owner and name — is never edited, and connections are never deleted from the panel; what one may reach is changed from its Entitlements card. The resource is excluded from the MCP tools themselves — a client can't list connections, revoke one, or change a grant through run_action.

If your initializer lists resources explicitly in config.resources, add "Avo::Resources::McpConnection" to it. To link the resource into the profile menu (needs the Menu editor add-on):

ruby
# config/initializers/avo.rb
config.profile_menu = -> do
  link_to "MCP connections",
    path: Avo::Engine.routes.url_helpers.resources_mcp_connections_path,
    icon: "plug-connected"
end

Tell connections apart ​

Every Claude Code install registers as the same client — one client id, one name — and so does every claude.ai connector, so a panel with five Claude Code rows can't say from the registration which is which. Nothing the client sends helps either: two Claude Code sessions send exactly the same thing.

So every connection has a name. Three words from the avocado's own vocabulary, unique, generated when you approve the client — creamy-golden-hass, ripe-mashed-guacamole — shown as the Name column and as the connection's title. No two connections ever share one: the database enforces it.

The server tells the client its name when it connects — in its answer to server/discover, or to initialize for a client that still initializes. The first time a connection is used after you authorize it, the server also asks the client to pass the name on, so that first Claude Code session opens with:

MCP connected — you're connection creamy-golden-hass.

Find that row in the panel and you know which session you're looking at. Later sessions and reconnects open without the line — you already know the name — but the client still holds it: ask the session "which Avo MCP connection is this?" at any point and it answers with the name. The name can't be changed; revoking names it too — creamy-golden-hass (Claude Code) can no longer reach this panel.

The whole text the client receives, which Claude Code puts in front of the model as MCP Server Instructions — on the connection's first use:

This session is MCP connection creamy-golden-hass to the Acme Admin panel, newly authorized. Tell the user once: "MCP connected — you're connection creamy-golden-hass." Afterwards, name it only when asked which MCP connection this is.

and on every connect after that:

This session is MCP connection creamy-golden-hass to the Acme Admin panel. Name it only when asked which MCP connection this is; do not announce it otherwise.

Whether claude.ai surfaces a server's instructions the same way is not verified; there, the live Log on the connection's page still shows which row a call landed on.

Upgrading from an earlier 4.2 beta

Names need a column the first migration didn't have. Run bin/rails generate avo:mcp_server install again — it adds the column as a migration of its own, names every existing connection, and changes nothing else — then bin/rails db:migrate. Until then rows are titled by the client name and the session is told its connection's id instead of a name; connections keep working.

See and change what a connection may reach ​

The Entitlements card is the consent screen's decision read back in the shape it was made in: one row per resource, at None, Read or Read & write, with a search over them and a count underneath — 4 of 14 granted — everything else is refused. It is also where that decision is changed.

Rows are the resources the connection's owner can list, not the reader's. A grant that names a resource the panel no longer registers keeps its row, marked no longer listed, so the card never under-reports what is held.

Running actions is not a per-resource question, so it sits above the grid as its own line — a checkbox — rather than as a row that could not hold it.

Clicking a level changes nothing on its own. The moment the card differs from what's stored, Apply changes and Undo appear — Apply writes the whole card at once, Run actions included, and Undo snaps back. Above the rows, a search box narrows the list as you type, and Set all shown applies one level to whatever the search is currently showing: "read-only on everything matching order" is a search and a click.

A connection is either unrestricted or fine-tuned, and there is nothing in between. Unrestricted means the connection limits nothing of its own: it can read and write everything its owner can reach, and the owner's own policies are the only limit — exactly what an unrestricted API token means. Such a connection shows one line instead of the grid: the Unrestricted pill and whose reach that is (Everything you can access.). There is no level on that line, because unrestricted is not a level. Fine-tune opens the rows with every one already at Read & write, so you narrow from there instead of building the list up from nothing. Nothing is stored until you apply.

Everything else is the grid, open. That includes a connection the consent screen granted Read across the board — its default: reading everything is a limit the connection itself imposes, so the card shows it as rows, every one at Read, and the count says so.

Unrestricted reaches what comes later; a list does not

An unrestricted connection reaches every resource its owner can see, including resources your app registers later. Fine-tune it, move a level and apply, and it holds an explicit list instead: a resource the owner gains after that is refused until you grant it. Setting every row to None leaves the connection with no record access at all; a Run actions grant it holds keeps its global meaning.

Make unrestricted is the way back, from any grid. It returns the connection to reading and writing everything its owner can — the confirmation says so, since a connection that held write on one resource will now hold it on all of them — and clears every per-resource entry, including ones naming resources the card no longer lists.

The change binds the connection's next tool call, on the tokens the client already holds — nothing is revoked and nobody authorizes again. A client that cached its tool list learns on its next refused call. A code approved before the change and exchanged after it is bound by the current grant too; the scope string the client is handed follows the code's original snapshot on that exchange and the current grant on refresh. Each accepted change is written to the Avo log with who made it, the connection, and the capabilities before and after.

Who may change a grant is the policy's edit_entitlements?. Denied, the card renders read-only and says so. It is read-only as well on a revoked connection (the grant is kept as a record of what was held), while config.mcp_server.enabled is false, when the owner no longer exists, and when Authorization is unlicensed.

INFO

This is the same grid the REST API shows for an API token's entitlements, with the same controls and the same way back. A panel running both add-ons asks "what may this credential reach?" in one form on both screens.

The Tools card below it is the same grant read as the calls it turns into — list_records, run_action — grouped by the capability that unlocks each group. A narrowed grant is counted there (on 3 resources) rather than named, since the names are rows on the Entitlements card above. The write group says what it stands on, above its tools: Everything in Read, plus: — "Read & write" heads three calls only because the group carries read's six as well.

Read a connection's access at a glance ​

The connections index sums each grant up in an Access column, so the connection that can write stands out without opening every one. Hover the badge for how far it reaches — a grant across the board says every resource its owner can access, a fine-tuned one counts its resources.

AccessMeans
UnrestrictedRead and write across everything the owner can reach, including resources added later.
Read onlyRead on every resource it reaches — the consent screen's default.
Read & writeRead & write on every resource it reaches, but fine-tuned rather than unrestricted.
CustomResources sit at different levels, such as read everywhere and write on a few.
No accessNo record access at all. A Run actions grant alone still reads as No access.

The same column, with the same words, is on the API tokens index.

Watch what a connection is doing ​

A connection's page carries a Log card: every request the client made, newest first, kept current while the page is open. Each row shows the tool and resource it named (or the method, for initialize and tools/list), the time, how long it took, and how it went:

OutcomeWhat happened
green dotServed.
ErrorA JSON-RPC error — a withheld capability, a policy that said no, a missing record. The message is the one the client got.
Tool errorThe tool ran and reported a failure.
RefusedA malformed request, refused before any tool ran.
Rate limitedOver tool_calls_per_minute.
Token rejectedAn expired or revoked token, a revoked connection, or an owner use? refuses. A client still retrying after you revoked it shows up here.

A client that disconnects through the revocation endpoint leaves an oauth/revoke row, so the moment it left sits where its calls do.

A tool call's arguments sit behind a disclosure on its row, after your app's filter_parameters and capped at 4 KB. Filter the list to tool calls or errors, or pause it while you read.

Each connection keeps its newest 500 rows and drops older ones as new ones arrive. Both are options — connection_log switches the log off, connection_log_size changes the size:

ruby
# config/initializers/avo.rb
config.mcp_server.connection_log_size = 2_000

Who may read the log is who may open the page, unless your policy defines view_log? — then the card and the endpoint it polls ask that instead.

The log's table (avo_mcp_server_events) comes with the installer's migration. Until you've run bin/rails db:migrate, the card says so and nothing is recorded.

Decide who sees and revokes what ​

This add-on ships no policy for the resource. Without one, Avo's defaults apply as for any other resource: with explicit_authorization = false every admin sees, may revoke, and may change the reach of every connection; with explicit_authorization = true the resource stays hidden until a policy answers index?. A policy goes where every other policy goes:

ruby
# app/policies/avo/mcp_server/connection_policy.rb
class Avo::McpServer::ConnectionPolicy < ApplicationPolicy
  # Whether this admin gets the MCP server at all. Required: see "Turn MCP on or off per admin" below.
  def use? = true

  def index? = true

  def show? = user.owner? || record.user == user

  # Revoke. Avo asks once for the action itself (record is the class), then per selected connection.
  def act_on? = record.is_a?(Class) || user.owner? || record.user == user

  # The four cards below the fields. Each is optional: without it, show? decides.
  def view_log? = user.owner?

  def view_audit_trail? = user.owner?

  def view_entitlements? = user.owner?

  def view_tools? = true

  # Changing the grant from the Entitlements card. Without this method the card is read-only.
  def edit_entitlements? = user.owner? || record.user == user

  # Connections are created by authorizing a client and ended by revoking it; the grant is changed from the Entitlements card, not the edit form.
  def create? = false

  def edit? = false

  def destroy? = false

  class Scope < ApplicationPolicy::Scope
    def resolve = user.owner? ? scope.all : scope.where(user: user)
  end
end

The Scope decides the list, show? the page, act_on? the Revoke action, edit_entitlements? the Entitlements card's controls. The connect page — where Connect a client and "Create new" both lead — and the button that opens it follow index?, not create? or new?: the page creates nothing, so create? = false keeps it reachable for anyone who may see the list. To keep the resource off the sidebar, set Avo::Resources::McpConnection.visible_on_sidebar = false in a to_prepare block.

Each card below the fields has an optional method of its own, and the four view_ methods fall back to show?, so a policy that defines none gives the whole page to anyone who may open it. Changing the grant is a separate question that does not fall back. They are separate because the cards disclose different things:

MethodCardWhat it discloses
view_log?Log, and the endpoint the page pollsData about your own records — tool arguments, record ids, search terms.
view_audit_trail?Audit log (with Audit Logging installed)What the client changed, and the titles of the records it changed. Asked again by the table's own endpoint, so one method covers both.
view_entitlements?EntitlementsThe connection owner's reach. On another admin's connection it names resources the reader's own policies may hide.
view_tools?ToolsTool names, derived from the registry and the grant. Nothing beyond them.
edit_entitlements?Entitlements — the controls, and the request Apply sendsChanging the connection owner's reach. Optional, but a host with a policy must define it to enable editing: denied or undefined, the card renders read-only and says so. Editing implies view_entitlements? — nobody may change what they may not see.

Refusing one leaves the rest of the page intact.

Turn MCP on or off per admin ​

use? on the same policy decides whether an admin can use the MCP server. Key it off a role, a plan, or a feature flag:

ruby
# app/policies/avo/mcp_server/connection_policy.rb
class Avo::McpServer::ConnectionPolicy < ApplicationPolicy
  def use? = Flipper.enabled?(:mcp, user) 

  # ...the rest of the policy
end

Its record is always the Avo::McpServer::Connection class, so key it off user, not a tenant or other request state, which isn't set on every path it runs on. If it raises, say because a flag store is down, the server answers 500 and leaves the client's tokens alone, and the connections list errors too until it's back, since its connect button asks. An admin it refuses:

  • can't open the consent screen, which tells them the server is turned off for their account, so no connection or code is created;
  • can't open the connect page, and the buttons that lead there are hidden;
  • can't use the connections they already have, from their next call: the token answers 401 invalid_token, and the client's refresh and any code it hasn't exchanged yet are refused with invalid_grant. The refused code or refresh token isn't spent, but a client may discard a token answered with invalid_grant, and then it has to connect again once MCP is back on.

use? only decides whether they can use the server. It doesn't hide anything else: the MCP connections menu entry and pages keep following index? and show?, so the admin can still see their connections and revoke them, and a client can still revoke its own token. To take the entry away too, refuse index? for the same admins.

Every ConnectionPolicy needs use?

The server asks use? the way Avo asks any policy method, so a ConnectionPolicy without it turns MCP off for every admin. That is on purpose: a typo in a switch that guards access should refuse, not allow. If you already have a ConnectionPolicy, add def use? = true when you upgrade to keep MCP on for everyone. With no ConnectionPolicy at all, Avo's defaults apply: on under explicit_authorization = false, and off under explicit_authorization = true, which is Avo's default. So an app that never wrote one adds it with use? to keep MCP on. use? needs an authorization client: with config.authorization_client = nil no policy runs, so the server stays on and nobody is blocked. With avo-permissions as the client, no policy is asked: a role needs a use? permission on Avo::Resources::McpConnection. The permissions screen doesn't offer it yet, so add it from a console as the upgrade guide shows. Under that client the server then answers, but a call that names a resource is refused and list_resources answers empty, because its policy checks don't pass the client the resource its rows match on.

Run it in production ​

Pin the server address ​

By default the server's address is whatever origin each request arrives on, plus the mount path. If your panel answers at more than one origin, or a proxy hides the public one from the app, pin resource_identifier to the one public URL every surface should read:

ruby
# config/initializers/avo.rb
Avo.configure do |config|
  config.mcp_server.enabled = true
  config.mcp_server.resource_identifier = "https://app.example.com/avo/mcp"
end

Its path must match the path you mount at, or boot raises. Once pinned, a request arriving at any other origin is answered 421.

Every issued token is bound to the address it was minted under, so changing the address is a migration: every live connection's next call is refused with 421, and admins reconnect through the authorize page. Announce it.

Behind a proxy or load balancer ​

A TLS-terminating proxy that doesn't pass the original scheme and host through leaves the app seeing http and an internal hostname — and plain http is refused outside localhost, because tokens would travel in the clear. Forward X-Forwarded-Proto and X-Forwarded-Host, allow the public hostname in config.hosts, or pin the address.

Rate limits ​

The JSON-RPC endpoint allows each connection 300 calls a minute, answering 429 with Retry-After over it. Raise tool_calls_per_minute for a heavy agent:

ruby
# config/initializers/avo.rb
config.mcp_server.tool_calls_per_minute = 600

The authorize, token, and registration endpoints have fixed per-IP limits. All of them count through Rails.cache: on :null_store nothing is counted, and on a per-process store each worker counts on its own. Use a shared store such as Redis or Memcached in production.

Turn it off without losing the connections resource ​

config.mcp_server.enabled = false answers 404 on every protocol endpoint and the consent screen, but the connections resource stays — it's part of the panel, not the server — so you can still revoke during an incident. If enabled is true but the routes aren't mounted, the server logs a warning at boot and the connections resource says so. To turn it off for some admins only, see use?.

Keep credentials out of your logs ​

The engine filters the OAuth parameters (code, code_verifier, refresh_token, access_token, client_secret) from your request logs on its own. The _meta object some clients attach to each call is left in, and ChatGPT puts the end user's coarse location in it. To drop it:

ruby
# config/initializers/filter_parameter_logging.rb
Rails.application.config.filter_parameters += [:_meta]

Trace changes back to a connection ​

The Log card tells you what a client asked for. With Audit Logging installed, the panel also tells you what it changed, and keeps that after the log has rolled over.

Every create, update, delete and action run a connection makes is recorded as an activity, attributed to the admin who authorized the connection — they are the one whose permissions it acted with — and marked with where it came from:

ColumnValue
authorThe admin who authorized the connection
origin"mcp_connection" (a change made in the panel has no origin)
origin_recordThe connection itself

That shows up in three places:

  • The record's own timeline continues the author's name with it: Ada Lovelace through MCP connection.
  • The activity's page has an Origin field — MCP connection — Claude — the whole of which links to the connection.
  • The connection's page has an Audit log table — Audit Logging's own activity table, the same one its docs put on a user's resource — listing what this client changed, each row linking to its entry.

The table is there only when Audit Logging is installed and switched on. Upgrading from a version before this? Run bin/rails generate avo:audit_logging upgrade, then bin/rails db:migrate — until then changes are still recorded, just unmarked, and the table is not offered.

Two things worth knowing:

  • Audit Logging's own opt-in still decides. A resource whose audit_logging switch is off records nothing, whoever made the change. See Enable specific resources and actions.
  • Clients can't reach the audit log. Avo::AuditLogging::Activity is never offered as an MCP resource, so a connection can't read, edit or delete the record of what it just did — even in an app whose policies would otherwise allow it.

WARNING

The connection log and the audit log answer different questions. The log holds every request, including reads and refusals, and keeps only the newest connection_log_size rows per connection. Audit entries cover only changes, and are never pruned. Investigating an incident usually means reading both.

Triage errors a client reports ​

Refusals come back as JSON-RPC errors with a numeric code, a message, and a data object. The full table is in the reference; these three cover most reports:

CodeMeansFirst thing to check
-32000Capability not grantedThe connection's Entitlements — grant what was withheld
-32001Capability granted, the admin's policy said noYour policy for that admin
-32002Authorization isn't being enforced, nothing ranThe license — both add-ons must be enabled

A token that was working and now answers 401 invalid_token, with its refresh refused as invalid_grant, on a connection that still reads Open, usually means your ConnectionPolicy#use? refuses that admin, the policy has no use?, or, under explicit_authorization = true, you have no ConnectionPolicy at all. See Turn MCP on or off per admin.

A tool of your own refused with a bare requiredCapability (no resource) while the connection holds that capability narrowed, say avo:read with avo:read:Post granted, has no resources declaration and the call sent no resource argument. Declare them rather than widening the grant.

A record outside the admin's policy scope reports as not found (-32602), identical to an id that never existed, so a read tool can't be used to probe for hidden records. A resource they may not list reports as unauthorized (-32001) and says so, because the caller already sees the sidebar.

Full example ​

ruby
# config/routes.rb
Rails.application.routes.draw do
  mount_avo_mcp_server at: "/avo/mcp"

  authenticate :user do
    mount_avo
  end
end
ruby
# 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
  config.mcp_server.extra_tools = ["IssueInvoiceTool"]
end
ruby
# app/policies/avo/mcp_server/connection_policy.rb
class Avo::McpServer::ConnectionPolicy < ApplicationPolicy
  def use? = true

  def index? = true

  def show? = record.user == user

  def act_on? = record.is_a?(Class) || record.user == user

  class Scope < ApplicationPolicy::Scope
    def resolve = scope.where(user: user)
  end
end

This is the Pundit setup: every admin can use the server, and sees and revokes their own connections. With config.authorization_client = nil, leave the policy out. With avo-permissions, add the use? row from step 5 instead.

Options reference ​

OptionTypeDefault
enabledBooleanfalse
resource_identifierStringnil
tool_calls_per_minuteInteger300
connection_logBooleantrue
connection_log_sizeInteger500
extra_toolsArray[]

The route helper, the two generators, the ten tools and their arguments, the capabilities, every error code, and the policy methods are in the MCP Server reference.