Documentation
¶
Overview ¶
Package pairing decides who is allowed to drive this server.
What this is not ¶
The MCP transport here is stdio: the client launches the binary as a child process and talks to it over an anonymous pipe pair. That channel is already private — there is no port, no socket, and nothing on the wire for anyone to read without the privileges to debug the process outright, at which point no token would help either. So none of this encrypts or signs the conversation. There is nothing there to protect.
What it is ¶
The gap that does exist is authorization to launch. Today any local process can execute this binary, speak MCP down its stdio, and use the server as a confused deputy: it spawns a headless coding agent that inherits this machine's environment — SSH keys, VPN routes, an unlocked credential agent — and by default may edit files. Nothing distinguishes the MCP client the user configured from a rogue npm postinstall script.
Pairing closes that. A secret is minted once, stored here as a hash, and handed to the client through its own config; a launcher that cannot present it gets a server that refuses to do anything.
The limit, stated plainly ¶
The secret lives in the client's config file and in this process's environment, both readable by anything running as the same user. An attacker who already has that read access takes the token and is indistinguishable from the real client. Pairing raises the bar — it stops code that can execute but not rummage through the user's profile — it is not a wall against a same-user attacker. Parent binding (see parent.go) is the second layer that narrows what a stolen token is worth.
Index ¶
- Constants
- func ConfigPath() (string, error)
- func Explain(r Result) (stderr, client string)
- func InstallToken(path, exe, secret string) (written string, created bool, err error)
- func Labels(f *File) string
- func Mint(stateDir, label string, noBind bool) (secret string, err error)
- func ParentExe() string
- func Path(stateDir string) string
- func Revoke(stateDir, label string) (bool, error)
- func Run(args []string, resolveStateDir func(string) string) int
- func Save(stateDir string, f *File) error
- func StartupLine(stateDir string, r Result) string
- func StatusName(s Status) string
- func Unbind(stateDir, label string) (bool, error)
- func Unpair(stateDir string) error
- type File
- type Parent
- type Result
- type Status
- type Token
Constants ¶
const DefaultLabel = "claude-desktop"
DefaultLabel is the client most installs pair first.
const EnvVar = "CLI_AGENT_MCP_TOKEN"
EnvVar carries the secret from the client's config into this process.
const FileName = "pairing.json"
FileName is the pairing record inside the state directory.
const ServerKey = "cli-agent-mcp"
ServerKey is the name this server is registered under in a client's mcpServers map.
Variables ¶
This section is empty.
Functions ¶
func ConfigPath ¶
ConfigPath is where Claude Desktop keeps its MCP configuration.
Cowork and other hosts read their own files; --config exists for those, and for anyone whose install does not sit in the usual place.
func Explain ¶
Explain renders a rejection for the two audiences that need it.
The stderr line is for the person reading a log; the client line is for the model on the other end of the pipe, which is the only party that will ever see a tool result. Getting the second one right matters more than it looks: the alternative — exiting non-zero on a bad token — surfaces in Claude Desktop as nothing but "server disconnected", and the user has no way to tell a pairing problem from a crash, a bad path, or a missing binary.
func InstallToken ¶
InstallToken adds or updates this server's entry in a client config, setting the pairing token in its env block. It returns the file it wrote and whether that file had to be CREATED.
Why "created" is worth returning ¶
Writing the token is not the same as the client reading it, and this cannot tell the difference — it edits a path, and whether the client actually loads that path is beyond anything this process can see. The one signal available is whether the file was already there: **a config that did not exist is a config the client was not using**, and that is the case where pairing looks like it worked and silently did not (issue #25).
Measured on a real install: this created `claude_desktop_config.json` from nothing, wrote a correct entry into it, and reported success — while the server that actually runs is registered as a connector held outside the filesystem entirely. There was no local file to edit, and the user found out by losing their MCP on the next restart.
The file is decoded into plain maps and re-encoded, never into a struct: a client config holds entries for other servers and settings this build has never heard of, and round-tripping through a typed shape would quietly drop every one of them.
func Mint ¶
Mint issues a token under label, replacing any existing token with that label. It returns the secret, which is the only time it exists in plaintext here — nothing but the hash is ever written to disk.
func ParentExe ¶
func ParentExe() string
ParentExe reports the absolute path of the executable that launched this process, or "" when the platform cannot answer.
Why bind to the launcher at all ¶
A token alone is only as private as the file it is configured in, and that file is readable by anything running as the user. Binding adds a second fact the attacker has to satisfy — "you must be started by that exact program" — which a secret copied out of a config file does not give them.
What binding does not stop ¶
On Windows a process able to open the real client with PROCESS_CREATE_PROCESS can declare it as the parent of something it spawns, so a determined same-user attacker can present the right lineage. Verifying the parent's code signature would not help there either: the spoofed parent *is* the genuine, correctly signed client. That is why this records a path rather than an Authenticode or Team ID chain — the extra machinery would buy nothing against the attack that actually applies, and the path is what an attacker who cannot write to the client's install directory genuinely cannot fake.
Treat it as the layer that makes a leaked token much less useful, not as a boundary that survives an attacker already running code as the user.
func Save ¶
Save writes the record with an owner-only mode, replacing any previous one.
The 0600 is honoured on Unix. Windows ignores the mode bits entirely — Go creates the file with the directory's inherited ACL — so on that platform the protection is the state directory's own location under the user's roaming profile, which is already user-scoped. Tightening the ACL further would mean hand-rolling SetNamedSecurityInfo for a file whose contents are hashes, not secrets; the secret itself is never written here.
func StartupLine ¶
StartupLine is what the server logs about its own pairing state.
func Unbind ¶
Unbind clears a token's recorded launcher, so the next successful use records a new one. This is the way out of a legitimate change — the client was reinstalled somewhere else, or is now started through a different wrapper — without minting a new secret and editing the client's config again.
Types ¶
type File ¶
type File struct {
Version int `json:"version"`
Tokens []Token `json:"tokens"`
// EnforceNow skips the trial described on Armed: no window, locked from the
// moment the record is written. Waiting for the token is the right default,
// not the right answer for everyone — a scripted install already knows the
// token arrives, and some operators simply will not have the window.
EnforceNow bool `json:"enforce_now,omitempty"`
// ConfirmedAt is when a launcher first presented a valid token — the
// evidence enforcement waits for.
//
// It lives on the record rather than being derived from Token.LastUsed, and
// the difference matters: rotating a token would leave a record whose only
// token had never been used, drop it back into its trial, and silently
// reopen a door shut months ago. Rotating a credential is not evidence that
// pairing stopped working.
//
// Only --unpair clears it, by removing the record.
//
// The omitempty is inert — encoding/json does not omit a zero struct, so an
// unconfirmed record carries "confirmed_at":"0001-01-01T00:00:00Z" rather
// than nothing. Harmless, since every reader goes through Confirmed() and
// IsZero(), but the tag promises something it does not do and someone will
// eventually read the file and wonder. Kept for the day it stops being a
// struct; not worth a pointer to fix the cosmetics of a field nobody edits
// by hand.
//
// AN OLDER SERVER READING THIS RECORD DOES NOT KNOW ANY OF IT. It sees a
// pairing with tokens and enforces immediately — the lockout the trial
// exists to prevent, arriving through a version mismatch. Measured against
// the v0.13.0 release binary: it refuses where this build serves. That is
// why runMint tells the user to install first and pair afterwards.
ConfirmedAt time.Time `json:"confirmed_at,omitempty"`
}
File is the whole pairing record.
func Load ¶
Load reads the pairing record. A missing file is not an error: it is the unpaired state, and the caller distinguishes it with the returned bool.
type Result ¶
type Result struct {
Status Status
Label string // the matched token, when there is one
Detail string
// Launcher is the executable that started this process, when the platform
// could answer. It is carried into the rejection because it is the single
// most useful fact for someone locked out: the token has to live in THAT
// program's configuration, and the installer may well have written it
// somewhere else entirely (issue #25).
Launcher string
}
Result reports the check and carries enough detail to tell the user what to do about it, both on stderr and inside the client conversation.
func Verify ¶
Verify checks a presented secret against the record and, on a match, enforces (or on first use records) the launcher binding.
parentExe is the executable of the process that launched this one, empty if it could not be determined. An unknown parent never blocks a valid token: this layer is here to narrow what a stolen secret is worth, and failing shut on a platform where the lookup is unavailable would deny the legitimate user their own server for no gain in safety.
type Status ¶
type Status int
Status is the outcome of checking a launcher's credentials.
const ( // Unpaired means no pairing record exists. The server runs as it always // did — an upgrade must not brick a working install — and says so on // stderr. Running `pair` once switches enforcement on permanently. Unpaired Status = iota // OK means the presented secret matched a live token. OK // NoToken means the server is paired but the launcher presented nothing. NoToken // BadToken means a secret was presented and matched nothing. BadToken // ForeignParent means the secret was valid but the process that launched // this one is not the launcher the token is bound to. ForeignParent // Armed means the record exists but no token has ever worked here, so this // launch is served instead of refused. // // Turning on authentication can cost you the ability to undo it: if the // token never reaches the server, the client stops working and the fix is a // terminal command nobody knows. SSH keeps password auth until you prove // the key in a second session; a router reverts unless you confirm. Same // shape: the risky change stays provisional until it is seen to work. // // So enforcement waits for evidence. The first launch presenting a valid // token confirms the pairing, permanently. Until then the server serves and // says so loudly. // // The price, stated rather than hidden: in that window any launcher gets in. // It closes at the user's next client restart — which is exactly when they // would otherwise be locked out — and --enforce-now skips it entirely. Armed )
type Token ¶
type Token struct {
Label string `json:"label"`
Hash string `json:"hash"` // hex SHA-256 of the secret
Created time.Time `json:"created"`
LastUsed time.Time `json:"last_used,omitempty"`
// Parent is the launcher this token has been bound to, recorded on first
// successful use. Nil means "not bound yet"; NoBind means the operator
// turned binding off for this token.
Parent *Parent `json:"parent,omitempty"`
NoBind bool `json:"no_bind,omitempty"`
}
Token is one issued credential. Labels exist so the clients that drive this server — Claude Desktop and Cowork are separate launchers, with separate config files — hold separate secrets: either can be revoked without disturbing the other, and the audit trail names which one was used.