npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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/cli

See Install for running without a global install.

First commands to run:

kinnet login
kinnet mcp add <url>
kinnet whoami

Contents

Install

npm install -g @kinnet/cli

That 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.json

State: $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 logout

login 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.com

A 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

  1. Probe. Fetch the server's RFC 9728 protected-resource document from both well-known locations (the resource-metadata profile).
  2. Bind. kinnet_participant_id is 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.verifiedDomains must 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.
  3. 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.
  4. Consent. The server's scopes_supported is shown with read-shaped abilities (last segment read/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.
  5. 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.
  6. Persist and configure. $KINNET_HOME/servers.json gets { resource, participantId, scopes, mode, ttlSeconds, binding, addedAt, configs } at mode 0600 — 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 audienceaud: [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.json

A 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….json

What 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 server

With 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 add brings into existence is written 0600; tightening an existing ~/.codex/config.toml is 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. 12345678901234567890 comes back from JSON.parse as 12345678901234800000, 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_token the 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. A manual binding is re-checked for being loopback too — servers.json is a file, and a file can travel to a machine where the same URL is not loopback at all. A none binding (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 Authorization header 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; every pnc1.… run becomes pnc1.<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" in dpop_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 (its aud caveat still names the resource), and every request sent as Authorization: DPoP <token> plus a DPoP: proof minted for that one request — new jti, iat now, 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. A 401 never falls back to a bearer, and a use_dpop_nonce challenge is answered once with the nonce, which is then sent proactively to that origin. Nothing about any of this is stored in servers.json — a server that starts or stops advertising DPoP is followed on the next start. A server that publishes dpop_bound_access_tokens_required: true that this machine cannot satisfy is refused, at mcp add and 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"] and kinnet_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 covered PN-Grants header, content-digest covers the body (the empty body for the SSE GET and the DELETE), and no Authorization header 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, a FormData) is refused rather than signed as something else; a 401 gets one re-signed retry under a freshly minted pair; a 413, a 403 and 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 at kinnet mcp add (exit 2) and at every start (exit 2). As with DPoP, nothing about it is stored in servers.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 empty grants/ — 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_TOKEN is the single-server convenience and nothing more. On a machine where servers.json holds two or more entries it is ambiguous by construction, so the shim refuses to start (exit 2) and names the KINNET_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.example and notes-example produce the same variable name, so mcp add refuses 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 variablekinnet 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 consent

The 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.