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/directoryKeep 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 doctorIt 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.
| Tool | Scope | Operation |
|---|---|---|
setup_app | apps:write | POST /api/v1/apps/setup |
grant_access | access:write | POST /api/v1/apps/{appId}/access/grants |
remove_access | access:write | DELETE /api/v1/apps/{appId}/access/grants |
create_group | access:write | POST /api/v1/groups |
add_to_group | access:write | POST /api/v1/groups/{groupId}/members |
list_group_members | access:catalog:read | GET /api/v1/groups/{groupId}/members |
set_group_sharing | access:write | PUT /api/v1/groups/{groupId}/sharing |
grant_cross_org_access | access:write | POST /api/v1/apps/{appId}/access/cross-org-grants |
list_cross_org_access | access:catalog:read | GET /api/v1/apps/{appId}/access/cross-org-grants |
revoke_cross_org_access | access:write | DELETE /api/v1/apps/{appId}/access/cross-org-grants/{grantId} |
register_app_module | apps:write | POST /api/v1/apps/{appId}/modules |
list_app_modules | apps:read | GET /api/v1/apps/{appId}/modules |
remove_app_module | apps:write | DELETE /api/v1/apps/{appId}/modules/{moduleKey} |
create_share_link | grants:write | POST /api/v1/apps/{appId}/grants/share-links |
rotate_secret | grants:write | POST /api/v1/apps/{appId}/grants/{grantId}/rotate |
get_ingress_snippet | apps:read | GET /api/v1/apps/{appId}/ingress-snippet |
resolve_access | apps:read | POST /api/v1/apps/{appId}/access/resolve |
read_audit | audit:read | GET /api/v1/audit |
read_app_directory | directory:read | GET /api/v1/apps/{appId}/directory |
read_access_catalog | access:catalog:read | GET /api/v1/access/catalog |
read_access_matrix | access:catalog:read | POST /api/v1/access/matrix |
read_saml_connection | apps:read | GET /api/v1/apps/{appId}/saml/connection |
import_saml_connection | apps:write | PUT /api/v1/apps/{appId}/saml/connection |
set_saml_connection_state | apps:write | PUT /api/v1/apps/{appId}/saml/connection/state |
read_saml_request_signing | apps:read | GET /api/v1/apps/{appId}/saml/request-signing |
update_saml_request_signing | apps:write | PUT /api/v1/apps/{appId}/saml/request-signing |
set_app_credential | apps:write | PUT /api/v1/apps/{appId}/credentials/{provider} |
get_user_provider_token | vault:read | POST /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-sharePass 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=oauthThe 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-certificateFor 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-metadataSetting an app up from nothing
- Call
setup_appwith 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. - Hand the public part of that result to the checked-in
setup-authskill. It writes a public configuration file, an environment example, and an inactive secret template — with no credential values in any of them. - Call
get_ingress_snippet, review the addressing rules (nginx YAML and, where needed, the EnvoySecurityPolicy), apply them through your own deployment, and test the protected hostname. See Protect an app. The checked-in agent skills under.agents/skillsare the playbooks for that work. - 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.