Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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)

ToolPurposeMaps to
aperture_checkDecide 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_batchDecide 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_enumerateList 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_batchEnumerate accessible objects for many queries in one round-trip, aligned with the input queries.service.EnumerateBatch
aperture_explainReturn 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_batchReturn decision traces for many questions in one round-trip, aligned with the input queries.service.ExplainBatch

What-if simulation (read-only, never persisted)

ToolPurposeMaps to
aperture_simulateRender 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

ToolPurposeMaps to
aperture_list_object_typesList every object type with its declared action verb set.service.ListObjectTypes
aperture_get_object_typeFetch one object type by name.service.GetObjectType
aperture_list_permissionsList every permission (object-type, action, scope-strategy, delegatable flag).service.ListPermissions
aperture_get_permissionFetch one permission by id.service.GetPermission
aperture_list_rolesList every role (named permission bundles).service.ListRoles
aperture_get_roleFetch one role by id, including its permission bundle.service.GetRole
aperture_list_groupsList every group (collections of principals usable as grant subjects).service.ListGroups
aperture_get_groupFetch one group by id, including member principal ids.service.GetGroup
aperture_list_principalsList every principal (user or machine) with assigned role ids and identity strings.service.ListPrincipals
aperture_get_principalFetch one principal by id, including assigned roles.service.GetPrincipal
aperture_list_grantsList every grant stamped to an account. Account-scoped: a grant in another account is never returned.service.ListGrants
aperture_get_grantFetch one grant by id (subject, permission, object pattern, effect, account).service.GetGrant

Surface documentation

ToolPurposeMaps to
aperture_skills_listList the embedded skill docs describing how the decision, simulate, and inspection tools fit together.mcp/skills.List
aperture_skills_getFetch 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 id on a get_* 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 error field (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:

FileRole
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.goThe typed In/Out structs for every tool (aliasing the facade’s service / engine / model types).
schema.goReflects an input + output JSON Schema for each contract type at init, carried as json.RawMessage.
handlers.goOne typed func(ctx, *service.Service, In) (Out, error) per tool; each calls exactly one facade read/decision method.
tools.goType-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_NoSDKImport runs go list -deps over 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_AllowedDepsReachable asserts 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.