MCP surface
Audience: authors integrating Aperture into an MCP client (an AI assistant or agent runtime) as a tool provider.
Aperture exposes its decision API and model inspection as
Model Context Protocol tools. An MCP client
spawns aperture mcp as a subprocess, speaks MCP over stdio, and calls the
Aperture tools the same way it calls any other tool server.
Read-only by construction
The MCP surface is read-only. Every tool calls a read or decision method
on the single service.Service facade — Check / Enumerate / Explain,
the what-if Simulate, and the Get* / List* inspectors. No tool mutates.
No tool name carries a mutating verb (put / create / add / set /
delete / remove / update / write / bestow / revoke / grant / …),
and a test (mcp.TestNoMutatingTool, mirrored at the wire level in the adapter)
fails if one ever does. The aperture mcp command deliberately wires the facade
with storage for inspection and what-if reads but not the gate, delegation,
or impersonation mutators, so the surface cannot write even by accident.
Everything on the surface is a thin translation onto the same facade the CLI and the HTTP/Twirp surface drive — one decision engine, one code path. If a behaviour is not described here it is governed by the facade and documented under The service facade.
The tool catalog
The catalog is stable and ordered. Tool names are aperture_-prefixed,
snake_cased, and defined once in mcp/toolmeta (the single source of truth for
tool identity), so the SDK-free core and the SDK adapter never drift.
Decision API (single + bulk)
| Tool | Purpose | Maps to |
|---|---|---|
aperture_check | Decide whether a principal may take an action on an object, scoped to an account. Returns the verdict (allow/deny), a human-readable reason, and the deciding grant ids. Fail-closed: an operational failure renders as a deny, not an error; only an ill-formed question is an error. | service.Check |
aperture_check_batch | Decide many (account, principal, action, object) questions in one round-trip; results[i] answers queries[i]. A single ill-formed query carries its error in that item without failing the batch. | service.CheckBatch |
aperture_enumerate | List the object ids under a pattern a principal may act on — the inverse of aperture_check. Deny-overrides and specificity are honoured, so a denied object is never returned. | service.Enumerate |
aperture_enumerate_batch | Enumerate accessible objects for many queries in one round-trip, aligned with the input queries. | service.EnumerateBatch |
aperture_explain | Return the full structured decision trace for one question: the expanded subject set, every grant considered with its per-grant outcome, which grants decided, and the final verdict. Use to understand why. | service.Explain |
aperture_explain_batch | Return decision traces for many questions in one round-trip, aligned with the input queries. | service.ExplainBatch |
What-if simulation (read-only, never persisted)
| Tool | Purpose | Maps to |
|---|---|---|
aperture_simulate | Render the full decision trace for a question as it would be under a hypothetical overlay of principals, groups, permissions, grants, and memberships — without writing anything. The overlay is additive; an overlay entity with the same id as a stored one shadows it. Nothing is persisted and nothing is audited. | service.SimulateExplain |
Model inspection
| Tool | Purpose | Maps to |
|---|---|---|
aperture_list_object_types | List every object type with its declared action verb set. | service.ListObjectTypes |
aperture_get_object_type | Fetch one object type by name. | service.GetObjectType |
aperture_list_permissions | List every permission (object-type, action, scope-strategy, delegatable flag). | service.ListPermissions |
aperture_get_permission | Fetch one permission by id. | service.GetPermission |
aperture_list_roles | List every role (named permission bundles). | service.ListRoles |
aperture_get_role | Fetch one role by id, including its permission bundle. | service.GetRole |
aperture_list_groups | List every group (collections of principals usable as grant subjects). | service.ListGroups |
aperture_get_group | Fetch one group by id, including member principal ids. | service.GetGroup |
aperture_list_principals | List every principal (user or machine) with assigned role ids and identity strings. | service.ListPrincipals |
aperture_get_principal | Fetch one principal by id, including assigned roles. | service.GetPrincipal |
aperture_list_grants | List every grant stamped to an account. Account-scoped: a grant in another account is never returned. | service.ListGrants |
aperture_get_grant | Fetch one grant by id (subject, permission, object pattern, effect, account). | service.GetGrant |
Surface documentation
| Tool | Purpose | Maps to |
|---|---|---|
aperture_skills_list | List the embedded skill docs describing how the decision, simulate, and inspection tools fit together. | mcp/skills.List |
aperture_skills_get | Fetch the markdown body of a named skill doc (e.g. mcp-surface), the authoritative reference for driving the surface. | mcp/skills.Get |
Grants are account-scoped by design: aperture_list_grants requires an
account argument and never returns another account’s grants, so the surface
cannot leak cross-account data.
Inputs, outputs, and errors
Each tool carries an input and output JSON Schema (draft 2020-12),
reflected at package-init time from the typed Go contract in mcp/schema.go and
mcp/contract.go. The In/Out types alias the facade’s own surface-neutral types
(for example aperture_check’s input is service.Query and its output is
service.Result), so the schema advertised to the client is exactly the shape
the facade decides on. A client discovers these schemas through the standard MCP
tools/list call — no Aperture-specific schema fetch is needed.
Argument handling and error surfacing:
- Parameterless tools (the
list_*inspectors,aperture_skills_list) accept an empty argument blob. - A missing required argument (for example
idon aget_*tool) returns a plain validation tool-error, surfaced to the model so it can self-correct — it is intentionally not a coded error. - A coded facade error (an
APERTURE_*error) is returned verbatim; the adapter renders it as a structured{code, message, details}envelope rather than a flattened string. - In the batch tools, a per-item failure is folded into that item’s
errorfield (a string); the rest of the batch is unaffected.
The mcp/ core and the SDK firewall
The core lives in the root mcp/ package and is SDK-free: it imports no MCP
protocol SDK. Its files divide the surface cleanly:
| File | Role |
|---|---|
mcp/toolmeta/ | Leaf, pure-data package: the canonical (name, description) table for every tool. Imported by both the core and the adapter so the two never drift. |
contract.go | The typed In/Out structs for every tool (aliasing the facade’s service / engine / model types). |
schema.go | Reflects an input + output JSON Schema for each contract type at init, carried as json.RawMessage. |
handlers.go | One typed func(ctx, *service.Service, In) (Out, error) per tool; each calls exactly one facade read/decision method. |
tools.go | Type-erases handlers into a ToolDescriptor catalog (Tools(cfg)), pairing each name with its reflected schemas and an Invoke closure. |
The core depends only on the decision facade (service), the domain types it
returns (engine, model), the coded-errors package (errors), the embedded
skill docs (mcp/skills, stdlib-only), and the schema reflector
(google/jsonschema-go). It carries the schema as json.RawMessage
specifically so a consumer can import the contract without pulling in any MCP
SDK.
The adapter is the only SDK importer
The one package permitted to import the protocol SDK
(github.com/modelcontextprotocol/go-sdk) is the thin mcp/gosdk adapter.
gosdk.Register(server, svc, cfg) mounts the core’s ToolDescriptor catalog
onto a caller-supplied go-sdk server via the low-level Server.AddTool path —
which accepts any value that marshals to a valid 2020-12 schema, exactly the
json.RawMessage the core emits. Register mounts onto the server it is given;
it never constructs, serves, or owns the server lifecycle. An embedder that
already runs its own go-sdk server can mount the full Aperture surface the same
way.
firewall_test.go makes the guarantee load-bearing
mcp/firewall_test.go enforces the boundary so it is a fact, not an aspiration:
TestMCPCore_NoSDKImportrunsgo list -depsover the core packages (mcp,mcp/toolmeta,mcp/skills) and fails if the MCP SDK module appears anywhere in their transitive dependency graph. Moving an SDK import into any core package makes this test fail with the exact offending dependency line.TestMCPCore_AllowedDepsReachableasserts the documented allow-list (service,engine,model,errors,mcp/skills,mcp/toolmeta,google/jsonschema-go) is reachable from the core, proving the firewall is inspecting a real, populated graph rather than passing vacuously.
The adapter (mcp/gosdk) is deliberately excluded from the firewall’s package
list — the SDK is allowed there and nowhere else. This keeps the promise a client
author relies on: importing the Aperture MCP contract never drags in a protocol
SDK.
Running it
aperture mcp hand-wires the read-only dependency graph
(storage → engine → service), constructs the go-sdk server, mounts the SDK-free
catalog through the gosdk adapter, and serves over stdio — the transport an
MCP client uses when it spawns Aperture as a subprocess:
aperture mcp [--seed <path>] [--store <dsn>]
--seed— path to a JSON/YAML seed model (defaults to the embedded example).--store— sqlite DSN for the backing store (defaults to in-memory).
With neither flag it serves the embedded example model over an in-memory store. There are no other flags: the surface is read-only by construction, so it needs no acting principal, account, or auth adapter.
On start it prints one line to stderr — stdout is reserved for the MCP protocol:
aperture mcp: serving read-only MCP surface over stdio
Connecting a client
You normally don’t launch aperture mcp interactively; an MCP client spawns it
over stdio. A minimal client configuration points at the binary and the model to
serve:
{
"mcpServers": {
"aperture": {
"command": "bin/aperture",
"args": ["mcp", "--store", "./aperture.db"]
}
}
}
The server advertises its identity as aperture with the binary’s build version
during the MCP initialize handshake, then answers tools/list with the full
catalog above and tools/call for each tool.
Related
aperture mcp— the command-level reference and flags.- The service facade — the one code path behind every tool.
- Decision API — the same check/enumerate/explain questions from Go.
- RPC / HTTP overview — the read/write Twirp surface served by
aperture serve.