@kinnet/cli
v0.6.0
Published
The `kinnet` operator/developer CLI and the `kinnet-mcp` credential shim that gives an MCP server one kinnet identity.
Downloads
415
Maintainers
Readme
kinnet — operator/developer CLI
At a glance
The package ships two bins: kinnet (the reference below) and kinnet-mcp, the credential
shim that lets one kinnet identity walk into an MCP server as itself, instead of as a
per-server OAuth account. kinnet creates and rotates participant identities, publishes them
to a discovery service, issues and verifies represents edges and grants, registers nodes,
sends messages, and registers MCP servers for the shim. Every verification it reports is done
locally from signed records; discovery is a directory, not a trusted party.
npm install -g @kinnet/cliSee Install for running without a global install.
First commands to run:
kinnet login
kinnet mcp add <url>
kinnet whoamiContents
- Install
- State:
$KINNET_HOME - Discovery URL
- Custody URL
- Session
- Identity
- Profile
- Trust
- Nodes and messages
- MCP servers
- License
Install
npm install -g @kinnet/cliThat puts both bins on the path: kinnet and kinnet-mcp.
Without a global install, name the bin explicitly:
npx --package=@kinnet/cli kinnet <command>
npx --package=@kinnet/cli kinnet-mcp shim --resource <url>The package has two bins, so a bare npx @kinnet/cli has no single bin to pick and will not
run — --package=@kinnet/cli plus the bin name is the form that works.
From a checkout, run commands with pnpm dev in place of kinnet:
cd packages/cli
pnpm dev identity:create --file identity.jsonState: $KINNET_HOME
login stores what this machine acts as under $KINNET_HOME (default ~/.kinnet, mode
0700):
| Path | What |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| config.json | discoveryUrl, issuer, custodyUrl, lane, machineLabel, ownerId, ownerPublishedAt — written at login (mode 0600) |
| identity.json | the active identity, secret keys included (mode 0600) |
| grants/ | grant chains held for the active identity; cleared by logout |
| servers.json | the MCP servers kinnet mcp add registered, keyed by URL (mode 0600) |
The directory is created lazily — reading never makes it — and KINNET_HOME relocates all of
it, which is how a test or a second environment gets its own state.
An identity file is plaintext secret key material, so it is written at mode 0600 and the
session commands refuse to read one that is group- or world-readable, naming the file and the
fix (chmod 600 <file>). File modes are meaningless on Windows, where the check is skipped.
Discovery URL
--discovery <url> > KINNET_DISCOVERY_URL > config.json's discoveryUrl > the built-in
default http://localhost:4000.
The environment variable sits above the persisted value on purpose: it points one process at another discovery service without rewriting the operator's configuration, and the redirection ends when the process does.
On the binding-critical paths it must be https, or http on a loopback host. mcp add,
the shim and kinnet token decide which participant a resource URL is from records read out of
discovery, so plaintext to a routable host would hand that decision to whoever is on the path.
The commands that only publish or read public records are unaffected, and
http://localhost:4000 stays perfectly usable for local development.
Custody URL
--custody <url> > KINNET_CUSTODY_URL > config.json's custodyUrl. Same precedence, and
no built-in default: custody holds a human's root keys, and a CLI that guessed at which
deployment that is would print an approval URL for the wrong one. It is needed only by the
human lane — the device-grant consent at mcp add and login --renew.
It is held to the same scheme rule as discovery — https, or http on loopback — and refused
at login, mcp add and login --renew rather than at the first consent, so a machine is never
created against a custody URL it will not be able to use.
Session
kinnet login [--label <name>] [--custody <url>] [--discovery <url>] [--issuer <url>]
kinnet login --identity <path> [--discovery <url>] [--issuer <url>] [--custody <url>]
kinnet login --renew [<resource-url>] [--grant-ttl <days>] [--timeout <s>] [--no-browser]
kinnet whoami
kinnet logoutlogin with no --identity is the human lane: this machine becomes a participant of
its own (type: application, displayName = --label or user@hostname), its key log and a
minimal profile are published to discovery, and config.json records lane: "machine". It is
idempotent — a second login prints the machine id and stops, because a second participant
would orphan every grant the human has already approved for the first.
No grant is minted at login. Consent happens per server, at mcp add, for exactly the
abilities picked there (D-K2): an umbrella grant taken at login would have to name every server
up front, and a namespace-wide one is refused by the resources that matter anyway. The machine
is published before anything asks custody to verify a signature from it, which is the
device-grant flow's stated precondition.
login --identity <path> is the service-identity form: the operator already holds the
identity file. It verifies the identity's key log resolves at discovery — an unpublished
identity is refused with the kinnet publish --file … command to run, and nothing is written —
then copies the identity into $KINNET_HOME/identity.json at mode 0600, persists the URLs,
and records lane: "service".
login --renew [<resource-url>] re-runs the consent for every registered server whose grant is
missing or expires within 14 days (or just the one named). It touches no harness configuration:
the entries are already written, and this is only about the authority behind them. There is no
silent renewal — each server that needs one costs the same single browser tap the first consent
did. When a held grant is renewed, its superseded local copy is removed from this machine and its
digest is printed so it can be revoked from the kinnet account that approved it; it stays valid at
custody until it expires or is revoked there. A grant installed with grant:hold is not custody's
to re-sign: --renew reports it as not renewable here (exit 3) and names the re-issue instead of
running a ceremony for it.
whoami prints the active participant id, the lane, the discovery and custody URLs, and every
held grant: who it is from, its digest, its abilities, its aud, and when it expires, flagging
the ones inside the 14-day window.
logout removes identity.json and grants/, keeping config.json — and prints the digest of
every grant it forgot. Forgetting is not revoking: a machine cannot revoke the grant a human
signed for it, because only the issuer or an upstream participant may (spec 008). Revoke those
digests from the kinnet account that approved them, where they are listed beside the human's
other consents.
Identity
kinnet identity:create [--file <path>] # keys + signed key log, written at mode 0600
kinnet identity:rotate --file <path> # rotate to the pre-committed next key
kinnet publish --file <path> # signed write (RFC 9421) of the key log
kinnet resolve <participant-id> # fetch and replay the log locally (spec 003)Profile
kinnet profile:publish [--file <identity>] --type <type> --display-name <name> \
[--description <text>] [--verified-domain <host[:port]>]... \
[--capability <label>]... [--owner <pk_…>] [--discovery <url>]Publishes how the participant presents itself in the public directory. --file defaults to
the logged-in identity. --type is one of person, organization, team, application,
service, workflow, agent.
--verified-domain is the participant's own signed assertion that it operates a service at a
host — the record a client checks before naming this participant as the aud of a chain it
is about to hand over as a bearer token. An entry is a lowercase host or host:port and is
matched exactly, with no suffix or wildcard inference, so anything else (Example.com,
https://x, x/y, *.x) is refused rather than published as an entry that could never
match. A resource operator publishes it once per environment:
kinnet profile:publish --file ~/.kinnet/server-prod.json \
--type service --display-name "Notes" \
--verified-domain mcp.example.comA development resource asserts its port: --verified-domain localhost:8787.
capabilities and verifiedDomains are written into the signed record even when empty (spec
017): the schema defaults them on parse, so a record signed without them would gain two fields
its signature never covered.
Trust
kinnet represents:issue --file <org> --agent <id> [--expires <iso-datetime>]
kinnet represents:verify <agent-id> <org-id> [--grants <path>] [--as <verifier-id>]
[--amount <d>] [--pay-currency <CODE>] [--pay-to <id>]
[--action-id <id>] [--approval-tier <n>]
kinnet grant:issue --file <org> --agent <id> --ability <a> [--ability <b>]...
[--aud <id>]... [--expires <iso>] [--out <path>]
[--max-amount <d>] [--currency <CODE>] [--beneficiary <id>]...
[--action-id <id>] [--approval-tier <n>]
kinnet grant:hold <path> [--discovery <url>]
kinnet revoke --file <path> <digest> [--reason <text>]Grants are bearer records: nothing is stored in discovery, and the agent presents the chain to
verifiers — so a chain written with --out is created 0600, and an existing file at that path
is tightened to 0600, the same treatment the identity file gets. A grant carrying financial caveats fails closed at verification unless the verifier
describes the pending action with the --amount / --pay-currency / --pay-to /
--action-id / --approval-tier flags.
grant:hold <path> installs a human → machine grant this machine is the audience of into
$KINNET_HOME/grants/, where the shim mints leaves under it — the step that lets a grant signed
by an identity file, rather than by the device-grant consent, actually be used. <path> is a
single grant or a one-link chain file as grant:issue --out writes it; a chain that already has
a leaf is refused, because grant:hold holds the parent and the shim mints the leaf per request.
It refuses anything it cannot stand behind, and stores nothing when it does: a grant delegated to
another participant, an issuer whose key log discovery does not have, a signature that does not
verify against that log, a grant with no aud caveat (holding it would let this machine
present the issuer's authority at any server — re-issue with --aud <resource participant id>),
no expiry or a past one, and a subject that is not the human this machine is pinned to. Holding
the same grant twice is reported and exits 0. It does not change the lane.
Nodes and messages
kinnet node:register --file <path> --endpoint <url> [--node-id <id>]
kinnet inbox:enroll --file <path> --node <url>
kinnet message:send --file <path> --to <id> --text <text> [--node <url>]
kinnet message:list --file <path> [--node <url>] [--with <id>] [--after <seq>]MCP servers
One kinnet identity walks into an MCP server as itself, instead of as a per-server OAuth
account. Two pieces: kinnet mcp add, which is where a human decides things once, and
kinnet-mcp shim, which runs unattended afterwards and asks nothing.
kinnet mcp add <url> [--local|--global] [--harness claude|codex|both] [--name <n>]
[--scope <ability>]... [--all-scopes] [--yes]
[--ttl <seconds>] [--direct] [--trust-participant <pk_…>]
[--discovery <url>] [--custody <url>]
[--grant-ttl <days>] [--timeout <seconds>] [--no-browser]
kinnet mcp list
kinnet mcp rm <url-or-name>
kinnet token --resource <url> [--ttl <seconds>]What mcp add decides
- Probe. Fetch the server's RFC 9728 protected-resource document from both well-known locations (the resource-metadata profile).
- Bind.
kinnet_participant_idis served unauthenticated by the server itself, so it is a claim. It is checked against a record that participant signed in this machine's own discovery —ParticipantProfile.verifiedDomainsmust list the resource URL's host, matched exactly. A hostile server can publish another participant's id; it cannot make that participant's profile assert its host. - Select the mode. Chain door plus a holding binding ⇒ mode A (self-issue). No chain door,
but the configured kinnet issuer listed in
authorization_servers⇒ mode C (the issuer mints the token — see below). A server that offers the chain door and fails the binding check is refused, never downgraded to an IdP login: the downgrade would hide exactly the attack the check exists for. In mode C the same refusal applies at the second door — a server that publishes a participant id it cannot bind is refused rather than re-audienced to the issuer. - Consent. The server's
scopes_supportedis shown with read-shaped abilities (last segmentread/list/get/search) pre-selected and everything else off. Non-interactive runs must be driven by--scope(repeatable),--all-scopes, or--yes; with no terminal and no flag the command stops rather than choosing. - Consent for real, in the human lane. If this machine was logged in by a human
(
lane: "machine") and no held grant literally lists the chosen abilities for this server, the device-grant flow runs here — see below. In the service lane there is nobody to ask and nothing to run: the identity self-issues. - Persist and configure.
$KINNET_HOME/servers.jsongets{ resource, participantId, scopes, mode, ttlSeconds, binding, addedAt, configs }at mode0600— plus{ issuer, tokenEndpoint, audienceId, audienceSource }for a mode-C entry — and the harness's own configuration gets one entry.
Mode C: the kinnet issuer mints the token
A server that verifies pnc1. chains itself is mode A and never involves an IdP. A server that
only does standard OAuth, and points at the kinnet issuer this machine is configured with
(kinnet login --issuer <url>, config.json's issuer), is mode C: the chain the shim
would have sent to a chain door is sent to the issuer instead, as a client assertion, and the
issuer answers with the token the server actually accepts
(the client-assertion grant profile the issuer implements).
A mode-C refresh is a round trip to the issuer, so an issuer outage during a long session is felt where mode A never is: the shim keeps serving the token it holds (a warning on stderr) until it actually expires, and only then surfaces the issuer error to the harness. With the default 1 h token that is a ≥ 12-minute grace after the first failed refresh at 80 %.
What goes out is one client_credentials request:
POST <token_endpoint> Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=<the leaf's issuer: this machine or this service>
&client_assertion_type=urn:kinnet:params:oauth:client-assertion-type:pnc1
&client_assertion=pnc1.<the same chain mode A would have minted>
&resource=<the MCP endpoint> (RFC 8707)
&scope=<the consented abilities>The token endpoint comes from the issuer's own RFC 8414 document
(/.well-known/oauth-authorization-server, falling back to /.well-known/openid-configuration),
read once per process with the same guards as the resource probe: https or loopback, no
redirects, a byte ceiling, a deadline, strict JSON, issuer matching the configured one, and a
token endpoint on the issuer's own origin.
Two audiences, and the server's own document decides which:
| The server publishes | The chain names | The server receives |
| ------------------------------------------- | --------------------- | ----------------------------------------------------------------- |
| kinnet_participant_id (binding must hold) | that participant | the same chain, verified at its own door exactly as in mode A |
| no participant id | the issuer itself | an ES256 JWT it verifies against the issuer's JWKS |
A JWT-only server therefore needs no kinnet code at all: it sees a standard bearer token from
the authorization server it already points at, with sub naming the human (or the service),
client_id naming the machine, aud naming the resource, and scope carrying the consented
abilities. What it never sees is a chain.
In the human lane the consent is requested for the decided audience — aud: [audienceId] —
so a machine consented for a JWT-only server holds a grant naming the issuer, and that grant
opens nothing else.
What mcp add prints for a mode-C server with no participant id of its own:
$ kinnet mcp add https://tickets.example/mcp --scope tickets/read
Added tickets.example -> https://tickets.example/mcp
participant: pk_z6Mki… (the issuer: this server publishes none)
binding: none
mode: C (token issued by the kinnet issuer)
issuer: https://id.kinnet.humanmeetsai.com
token: https://id.kinnet.humanmeetsai.com/token
audience: pk_z6Mki… (the kinnet issuer — the server sees a JWT)
abilities: tickets/read
ttl: 3600s
config: /Users/an/.claude.jsonA server that publishes an id instead reports binding: verified and
audience: pk_… (the resource — it is handed back the same chain).
kinnet mcp list shows the issuer and which of the two audiences applies, and
kinnet token --resource <url> for a mode-C entry prints the token the server accepts (the
JWT, for a shallow server) rather than the assertion that bought it.
The device-grant consent (human lane)
kinnet mcp add https://mcp.example.com/mcp --scope notes/read
Consent required. Approve this machine in a browser:
code: BCDF-GHJK
url: https://id.kinnet.humanmeetsai.com/approve?code=BCDF-GHJK
for: notes/read
Approved by pk_zHuman…
grant: zQm…
expires: 2026-11-23T00:00:00.000Z
stored: ~/.kinnet/grants/zQm….jsonWhat happens between those two blocks (the device-grant consent flow): the machine
opens a consent request at custody, RFC 9421-signed as itself, naming the chosen abilities, an
expiry (90 days by default, --grant-ttl <days>, custody's own cap) and the resource's
participant id as the aud caveat. The CLI prints the code, tries to open the approval page
(--no-browser prints it instead), and polls at the interval custody hands back until the human
approves with a passkey, denies, or the ten-minute request expires (--timeout <seconds>,
default 600).
The code is safe to read aloud. The request names this machine as its audience and custody demands that participant's signature to collect the answer, so whoever holds the code can look it up and deny it but can never receive the grant — it goes to the key that asked.
The approval URL is printed and opened only when it is https (or http on loopback) at an
origin this machine's own configuration names — custody's, or the configured --issuer's,
because the approval page is the identity site's. Anything else and only the code and custody's
origin are printed, and nothing is opened. No launcher is ever invoked through a shell, on any
platform.
What comes back is not a token: it is a spec-009 Grant signed by the human's root key, and it has
to match the request rather than merely cover it before anything is stored — delegated to
this machine, a root link the human signed for their own authority (issuerId == subjectId, no
parent proof), aud exactly the resource that was asked about, abilities exactly the set that
was shown, an expiry no later than the one requested, and the subject this machine is pinned to.
A grant that carries more than that is authority nobody at the terminal ever saw, and the machine
would delegate leaves from it silently for as long as it lasts. Any mismatch is exit 3 and
nothing stored, exactly like a denial. What passes is stored at
$KINNET_HOME/grants/<digest>.json, mode 0600. It appears in the human's account beside their
other consents, under the machine's label, and is revoked from there by that digest — which is
also the actor a resource records for every request the machine makes under it.
Who the machine belongs to
The consent row is subject-blind: custody looks a user code up for any enrolled human, so whoever reaches the approval page first is who signs. That is what keeps the code from being an existence oracle for accounts, but it means the first consent on an unpinned machine is a race — and the winner's authority is what the machine then holds.
So the owner is pinned, one way or the other:
kinnet login --owner pk_zHuman… # decided in advance
kinnet mcp add <url> --owner pk_zHuman… # same, at the first serverWith a pin in place, a consent approved by anyone else is refused with both ids named and
nothing is stored. With no pin, the first approval is shown before it is stored — the approver's
participant id, and their displayName from discovery, labelled as coming from there because it
is a hint and not evidence — and a human confirms it. --yes accepts without asking; with no
terminal and neither flag, nothing is stored and the message names the --owner value that would
have worked.
The pin is written locally the moment a grant is accepted, whether or not discovery is reachable:
it is what refuses a second human, so it must not depend on a service being up. Republishing the
machine's own profile with ownerId is the public half, and ownerPublishedAt records whether it
landed — a failure is a warning, and the next consent retries it.
Denied, expired, or timed out: exit 3, and nothing is written — no servers.json entry, no
harness configuration, no grant. Run the command again.
--trust-participant <pk_…> records a manual binding for a development server whose profile
is not published yet. It is honoured for loopback URLs only (http on localhost,
127.0.0.1, [::1]) and refused on anything else before a single request is made; on a
routable host the binding check is the whole defence. It is also refused when it contradicts
an id the document itself publishes.
mcp list prints every entry with its mode, binding, abilities, the configuration files that
reference it, and whether a held grant actually covers it — asked of the same selector the shim
mints through, so the listing can never claim a server is usable that the shim would refuse. mcp rm deletes the entry and unwrites exactly the configuration entries
add wrote — including deleting a file add created, so a project directory comes back as it
was.
Harness configuration
Claude Code, .mcp.json in the current directory (--local, the default) or ~/.claude.json
(--global), under mcpServers.<name> where <name> is --name or the resource host:
{
"mcpServers": {
"mcp.example.com": {
"command": "kinnet-mcp",
"args": ["shim", "--resource", "https://mcp.example.com/mcp"]
}
}
}--direct writes an HTTP entry instead, for a harness that should talk to the resource itself
with a token from the environment:
{
"type": "http",
"url": "https://mcp.example.com/mcp",
"headers": { "Authorization": "Bearer ${KINNET_TOKEN_MCP_EXAMPLE_COM}" }
}Codex, ~/.codex/config.toml (one file per machine; --local and --global do not apply):
[mcp_servers."mcp.example.com"]
command = "kinnet-mcp"
args = ["shim", "--resource", "https://mcp.example.com/mcp"]A dotted host is quoted, or TOML would read it as a nested table.
--direct writes Codex's streamable-HTTP entry, which names the environment variable rather
than carrying the token:
[mcp_servers."mcp.example.com"]
url = "https://mcp.example.com/mcp"
bearer_token_env_var = "KINNET_TOKEN_MCP_EXAMPLE_COM"Same semantics as the Claude Code direct entry, same variable: the token is minted per session and never lands in either file.
These are your files, and unrelated content is preserved. The JSON writers add and remove
one key and re-serialize with the two-space indent both harnesses use themselves. The TOML
writer splices text rather than parsing a document — no TOML library is in this workspace and
adding one to reach two scalar keys would be a poor trade — so it recognizes only the shape it
writes: a [mcp_servers.<name>] header followed by simple key = value lines. An existing
section under that name, a sub-table under it ([mcp_servers.<name>.env]), or a section whose
lines it cannot read back (a multi-line array) is refused with the reason, and the file is
not touched. Pass --name <other>, or edit it by hand.
A name that is not a TOML bare key has exactly one spelling — the quoted one. [mcp_servers.a.b]
is table b inside table a, a different server's configuration, and is never matched for the
name a.b; a bare name written quoted by hand ([mcp_servers."notes"]) still is.
Three more refusals about somebody else's file, all of them leaving it untouched:
- A symlink is refused, not followed. Writing through one lands on a file the operator never named, and would change its mode along the way.
- A file this CLI did not create keeps its mode. Only a file
mcp addbrings into existence is written0600; tightening an existing~/.codex/config.tomlis a change nobody asked for and one that a build step reading it as another user would notice the hard way. - A JSON file whose numbers would not survive a re-serialization is refused.
12345678901234567890comes back fromJSON.parseas12345678901234800000, and rewriting the file would silently corrupt somebody's id. The message names the number.
The shim
kinnet-mcp shim --resource <url> [--discovery <url>] [--verbose]An MCP server on stdio facing the harness, an MCP client over Streamable HTTP facing the
resource, and one job between them: a kinnet credential on every outbound request — an
Authorization: Bearer header, or DPoP plus a proof of possession when the server asks for
one (below). Messages are forwarded verbatim in both directions — initialize (so the harness's
client info reaches the server and the server's own capabilities come back), tools/*,
resources/*, prompts/*, ping, completion/complete, logging/setLevel, and every
notification — so nothing here has an opinion about MCP's method list.
The server must be registered first: the shim runs unattended and refuses to invent a participant id, an ability list, or a binding verdict.
- Tokens live in memory only, for at most their TTL. Nothing is written to disk, nothing is logged, and stdout is never a log target — it is the MCP channel.
- Re-minting happens at 80% of the TTL, so a request never carries a token that expires in
flight. In mode C the 80% is measured against the issuer's
expires_in, not against the assertion's TTL — the issuer decides how long its token lives. - In mode C the issuer's document is read once, at startup, and cached for the process. An issuer that cannot refresh a token does not end the session: the token in hand keeps being served until it actually expires, with one warning per failure on stderr, and only then does the issuer's error reach the harness as a JSON-RPC error (redacted and capped like any other forwarded text). An issuer that is unreachable at startup is exit 4 — mode C mints nothing on its own.
- On a 401
invalid_tokenthe cached token is dropped, one fresh token is minted, and the request is retried once. A second 401 reaches the harness as a JSON-RPC error carrying the server's own message. - The binding is re-verified at startup, not only at
mcp add: profiles get republished and participants rotate, and a token must not be minted for a server that no longer binds. Amanualbinding is re-checked for being loopback too —servers.jsonis a file, and a file can travel to a machine where the same URL is not loopback at all. Anonebinding (mode C against a server that publishes no id) is re-checked for still publishing none, and a mode-C entry audienced to the issuer is re-checked against the id the issuer publishes today. - What the server says is redacted before it is forwarded. A resource that quotes the
Authorizationheader back in an error body would otherwise put the bearer on the shim's own stdout, and from there into the harness's session log; everypnc1.…run becomespnc1.<redacted>and the echoed text is capped at 512 characters. - DPoP is decided from the server's own metadata, per process. When the resource's RFC 9728
document lists
"EdDSA"indpop_signing_alg_values_supported— and, in mode C, the issuer's metadata does too — the shim mints a bound chain: a fresh Ed25519 session key per mint, the leaf audienced to that key rather than to the resource (itsaudcaveat still names the resource), and every request sent asAuthorization: DPoP <token>plus aDPoP:proof minted for that one request — newjti,iatnow, its method and canonical URL, the hash of the token beside it. The key never leaves the process, is never written and is never logged; a re-mint is a new key and a new leaf together. A401never falls back to a bearer, and ause_dpop_noncechallenge is answered once with the nonce, which is then sent proactively to that origin. Nothing about any of this is stored inservers.json— a server that starts or stops advertising DPoP is followed on the next start. A server that publishesdpop_bound_access_tokens_required: truethat this machine cannot satisfy is refused, atmcp addand at startup, rather than sent a bearer it has said it will reject. - Signed requests (mode B) when the server requires them. A resource that publishes
kinnet_request_signing_supported: ["rfc9421"]andkinnet_signed_requests_required: true— or that advertises signing as its only chain door — is reached with the same bound chain, in a different envelope: every request is signed by this process's session key (RFC 9421, spec 004's profile), the chain travels in the coveredPN-Grantsheader,content-digestcovers the body (the empty body for the SSEGETand theDELETE), and noAuthorizationheader is sent at all — the signature is the credential. That is what mode B adds over DPoP: the body is covered, so a captured request cannot be re-aimed or re-bodied. A body the shim cannot digest before sending (a stream, aFormData) is refused rather than signed as something else; a401gets one re-signed retry under a freshly minted pair; a413, a403and every 5xx are surfaced as they stand. Nothing downgrades: no response ever turns a signed session back into a bearer, and a server that requires a signature profile this CLI does not speak is refused atkinnet mcp add(exit 2) and at every start (exit 2). As with DPoP, nothing about it is stored inservers.json— the mode is re-derived from the metadata each time the shim starts. - A machine identity never silently self-issues.
lane: "machine"with an emptygrants/— a machine that has never consented, or one that was logged out and back in — exits 4 asking for the consent, rather than minting a chain that claims the machine's own authority in place of the human's. - Shutdown is clean on stdin closing or on
SIGINT/SIGTERM.
Token mode
Set a token in the environment and the shim uses it verbatim, opens no identity file, and mints nothing:
| Variable | Which server |
| --------------------- | ----------------------------------------------------------------------------- |
| KINNET_TOKEN_<SLUG> | that one resource; checked first |
| KINNET_TOKEN | the single registered resource — honoured only when exactly one is registered |
The other variables the CLI reads are KINNET_HOME (the state directory),
KINNET_DISCOVERY_URL and KINNET_CUSTODY_URL (both above).
<SLUG> is the resource host with every non-alphanumeric character folded to _, then
uppercased. The host includes a non-default port, so two development servers on one machine do
not share a token slot:
| Resource URL | Variable |
| ----------------------------- | ------------------------------ |
| https://mcp.example.com/mcp | KINNET_TOKEN_MCP_EXAMPLE_COM |
| http://localhost:8787/mcp | KINNET_TOKEN_LOCALHOST_8787 |
Three refusals keep a token from reaching a server it was not minted for — a bearer chain carries the caller's full consented authority, so a misdelivery is a credential handed to the wrong party:
- The bare
KINNET_TOKENis the single-server convenience and nothing more. On a machine whereservers.jsonholds two or more entries it is ambiguous by construction, so the shim refuses to start (exit 2) and names theKINNET_TOKEN_<SLUG>to set instead. - An injected value that decodes as a
pnc1.chain must be audienced to the registered participant. A chain for another participant is refused (exit 4) with both ids named, and no request is made. An opaque token is not guessed at — only a chain this process can decode is checked. - Two hosts that fold to one
<SLUG>cannot both be registered.notes.exampleandnotes-exampleproduce the same variable name, somcp addrefuses the second with both URLs named, before probing anything. Registering the same URL again is not a collision with itself.
The shim logs the token's remaining lifetime at startup when it decodes as a pnc1. chain, and
opaque token when it does not. It never logs the token. A 401 in token mode is passed through
rather than retried: there is nothing to mint a replacement from — the shim stays up and the
harness sees the server's error on that call, so a provisioner that rotates the variable and
restarts the server entry recovers without a crash loop.
Token mode still needs the servers.json entry for the resource (the shim reads the resource's
participant id and the harness wiring from it); an empty $KINNET_HOME exits 2, not 4. The unit
a sandbox provisioner ships is therefore servers.json + the token variable — kinnet mcp add
on the provisioning side produces the file, and no identity file travels with it.
kinnet token --resource <url> prints one freshly minted token on stdout — the value that
variable holds, and what a provisioner or a --direct entry needs. It is a bearer credential
carrying the caller's full consented authority; treat it as one.
It is always an unbound bearer, even for a server the shim would bind to or sign for. A DPoP-bound token is audienced to a session key that never leaves the process that minted it, so a bound token on somebody else's clipboard is a string nothing can present — printing one would be handing out a credential that cannot be used. Token mode is the same story from the other side: an injected token is carried verbatim as a bearer, whatever the resource advertises. Mode B has no printable form at all: the credential is a signature over one request, not a value.
The two lanes
A service identity self-issues: the chain is one link, subjectId = issuerId = the
service. A machine identity logged in by a human presents two links, leaf first — the
custody-signed human → machine grant from $KINNET_HOME/grants/, and the leaf the machine
delegates from it — so the resource sees who is asking rather than merely which program asked.
A machine that holds no grant literally covering the chosen abilities for that server needs one
consent, once, in a browser — the device-grant flow above, which mcp add runs on the spot. It
never quietly falls back to a self-issued root, which would claim the machine's own authority in
place of the human's; and kinnet token for a server whose consent has not happened refuses
with exit 3 rather than minting one.
Custody is one issuer of that grant, not the definition of the lane. The link is an ordinary
spec-011 grant, so a human who holds their own root keys signs exactly the same thing from an
identity file, with no custody service anywhere — grant:issue mints it and grant:hold
installs it, and from there mcp add, mcp list, kinnet token and the shim behave identically
to the custody-signed case, because none of them asks where the grant came from:
kinnet login --owner <root id>
kinnet grant:issue --file <root identity> --agent <machine id> \
--ability notes/read --aud <resource id> --expires <iso> --out grant.json
kinnet grant:hold grant.json
kinnet mcp add https://mcp.example.com/mcp --scope notes/read # no browser, no consentThe one command that does ask is login --renew: a grant installed this way is not custody's to
re-sign, so --renew says so and names the re-issue instead of running a ceremony that nobody
could answer.
Which lane a machine is in is recorded at login (config.json's lane), not guessed from
whether grants/ happens to be empty: a freshly logged-in machine and a service identity look
identical on disk, and only one of them owes a human a tap.
Exit codes
kinnet mcp add:
| Code | Meaning | | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | 0 | registered | | 1 | a bad flag, or an error while writing | | 2 | refused: not kinnet-aware, binding failed, the issuer did not answer, no abilities chosen, or the server requires DPoP-bound tokens this machine cannot mint | | 3 | the consent was denied, expired, or timed out (or cannot be run here) |
kinnet-mcp shim:
| Code | Meaning |
| ---- | ------------------------------------------------------------------------------------------------------------------- |
| 0 | ran and shut down cleanly |
| 1 | unexpected failure |
| 2 | configuration: bad flags, no servers.json entry, or the server now requires bound tokens this machine cannot mint |
| 3 | the binding check no longer holds, or the issuer's participant id moved; refused to start |
| 4 | no credential: nobody logged in and no injected token, the first mint failed, or the issuer is silent |
License
Free to use, not open source (yet). The package is published under a use-only license: install
it and run it, commercially included, but the software may not be modified or redistributed —
see LICENSE for the exact terms. The implementation ships minified. The intent is to open the
source as the network matures.
