docs GitHub

Architecture

ADR 0016: Connectors, the MCP hub and native accounts

Status: proposed, 27 Sep 2026 · Workstream: connectors · Code: core/connectors/, core/mcp/, core/google/, core/cli/commands/connect.js, deck/views/connections.js

The problem

The assistant and the Capsule need the person's calendar and mail, and the person wants to plug in many MCP servers (a tracker, a CRM, a docs tool) with tokens and service accounts they already have. Claude Code can load MCP servers, but each one is a line in a config file, often with its token in the same file or in an env var that every tool call in that session can read. That breaks floor rule 8 (no vault value on any screen, log or model) and rules 1 and 2 (nothing goes out unseen), and it does not scale past a handful of servers per session.

Decision

1. Three modules and one shared library

ModuleFolderWhat it does
mcpcore/mcp/The hub: any number of MCP servers, their credentials, scoping, lifecycle, and one aggregated tool list.
googlecore/google/Native Google Calendar and Gmail, over REST, with OAuth or a domain-wide-delegation service account.
(library)core/connectors/Credential minting and scrubbing shared by both: bearer tokens, OAuth refresh, service-account JWTs, an in-memory token cache. Fakes for tests live in core/connectors/testing/.

Microsoft 365 is a later microsoft module on the same library and the same tool shapes.

mcp and google both require vault and gate. They register as Gate senders at start, so a held item survives a restart of vyred and can still be approved.

2. Credentials: vault items, minted at call time, never on disk

A connection names a vault item and says how to use it. Nothing secret is stored in the module's tables or any config file; the table holds the item's name.

auth.typeVault itemUsed as
nonenonenothing
bearerapi-key or secretauthorization: Bearer <value>, or another header with a format like "token {value}"
envapi-key, secret or env-setstdio only: env vars for the server's own process, { "GITHUB_TOKEN": "gh-token" } or a field of an env-set
oauthenv-set with client_id, client_secret, refresh_token, token_urian access token minted by refresh, cached in memory until a minute before expiry, refreshed once on a 401
service-accountnote or secret whose value is the Google service-account JSONan RS256 JWT with sub set to the impersonated user, exchanged at the key's token_uri; scopes per call

