stdio-box
Runs an Atlassian MCP provider (mcp-atlassian) with per-profile accounts, routing calls to Jira and Confluence as the matched user, role, service account, or default profile.
Provides Atlassian Confluence access through the mcp-atlassian child process, using the caller's selected profile to interact with Confluence as that account.
Provides Atlassian Jira access through the mcp-atlassian child process, using the caller's selected profile to interact with Jira as that account.
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
stdio-box
stdio-box is one container image that runs a third-party MCP stdio server behind the Keystone door. The wrapper is the same for every server; each server is a provider directory under packages/.
Callers reach the box only through Keystone. The door checks the person or service account, and the box checks the door's bearer token and signed identity before it forwards a tool call.
How Keystone calls the box
Only the door calls the box. A person reaches it through an agent signed in at the door. A service reaches it as a client of the door, with that service's own service account: the assertion then has sub of the form svc:<name> and sub_type of service. The box does not read X-Author-Email.
stdio-box serve listens on 0.0.0.0 and BOX_PORT. A missing or unacceptable setting exits 2, one line per problem: config error: <NAME> is required.
Variable | Default | Accepted value |
| at least 32 bytes | |
| at least 32 bytes | |
| non-empty; the registry entry name | |
| an | |
| at least 32 bytes | |
|
| |
|
| directory containing |
| non-empty | |
|
| integer from 1 to 65535 |
|
| |
|
|
|
| empty | comma-separated host names |
| unset |
|
| unset | child program path, used only when |
|
| integer from 1 to 32 |
|
| integer from 1 to 31536000 |
|
| integer from 1 to 31536000 |
|
| integer from 1 to 31536000 |
|
| integer from 1 to 3650 |
|
|
|
|
|
|
| empty | comma-separated host patterns ( |
|
| integer from 60 to 3600 |
BOX_DEBUG and BOX_CHILD_ARGV_OVERRIDE are spawner settings. A missing value is not an error. BOX_DEBUG=1 enables debug-spawn. Together with BOX_CHILD_ARGV_OVERRIDE, it names the child program and launch does not install.
Checks on /mcp, in order:
Host, with or without a port, must beBOX_IDENTITY_AUDIENCE, that name plus-mcp,localhost,127.0.0.1, or a name fromBOX_ALLOWED_HOSTS. Otherwise HTTP 421 and{"error":"misdirected_request"}.Authorization: Bearermust matchBOX_BEARER_TOKEN. The comparison does not stop early on length. Otherwise HTTP 401 andWWW-Authenticate: Bearer. The MCP parser does not run.tools/listcarries no identity. The box returns the published tools fromtools.snapshot.json, then the fourteencreds_*tools. It does not start the provider server. Onlycreds_snapshot_toolsasks the server for a fresh list, and it does that under the caller's own profile.tools/callrequiresX-Keystone-Identity: HS256, headertypofkeystone-identity+jwt, audienceBOX_IDENTITY_AUDIENCE.mcp_targetmust be the tool name on this call. Eachjtiis accepted once, and remembered until itsexpplus 30 seconds. The memory holds at most 100000 entries; when it is full the call is rejected and nothing already stored is dropped.
A failed tool call is isError: true with one text block of JSON: code, message, and hint. code is one of identity_rejected, no_credentials_profile, tool_not_in_profile, profile_needs_reentry, instance_unavailable, instance_failed, instances_exhausted, instance_busy, upstream_timeout, not_available, invalid_argument, unknown_provider, profile_exists, profile_not_found, profile_in_use, binding_exists, binding_not_found, secret_field_in_chat, secret_reentry_required, missing_fields, form_unavailable, check_failed, snapshot_invalid. There is no credentials_ambiguous code: when several bindings match, the first in order is the one that applies.
tools.snapshot.json is the provider server's tools/list, stored as-is:
{
"schema_version": 1,
"server": {"command": "uvx echo-server==0.0.0"},
"taken_at": "2026-10-15T00:00:00Z",
"tools": []
}The snapshot's server.command must equal the provider's server.command. Published names come from the child names by tools.strip_prefix, then tools.normalize: snake (re.sub(r"[^A-Za-z0-9]+", "_", name).strip("_").lower()), then tools.rename, then tools.hide. A published name matches ^[a-z][a-z0-9_]{0,63}$ and does not start with creds_. Two child tools that publish as one name are an error at startup.
GET /healthz does not check the bearer token. A request that carries X-Forwarded-Host (the door's proxy, from the public domain) gets the status code and {}. Any other request gets status, store, isolation, and install. isolation is the wrapper's current value: unknown until a child starts, then per_slot_uid or shared_uid. install is unknown until launch has installed a server; then it is key, at, duration_ms, and from_cache. A store schema newer than this process is HTTP 503. A key that does not match is HTTP 200 with status degraded and store key_mismatch.
A call of a provider tool is routed to one profile, described under How a call is routed. creds_providers describes the loaded provider. creds_whoami reports which profile this caller would use and does not refuse. The other creds_* tools are the account tools under Box tools. isolation and server.installed_key are "unknown" until those checks report.
Related MCP server: ACR Meta-MCP Forward Proxy
How a call is routed
Each call reads the caller's identity again. Nothing about the choice is cached.
A binding
user:<sub>wins.subis lowercased, souser:Ann@Example.commatchesann@example.com. A service account is the same rule withsubsvc:<name>: the binding isuser:svc:<name>.When
rbacis notoff, role bindings are checked in ascendingposition. The first role that is in the caller'sroleswins. Other roles of that caller that sit lower are not used. Two roles never refuse the call; the administrator's order decides. Whenrbacisoff, this step is skipped because the assertion carries no roles.The
defaultbinding.Otherwise the call is refused with
no_credentials_profile: "There is no account for you in this box. Ask an administrator to bind one to you or to your role."
The profile's state does not change which binding wins. If the winning profile has no secrets yet, or its secrets cannot be read, the call is profile_needs_reentry and does not fall through to a later binding. A profile that is ready or check_failed is allowed to run.
The tool must be in the profile variant's tools patterns (or the variant must list no patterns, which allows every tool). Otherwise the call is tool_not_in_profile and the child is not started: "The account () does not include . Ask an administrator for an account that covers it."
creds_whoami returns the same choice without refusing: sub, sub_type, roles, rbac, profile, tier (user, role, or default), the winning binding, not_used (lower role bindings that also matched), the variant's tool patterns, would_refuse, and a hint. When not_used is not empty, the hint says the higher binding was used and how to move another up with creds_bind … --replace --position.
Instances
One package runs as one box container. Inside it, the wrapper starts one copy of the provider's server per profile, so each account of the external system has its own process. The path of one call, with three profiles of the atlassian box:
flowchart LR
person["Person<br/>signed in with Google"]
backend["Service svc:backend<br/>API key"]
rolecheck["Service svc:role-check<br/>API key, role atlassian_writer"]
door["keystone-mcp door<br/>roles, policy,<br/>signed X-Keystone-Identity"]
subgraph box["atlassian-mcp container, one per package"]
wrapper["box wrapper<br/>picks a profile:<br/>user → role → default"]
p1["atlassian/alexey<br/>own process, uid 20000+slot"]
p2["atlassian/backend<br/>own process, uid 20000+slot"]
p3["atlassian/skynet<br/>own process, uid 20000+slot"]
end
jiraA["Jira a.example.com"]
jiraB["Jira b.example.com"]
person --> door
backend --> door
rolecheck --> door
door --> wrapper
wrapper -- "user:alexey@example.com" --> p1
wrapper -- "user:svc:backend" --> p2
wrapper -- "role:atlassian_writer, position 1" --> p3
p1 --> jiraA
p2 --> jiraB
p3 --> jiraBThe same path as text, for a terminal:
MCP client an agent signed in at the door, or a service with its own service account
│ MCP over HTTPS
▼
keystone-mcp door signs the caller in, reads their roles, applies the policy
│ Authorization: Bearer <BOX_BEARER_TOKEN>
│ X-Keystone-Identity: sub, roles, mcp_target (signed, one use)
▼
atlassian-mcp one container for the package
└─ wrapper picks one profile per call: user:<sub> → role:<name> by position → default
│
├─ atlassian/alexey ← user:alexey@example.com
│ └─ child uid 20000+slot mcp-atlassian ──▶ Jira and Confluence as Alexey
├─ atlassian/backend ← user:svc:backend
│ └─ child uid 20000+slot mcp-atlassian ──▶ as the backend service account
└─ atlassian/skynet ← user:skynet@example.com, role:atlassian_writer (position 1)
└─ child uid 20000+slot mcp-atlassian ──▶ as the shared writer accountThe door decides whether the caller may call the tool at all. The box decides which account the call runs under.
A person with no
user:binding who has the roleatlassian_writerruns asatlassian/skynet. A caller that matches nothing, and the box has nodefault, getsno_credentials_profile.A child starts on the first call for its profile and stops after
BOX_IDLE_SECONDSwithout calls. At mostBOX_MAX_INSTANCESchildren run at once; a new profile evicts the idle one that was used longest ago.Each child runs under its own uid
20000+slot(isolation: per_slot_uidin/healthz) with its ownHOME, and gets only its own profile's fields.Adding, changing, binding, or deleting a profile (with
credsctlor thecreds_*tools) takes effect within about 2 seconds, without restarting the container.
One profile has at most one child. The child starts on the first call and stops when it is idle. The instance key is the profile id and its secret version.
Limit | Setting | Default | What happens |
Live instances |
| 8 (1–32) | A new profile evicts the idle instance with the oldest |
Idle stop |
| 600 | A background pass every 30 seconds stops an instance with no calls in flight for longer than this. |
Start |
| 60 |
|
Call |
| 120 | The call is |
Calls at once on one instance | constant | 8, wait 30 seconds |
|
Ping | constants | every 15 seconds, timeout 5 seconds, restart after 3 misses in a row | One missed ping does not restart. The log says |
Restart after a crash | constants | 1 second, doubling up to 60 seconds; back to 1 second after 60 seconds of stable running | During the wait the call is |
Failed starts | constant | 5 in a row, then | The call is |
Stop | constants | close the session, then the spawner closes stdin, waits 2 seconds, | In-flight calls fail as soon as the session closes. |
stderr | constants | 50 lines per second, burst 200 | Suppressed lines are counted. Field values of length 6 or more are masked. |
Child environment | — |
| The wrapper's environment is not inherited. A field that is not part of the variant is not passed. |
Slot cleanup | — | before the slot is given out, and after the child stops | A slot whose cleanup failed is quarantined and is not given out again. |
The wrapper also reads meta.revision every 2 seconds. If a live profile was deleted, its version changed, or it now needs its secrets again, that instance is stopped (a version change lets in-flight calls finish for up to 30 seconds). credsctl therefore applies without restarting the container. An audit row or a stored profile set does not change the revision. Audit rows older than BOX_AUDIT_RETENTION_DAYS are deleted once an hour. A status snapshot is written about every 10 seconds for credsctl status.
Changing limits
Set the BOX_* limits in the package override, under environment:. The file is packages/<package>.override.yml.
environment:
BOX_MAX_INSTANCES: "4"
BOX_IDLE_SECONDS: "300"
BOX_START_TIMEOUT_SECONDS: "60"
BOX_CALL_TIMEOUT_SECONDS: "120"
BOX_AUDIT_RETENTION_DAYS: "30"A value outside the accepted range is a config error. The process exits 2 and names the variable. The ping, backoff, and per-instance concurrency numbers are not variables.
One image, many providers
The image is built from this repository and published as ghcr.io/keystone-codes/stdio-box. It contains the wrapper and the data for every provider: provider.yaml, the lock file, and tools.snapshot.json land at /opt/box/packages/<name>/. Manifests, example policies, and provider READMEs stay out of the image, because the manifest pins the image digest.
The server program itself is not in the image. The first start of a container installs it from the lock file into the cache volume. After that the container can run without the public network. A host that cannot reach PyPI or npm can set BOX_PIP_INDEX_URL or BOX_NPM_REGISTRY. This release does not ship a per-provider image with the server already installed.
Adding a provider is a new directory under packages/ and a new image release. See packages/README.md.
Processes in the container
PID 1 tini (root; reaps orphaned children)
└─ stdio-box launch (root, short-lived)
│ 1. prepares /data, /cache, /tmp/box
│ 2. installs the provider into /cache under uid 10002
│ 3. splits into two processes:
├─ spawner (root; spawn, signal, and reap over a socketpair)
└─ wrapper uid 10001, non-dumpable (HTTP :8081 — /mcp, /creds/, /healthz)
↕ stdin/stdout/stderr passed across by the spawner
child uid 20000+slot (one profile's server; HOME=/tmp/box/slot-N)stdio-box launch is the process the image starts. It fixes the directory owners, installs the provider server into /cache under uid 10002 when setuid works, then splits into the root spawner and the wrapper. A missing BOX_COMMAND exits 2. A failed install logs provider_install_failed and exits 3. BOX_DEBUG=1 together with BOX_CHILD_ARGV_OVERRIDE skips installation and uses that program as the child. stdio-box serve without spawner arguments is the HTTP wrapper. stdio-box serve --spawner inline or --spawner-fd is the wrapper process launch execs. stdio-box install and stdio-box snapshot-tools install without that split.
Path | Owner | Mode | Use |
| uid 10001 ( |
| Profile store |
| uid 10002 ( |
| Installed server, written only by the installer |
| root |
| Per-slot homes for children |
The image does not set USER. launch starts as root so it can drop to these uids.
Installing the server
The image does not contain the provider's server. BOX_PROVIDER selects /opt/box/packages/<BOX_PROVIDER> (or BOX_PROVIDER_DIR when that is set). BOX_COMMAND names the server and its exact version. The box parses that string with shlex. It does not run it in a shell, and it does not invoke uvx or npx while the container is up.
Grammar:
uvx <distribution>==<version> [arguments…]npx [-y|--yes] <package>@<version> [arguments…](<package>may be@scope/name)
The version is exact. A Python version must be a single packaging version. An npm version must be X.Y.Z or X.Y.Z-<prerelease>. Ranges, latest, and ^ ~ > < * x are rejected (box_command_invalid). A flag before the package (--from, --with, -p, and any other) is rejected: those flags would skip the lock file. Arguments after the package are kept for the child process.
Before installation, the command is checked against the provider lock (box_command_not_locked when it does not match):
Python:
requirements.txthas a line for the normalized distribution name at that exact version, with at least one--hash=sha256:, and every requirement line has a sha256 hash.Node:
package.jsondependenciesis exactly that package and version,package-lock.jsonis lockfileVersion 3,packages["node_modules/<package>"].versionis that version, and every downloaded package hasintegrity.
The installer runs as uid 10002 and writes /cache/<provider>/<key>/. The key is the first 16 hex characters of a SHA-256 over the lock bytes, package.json for Node, the installer kind, and the installer version baked into the image. If <key>/.complete already contains that key, the tree is reused and the network is not used. The first start needs a route to the index, or to a mirror. Later starts do not.
Mirrors are visible only to the installer. BOX_PIP_INDEX_URL is passed as UV_INDEX_URL. BOX_NPM_REGISTRY is passed as npm_config_registry. When either is set it must start with https://. Hashes are checked for a mirror the same way as for the public index.
BOX_INSTALL_TIMEOUT_SECONDS defaults to 240 and must be from 30 to 1800. A failed install logs provider_install_failed and launch exits 3. Docker's restart: unless-stopped starts the container again, and the install is tried again.
A machine with no network and no mirror can be prepared by installing once where the index is reachable, then copying the Docker volume ks-pkg-<name>-cache. This release does not publish an image with the server already installed.
The installed tree is owned by uid 10002. Directories are 0755. Files are not writable by the group or by other users, so a child (uid 20000+slot) can run the server and cannot change it. Nothing except the installer runs as uid 10002.
If the container cannot change uid (user: in an override, or the capabilities that setuid needs have been dropped), the installer runs without changing uid and the log records install_isolation_unavailable. In that shared_uid mode the spawner does not start, children share the wrapper's uid, and a child can modify the cache.
stdio-box install [--provider-dir DIR] [--cache DIR] runs the same install without launch. When the caller is not root, it does not try to change uid.
stdio-box snapshot-tools installs into a temporary cache, starts the server once, and writes tools/list. keystonectl package wrap-stdio runs it as docker run --rm --entrypoint stdio-box -v <directory>:/opt/box/provider:ro -e BOX_PROVIDER_DIR=/opt/box/provider <image> snapshot-tools --out -.
provider.yaml
schemas/provider-v1.json (Draft 2020-12, $id https://keystone.codes/schemas/stdio-provider-v1.json). Every object rejects unknown keys. stdio-box validate-provider <directory> checks the schema and the rules below. Success is exit 0. Failures are one path: message line each and exit 1.
Field | Required | Constraints |
| yes |
|
| yes |
|
| yes | 1–80 characters |
| yes | 1–300 characters, starts with |
| yes |
|
| yes |
|
| no | Up to 32 pairs. Name |
| yes | 1–16 objects: |
| yes | 1–8 objects. |
| yes | Exactly one of |
| no |
|
| yes |
|
| no |
|
Further checks: field names are unique, variant names are unique, and two variants cannot list the same set of fields. Every name used in a variant's fields, in auth, or as {FIELD} in a check URL or header must exist. {FIELD} in a URL or header must be a non-secret field of that variant. The credential in auth (the second basic field, bearer, or header.field) must be a secret field of that variant; the first basic field is the username and may be non-secret. A secret field with to: {arg: ...} is rejected: secret field <name> cannot be passed as an argument.
Worked example, Atlassian (Jira and Confluence). The same file is tests/data/packages/atlassian/provider.yaml. Its requirements.txt in the test tree is a stub so the presence check passes; the real lock arrives with the provider package.
schema_version: 1
name: atlassian
title: Atlassian (Jira and Confluence)
server:
command: "uvx mcp-atlassian==0.23.1"
bin: mcp-atlassian
lock: requirements.txt
static_env:
READ_ONLY_MODE: "false"
fields:
- {name: JIRA_URL, type: url, secret: false, hint: "Jira address, for example https://example.atlassian.net"}
- {name: JIRA_USERNAME, type: string, secret: false, hint: "Account email (Atlassian Cloud)"}
- {name: JIRA_API_TOKEN, type: string, secret: true, hint: "API token from id.atlassian.com, Security, API tokens"}
- {name: JIRA_PERSONAL_TOKEN, type: string, secret: true, hint: "Personal access token (Jira Server or Data Center)"}
- {name: CONFLUENCE_URL, type: url, secret: false, hint: "Confluence address, for example https://example.atlassian.net/wiki"}
- {name: CONFLUENCE_USERNAME, type: string, secret: false, hint: "Account email (Atlassian Cloud)"}
- {name: CONFLUENCE_API_TOKEN, type: string, secret: true, hint: "API token from id.atlassian.com (the same token works for Jira)"}
- {name: CONFLUENCE_PERSONAL_TOKEN, type: string, secret: true, hint: "Personal access token (Confluence Server or Data Center)"}
variants:
- name: cloud
title: Jira and Confluence, Atlassian Cloud
fields: [JIRA_URL, JIRA_USERNAME, JIRA_API_TOKEN, CONFLUENCE_URL, CONFLUENCE_USERNAME, CONFLUENCE_API_TOKEN]
allowed_hosts: ["*.atlassian.net"]
checks:
- http:
method: GET
url: "{JIRA_URL}/rest/api/2/myself"
auth:
basic: [JIRA_USERNAME, JIRA_API_TOKEN]
expect_status: [200]
identity: displayName
- http:
method: GET
url: "{CONFLUENCE_URL}/rest/api/user/current"
auth:
basic: [CONFLUENCE_USERNAME, CONFLUENCE_API_TOKEN]
expect_status: [200]
identity: displayName
- name: jira_cloud
fields: [JIRA_URL, JIRA_USERNAME, JIRA_API_TOKEN]
tools: ["jira_*"]
allowed_hosts: ["*.atlassian.net"]
checks:
- http:
method: GET
url: "{JIRA_URL}/rest/api/2/myself"
auth:
basic: [JIRA_USERNAME, JIRA_API_TOKEN]
expect_status: [200]
identity: displayName
- name: confluence_cloud
fields: [CONFLUENCE_URL, CONFLUENCE_USERNAME, CONFLUENCE_API_TOKEN]
tools: ["confluence_*"]
allowed_hosts: ["*.atlassian.net"]
checks:
- http:
method: GET
url: "{CONFLUENCE_URL}/rest/api/user/current"
auth:
basic: [CONFLUENCE_USERNAME, CONFLUENCE_API_TOKEN]
expect_status: [200]
identity: displayName
- name: server
title: Jira and Confluence, Server or Data Center
fields: [JIRA_URL, JIRA_PERSONAL_TOKEN, CONFLUENCE_URL, CONFLUENCE_PERSONAL_TOKEN]
checks:
- http:
method: GET
url: "{JIRA_URL}/rest/api/2/myself"
auth:
bearer: JIRA_PERSONAL_TOKEN
expect_status: [200]
identity: displayName
- http:
method: GET
url: "{CONFLUENCE_URL}/rest/api/user/current"
auth:
bearer: CONFLUENCE_PERSONAL_TOKEN
expect_status: [200]
identity: displayName
- name: jira_server
fields: [JIRA_URL, JIRA_PERSONAL_TOKEN]
tools: ["jira_*"]
checks:
- http:
method: GET
url: "{JIRA_URL}/rest/api/2/myself"
auth:
bearer: JIRA_PERSONAL_TOKEN
expect_status: [200]
identity: displayName
- name: confluence_server
fields: [CONFLUENCE_URL, CONFLUENCE_PERSONAL_TOKEN]
tools: ["confluence_*"]
checks:
- http:
method: GET
url: "{CONFLUENCE_URL}/rest/api/user/current"
auth:
bearer: CONFLUENCE_PERSONAL_TOKEN
expect_status: [200]
identity: displayName
tools:
normalize: none
discovery:
mode: placeholder
env:
JIRA_URL: https://discovery.invalid
JIRA_USERNAME: discovery@example.invalid
JIRA_API_TOKEN: discovery-placeholder
CONFLUENCE_URL: https://discovery.invalid/wiki
CONFLUENCE_USERNAME: discovery@example.invalid
CONFLUENCE_API_TOKEN: discovery-placeholder
identity_to_child: noneAdding a provider
A provider is a directory packages/<name>/. The image build copies the directory's data (provider.yaml, the lock, package.json when the server is Node, and tools.snapshot.json) to /opt/box/packages/<name>/. Manifests and the provider README stay out of the image.
Build the lock in that directory:
uv pip compile --universal --generate-hashes --python-version 3.12 requirements.in -o requirements.txt
npm install --package-lock-onlyuv pip compile --universal records wheels for more than one platform, and uv pip install honors those markers. Do not hand-write tools.snapshot.json. keystonectl package wrap-stdio runs snapshot-tools in this image and writes the file. A server that does not answer tools/list with placeholder values can be listed by a running box instead: creds_snapshot_tools takes the list under an account someone already entered on the form, and credsctl snapshot export <id> prints it. No secret has to be written to a file for that. Commit the lock and the snapshot with provider.yaml.
Security model
Who may call a tool is decided by the door's policy. The box keeps no role list of its own.
The signed identity assertion is forwarded to the child only when the provider sets identity_to_child: meta. The default is none.
A secret field cannot be passed as a command-line argument. Credential values travel in the child's environment, and only in the explicit list the wrapper builds for that profile. The child never receives the wrapper's environment.
PID 1 tini (root; reaps orphaned children)
└─ stdio-box launch (root, short-lived)
├─ spawner (root; spawn, signal, and reap over a socketpair)
└─ wrapper uid 10001, non-dumpable (HTTP :8081 — /mcp, /creds/, /healthz)
↕ stdin/stdout/stderr passed across by the spawner
child uid 20000+slot (one profile's server; HOME=/tmp/box/slot-N)The wrapper calls prctl(PR_SET_DUMPABLE, 0) as soon as it starts, so /proc/<wrapper>/environ is not readable by another process of uid 10001. After exec, a child is dumpable again; what protects the next profile is that the child runs as 20000 + slot, not as the wrapper.
A child can see its own profile values, its slot directory /tmp/box/slot-N (mode 0700), and the server code under /cache (readable and executable, not writable). It cannot see the store key, the database under /data (mode 0700, owner 10001), or another profile's environment. It cannot change the code a later profile's child will run: /cache is owned by uid 10002, and the spawner never runs a child as that user.
The spawner is the only process that stays root. It does not listen on the network and does not read /data. The wrapper asks it to spawn, signal, and reap over the socketpair inherited across exec. There is no control socket on the filesystem.
keystonectl sets security_opt: no-new-privileges:true and does not set user: or drop capabilities. no-new-privileges does not block this layout: root drops uid with setuid, not with a setuid binary.
If an override sets user: (the process is not root) or drops SETUID/SETGID, the box keeps running in shared_uid mode and does not start the root spawner. Children then share the wrapper's uid, the log contains child_isolation_unavailable once, and isolation is shared_uid. Dropping those capabilities leaves the process root, because it can no longer call setuid to become uid 10001 either. In that mode a uid-wide reap would kill the wrapper, so cleanup is only killpg of the child's process group and recreating /tmp/box/slot-N. A descendant that called setsid can outlive that.
Slot numbers are reused, and with them the uid 20000 + slot. Before every spawn into a slot, and after every stop, the spawner reaps that uid: a helper process setuid to the slot sends SIGKILL to every process it can see (this container's PID namespace), then deletes that uid's entries at the top of /tmp and /dev/shm without following symlinks, and recreates the slot directory. If processes remain after 5 seconds the slot is quarantined until the container restarts and is not handed out again.
Each child sets RLIMIT_NPROC to 512 and RLIMIT_CORE to 0 before exec. Core dumps would contain tokens. There is no address-space limit: RLIMIT_AS breaks Node; an operator can set the container memory limit with an override. Without user namespaces the process count is per uid on the whole host, so two boxes on one machine share the 512 processes of a slot with the same number.
Child stderr is logged at info as stdio_box.child.stderr with profile, generation, and slot. Lines are masked before they are written: every profile field value of 6 characters or more becomes ***, then well-known token shapes (GitHub, GitLab, Slack, cloud keys, Atlassian, Notion, Yandex OAuth, Tavily). Lines longer than 2000 characters are cut. A token bucket keeps 50 lines per second with a burst of 200; the rest increment stderr_suppressed_total and, once a second, produce a warning suppressed N stderr line(s).
Children share the container network. Third-party code can open connections to other packages' facades on the keystone-mcp-upstreams network and to addresses outside the host. Those facades still require their own bearer; the network path itself is open.
Storage
Profiles and bindings live in <BOX_DATA_DIR>/box.sqlite3 (in the image, /data/box.sqlite3). The file is created mode 0600. The database uses WAL. Tables:
Table | What it holds |
| schema version, salt, key check, revision |
| id, variant, non-secret fields, state, check result, |
| secret fields, AES-256-GCM, one row per version |
| who receives which profile, and the role order |
| SHA-256 of a form token, never the token |
| calls and admin actions, kept for |
| the newest five |
| every version of the profile set stored by |
| one JSON snapshot written by the running wrapper |
BOX_STORE_KEY is the key generated for the package (secrets.token_urlsafe(32)). The store derives separate 32-byte keys with HKDF-SHA256 for secrets, the key check, form CSRF, audit subject hashes, and creds_sync confirm codes. Secret fields of one profile are a single JSON object sealed with AES-256-GCM. The nonce is random. The additional data is the profile id and the secret version, so a row cannot be moved under another profile or version.
What this protects: a copy of the data volume without BOX_STORE_KEY. The secret fields cannot be opened.
What this does not protect:
Non-secret fields (the address, the account name) are stored in the clear, as are profile ids, bindings, and audit rows.
The key is in the container environment.
docker inspectshows it. Anyone who can read the running container can read the store.Encryption does not hide which profile was used, only the secret values.
A database written by a newer build is not opened and is not modified. An internal /healthz then answers 503. A request that arrived through the public domain (X-Forwarded-Host) gets the same status code and an empty body.
Box tools
An administrator configures the box from chat. These fourteen tools are part of the server's catalog. Secret values never travel in either direction: a tool result, a refusal, a log line, and an audit row never contain one. The person who owns the account types secrets on the one-time form.
Tool | What it does |
| The provider in this box: variants, which fields are secret, |
| Profiles, their non-secret fields, |
| Every binding in the order a call is resolved: |
| Create a profile. When the variant has secret fields, the result is a one-time link. |
| Change non-secret fields, or issue a new link so the secrets can be entered again. |
| Delete a profile that nothing is bound to, and stop its instance. |
| Bind |
| Remove one binding. Role positions below it move up. |
| Which profile this caller would use, which binding won, and which other role bindings were skipped. |
| Run the variant's checks and start the instance. Both have to succeed. |
| Take the server's |
| The stored profile set: its version, who wrote it and when, its text, and the recent versions. |
| Store a new version of the profile set, or restore an older one. Nothing is applied. |
| Show what applying the stored set would change, with a confirm code; apply it when called again with that code. |
creds_snapshot_tools
Takes no arguments. The profile is the one a provider call of this caller would use, by the same order as How a call is routed: the profile creds_whoami shows. No argument can name another profile. A caller with no profile gets no_credentials_profile; a profile without readable secrets gets profile_needs_reentry. The box does not ask for a signed-in person: the tool only reads, under the caller's own account. Who may call it is the policy's decision; <ns>_box_admin covers it through <ns>_creds_*, and it is not in requires_human_identity. Annotations: readOnlyHint: true, openWorldHint: true.
The box starts one short-lived server on a free slot with that profile, calls tools/list, and stops it. When every slot is in use the call is instances_exhausted; a server that does not start or does not list its tools is instance_failed; a listing that does not arrive within BOX_CALL_TIMEOUT_SECONDS is upstream_timeout. A listing whose names cannot be published under the provider's tools rules is snapshot_invalid and is not stored.
The stored snapshot is the same document stdio-box snapshot-tools --out - writes: schema_version, server.command, taken_at, and tools exactly as the server listed them. The tools rules are not applied to it; they are applied when the box loads a tools.snapshot.json. The box keeps the newest five snapshots in the table tool_snapshots and deletes older ones. credsctl snapshot list and credsctl snapshot export <id> read them.
Result:
{
"id": 3,
"sha256": "5f0c…e1a2",
"tools": ["get_issue", "search", "create_issue"],
"profile": "atlassian/shared",
"variant": "cloud",
"variant_filters_tools": true
}tools are the published names after the provider's tools rules (strip_prefix, normalize, rename, hide), without the namespace and without the variant's patterns. variant_filters_tools is true when the profile's variant lists tools patterns: that account may see a narrower set, and a package built from this snapshot publishes that set to everyone. sha256 is the hex SHA-256 of the snapshot without taken_at, as json.dumps(document, sort_keys=True, separators=(",", ":"), ensure_ascii=False) encoded as UTF-8, so two snapshots of the same server have the same hash. The result, the log, the audit row, and the table never contain a secret value.
The profile set from chat: creds_desired_get, creds_desired_set, creds_sync
These three tools do what credsctl sync does, without a shell. The set is the same YAML, kept in the box with every earlier version. Secrets are never part of it: whoever owns an account types its secret on a one-time form.
version: 1
profiles:
atlassian/alexey:
description: Alexey
fields:
JIRA_URL: https://a.example.com
JIRA_USERNAME: alexey@example.com
atlassian/skynet:
variant: jira_cloud # needed when the fields alone do not pick one variant
fields:
JIRA_URL: https://b.example.com
JIRA_USERNAME: skynet@example.com
bindings:
users:
alexey@example.com: atlassian/alexey
svc:backend: atlassian/backend # a profile already in the box can be bound by id
roles:
- atlassian_writer: atlassian/skynet
default: nullcreds_desired_set takes text (the YAML, at most 256 KiB) or from_version (copy an older version back as the newest one; this is the rollback). It checks the whole set before it stores anything: keys, fields, variants, lengths, addresses (as creds_profile_create does), subjects, and that every bound profile is in the set or in the box. A problem is desired_invalid (with at, where in the set) or profile_not_found, and nothing is stored. secrets, {file: ...}, {env: ...}, env_file, and a secret field under fields are refused with secret_field_in_chat; the message says that creds_sync sends a one-time form link where the account owner enters the secret. A set identical to the newest version is not stored again (unchanged: true). Storing a set applies nothing and does not change meta.revision.
creds_desired_get returns the newest version, or the one given as version: version, sha256, created_at, created_by, restored_from, text, plus latest and the twenty most recent versions without their text. Before the first set, version and text are null.
creds_sync works on the newest stored version. Called without confirm, it writes nothing and runs no checks: it returns the plan and a confirm code. Called again with that code (and the same prune), it applies the plan. The code is bound to the set's version, to prune, and to every profile and binding in the box, open form links included. If any of them changed after the plan, or the code is not that plan's, the call is refused with sync_plan_stale and nothing is applied; ask for a new plan. When there is nothing to change, the plan has no code, and nothing is written, the revision does not change, and no instance restarts. Bindings and sync-written profiles that the set does not name, and that stay because prune is off, are listed with action: kept and are not changes; next then says how many there are and that prune: true would remove them.
What applying does to each profile in the set:
| When | What happens |
| the profile is not in the box | It is created and marked |
| an address, or a field a check sends, changed; or the profile waits for its secrets and has no open link | A new one-time link with the new fields. The profile keeps its current fields and secrets until the form is sent, as |
| other non-secret fields or | The checks run with the stored secrets, then the fields are saved as a new secret version and the running instance restarts. |
| the profile waits for its secrets and its link is still open | Nothing; |
| nothing differs | Nothing. |
| the provider rejected it, its variant differs from the box's, or the box has no public address for the form | Nothing for that profile; the reason is in |
changed lists the fields that differ. A variant cannot change through the set: give the profile a new id. Bindings and prune follow the same rules as credsctl sync: listed bindings replace what the subjects had, role positions follow the list, and only with prune: true are bindings that are not in the set removed and profiles marked managed_by: credsctl sync deleted. Without it, those bindings are listed under bindings with action: kept. Other profiles stay and are listed in not_in_set with managed: false.
A profile that is skipped does not stop the others. The result then has partial: true and errors, each with id (check_failed, variant_changed, form_unavailable, profile_skipped, profile_in_use, profile_changed, or secret_unreadable), message, and profile or subject.
{
"version": 4,
"prune": false,
"applied": true,
"partial": false,
"changes": 2,
"confirm": null,
"profiles": [
{"profile": "atlassian/alexey", "variant": "jira_cloud", "action": "created",
"secret_fields": ["JIRA_API_TOKEN"],
"form_url": "https://atlassian.example.com/creds/…", "expires_at": "2026-10-27T10:15:00Z"},
{"profile": "atlassian/skynet", "variant": "jira_cloud", "action": "unchanged"}
],
"bindings": [
{"subject": "user:alexey@example.com", "profile": "atlassian/alexey", "action": "bound"}
],
"not_in_set": [{"profile": "atlassian/manual", "managed": false, "action": "kept"}],
"errors": [],
"next": "Send each form_url to the person who owns that account. …"
}Each call of the three tools writes an audit row (event=admin, the tool name, the set's version, and for creds_sync prune, changes, and confirmed); each change creds_sync makes writes one more, with command sync. The confirm code and form tokens are not stored. creds_desired_set and creds_sync are in requires_human_identity in the sample policy; <ns>_box_admin reaches all three through <ns>_creds_*, and no other sample role does.
creds_profile_create and creds_profile_rotate accept only non-secret fields. Naming a secret field, in any letter case, is refused and the value is discarded. A field of type url is checked before anything is written. Changing a URL, or any field that a check places in a URL or a header, requires the secrets to be entered again on the form. Stored secrets are never sent to a new address.
Bindings and their order
The box picks one profile for a call, in this order:
user:<sub>— the subject from the signed identity, lowercased.user:svc:backendis a service account.role:<name>bindings whose role is in the call, walked in the order an administrator set. The first hit wins.default.
There is no error when several roles match. The administrator sets the order with credsctl bind <subject> <profile> --position N. A new role binding without --position goes to the end. --replace --position N moves an existing role binding and can point it at another profile. Removing a role binding closes the gap so positions stay 1, 2, 3 with no holes. user: and default have no position.
Profile state does not affect the choice. If the first match needs its secrets entered again, the call uses that profile and fails, instead of quietly using the next account.
creds_bindings is that same order, with the profile state on every row. user is sorted by subject. role is position 1, 2, 3 with no holes. default is last, or null when it is not set. creds_whoami says which row won (tier and binding, including position for a role) and lists the caller's other role rows under not_used, because they were lower in the order. hint names the binding to move up.
To put a role binding somewhere else, call creds_bind again with replace: true and the new position. The reply for a role includes order, the full role list after the change. A new role binding without position goes to the end. position on user: or default is refused. user: and default have no position.
Recommended policy
The box does not check roles. Whoever the policy lets call <ns>_creds_* manages everyone's accounts in this box. That is the administrator's decision and their responsibility. The box does not keep an allow-list of administrators.
Do not give <ns>_* to an ordinary role. A pattern is matched against the full published name, so billing_* covers every tool of namespace billing, including billing_admin_report. The same pattern on this box also covers <ns>_creds_profile_delete and the other account tools. List the tool names the role should have. stdio-box policy-example --namespace <ns> prints a reader role and a writer role that do that, and a <ns>_box_admin role whose only grant is <ns>_creds_*. keystonectl prints that file and does not merge it into the policy.
The seven tools that change accounts (creds_profile_create, creds_profile_rotate, creds_profile_delete, creds_bind, creds_unbind, creds_desired_set, creds_sync) are listed in requires_human_identity. creds_snapshot_tools is not: it changes no account and runs under the caller's own profile. <ns>_box_admin covers it through <ns>_creds_*. Only a signed-in person may change accounts. An API key or a service account must not: those calls act for a machine, and a machine must not be able to rebind the accounts everyone else uses. The door enforces that list. The box does not.
Moving or reinstalling a box
Copy the store volume and use the same BOX_STORE_KEY. The process still starts if the key is different: bindings and non-secret fields stay readable, secret fields do not, and those profiles are reported as needs_reentry. An internal /healthz answers 200 with "status": "degraded" and "store": "key_mismatch". A public-domain request gets 200 and {}. Enter the secrets again, or re-encrypt with the old key (below). sub_hash values in logs from before the key change do not match values from after it.
Backups and the key
Before a schema migration the store copies the database to <BOX_DATA_DIR>/backup/ and keeps the three newest copies. Keep a backup of the volume separate from BOX_STORE_KEY. The ciphertext is only a secret from someone who does not have the key. The key is also visible in the container environment, so a backup that includes both the volume and the key (or docker inspect output) is not protected.
The cache volume does not hold profiles. It does not need the same treatment.
Changing the key
credsctl store rekey --old-key-file <path> (or - for stdin) opens every secret row with the old key and seals it with the current BOX_STORE_KEY. Rows already sealed with the current key are left as they are. If you no longer have the old key, enter the secrets again; the first successful write adopts the new key, and profiles you have not rewritten stay needs_reentry.
Entering secrets
There are three ways to put an account into the box:
A one-time form in the browser, at a link the administrator asked for.
credsctl profile import, from a mode0600env file or from stdin.OAuth done by the person, in a later version. This version has no such flow.
The form is not a login. A link that expires in 15 minutes and works once is enough: the person who opens it types the token, and the box checks that token with the provider before it stores anything. There is no other sign-in on the box.
Request | Response |
| 200, an HTML form: provider and variant titles, profile id, non-secret fields read-only, |
| 404, the same page: "This link is not valid. Ask the administrator for a new one." |
| Checks below. Success is 200: "Saved. <title> says this account is <identity>. You can close this page." |
| 422, the same form, "<title> did not accept these values (HTTP <code>).", and one more attempt. The fifth failure is 410. |
| Values are stored, state |
Any other method | 405 |
POST is accepted only in this order: the token is live, Origin is the origin of BOX_PUBLIC_BASE_URL (Origin: null is rejected), the cookie is present, csrf matches, the body contains only csrf and the variant's secret fields (each non-empty, within max_length, no line break), and the box has seen fewer than 60 such posts in the last minute. A failure before the provider check is 400, "The form could not be accepted. Open the link again." An unknown token is 404. The rate limit is 429.
GET does not use the link up. A link is used up when the values are saved, or after five rejected attempts. One profile has one live link; a new link replaces the previous one. The lifetime is BOX_FORM_TTL_SECONDS (default 900, allowed 60–3600).
Every /creds/ response sends Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none', X-Frame-Options: DENY, Referrer-Policy: same-origin, Cache-Control: no-store, and X-Content-Type-Options: nosniff. The page has no script and no external resources. Referrer-Policy is same-origin, not no-referrer: with no-referrer a browser sends Origin: null on the form post, and the origin check would reject every real submission.
nginx in front of the box sets Host to 127.0.0.1 and passes the public name in X-Forwarded-Host. /creds/ accepts Host 127.0.0.1, localhost, or the host of BOX_PUBLIC_BASE_URL. If X-Forwarded-Host is present it must be that public host, otherwise the response is 421.
When BOX_PUBLIC_BASE_URL is https://public-domain-pending.invalid (--defer-public), the form cannot be opened. Enter the account with credsctl profile import only.
A check URL has to be https unless BOX_ALLOW_HTTP_URLS=true. Its host has to match the variant's allowed_hosts when that list is set, and BOX_ALLOWED_URL_HOSTS when that list is set. Every address the name resolves to has to be public: not loopback, not a private network, not link-local, not a unique-local IPv6 address, and not carrier-grade NAT. BOX_ALLOW_PRIVATE_URLS=true turns that address check off for a provider that lives on an internal network; set it only on purpose. The check connects to the address it just resolved and keeps the original name for Host and TLS.
With TLS=manual, keystonectl's nginx does not write an access log for public paths, but your own proxy will record /creds/<token> unless you turn that log off for /creds/.
location ^~ /creds/ {
access_log off;
proxy_pass http://127.0.0.1:8081;
}handle /creds/* {
log {
output discard
}
reverse_proxy 127.0.0.1:8081
}Rotation writes the new values only after the check is not a refusal. Until then the previous version stays as it was.
credsctl
credsctl is the operator CLI. In the container it runs as uid 10001. Started as root, it drops to that uid before it opens the database. Every command accepts --json. credsctl <command> --help prints that command's usage and exits 0. Field values are never printed. Each command that changes the store writes an audit row event=admin, sub=cli:credsctl.
credsctl profile list
credsctl profile delete <id>
credsctl profile import <id> [--variant <name>] --from-file <path|-> \
[--if-absent] [--ignore-unknown] [--no-check] [--description <text>]
credsctl bind <subject> <profile> [--replace] [--position N]
credsctl unbind <subject>
credsctl bindings
credsctl sync --file <path|-> [--dry-run] [--prune] [--no-check]
credsctl store status
credsctl store rekey --old-key-file <path|->
credsctl audit [--since 24h] [--profile <id>] [--event <name>]
credsctl snapshot list
credsctl snapshot export <id>
credsctl statusprofile import reads an env file (NAME=value, blank lines and # comments skipped, quotes left as typed). The file must be mode 0600 unless the path is - (stdin). Without --variant, the variant is the one whose fields are exactly the non-empty provider fields in the file. A file that matches none (or more than one) is variant_ambiguous. Keys that are not provider fields are unknown_fields unless --ignore-unknown is set, in which case those names are skipped and listed as ignored. --if-absent on a profile that already exists exits 4 and writes nothing. Without --if-absent, import of an existing profile replaces its variant, non-secret fields, and secrets as a new secret version (action: rotated); its bindings stay, and its running instance is stopped and starts again on the next call. Import does not bind the profile; bind is a separate command. Without --no-check, the variant's HTTP checks run before anything is written. A refusal exits 1 and leaves the store unchanged. If a check cannot be reached, the import still exits 0 (check: unreachable) and last_check_ok stays null. --no-check stores the values without a check (check: skipped, last_check_ok null).
bindings prints user, then role with positions, then default — the same order a call is resolved in.
credsctl sync
sync brings the box's profiles and bindings to what a YAML file describes. To do the same from chat, without secrets in the file, see the profile set from chat. Run it by hand, or from cron or a systemd timer: when nothing differs, it writes nothing, the secret version does not change, and no running instance is restarted.
version: 1
profiles:
atlassian/alexey: # a person
description: Alexey
fields: # non-secret fields, as they are
JIRA_URL: https://a.example.com
JIRA_USERNAME: alexey@example.com
secrets: # references only
JIRA_API_TOKEN: {file: alexey.token} # mode 0600
atlassian/backend: # a service account
env_file: backend.env # the env file profile import reads, mode 0600
atlassian/skynet: # the shared writer account
variant: jira_cloud # optional; otherwise chosen by the fields, as in import
fields:
JIRA_URL: https://b.example.com
JIRA_USERNAME: skynet@example.com
secrets:
JIRA_API_TOKEN: {env: SKYNET_JIRA_TOKEN}
bindings:
users: # user:<sub>; svc:<name> is a service account
alexey@example.com: atlassian/alexey
svc:backend: atlassian/backend
skynet@example.com: atlassian/skynet
roles: # list order is position 1, 2, 3
- atlassian_writer: atlassian/skynet
default: nullSecret values are never in the file itself. A secret is {file: <path>}, a mode 0600 file with the value on one line (a trailing line break is dropped), or {env: <NAME>}, an environment variable of the credsctl process; BOX_* variables are refused. env_file gives non-secret and secret fields together, in the format of profile import. Relative paths are relative to the YAML file, or to the working directory when the file is - (stdin). A secret field under fields is secret_in_file. A field may be given only once.
What sync does:
It reads and checks the whole file first: unknown keys and fields, secret references, variants, lengths, subjects, and that every bound profile is either in the file or already in the box. Any problem exits 3 and nothing is written.
A profile that is not in the box is created. A profile whose variant, non-secret fields,
description, or secrets differ is rotated: a new secret version, the same bindings, and its running instance is stopped and starts again on the next call. Secrets are compared in memory with the stored ones; neither is printed. A profile that does not differ is left as it is.Before it writes a profile,
syncruns that variant's checks, asprofile importdoes, unless--no-checkis set. A profile the provider rejects is skipped: a new one is not created, an existing one keeps its current values, and a binding to a skipped new profile is not made. The rest of the file is still applied, and the run exits 6. A check that cannot reach the provider still writes (check: unreachable).When the file has
bindings, each listed binding is set, replacing the profile it pointed at. Role bindings get positions in list order. Role bindings that are not in the file keep their order after the listed ones. A file without abindingskey does not touch bindings. A profile that is only in the box, for example one entered on the form, can be bound by its id without being listed underprofiles.A profile
syncwrites is markedmanaged_by: credsctl sync.
Without --prune, sync deletes nothing; profiles that are not in the file stay and are listed under not_in_file, and, when the file has bindings, bindings that are not in it stay and are listed under bindings with action: kept. With --prune:
every binding that is not in the file is removed, including
defaultwhen the file has none. The file is then the complete list of who gets which account.a profile that is not in the file is deleted only when it is marked
managed_by: credsctl sync. Profiles entered on the form, created in chat, or imported by hand stay, and are listed withmanaged: false. A marked profile that is still bound is not deleted (profile_in_use, exit 6).
--dry-run reads the file and the store and reports what would change, in the same shape, without running checks and without writing. It exits 0 when the file is valid, or 6 when a marked profile that --prune would delete is still bound.
With --json, result is:
{
"dry_run": false,
"prune": true,
"profiles": [
{"profile": "atlassian/alexey", "variant": "jira_cloud", "action": "rotated", "check": "ok"},
{"profile": "atlassian/backend", "variant": "jira_cloud", "action": "unchanged"}
],
"bindings": [
{"subject": "role:atlassian_writer", "profile": "atlassian/skynet", "action": "moved", "position": 1},
{"subject": "user:ann@example.com", "profile": "atlassian/manual", "action": "unbound"}
],
"not_in_file": [
{"profile": "atlassian/manual", "managed": false, "action": "kept"}
],
"errors": []
}A profile's action is created, rotated, unchanged, or skipped; check is ok, unreachable, skipped (--no-check), or rejected, and is absent when nothing was written. A binding's action is bound, rebound, moved, unbound, skipped, or kept (not in the file, left because --prune is off); bindings the file names and that already match are not listed. In not_in_file, action is kept, deleted, or delete_failed. In a dry run, the actions are what would happen. On exit 6, error is partially_applied and result is filled in; each entry of errors has id (check_failed, profile_skipped, profile_in_use, or profile_changed when someone else created or deleted the profile during the run), message, and profile or subject. Without --json, the same report is printed one line per change. Each change writes an audit row with command sync.
On a schedule, from the host. The YAML goes in on stdin; the secret files it names must be readable inside the container by uid 10001, for example from a read-only volume in the package override.
docker compose exec -T <service> credsctl --json sync --file - --prune < profiles.yamlsnapshot list prints the stored tool snapshots, newest first: id, sha256, taken_at, and profile, without the JSON. snapshot export <id> prints that snapshot's JSON on stdout byte for byte as stored. With --json, result is {"id", "sha256", "snapshot"} and snapshot is that same text as a string. An id that is not stored exits 3 with snapshot_not_found.
store status reports key_state, schema_version, how many profiles there are, and how many are needs_reentry. status prints the wrapper's status row. Until the wrapper is running it is {"status": "not running"}.
Exit codes:
Code | Meaning |
0 | Done. An import whose check could not reach the provider is still 0 ( |
1 | The provider rejected the values. Nothing was written. |
2 | Usage. |
3 | Invalid input: |
4 |
|
5 | The store cannot be opened, including a database newer than this program ( |
6 |
|
--json prints one object: {"ok", "code", "error": {"id", "message"}, "result"}. code is the exit code. error is null on success.
Moving an account out of a deploy env file, then binding it as the default. <service> is the service field of the container whose role is mcp, from keystonectl service status <name> --json (result.services[].containers[]). Do not build that name from the package name.
docker compose exec -T <service> \
credsctl profile import atlassian/shared --from-file - --if-absent --ignore-unknown \
< jira.env
docker compose exec -T <service> credsctl bind default atlassian/sharedBox tools of this image
stdio-box box-tools prints the names of this image's own creds_* tools as one JSON array on one line and exits 0:
["creds_providers", "creds_profiles", "creds_bindings", "creds_profile_create", "creds_profile_rotate", "creds_profile_delete", "creds_bind", "creds_unbind", "creds_whoami", "creds_check", "creds_snapshot_tools", "creds_desired_get", "creds_desired_set", "creds_sync"]It reads no configuration. Use it to learn which box tools an image has instead of reading its tag.
Releasing
IMAGE in the repository root is the pinned reference ghcr.io/keystone-codes/stdio-box:<version>@sha256:<digest>. Until the first release it has no digest line. Package manifests pin that same reference on containers.mcp.image. A package that is not part of the release keeps its own image.
Run the
Release imageworkflow (workflow_dispatch) with a version that matchespyproject.toml. The workflow tests, buildslinux/amd64andlinux/arm64, and pushesghcr.io/keystone-codes/stdio-box:<version>with provenance and an SBOM.It runs
scripts/pin_digest.pywith that image reference and opens a pull request. The pull request writes the reference intoIMAGEand intocontainers.mcp.imageof each package whose image is still the zero digest or a previousstdio-boxrelease. The description lists those packages and their versions.Merging that pull request to
mainrunsTag release. It checks the registry digest againstIMAGE, tagsv<version>, and for each manifest whoseimageequalsIMAGEtags<name>/v<version>when that tag does not exist yet. An existing package tag is left where it is. The same package version with different contents fails the workflow, because a tag is immutable. The GitHub release records the image digest and the package tags.
The next change to a provider is a new image version and a new patch version of that package.
Development
Requires Python 3.12, uv, and Docker.
uv sync --frozen
uv run ruff check .
uv run ruff format --check .
uv run pytest -q
docker build .pytest -q includes the image-layout test, which builds an image from the echo provider under tests/data/packages. pytest -q -m "not integration" skips it.
This server cannot be deployed
Maintenance
Related MCP Connectors
Governed MCP gateway: one endpoint for your tools, with credential custody and audit log.
Toolstile is a managed MCP gateway that connects remote MCP servers behind one endpoint, with per-agent permissions, shared accounts and activity logs. Connect at https://mcp.toolstile.com/mcp over Streamable HTTP with OAuth; sign in at https://toolstile.com, select a workspace and connect your upstream servers. Available tools, resources and prompts depend on that workspace and the agent's granted access.
MCP Gateway: wrap any MCP server with cold-start retries, uptime SLA, and per-execution MPP billing.
Unified gateway exposing 150+ tools across all NexGenData MCP servers via one endpoint.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceA universal MCP server for registering internal, external, and OpenAPI-based APIs as MCP tools. It exposes them to MCP clients via Streamable HTTP and provides admin portal, RBAC/session auth, credential injection, and audit logging.-
- AlicenseNot gradedqualityAmaintenanceEnables deploying a secure MCP gateway that proxies, sandboxes, and policy-governs third-party MCP servers, with credential vaulting and audited tool access across stdio, SSE, and Streamable HTTP.11 npmApache 2.0
- AlicenseNot gradedqualityAmaintenanceEnables connecting and managing multiple backend MCP servers and exposing their aggregated tools and resources through unified SSE or Streamable HTTP endpoints, or as a Stdio server, with request routing and optional authentication.104 npmMIT
- AlicenseNot gradedqualityBmaintenanceAggregates multiple upstream MCP servers, OpenAPI/REST APIs, and local tools behind scoped, token-authenticated endpoints with centralized governance, auditing, RBAC, and runtime tool controls.MIT