MCP tools

Give an agent the same operations the dashboard has, with an API key that bounds what it may touch.

@davidapps/mcp is a local stdio server that gives an agent the same operations you have. It opens no database connection: each tool turns into exactly one authenticated call to the HTTP API. Public fields come back to the agent, while one-time values are written to a local owner-only store and represented by an opaque reference.

That one-for-one shape is the point. Anything an agent can do here, you can do in the dashboard and with a plain HTTP call. The scope a tool needs is the scope its operation needs, and every call lands in History the same way.

Install it

The package is built and install-verified but not published to a registry yet. Install it from this workspace, or from a tarball you packed yourself, into the project where the agent runs. Once it is published, the usual install will work; until then, treat a registry install as unavailable rather than as something to work around.

Configure a client

The command is davidapps-mcp. It reads the API endpoint and key from its environment. Secret-capable operations additionally require an absolute local directory:

DA_BASE_URL=https://id.davidapps.dev
DA_API_KEY=dak_...
DA_SECRET_DIR=/absolute/owner-only/directory

Keep the key in your client's secret configuration. The directory must be a real, non-symlink 0700 directory owned by the current user. Input references must name 0600 regular files in that directory; output files are created as exclusive 0600 files. Do not commit them, paste a key into a prompt, or ask an agent to open an opaque output reference.

On a Mac with this workspace checked out, the repository also provides a user-wide setup for Codex and Claude Code:

pnpm davidapps:agent plan
pnpm davidapps:agent install
pnpm davidapps:agent doctor

It links the three checked-in setup skills, registers the local command, and reads the API key from macOS Keychain only when the command starts. plan makes no changes. install also creates the owner-only local store. doctor verifies its ownership and permissions, the public issuer, and the exact stdio tool contract — one tool per operation — without displaying a credential or making a write request. The operator runbook in the repository records the Keychain item name and conflict rules.

Create keys on Settings → API keys. The secret is shown once, at creation, and cannot be retrieved afterwards. Give a key the fewest scopes it needs, and bind it to a single app unless it genuinely has to work across all of them. A key bound to one app that is used for another, or for an operation that is not about a specific app such as setup_app, is refused with API_KEY_FORBIDDEN by the HTTP API. MCP reduces either case to its generic API failure, so a rejected tool call tells the caller nothing about what exists.

The tools

One tool per operation. Each advertises the scope it needs in its davidapps.dev/requiredScope metadata, and the API enforces that scope regardless of what the client believed.

ToolScopeOperation
setup_appapps:writePOST /api/v1/apps/setup
grant_accessaccess:writePOST /api/v1/apps/{appId}/access/grants
remove_accessaccess:writeDELETE /api/v1/apps/{appId}/access/grants
create_groupaccess:writePOST /api/v1/groups
add_to_groupaccess:writePOST /api/v1/groups/{groupId}/members
list_group_membersaccess:catalog:readGET /api/v1/groups/{groupId}/members
set_group_sharingaccess:writePUT /api/v1/groups/{groupId}/sharing
grant_cross_org_accessaccess:writePOST /api/v1/apps/{appId}/access/cross-org-grants
list_cross_org_accessaccess:catalog:readGET /api/v1/apps/{appId}/access/cross-org-grants
revoke_cross_org_accessaccess:writeDELETE /api/v1/apps/{appId}/access/cross-org-grants/{grantId}
register_app_moduleapps:writePOST /api/v1/apps/{appId}/modules
list_app_modulesapps:readGET /api/v1/apps/{appId}/modules
remove_app_moduleapps:writeDELETE /api/v1/apps/{appId}/modules/{moduleKey}
create_share_linkgrants:writePOST /api/v1/apps/{appId}/grants/share-links
rotate_secretgrants:writePOST /api/v1/apps/{appId}/grants/{grantId}/rotate
get_ingress_snippetapps:readGET /api/v1/apps/{appId}/ingress-snippet
resolve_accessapps:readPOST /api/v1/apps/{appId}/access/resolve
read_auditaudit:readGET /api/v1/audit
read_app_directorydirectory:readGET /api/v1/apps/{appId}/directory
read_access_catalogaccess:catalog:readGET /api/v1/access/catalog
read_access_matrixaccess:catalog:readPOST /api/v1/access/matrix
read_saml_connectionapps:readGET /api/v1/apps/{appId}/saml/connection
import_saml_connectionapps:writePUT /api/v1/apps/{appId}/saml/connection
set_saml_connection_stateapps:writePUT /api/v1/apps/{appId}/saml/connection/state
read_saml_request_signingapps:readGET /api/v1/apps/{appId}/saml/request-signing
update_saml_request_signingapps:writePUT /api/v1/apps/{appId}/saml/request-signing
set_app_credentialapps:writePUT /api/v1/apps/{appId}/credentials/{provider}
get_user_provider_tokenvault:readPOST /api/v1/apps/{appId}/vault/token