Rules, and why:

  • Values come from ctx.vault.fetch (manifest needs.vault: ["per-connection"]), so each item still needs vyre vault grant <item> mcp (or google). vyre connect add asks for the grant itself, with presence, so a person does it once.
  • Access tokens live only in process memory. A restart mints new ones. The refresh token and the private key are fetched per mint and dropped.
  • For an HTTP server the header is built per request. For a stdio server the env is built at spawn and given to that child only: never to Claude, never written to a file. A stdio server is a program the person chose to run with that token, which is the same trust as today.
  • Every result and error that leaves the hub or the connector is scrubbed of every value it used (the Gate's scrub: raw, base64, base64url and URL-encoded forms).
  • stderr of a stdio server is kept as a bounded ring for mcp.test, scrubbed, never logged raw.

3. The hub

Tools (callers in brackets; "people" means cli, local, deck, capsule and modules, never mcp):

ToolCallersInputReturns
mcp.serversall{}[{name, transport, state, tools, error?, lastUsed, auth: {type, item?}, scope}], never a value
mcp.addpeople{name, transport: "stdio"\|"http"\|"sse", command?, args?, cwd?, env?, url?, headers?, auth?, scope?, tools?, idle?}the server as mcp.servers shows it
mcp.updatepeople{name, ...any field of add}same
mcp.removepeople{name}{removed}
mcp.testpeople{name}{ok, tools, ms, error?, stderr?}: starts it, lists tools, caches them
mcp.restartpeople{name}{state}
mcp.toolsall{}the tools the verified caller may see: [{name: "<server>__<tool>", server, tool, description, input, outward}]
mcp.callall{server, tool, arguments?}the server's result, scrubbed; or {held: id} when it went to the Gate
mcp.releaseinternal, gate only{id, to, content}runs an approved call

Names: a server name is [a-z][a-z0-9-]{0,31}. An aggregated tool is <server>__<tool>, with the server's tool name reduced to [A-Za-z0-9_-], so Claude Code shows it as mcp__vyre__<server>__<tool> and no hub name can collide with a Vyre module tool (those use one underscore).

Scope. Each server has scope: { projects: "*" | [ids], agents: "*" | [names] } (default both "*") and tools: { allow?: [], deny?: [], mode?: { "<tool>": "read" | "write" | "off" } }. mcp.tools and mcp.call use only what vyred verified: the agent from its key, the thread from the session key, and the thread's project from the Switchboard. An agent sees a server when its name is in agents (or "*") and, if projects is a list, one of its projects is in it. A person's session sees a server when projects is "*" or the thread's project is in the list. People's own callers (cli, deck) see everything, for managing it.

Lifecycle. Nothing starts at boot. A server starts on its first call or mcp.test, and its tool list is cached in the store, so listing tools for a new session never spawns anything. A running server stops after idle ms without a call (default 10 minutes), on one setTimeout per running server, never a poll. A crash marks it failed with the last scrubbed stderr line; the next call restarts it, at most three times in five minutes, then it stays failed until mcp.restart. State is stopped | starting | running | failed.

Transports, all in core/mcp/client.js, no dependencies: stdio (newline-delimited JSON-RPC), streamable HTTP (POST with JSON or an SSE reply, Mcp-Session-Id kept), and legacy SSE (GET the stream, POST to the endpoint it names). Redirects are refused for HTTP servers, as the Gate does, so a credential never follows one to another host.

4. What goes through the Gate

Anything a server's tool does to the world outside is held, unless it is plainly a read.

  • A tool is a read when its own name starts with or contains a read verb (list, get, search, read, find, fetch, query, describe, lookup, view, show) and no send, write or delete word, or when its annotations say readOnlyHint: true and its name has no send, write or delete word. Everything else is outward. Unknown means outward.
  • The person can set a tool's mode in tools.mode: read, write (held) or off (hidden). A tool whose name has a send word can be write or off, never read.
  • An outward call becomes gate.request { kind, via: "mcp:<server>", to, content: { tool, arguments } }. kind is delete for delete-like names, spend for pay-like names, send otherwise. to is the first of the argument's to, channel, channel_id, chat_id, recipient, email, address, url, else the server name. The model gets { held: id, message }.
  • On approval the Gate calls mcp.release with exactly the approved content, as module:gate.

The Gate change that makes this possible is small and generic: gate.offer { name, tool, kinds, content } (internal, modules only) registers a sender whose name starts with the calling module's name and whose tool is that module's own internal tool. Sending calls ctx.call(tool, { id, to, content }). Registrations are in memory; modules offer again at start.

The harness Rules ask about any MCP tool with a send word in its name (rule 1), and gate.route denies them to agents. The hub's tools already go through the Gate, so both step aside for mcp__vyre__<server>__<tool>: the hub's classification is stricter than the name rule (unknown is outward), so nothing that the rule would have asked about goes out unheld.

That stepping aside is sound only while one invariant holds: every hub tool with a send word (send, post, reply, forward, publish, share, invite, tweet, dm, comment, in its own name or in the aggregated name the floor sees) is held at the Gate, whatever the person's mode or the server's annotations say, and mcp.add and mcp.update refuse read for one. And Vyre's own MCP server is never a hub server: vyre mcp, harness/mcp/server.js and VYRE_ variables are refused at add, and every stdio child gets VYRE_HUB_CHILD=1, under which the server answers every request with an error and never reaches vyred.

5. One MCP entry for every session

harness/mcp/server.js (the vyre MCP server every Vyre thread already loads) adds mcp.tools to its listing and routes <server>__<tool> calls to mcp.call, with the session key, so scope follows the session. vyre mcp runs the same server on stdio, for plain claude: claude mcp add -s user vyre -- vyre mcp is printed by vyre mcp install, which runs it only when the person asks. Vyre never edits the global Claude Code config on its own.

6. Google, native

Accounts: google.accounts, google.add {name, email, auth: {type: "oauth" | "service-account", item, subject?}}, google.remove, google.test. A DWD service account acts as subject; OAuth acts as whoever consented.

ToolWhatGate
google.calendar.nextthe next events across accountsread
google.calendar.listevents between two timesread
google.calendar.searchevents matching wordsread
google.calendar.create / .updatean eventheld when it has attendees (an invite goes out), direct otherwise
google.mail.searchmessages matching a Gmail queryread
google.mail.readone message or thread, as textread
google.mail.drafta Gmail draftdirect: a draft goes nowhere
google.mail.sendan emailalways held
google.findthe Capsule's results provider: "what's next", "email from dana"read
google.releaseinternal, gate onlyruns an approved send or invite

Scopes are the narrowest per call: calendar.readonly and gmail.readonly for reads, calendar.events for writes, gmail.compose for drafts, gmail.send for sends. A DWD client must be allowed those scopes in the Workspace admin console; google.test names any that are refused.

Sign-in. google.connect {name, client?} (people only; client defaults to google-oauth-client) runs Google's installed-app flow: the person keeps an OAuth client (client_id, client_secret, optionally auth_uri and token_uri, both https) as a vault env-set granted to google, and Vyre returns the consent address, with PKCE S256 and a random state, redirecting to a loopback listener on 127.0.0.1 port 0. The listener exists only while a sign-in is open; each sign-in expires after 10 minutes on its own timer, and its state works once, compared in constant time. A browser on another device cannot reach that loopback, so google.connect.finish {id, url} takes the address it landed on, pasted. Finishing exchanges the code, reads the address from the id_token, and puts the refresh token in a new env-set google-<name> that the module makes and grants to itself (vault.put lets a module do that, for items it made); then the account is added as google.add does, with google.connected {id, name, email}, or google.connect-failed {id, error}. No value reaches a result, an event or a log. With no refresh token (a client allowed before), the error says to remove Vyre's access at myaccount.google.com/permissions and sign in again.

Domain-wide delegation helper. For a service account, google.test also returns the key's client_id, a public number, and admin_scopes, the five scope URLs comma-separated, which are exactly what the Workspace admin console asks for under Security, API controls, Domain-wide delegation. No other field of the key is returned.

Capsule: shows.capsule lists results:google.find and actions on its rows (open, reply as a draft). Deck: Settings, Connections (deck/views/connections.js).

7. CLI

vyre connect add|list|remove|test covers both: vyre connect add mcp <name> -- <command>, vyre connect add mcp <name> --url <url> [--sse], --auth bearer|env|oauth|service-account --item <vault item>, and vyre connect add google <name> --email <user> --item <item> [--dwd]. After adding, it asks for the vault grant (presence) and runs the test. vyre connect add google <name> --sign-in [--client <vault item>] runs decision 6 from a terminal: it grants the client (default google-oauth-client) to google, prints the consent address, and waits on the event stream for the loopback, or for the landed address pasted on stdin. Ctrl-C cancels it, and so does end of input at a terminal (piped or empty stdin only stops the paste reader); it gives up after 10 minutes.

8. Mail: one capability over every account

"Send an email" works from the Capsule, a chat and an agent through any account the person connected, and several at once. A module, mail (core/mail/), is the one contract. It never imports google, mcp or vault: it reaches them through ctx.call, and it serves two kinds of account itself.

A mail account is a vault connection (ADR 0028 decision 9b). The vault keeps the list, the items and the surfaces each connection is granted to; mail keeps nothing about an account except the tool map of one served by an MCP server. Every mail tool's account is the connection's id.

AdapterConnectionWhere it runsHeld at the Gate by
googlesource google (OAuth or DWD)the google module, through google.mail.*google, sender google:<account>
mcpsource mcp, one per hub serverthe hub, through mcp.call and a tool mapthe hub, sender mcp:<server>, always held
apps-scriptprovider google-apps-script: {url, token}mail: HTTPS POST to the person's web appmail, sender mail:<connection>
imapprovider imap-smtp: hosts, ports, username, password, securitymail: IMAP reads, SMTP sends, TLS requiredmail, sender mail:<connection>

Tools (callers as in decision 3):

ToolCallersInputReturns
mail.accountsall{}the accounts this caller may use: [{account, adapter, address, label, provider, source}], never a value
mail.mappeople{account, map?}an MCP account's tool map, guessed the first time; with map, the person's correction
mail.testpeople{account}{ok, can: {send, search, read}, error?}: a harmless check, never a send
mail.sendall{account?, to, subject, body, cc?, bcc?, in_reply_to?, why?}{held, account, via, message}: always held
mail.searchall{q, account?, limit?}{messages: [{account, id, thread_id?, from, to, subject, date, snippet}], errors?}
mail.readall{account, id}one message as text
mail.findcapsule, people{q}Capsule rows (below)
mail.composecapsule, people{id, to?, subject?, body?}a held message, or a message read
mail.releaseinternal, gate only{id, to, content}sends an approved item of a mail:<connection> sender

Rules, and why:

  • The vault decides who may use which account. mail asks vault.connections.list for the caller vyred verified (the Capsule is capsule, a person's session mcp:thread:<id>, an agent mcp:agent:<name>) and acts only on what comes back. An account a surface may not use is invisible to it, and a refusal lists only the accounts it may use.
  • Every send is held. mail.send never sends. A Google account holds through google.mail.send, an MCP account through mcp.call with hold: true (a new input heard from module callers only, which forces the Gate whatever the tool's mode or name says, so a mapped tool called compose or marked read still waits), and the two native adapters through gate.request as mail:<connection>. Approving needs presence (Touch ID), as every outbound item does; nothing else here asks for it (the no-nag rule). A connection without the send_mail capability never sends.
  • The thread and the agent follow the call. mail passes what vyred verified as on_behalf: {thread, agent} to google.mail.send and mcp.call. They hear it only from one of Vyre's own modules: the registry sets meta.firstParty by the loader's own first-party rule, over anything passed in, and anyone else who passes on_behalf is refused. The thread must exist in the Switchboard, and when it is an agent's, the agent named must be that one; a mismatch is refused, never filed (core/connectors/behalf.js). In the hub, a call on behalf of a thread or an agent is scoped as that thread or agent, not as the person. A module calling mail may say whom it acts for the same way; a home module is refused it, and sees no mail account at all.
  • Which account. mail.send with no account uses the only account the caller may use; with several it answers ambiguous and lists them, never a guess. Reads fan out over every account the caller may use, newest first; one failing account is an entry in errors, not a failed search.
  • One query language. q takes words, from:, to:, subject:, newer_than:<n>d and is:unread. Google and Apps Script pass it to Gmail as is; IMAP translates it to SEARCH and never marks a message read; an MCP account passes it to the mapped argument.
  • Needs a credential. A missing or ungranted item answers {code: "needs_credential", detail: {module: "mail", need, account: <connection>}}, where need is imap or apps-script from needs.credentials in the manifest (both multiple: true), so surfaces call vault.connect {module: "mail", need, label}. Never a value.
  • Native adapters. IMAP and SMTP need TLS (implicit or STARTTLS), except to a loopback host, which exists for the test fakes. SMTP never carries Bcc in the headers. Apps Script is a POST with the token in the body, and one redirect is followed only to https://script.googleusercontent.com, as a GET without the token, which is how Apps Script returns its answer. The script to paste is core/mail/apps-script.gs. No dependencies. Every result and error is scrubbed of every value used.
  • MCP tool map. The first use reads the server's cached tools and guesses the send, search and read tools and their arguments (to or recipients, subject, body or text), and stores that map; mail.map shows and corrects it. Several instances of one server (gmail-home, gmail-work) are several hub servers, each with its own items, and so several connections.
  • Restarts. A held mail:<connection> item survives a restart of vyred: at start mail offers the sender of every such item still waiting.

The Capsule. shows.capsule lists results:mail.find and action:mail.compose. "send an email", "email [email protected] about the order" or "write to dana" gives one row per account the Capsule may send from ("Send from [email protected]"), with what the words named filled in; a name is resolved from the person's own mail. "email from dana" gives messages across accounts. mail.compose on a send row holds the message at once, so the person finishes it in the Gate card and approves it there; the Capsule's deeper path (an agent) writes the body and calls mail.send with the row's account.

What the vault serves for this (ADR 0028 decision 9): vault.connections.list {caller} from a module caller, answering for that caller's surface; vault.connections.get {id} for mail.release, with no surface filter, since the person already approved; and use entries that point send_mail and read_mail of every source at mail.send and mail.search.

Consequences

  • Many servers cost nothing until used: one row each, and one child process only while in use.
  • A model never sees a token, a refresh token, a private key or a server's env.
  • A server's tool that the hub cannot classify is held. That is the safe mistake; the person can mark it read once.
  • The Gate learns one generic sender kind instead of one type per integration.
  • The first refresh token comes from "Sign in with Google" (decision 6); the person still brings their own OAuth client, since Vyre ships no client secret.

Rejected

  • Writing each server into Claude Code's .mcp.json. Tokens in files and env, no scoping, no Gate, and every session starts every server.
  • Starting every server at boot. Breaks principle 8 for people with twenty servers.
  • Passing a token from one module to another (a shared connect.token tool). Each module fetches its own items under its own grants, through the shared library.