
Security News
Deno Goes the Way of the Dinosaur
Cloudflare is acquiring Deno’s team, which will provide another year of bug fixes and security updates before ending support for the runtime.
connections-mcp
Advanced tools
Connections Local MCP - a thin SELF-UPDATING loader that fetches its implementation from AWS at launch (push fixes to AWS, never re-publish). Local-execution companion (shell/git/files/ssh/browser) + browser sign-in for the remote Connections (Studio) MCP
A local MCP server - a small Node program that runs on your machine and gives an AI client
(Claude Code, Cursor, …) full local power: run any command, drive git (commit/push your working
tree), read/write files, and ssh to servers.
It is the companion to the remote Connections MCP (https://studio.connectionsapi.com/v1/mcp,
the Studio lambda + credential vault). The split is by where the code runs, not by capability:
| Connections remote MCP (Studio) | Connections local MCP (this) | |
|---|---|---|
| Runs | in the cloud (a lambda) | on your machine |
| Reaches | AWS/GitHub/… via the vault; deploys; DB | your shell, files, git, local network, ssh |
| Transport | Streamable HTTP (public) | stdio (this process) |
| Why | cloud creds stay server-side, governed, multi-account | needs your machine - exactly like a VS Code extension would |
Together they cover everything SayDeploy did. (Browser automation: run the official Playwright MCP alongside. Desktop control: out of scope.)
shell - run any command ({ command, cwd?, shell?, timeoutMs? }); shell = auto | bash | powershell | cmd.shell_job_wait / shell_job_tail / shell_job_kill - follow a shell { detached:true } run by the job it returns: wait with a bound ({ job, timeoutMs?, until?, lines? }, for the exit or for a line such as "ready on port"), read its output with colour codes and \r spinner redraws removed ({ job, lines? }, or { job, offset, maxBytes? } to page the whole log in UTF-8-safe windows), and stop its whole process tree with a verified kill that refuses a recycled pid. Declared in src-impl/26g-tools-shell-jobs.mjs.shell_policy - the command policy shell and ssh apply first ({ op: check | list | grant | revoke, command?, shell?, cwd?, prefix? }): dry-run a verdict, list the workspace rules and grants, record a human's prefix grant. See The shell command policy.git - git with an args array ({ args, cwd? }), e.g. ["commit","-m","msg"], ["push"].read_file - read a UTF-8 text file on this machine ({ path }). Returns the contents as text, capped at MAX_OUT with a …[TRUNCATED: N characters, cap MAX_OUT - this is NOT the whole answer] marker when larger; structuredContent is { path, chars, truncated } (never a second copy of the contents). A missing path is isError with error file_not_found, a folder is is_a_directory. If your own MCP client already has a native file-read tool, prefer that - this exists for a client with no local filesystem of its own.write_file - write a UTF-8 text file on this machine ({ path, content }). Overwrites, never appends - creates parent directories as needed and replaces the whole file; returns wrote N bytes → path, with structuredContent { path, bytes }.list_dir - list ONE directory's immediate entries on this machine ({ path }) - not recursive. Each row is type size name (d = directory, size blank; - = file, its byte size); capped at 2,000 entries with a …[truncated, N entries total] footer past that, and the text at MAX_OUT. structuredContent is { path, total, truncated, entries, entriesTruncated }, where entries ({ name, type: dir|file|unknown, size? }) holds only the first 200 rows. A missing path is isError with error directory_not_found, a file is not_a_directory.{ ok, data, markdown } outcome, every surface derived): docs/architecture/CONNECTIONS_MCP.md § The toolset form.retrieve_output - read the part of an oversize answer that did not fit ({ handle, offset?, length? } or { handle, find }). Any tool answer over the dispatcher's MAX_OUT (and browser_get_text past 50,000 characters) is first saved whole to ~/.connections/tool-results/<handle>.txt (handle = out- + the first 16 hex of its SHA-256, so the same bytes never write twice; files older than a week are pruned; the folder holds RAW tool output, secrets included when a tool returned them, so it is written owner-only: dir 0700, files 0600) and then cut by the gentlest of five phases: compact JSON, long strings shortened, a list's leading items kept, the largest field cut to a string (its short scalar fields kept whole in keptFields), or plain text with a marker. The cut answer carries truncatedPhase, truncatedNote and retrieveHandle; a 2xx answer says the call SUCCEEDED so a write is never repeated to see the rest. An answer past 80% of the cap gets a size note beside it. A failed save still returns the cut answer (src-impl/27-output-spill.mjs, capToolText in 90-prompts-and-dispatcher.mjs).ssh - run a command on a remote host over the system ssh ({ host, user?, command, identityFile?, port? }). sudo:true answers a sudo password prompt (any locale, or sudo-rs) from the connected credential's stored password, value-blind: the command runs under ssh -tt, only a prompt naming the credential's own user is answered, once, and the reply says only whether it was answered.sftp - move files to/from a remote host over SFTP (uses the system sftp) ({ op, remotePath?, localPath?, instance? | host?, user?, port?, identityFile?, timeoutMs? }). op is one of put | get | ls | mkdir | rm | rmdir (ls defaults to the login directory). Credentials work exactly like ssh - pass instance (a server connected in Studio; a box connected under EITHER the ssh or sftp card is reachable from both, same stored credential) or host/identityFile for a key already on this machine. Runs the batch line over -b - (STDIN), never argv, so a remote path never shows up in ps for every user on the box; port is -P, not ssh's -p - sftp and ssh spell it differently, and mixing them up silently connects to port 22.deploy_static - deploy a built non-plane static site: aws s3 cp --recursive, hashed assets/ immutable and everything else no-cache, and no CloudFront invalidation (billed per path; dropped 2026-09-30, the edge revalidates a no-cache file on the next request) ({ dir, bucket, prefix?, profile? }). Build first with shell. Refuses a registered plane's bucket (one carrying build-stamp.json, Main included): those ship with deploy-all-plane-web.mjs --from-sha origin/main --only <plane>.egress_ledger - the egress ledger: every ssh command, sftp put, deploy_static tree, lambda_deploy_zip bundle, cloudflare_worker_deploy module and browser_upload_file file first appends one content-free receipt to ~/.connections/egress/receipts.jsonl (0600; CONNECTIONS_EGRESS_LEDGER moves it): tool, sink, SHA-256 and byte count of the exact bytes, and prev = SHA-256 of the previous raw line, so an edited or deleted receipt breaks the chain. A send whose receipt cannot be written is REFUSED with EGRESS_RECEIPT_FAILED - nothing leaves unrecorded. { action: verify | recent | find, limit?, path?, sha256? }: verify recomputes the chain and prints its head hash, find answers "did this file ever leave, and where". Declared in src-impl/26h-tools-egress-ledger.mjs.http_call - an authenticated REST call written as only the real upstream URL, value-blind ({ url, method?, headers?, body?, instance?, timeoutMs? }). The URL's host picks the connected vault credential (built in: api.stripe.com, api.openai.com, api.anthropic.com, api.resend.com, api.cloudflare.com; more under "http" in ~/.connections/secret-bindings.json), leased in-process and attached as bearer, basic, a header or a query parameter. The reply names the key as <secret>service/instance</secret>; HTTP 401 comes back as credential_invalid and 403 as credential_forbidden, naming the service to re-connect. https only; redirects are reported, never followed.lambda_deploy_zip - targeted Lambda CODE deploy from a LOCAL .zip ({ functionName, zipPath, region?, instance?, publish?, dryRun? }): reads the zip + base64s it on your machine (a real ~30 KB bundle never has to be hand-authored), then UpdateFunctionCode through the vault (creds injected server-side). RevisionId-guarded - reads the function's current RevisionId and updates only if it still matches, so it won't clobber a concurrent deploy. dryRun reports the bundle bytes + current RevisionId/CodeSha256 without deploying. Lets an agent ship a code fix without waiting for the next CDK fromAsset deploy.architect_run - the Architect: code + database audit in ONE ({ scope: code|hosted|all, root?, check?, mode? }). code = the architecture gate of record (ever-expanding offline checks), hosted = the read-only database/cloud governor (recommends, never executes), all = both. It runs on your machine against your repository - no code is uploaded - and it works on ANY codebase, not just this one (architect_install lays down .arkitect/ for a repo that wants its own checks; only on an explicit request). ⚠️ METERED (2026-08-22): each run costs 20 MCP call units against your plan's weekly and 5-hour allowances, where an ordinary call costs 1 - so a free account's 500 calls/week buy ~25 runs if spent entirely here, and past the allowance a run is PRICED, not refused. Polling a hosted job (mode:'status') is free. The authorization is a single metered call to the remote door (architect_grant) that the local tool makes before it starts work; the price, the ladder and the reasoning live in services/studio/api/executor-mcp-turnstile-tariff.mjs.architect_ab - the Architect's detector A/B (same args as bun run arkitect:ab, minus --root): old bytes vs new bytes over the same tree, with the vault served to both arms on a loopback bridge so hosted checks are measured rather than left unmeasured. Declared in src-impl/19-tools-arkitect.mjs.connections_hook - the in-process answer to a Claude Code mcp_tool hook (the board's and the lane's), from the same logic the node hook file runs; written only where the client and this MCP are new enough, otherwise the node command hook is kept. Declared in src-impl/23b-tools-lane.mjs.local_processor - this PC does the member's own heavy work (talking faces, transcribing their own recordings) so it costs no credits: check reads the machine and says which workloads it can take, install / start / stop drive one workload's local server. Declared in src-impl/26f-tools-local-processor.mjs; design in docs/architecture/ai/LOCAL_PROCESSOR.md.day_recordings - the all-day recorder's hourly segments (tools/day-recorder) into the member's Connections locker, and the recorded day read back ({ action: list | upload | review, day?, folder?, timeZone?, maxTranscriptChars? }): list shows this PC's segments and which are uploaded, upload sends every finished one not yet sent as a meeting recording with its capture time, review returns the day's transcripts with every voice recognised across it. Declared in src-impl/26g-tools-day-recordings.mjs.games_upload_version - upload a game build to Connections Games from this PC ({ slug, zipPath, launch?: beta | live }): reads the local zip (refuses a missing file, a non-zip or one over 100 MB), leases the workspace's own Connections key value-blind, skips the upload when that bundle already passed, otherwise posts it to the games plane's /v1/dev/games/{slug}/versions, then launches the passed version to the beta pool or live when asked. Answers the version's gate warnings/reasons, the launch status and the play URL. Declared in src-impl/26g-tools-games-upload-version.mjs.browser_navigate / browser_tab / browser_close - open a page, switch tabs, close sessions - in a real Chrome/Edge already on this machine (no Playwright/Chromium download), driven over the Chrome DevTools Protocol, headless by default (headed:true pops a visible window). browser_snapshot returns the page as a compact aria outline (YAML: landmarks, headings, links, buttons, fields) whose interactive nodes carry refs like e12 (the cheap way to decide what to click; mode:'selectors' keeps the older flat list with CSS selectors); browser_take_screenshot renders pixels (optional coordinate grid); browser_get_text / browser_read read page/element text or an attribute. browser_click / browser_select / browser_hover / browser_type / browser_press_key / browser_upload_file act on the page (ref/text/selector/coordinate targeting: ref:'e12' or selector:'aria-ref=e12' from a snapshot, piercing shadow DOM and same- and cross-origin iframes). browser_wait_tab waits for an OAuth popup; browser_wait_idle waits for the network to go quiet. browser_evaluate runs arbitrary JS (awaiting a returned Promise) as the escape hatch. browser_frames / browser_targets list frame/CDP-target topology; browser_resize changes the viewport; browser_profiles / browser_status manage/inspect saved browser profiles - browser_profiles lists BOTH stores (the MCP's own, and the machine's real Chrome/Edge/Brave profiles by their friendly names) with the hosts each one holds a session for, so nobody has to open windows to find the login; browser_profile_login is how a login gets in (one visible, undebugged sign-in, then verify:true records it); browser_profile_adopt copies a real profile in and REFUSES when app-bound encryption would make that copy a signed-out lie; browser_handoff gives a saved profile's window to a human with nothing attached (a bot check refuses a debugged Chrome), closing and relaunching it plain with no debug port at all (a human reads a port-carrying window as automated) or just detaching, and the next profile call closes the plain window gracefully and relaunches the profile with a port; browser_script runs a whole named choreography of steps in one call; browser_capture_credential reads a paste-key field straight off a live page into the vault, value-blind. (browser.mjs, zero deps.) - browser_navigate / browser_tab / browser_close - open a page, switch tabs, close sessions - in a real Chrome/Edge already on this machine (no Playwright/Chromium download), driven over the Chrome DevTools Protocol, headless by default (headed:true pops a visible window). browser_snapshot lists interactable elements (the cheap way to decide what to click); browser_take_screenshot renders pixels (optional coordinate grid); browser_get_text / browser_read read page/element text or an attribute. browser_click / browser_select / browser_hover / browser_type / browser_press_key / browser_upload_file act on the page (text/selector/coordinate targeting, piercing shadow DOM and same- and cross-origin iframes). browser_wait_tab waits for an OAuth popup; browser_wait_idle waits for the network to go quiet. browser_evaluate runs arbitrary JS (awaiting a returned Promise) as the escape hatch. browser_frames / browser_targets list frame/CDP-target topology; browser_tab_errors asks EVERY open tab at once what is broken on the page (uncaught exceptions, unhandled rejections, console.error, failed loads), including errors logged before the call, returning at the deadline with whatever tabs answered plus the ones that did not; browser_resize changes the viewport; browser_profiles / browser_status manage/inspect saved browser profiles - browser_profiles lists BOTH stores (the MCP's own, and the machine's real Chrome/Edge/Brave profiles by their friendly names) with the hosts each one holds a session for, so nobody has to open windows to find the login; browser_profile_login is how a login gets in (one visible, undebugged sign-in, then verify:true records it); browser_profile_adopt copies a real profile in and REFUSES when app-bound encryption would make that copy a signed-out lie; browser_handoff gives a saved profile's window to a human with nothing attached (a bot check refuses a debugged Chrome), closing and relaunching it plain with no debug port at all (a human reads a port-carrying window as automated) or just detaching, and the next profile call closes the plain window gracefully and relaunches the profile with a port; browser_script runs a whole named choreography of steps in one call; browser_capture_credential reads a paste-key field straight off a live page into the vault, value-blind. The other direction is browser_type { secret: NAME, selector } (the selector is required and must resolve to an input or textarea in the top page, so the value never goes to whatever happens to have focus): NAME is declared in ~/.connections/secret-bindings.json under "browser" (a connected vault entry plus the domains it may be typed on; the file holds references, never values), the value is leased in-process and typed only on an https page inside those domains, and every reply of every tool shows it as <secret>NAME</secret>. browser_type_secret is the same value-blind typing for a chat driving its AgentHydra browser page ({ secret: NAME, selector }, both required): the page must be https on one of NAME's declared domains before any lease, and the reply carries only <secret>NAME</secret> and the host. (browser.mjs, zero deps.)secret_clipboard - a sealed clipboard for a secret an agent must MOVE but never SEE (Jacob, 2026-10-02): a token a dashboard shows once, a key going from one store to another. capture puts a value into a named slot held only in this process's memory, from the OS clipboard (after the page's own Copy button; the clipboard is emptied and the matching Windows clipboard-history entry removed), a managed-browser field, an element of the person's own browser (browser_live's engine, internal value action), a file, a vault field, or freshly generated. paste sends it to a plane's secrets bundle (secretsBundleMerge, fill-only-if-absent unless force), the vault, a Cloudflare Worker secret, a managed-browser field or an https call on the slot's domains (set once, at capture), or the OS clipboard for a few seconds (kept out of Windows clipboard history and cloud sync, then emptied). shell/script_run take a slot with secrets:[{ clip, as }]. A slot from the vault or a file only goes to our own stores. Replies show only name, length, character classes and a 12-hex fingerprint, and every later reply prints the value as <secret>clip:NAME</secret>. Pure rules pinned by secret-clipboard.spec.ts.browser_live - drives the person's OWN browser windows already open on this PC (their everyday Chrome, Edge or Brave, with every login they hold) through Windows UI Automation: no add-on, no debug port, nothing leaves the machine. list (windows and tabs), read (the front tab as an outline with refs; find, from, max), open {url} (always a NEW tab), wait {text}, click / type / select (by ref or name + role, through accessibility patterns so the person keeps mouse and keyboard; mouse:true / keyboard:true fall back to the real ones), activate {tab}, key {keys}, screenshot, and close (shuts only the tabs it opened and restores the person's front tab). tag {tag, note?} names the browser PROFILE of a window (keyed on the relaunch command Chrome stamps on it, --profile-directory=Default, because several profiles can share one name), kept in ~/.connections/browser-tags.json on this PC only; after that window:'<tag>' picks it, open {window:'<tag>'} starts it when closed, list shows each window's profile and tags, and browser_profile_find answers with the tagged browser (Jacob, 2026-09-29: "if another AI comes looking for my browser, they look and see the tag"). Windows accessibility reaches only the FRONT tab, so read, wait, click, type, select, key and screenshot act only when the front tab is the one named in tab (else browser_live_tab_not_front) or, with no tab, one this chat opened in that window (else browser_live_person_switched): a person switching tabs mid-task never has their own tab read (2026-09-30, a read meant for an Apple sign-in tab returned Jacob's inbox). Password fields are never typed or read; open takes only http(s) and passes the web-access gate; a typed value is read back and a page that cut it answers browser_live_value_mismatch. Why it exists: Chrome 127+ app-bound-encrypts cookies and Chrome 136+ ignores the debug port on the default profile, so the managed-profile tools cannot reach a login that lives only in the person's own browser (Jacob, 2026-09-29). Windows only. Declared in src-impl/26g-tools-browser-live.mjs.browser_profile_note - write down WHAT A SAVED BROWSER IS LOGGED INTO AND WHAT IT IS FOR, so any later chat in this workspace knows which one to use ({ profile, note?, title? }). browser_profiles and browser_profile_find return the note and title, and browser_profile_find also matches the note's words (for:'stripe' finds the browser whose note mentions Stripe). Call it right after a verified browser_profile_login, and whenever you save a note ALSO give a short 2-3 word title (e.g. 'GitHub') - the title is the name people see in lists instead of the raw profile name. Keep the note to a few words: which ACCOUNT (an email or username, NEVER a password, token or secret) and what the browser is FOR; never repeat the site name, never write sign-in status. A field you leave out keeps its current value; an empty string clears it. Note capped at 500 characters, title cut to 40. Same workspace scope as browser_profile_login. Declared in src-impl/15-tools-browser.mjs.web_read / web_access - web_read { url } reads a page as text WITHOUT Chrome, routed by host: GitHub through gh, YouTube through yt-dlp subtitles, an RSS/Atom feed as its items, any other page fetched on this machine and turned into markdown, the browser last; it names the backend that served it and what it tried (ok beats warn), and readOrder / CONNECTIONS_WEB_READ_ORDER_<CHANNEL> promotes a backend. Every browser navigation and every web_read passes one per-host gate (src-impl/05b-web-access.mjs), set in ~/.connections/web-access.json { mode, blocked, allowed, readOrder } or CONNECTIONS_WEB_ACCESS_MODE / CONNECTIONS_WEB_BLOCKLIST / CONNECTIONS_WEB_ALLOWLIST: the blocklist always refuses (a host covers its subdomains, redirects included); open (default) allows the rest; approve answers an unknown host with a typed web_access_approval_required request the person answers through web_access { action: "answer", decision: allow_once | allow_always | deny }; provenance, for unattended and worker sessions, opens only a URL that appeared in the task prompt (CONNECTIONS_WEB_TASK_PROMPT / _FILE, which also makes it the default mode) or in something the session already read, matched exactly, so ?q=<secret> added to a known URL is refused. Loopback hosts are exempt from approve and provenance. Outside open, a non-http(s) URL other than about:blank (a data: page can load any host) and agent-written page JavaScript (browser_evaluate, script eval steps) are refused, handoff urls and every script step's url are gated, and only page-derived output (goto, text, snapshot, read, html) adds provenance. allow_always lasts for the session only: nothing writes the file, so an agent cannot widen access past its session. Known gap: no orchestrator or worker launcher sets CONNECTIONS_WEB_TASK_PROMPT yet.connections_signin / connections_signout / connections_whoami - the keyless path (no key to paste). connections_signin self-registers a public PKCE client with AEGIS (RFC 7591), starts a device-code flow (RFC 8628), returns a branded "Sign in with Connections" link, opens your browser, and stores the session at ~/.connections/auth.json (machine-level, refresh-rotated) the instant you approve at accounts.connectionsapi.com/activate. Identical in every AI client because it's a tool, not a client-specific slash command. connections_signout clears + revokes this machine's session; connections_whoami reports who you're signed in as. Also surface as /signin, /signout, /whoami.connections_briefing - the full Connections briefing the short handshake has no room for (read-only, no arguments): how to use Connections - the first call and the company binding, browsing the web and running local power, calling API endpoints, jmespath projection, the team board for reaching other AIs, Script Vault, setting up infrastructure, and self-improving the server. Read it once at the start of a session; connections_search_catalog { query: "briefing" } finds it. Declared in src-impl/21-tools-identity.mjs.script_save / script_run / script_list - the Script Vault (stored operator instruments; JE-XCUT-031). script_save uploads an instrument and upserts per tool_name; use inline code or a local code_path only for self-contained source. For a tracked repository instrument, use delegate_path (for example scripts/aws/apply-plane-migration.mjs): it requires a Git-tracked workspace file and stores a tiny workspace-anchored launcher, so a caller may choose any script_run.cwd without changing which tracked file runs. delegate_path cannot be combined with source, and copied source with detectable relative module imports is refused because cache materialization cannot preserve them. The declared runtime is javascript/Node by default or bun for Bun APIs/TypeScript imports; server-side size gates are free 16 KB, Pro 64 KB, Business and Business Plus 1 MB (a downgrade LOCKS over-ceiling scripts, never deletes). script_run { script, args?, cwd?, timeoutMs? } fetches it, verifies its SHA-256, writes an immutable hash-keyed copy under the nearest ancestor node_modules/.cache/connections-scripts/ (falling back to the isolated user cache when the workspace has no install), and runs it HERE - args arrive as JSON in CONNECTIONS_SCRIPT_ARGS. Typed headers: a script may declare its args in its leading comment block - // @arg days:int=7 "Days" "help" (no =default means required; types str/int/float/bool/path or an @enum name), // @enum Grouping:str SERVICE REGION, // @timeout 300000 (# for Python). script_save stores the parsed schema as parameters (and refuses a header that does not parse); script_run re-reads it from the code it is about to run - the live delegated file for a delegate_path instrument - and refuses a missing, mistyped or unknown arg before the script starts, filling declared defaults. This preserves normal bare dependency imports from nested package directories; scripts never execute server-side. script_list is the browse view and refreshes ~/.connections/instruments-cache.json (read by prompt-hook integrations to push "you already have an instrument for this"). Stored scripts also surface in connections_search_catalog results (a scripts section beside endpoint hits). The discipline: search the vault before writing an enumerator; save every proven enumerator after - that's how a session leaves legacy instead of buckets.src-impl/02b-elicitation.mjs). When the client declares capabilities.elicitation at initialize and the loader routes its replies back (a current loader.mjs does), the server asks the person through the client: URL mode hands the sign-in page to the client and the call that found the machine signed out continues once it is approved; a form yes/no gates connections_claim_company and connections_switch_workspace (no, or no answer within 5 minutes, returns human_declined); and a prompt with a ${name} placeholder (the /todo prompt) asks for the missing value. Clients without it get today's text unchanged. CONNECTIONS_ELICITATION=off disables it; CONNECTIONS_ELICITATION_TIMEOUT_MS sets the wait. Secrets are never asked for in a form.human_step_wizard - for a step that needs a secret only the person holds: the agent passes a stage list and gets a bash script (~/.connections/wizards/<slug>.sh) that opens each page, reads the secret hidden from the keyboard into .env (replaced in place, never duplicated) and/or gh secret set, asks y/N before a confirm:true step, and prints a names-only summary. todo_add offers it when a blocked-on-jacob reason names a credential.connections_search_catalog / connections_execute / connections_accounts / connections_catalog_add / connections_catalog_remove / connections_catalog_refresh - search + run the company's API and connected-services vault (first-party Connections + third-party AWS/GitHub/…), proxied to the remote Studio MCP. connections_catalog_add extends the catalog with a new op - AWS (server_url host *.amazonaws.com; the SigV4 signing sub-service is auto-derived from the host, e.g. lambda.us-east-1.amazonaws.com → lambda, pass aws_service to override) or a connected NON-AWS service (pass service, a slug in the code-level host allowlist like cloudflare/github/stripe; the row is host-pinned and signs with the vaulted bearer/basic/apikey credential; an unnamed scheme is inherited from that service's own existing catalog rows, bearer only when the service has no rows yet, and the response names the source (auth_scheme_source) plus a warning if it disagrees with the rest of the service); it refuses to overwrite a curated/seeded row - the GitHub catalog carries github_repos_get (GET /repos/{owner}/{repo}) as the authenticated visibility check, returning visibility/private outright and telling a private repo apart from a nonexistent one, which the pre-push unauthenticated curl cannot. connections_catalog_remove deletes an agent-added row to repair a bad/duplicate one (curated rows are protected). connections_catalog_refresh re-ingests ONE first-party plane's LIVE /openapi.json on demand ({ service: "connections" | "studio" | "analytics" | "notes" }) - run it right after you deploy a plane so its new endpoints/params are discoverable via connections_search_catalog immediately, instead of waiting for the scheduled sync (the plane's OpenAPI URL is resolved server-side, so it can't be aimed at an arbitrary host). Auth is browser sign-in only - there is NO pasted key: if you're not signed in these tools return a sign-in link automatically (and open your browser); once signed in they run as THIS workspace's company, chosen per-workspace on the MCP Servers page or via the one-time agent claim (see "Per-workspace companies" below) - sent to the remote as ?project=, never a global default: an unassigned workspace fails loudly with no_company_assigned.connections_execute { …, jmespath } - an optional JMESPath expression applied to the JSON answer of any endpoint or local tool before it reaches the agent (body.items[*].{id: id, name: name}), so a large body costs only the fields asked for. Parsed before the call runs (a bad expression runs nothing); a failure after the call, or a projection matching nothing, answers with the top-level keys and body's keys. An answer the executor already cut (truncated: true, its body a cut JSON string) is refused with jmespath_answer_truncated when the expression reads body, naming params.outputFilter and the executor's outputFilterHint, because projecting a cut string reads null and a length() or || default turns that into a confident zero (2026-10-03, a 120-model Bedrock list projected to 0); an expression reading only the envelope (status) still projects. The grammar is taught once in INSTRUCTIONS; the evaluator is dependency-free, in src-impl/27-jmespath-projection.mjs. It is applied on this machine after the answer arrives; an AWS op's server-side params.outputFilter (the executor's JMESPath subset) is still the way to trim a payload before it travels.connections_execute { endpoint_id, params, files } - files: { "<params field>": "<file path>" } reads each file on this machine, base64s it and sets that (dotted) params field in-process, so a logo or an attachment reaches a body field like data_base64 without its bytes passing through the model. Catalog endpoints only; refused by name, before anything runs, for a missing file, a directory, a file over 4 MB, or a field params already sets. Source: src-impl/27b-execute-local-files.mjs.workspace_directory / workspace_describe - the workspace directory: who else is working in this signed-in account. workspace_directory { query?, includeInactive? } lists the other workspaces in the account (with its own workspace_describe blurb and live/idle status): live ones, ones someone chose something about, and folders used in the last day outside an agent temp or scratch folder; an idle untouched folder returns when it next reports - call it first to find who to ask, then board_post { to, text } leaves that folder's AI a message (below); workspace_describe { description } writes THIS workspace's own blurb so others can find it. Declared in src-impl/25a-tools-workspace-directory.mjs; routes GET /v1/mcp/workspaces and POST /v1/mcp/workspaces/describe. Also surfaces as /mcp__connections-local__who_else.board_on / board_post / board_claim / board_reply / board_done / board_release / board_read / board_who / board_status / board_wait / board_ask / board_off - the team board: a group chat for every AI working on a project, on every machine, and for the people the Connections workspace is shared with once a person accepts them in Studio (schemas 0056-0057 and 0061, routes /v1/mcp/board/*). board_post { text, board?, kind? } asks everyone listening at once; one agent takes it with board_claim { post } and the rest are told it is taken; board_reply { post, text, private? } talks in the thread, and private: true keeps it between the asker and the claimer; board_done { post, note } closes it; board_who lists the live agents, the PC each is on and the list each is working on (board_status { add | done | set | clear }: at most 5 items of 100 characters, added when a chat takes a task and marked done when it finishes, never filled from prompt text); board_who { q } ranks the live agents whose list, claims, folder or PC match q, and board_ask { q | agent, text } sends one of them an ask no listener sees (POST /board/ask, never a fallback to the whole board); board_wait holds until something reaches you. Each MCP process is one agent. board_on makes a folder listen, and under Claude Code it installs a hook in the folder's .claude/settings.local.json so new posts reach the model after every tool call, mid-work (answered by this MCP itself through a Claude Code mcp_tool hook calling connections_hook, so no process starts per call, wherever the three conditions in src-impl/23b-tools-lane.mjs hold: Claude Code 2.1.277+, one certain server key, and no older Connections MCP still running; otherwise the node hook file, as before); other clients get them on the next Connections tool result, and a Claude Code session with channels enabled for this server gets a notifications/claude/channel push. Folders bound to the same company share one board. Another person's AIs are heard only after this account's person accepts them on that board, signed in to Studio (/dev/mcp-servers#team-board, POST /v1/mcp/board/trust refuses anything but a console session); there are no invite codes. A post, reply or note carrying a credential is refused by the server. Folder mail rides the same board (P2: the two-party message board of schema 0037 and its message_* tools folded into it): board_post { to, text, kind? } addresses ONE other folder of this account (its name, id or path as workspace_directory prints it; an ambiguous name is refused with the candidates) and the thread stays private to the two folders; the first line of text is the subject. The folder it was sent to accepts with board_claim, grades with board_reply { status: answered | declined | open } (the sender cannot grade, but its plain reply re-opens an answered thread), and either side closes it with board_done (the sender withdrawing). board_read { box: inbox | sent | all, status?, limit? } is the mailbox (POST /v1/mcp/board/mail, ● marks unread), board_read { post, peek? } opens a thread and marks it read unless peek, and board_wait { post } holds on one thread (a reply that landed before the wait returns at once). A folder that never ran board_on still hears its mail: the registration report's counts ride the tool-result banner, announced once per version, and the lane hook prints the same line on the next prompt from ~/.connections/lane/mail-<key>.json, no network. The /mcp__connections-local__messages prompt checks, reads and answers mail in one slash command.cranium - the team board's health report (read-only: posts nothing, marks nothing read, changes no row): over the last days (default 7, clamped 1–30) it answers how the board and the memory are actually being used - who posted and how much; whether asks are answered by ANOTHER AI and how fast (median/p90 first reply); which agents and folders are live now, and which active folders sit quiet (neither listening nor posting) - plus the AI memory lane and the AI's own to-do list (saved, updated, read, never read), ending in a deterministic list of signals that flags the gaps against the intended use (nobody listening, nobody talking directly, a knowledge base nobody reads). format: "json" returns the raw pulse object for scripts such as tools/cranium/pulse.mjs; the default is a plain-text report. Declared in src-impl/25c-tools-cranium.mjs.cranium_link - the link to this account's hosted CrAInium page, built in the cloud whenever it is opened (your board's numbers, every person you accepted in Studio, their AI chats, memories and to-dos, and the small AI's hourly verdict) from any browser with nothing running on this machine. The first call makes the link and saves it to a file on this computer (a Windows internet shortcut; the link is its own password, so it is never printed into the chat); the link never changes (Michael, 2026-10-09), so every later call, on any of the account's computers, saves the same link and refreshes the page's time zone; nothing replaces it. Declared in src-impl/25c-tools-cranium.mjs.prompt_status / prompt_show / prompt_rate / prompt_second / prompt_propose / prompt_score / prompt_reflect / prompt_reword / prompt_retire / prompt_search / prompt_duplicates / prompt_improve / prompt_doc / prompt_block / prompt_injections / prompt_notes / prompt_import - the agent lane's scoring tools (src-impl/26-tools-rules-lane.mjs): chats rate, second and propose the rules and readings they are shown, score the prompts injected into them, and the lane folds that evidence into tiers (proposed, trial, asserted, proven). Each tool's parameters, and when the reflection hook asks for it, are in the tool table of AGENT_SCORING.md.sue - SUE, the simulated-people cannon: send a few AI "people" through a real page, each with her own persona, a fresh browser and a real inbox, and read what they said. action: "run" with url + intent returns a runId in about a second and the Sues run in the background (about two minutes each); status / report read a run back, stop ends one, personas / save_persona manage the cast, journal and scoreboard read the committed evidence under .sue/. The brain is the AI signed in on THIS machine - Connections supplies the tooling, never the model. Declared in src-impl/26b-tools-sue.mjs; the cannon itself is tools/sue/.dredd - DREDD, the judge: give it any ask (a complaint, a review, "which tool do we have for this") and it returns a DOCKET - which instruments to convene (the Boardroom of critics, the copy checks, the Design Critic, LilGranny, the flow walk, SUE, the Architect, Odin, the AWS Atlas, the marketing minds, pulse, the security audit) with the exact command for each, which memories the lane holds about it, which tier should do the work, and who else is on the team board. action: "docket" decides in seconds (Jev, TypeSafe's typed-decision model, through HSwarm; keywords when Jev is unreachable); "convene" decides and runs (facts, then the board argues over them, then the field instruments launch hidden with logs); "boardroom" is just the critics arguing; "bench" lists the roster. Declared in src-impl/26c-tools-dredd.mjs; the engine is tools/dredd/ (manual tools/dredd/README.md).dredd_hook - Dredd's prompt-hook memories, not for direct use: Claude Code's UserPromptSubmit mcp_tool hook calls it with the prompt. When the prompt is one Dredd judges (a complaint, a review, a which-tool ask; the same gate as the .claude/hooks/dredd-docket.mjs command hook), it answers the memories the memory lane holds about it as a <dredd-memories> block beside Dredd's docket, found by this process's own memory_search; any other prompt gets an empty answer. It gives up after 3.5 s, inside the hook's 5 s timeout, and signed out it finds nothing rather than start a sign-in. It replaced the command hook's memsearch.py spawn over the memory files. The gate and the block's wording live in the engine (tools/dredd/lib/hook-recall.mjs); this door only lends it the search. For a search of your own, call memory_search. Declared in src-impl/26c-tools-dredd.mjs.fourman - 4MAN, the foreman's board of work, one per Connections workspace: every AI files what it could not finish (add) and takes its next task (next { take, as }); Jev sorts each one, plain code ranks them (by the four quadrants, important against urgent, tier A do first first; tier narrows list and next to one quadrant, due gives a task a deadline; promote, demote, move, unpin and project change one task or a whole project with one event, and a project Project Hydra parks is offered to nobody) and says who should do it, and done closes a task only on its passing check or a stated proof. Before and after every action the door syncs this machine's board file with the workspace's shared log in Studio (POST /v1/mcp/4man/sync), and stores the summary the hosted Pulse page shows as its work section. 4man never runs by itself: action: "run" prints the orchestrator's runbook for the chat the owner turns on. Declared in src-impl/26j-tools-fourman.mjs; the engine is tools/4man/ (manual tools/4man/README.md).gains - GAIns, the CrAInium's ledger of gains, one per Connections workspace: every improvement, regression, major change, benchmark, A/B result and RSI trial the AIs recorded on any computer (Project Hydra's ph ledger), each marked measured or claimed. search (words plus project, kind, verdict, evidence, source), tried (what was tried and not kept), summary (the counts, and which techniques win and which are dead ends) and push. Before every action the door sends what ph ledger export says the cloud does not hold yet (POST /v1/mcp/gains/sync, schema 0088) and marks it with ph ledger pushed; reads go to POST /v1/mcp/gains/search, so a computer without Project Hydra still reads. The hosted CrAInium page shows it in its GAIns tab. Declared in src-impl/26l-tools-gains.mjs.docs - the CrAInium docs, one set per Connections workspace: the knowledge docs (guides, how-tos, architecture, strategy, reference) of the workspace's repos, kept in Studio with every version, including docs that have left the repo. search (words, plus folder, kind, owner, status; best match first with where it matched), get (one doc in full by path, older versions by sha), summary (counts), push (send the checkout's committed docs under root, default docs; only what changed, and a path git no longer tracks is marked gone, never deleted). Reads push first at most every ten minutes. The hosted CrAInium page shows them in its Docs tab. Schema: studio 0093.creaitor - the CreAitor's rulings, one corpus per Connections workspace: the owner's distilled answers the CreAitor (claude-memory tools/creaitor) replies from, kept in the cloud instead of on one PC. push { owner, path, summary_path? } sends a JSONL file of rulings (one a line, in batches) and the CreAitor's summary; pull { owner?, path } writes every ruling of the workspace, or one owner's, as JSONL to path; summary answers the rulings per owner. The files are the payload, so the rulings never pass through the chat. Studio stores them by POST /v1/mcp/creaitor/sync and reads them by POST /v1/mcp/creaitor/pull (schema 0089); the hosted CrAInium page shows each owner's count in its CreAitor tab. Declared in src-impl/26m-tools-creaitor.mjs.kit_search / kit_view / kit_examples - the shared UI kit, searched before any UI is written. kit_search { query, tier?, group?, limit? } ranks the SPL primitives and SCL components in the generated kit manifest (packages/connections-ui/registry/ui-registry.json, built by build-ui-registry.mjs from scl-catalog.json + catalog.d/); kit_view { names } returns a unit's home, version, source, summary, composition, import line and its API read from its own SFC (props with their withDefaults defaults, emits, v-model names, slots) plus one real usage snippet; kit_examples { name, limit? } returns real call sites (one per file, file:line, planes first). The kit is found in the open workspace, else the checkout this machine bound to Connections (or CONNECTIONS_KIT_ROOT). Declared in src-impl/26g-tools-kit.mjs.connections_list_companies - list the companies the signed-in account can link a workspace to (companyId, name, owner). Read-only; the step before connections_claim_company when connections_whoami shows company:null.connections_capabilities - what THIS session may run and why: the active capability file, each tool's least-authority class and derived annotations, and any gap between the policy table and the roster. { tool, params } checks one call without running it. See Least-authority table and capability files.connections_create_workspace - create a brand-new workspace (company) on the signed-in account and bind THIS folder to it in one call ({ name, confirm?, claim? }) - the third verb beside connections_list_companies / connections_claim_company.connections_claim_company - ONE-TIME: link this unassigned workspace to a company ({ company }, an id from connections_list_companies or an exact name). Refuses once anything is already bound - use connections_switch_workspace to change an existing binding.connections_switch_workspace - CHANGE this workspace's company binding ({ company, remember? }), unlike the write-once connections_claim_company.connections_use_workspace - run THIS CHAT ALONE against another of the member's workspaces ({ company }, an id, projectId or exact name; { clear:true } undoes it; no arguments shows it). It moves no registration and no other chat: the pin sits in the chat's own state and ends with the chat. connections_switch_workspace is the folder-wide one. For ONE call, connections_execute { local:true, tool_name, project } pins that call alone (checked against the same membership list, refused by name when you are not a member, and the reply names the workspace it ran against or says the tool made no company-scoped call); a tool that sets or reports the binding refuses project.connections_request_company_change - when you cannot switch the binding yourself (someone else's account, or the switch was refused), file a request the owner approves/dismisses on the MCP Servers page ({ company, note? }) instead of just doing it.connections_ping - highlight THIS workspace on the owner's MCP Servers page until they click OK, for "which registration am I actually riding?" debugging ({ note? }).provision_signing_credential / mint_org_operator / mint_plane_operator - value-blind credential minting, entirely server-side (the new secret never reaches this conversation). provision_signing_credential { appId, instance?, instanceName?, displayName?, lifetimeMonths? } gives a connected Microsoft app registration a usable client secret (the Azure Artifact Signing use case). mint_org_operator { fromRootInstance, instanceName?, userName? } rotates an already-connected ROOT-tagged AWS instance to a scoped IAM operator identity (fixes a root key being unable to call IAM APIs). mint_plane_operator { fromRootInstance, targetAccountId, instanceName, userName?, confirm:true } repairs a dead/deleted-key AWS instance in a member (plane) account.lambda_live_bundle_fetch - download and extract what a Lambda ACTUALLY serves right now ({ functionName, outPath?, qualifier?, region?, instance? }) - pass qualifier (an alias like "live" or a version number) or omit it for $LATEST; pairs with scripts/probes/lambda-bundle-symbol-drift.mjs to prove live code matches (or doesn't match) a repo build before deploying over it.lambda_finish_alias_repoint - settle an alias's provisioned-concurrency wait, or repoint it once ready ({ functionName, alias?, region?, instance?, expectVersion?, timeoutSeconds? }), so a lambda_deploy_zip { alias } call that hit its warm-up budget can be finished without a hand-rolled AWS CLI polling loop.cloudflare_worker_deploy - take a Cloudflare Worker from nothing to live in one call, through the vault ({ accountId, scriptName, filePath, instance?, mainModule?, bindings?, compatibilityDate?, compatibilityFlags?, routes?, customDomains?, workersDevEnabled?, dryRun? }) - the CF credential is used server-side, the module source is read on this machine, dryRun:true reports create-vs-update + bindings without uploading.provision_shared_secret / import_vendor_secret / import_vendor_credential - value-blind secret provisioning into AWS Secrets Manager or this workspace's vault. provision_shared_secret { secretId, instance, key?, region?, force? } mints a FRESH first-party handshake secret (e.g. an m2m client secret between two of our own systems) inside the vault process and json_merges it in, fill-only-if-absent by default. import_vendor_secret { secretId, key, instance, valueFile, region?, force? } is its sibling for a credential an OUTSIDE vendor issued - the value is read once from a local file, written into the vault, then the file is shredded. import_vendor_credential { service, field?, valueFile?, fieldFiles?, plainFields?, instanceName?, accountName? } is the same shred-after-read shape targeting this workspace's connected-services vault (single- or multi-field) instead of Secrets Manager.studio_key_to_worker_secret - mint a scoped Studio API key and place it straight into a Cloudflare Worker secret or Pages environment variable, value-blind ({ label, scopes, accountId, secretName, target?, scriptName?, pagesProject?, pagesEnvironment?, project?, instance? }).pay_webhook_to_cloudflare_secret - register a Pay webhook endpoint and place its whsec_ signing secret straight into a Cloudflare Worker secret or Pages environment variable, value-blind ({ url, accountId, secretName, target?, scriptName?, pagesProject?, pagesEnvironment?, eventTypes?, account?, label?, instance? }).cdk_deploy - synth + deploy (or dryRun diff) CDK stack(s) through the vault ({ stacks, instance, dryRun?, allowDestructive?, allowDistributionFieldLoss?, allowEnvRegression?, exclusively?, infraDir?, env?, context?, fromSha? }) - the canonical CDK deploy path, with a target-account preflight, a destructive-change (removal/replacement) gate, a CloudFront synth-vs-live FIELD-loss preflight (a dropped vanity alias/cache-behavior/origin/cert diffs as one benign [~] line otherwise), and a Lambda-environment-regression preflight mirroring lambda_deploy_zip's own guard. Every dryRun also prints an approvalDigest over the exact action (stacks, source sha, each synthesized template's hash, the loss verdicts, flags, context, env); pass it as approvalDigest on the real deploy once the owner has approved the preview, and the deploy refuses with a named reason (digest_mismatch, expired, replayed, tampered, stale_policy, key_rotated, unknown) unless that exact action is what runs. CONNECTIONS_DEPLOY_REQUIRE_APPROVAL=1 in the server's own environment makes it mandatory; each decision is one UTC line in ~/.connections/deploy-approvals/audit.log.cdk_import - adopt a pre-existing, out-of-band AWS resource into a CFN stack ({ stack, resources?, resourcesPath?, instance, execute?, infraDir?, context? }) for resources cdk_deploy's auto-match can't find. Previews (creates, prints, deletes the IMPORT change set) unless execute:true.audit - the Architect, addressed as a plain verb ({ scope?: code|db|all, args?, cwd? }) - code runs the offline code gate, db runs the hosted database governor (credential-blind, same as architect_run { scope:"hosted" }), read-only either way. Needs bun + the target repo on this machine.architect_scan - grade a codebase's health without the Architect engine ever touching it or your code ever leaving this machine ({ root?, format? }) - a commodity lexer runs here and reports a scored result locally.architect_install - the explicit opt-in step that lays .arkitect/ down in a repo that wants its own Architect checks. Only on a direct request to add the Architect to a codebase.npm_publish - publish an npm package using your CONNECTED npm token ({ dir }, the package directory containing package.json) - packs locally, publishes through Connections, the token is injected server-side and never exposed. Until 2026-09-27 the Studio Lambda replaced npm's 502 body with an empty {}, so a refused publish looked like success; it now answers 424 with npm's status. npm's staged publishing answers ok and serves the version a few minutes later (2.191.0 and 2.191.3 each took about 3-4 minutes on 2026-09-29, nobody approving); one still missing after that needs a maintainer's 2FA approval at npmjs.com -> Staged Packages. Since 2.191.1 the tool reads the version back and says which of those it is instead of "Published".ci - the one door to every CI leg (Linux, macOS, Windows) for the repo you're in: reads that repo's own .github/workflows/*.yml and runs the run: steps it finds, so the workflow file is the gate list.guest_lookup - one call for a guest's contact info AND their full attendance history across every hosted event ({ who, limit?, instance? }) - searches name/email/phone (partial ok), returns a merged person card plus one row per event (registered/approved/denied/checked-in); a loose term matching several people comes back as multiple distinct cards, never merged into one.scan_website - scan a company's deployed site for exposed live credentials baked into its public HTML/JS/CSS ({ url }) - the same scanner as this MCP's own push-scan guard and the Architect's no-secrets-committed gate; findings are masked (shape + fingerprint, never the raw value) and the site owner is emailed automatically on a hit. ⚠️ Metered - spends credits at the website_key_scan door.sign_artifact - Authenticode-sign a local file (.exe/.dll/.msi/.cab/.appx/etc.) with Azure Artifact Signing using this workspace's vaulted Microsoft credential ({ file, instance?, account?, profile?, endpoint?, subscription?, expect_subject?, description?, description_url?, arch?, signtool?, dlib?, timeoutMs? }) - no repository, no CI, no az login; fetches the Microsoft signing client tools itself on first use, reads account/certificate/endpoint from Azure, and verifies the result before reporting success. Windows only.script_get / script_delete - the Script Vault's read-one and delete-one: script_get fetches a stored instrument's code + metadata by tool_name without running it (to review before re-saving, or before script_run); script_delete removes a stored instrument (always allowed on your own rows, even a tier-locked one) into Trash.script_restore - puts a deleted Script Vault instrument back from Trash, code, parameters and description as they were, under the same tool_name ({ script }); with no script it lists this company's deleted scripts still kept. Refused with already_exists when a script of that name was saved since, and past the plan's Trash window. Declared in src-impl/10-tools-script-vault.mjs.prompt_save / prompt_get / prompt_list / prompt_copy_here - the Prompt Vault, script_save's sibling for reusable prose an AI reads rather than runs (a playbook, a persona, a house rule). A prompt is a Notes-workspace note tagged type:'prompt', so it's editable at notes.connections.icu too. prompt_save { tool_name, name?, id?, description, text, global? } upserts by tool_name (or targets an exact note via id); global:true makes it visible from every workspace you sign into. prompt_get { prompt } fetches one's full text by title or note id; prompt_list browses them; prompt_copy_here { prompt, title? } forks a global prompt into this workspace to edit locally without touching the shared original.github - use GitHub with no git/GitHub-CLI/Desktop install, via a bundled pure-JS git engine authenticating as a CONNECTED GitHub account ({ op, cwd?, instance?, url?, message?, filepath?, author?, ref?, remote?, depth?, singleBranch? }). op is one of accounts | init | clone | status | add | commit | push | pull | fetch | log | branch | checkout | current; private clone/push/pull/fetch mint a short-lived token for the named instance from the vault, value-blind.todo_list / todo_open / todo_add / todo_done / todo_update / todo_delete / todo_label / todo_retitle / todo_assign / todo_pin / todo_folders / todo_agenda - the HUMAN's Notes to-do queue. todo_add is HARD-GATED: every add must carry exactly one routing label (future / blocked-on-jacob / large-radius, the last two requiring a reason) - it exists for what physically requires a person, never for work an agent can just do (see .claude/rules/todo-queue-sync.md). The rest are the ordinary verbs - open one in full, check off/reopen, edit without touching labels/body (todo_label, todo_retitle, todo_pin), assign, delete (to trash, recoverable 30 days), browse by folder, or by deadline (todo_agenda). workspace_members lists the workspace's people (name, @handle, Owner/Member, their workspace title and description, sub) from the notes plane's POST /v1/scopes/members, and tells the assistant to give a to-do to the member whose title fits the work; todo_add / todo_assign take assignee as 'me', a sub, or a member's name or @handle, which is resolved against that roster before the write (ambiguous or unknown is refused with the candidates). goal_list / goal_save read and set the goals behind the list (the notes plane's POST /v1/goals/list and /v1/goals/upsert): the same goals every to-do is ranked against and a Schedule Focus block points at by id, so an assistant can set the goal a focus block uses.notes_search / notes_grep / notes_from_file / notes_patch - Notes beyond the to-do queue. notes_search ranks by MEANING (a plain-English question); notes_grep is literal-substring search (a retired name, a slug, a URL - what notes_search misses); notes_from_file creates/updates a note from a local file's content; notes_patch edits part of a note without a full-body round-trip.almanac - the Almanac: what Connections already has, why each thing exists and whether it should stay (one entry per feature, capability, primitive, guardrail, tool or policy, per area: the planes plus shared and fleet). Read it before answering "do we have X" or planning a feature. Actions: overview, areas, area, find, show, recall, changes, prune-queue, stale, check, add, update, retire, gate, sweep. It is a thin door onto the checkout's tools/almanac/almanac.mjs engine, so the MCP, the CLI and Dredd's bench never disagree (manual: tools/almanac/README.md).memory_save / memory_search / memory_open / memory_list / memory_verify / memory_forget / memory_prune / memory_export - the agent's OWN memory lane (never the human's Notes). memory_save upserts by slug - search with memory_search first, or the same learning gets filed under a second name. memory_open reads one in full; memory_list browses (unread_for_days surfaces the stale ones); memory_verify records that you re-checked a memory against the live system and it still holds; memory_forget trashes/restores one (recoverable); memory_prune clears many at once; memory_export dumps every memory as one re-importable markdown document. memory_ledger reads the memory ledger (notes schema 0056): every memory by project with who read it, who changed it and how often, plus the leads worth acting on (never read, rewritten again and again, a project under two spellings); memory_ledger { id } is one memory's whole history, each change with the text it replaced. Every memory call names its agent ("# on ()") so the ledger can say who; the CrAInium page's Lexicon tab shows the same numbers. memory_review is the reviewer pass: given a chat's transcript_path it returns the stored index (closest repo first) and the conversation for the calling model to review, then takes back JSON operations (add / replace / move / remove), checks the whole batch against the stored index (no add onto a stored slug, no edit of an unstored one, no credential) and applies it through the same save and trash doors (dry_run first).memory_home - which company holds this account's memory, set once per PC (saved in ~/.connections/memory-company.json, beside the folder bindings) and used by EVERY folder's memory tools whatever company the folder is bound to. Why: measured 2026-10-03, the Connections folder's company held all ~6,500 workspace memories (45 repos) while every other repo folder was bound to "Default project", which held only 268 almanac rows, so a chat in zswarm saw none of zswarm's own memories; rebinding each folder with connections_switch_workspace moves its connected services, board and billing too and needs a person per folder. memory_home { company: "<companyId or exact name>" } sets it, no arguments reports it beside the folder's own company, company: "" clears it (unset = each folder follows its bound company, as before). An explicit scope_type global/person or scope_id on a memory call is untouched, and every memory call for it goes through Studio as your own sign-in for that company (project on the call; the folder's key cannot reach another company), while lane_brief appends a second memories-only brief from it (layers: "memories") to the folder's own, whose pinned prompts and doc reports stay on the folder's company.memory_rekey - moves a project's memories from one repo name to another: memory_rekey { from, to, dry_run? } re-files every live memory whose repo is from (case-insensitive) under to in place, keeping id, reads and history. A slug already live under to is a clash and stays where it is, listed, never overwritten. Run dry_run: true first. For a repo renamed on GitHub or one project saved under two names; lane_brief runs it on its own (dry run, then apply) when a folder's repo key changed since it was last used.lane_brief / memory_prompt - the Connections brief: global memory (the account's layer, above every workspace) and THIS folder's workspace memory, pinned prompts first, rendered by the notes plane (post_agent_lane_brief) into one text every AI hears the same way. lane_brief { repo?, budget_chars? } returns it (INSTRUCTIONS tells a session to call it first); memory is always on, and memory_prompt { add, scope: global | workspace } adds or removes Connections' template prompt ("use Connections memory") in a layer; the brief is never written into a file (Jacob, 2026-10-03): every agent's GLOBAL file holds only the Connections pointer (CONNECTIONS_POINTER: read Connections first, save to Connections, what to do while it is not connected), and lane_brief prints that text, naming the files, while one of them lacks it (globalAgentFiles: Claude Code's ~/.claude/CLAUDE.md, Codex's ~/.codex/AGENTS.md, opencode's ~/.config/opencode/AGENTS.md, Gemini CLI's ~/.gemini/GEMINI.md; CLAUDE_CONFIG_DIR and CODEX_HOME honoured, Codex's AGENTS.override.md taken when it is the file Codex reads); a block an MCP before 2.216.0 pasted between brief markers into one of those files or this folder's CLAUDE.local.md is replaced by the pointer once (migrateBriefBlocks, at start and on lane_brief; a symlinked file written through its link, a file git tracks never written), and under Claude Code adding the prompt installs one UserPromptSubmit hook in the user settings that prints the cached brief (~/.connections/lane/brief-<key>.json) on the first prompt of every session, and on any prompt one line when another folder's AI has written to this one (mail-<key>.json, once per mail version), with no network. lane_brief also ends with a <RUNTIME_SERVICES> block: the local services this folder declares (.connections/services.json entries { name, url, key_env? } or { from, pattern } reading (name)(port) out of a registry file inside the folder; localhost servers in .mcp.json; package.json scripts with --port/PORT= or a vite/next/wrangler/astro default), each as a URL that works from the session's shell, which answer right now (a 300 ms loopback TCP connect, never a remote host), and the NAME of the env var holding each key, never its value. Under the same three conditions as the board's hook the entry is an mcp_tool hook this MCP answers in-process, from the same logic the node hook file runs. Declared in src-impl/23b-tools-lane.mjs.ai_todo_add / ai_todo_list / ai_todo_open / ai_todo_update / ai_todo_retitle / ai_todo_done / ai_todo_promote - the agent's OWN to-do queue (work an agent intends to do itself - the default destination, never the human's todo_add). ai_todo_list is read at the start of a session for what a past session could not finish and prints titles only; ai_todo_open <id> reads one item's full body before it is worked; ai_todo_done requires a reason when abandoning (dropped) but not when finishing; ai_todo_promote is the one door out of this queue - records that an item turned out to need a human and which todo_add note it became.own_brain - OWN BRAIN: while you keep calling action: "listen", chat's AI door hands every eligible model turn of this signed-in member (note summaries, receipt reads, listing/profile drafts, invite blurbs...) to THIS chat instead of Bedrock, and reserves no Connections AI credits for it - moderation and other checks that protect somebody else never come here. { action: listen | answer | stop | serve, jobId?, input?, text?, declined?, baseUrl?, model?, contextTokens? } (listen answers at once with a job or a heartbeat; serve answers every job from a local OpenAI-compatible model at baseUrl); run the listen/answer loop in a dedicated agent with no shell or file tools, since the material can come from other people. The wire, the feature list and the reasons: infra/shared/ai/own-brain-contract.ts; the design: docs/architecture/ai/BRAIN_CHOICE.md. Declared in src-impl/26e-tools-own-brain.mjs.code_outline / code_unfold / code_search - read code by SYMBOL instead of by file. code_outline { path } folds a source file to its declarations (classes, functions, methods, types, constants) with line ranges and the token cost of the full file against the outline; code_unfold { path, symbol } returns the exact source of one declaration (parse, Parser.next, a Go Server.Handle), doc comment and decorators included; code_search { query, path?, kind?, limit? } finds where a symbol is DECLARED across a folder (skipping node_modules, build output and dot-folders). TypeScript/JavaScript, Vue/Svelte scripts, Python, Go, Rust, Java, C#, Kotlin, Swift, PHP, C/C++. A zero-dependency lexer (strings, comments, template and regex literals) plus one declaration table per language, not tree-sitter, because impl.mjs may import only node: builtins. Idea from thedotmack/claude-mem's smart_outline/smart_unfold/smart_search (Apache-2.0). Declared in src-impl/26g-tools-code-outline.mjs.One-call idioms (executor-native, SHIPPED 2026-07-02) - the four ops + three shaping params that
collapse the old multi-call dances into single connections_execute calls (all service:"aws"):
connections_execute { service:"aws", tool_name:"rds_query", params:{ db:"main", sql:"select 1" } }
→ { rowCount, records:[…] } (rows parsed server-side). db ∈ main · aegis · analytics · pay ·
companies · events · dating · intel · studio · ledger · deals · ads · chat · daily · market ·
referrals · notes · storage (aliases: connections, accounts/identity, argus, plutus/payments,
intelligence/heimdall, hephaestus, brand-deals, hermes, locker) - the whole vault-connected fleet.
Data-API gotchas still apply (jsonb→strings, no array params, ~1MB cap).outputFilter (JMESPath subset) to the op's params:
lambda_list_functions {outputFilter:"Functions[].{name:FunctionName, mod:LastModified}"} returns
just name+date pairs. Subset: dot paths · [n]/[a:b] · []/[*] projections ·
[?field==`lit`] filters · {k:path} multiselect · top-level |. Unsupported syntax errors
loudly with a hint. An UNFILTERED response over 25,000 chars now answers with an
outputFilterHint naming the array that made it big, its element count, its scalar field names,
and a ready-to-paste expression - because this README is not what an agent is reading at the moment
it needs this (a 158,939-char lambda_list_functions spill was filed as a missing feature on
2026-08-09; the feature had shipped five weeks earlier).paginateAll:true: the executor follows
NextToken/NextMarker/ContinuationToken/ExclusiveStartKey server-side, merges arrays, applies
outputFilter after the merge (cap 20 pages/5MB; a hit cap returns __paginated.capped + the token).aws_sweep { params:{ tool_name:"sts_get_caller_identity", instances:["Connections","Pay@connections"] } }
(omit instances for ALL connected AWS accounts) → one response keyed by instance; an unreachable
account is an isolated error entry, never a failed sweep.probe { params:{ targets:[{ url:"https://pay.connections.icu/health", expectStatus:200, bodyContains:"ok" }] } }
→ per-target {pass, status, matched, bodySnippet≤500B} (≤10 targets, headers never echoed,
private/metadata hosts refused).logs_tail { instance:"Connections", params:{ functionName:"connections-product-api", minutes:15, filter?:"ERROR", limit?:100 } }
→ trimmed ISO-ts message lines. The log group is resolved ONCE from the function's own
LoggingConfig.LogGroup (echoed as logGroupSource, classic /aws/lambda/<fn> only as fallback), so a
filtered and an unfiltered call read the SAME group - no more false "no errors" from a filter hitting the
wrong group. The top-level instance is honored (as well as params.instance).connections_search_catalog lists each hit's param NAMES only
(required suffixed *); pass verbose:true when you need the full parameter schema to build a call.tools/list, connections_search_catalog's
description ends with one generated sentence: memory rows in the global layer and this workspace's layer
(from the cached lane brief), Script Vault instruments (from ~/.connections/instruments-cache.json) and
the local tools search can show. Counts and fixed words only, never a title, slug, script or workspace
name; local reads only, cached a minute; with no cached count the static description is served unchanged
(src-impl/90-prompts-and-dispatcher.mjs, pinned by catalog-live-summary.spec.ts).src-impl/05b-catalog-search-ranking.mjs);
use_case (a sentence) sharpens it. Every search page (local hits and every remote row shown) and every
connections_execute (local tool or catalog endpoint) is logged to ~/.connections/local-mcp/catalog-search.jsonl
(never sent anywhere; params and results are not written), so the top hit carries the tools callers here
usually run next and any hit its recurring failures. catalog_search_report reads the log: the miss
rate (a search whose session ran nothing it showed before its next search or 15 idle minutes), rescued
misses, unresolved queries, endpoint follow-through, zero-hit queries, local misses, chains, pitfalls.
Search and endpoint execute both send a per-process session_key, so the server can pair them too.
To measure a ranking change, set CONNECTIONS_SEARCH_EVAL=shadow (log both pages, serve BM25) or
interleave (serve a team-draft merge, credit whichever ranker placed the row the next execute ran);
the challenger is CONNECTIONS_SEARCH_CHALLENGER (default substring, the pre-BM25 matcher).[hidden…] values; pass includeEnv:true for the non-secret tier (ARNs/URLs/flags readable -
secret-shaped values stay [redacted], no opt-out).instance accepts a 12-digit AWS account id anywhere, resolved server-side - no more guessing
whether it's Pay@connections or pay@connections.icu.shell{instance}/script_run{instance}/deploys is AssumeRole-first - when the account
carries the standing connections-vault-operator role (trust policy names that account's own vault
user), the mint assumes it and the child process gets creds that CAN do IAM/CDK; accounts without
the role keep the historical GetSessionToken mint, whose sessions AWS blocks from every IAM-class
API (root and IAM user alike - the class that killed the storage-plane CDK bootstrap). To upgrade an
account: provision the role once with scripts/deploy/provision-vault-operator-role.mjs (run
through the org-plane-exec bridge with VAULT_USER_ARN set), then prove it with the
vault_operator_mint_verify Script-Vault instrument. For accounts with no vault instance at all
(fresh plane spin-up) the sanctioned IAM-touching deploy path stays the
scripts/deploy/org-plane-exec.mjs bridge (owner-blessed 2026-07-10). It assumes the target
account's OrganizationAccountAccessRole straight from the ambient vault-operator credential - the
throwaway plane-deployer user, its two-key cap and the keys --prune-hours N recovery for
KEY_SLOTS_FULL were all deleted on 2026-09-15, because a shared throwaway credential is a global
two-slot lock with no queue.Speed (mcp-speed round 2, SHIPPED 2026-07-03 - impl 2.15.0 + executor caches): warm
connections_execute ≈ 0.4-0.7 s (was 2-2.5 s), a REPEATED identical search is ~free (90 s local
cache; accounts 120 s), the first call of a session no longer eats a 10-30 s studio-DB resume
(auto-pause disabled), and shell{instance}/deploy_static/lambda_deploy_zip reuse one cached
per-instance STS mint per hour instead of re-minting per call - with the local tools' own AWS legs
signed locally and sent straight to AWS (vault fallback; kill switch CONNECTIONS_NO_DIRECT_AWS=1).
Full record: the "Resolved 2026-07-03 (mcp-speed round 2)" section of the MCP friction intake, which was retired on 2026-10-10 and lives in git history (the commit that deleted the intake folder is 4f9ab18a65; read the file from its parent).
The two slowest habits are (1) connections_search_catalog for ops everyone always uses and
(2) re-deriving DB/instance coordinates every session. Skip both:
Call connections_execute directly with service:"aws" + tool_name (no search, no endpoint_id).
The everyday ops: rds_data_execute_statement (params resourceArn, secretArn, database, sql, formatRecordsAs:"JSON") · lambda_list_functions · lambda_get_function · lambda_update_function_code
· cloudformation_describe_stacks / _list_stacks · s3_list_objects_v2 · logs_filter_log_events ·
cloudfront_get_distribution_config / _update_distribution (not _create_invalidation: it is billed per path, and no deploy needs one since 2026-09-30) · sts_get_caller_identity
(verify the account BEFORE anything mutating) · aws_call (generic escape hatch, any service/op).
Instance names (pass as instance; the ambient default is often the WRONG account - always pin).
⛔ 896784231613 is NOT Studio. This table said it was until 2026-07-31; it is start.connections
(the renamed ex-SayDeploy account, now in the Connections OU and slated for teardown). An agent that
trusted the old line would have aimed Studio work at an account being emptied. connections_accounts
is the authority - this table is a convenience copy of it and has now been wrong once:
Connections = Main 109653023276 · accounts@connections.icu = AEGIS 180555415005 ·
analytics@connections.icu = ARGUS 765727303723 · Pay@connections = 307223751692 ·
Studio@connections.icu = Studio 637560253023 ·
events 388460142414, usage 919446725664, intel 916442395558, deals 625838970450 - run
connections_accounts once if a plane's exact string is unknown; NEW plane accounts absent from the
vault are reached via scripts/deploy/org-plane-exec.mjs (management→org role).
Prod database coordinates (the RDS Data API wire, answered from each plane's Postgres database on box A). ⛔ secretArn is intentionally NOT
reproduced here - it is exactly the class of stale second-copy this table's own drift warning
above is about (resourceArn/secretArn for a co-tenanted plane change on every cutover, and
several of these already have since this table was written). Derive it live instead of trusting
this doc: resolveDbCoordinates({ plane }) / coTenantCoordinates(plane) in
services/_shared/db/resolve-db-secret-arn.mjs / scripts/aws/lib/cotenant-db-coordinates.mjs,
which read .arkitect/arkitect.config.json and infra/shared/aurora-cotenants.json - the same
inputs this table was hand-copied from.
| db | instance | database |
|---|---|---|
| Main | Connections | connections |
| AEGIS | accounts@connections.icu | accounts |
| ARGUS | analytics@connections.icu | analytics |
| Pay | Pay@connections | payments |
| Companies | workspaces.connections.icu | companies |
Data-API gotchas: jsonb columns return as STRINGS on read; array parameters THROW; responses cap at
~1MB (page big reads ≤500 rows). The resourceArn/secretArn pair for each of these is also
resolvable through the executor's EXECUTOR_DB_TARGETS map (services/studio/api/index.mjs) - the
deeper fix (named-DB rds_query + server-side output filtering) SHIPPED 2026-07-02 and that map is
the runtime source the db: names resolve through.
It is one public npm package - connections-mcp - and every client below installs that same package. Zero
runtime dependencies, Node >= 22 (what package.json engines actually declares, so npm warns
below it - this line said 18 until 2026-08-26), and no key to paste: the first tool call opens a browser sign-in
(/signin). This section used to give a repo-relative node <abs path>/loader.mjs command, which only
worked for someone who already had this checkout; the npm path is the real one and it is now first.
| client | the one command |
|---|---|
| Claude Code | claude mcp add-json -s user connections "{\"type\":\"http\",\"url\":\"http://127.0.0.1:7791/mcp\",\"headersHelper\":\"npx -y connections-mcp --connect\"}" (the shared form, below) |
| Codex | add the [mcp_servers.connections] block below to ~/.codex/config.toml |
| Cursor / Windsurf / any stdio client | command npx, args ["-y", "connections-mcp"] (stdio: they cannot run a headersHelper; the loader relays to the shared server, see below) |
| Claude Desktop | install connections.mcpb (Settings -> Extensions -> Install from file); build it with node build-mcpb.mjs |
# ~/.codex/config.toml
[mcp_servers.connections]
command = "npx"
args = ["-y", "connections-mcp"]
Prefer a pinned global install to a per-launch npx resolve? npm i -g connections-mcp, then use
connections-mcp as the command. Either way the IMPLEMENTATION self-updates from the cloud on every
launch (next section), so the installed thing is only ever the thin loader and a reinstall is never
the fix for a stale tool.
The Claude Code line is the shared http form: one local server for every chat on the machine, started on demand by the headersHelper (npx -y connections-mcp --connect, which prints the folder headers and starts the host when absent; checked on Windows through npx). The \" quoting runs unchanged in bash, zsh and cmd and in PowerShell 7.3+; Windows PowerShell 5.1 splits the string at its spaces, so write claude --% mcp add-json ... there. If the first connect times out while npx fetches the package (Claude Code gives a helper 10 s), run npx -y connections-mcp --connect once in a terminal and reconnect. A client that cannot run a headersHelper registers stdio instead, and its loader relays to the same server (next section).
Verify: claude mcp list should show connections … ✔ Connected, and the first connections_whoami
call tells you who this machine is signed in as and which company the workspace is bound to.
The official MCP Registry (registry.modelcontextprotocol.io) is a metadata FEED, not the list
Claude browses. Anthropic says so outright: "The Anthropic Directory is independent of the open MCP
Registry ... Publishing to those does not surface your server in Claude"
(directory-vs-custom). Publish here
so the third-party aggregators that DO poll it hourly (PulseMCP, Glama) pick us up - not to appear
inside Claude. Appearing inside Claude is a separate submission: see
Getting into Claude's own lists below. The registry hosts METADATA
only - the artifact stays on npm - so the order is always npm first, registry second.
server.json in this directory is that manifest. Two identity rules the registry enforces and this
repo asserts before you can publish (publish-impl.mjs step 0):
package.json's mcpName must equal server.json's name (io.github.Lunarwerx/connections).
That pair is how the registry proves the npm package and the listing belong to the same owner.server.json (top level and
packages[0].version) - but package.json's version is the only one you ever hand-edit;
node build-impl.mjs derives the other four from it (see "Bumping the version" below). The
registry rejects a package version that is not already live on npm, and by then the npm publish
cannot be taken back - so publish-impl.mjs step 0 re-checks parity from scratch and refuses
locally first, as a backstop.A release is one dispatch, with no login (since 2026-10-10): .github/workflows/npm-release.yml
publishes the npm package through npm trusted publishing, waits until npm serves it, then lists it here
with the job's own GitHub OIDC token. Nobody signs in to npm, touches a security key or pastes a token.
node publish-registry.mjs --dry-run # the registry's own validator; needs no token
gh workflow run npm-release.yml -f package=connections-mcp -f dry_run=true # proves both sign-ins, publishes nothing
gh workflow run npm-release.yml -f package=connections-mcp # the release: npm, then the registry
curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.Lunarwerx/connections"
From a machine instead (npm already serving the version): gh auth token --user lunawerx | node publish-registry.mjs, any Lunarwerx org member's token with the read:org scope.
The registry caps description at 100 characters; --dry-run says so before anything is sent.
server.json deliberately carries no repository field: Lunarwerx/connections is private, and
pointing a public listing at a 404 is worse than omitting an optional field. websiteUrl points at
studio.connectionsapi.com/mcp, which is the page a stranger should land on.
The io.github.Lunarwerx/* namespace is the GitHub-auth path, and the registry matches it case for case against the org's own spelling (Lunarwerx): the lowercase io.github.lunarwerx/connections was refused with a 403 on 2026-09-29; the alternative is DNS authentication on
connections.icu, which would name the server icu.connections/... - available if the org handle ever
stops being the right brand, not worth the reverse-DNS ugliness today.
Verified against Anthropic's live docs 2026-08-26. There are three separate Anthropic surfaces and they share nothing - no submission carries across, and none of them reads the MCP Registry.
| surface | what it lists | how you get in |
|---|---|---|
Connectors Directory (claude.ai/directory, "Browse connectors") | REMOTE MCP servers only, over HTTPS | Self-serve portal, claude.ai/admin-settings/directory/submissions/new. Needs a Team or Enterprise claude.ai org. |
| Claude Code plugins | plugins, which may bundle an MCP server | Form -> the community marketplace (anthropics/claude-plugins-community). The curated claude-plugins-official has no application process at all. |
| Desktop Extensions (Claude Desktop, Settings -> Extensions -> Browse) | LOCAL stdio servers packaged as .mcpb | Separate form, clau.de/desktop-extention-submission (the URL's typo is Anthropic's, and it is the real one). |
Measured 2026-08-26, not estimated. A connector runs in Anthropic's cloud, so it can only ever expose the REMOTE door. The local door offers 83 tools; 68 of them do not exist over a connector:
| gone over a connector | count |
|---|---|
the whole browser (browser_navigate … browser_evaluate) | 22 |
| notes / todo / prompt | 20 |
deploy + infra (cdk_deploy, lambda_deploy_zip, cloudflare_worker_deploy, npm_publish, …) | 10 |
the machine itself (shell, git, github, read_file, write_file, list_dir, ssh, sftp) | 8 |
the Architect (architect_run, architect_install, audit) | 3 |
script_run, script_list | 2 |
That is the product, not a rounding error. So the connector is at best a companion to the npm
package, never a substitute - and note it additionally needs a Team/Enterprise claude.ai org to
submit, plus a ruling on connections_execute, which is precisely the shape
review-criteria rejects: "Do not
ship a catch-all api_request tool with a method parameter."
Do NOT build a connector whose job is to tell the agent to install the npm package. It buys nothing (the one command below already works, and only clients that can run commands could act on it anyway) and the Software Directory Policy forbids the shape: "Instructional Software must not direct Claude to dynamically pull behavioral instructions from external sources for Claude to execute."
npx -y connections-mcp already works, today, for a stranger. Verified from a scratch HOME on
2026-08-26: npm's published version is still 2.19.1 (2026-07-05), and it came up reporting 2.100.0,
because the loader fetched the current impl.mjs from S3 and SHA-verified it. The stale npm version
costs us nothing functionally - it only blocks the MCP Registry, which rejects a version not live on
npm.
So the packaging work is a wrapper, not a port: a Claude Code plugin is two small files
(.claude-plugin/plugin.json + .mcp.json -> npx -y connections-mcp), exactly how context7,
playwright and firebase ship in Anthropic's own marketplace. Submission requires a public
GitHub repo ("Plugins must link a public GitHub repo; closed-source is not accepted") - satisfied by
a manifest-only repo, since connections-mcp is already a public npm package and no product source
moves. The .mcpb desktop-extension variant of the same idea is what Claude Desktop's
Settings -> Extensions -> Browse extensions lists; that path carries an unwaivable open-source clause
whose text is on neither the Terms nor the Policy page, so confirm it with mcp-review@anthropic.com
before committing to it.
No approval needed for the install link. Anthropic publishes a documented deeplink that opens claude.ai with the Add-custom-connector dialog prefilled (it prefills only; the user still confirms):
https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Connections&connectorUrl=https%3A%2F%2Fstudio.connectionsapi.com%2Fv1%2Fmcp
Claude Code's plugin marketplace is a separate, already-wired path: .claude-plugin/marketplace.json
.claude-plugin/plugin.json in this directory declare the connections plugin, whose mcpServers
entry runs ${CLAUDE_PLUGIN_ROOT}/loader.mjs. That path installs from the repo, so it is for people who
have the checkout; the registry + npm path above is the one for everyone else.Scope, since it is the first thing anyone asks: a profile belongs to the WORKSPACE that owns it, not to the machine (owner directive, Jacob, 2026-09-18: "Not machine wide, it should be workspace wide"). The store is partitioned by the same canonical workspace key the company binding uses:
~/.connections/browser-profiles/
registry.json one index, keyed by scoped name ("<slug>/<profile>", a bare name pre-partition, "external:…")
workspaces.json <slug> -> the canonical workspace path, so the layout is readable by eye
ws/<slug>/<profile>/ that workspace's profiles - the only ones it drives by name
<profile>/ pre-partition profiles: owned by nobody, usable from anywhere, claimable
A name resolves to this workspace's own profile first, then to an unowned pre-partition one in
place (nothing that worked before the partition breaks), and otherwise creates a new one here.
Another workspace's profile is reported and never driven - browser_profiles lists it under
otherWorkspaces and browser_profile_find ranks it separately, because a silent miss is what
sends an agent off opening windows. browser_profile_claim { profile, from? } is the one door
through the boundary, and it MOVES (two workspaces driving one identity's cookies means two Chromes
racing the same session). Nothing crosses to another machine: each PC has its own store.
Why it is scoped at all, measured: the first cut ranked every profile on the PC, and a
Connections session asking "who is signed into Cloudflare?" was handed company-4c39a4b1-…,
which ~/.connections/workspace-bindings.json shows is the Ticket Tool workspace's default
profile - one project's logged-in identity offered to an agent working in another, with nothing in
the reply saying whose it was. Pinned by browser-external-profiles.spec.ts.
~/.connections/browser-profiles/ holds the profiles this MCP owns. The machine ALSO has the
human's own Chrome (dozens of named profiles holding the real registrar/vendor/bank logins).
Until 2026-09-18 the tools could only see the first store, so the honest answer to "use the
browser that is already logged in" was "nothing is logged in" while the session sat on the same
disk. Both stores are now indexed:
browser_profile_find { for } is the question you actually have: "which browser here is
already logged in for gmail / cloudflare / LunarWerx?". It takes a service word, a URL or an
identity and returns drivableNow - the managed profile to pass as profile:'<name>' - or, when
only the human's own Chrome holds that identity, the single browser_profile_login call that
mirrors it into a profile the MCP owns. Name a profile after the IDENTITY it carries
(lunarwerx, saddlegauge), because the search matches the name as well as the sites.browser_profiles returns managed (this workspace's), unowned (pre-partition), external
(the human's own Chrome) and otherWorkspaces (names only). Every profile row carries
signedInHosts - the hosts whose cookies are NOT pure tracking noise - and sites, the dated
record of what each profile was last seen to reach. ⛔ Cookie VALUES are never read, returned or
logged; a host list routes a task, a value is a live credential.browser_profile_claim { profile, from? } moves a profile into this workspace for good: no
from for an unowned pre-partition one, from:'<workspace path>' to take one from another
project. It refuses when the name exists in two places, and when the profile has a live Chrome.~/.connections/browser-profiles/registry.json and rescanned per profile
only when its cookie store's mtime changes. Deleting the file costs one rescan.__cf_bm / _ga / cf_clearance is NOT a login. Counting any cookie named
eleven local profiles as "signed into Cloudflare"; none could open the dashboard.⛔ A modern Chrome session cannot be copied, and the tooling says so instead of pretending.
Chrome >=127 app-bound-encrypts cookies (v20 values, key in os_crypt.app_bound_encrypted_key,
unwrapped by an elevated service). Measured here on 2026-09-18: copying a profile with os_crypt
carried took 96 cookie rows to 16, and carrying the whole Local State took it to 0 - Chrome
silently deletes every row it cannot decrypt. That is the anti-infostealer feature working. So
browser_profile_adopt refuses an app-bound source (it still works for stores without that key,
and force:true copies the non-session files), and the sanctioned path is:
browser_profile_login { profile: "cloudflare-registrar", url: "https://dash.cloudflare.com/" }
# a plain visible Chrome opens with NOTHING attached - no debugger, so bot checks behave
browser_profile_login { profile: "cloudflare-registrar", url: "https://dash.cloudflare.com/", verify: true }
# records, from host names alone, that the login landed
After that, profile:"cloudflare-registrar" on any browser_* call carries the session for as
long as the site keeps it. profile:"chrome:<friendly name>" names one of the human's profiles and
resolves to the managed twin of the same name - it throws, naming browser_profile_login, when no
twin holds a session yet, rather than handing back a signed-out window that looks right.
Adoption copies cookies, storage and preferences. It deliberately does not copy
Login Data / Login Data For Account - the saved-password vault is not session state and lifting
it is not this tool's job.
The stdio registration above starts one loader per chat: 80-95 MB each, 2.8 GB across 33 chats measured on 2026-09-24. loader.mjs --http is the same server as ONE process on 127.0.0.1:7791 (--port N or CONNECTIONS_HTTP_PORT), serving every chat on the machine over MCP's streamable HTTP transport. Register it once, for every folder:
claude mcp add-json -s user connections-local '{"type":"http","url":"http://127.0.0.1:7791/mcp","headersHelper":"node <abs path>/loader.mjs --connect"}'
A machine without this checkout uses the npm loader as the helper ("headersHelper":"npx -y connections-mcp --connect"), which needs an npm release of 2.173.0 or later; what each npm release changes is in CHANGELOG.md, and a publish takes its notes from there.
node loader.mjs over stdio (the old install line, Cursor, Windsurf) no longer loads the impl into every chat's process: it starts the host the way --connect does when none answers, opens one session on it with this chat's folder (CLAUDE_PROJECT_DIR, else its cwd) and chat env in the same headers on every request, and passes each message through. The host's replies come back on the same stdout, progress, roots/list and elicitation/create included, and the client's answers go back as POSTs - so a client that declares roots still moves the chat's folder. Measured on Windows (Node 22, idle, a scratch host): the relay's working set is 57 MB, an in-process stdio loader's 82 MB (the host itself 77 MB, shared by every chat). The relay also drops the stall-watchdog worker thread (~15 MB), since it holds no impl that can wedge. It loads the impl in-process, as before, only when no Connections host can be reached (port held by something else, or the host will not start), and then starts the watchdog. CONNECTIONS_STDIO_RELAY=off or a pinned CONNECTIONS_IMPL_FILE (a test or dev impl is meant to run in the process you started) keeps the old in-process behaviour. In shared mode the host cannot start an unprompted message to a chat - its only voice is the event stream of a request that chat is waiting on - and the chat's browser sessions, shell jobs and state live in the host, so they outlive the chat that opened them until its idle sweep or DELETE. Spec: stdio-relay-to-shared-host.spec.ts.CONNECTIONS_WORKSPACE, the client's CLAUDE_PROJECT_DIR and its cwd that is not the user's home or the Claude config home (~/.claude, CLAUDE_CONFIG_DIR, anything under it); isNonProjectHome / chooseProcessFolder in src-impl/00-header-and-workspace.mjs. Before this, a fallback process that inherited CONNECTIONS_WORKSPACE=C:\Users\<me>\.claude registered that folder with no company and every vault call answered service_not_connected ("no aws credential in the Studio vault") although the credential existed; cdk_deploy with fromSha also resolved the repo from it, and every shell child inherited the pin. When no usable folder is known the chat is unbound: it registers nothing (reportWorkspace returns early), reads no stored company (loadBinding), connections_whoami says assignmentSource: "unbound" with an unboundNote, connections_health shows issuedBy.unbound: true and folder: null, and a vault mint names the cause ("this chat is not bound to a project folder") instead of a missing credential. The client's roots still bind it on the first tools/list (a home-folder pin no longer stops that ask), connections_use_workspace { company } pins it by hand, and a stdio process whose inherited pin is a home folder hands its shell children the chat's folder instead (unset when unbound). The shared host's chats follow the same rule through makeChat. Spec: relay-fallback-never-binds-claude-home.spec.ts. Not covered: the Studio API's own vault-miss text (executor-db-core-mcp-vault-row.mjs) still reads "Connect one in Studio first" for a real project folder that has no company assigned; that wording is a backend change.--connect is the chat's headersHelper. Claude Code runs it, in the chat's folder, each time it connects. It makes sure the server is up (starts it hidden and detached, logging to ~/.connections/local-mcp/http-7791.log, behind a lock so two chats never start two) and prints the headers that name this chat's folder (X-Connections-Workspace) and its chat env (X-Connections-Chat-Env: CLAUDECODE, CLAUDE_CODE_SESSION_ID, CLAUDE_PROJECT_DIR). A server that cannot come up fails that chat's connection with the reason; it never leaves a chat quietly without tools.initialize. Every request runs inside its chat's scope (openChat / runInChat in src-impl/00-header-and-workspace.mjs): the folder, the chat env, the team-board agent, the lane timers, the browser's current page and the client are the chat's; the registration row, company binding, heartbeat and mail counts are the folder's; vault-minted credentials are the company's. Nothing is read from the server's own cwd or env, and a folder read outside any chat throws instead of answering from them.CONNECTIONS_CHAT_IDLE_MS), or when the server ends.carriedChats in loader.mjs); the swap's answer says chats: <n>, readopt: .... A shared-server chat's workspace report, team board, lane and almanac upkeep start on its first request instead of at initialize (bootSharedChatOnce), the lane-seen prune runs at most every 10 minutes, and the idle sweep yields every 50 chats. A new chat's owner lookup (the claude process behind its socket: netstat, then one PowerShell per parent hop, 3.3 s for a client that is not Claude) runs on a worker thread, so only that chat waits for it. Spec: shared-host-loop-stays-free.spec.ts.~/.connections/local-mcp/sessions-<port>.json (what a restarted host re-opens each chat from) is one snapshot line followed by one line per change, so opening a chat appends a few hundred bytes. It used to rewrite every row on each initialize: the :7791 host had 25,000 rows (a 10 MB file, about 28 MB of heap) after 22 hours, and that rewrite was 46% of everything the host allocated. The snapshot is rewritten once the change lines outnumber the rows, a torn last line (a host killed mid-write) costs only that line, and the idle sweep drops rows unseen for 7 days from memory, where they used to stay until the host restarted. Rows share one copy of each distinct initialize.impl-script.mjs), not an ES module: Node keeps every module it imported until the process ends, so each swap used to keep the whole previous impl, 11-13 MB, and the :7791 host swapped 5 times in 22 hours. After a swap the old impl's retireImpl stops what it ran on its own (its process exit and signal listeners, folder heartbeats, the wedge watch, the resident loops, a browser_live engine) and starts nothing again, so the collector takes all of it. The published bytes are still an ES module (an older loader imports them as before), checked by sha256 and signature as they always were; build-impl.mjs refuses to write an impl the script form would read wrong. GET /health and connections_health report implLoad: { form, swappedOutStillInHeap }: form is script or module (an impl published before 2.238.0), and the count is retired impls not yet collected. A closed chat keeps nothing of its impl (2.240.0): every async resource made inside a chat's call keeps the context it was made in, and so that chat, and a keep-alive connection to Studio is reused and never sits idle on a busy host, so on :7791 the impl 2.239.0 replaced was still held 50 minutes after the swap, through context, chat, its enders and a closure of the old script. closeChat now clears the chat's enders and state and gives it a copy of its folder identity with a state of its own. A copy retired by an impl older than 2.240.0 stays until its connections close or the host restarts. Spec: loader-impl-swap-frees-the-old-impl.spec.ts (both cases).GET /health answers { connectionsLocal, pid, port, ready, version, chats }; connections_health lists every in-flight call with the chat that issued it. Its issuedBy.chatSummary counts the host's chats (active in the last hour, oldest idle, folders, the five busiest, and awaitingReopen: chats an impl swap kept that have not re-opened yet, also chatsAwaitingReopen on GET /health), issuedBy.chats holds only the asking chat's own row, and params: { chats: true } lists every chat. jmespath applies to it as to any other call, and a malformed expression is refused by name.You register the loader once. It is tiny and stable. On every launch it fetches the latest impl.mjs from AWS (CONNECTIONS_IMPL_URL, default https://connections-mcp-local-637560253023.s3.us-east-1.amazonaws.com/impl.mjs - S3 in the Studio account), caches it under ~/.connections/local-mcp/, and runs it. So you publish a fix to AWS and every machine picks it up on its next session - no reinstall, no re-publish of the loader. Resilience ladder: latest-from-AWS → last cached copy → the impl.mjs bundled next to the loader (full offline fallback). A running process follows the cache (2026-10-07): between requests, with no call in flight, the shared host and an in-process stdio loader compare ~/.connections/local-mcp/impl.mjs with the impl they run and swap when the cache is newer by version (or, at the same version, by sha); a unknown_local_tool miss re-checks (cache, then a verified fetch) and runs the call again on the new impl, so a tool published a moment ago works on the first call. In-flight calls finish on the old impl. GET /health and connections_health report loadedVersion, newestCachedVersion, behind and a note when behind. The impl stays self-contained: the loader only reads the cache. Dev override: set CONNECTIONS_IMPL_FILE=<path> to run a local impl and skip the fetch. ⛔ That override is also the only way to EXERCISE a tool you just added - your own session's server is still running the published bundle, so a new tool answers unknown local tool until the client restarts. The Script Vault instrument local_mcp_call_freshly_built speaks MCP over stdio to a fresh loader with this variable pointed at the on-disk build, which is how sign_artifact was proven before it shipped.
Publish a fix to AWS: impl.mjs is a GENERATED, committed artifact (2026-07-15, same shape as gitcore.mjs) - edit the owning fragment under src-impl/ (see src-impl/manifest.json for the tool-family → file map), run node build-impl.mjs to regenerate impl.mjs, node selftest.mjs to verify, then bun publish-impl.mjs (value-blind, uses vault-injected Studio creds - bun, not node: it imports a .ts through the workspace node_modules, which node refuses to type-strip) - it uploads impl.mjs to S3 and verifies the live fetch. Don't hand-edit impl.mjs directly: the next node build-impl.mjs silently overwrites a direct edit with whatever the fragments still say (node build-impl.mjs --check, wired into selftest.mjs, catches this drift). That's the whole update workflow; no client reinstall.
Bumping the version - the one-line rule: bump it in
package.json, and only there. Then runnode build-impl.mjs.package.json'sversionis the single source of truth;build-impl.mjsreads it and syncsmanifest.json,.claude-plugin/plugin.json,server.json(both the top-levelversionandpackages[0].version) andSERVER_INFOinsrc-impl/00-header-and-workspace.mjsfrom it automatically, in place, before it regeneratesimpl.mjs- so those four never need a hand-edit again.node build-impl.mjs --checkverifies the same parity read-only (fails loud ifpackage.jsonhas drifted from any of the four without a rebuild), andpublish-impl.mjsstep 0 re-checks from scratch immediately before anything ships, as the backstop of last resort. (This collapsed a five-file-by-hand bump that was easy to skip and only ever caught by that backstop refusing to publish - seedocs/todo/improvements/tooling/local-mcp-version-number-duplicated-across-files-causing-publish-drift.md.)
⚠️ A bare
bun publish-impl.mjsnow REFUSES: the loader'sPINNED_SIGNING_PUBKEYis armed (2026-08-30), so every publish must carry a signature. The signing PRIVATE key lives in accounts (180555415005), Secrets Managerconnections/provenance/artifact-signing(signing_private_key_pem) - deliberately not Studio, so this repo's own credentials can't read it. Publish signed with the one-call cross-account seam instead, which fetches the key value-blind and never writes it to disk:shell { instance:"Root/Connections", command:"bun services/studio/local-mcp/publish-impl-signed.mjs" }(--check-onlyfetches + fingerprints the key and publishes nothing). Full doctrine:docs/security/MCP_ARTIFACT_PROVENANCE.md.
Trust note: the loader runs code fetched over HTTPS from the Connections origin (an "always-latest" tradeoff). The integrity hardening is shipped (2026-06-29) -
publish-impl.mjsemitsimpl.mjs.sha256and the loader verifies the SHA-256 before executing the fetched code, with a last-good.prevrollback. Version is single-sourced frompackage.json(see "Bumping the version" above) and consistency-asserted across the four derived manifests before every publish. (Full architecture:docs/architecture/CONNECTIONS_MCP.md.)
Mid-session "MCP server disconnected… reconnected" events were the server process dying and the
client respawning it (there is no idle timeout, and the self-updating loader cannot swap the impl
mid-session - the impl is imported exactly once at launch). The audited kill vectors - a stdout EPIPE
when the client's pipe end drops (an unlistened 'error' event = uncaught exception), an unhandled
rejection escaping detached background work (the device-sign-in poll's saveAuth), and browser spawn
'error' events with no listener - are all closed (shipped 2.34.0), and a process-level crash net
guarantees the contract:
~/.connections/local-mcp/lifecycle.log (1 MB cap, one rotation kept). A disconnect is now always
attributable: check that file first.The net lives in the loader and ships inside impl.mjs (armed on initialize,
globalThis.__cxCrashNet keeps them from double-installing), so machines still running an older loader
get it via the normal S3 self-update. Reconnect/backoff itself is the MCP client's job - Claude Code
respawns the stdio server automatically; our side's contract is to not die, and to say why when it must.
Many concurrent sessions are by design: each session spawns its own loader process; the only shared
state is the launch-time cache under ~/.connections/local-mcp/, written atomically (temp + rename -
and when Windows refuses the rename because a concurrent launch holds the cache open, the destination
is never torn by a direct write: the session imports its fresh pid-namespaced tmp copy beside it,
logs cache-rename-refused, and the cache updates on a later launch). Locked by
selftest-lifecycle.mjs (11 assertions, wired into selftest.mjs).
The filed wedge: a session's loader stopped answering EVERY call, connections_ping included, each one
running into Claude Code's "sent no response or progress for 1800s" abort, while a fresh loader on the
same machine answered the same calls in seconds. The process sat at 0% CPU with no child.
The measured root-cause class. On Windows, Node makes a piped stdout/stderr blocking. Once about
64 KB sits in a pipe the client is not draining, the next write stops the whole event loop: no timer
fires (so timeoutMs never does), no request line is read (so ping hangs), nothing burns CPU. A fresh
process answers because its pipe starts empty. setBlocking(false) does not help, and process.exit()
hangs while such a write is stuck, which shaped the fix. What stops a client draining is on the
client's side; the server now survives it either way.
timeoutMs + 2 min,
else 25 min, returned as { error: "tool_call_ceiling_reached", detail, hint } naming the tool and
what the process was still waiting on. The work is NOT cancelled - read the real state before
retrying a deploy._meta.progressToken gets notifications/progress
every 30 s, so the client's idle timer never fires on a live call (which is also why a declared
60-minute shell is honoured when a token is present).ping and connections_health
(connections_execute { local:true, tool_name:"connections_health" }) are answered by loader.mjs
before the impl dispatcher. It lists every in-flight request and tool call with its running time and
ceiling, whether a response is stuck, and how much stderr was dropped.fs.write;
stderr past 256 KB of backlog is dropped (stderr-backlog-dropping in lifecycle.log), stdout never is.watchdog-event-loop-stalled / watchdog-stdout-not-draining to
lifecycle.log and kills the process, so the client respawns a clean one.~/.connections/local-mcp/lean
(or CONNECTIONS_LEAN=1) makes new loaders skip the watchdog worker, which saves about 7 MB private
per chat but gives up the kill-and-respawn above. The loader logs watchdog-skipped-lean and
connections_health reports the watchdog as off. Delete the marker to undo; running chats keep the
mode they started with.Recovery for a session that still does not answer: run one call in a process of its own with
node loader.mjs --call <args.json> (the file holds connections_execute's arguments, for example
{ "local": true, "tool_name": "shell", "params": { "command": "..." } }; it prints the answer and exits 1 on an
error), adding --hold when the call starts a detached job, so the process stays until the job's log has its exit
line. It is the way through for a chat registered over http, which has no stdio fallback. The Script Vault instrument
local_mcp_call_freshly_built does the same over stdio for a freshly built impl. Then stop the wedged loader's pid
(find it as the child of the session's claude.exe); the client respawns a clean process on its next call. Locked by
tool-call-ceiling-and-progress-heartbeat.spec.ts and loader-health-path-and-stall-watchdog.spec.ts.
Recovery for the shared host. The shared host (loader.mjs --http, 127.0.0.1:7791) can log every minute and still answer nothing: GET /health times out, or a connect is refused while the socket shows Listen. Every chat on the machine inherits it. A stdio chat falls back to running in-process; an http-registered chat has no fallback, so run its calls with --call as above. The host recovers on its own: its watchdog thread sends GET /health over loopback every 30 s, and after four misses of 10 s in a row (a busy loop or a blocked one) it kills the host, logging watchdog-host-unanswered; the next chat call respawns it, and each open chat re-opens from the session registry on its folder and its company pin. Lean mode does not turn this guard off for the shared host. To recover sooner by hand, stop the host's pid (the one listening on 7791). CONNECTIONS_WATCHDOG_PROBE_MS, _PROBE_TIMEOUT_MS and _PROBE_MISSES tune the probe. Background work every chat schedules (mail counts, lane refreshes, board starts) shares four slots across the host (hostBackground), so a host holding hundreds of chats does not fire hundreds at once.
A failure that only shows up two hours into an agent session is hard to act on. Record the session, then let the minimizer cut it down to the calls that still reproduce the failure.
CONNECTIONS_MCP_RECORD set (in the client's MCP registration,
e.g. claude mcp add ... -e CONNECTIONS_MCP_RECORD=1). 1 writes
~/.connections/local-mcp/sessions/<start>-<pid>.ndjson; a folder writes there; a *.ndjson path
appends to that file. Each request writes a call line BEFORE it runs (seq, method, the params
with host paths as @WORKSPACE_ROOT@ / @HOME@ and secret-keyed params - password, token, secret,
API key, private key, credential, authorization, cookie, bearer, auth, session id/key - as
@REDACTED@), then an outcome line with its failure signature (ok, error:<code>,
throw:<code>:<message>) once it settles. A call with no outcome line killed the process or never
returned. Results are never stored. Off unless set; nothing leaves the machine. Free-text params
are stored as written: a shell command carrying curl -H 'Authorization: Bearer ...' keeps its
token, so treat a recording as sensitive and scrub such lines before you share it.node session-replay.mjs <file> lists the recorded calls and their signatures. It runs
nothing yet.node session-replay.mjs <file> --yes plays it
through a fresh loader + this impl.mjs over stdio and names each call whose signature differs
from the recording. --root / --home put the placeholders back on another machine.node session-replay.mjs <file> --yes --minimize runs ddmin over the calls, keeping a
subset only while a fresh replay still produces the target signature (exit:/timeout - any crash
or hang - when the last recorded call has no outcome, else the recording's last non-ok one, or
--signature), and writes <file>.min.ndjson. A process that dies signs as exit:<code>,
a call that never answers as timeout (--call-timeout-ms). For a flaky failure,
--runs 5 --rate 0.4 keeps a subset when at least 2 of 5 fresh runs reproduce it.The recorder ships with every impl publish; session-replay.mjs does NOT (it is not in the package's
files), so replay and minimize run from a repo checkout of services/studio/local-mcp/. Copy the
recording there, or point --impl at the installed impl.mjs.
Code: src-impl/26g-session-record.mjs (recorder), session-replay.mjs (replay + ddmin). Locked by
session-replay.spec.ts. Idea from microsoft/TypeScript's LSP replay minimizer (Apache-2.0).
Read this before you run bun publish-impl.mjs (no flag) - it's the one way to lose someone else's work.
Several of us - and our AIs, across separate checkouts - build against this MCP at the same time, and
hotfixes get published straight to S3 (impl.mjs / browser.mjs) faster than they get ported back
into this repo. So the copies in this repo routinely sit BEHIND live. That's expected and fine - it
settles out - except at publish time: a no-flag bun publish-impl.mjs uploads THIS repo's
impl.mjs + browser.mjs (+ gitcore.mjs), so publishing from a stale checkout silently REGRESSES
the live hotfixes. This has already bitten twice; the rule below is how we stop repeating it.
This is now automated two ways (you still reconcile by hand, but you can't publish a regression by accident):
node check-live-drift.mjs (or bun run --cwd services/studio/local-mcp drift) fetches both live files
and reports IN_SYNC / AHEAD / BEHIND / DIVERGED - directional, using SERVER_INFO.version as the tell,
so it tells "repo has an unpublished local change" (fine, AHEAD) apart from "repo is missing a live hotfix"
(danger, BEHIND). Exit 0 = safe to publish · exit 1 = reconcile first.publish-impl.mjs runs that same guard on every full publish (step 0b) and REFUSES when the repo is
BEHIND/DIVERGED from live - so a stale-checkout publish is blocked before it touches S3. Bypass with
--force only once you've verified you're not regressing anything.Two safe moves - pick by what you actually changed:
You only rebuilt the git engine (build-gitcore.mjs → gitcore.mjs) and did NOT touch
impl.mjs/browser.mjs → publish with bun publish-impl.mjs --gitcore-only. It ships ONLY
gitcore.mjs (+sha) and leaves live impl.mjs/browser.mjs untouched - zero regression risk.
You changed impl.mjs/browser.mjs, OR you're doing a full publish → reconcile the repo UP to
live first, then publish. The live files are public, non-secret code. Fetch them, diff, and port any
live-only additions back into the repo verbatim - this is preserve-never-delete: live is
normally a strict superset of the repo, so a straight sync drops nothing (sanity-check the diff's
removed-lines to be sure). Then re-run the selftest:
cd services/studio/local-mcp
node check-live-drift.mjs # BEHIND? then reconcile - fetch live + diff:
BASE=https://connections-mcp-local-637560253023.s3.us-east-1.amazonaws.com
curl -s -o /tmp/impl.live.mjs "$BASE/impl.mjs"; curl -s -o /tmp/browser.live.mjs "$BASE/browser.mjs"
# ⛔ NORMALISE LINE ENDINGS FIRST - never diff these raw. See "The CRLF trap" below.
diff <(tr -d '\r' < impl.mjs) <(tr -d '\r' < /tmp/impl.live.mjs) # '>' lines = live-only hotfixes to port back
diff <(tr -d '\r' < browser.mjs) <(tr -d '\r' < /tmp/browser.live.mjs) # same for browser.mjs
# impl.mjs is GENERATED (src-impl/*.mjs → build-impl.mjs) - port each '>' hunk into the FRAGMENT
# that owns those lines (grep src-impl/*.mjs for the surrounding function/tool name to find it),
# NEVER into impl.mjs directly (the next build-impl.mjs run would silently discard a direct edit).
# browser.mjs has no split yet - port straight into the file as before. Then:
node build-impl.mjs && node check-live-drift.mjs && node selftest.mjs # all three must pass before you publish
Don't forget the version: when the port includes a SERVER_INFO.version bump, bump it in
package.json only - node build-impl.mjs (already in the command above) syncs manifest.json,
.claude-plugin/plugin.json, server.json and SERVER_INFO from that one field (see "Bumping the
version" below; publish-impl.mjs step 0 still refuses on any disagreement, as a backstop). Publish
only once check-live-drift.mjs reports IN_SYNC/AHEAD plus your intended change is in place.
> lineA raw diff of these two files lies on Windows, and it lies in the most alarming direction.
The S3 object can be served with \r\n while the repo copy is \n; every line then differs, and
because the live side is the > side, the output reads as "live is carrying work you are about to
erase." That is the exact shape of a real BEHIND, so it is believed.
It has already cost a full session (2026-09-01). The raw diff reported 84 live-only lines, 17 of
them using identifiers this repo had nowhere, which was relayed to the owner as a live production
risk and filed as a to-do telling the next session to port live UP first. All of it was noise from
2,047 CR bytes. Normalised, the true difference was 7 live-only lines against 31 repo-only - and
every one of the 7 was a line this repo's own committed change had replaced (an if split into two
branches, its comment rewritten, a ternary re-indented). The repo was strictly AHEAD. Following the
to-do would have dragged a committed fix backwards.
So, before believing any drift verdict below the IN_SYNC/AHEAD/BEHIND headline:
tr -cd '\r' < impl.mjs | wc -c # repo side
tr -cd '\r' < /tmp/impl.live.mjs | wc -c # live side
If one side has thousands and the other has zero, every line-level verdict above it is noise -
normalise and re-diff before reading a single hunk. check-live-drift.mjs's own verdict is safe here
(it compares SERVER_INFO.version, not lines); it is the by-hand reconcile diff that misleads.
And the general rule, which outlives this file: "live differs from HEAD" is half a finding. The
other half is DIRECTION, because the repo being ahead makes live differ in exactly the same way a
live hotfix does. Prove direction - check whether each > line is one your change replaced - before
relaying anything to a human, and never file a to-do off an unproven verdict: a wrong finding in
a reply is corrected by the next message, a wrong finding in a queue gets executed by a session that
was not there and cannot see it was wrong.
git pull origin main, never beforeThe guard above stops you publishing from a checkout that is behind. It cannot stop you publishing from a checkout that is about to become behind - and that is the ordering people actually get wrong.
impl.mjs is GENERATED by concatenating the src-impl/ fragments, and peers commit fragment
changes without publishing. So a merge pulls their edits in, node build-impl.mjs rebuilds a
DIFFERENT impl.mjs, and the artifact you uploaded five minutes ago no longer matches the repo that
claims its version number - live and repo both saying e.g. 2.62.0 while holding different code,
which is exactly the state check-live-drift.mjs reports as DIVERGED. The only fix is a second
version bump and a second publish.
Hit 2026-07-30 during the 23rd improvements/mcp burn-down: published 2.62.0, then merged 49
commits, two of which touched fragments - 2.63.0 existed purely to get back to IN_SYNC.
So the order is always: pull → node build-impl.mjs → node selftest.mjs → publish.
Two corollaries worth keeping in the same breath:
impl.mjs as if it were
source, and the result can be syntactically fine and semantically wrong. Only a rebuild from the
merged fragments is trustworthy (node build-impl.mjs --check catches the mismatch).selftest.mjs and a
runtime call before believing the repo really is ahead.History: reconciled up to live twice on 2026-07-04 - first to 2.17.0 (unpacked-extension loading +
browser_evaluate timeoutMs + browser_targets), then to 2.18.0 (browser_status tool + status
action) a few minutes later, mid-session, when a teammate published while this was being written - which is
exactly why the check-live-drift.mjs guard now exists. If you're reading this and the repo is behind again,
that's just concurrent work - run node check-live-drift.mjs, reconcile up, carry on.
This server is ours and hot-reloadable - so when you use it and find a tool inefficient, clunky, or broken that you believe could be better, do not just endure it:
impl.mjs, split (2026-07-15) into
per-tool-family source fragments under src-impl/ - edit the fragment that owns the tool
(src-impl/manifest.json maps family → file), node build-impl.mjs to regenerate impl.mjs,
then bun publish-impl.mjs and every machine gets the fix on its next session (no reinstall);
verify with node selftest.mjs. A missing/wrong remote op (AWS or a connected non-AWS service -
the allowlisted cloudflare/github/stripe/… hosts) is a connections_catalog_add /
connections_catalog_remove away; deeper connect/exec behavior is in the Studio API
(services/studio/api).fourman add) describing the friction + the
proposed improvement (AI-buildable backlog; owner-gated MCP steps go to Jacob's Notes to-do queue/ instead).Never silently tolerate MCP friction - improving the tools we work through compounds (this is the "Leave the Guardrails Stronger Every Run" mandate applied to the MCP itself).
The loader runs in your workspace (cwd), so each folder is its own registration. Registration is automatic: any MCP use (and a 30s heartbeat while the session lives) upserts this folder onto the MCP Servers page - Active within seconds of opening, Inactive within seconds of closing (a goodbye beacon fires on clean close; a 90s window catches crashes).
A fresh workspace is Unassigned - hard none, no default company: company-scoped calls return a structured error instead of silently running as anything. Binding it, exactly two ways:
/link or /setup): while - and only while - the workspace has NO
company, the agent may run connections_whoami → connections_list_companies → (human confirms in
chat) → connections_claim_company. The claim is write-once: after it (or any Studio
assignment) there is deliberately no agent path to change, clear, or re-claim - /switch and
/disconnect just point you at Studio. Agent-claimed rows wear an "Agent-claimed" badge for review.Different folder → different company, automatically, no reload. (The old paste-a-key connections_setup
model is gone - auth is browser sign-in only.)
A chat opened in an unassigned or wrong-company folder can still do company-scoped work. The desktop client
auto-declines the elicitation form connections_claim_company and connections_switch_workspace need, so call AS the
already-assigned repo folder instead, which changes no binding:
node services/studio/local-mcp/call-as-folder.mjs <folder> <tool_name> ['<params json>'] [--remote] [--service=<name>]
--remote --service=boardroom reaches catalog endpoints such as boardroom_personas_list; the history is in the
memory deploy-creds-need-the-mcp-workspace-bound-to-connections.
evals/cases.json holds read-only operator tasks. Each names the ordered workflow a well-steered
agent shows (connections_search_catalog, then connections_execute:<tool>), the forbidden
fallbacks (raw Bash, Grep, the human's todo_list; connections_execute:endpoint is any catalog
API call, scored apart from local tools so forbidding it never fails the connections_whoami that
INSTRUCTIONS ask for first), optional answer_contains phrases, and a probe search. A known
failure stays in the file as skip with observed, run, date and reenable; the runner
refuses a skip missing any of them. skip parks a model's behaviour, so it applies to --live
only: the offline replay still checks a skipped case against the shipped matcher.
node services/studio/local-mcp/evals/run-evals.mjs # offline replay (CI, via mcp-evals.spec.ts)
node services/studio/local-mcp/evals/run-evals.mjs --live --model opus --model sonnet --out tmp/evals
node services/studio/local-mcp/evals/run-evals.mjs --transcript <session.jsonl> --case memory-recall
The replay ranks every probe through impl.mjs's own searchLocalTools and fails when a promised
tool falls outside the window a default search shows (impl's own localSearchWindow(), 4 today),
or no longer exists. --live runs each case
through claude -p per model and scores the session: run it before changing INSTRUCTIONS or a
tool description. It spends model time, so CI never runs it.
node services/studio/local-mcp/selftest.mjs
Speaks the MCP protocol to the server and checks initialize / tools/list / tools/call for every
tool. Exits non-zero on any failure.
This server runs commands on your machine with your privileges - that is the point (it's the local half an AI needs to actually do work). Only register it in clients you trust, and review what the AI runs the same way you would review a teammate with shell access.
Source: src-impl/09b-shell-policy.mjs. Before shell or ssh runs anything (and before the git tool runs its args, judged as literal words), the command is PARSED (bash, PowerShell or cmd, each with its
own quoting) into sub-commands, and wrappers are unwrapped and checked too: bash -lc, pwsh -c and
-EncodedCommand, cmd /c, Start-Process pwsh -ArgumentList, Invoke-Expression '<literal>',
$(...), backticks, script blocks, sudo/env/xargs. The strictest decision wins:
rm -r, Remove-Item -Recurse under every alias
and parameter abbreviation, rd /s, del /s, [IO.Directory]::Delete(p, $true)) of a filesystem
root, the home directory, or the working directory or a parent of it, judged on the path-normalised
form (/tmp/.. is /); disk formats and dd of=/dev/.... Rule ids: recursive-delete-protected-path,
disk-format, raw-device-write.recursive-delete-unresolved-target): resolve the path and pass it literally.<workspace>/.connections/shell-policy.json:
{ "rules": [{ "id", "pattern": ["git", ["pull", "merge"]], "decision": "allow|prompt|forbidden", "justification", "dialect": "any|bash|powershell|cmd", "match": [...], "not_match": [...] }] }.
pattern is a command prefix (a nested array is a set of alternatives); the match/not_match
examples run every time the file loads, and a file that fails them refuses EVERY command until fixed.
A forbidden rule's justification is returned as the alternative.prompt refusal names the prefix to grant. After the human approves,
shell_policy { op: "grant", prefix: "git push" } stores it machine-level
(~/.connections/shell-grants/<workspace hash>.json). A grant never covers a sub-command with a
variable or substitution, never relaxes a forbidden or built-in rule, and is refused for ./ or
absolute-path scripts. shell_policy { op: "check", command } dry-runs a verdict; op: "list" shows
the rules, the grants and whether the file loaded.prompt is a speed bump, not a lock. Nothing here can tell a human's call from the agent's:
the agent can call shell_policy { op: "grant" } itself. Every grant the tool writes is stamped
granted_via: "mcp:shell_policy ..." with its time, so op: "list" shows how each one was made; a
grant a human writes by hand into the grants file carries no such stamp. When a command must never run
without a person, make the rule forbidden and let the human run it.eval '<literal>' is unwrapped and $'...' words are decoded,
but a PowerShell delete whose parameters come from a splat (Remove-Item C:\x @p,
delete-splatted-parameters) and an inline git alias (git -c alias.p=pull p, git-inline-alias)
cannot be read, so both prompt.Refusals come back as the standard envelope (shell_policy_forbidden, shell_policy_needs_approval,
shell_policy_file_invalid) and name rule ids, never the command.
Every local tool has one row in TOOL_POLICY (src-impl/91-tool-policy-and-capabilities.mjs): its
class (read, external-read, write, destructive-write, external-write, interact, execute,
deploy), the params that carry a path, host or service, and the confirm flag it needs. The MCP
annotations every client sees are derived from the class, and the confirm flag is enforced at the
dispatcher, so a tool cannot advertise read-only while it writes. A tool with no row fails the selftest.
To give one client or one lane less than everything, write a capability file and point the client's
MCP config at it with CONNECTIONS_CAPABILITY_FILE (else <workspace>/.connections/capability.json
is read; with neither, every tool is granted, as before):
{
"identifier": "hswarm-reader",
"description": "Reads this repo, pushes nothing.",
"permissions": [
"allow-class-read",
{ "identifier": "allow-shell", "allow": [{ "path": "$WORKSPACE/**" }] },
{ "identifier": "allow-catalog_endpoint", "allow": [{ "service": "github" }] },
{ "identifier": "deny-read_file", "deny": [{ "path": "**/.env*" }, { "path": "**/.secrets/**" }] },
"deny-browser_*"
]
}
allow-<tool>, deny-<tool> (a tool name may be a
glob), allow-class-<class>, deny-class-<class>. connections_capabilities { permissions: true }
lists them all; connections_capabilities { tool, params } checks one call without running it.{ path | host | service: "<glob>" }; * stays inside one
path segment, ** crosses them, $WORKSPACE and $HOME expand. A path is judged the way the tools
resolve it (against the workspace, .. collapsed); an omitted path param counts as the workspace.service deny
refuses a bare endpoint_id call; a host deny on github refuses a call with no url).allow-class-execute with a
path scope grants shell in that path, and does NOT grant ssh, ci or API endpoints, which take
no path. Grant those with their own permission.connections_search_catalog.read is
refused a path that names the active file, but shell (or any program the agent runs) can still
rewrite it, and the widened grant applies at the next start or loader reload. A client-set
CONNECTIONS_CAPABILITY_FILE outside the workspace is safer than <workspace>/.connections/capability.json.node_modules is a junction) reaches whatever it points at.connections_capabilities and connections_health is refused.shell with a cwd scope still runs
any command. Grant execute-class tools only when you mean it.To have a separate policy process allow or deny what the AI runs, start the server with
CONNECTIONS_PERMISSION_BROKER set to a named pipe (\\.\pipe\<name> on Windows) or a Unix socket
path the broker listens on. Before each governed call the server opens a connection and writes one
line:
{ "v": 1, "pid": 1234, "id": 7, "datetime": "2026-09-24T21:00:00.000Z", "permission": "shell", "value": { "command": "bun run build" } }
The broker answers one line, {"id":7,"result":"allow"} or {"id":7,"result":"deny","reason":"..."}.
A deny returns permission_denied with the reason. It fails closed: no broker listening, a reply
that is not JSON, a different id, any other result, or no answer within
CONNECTIONS_PERMISSION_BROKER_TIMEOUT_MS (default 120000) returns permission_broker_failed, and
the call does not run. An allow that arrives after the per-call ceiling has already answered the client
returns permission_broker_late and does not run either. Governed by default: shell, script_run, git, ci, ssh, sftp, deploy_static,
cdk_deploy, cdk_import, lambda_deploy_zip, lambda_finish_alias_repoint,
cloudflare_worker_deploy, npm_publish (git is governed because -c alias.x='!<cmd>' or
-c core.sshCommand=... runs any command, and a push can start a deploy); set
CONNECTIONS_PERMISSION_BROKER_TOOLS to a comma-separated list to change that, or * for every
call. A connections_execute call with local: true is governed whatever else it names, because it
runs on this machine. Catalog endpoints (endpoint_id or service without local: true) run
remotely under Studio's own policy and are never sent to the broker. The protocol follows Deno's
permission broker.
FAQs
Connections Local MCP - a thin SELF-UPDATING loader that fetches its implementation from AWS at launch (push fixes to AWS, never re-publish). Local-execution companion (shell/git/files/ssh/browser) + browser sign-in for the remote Connections (Studio) MCP
The npm package connections-mcp receives a total of 426 weekly downloads. As such, connections-mcp popularity was classified as not popular.
We found that connections-mcp demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Security News
Cloudflare is acquiring Deno’s team, which will provide another year of bug fixes and security updates before ending support for the runtime.

Security News
Anthropic’s free OSS Scanner uses Mythos and other Claude models to find open source vulnerabilities and send maintainers reports with proposed fixes.

Security News
On Front Lines’ Early Adopters: CISO podcast, Andrew Becherer shares lessons from Datadog, weighs the long-term costs of AI-built tools, and explains how founders can actually gain a CISO’s trust.