list_group_members answers "who is in this group" without a database, which is what a scheduled reconciler on another cluster needs. An organization-global key may name the group as name:<group> and receives each member's platform user id. An app-bound key receives that app's own pairwise:<subject> for each member instead, and can read only the groups delegated to it as directories. Members are returned with a verified email or none at all, and a group tied to an email domain only ever reports identities at that domain.

resolve_access follows the same rule. An organization-global key may name a person by email:, member:, pairwise:, or their platform user id, and reads back the platform identity plus the structured reasons chain that names the deciding groups. An app-bound key may name a person only by pairwise:<subject> and reads back that subject, the identity selected for that app, and nothing else — no platform user id, no platform email, and no group names. Both get reasonCodes, a closed vocabulary that says which kind of row settled the answer: direct_allow, direct_deny, group_allow, group_deny, organization_role_allow, organization_role_deny, organization_role_implicit_admin, cross_org_group_allow, cross_org_group_deny, cross_org_direct_deny, role_unsettled, and no_matching_grant for the person no row mentions. There is no cross_org_direct_allow: a rule may only ever deny one named person in another organization, so the code would advertise an outcome nothing can produce.

A person in a partner organization is a third case. They hold no membership in the application's organization, so organizationRole is null and guestOrganizationIds names the organizations a deciding rule reached them through. They are nameable only by pairwise:<subject> — an opaque digest the application already minted for them, so holding it is evidence of a relationship, where email: would be a way to ask whether a stranger exists — and they are named back the same way whichever key asked. A host organization never receives a partner's platform user id or platform email, and a cross_org_group reason carries the label the partner published rather than its own name for the group.

setup_app decides how wide a manager application's delegation is. managedApplicationSlugs enumerates the applications it may provision access to, and managedApplicationScope chooses how that list is read:

{
  "slug": "employee-hub",
  "name": "Employee Hub",
  "mode": "oidc",
  "signupPolicy": "closed",
  "hostnames": ["hub.bostanenterprise.com"],
  "managedApplicationScope": "organization",
  "managedDirectoryTeamIds": ["team_bostan"]
}

explicit, the default, is the enumerated list and nothing else, so an application added to the organization later stays invisible until someone calls setup_app again with a longer list. organization is every other application in the same organization, resolved on each read rather than copied into rows, so a new application appears in read_access_catalog with no second call. Both require an organization-global key to set, and the change is recorded as access.management.configured with the scope in its before and after. Widening the scope does not delete the enumerated list: narrowing back to explicit restores it.

Only the breadth of the list changes. A wider manager still names every application and person by the opaque managed: reference the catalog minted for that exact key, and it still reaches neither another organization nor itself. Directories stay enumerated: managedDirectoryTeamIds has no organization-wide form, because a group is a list of people, and a new group should not become readable to an application without someone saying so.

grant_access may attach up to 32 enabledModules identifiers to an allow grant. A user's matching direct, group, and organization-role allow grants are combined into one sorted module set and projected in the signed application claim set. An app-bound key can use pairwise:<subject> from that app's directory response to update a person without receiving the global user ID.

An app must declare a module before a grant may enable it. This is the pair an agent uses after it provisions something inside the service itself — a Grafana folder, a Plausible site, an admin section — so that the thing it just made becomes a real toggle:

{
  "appId": "slug:employee-hub",
  "key": "grafana_ops",
  "label": "Grafana operations folder",
  "description": "Dashboards and alert rules for the ops team"
}

register_app_module is an upsert on key, so a provisioning script is safe to re-run: the same key with the same label reports changed: false. The key is what the app itself checks for and cannot be edited; the label and description only change what the dashboard shows. An app may declare at most 64 modules, so list_app_modules returns the whole registry without a cursor.

