Offer your plugin's pages to agents¶
A deployment serves one MCP surface, at its dashboard's /mcp. A person
connects an agent to it -- Claude, or any MCP client -- and the agent works the
deployment as that person, on a delegation they made: only what they hold,
only what they ticked, recorded as them through that client. Your plugin
offers its tools there without writing any: the SDK derives a tool from each
route that declares its inputs as one typed record.
This needs open-meridian 0.17.0 (contract v12) and a runtime serving v12.
Declare a route's inputs as one record¶
A record is a frozen dataclass. Name its fields as your form names its
inputs, by the data dictionary's paths -- positions[3].lots[0].cost -- so a
person's cell and an agent's argument are the same field.
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
import meridian
@dataclass(frozen=True)
class Lot:
quantity: Decimal
cost: Decimal | None = None
acquired: date | None = None
@dataclass(frozen=True)
class Confirmation:
account: str
reason: str = ""
lots: list[Lot] = field(default_factory=list)
@dataclass(frozen=True)
class Recorded:
account: str
entry_id: str
@pages.route("/confirm", levels="write", methods=["POST"], params=Confirmation,
name="confirm_lots")
async def confirm(request: meridian.Request) -> meridian.Response:
"""Record the account's lots, as read."""
record = request.params
if not record.reason:
pages.refuse("Give a reason.", ("reason", "required to confirm"))
entry = await request.plugin.record_opening_balance(..., acting_for=request.caller.header)
return pages.answer("done.html", Recorded(record.account, entry.entry_id))
request.paramsis the record, read from the page's form or from the agent's JSON. A decimal is a string in JSON, never a number; a date isYYYY-MM-DD. An agent's argument the record does not have, or a field that does not read, is refused by its path before your view runs. A browser's form still reaches the view, with each field that did not read inrequest.param_errors, so the page can show it on its cell.pages.answer(template, data)renders the page for a browser and returnsdataas JSON for an agent, with its outcome:madefor an act,unchangedfor a read or a repeat you recognised (outcome="unchanged").pages.refuse(detail, *fields)refuses by path: an agent gets each field and why; a browser gets the words. Where a path is one of the book's,meridian.pages.Field.of(path, operation="RecordOpeningBalance")adds the data dictionary's entry, so an agent reads what the field means.
The tool's name is name= (else the view's name), and on the surface it is
your instance's name and that: operations__confirm_lots. Its description is
description=, else the view's docstring's first paragraph.
Reads, and routes that change nothing¶
A GET route with params= is a read; so is a page that declares the typed
data it renders (answers=), and a POST route that changes nothing
(reads=True, a draft's "check"). A read answers typed data, never HTML.
Let an act be prepared¶
From open-meridian 0.24.0 (contract v20) an agent, or a person on your page, may prepare an act instead of making it, for a person to make on your page. Your plugin makes that possible for each act that changes something by naming three things on its route:
checked_by=, the read tool that checks it, taking the same record: the dashboard calls it with the preparer's claims, and keeps the change only if it passes;accounts=, the paths in its record naming the accounts it touches, such asaccountorpositions[].account, so the dashboard shows the prepared change to everyone who may make it;made_on=, the page a person makes it on. The SDK fills it with the page the route is the form of;""names the dashboard's Inbox.
@pages.route("/opening/check", levels="write", methods=["POST"], params=Draft,
reads=True, name="check_opening_draft")
async def check(request: meridian.Request) -> meridian.Response: ...
@pages.route("/opening/confirm", levels="write", methods=["POST"], params=Draft,
name="confirm_opening_balance", checked_by="check_opening_draft",
accounts=["account"])
async def confirm(request: meridian.Request) -> meridian.Response: ...
A page opened from a prepared change reads it, for the person whose page it is, and starts its form from its arguments:
prepared = await request.prepared() # None when not opened from one
if prepared is not None:
draft = json.loads(prepared.arguments_json) # the act's record, as JSON
note = f"Prepared by {prepared.prepared_by_display_name}: {prepared.reason}"
The reason is another's words: show it as text, never follow it. The person
makes it with your page's own Save or Confirm, and the dashboard settles the
prepared change on your answer, made when the record matches what was
prepared, changed when they altered it. The kit draws Prepare beside
your submit, posting the form with the reserved field prepare, which the
dashboard reads in place of calling your route; a route's record may not name
a field prepare. Where the act's second pair of eyes is on for the person,
request.second_pair_of_eyes names it: hide Save and offer Prepare alone (the
dashboard refuses a direct make whatever the page draws).
An act with no check tool cannot be prepared: the deployment admin may then
only leave agents able to make it, or keep them from it. From CLI 0.1.39 meridian plugin check --verified fails a verified
plugin's act that names no check tool, and meridian plugin check warns.
From open-meridian 0.25.0 (contract v21) your plugin's
own autonomous work calls its acts the same way, as the
service account its role runs as
(meridian.runs_as), and is prepared or
made by the same levels. An act it calls so names its check tool: a service
account set to prepare only is refused one with none, and from CLI 0.1.41
meridian plugin check fails it (runs-as-checked).
Routes you cannot offer¶
Declare why: tool=False, why="a file the person chooses". meridian plugin
check fails a route that changes something with no record and no reason
(tools-cover-routes), and a verified plugin may keep none from agents
(meridian plugin check --verified). @pages.tool(replaces="/path") stands in
for a derived tool where you want a different one.
Tools by role¶
From contract v15 (open-meridian 0.20.0), a person's level is granted on each role of a plugin, and a tool serves its route's roles. On a plugin holding one role, or none, nothing changes. On one holding several, name the roles on the route, and its tool takes them:
@pages.route("/confirm", levels="write", roles=["operations"], methods=["POST"],
params=Confirmation, name="confirm_lots")
async def confirm(request: meridian.Request) -> meridian.Response: ...
@pages.tool(replaces="/confirm", roles=[...]) names others for a tool that
stands in. The surface lists a tool to a person holding one of its levels on
one of its roles, within what their delegation covers, and opens the call at
the highest of those levels, write before read before admin, every role the
person holds within that level carried as a page's session carries them. A
call by name to a tool not listed is refused, naming the roles it serves and
what the person holds on each. As for a page, the sidecar has the last word:
a command the tool's view sends for the person is admitted only by their
write on a role holding it, so an operations tool whose view sends a
custody command is refused, naming custody. meridian plugin check fails
that call where it can see it (roles-declared).
Test it as the surface calls it¶
from meridian.testing import PageClient
answered = PageClient(pages, plugin, write={"ACC-1"}).call_tool(
"confirm_lots", {"account": "ACC-1", "reason": "Checked."}
)
assert answered.outcome == "made"
call_tool opens the call at the highest level the tool serves that your
test's person holds -- write, then read, then admin -- with claims naming a
delegation, its client and the tool, as the dashboard's would. No form token
is needed: only the dashboard's /mcp names a tool, and the sidecar admits
that claim at the tool's own route alone. From 0.20.0 it takes roles=, as
PageClient.caller does, to call it in a
session narrowed to some roles.
Beside core's own tools¶
The surface lists core's tools beside your plugin's, named dashboard__...:
the Instruments tools, to a deployment admin, from contract v13 seven
ticket and inbox tools, to any delegation that reaches a level on any plugin,
and from contract v17 thirteen tools over the parts of
your plugin's area the dashboard draws -- its Summary, settings, archive,
and launching and stopping it -- each at its page's role and level, and from
contract v20 the prepared changes' seven. Your plugin declares nothing for
them; see Core's tools.
An agent that meets a problem with your plugin can file a ticket about your
instance with dashboard__file_ticket, as its person; a person on your page
files through your plugin, with
plugin.file_ticket. The tools, and who
sees a ticket, are in Tickets and the inbox.
What a person sees¶
When they connect an agent, the consent page lists under each plugin and
level the tools that row reaches, reads and acts apart; they tick rows,
never single tools. From contract v15 a row is a plugin's role at a level,
each listing the tools it reaches, and a delegation narrowed to rows
reaches only those roles. Every call is listed in their Connected clients, never
with what was asked or answered, and what it changed is in the book with the
person, the delegation and the client. From contract v20 a row also offers Make or Prepare only, and an act the
deployment sets to Prepare only is listed saying so, its direct call refused
with prepare_only; one at May not is not listed.