grant_access refuses a module the target app has not declared, with CONTROL_PLANE_UNDECLARED_MODULE and status 409 — a typo can no longer produce a module nothing will ever honour. Two things deliberately still work: omitting enabledModules carries the stored set forward unchecked, and a key a subject's current rule already holds may be re-sent. Grants written before an app declared anything keep resolving exactly as before; only new module names are validated. remove_app_module is refused with CONTROL_PLANE_MODULE_IN_USE while any rule still enables the module, so switching a capability off for people stays a separate, visible decision.

Modules an app has declared appear as per-rule toggles on Who can open it and on Groups, and the app's own What it offers screen is where they are declared by hand.

Giving another organization access

Two organizations, two halves. Nothing here lets one organization read the other's directory, and neither half works without the other.

The organization that owns the people goes first. set_group_sharing publishes one of its own Groups as nameable by somebody else and returns a code:

{
  "groupId": "name:Bostan Enterprise Employees",
  "shared": true,
  "sharedLabel": "Bostan Enterprise Employees"
}

The code is the only way the other organization can name that Group, which is why there is no lookup, no search, and no listing. Without it there is nothing to guess; with it there is exactly one Group to name and nothing else to learn. sharedLabel is what the other organization is shown instead of the Group's real name, so even the name is published on purpose rather than by default. The code is secret material: it is written to DA_SECRET_DIR as an owner-only reference and handed over out of band, never pasted into a prompt. rotate: true mints a new one and stops the old one working, without touching access already given. shared: false withdraws consent and revokes every rule the code authorized, everywhere.

The organization that owns the app imports the transferred consent from its Keychain without displaying it:

pnpm davidapps:agent input-ref --keychain-service=partner-consent --keychain-account=partner --format=group-share

Pass the returned sharedReferenceRef to grant_cross_org_access. The input file is consumed only after a successful API write:

{
  "appId": "slug:grafana",
  "sharedReferenceRef": "input-00000000-0000-4000-8000-000000000003",
  "role": "member",
  "effect": "allow",
  "enabledModules": ["graf_bostan_analytics_edit"]
}

grant_cross_org_access requires an organization-global key. It is deliberately not reachable through delegated application management, because configuring one manager application must never quietly hand it the right to let outsiders into every app in its organization. The same rule holds in the dashboard: the section appears only for whoever administers the organization, not for whoever runs the app.

enabledModules is checked against the HOST app's registry, never the other organization's. A module name they declared on an app of their own is worthless here, which is what stops the capability list becoming something they can extend.

Partner allows currently work only for apps using Sign in with DavidApps. Walled-off and auto-login apps refuse them with CONTROL_PLANE_CROSS_ORG_GATE_ALLOW_UNSUPPORTED and status 409. Their local checker cannot resolve a partner Group yet, so accepting the rule would report access that nobody receives.

list_cross_org_access shows which organizations reach one app, the label each published, the role, the modules, and degradesApplication. It never returns their group id or their own name for the group. revoke_cross_org_access removes one rule by the id that listing returned, and refuses a same-organization rule — that is remove_access.

One shape is refused rather than allowed quietly. Blocking another organization's Group on a walled-off or auto-login app takes rule-based access away from everyone on that app, not just them: the local checker cannot see the other organization's Groups, so it refuses every rule-based decision there until the rule is removed. Owners and admins of the organization keep their own access. It fails with CONTROL_PLANE_CROSS_ORG_GATE_OUTAGE and status 409 unless acknowledgeGateOutage is true, and a successful acknowledged write returns the consequence in warnings — stated on the way out, because a failed call reaches an agent only as an opaque error. Removing their allow rule is almost always the change that was actually wanted.

Access resolves through the platform: Sign in with DavidApps and the access history. Every step on both sides is audited under group.sharing_* and app_grant.cross_org_*.

The full scope list is apps:read, apps:write, access:catalog:read, access:write, directory:read, grants:write, vault:read, audit:read, and step-up:consume. The step-up:consume scope backs the app-integration POST /api/v1/apps/{appId}/step-up/consume endpoint documented in the HTTP API reference; it has no MCP tool. The app directory and the access catalog both require an app-bound key: the catalog is the delegated view one manager application has of its own boundary, so an organization-global key has no boundary to project and is refused. Request and response shapes are in the HTTP API reference. Tool names, routes, scopes, and public fields stay paired with those contracts. The local owner-only boundary is the intentional exception: provider values enter by reference, and one-time output values leave by reference.

create_group accepts an optional emailDomain. This keeps the group synced to verified team accounts at that exact domain without adding another tool:

{
  "name": "Bostan Enterprise",
  "emailDomain": "bostanenterprise.com"
}

The API backfills existing matches and reconciles future verified accounts. Use add_to_group only for groups without an email domain.

Five cases deserve care. rotate_secret replaces a shared password, share link, or bypass token and invalidates the old one. Its value is written only to the owner-only store. set_app_credential accepts credentialRef, never the raw provider value as MCP text; that reference names a pre-created 0600 file inside the configured directory. update_saml_request_signing likewise accepts certificateRef, never certificate PEM as MCP text. import_saml_connection accepts metadataRef, never raw XML as MCP text. set_saml_connection_state then prepares, enables, or disables the normalized connection with revisioned preflight checks; it never accepts private key material. Read and import keep their compatibility readiness result. The state transition returns the current lifecycle, including whether the connection can be prepared or enabled and the blockers that still apply. setup_app is normally idempotent, but rotateClientSecret: true explicitly replaces the secret of a matching, existing OpenID Connect client. Any one-time result is stored locally and only its opaque reference reaches the agent. Omit the field, or send false, for ordinary repeatable setup.

Create provider input outside the agent session. In Keychain Access, save an OAuth value as exact JSON with only clientId and clientSecret, or save a Resend value as the raw key. Then run this yourself from the canonical checkout:

pnpm davidapps:agent input-ref \
  --keychain-service=davidapps-provider \
  --keychain-account=wiki-google \
  --format=oauth

The command reads Keychain without displaying the value and prints only a generated input-<uuid> reference. Give that reference to set_app_credential. A successful update deletes the source; a failed update keeps it for retry. For returned mcp-*.json references, the human or approved non-model mover imports the value once, confirms the destination, and deletes the transport file. It leaves the file in place if the import fails.

Prepare a SAML certificate reference from a Keychain value the same way, using --format=saml-certificate. The helper validates that it contains exactly one RSA key of at least 2048 bits and prints only certificateRef. The update consumes that source only after the revisioned API write succeeds:

pnpm davidapps:agent input-ref \
  --keychain-service=plausible-saml-request-signing \
  --keychain-account=plausible-canary \
  --format=saml-certificate

For offline SP metadata, save the exported XML as a Keychain value and use --format=saml-metadata. The helper accepts at most 128 KiB and prints only metadataRef. import_saml_connection consumes it only after a successful revisioned import; a rejected or stale import leaves the source available for retry.

pnpm davidapps:agent input-ref \
  --keychain-service=service-saml-metadata \
  --keychain-account=service-production \
  --format=saml-metadata

Setting an app up from nothing

  1. Call setup_app with the app's exact hostnames, how it lets people in, its sign-up policy, and any development addresses. Give the returned opaque reference to the human or an approved non-model secret mover; do not ask the agent to open it.
  2. Hand the public part of that result to the checked-in setup-auth skill. It writes a public configuration file, an environment example, and an inactive secret template — with no credential values in any of them.
  3. Call get_ingress_snippet, review the addressing rules (nginx YAML and, where needed, the Envoy SecurityPolicy), apply them through your own deployment, and test the protected hostname. See Protect an app. The checked-in agent skills under .agents/skills are the playbooks for that work.
  4. Replace the broad key you started with by one bound to that app, carrying only the scopes it still needs.

If an existing app has lost its client secret, first prepare its approved secret store, then repeat the same matching setup_app request with rotateClientSecret: true. The client ID and return addresses stay in place; the old secret stops working and the replacement is written to a new owner-only file referenced by that call only. Omit oidcRedirectUris from that request; reconcile return addresses in a separate ordinary setup call. Do not retry an ambiguous rotation response.

The repository acceptance command pnpm phase6:proof runs this whole sequence from a fresh temporary Git repository against a packed, installed copy of the package and a live API, and additionally checks that the generated files have not drifted, that a key bound to one app cannot reach another, and that a full local database restore leaves share-link sign-in still working.

Reading failures

Errors are deliberately normalized at the model boundary. API failures use DAVIDAPPS_API_ERROR; local client failures use MCP_CLIENT; owner-only store failures use a fixed SECURE_* code. Upstream bodies, input values, paths, stack traces, and reflected messages are never included.