v0.42.2.0 feat: gbrain connect — one-command Claude Code onboarding from a bearer token (#1683)

* fix: gbrain auth create dropped the name on the bare (no-flag) form

Extract parseAuthCreateArgs; only exclude the --takes-holders value from the
positional search when the flag is present (rest[takesIdx+1] resolved to rest[0]
when takesIdx === -1, silently dropping the name). Add regression test.

* feat: gbrain connect — one-command Claude Code onboarding from a bearer token

New connect command prints a paste-ready claude-mcp-add block (or --install wires
it + smoke-tests the token via a raw-bearer get_brain_identity probe). Direct HTTP
MCP, literal-token default, URL normalization, token header-injection guard,
--json redaction, execFileSync (no shell). Wired into CLI_ONLY + CLI_ONLY_SELF_HELP
+ handleCliOnly. 58 unit + 3 PGLite-E2E cases; e2e-test-map updated.

* docs: lead CLAUDE_CODE.md with gbrain connect (remote fast path) + README one-liner

Regenerate llms-full.txt for the README change.

* refactor: pre-landing review fixes for gbrain connect

- DRY: single DEFAULT_PROBE_TIMEOUT_MS + shared isAuthErrorMessage predicate
- reuse promptLine (shared stdin lifecycle) for the --install confirm
- harden redactToken with a Bearer <value> scrub (defense in depth)
- +8 tests: orchestrator guard paths, deterministic timeout, invalid --timeout-ms,
  Bearer-redaction

* fix: adversarial-review hardening for gbrain connect

- probe: Promise.race the call against a real timer so a stalled connect()/SSE
  handshake (signal alone doesn't cover it) can't hang --install indefinitely
- probe: close transport even if client.connect() throws
- parseArgs: reject a missing/flag-shaped value (e.g. --token --install)
- block link-local / cloud-metadata hosts (169.254/fe80:/fd00:ec2::254) — keeps
  localhost + RFC1918 LAN brains working
- non-interactive --install now requires --yes
- clearer message when --force removed then add failed
+8 tests covering each

* fix: codex-review P2s for gbrain connect

- POSIX single-quote the rendered claude-mcp-add command so a token with shell
  metacharacters ($(), backticks) can't trigger command substitution on paste
- detect IPv4-mapped IPv6 metadata addresses (::ffff:169.254.x.x / ::ffff:a9fe:*)
  so they don't bypass the link-local guard
+3 tests

* chore: bump version and changelog (v0.42.2.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: document gbrain connect + connect-probe in CLAUDE.md Key files (v0.42.2.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: gbrain connect — add codex and perplexity agents

--agent codex emits 'codex mcp add ... --bearer-token-env-var GBRAIN_REMOTE_TOKEN'
(token read from the env var at runtime, never in Codex config; --install runs it).
--agent perplexity prints the URL + token for the Settings → Connectors GUI (no
--install). Generalized the command file: AGENT_SPECS table, buildCodexMcpAddArgv,
cmdString(binary,argv), binary-generic ConnectDeps (hasBinary/runBinary/env),
agent-aware buildConnectBlock/buildJson. +25 tests.

* docs: codex + perplexity connect paths (new CODEX.md, README, CHANGELOG, CLAUDE.md)

Regenerate llms-full.txt for the CLAUDE.md/README edits.

* test: real-CLI E2E for connect — drive actual claude + codex against a live server

Adds claude-code + codex cases to connect-bearer.test.ts that run the real
'claude mcp add' / 'codex mcp add' through 'gbrain connect --install' against a
live 'gbrain serve --http' (sandboxed HOME/CODEX_HOME), then assert via
'claude mcp get' / 'codex mcp get' that the server registered (and codex's token
stays out of config). Skips when the binary is absent. Perplexity is GUI-only so
it's print-asserted. Regen llms for the CLAUDE.md note.

* docs: perplexity OAuth + serve --bind/--public-url footgun (per Perplexity feedback)

PERPLEXITY.md now documents the host-side HTTP setup (gbrain serve --http
--bind 0.0.0.0 --public-url, the v0.34 ECONNREFUSED footgun) and the OAuth 2.1
client_credentials path (gbrain auth register-client) alongside the legacy
bearer token. The 'connect --agent perplexity' output points at the same
bind/public-url requirement + PERPLEXITY.md.

* feat: gbrain connect --oauth — client-credentials path for perplexity/generic

OAuth is the correct path for a third-party cloud connector (Perplexity): instead
of a long-lived full-access bearer token, the connector gets Issuer URL + Client
ID + Client Secret and mints short-lived scoped tokens. --oauth --register mints a
least-privilege client on the host (shells gbrain auth register-client); --oauth
--client-id/--client-secret uses an existing one. Rejected for claude-code/codex
(bearer) and with --install. Issuer derived from the mcp-url. New E2E proves the
full chain: register → connect --oauth → OAuth discovery → /token client_credentials
mint → get_brain_identity tool call against a live server. Docs: PERPLEXITY.md leads
with OAuth; README + CLAUDE.md updated; +18 unit cases.

* docs: add gbrain connect to INSTALL.md MCP section + link CODEX.md

The remote-client onboarding command was documented in README/CLAUDE_CODE/CODEX/
PERPLEXITY but missing from INSTALL.md §3 (the natural 'how do I connect a client'
home). Add the one-command connect how-to (claude-code/codex/perplexity) and the
missing docs/mcp/CODEX.md link.

* fix: connect LEARN_INSTRUCTION names put_page, not CLI-only capture

The self-orientation block told a connected agent that `capture` is an
available MCP tool. It isn't — `capture` is a CLI-only convenience command;
the MCP write tool is `put_page`. An agent that followed the instruction hit
"unknown tool". Drop capture; put_page was already in the list. Adds a
regression block to connect.test.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: serve --http surfaces skill-publishing status (banner + nudge)

When mcp.publish_skills is OFF, connected agents can search/write but can't
call list_skills/get_skill, so the host's skill catalog is invisible to them.
The startup banner now shows a Skills: line, and a stderr nudge fires when off
with the paste-ready fix. Pure skillPublishStatus() helper, unit-tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: prove the local stdio MCP funnel end-to-end

Spawns real `gbrain serve` (stdio) against a freshly init --pglite brain and
drives the official MCP SDK client through initialize -> tools/list ->
tools/call (get_brain_identity + search). Pins the advertised core-tool set
against what the server actually exposes (asserts capture is NOT advertised).
This funnel had zero e2e coverage before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: make batch-retry-audit ENOENT case hermetic

The 'no-op when audit dir does not exist' case called
pruneOldBatchRetryAuditFiles(30) without a GBRAIN_AUDIT_DIR override, so it
read the real ~/.gbrain/audit and flaked (kept:1) on any dev machine with a
batch-retry-*.jsonl on disk. Point it at a guaranteed-missing temp subdir,
matching this file's own hermetic-header contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: two-funnel coding-agent onboarding (Claude Code / Codex)

New tutorial docs/tutorials/connect-coding-agent.md: Path A (connect to an
existing brain) + Path B (start from nothing, local stdio), the brain-first
protocol to paste into CLAUDE.md/AGENTS.md, and the four translatable habits.
README gains a 'Quick start: Claude Code or Codex' fork separating lightweight
retrieval from the full autonomous install. INSTALL.md shows the one-command
wire-up at the standalone CLI section. mcp/CLAUDE_CODE + CODEX cross-link the
tutorial + note publish_skills + capture-is-CLI-only. Tutorial promoted to
Shipped in the tutorials index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: changelog + regenerated llms (v0.42.2.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: CLAUDE.md Key Files annotation for two-funnel onboarding wave

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-06-01 19:09:15 -07:00
committed by GitHub
parent eefe8b5741
commit 7b0d99adb0
25 changed files with 2966 additions and 47 deletions

View File

@@ -2,6 +2,156 @@
All notable changes to GBrain will be documented in this file.
## [0.42.2.0] - 2026-05-30
**One command now wires Claude Code, Codex, or Perplexity Computer to a remote
gbrain when all you have is a bearer token. `gbrain connect <url> --token <tok>`
prints a paste-ready block, or `--install` runs it for you and checks the token
actually works before you walk away.**
If your brain runs somewhere as an HTTP server (`gbrain serve --http`) and you
have a token, connecting an agent used to mean remembering the exact
`claude mcp add ... -H "Authorization: Bearer ..."` incantation, getting the
`/mcp` path right, and hoping the token was valid. Now you run one command.
It normalizes the URL (adds `/mcp`, rejects a bare host so you don't silently
point at the wrong thing), and the block it prints tells the agent to call
`get_brain_identity` and `list_skills` so it immediately knows whose brain this
is and everything it can do. No local brain, no proxy, no OAuth dance: the agent
talks straight to your remote over HTTP.
Pick your agent with `--agent`:
- **claude-code** (default): `claude mcp add ... -H "Authorization: Bearer ..."`.
`--install` runs it.
- **codex**: `codex mcp add <name> --url <url> --bearer-token-env-var
GBRAIN_REMOTE_TOKEN`. Codex reads the token from the env var at runtime, so the
secret never lands in Codex's config file. `--install` runs it.
- **perplexity**: prints the exact connector fields to paste into Perplexity's
Settings → Connectors (it's a GUI connector, so no `--install`). Defaults to a
bearer token, but since Perplexity is a cloud service the recommended path is
**OAuth**: `--agent perplexity --oauth --register` mints a least-privilege
client and prints the Issuer URL + Client ID + Client Secret. OAuth means the
connector mints short-lived, scoped access tokens instead of holding a
long-lived full-access secret.
- **generic**: prints the URL + `Authorization` header (or OAuth fields with
`--oauth`) for any other MCP client.
How to use it (run anywhere gbrain is installed):
```
gbrain auth create "claude-code" # mint a token on the host
gbrain connect https://your-host/mcp --token gbrain_xxx # print the paste block
gbrain connect https://your-host --token gbrain_xxx --install # or wire it + verify the token
```
`--install` runs `claude mcp add` for you, then makes one real call to your brain
so a wrong or expired token fails right then instead of silently 401-ing on the
agent's first question. `--json` gives you a machine-readable version with the
token redacted (pass `--show-token` if you really want it inlined).
A note on the token: a `gbrain auth create` token is long-lived and full-access.
The printed block single-quotes it so pasting it can't accidentally run shell
code, the command refuses to send it to a link-local or cloud-metadata address,
and error output never echoes it. Keep it private, and prefer a scoped token if
your host supports one.
**Two ways to give a coding agent a memory, written down end to end.** Connecting
to a remote brain is one funnel. The other is starting from nothing: `gbrain init
--pglite` gives you a local brain in 2 seconds, and `claude mcp add gbrain --
gbrain serve` (or `codex mcp add gbrain -- gbrain serve`) wires it straight into
your agent with no server, no token, no tunnel. The new tutorial,
[Give your coding agent a memory](docs/tutorials/connect-coding-agent.md), walks
both funnels with copy-paste commands, then hands you the brain-first protocol to
paste into `CLAUDE.md` / `AGENTS.md` and the four habits that make it worth it
(brain-first lookup, ambient capture, briefing from your brain, whoknows). The
README now has a "Quick start: Claude Code or Codex" fork that separates the
lightweight retrieval path from the full autonomous install, and `INSTALL.md`
shows the one-command wire-up right where the standalone CLI section ends.
**`gbrain serve --http` now tells you when your skills are invisible.** If
`mcp.publish_skills` is OFF, a connected agent can search and write but can't call
`list_skills` / `get_skill` — so your skill catalog (the thing that makes an
OpenClaw setup special) silently doesn't show up. The startup banner now prints a
`Skills: published / not published` line, and when it's off you get a one-line
nudge with the exact fix: `gbrain config set mcp.publish_skills true`. New brains
from `gbrain init` default it ON; brains upgraded from before stay OFF until you
opt in, which is the common gotcha.
**Fixed: `connect` told agents to call a tool that doesn't exist over MCP.** The
self-orientation block named `capture` as a core tool, but `capture` is a CLI-only
convenience command, not an MCP tool — an agent that followed the instruction got
"unknown tool." The block now names `put_page`, the real MCP write tool. A new
end-to-end test spawns `gbrain serve` over stdio and drives the official MCP SDK
client through `initialize``tools/list``tools/call`, so the advertised tool
set is now pinned against what the server actually exposes (the local stdio funnel
had zero coverage before this).
## To take advantage of v0.42.2.0
`gbrain upgrade` is all you need. `gbrain connect` is available immediately after
upgrade. To wire up a coding agent:
1. On the brain host (or anywhere gbrain is installed), mint a token:
```bash
gbrain auth create "claude-code"
```
2. Generate the onboarding block (or wire it directly):
```bash
gbrain connect https://your-host/mcp --token <the-token>
# or, on the machine you want to connect:
gbrain connect https://your-host/mcp --token <the-token> --install
```
3. Paste the printed block into Claude Code. It connects the MCP server and
tells the agent to call `get_brain_identity` + `list_skills`.
4. Verify: in Claude Code, ask it to `search` for something in your brain.
If anything looks wrong, `gbrain connect --help` lists every flag, and
`docs/mcp/CLAUDE_CODE.md` covers the local-stdio path too.
### Itemized changes
#### Added
- **`gbrain connect <mcp-url>`** generates (or, with `--install`, runs) the MCP
wiring for a remote gbrain from a bearer token. Flags: `--token`, `--name`,
`--agent claude-code|codex|perplexity|generic`, `--install`, `--yes`, `--force`,
`--json`, `--show-token`, `--timeout-ms`. Reads the token from `--token` or
`$GBRAIN_REMOTE_TOKEN`; in print mode the token is optional (it emits a
`<paste-your-token>` placeholder).
- **Per-agent setup**: `--agent codex` emits `codex mcp add ... --bearer-token-env-var
GBRAIN_REMOTE_TOKEN` (token read from the env var at runtime, never written to
Codex config; `--install` runs it). `--agent perplexity` prints the URL + token
for Perplexity's Settings → Connectors GUI (no `--install`). `--agent generic`
prints the URL + `Authorization` header for any other MCP client. Docs:
`docs/mcp/CLAUDE_CODE.md` (leads with `gbrain connect`, keeps the local stdio
path), new `docs/mcp/CODEX.md`, updated `docs/mcp/PERPLEXITY.md`, and the README.
- **`--install` smoke-tests the token.** After registering the server it makes a
real `get_brain_identity` call over the bearer connection and warns loudly on
a 401, unreachable host, or timeout, so a bad token fails at setup instead of
on the agent's first request. Supported for claude-code and codex (Perplexity
is GUI-only).
- **OAuth client-credentials path (`--oauth`, perplexity + generic).** The
correct path when the credential lives on a third-party cloud: instead of a
long-lived full-access bearer token, the connector gets an Issuer URL + Client
ID + Client Secret and mints its own short-lived, scoped access tokens.
`--oauth --register` mints a least-privilege client on the host in one command;
`--oauth --client-id X --client-secret Y` uses an existing one (runs anywhere).
The full chain (register → OAuth discovery → `/token` → tool call) is proven by
a new end-to-end test against a live server.
#### Fixed
- **`gbrain auth create <name>` no longer drops the name.** On the bare form
(no `--takes-holders` flag) the name was silently discarded and the command
printed usage instead of minting a token. It now creates the token as
documented.
#### Security
- The connection command single-quotes the rendered `claude mcp add` so a token
containing shell metacharacters can't run code when the block is pasted;
validates the token to keep it out of HTTP headers; refuses to send the token
to link-local / cloud-metadata addresses (including IPv4-mapped IPv6 forms);
redacts the token from all error output and from `--json` unless `--show-token`;
and requires `--yes` for `--install` in a non-interactive shell.
## [0.42.1.0] - 2026-05-29
**Skill self-improvement no longer starts from a blank file.**

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
# GBrain
**Search gives you raw pages. GBrain gives you the answer.** It's the brain layer your AI agent has been missing — the only one that does synthesis, graph traversal, and gap analysis in one box.
**Search gives you raw pages. GBrain gives you the answer.** It's the brain layer your AI agent has been missing — the only one that does synthesis, graph traversal, and gap analysis in one box. Run a full autonomous agent on top of it, or just wire it into Claude Code or Codex as a supercharged retrieval layer in one command; either way your coding agent stops being amnesiac about everything that isn't code.
I'm Garry Tan, President and CEO of Y Combinator. I built GBrain to run my own AI agents. It's the production brain behind my OpenClaw and Hermes deployments: **146,646 pages, 24,585 people, 5,339 companies**, 66 cron jobs running autonomously. My agent ingests meetings, emails, tweets, voice calls, and original ideas while I sleep. It enriches every person and company it encounters. It fixes its own citations and consolidates memory overnight. I wake up smarter than when I went to bed — and so will you.
@@ -85,9 +85,29 @@ The agent installs GBrain, creates the brain, asks for your API keys, loads 43 s
> **Never set up an AI agent platform before?** The [personal-brain tutorial](docs/tutorials/personal-brain.md) walks the whole path end-to-end — picking OpenClaw vs Hermes, deploying it, pointing it at INSTALL_FOR_AGENTS.md, getting the API keys, and verifying the first query. Start there if any of the above is new.
### Install it into your existing agent
### Quick start: Claude Code or Codex
Already running Codex, Claude Code, Cursor, or another coding agent? Paste the same instruction in:
Already running Claude Code or Codex? There are two ways to wire GBrain in, depending on what you want.
**Just want a memory for your coding agent (recommended starting point).** Spin up a local brain and connect it in two commands — zero server, zero token, zero tunnel:
```bash
gbrain init --pglite # 2-second local brain (no Docker)
claude mcp add gbrain -- gbrain serve # or: codex mcp add gbrain -- gbrain serve
```
**Already have a brain on a remote host** (OpenClaw, Hermes, or any `gbrain serve --http`)? Point your laptop agents at it with one command each — `--install` wires it up and smoke-tests the token before handoff:
```bash
gbrain connect https://your-host/mcp --token gbrain_xxx --install # Claude Code
gbrain connect https://your-host/mcp --token gbrain_xxx --agent codex --install # Codex
```
**[→ Full walkthrough: give your coding agent a memory](docs/tutorials/connect-coding-agent.md)** — both paths end to end, plus the brain-first protocol you paste into `CLAUDE.md` / `AGENTS.md` and the four habits that make it actually change how you work.
### Install the full autonomous setup into your existing agent
Want the whole thing — local brain, 43 skills, the overnight dream cycle that enriches while you sleep? Paste this into Codex, Claude Code, Cursor, or another coding agent:
```
Retrieve and follow the instructions at:
@@ -112,11 +132,12 @@ Postgres-at-scale, Supabase, and thin-client setup paths live in [`docs/INSTALL.
GBrain exposes 30+ tools over MCP (stdio and HTTP). The specific snippet depends on which client you use:
- **[Claude Code](docs/mcp/CLAUDE_CODE.md)** — one command: `claude mcp add gbrain -- gbrain serve`. Zero server, zero tunnel.
- **[Claude Code](docs/mcp/CLAUDE_CODE.md)** — local: one command, `claude mcp add gbrain -- gbrain serve` (zero server, zero tunnel). Remote with just a bearer token: `gbrain connect https://your-host/mcp --token gbrain_xxx` prints a paste-ready block (or `--install` wires it up and smoke-tests the token).
- **[Codex](docs/mcp/CODEX.md)** — `gbrain connect https://your-host/mcp --token gbrain_xxx --agent codex` (or `--install`). Codex reads the bearer from `$GBRAIN_REMOTE_TOKEN` at runtime, so the token never lands in Codex config.
- **[Cursor / Windsurf / any stdio MCP client](docs/mcp/CLAUDE_CODE.md)** — same shape, add `{"command": "gbrain", "args": ["serve"]}` to your MCP config.
- **[Claude Desktop (Cowork)](docs/mcp/CLAUDE_DESKTOP.md)** — Settings → Integrations → add the URL of your HTTP server. Remote only; the local `claude_desktop_config.json` does not work for remote servers.
- **[Claude Cowork (team plan)](docs/mcp/CLAUDE_COWORK.md)** — org Owner adds the connector under Organization Settings → Connectors.
- **[Perplexity Computer](docs/mcp/PERPLEXITY.md)** — Settings → Connectors → add the URL + bearer token. Pro subscription required.
- **[Perplexity Computer](docs/mcp/PERPLEXITY.md)** — `gbrain connect https://your-host/mcp --agent perplexity --oauth --register` mints a least-privilege OAuth client and prints the Issuer/Client ID/Secret to paste into Settings → Connectors (OAuth is the right path for a cloud connector; a bearer token also works for local use). Pro subscription required.
- **[ChatGPT](docs/mcp/CHATGPT.md)** — uses OAuth 2.1 with PKCE (the hard requirement). Register a `chatgpt` client from the admin dashboard with grant type `authorization_code`.
For the HTTP server itself:

View File

@@ -1,5 +1,27 @@
# TODOS
## v0.42.2.0 gbrain connect follow-ups (v0.42+)
- [ ] **T6 (P3): `gbrain connect --env-token` form.** Ship the env-var-indirection
token form (`-H 'Authorization: Bearer ${GBRAIN_REMOTE_TOKEN}'`, single-quoted so
the shell doesn't pre-expand) ONLY after verifying that Claude Code actually expands
`${VAR}` inside a stored `-H` header at runtime. v0.42.2.0 deliberately ships the
literal-token default (matches the shipped docs, verified to work) because the
env-default was unverified — the shell expands `${...}` before `claude mcp add`
stores it, so it would have stored the literal token anyway. Verify CC behavior
first, then add the opt-in flag. Files: `src/commands/connect.ts` (token-form),
`docs/mcp/CLAUDE_CODE.md`.
- [ ] **T7 (P3): Tier 2 — local thin-client over a bearer token.** `gbrain connect`
today only wires the MCP *connection* (Claude Code talks straight to the remote /mcp).
The local `gbrain` CLI (`gbrain search`, `gbrain remote ping/doctor`, routed ops) still
requires OAuth client-credentials — `remote_mcp` + `callRemoteTool`/`getAccessToken`
in `src/core/mcp-client.ts` are OAuth-only. To let the local CLI work against the
remote with just a bearer token, widen `remote_mcp` with a bearer path (`auth: 'bearer'`,
`bearer_token`), short-circuit `getAccessToken` when `auth === 'bearer'` (skip discovery +
/token mint), and teach `initRemoteMcp` (`src/commands/init.ts`) to write a bearer-shaped
config. Then `gbrain connect --install` can also `bun install -g` gbrain + write the config.
Deferred per D1 (Tier 1 only this release).
## v0.41.38.0 dream-postgres / source-pin follow-ups (v0.42+)
Deferred from the v0.41.38.0 wave (code-callers/callees pin + dream-on-postgres).

View File

@@ -1 +1 @@
0.42.1.0
0.42.2.0

View File

@@ -54,6 +54,15 @@ gbrain sync --watch # live-sync a git repo (autopilot mode)
gbrain autopilot --install # background daemon for nightly enrichment
```
**Wire this same local brain into your coding agent** — zero server, zero token:
```bash
claude mcp add gbrain -- gbrain serve # Claude Code
codex mcp add gbrain -- gbrain serve # Codex
```
The agent spawns `gbrain serve` as a stdio subprocess against your local brain. Full walkthrough (both this local path and connecting to a remote brain), plus the brain-first protocol to paste into `CLAUDE.md` / `AGENTS.md`: **[Give your coding agent a memory](tutorials/connect-coding-agent.md)**.
## 3. MCP server (any MCP client)
```bash
@@ -61,9 +70,21 @@ gbrain serve # stdio MCP (Claude Desktop / Code / Cursor)
gbrain serve --http # HTTP MCP with OAuth 2.1 + admin dashboard
```
**Wire a coding agent to a remote brain in one command** (when you have an HTTP
server + a bearer token): `gbrain connect` prints a paste-ready setup block, or
`--install` runs it and smoke-tests the token.
```bash
gbrain auth create "claude-code"
gbrain connect https://your-host/mcp --token gbrain_xxx # Claude Code (default)
gbrain connect https://your-host/mcp --token gbrain_xxx --agent codex # Codex (env-var bearer)
gbrain connect https://your-host/mcp --agent perplexity --oauth --register # Perplexity (OAuth)
```
Per-client setup guides live in [`docs/mcp/`](mcp/):
- [`docs/mcp/CLAUDE_CODE.md`](mcp/CLAUDE_CODE.md)
- [`docs/mcp/CODEX.md`](mcp/CODEX.md)
- [`docs/mcp/CLAUDE_DESKTOP.md`](mcp/CLAUDE_DESKTOP.md)
- [`docs/mcp/CHATGPT.md`](mcp/CHATGPT.md)
- [`docs/mcp/PERPLEXITY.md`](mcp/PERPLEXITY.md)

View File

@@ -1,5 +1,10 @@
# Connect GBrain to Claude Code
> New to this? The [Give your coding agent a memory](../tutorials/connect-coding-agent.md)
> tutorial walks both paths (local-from-nothing and connect-to-an-existing-brain)
> end to end, plus the brain-first protocol that makes it worth it. This page is
> the connection reference.
## Option 1: Local (recommended, zero server needed)
```bash
@@ -9,10 +14,44 @@ claude mcp add gbrain -- gbrain serve
That's it. Claude Code spawns `gbrain serve` as a stdio subprocess. No server, no
tunnel, no token needed. Works with both PGLite and Supabase engines.
## Option 2: Remote (access from any machine)
## Option 2: Remote, one command (fastest from a bearer token)
If you have GBrain running on a server with a public tunnel (see
[ngrok-tunnel recipe](../../recipes/ngrok-tunnel.md)):
If GBrain is running somewhere as an HTTP server (`gbrain serve --http`, see the
[ngrok-tunnel recipe](../../recipes/ngrok-tunnel.md)) and you have a bearer token,
let `gbrain connect` generate the wire-up for you.
On the host (or anywhere `gbrain` is installed), mint a token and print the block:
```bash
gbrain auth create "claude-code"
gbrain connect https://YOUR-DOMAIN.ngrok.app/mcp --token gbrain_xxx
```
`gbrain connect` prints a short, copy-paste block. Paste it into Claude Code — it
runs the `claude mcp add` for you and tells the agent to call `get_brain_identity`
and `list_skills` so it immediately knows what the brain can do.
Already on the machine you want to wire up? Skip the copy-paste and let `connect`
do it directly, with a built-in token smoke-test:
```bash
gbrain connect https://YOUR-DOMAIN.ngrok.app --token gbrain_xxx --install
```
(`--install` runs `claude mcp add`, then verifies the token by calling
`get_brain_identity` — so a wrong or expired token fails now, not silently on the
agent's first request. The URL is normalized: a bare host without `/mcp` gets it
appended; pass an explicit `https://` scheme.)
Pipe-friendly machine output (token redacted unless `--show-token`):
```bash
gbrain connect https://YOUR-DOMAIN.ngrok.app/mcp --token gbrain_xxx --json
```
## Option 3: Remote, manual `claude mcp add`
Equivalent to what `gbrain connect` generates, if you'd rather run it yourself:
```bash
claude mcp add gbrain -t http \
@@ -20,8 +59,12 @@ claude mcp add gbrain -t http \
-H "Authorization: Bearer YOUR_TOKEN"
```
Replace `YOUR-DOMAIN` with your ngrok domain and `YOUR_TOKEN` with a token
from `gbrain auth create "claude-code"`.
Replace `YOUR-DOMAIN` with your ngrok domain and `YOUR_TOKEN` with a token from
`gbrain auth create "claude-code"`.
> A `gbrain auth create` token is a long-lived, full-access secret. Keep it
> private (it lands in `~/.claude.json`), and prefer a scoped/short-lived token
> where your host supports one.
## Verify
@@ -33,6 +76,13 @@ search for [any topic in your brain]
You should see results from your GBrain knowledge base.
> **`list_skills` returns nothing?** Skill discovery is gated by `mcp.publish_skills`
> on the host. New brains from `gbrain init` default it ON; brains upgraded from an
> older release stay OFF until you opt in. Enable it on the host with
> `gbrain config set mcp.publish_skills true`. The core tools (search, query,
> get_page, put_page, think, find_experts) work regardless. Note: `capture` is a
> CLI-only command, not an MCP tool — the agent writes over MCP with `put_page`.
## Remove
```bash

71
docs/mcp/CODEX.md Normal file
View File

@@ -0,0 +1,71 @@
# Connect GBrain to Codex
> New to this? The [Give your coding agent a memory](../tutorials/connect-coding-agent.md)
> tutorial walks both paths (local-from-nothing and connect-to-an-existing-brain)
> end to end, plus the brain-first protocol that makes it worth it. This page is
> the connection reference.
Codex CLI (`@openai/codex`, v0.130+) supports remote streamable-HTTP MCP servers
with a bearer token read from an environment variable. The token lives in your
shell env, not in Codex's config file.
## Fastest path: `gbrain connect`
Run anywhere `gbrain` is installed (mint a token on the brain host first):
```bash
gbrain auth create "codex"
gbrain connect https://YOUR-DOMAIN.ngrok.app/mcp --token gbrain_xxx --agent codex
```
This prints a copy-paste block. Or wire it up directly and smoke-test the token:
```bash
gbrain connect https://YOUR-DOMAIN.ngrok.app/mcp --token gbrain_xxx --agent codex --install
```
`--install` runs `codex mcp add` for you, then makes one real call to the brain so
a wrong/expired token fails right away. Because Codex reads the token from the env
var at runtime, keep `GBRAIN_REMOTE_TOKEN` exported in your shell profile.
## Manual setup
```bash
export GBRAIN_REMOTE_TOKEN=gbrain_xxx
codex mcp add gbrain --url https://YOUR-DOMAIN.ngrok.app/mcp \
--bearer-token-env-var GBRAIN_REMOTE_TOKEN
```
Codex stores the env-var *name* (`GBRAIN_REMOTE_TOKEN`), not the token itself, and
reads the value when it launches the MCP server. Add the `export` line to your
`~/.zshrc` / `~/.bashrc` so it's set in every session.
## Verify
In Codex, ask it to use the brain:
```
Call get_brain_identity, then search my brain for [topic].
```
`get_brain_identity` confirms whose brain you're connected to; `list_skills` shows
everything it can do.
> **`list_skills` empty?** It's gated by `mcp.publish_skills` on the host (default
> ON for `gbrain init` brains, OFF for brains upgraded from older releases). Enable
> it on the host: `gbrain config set mcp.publish_skills true`. The core tools
> (search, query, get_page, put_page, think, find_experts) work regardless.
> `capture` is CLI-only, not an MCP tool — write over MCP with `put_page`.
## Remove
```bash
codex mcp remove gbrain
```
## Notes
- The token is a long-lived, full-access secret. Keep `GBRAIN_REMOTE_TOKEN` out of
version control and prefer a scoped token if your host supports one.
- Local stdio also works if you run the brain on the same machine:
`codex mcp add gbrain -- gbrain serve`.

View File

@@ -1,20 +1,83 @@
# Connect GBrain to Perplexity Computer
Perplexity Computer supports remote MCP servers with bearer token authentication.
Perplexity Computer connects as a **remote** MCP client, so GBrain must be served
over HTTP and reachable at a public HTTPS URL. Perplexity does not run
`gbrain serve` (stdio) the way Claude Code does — it needs a reachable endpoint:
## Setup
```
Perplexity Computer
→ ngrok tunnel (https://YOUR-DOMAIN.ngrok.app/mcp)
→ gbrain serve --http (built-in OAuth 2.1 transport)
→ Postgres / PGLite
```
1. Open Perplexity (requires Pro subscription)
2. Go to **Settings > Connectors** (or **MCP Servers**)
## 1. Serve GBrain over HTTP (host side)
```bash
gbrain serve --http --port 3131 --bind 0.0.0.0 \
--public-url https://YOUR-DOMAIN.ngrok.app
```
- **`--bind 0.0.0.0` is required.** Since v0.34, `--http` defaults to
`127.0.0.1`, so without it the tunnel reaches the server but the connection is
refused (`ECONNREFUSED`).
- **`--public-url` must match the tunnel.** The OAuth issuer in the discovery
metadata has to line up with the URL Perplexity actually hits (RFC 8414 §3.3),
or OAuth client-credentials auth fails.
## 2. Expose it with a tunnel
```bash
ngrok http 3131 --url YOUR-DOMAIN.ngrok.app
```
See the [ngrok-tunnel recipe](../../recipes/ngrok-tunnel.md) for a persistent
tunnel.
## 3. Create credentials
Two supported auth paths.
**OAuth 2.1 client credentials (recommended, v0.26.0+).** Perplexity is a cloud
service, so it holds whatever credential you give it. OAuth is the correct choice:
least-privilege scopes + short-lived rotating access tokens instead of a
long-lived full-access secret. Mint a client and print the connector fields in
one step (on the brain host):
```bash
gbrain connect https://YOUR-DOMAIN.ngrok.app/mcp --agent perplexity --oauth --register
```
Or register separately and pass the creds (works anywhere, no DB needed):
```bash
gbrain auth register-client perplexity --grant-types client_credentials --scopes "read write"
gbrain connect https://YOUR-DOMAIN.ngrok.app/mcp --agent perplexity --oauth \
--client-id gbrain_cl_xxx --client-secret gbrain_cs_xxx
```
`connect --oauth` prints the **Issuer URL + Client ID + Client Secret** to paste
in step 4.
**Legacy bearer token (simplest, best for local/personal):**
```bash
gbrain auth create "perplexity"
gbrain connect https://YOUR-DOMAIN.ngrok.app/mcp --token gbrain_xxx --agent perplexity
```
(Perplexity is a GUI connector, so there's no `--install``connect` prints the
exact values to paste in step 4.)
## 4. Add the connector in Perplexity
1. Open Perplexity (requires Pro subscription).
2. Go to **Settings → Connectors** (or **MCP Servers**).
3. Add a new remote connector:
- **URL:** `https://YOUR-DOMAIN.ngrok.app/mcp`
- **Authentication:** API Key / Bearer Token
- **Token:** your GBrain access token
(create one with `gbrain auth create "perplexity"`)
4. Save
Replace `YOUR-DOMAIN` with your ngrok domain (see
[ngrok-tunnel recipe](../../recipes/ngrok-tunnel.md) for setup).
- **Authentication:** API Key / Bearer Token, or OAuth client credentials
- Paste the token (bearer) or `client_id` + `client_secret` (OAuth).
4. Save.
## Verify
@@ -24,8 +87,14 @@ In a Perplexity conversation, ask it to use your brain:
Use my GBrain to search for [topic]
```
Have it call `get_brain_identity` (whose brain this is), then `list_skills`
(everything it can do).
## Notes
- Perplexity Computer is available to Pro subscribers
- Both the Perplexity Mac app and web version support MCP connectors
- The Mac app also supports local MCP servers if you prefer `gbrain serve` (stdio)
- Perplexity Computer is available to Pro subscribers; both the Mac app and web
version support remote MCP connectors.
- The Mac app can also use a local MCP server (`gbrain serve` stdio) if you'd
rather not expose an HTTP endpoint.
- A `gbrain auth create` token is a long-lived, full-access secret. Keep it
private and prefer a scoped token where possible.

View File

@@ -7,13 +7,12 @@ Step-by-step walkthroughs that take you from zero to a working outcome. Concrete
- [**Set up your personal AI agent + brain from zero**](personal-brain.md) — the canonical solo install. Two GitHub repos, a Telegram bot, AlphaClaw on Render, OpenClaw + GBrain + Supabase. End-to-end in about 2 hours; about $100 to $150 a month sustained. The full-stack install I'd run today.
- [**Set up GBrain as your company brain**](company-brain.md) — federated, multi-user, OAuth-scoped institutional memory for a 10-50 person team. Three sources (shared / customers / internal-only), per-user scope, first synthesized query as a teammate. About 90 minutes end-to-end, about $5 in API calls for the demo, under $100 a month sustained for a 25-person company.
- [**Auto-improve a skill with `gbrain skillopt`**](improving-skills-with-skillopt.md) — treat a `SKILL.md` as the trainable parameter of a frozen agent. Write your first benchmark from scratch (the part everyone gets stuck on), preview the cost, run the optimizer, read accepted vs no_improvement vs aborted, and accept a measurably better skill. About 20 minutes, about $1 in API calls. Reference: [`../guides/skillopt.md`](../guides/skillopt.md).
- [**Give your coding agent a memory: GBrain + Claude Code / Codex**](connect-coding-agent.md) — the two-funnel walkthrough for coding-agent users. Path A: connect Claude Code / Codex to a brain you already run (OpenClaw, Hermes, any `gbrain serve --http`). Path B: start from nothing with a 2-second local PGLite brain. Both end with the brain-first protocol you paste into `CLAUDE.md` / `AGENTS.md` and the four habits (brain-first lookup, ambient capture, briefing-from-your-brain, whoknows) that make it worth it. About 10 minutes.
## In progress
These are the next tutorials on the roadmap. Open an issue if one of them is the one you need most; that's how we'll prioritize.
- **Connect GBrain to your existing agent** — for users who already run [OpenClaw](https://github.com/garrytan/openclaw), [Hermes](https://github.com/garrytan/hermes), Claude Code, Cursor, or any MCP-aware client. Wire GBrain in as the memory layer, scaffold the 43 skills, see brain-first lookup fire on the next message your agent gets.
- **Set up GBrain for VC dealflow** — the operator's recipe. People pages for founders, companies with typed Facts fence carrying ARR / team-size / runway across dates, meetings auto-ingested, deal pages linking everything. Shows `gbrain whoknows`, `gbrain find_trajectory`, and `gbrain founder scorecard` on real workflows.
- **Migrate your existing vault into GBrain** — for Notion / Obsidian / Roam users with a vault that doesn't match GBrain's default layout. Walks through `gbrain schema detect``suggest``review-candidates` so the brain learns your shape instead of forcing you to learn its.

View File

@@ -0,0 +1,235 @@
# Give your coding agent a memory: GBrain + Claude Code / Codex
Coding agents got very good at code. They're still amnesiac about everything
else. Claude Code and Codex forget your last conversation, can't tell you what
you decided three meetings ago, and re-derive context you already have written
down somewhere. GBrain is the retrieval layer that fixes that: search, synthesis,
and a self-wiring knowledge graph, wired into your agent over MCP.
There are two ways to do this. Pick the one that matches where you are:
- **Path A — I already run a brain** (OpenClaw, Hermes, or any `gbrain serve`
host) and I want my Claude Code / Codex to reach the same brain. → [jump to Path A](#path-a-connect-an-agent-to-a-brain-you-already-have)
- **Path B — I have nothing yet.** Spin up a local brain in 2 seconds and wire it
into my coding agent. → [jump to Path B](#path-b-start-from-nothing-local-brain-local-agent)
Both end in the same place: an agent that searches your brain before it answers,
and writes new knowledge back as you work. The last section,
[Now make it actually useful](#now-make-it-actually-useful), is the same for both
and is the part that changes how you work.
Prerequisite for either path: `bun install -g github:garrytan/gbrain`.
---
## Path A: connect an agent to a brain you already have
You already have a populated brain (the OpenClaw / Hermes case: it's on your
agent host, full of meetings, people, and ideas). You want Claude Code on your
laptop, and Codex too, to query it. This is the remote path: the host serves
HTTP, your laptop agents connect with a token.
### A1. On the host: serve over HTTP
If your host isn't already serving HTTP MCP, start it:
```bash
gbrain serve --http --bind 0.0.0.0 --public-url https://your-host.example.com
```
Two flags matter and people skip them:
- **`--bind 0.0.0.0`** — the default bind is `127.0.0.1` (loopback only), which
silently refuses every remote connection. If your agent "can't reach the
brain" and you didn't pass this, that's why. `gbrain serve --http` warns you at
startup when `--public-url` is set without `--bind`.
- **`--public-url`** — the externally reachable HTTPS URL (your Render/Railway
URL, ngrok domain, Tailscale Funnel, etc.). It's the issuer the OAuth/MCP
layer advertises.
Watch the startup banner. It now prints a `Skills:` line:
```
║ Skills: published ║
```
If it says `not published`, your connected agents will be able to search and
write but won't see your skill catalog (the OpenClaw skills that make your setup
special). Turn it on:
```bash
gbrain config set mcp.publish_skills true
```
(New brains from `gbrain init` default this ON. Brains upgraded from before
v0.41.36 stay OFF until you opt in, so this is the common gotcha for existing
OpenClaw users.)
### A2. On the host: mint a token
```bash
gbrain auth create "laptop-agents"
```
Copy the `gbrain_…` token it prints. It's a long-lived, full-access secret. Treat
it like a password; prefer a scoped OAuth client for anything cloud-hosted (see
[DEPLOY.md](../mcp/DEPLOY.md)).
### A3. On the laptop: one command per agent
```bash
# Claude Code
gbrain connect https://your-host.example.com/mcp --token gbrain_xxx --install
# Codex
gbrain connect https://your-host.example.com/mcp --token gbrain_xxx --agent codex --install
```
`--install` runs the agent's `mcp add` for you AND smoke-tests the token: it
actually calls `get_brain_identity` before handing off, so a wrong or expired
token fails right now, not silently on the agent's first request. You'll see:
```
Added MCP server 'gbrain' -> https://your-host.example.com/mcp.
Verified: {"version":"0.42.x","engine":"postgres","page_count":146646,...}
```
Drop `--install` to print a paste-ready block instead (useful when the host and
the agent are different machines, or you want to read before you run). Codex
reads the bearer from `$GBRAIN_REMOTE_TOKEN` at runtime, so the token never lands
in Codex's config file. Keep that variable exported in your shell profile.
### A4. Verify
In the agent: *"Call get_brain_identity, then search my brain for [a topic you
know is in there]."* You should get your own pages back. Done.
Full per-client detail: [Claude Code](../mcp/CLAUDE_CODE.md),
[Codex](../mcp/CODEX.md), [Perplexity](../mcp/PERPLEXITY.md).
---
## Path B: start from nothing (local brain, local agent)
No OpenClaw, no server, no token. The lowest-friction path in the whole product:
a local PGLite brain in the same process your agent spawns. Zero server, zero
tunnel.
### B1. Create a local brain
```bash
gbrain init --pglite # 2 seconds; embedded Postgres via WASM, no Docker
```
### B2. Put something in it
A brain with nothing in it answers nothing, so an empty brain on day one feels
broken. Two ways to fill it:
```bash
# Bulk-import a folder of markdown you already have:
gbrain import ~/notes/
# Or capture as you go (one thought at a time):
gbrain capture "Decided to use PGLite as the default engine: zero-config beats Postgres for <1000 files."
```
You don't have to import everything up front. The capture-as-you-go habit (see
the next section) means the brain fills with the decisions and context you
generate while working, and is genuinely useful by day two.
### B3. Wire it into your coding agent
```bash
# Claude Code
claude mcp add gbrain -- gbrain serve
# Codex
codex mcp add gbrain -- gbrain serve
```
That's the whole wire-up. No token, no URL, no tunnel. The agent spawns
`gbrain serve` as a stdio subprocess and talks to your local brain directly.
### B4. Verify
In the agent: *"search my brain for PGLite"* (or whatever you just captured). You
get the page back. The same brain is now query-able from the CLI
(`gbrain query "..."`) and from your agent.
---
## Now make it actually useful
Connecting is the easy part. The value comes from teaching your agent a few
habits. These are the patterns that turn a coding agent into a knowledge-aware
one. Paste the protocol below into your agent's instructions file
(`CLAUDE.md` for Claude Code, `AGENTS.md` for Codex / Cursor / others), then lean
on the patterns.
### The brain-first protocol (paste this in)
```markdown
## Brain-first protocol
You have a knowledge brain connected over MCP. Before answering any question
about people, companies, decisions, projects, or past context:
1. **Search first.** Call `search` (or `query` for a synthesized answer) against
the brain BEFORE answering from memory or asking me. If the brain has the
answer, use it. Never ask "who is X?" or "what did we decide about Y?" before
searching — the brain probably already knows.
2. **Write back.** When I make a decision, mention a new person/company, or land
on an idea worth keeping, write it to the brain with `put_page` (entity pages
under people/, companies/; decisions under decisions/ or notes/). One insight,
one page, linked.
3. **Cite.** When you answer from the brain, name the page you used.
```
### The four patterns worth stealing
These come straight from a production OpenClaw setup. They translate directly to
any coding agent with GBrain connected:
**1. Brain-first lookup (never ask what you can retrieve).** The single highest-
value habit. Before the agent asks you "which repo?" or "who owns this?", it
searches. Try: *"What did we decide about the auth rewrite?"* and watch it pull
the decision page instead of asking you to re-explain.
**2. Ambient capture (your brain as a side effect of working).** Don't make
saving a separate chore. Tell the agent: *"As we work, capture any decision or
new idea to the brain without interrupting."* After a month of this, you have
hundreds of linked pages and patterns you didn't know were there.
**3. Briefing from your brain (not from the internet).** *"What do I need to know
before my 2pm with the Acme team?"* pulls your meeting history, the people,
what's still open, what the brain doesn't know yet. The agent does your prep
because it read your context. (`query` gives you the synthesized answer with
citations; this is the example on the [README](../../README.md).)
**4. whoknows (expertise routing).** *"Who do I know who's shipped a rate
limiter in Postgres?"* The `find_experts` tool ranks people in your brain by
relevance + recency. Useful the moment your brain has more than a handful of
people in it.
That's the spine of it. Two commands to connect, one protocol to paste, four
habits to build. Your agent stops being amnesiac.
---
## Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Agent "can't reach the brain" (Path A) | `gbrain serve --http` bound to loopback | Restart with `--bind 0.0.0.0` |
| `list_skills` returns nothing / errors | Skill publishing OFF on the host | `gbrain config set mcp.publish_skills true` |
| Token rejected on first call | Wrong/expired token | Re-mint with `gbrain auth create`; `--install` smoke-tests it for you |
| `unknown tool: capture` | `capture` is CLI-only, not an MCP tool | Use `put_page` over MCP; `capture` only on the CLI |
| Empty results (Path B) | Brain has nothing in it yet | `gbrain import ~/notes/` or `gbrain capture "..."` |
## Next steps
- Go full autonomous: the overnight enrichment daemon ([dream cycle](../../CHANGELOG.md)) fixes citations, dedupes people, builds scorecards while you sleep. See `gbrain autopilot --install`.
- Run a real agent platform on top: [personal-brain tutorial](personal-brain.md).
- Scale to a team: [company-brain tutorial](company-brain.md).
- Every MCP client's exact setup: [`docs/mcp/`](../mcp/).

File diff suppressed because one or more lines are too long

View File

@@ -141,5 +141,5 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.42.1.0"
"version": "0.42.2.0"
}

View File

@@ -76,6 +76,10 @@ export const E2E_TEST_MAP: Record<string, string[]> = {
"src/mcp/**": ["test/e2e/mcp.test.ts", "test/e2e/http-transport.test.ts"],
// Integrity batch-load fast path.
"src/commands/integrity.ts": ["test/e2e/integrity-batch.test.ts"],
// gbrain connect — raw-bearer MCP smoke probe exercised end-to-end against
// a real serve --http (PGLite), so changes to either feed it.
"src/commands/connect.ts": ["test/e2e/connect-bearer.test.ts"],
"src/core/connect-probe.ts": ["test/e2e/connect-bearer.test.ts"],
// Upgrade chains migration ledger; touches both runners.
"src/commands/upgrade.ts": [
"test/e2e/upgrade.test.ts",

View File

@@ -35,7 +35,7 @@ for (const op of operations) {
}
// CLI-only commands that bypass the operation layer
const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'skillopt']);
const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture', 'onboard', 'conversation-parser', 'status', 'connect', 'skillopt']);
// CLI-only commands whose handlers print their own --help text. These are
// excluded from the generic short-circuit so detailed per-command and
// per-subcommand usage stays reachable.
@@ -75,6 +75,9 @@ const CLI_ONLY_SELF_HELP = new Set([
// describing segment splitting + checkpointing + budget caps + the
// unified types config story. Route around the generic short-circuit.
'extract-conversation-facts',
// `gbrain connect --help` prints its own usage (flags + examples) from
// runConnect; route around the generic one-line short-circuit.
'connect',
]);
async function main() {
@@ -906,6 +909,14 @@ async function handleCliOnly(command: string, args: string[]) {
await runRemote(args);
return;
}
if (command === 'connect') {
// No local DB: connect generates/wires a Claude Code MCP connection to a
// REMOTE gbrain over HTTP from a bearer token. Print mode touches nothing;
// --install talks to the remote, not the local engine.
const { runConnect } = await import('./commands/connect.ts');
await runConnect(args);
return;
}
if (command === 'upgrade') {
const { runUpgrade } = await import('./commands/upgrade.ts');
await runUpgrade(args);
@@ -2071,6 +2082,8 @@ ADMIN
--token-ttl N Access token TTL in seconds (default: 3600)
--enable-dcr Enable Dynamic Client Registration
--public-url URL Public issuer URL (required behind proxy/tunnel)
connect <mcp-url> --token <t> Wire Claude Code to a remote gbrain (bearer token)
[--install] [--json] Print the paste-ready command, or --install to run it
call <tool> '<json>' Raw tool invocation
version Version info
--tools-json Tool discovery (JSON)

View File

@@ -460,17 +460,32 @@ async function registerClient(name: string, args: string[]) {
* direct-script path (see bottom of file) so `bun run src/commands/auth.ts`
* still works.
*/
/**
* Parse `auth create` args into `{ name, takesHolders }`.
*
* Exported + pure so the positional-vs-flag logic is unit-testable. Only
* excludes the --takes-holders VALUE from the positional search when the flag
* is present — the pre-v0.41 inline version used `rest[takesIdx + 1]` which
* resolved to `rest[0]` when `takesIdx === -1`, silently dropping the name on
* the bare `gbrain auth create <name>` form.
*/
export function parseAuthCreateArgs(rest: string[]): { name: string; takesHolders?: string[] } {
const takesIdx = rest.indexOf('--takes-holders');
const takesHolders = takesIdx >= 0 && rest[takesIdx + 1]
? rest[takesIdx + 1].split(',').map(s => s.trim()).filter(Boolean)
: undefined;
const takesValue = takesIdx >= 0 ? rest[takesIdx + 1] : undefined;
const positional = rest.find(a => !a.startsWith('--') && a !== takesValue);
return { name: positional || '', takesHolders };
}
export async function runAuth(args: string[]): Promise<void> {
const [cmd, ...rest] = args;
switch (cmd) {
case 'create': {
// v0.28: optional --takes-holders world,garry,brain (default: world only)
const takesIdx = rest.indexOf('--takes-holders');
const takesHolders = takesIdx >= 0 && rest[takesIdx + 1]
? rest[takesIdx + 1].split(',').map(s => s.trim()).filter(Boolean)
: undefined;
const positional = rest.find(a => !a.startsWith('--') && a !== rest[takesIdx + 1]);
await create(positional || '', { takesHolders });
const parsed = parseAuthCreateArgs(rest);
await create(parsed.name, { takesHolders: parsed.takesHolders });
return;
}
case 'list': await list(); return;

766
src/commands/connect.ts Normal file
View File

@@ -0,0 +1,766 @@
/**
* `gbrain connect` — one-command coding-agent onboarding from a bearer token
* (or OAuth 2.1 client credentials).
*
* Turns an MCP URL + credential into a paste-ready block (or wires it up
* directly with --install) that connects a coding agent straight to a remote
* `gbrain serve --http` and teaches it to self-orient via `get_brain_identity`
* + `list_skills`. Direct HTTP MCP — no local install or thin-client config
* needed for the connection.
*
* gbrain connect <mcp-url> [--token <bearer>] [--name gbrain]
* [--agent claude-code|codex|perplexity|generic]
* [--oauth [--register | --client-id ID --client-secret SECRET] [--scopes "read write"]]
* [--install] [--yes] [--json] [--show-token] [--force]
* [--timeout-ms N]
*
* Auth:
* - Bearer (default): a `gbrain auth create` token. Simple; long-lived +
* full-access. Best for local/personal use.
* - OAuth 2.1 client credentials (`--oauth`, perplexity/generic only): the
* correct path for anything exposed to a third-party cloud — least-privilege
* scopes + short-lived rotating access tokens. The connector is given an
* issuer URL + client_id + client_secret; it mints its own tokens.
*
* Per-agent shape:
* - claude-code: `claude mcp add ... -H "Authorization: Bearer <tok>"` (bearer
* only; --install runs it).
* - codex: `codex mcp add <name> --url <url> --bearer-token-env-var
* GBRAIN_REMOTE_TOKEN` (bearer via env var; --install runs it).
* - perplexity: GUI connector (Settings → Connectors). Supports bearer or
* OAuth; no --install.
* - generic: prints the connector fields for any other MCP client.
*/
import { execFileSync } from 'child_process';
import type { ConnectProbeResult } from '../core/connect-probe.ts';
import { probeBrainIdentity, DEFAULT_PROBE_TIMEOUT_MS } from '../core/connect-probe.ts';
import { promptLine } from '../core/cli-util.ts';
export const ENV_VAR = 'GBRAIN_REMOTE_TOKEN';
export const PLACEHOLDER_TOKEN = '<paste-your-token>';
export const PLACEHOLDER_SECRET = '<paste-your-client-secret>';
export const REDACTED = '***';
export const DEFAULT_NAME = 'gbrain';
export const DEFAULT_SCOPES = 'read write';
const NAME_RE = /^[a-z0-9][a-z0-9_-]*$/;
// Single source of truth shared with the probe (was a duplicated 15_000 literal).
const DEFAULT_TIMEOUT_MS = DEFAULT_PROBE_TIMEOUT_MS;
export type AgentId = 'claude-code' | 'codex' | 'perplexity' | 'generic';
interface AgentSpec {
id: AgentId;
label: string; // human label for messages
binary?: string; // CLI binary backing --install ('claude' | 'codex')
installable: boolean;
supportsOAuth: boolean; // accepts OAuth client-credentials connector fields
}
export const AGENT_SPECS: Record<AgentId, AgentSpec> = {
'claude-code': { id: 'claude-code', label: 'Claude Code', binary: 'claude', installable: true, supportsOAuth: false },
codex: { id: 'codex', label: 'Codex', binary: 'codex', installable: true, supportsOAuth: false },
perplexity: { id: 'perplexity', label: 'Perplexity Computer', installable: false, supportsOAuth: true },
generic: { id: 'generic', label: 'your agent', installable: false, supportsOAuth: true },
};
export const AGENT_IDS: AgentId[] = ['claude-code', 'codex', 'perplexity', 'generic'];
// The named tools MUST be real MCP-exposed ops (verified by the round-trip
// E2E). `capture` is intentionally absent: it's a CLI-only convenience wrapper,
// not an MCP tool — the agent writes over MCP with `put_page`.
export const LEARN_INSTRUCTION =
'Once connected, call the `get_brain_identity` tool (whose brain this is), then ' +
'`list_skills` (everything it can do; if it errors, the host has not enabled skill ' +
'publishing — these core tools still work: search, query, get_page, put_page, ' +
'think, find_experts). Always search the brain before answering or writing.';
const SECRET_NOTE =
'Note: that bearer token is a long-lived, full-access secret — keep it private and ' +
'prefer a scoped/short-lived token if your host supports one.';
const OAUTH_SECRET_NOTE =
'Note: the client secret is sensitive — store it like a password. It mints ' +
'short-lived, scoped access tokens; revoke with `gbrain auth revoke-client`.';
const PERPLEXITY_REMOTE_NOTE = [
'Perplexity connects remotely, so the brain must be reachable over HTTPS. On the',
'host run: gbrain serve --http --bind 0.0.0.0 --public-url <your-https-url> (the',
'default 127.0.0.1 bind refuses tunneled connections). See docs/mcp/PERPLEXITY.md.',
].join('\n');
const HELP = `gbrain connect — wire a coding agent to a remote gbrain over MCP
Usage:
gbrain connect <mcp-url> [--token <bearer>] [flags]
Prints a copy-paste setup block for your agent, or wires it up directly with
--install (claude-code + codex only). The MCP URL is your remote
'gbrain serve --http' endpoint; a bare host is rejected — pass an explicit
https:// URL.
Auth:
Bearer token (default) simple, long-lived, full-access — best local/personal
--oauth OAuth 2.1 client credentials (perplexity/generic):
least-privilege scopes + short-lived tokens — best for
anything exposed to a third-party cloud
Flags:
--token <bearer> Bearer token (else $${ENV_VAR}; from 'gbrain auth create')
--name <id> MCP server name in the agent (default: ${DEFAULT_NAME})
--agent <kind> claude-code (default) | codex | perplexity | generic
--oauth Use OAuth client credentials instead of a bearer token
--register With --oauth: mint a client on the host (gbrain auth register-client)
--client-id <id> With --oauth: use an existing OAuth client id
--client-secret <s> With --oauth: use an existing OAuth client secret
--scopes "<s>" With --oauth --register: client scopes (default: "${DEFAULT_SCOPES}")
--install Run the agent's MCP-add command, then smoke-test the token
(claude-code + codex only)
--yes Skip the install confirmation prompt
--force On --install, replace an existing server of the same name
--json Emit machine-readable JSON (secret redacted)
--show-token With --json, include the literal token/secret (avoid in logs)
--timeout-ms <n> Smoke-test timeout for --install (default: ${DEFAULT_TIMEOUT_MS})
Examples:
gbrain connect https://brain.example.com/mcp --token gbrain_xxx
gbrain connect https://brain.example.com:3131 --install --yes
gbrain connect https://brain.example.com/mcp --token gbrain_xxx --agent codex
gbrain connect https://brain.example.com/mcp --agent perplexity --oauth --register
gbrain connect https://brain.example.com/mcp --agent perplexity --oauth \\
--client-id gbrain_cl_xxx --client-secret gbrain_cs_xxx
`;
// ---------------------------------------------------------------------------
// Pure helpers (unit-tested in test/connect.test.ts)
// ---------------------------------------------------------------------------
export type UrlResult =
| { ok: true; url: string; warning?: string }
| { ok: false; error: string };
/**
* Block link-local / cloud-metadata addresses — the one class of host that is
* never a legitimate brain endpoint but IS a token-exfil target (e.g. the AWS/
* GCP metadata service at 169.254.169.254). Deliberately does NOT block
* localhost or RFC1918/LAN ranges: self-hosted brains on a private network are
* a documented, supported topology (`gbrain serve --http --bind`).
*/
export function isLinkLocalOrMetadata(hostname: string): boolean {
const h = hostname.toLowerCase().replace(/^\[|\]$/g, '');
if (/^169\.254\.\d{1,3}\.\d{1,3}$/.test(h)) return true; // IPv4 link-local incl. cloud metadata
if (h.startsWith('fe80:')) return true; // IPv6 link-local
if (h === 'fd00:ec2::254') return true; // AWS IMDSv2 over IPv6
// IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254 dotted, or ::ffff:a9fe:xxxx
// hex where a9fe == 169.254) must not slip past the dotted-IPv4 check.
const mapped = h.match(/^::ffff:(.+)$/);
if (mapped) {
if (/^169\.254\.\d{1,3}\.\d{1,3}$/.test(mapped[1])) return true;
if (mapped[1].startsWith('a9fe:')) return true;
}
return false;
}
/**
* Normalize an MCP URL to a canonical `<scheme>//<host><path>` ending in /mcp.
* Explicit spec (not best-effort) — see plan D-codex findings.
*/
export function normalizeMcpUrl(input: string): UrlResult {
const raw = (input ?? '').trim();
if (!raw) {
return { ok: false, error: 'Missing MCP URL. Usage: gbrain connect <https://host/mcp> --token <bearer>' };
}
// Require an explicit scheme. A bare `host:3131` parses as scheme `host:`
// under WHATWG URL, so reject anything without `://`.
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) {
const guess = raw.replace(/^\/+/, '');
return { ok: false, error: `Add an explicit scheme, e.g. https://${guess} (a bare host:port is ambiguous).` };
}
let u: URL;
try {
u = new URL(raw);
} catch {
return { ok: false, error: `Invalid URL: ${raw}` };
}
const scheme = u.protocol.toLowerCase();
if (scheme !== 'http:' && scheme !== 'https:') {
return { ok: false, error: `Only http(s) URLs are supported (got ${u.protocol}).` };
}
if (u.username || u.password) {
return { ok: false, error: 'Remove credentials from the URL (user:pass@host is not supported); pass the token via --token.' };
}
if (u.search) {
return { ok: false, error: 'Remove the query string from the MCP URL.' };
}
if (isLinkLocalOrMetadata(u.hostname)) {
return { ok: false, error: `Refusing to target a link-local / cloud-metadata address (${u.hostname}). Point the MCP URL at the brain host's real address.` };
}
const host = u.host; // host:port; hostname already lowercased by URL
const path = u.pathname;
const trimmed = path.replace(/\/+$/, '');
const lower = trimmed.toLowerCase();
let finalPath: string;
if (path === '' || path === '/') {
finalPath = '/mcp';
} else if (lower === '/mcp') {
finalPath = '/mcp';
} else {
return {
ok: false,
error: `Unexpected path '${path}'. Pass the full /mcp URL, e.g. ${scheme}//${host}${trimmed}/mcp`,
};
}
const url = `${scheme}//${host}${finalPath}`;
const hn = u.hostname.toLowerCase();
const isLocal = hn === 'localhost' || hn === '127.0.0.1' || hn === '::1' || hn === '[::1]';
if (scheme === 'http:' && !isLocal) {
return { ok: true, url, warning: 'Warning: http:// sends your bearer token unencrypted. Use https:// unless this is localhost.' };
}
return { ok: true, url };
}
/** The OAuth issuer is the server base — the /mcp endpoint's URL minus /mcp. */
export function issuerFromMcpUrl(url: string): string {
return url.replace(/\/mcp$/, '');
}
export type TokenValidation = { ok: true } | { ok: false; error: string };
/** Reject empty/whitespace/control-char tokens (a newline is a header-injection vector). */
export function validateToken(token: string): TokenValidation {
if (!token || !token.trim()) return { ok: false, error: 'Token is empty.' };
if (/\s/.test(token)) return { ok: false, error: 'Token contains whitespace (space/tab/newline) — refusing (header-injection risk).' };
if (/[\x00-\x1f\x7f]/.test(token)) return { ok: false, error: 'Token contains control characters — refusing (header-injection risk).' };
return { ok: true };
}
export type TokenResolution =
| { kind: 'literal'; token: string }
| { kind: 'placeholder' }
| { kind: 'error'; error: string };
export function resolveToken(opts: { tokenFlag?: string | null; env?: string | null; mode: 'print' | 'install' }): TokenResolution {
const t = opts.tokenFlag ?? opts.env ?? null;
if (t != null && t !== '') {
const v = validateToken(t);
if (!v.ok) return { kind: 'error', error: v.error };
return { kind: 'literal', token: t };
}
if (opts.mode === 'print') return { kind: 'placeholder' };
return {
kind: 'error',
error: `No token. Pass --token <bearer> or set ${ENV_VAR}. Create one on the host with: gbrain auth create "<name>"`,
};
}
export function isValidName(name: string): boolean {
return NAME_RE.test(name);
}
export function buildClaudeMcpAddArgv(p: { name: string; url: string; headerToken: string }): string[] {
return ['mcp', 'add', p.name, '-t', 'http', p.url, '-H', `Authorization: Bearer ${p.headerToken}`];
}
/** Codex reads the bearer from an env var at runtime — the token is NOT in argv. */
export function buildCodexMcpAddArgv(p: { name: string; url: string; envVar: string }): string[] {
return ['mcp', 'add', p.name, '--url', p.url, '--bearer-token-env-var', p.envVar];
}
/**
* POSIX single-quote any arg that isn't already shell-safe, so `$()`, backticks,
* etc. in a token are inert literals when the block is pasted into a shell
* (double-quoting would still allow command substitution).
*/
function shellQuote(arg: string): string {
if (/^[A-Za-z0-9_.:/@-]+$/.test(arg)) return arg;
return `'${arg.replace(/'/g, "'\\''")}'`;
}
/** Render `<binary> <argv...>` as a copy-pasteable, shell-safe command string. */
export function cmdString(binary: string, argv: string[]): string {
return `${binary} ${argv.map(shellQuote).join(' ')}`;
}
export function redactToken(s: string, token: string | null): string {
// Exact-substring scrub of the known token, plus a defense-in-depth pass over
// any `Bearer <value>` shape the SDK/CLI might echo in a transformed form the
// exact match would miss. Both run on the --install error paths only.
let out = token ? s.split(token).join(REDACTED) : s;
out = out.replace(/Bearer\s+\S+/gi, `Bearer ${REDACTED}`);
return out;
}
export interface OAuthCreds {
issuer: string;
clientId: string;
clientSecret: string | null;
}
function claudeBlock(p: { name: string; url: string; token: string | null }): string {
const headerToken = p.token ?? PLACEHOLDER_TOKEN;
const cmd = cmdString('claude', buildClaudeMcpAddArgv({ name: p.name, url: p.url, headerToken }));
const lines = ['# Paste into Claude Code:', '', 'Connect my knowledge brain, then learn what it can do:', '', ` ${cmd}`, ''];
if (!p.token) lines.push(`Replace ${PLACEHOLDER_TOKEN} with a token from \`gbrain auth create "claude-code"\` on the host.`, '');
lines.push(LEARN_INSTRUCTION, '', SECRET_NOTE);
return lines.join('\n');
}
function codexBlock(p: { name: string; url: string; token: string | null }): string {
const tokenValue = p.token ?? PLACEHOLDER_TOKEN;
const cmd = cmdString('codex', buildCodexMcpAddArgv({ name: p.name, url: p.url, envVar: ENV_VAR }));
const lines = [
'# Paste into Codex:',
'',
'Connect my knowledge brain, then learn what it can do:',
'',
` export ${ENV_VAR}=${shellQuote(tokenValue)}`,
` ${cmd}`,
'',
];
if (!p.token) lines.push(`Replace ${PLACEHOLDER_TOKEN} with a token from \`gbrain auth create "codex"\` on the host.`, '');
lines.push(
`Codex reads the token from $${ENV_VAR} at runtime — keep that variable set in your shell profile so new Codex sessions can reach the brain.`,
'',
LEARN_INSTRUCTION,
'',
SECRET_NOTE,
);
return lines.join('\n');
}
function perplexityBearerBlock(p: { url: string; token: string | null }): string {
const tokenValue = p.token ?? PLACEHOLDER_TOKEN;
return [
'# In Perplexity (Pro): Settings → Connectors → add a remote MCP server:',
`# URL: ${p.url}`,
'# Auth: Bearer token (API key)',
`# Token: ${tokenValue}`,
'',
PERPLEXITY_REMOTE_NOTE,
'',
LEARN_INSTRUCTION,
'',
SECRET_NOTE,
].join('\n');
}
function perplexityOAuthBlock(p: { oauth: OAuthCreds }): string {
const secret = p.oauth.clientSecret ?? PLACEHOLDER_SECRET;
return [
'# In Perplexity (Pro): Settings → Connectors → add a remote MCP server:',
`# URL: ${p.oauth.issuer}/mcp`,
'# Auth: OAuth 2.1 (client credentials)',
`# Issuer URL: ${p.oauth.issuer}`,
`# Client ID: ${p.oauth.clientId}`,
`# Client Secret: ${secret}`,
'',
'OAuth is the recommended path for Perplexity (a cloud service): the connector',
'mints short-lived, scoped access tokens instead of holding a long-lived secret.',
'',
PERPLEXITY_REMOTE_NOTE,
'',
LEARN_INSTRUCTION,
'',
OAUTH_SECRET_NOTE,
].join('\n');
}
function genericBearerBlock(p: { url: string; token: string | null }): string {
const headerToken = p.token ?? PLACEHOLDER_TOKEN;
return [
'# Add an HTTP MCP server pointed at your gbrain:',
`# URL: ${p.url}`,
`# Header: Authorization: Bearer ${headerToken}`,
'',
LEARN_INSTRUCTION,
].join('\n');
}
function genericOAuthBlock(p: { oauth: OAuthCreds }): string {
const secret = p.oauth.clientSecret ?? PLACEHOLDER_SECRET;
return [
'# Add an OAuth 2.1 (client-credentials) MCP server pointed at your gbrain:',
`# URL: ${p.oauth.issuer}/mcp`,
`# Issuer URL: ${p.oauth.issuer}`,
`# Client ID: ${p.oauth.clientId}`,
`# Client Secret: ${secret}`,
'',
LEARN_INSTRUCTION,
'',
OAUTH_SECRET_NOTE,
].join('\n');
}
export function buildConnectBlock(p: { agent: AgentId; name: string; url: string; token: string | null; oauth?: OAuthCreds }): string {
if (p.oauth) {
// OAuth is only emitted for connector-style agents (gated upstream).
return p.agent === 'generic' ? genericOAuthBlock({ oauth: p.oauth }) : perplexityOAuthBlock({ oauth: p.oauth });
}
switch (p.agent) {
case 'claude-code': return claudeBlock(p);
case 'codex': return codexBlock(p);
case 'perplexity': return perplexityBearerBlock(p);
case 'generic': return genericBearerBlock(p);
}
}
export function buildJson(p: { url: string; name: string; agent: AgentId; token: string | null; showToken: boolean; oauth?: OAuthCreds; scopes?: string }): Record<string, unknown> {
if (p.oauth) {
const secret = p.oauth.clientSecret;
return {
schema_version: 1,
agent: p.agent,
mcp_url: p.url,
name: p.name,
auth: 'oauth',
issuer_url: p.oauth.issuer,
client_id: p.oauth.clientId,
client_secret: secret == null ? null : (p.showToken ? secret : REDACTED),
secret_redacted: secret != null && !p.showToken,
scopes: p.scopes ?? DEFAULT_SCOPES,
command: null,
command_argv: null,
learn_instruction: LEARN_INSTRUCTION,
};
}
const shownToken = p.token ? (p.showToken ? p.token : REDACTED) : PLACEHOLDER_TOKEN;
let command_argv: string[] | null = null;
let command: string | null = null;
if (p.agent === 'claude-code') {
command_argv = buildClaudeMcpAddArgv({ name: p.name, url: p.url, headerToken: shownToken });
command = cmdString('claude', command_argv);
} else if (p.agent === 'codex') {
// Codex command carries no token (env-var name only), so it's safe verbatim.
command_argv = buildCodexMcpAddArgv({ name: p.name, url: p.url, envVar: ENV_VAR });
command = cmdString('codex', command_argv);
}
return {
schema_version: 1,
agent: p.agent,
mcp_url: p.url,
name: p.name,
auth: 'bearer',
env_var: ENV_VAR,
token_present: p.token != null,
token_redacted: p.token != null && !p.showToken,
header: `Authorization: Bearer ${shownToken}`,
command, // runnable CLI command; null for perplexity/generic (UI/manual setup)
command_argv,
learn_instruction: LEARN_INSTRUCTION,
};
}
// ---------------------------------------------------------------------------
// --install / --register dependencies (injectable for tests)
// ---------------------------------------------------------------------------
export type RegisterResult =
| { ok: true; clientId: string; clientSecret: string }
| { ok: false; message: string };
export interface ConnectDeps {
isTTY(): boolean;
promptYesNo(question: string): Promise<boolean>;
hasBinary(binary: string): boolean;
runBinary(binary: string, argv: string[]): { code: number; stdout: string; stderr: string };
probe(url: string, token: string, timeoutMs: number): Promise<ConnectProbeResult>;
env(name: string): string | undefined;
registerOAuthClient(name: string, scopes: string): RegisterResult;
}
async function defaultPromptYesNo(question: string): Promise<boolean> {
// Reuse the shared prompt helper so stdin pause/resume lifecycle matches the
// rest of the interactive CLI flows (init, apply-migrations, ...).
const answer = (await promptLine(`${question} (y/N): `)).toLowerCase();
return answer === 'y' || answer === 'yes';
}
function defaultRunBinary(binary: string, argv: string[]): { code: number; stdout: string; stderr: string } {
try {
const stdout = execFileSync(binary, argv, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
return { code: 0, stdout: stdout ?? '', stderr: '' };
} catch (e) {
const err = e as { status?: number; stdout?: string | Buffer; stderr?: string | Buffer; message?: string };
return {
code: typeof err.status === 'number' ? err.status : 1,
stdout: err.stdout ? String(err.stdout) : '',
stderr: err.stderr ? String(err.stderr) : (err.message ?? ''),
};
}
}
/** Mint an OAuth client by shelling to the host's `gbrain auth register-client`. */
function defaultRegisterOAuthClient(name: string, scopes: string): RegisterResult {
const r = defaultRunBinary('gbrain', [
'auth', 'register-client', name,
'--grant-types', 'client_credentials',
'--scopes', scopes,
'--token-endpoint-auth-method', 'client_secret_post',
]);
if (r.code !== 0) {
return { ok: false, message: r.stderr || r.stdout || 'gbrain auth register-client failed' };
}
const clientId = r.stdout.match(/Client ID:\s+(\S+)/)?.[1];
const clientSecret = r.stdout.match(/Client Secret:\s+(\S+)/)?.[1];
if (!clientId || !clientSecret) {
return { ok: false, message: 'could not parse client_id/client_secret from register-client output' };
}
return { ok: true, clientId, clientSecret };
}
const defaultDeps: ConnectDeps = {
isTTY: () => !!process.stdin.isTTY,
promptYesNo: defaultPromptYesNo,
hasBinary: (binary) => {
try {
execFileSync(binary, ['--version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
},
runBinary: defaultRunBinary,
probe: (url, token, timeoutMs) => probeBrainIdentity(url, token, { timeoutMs }),
env: (name) => process.env[name],
registerOAuthClient: defaultRegisterOAuthClient,
};
// ---------------------------------------------------------------------------
// Orchestrator
// ---------------------------------------------------------------------------
interface ParsedFlags {
url?: string;
token?: string;
name: string;
agent: AgentId;
oauth: boolean;
register: boolean;
clientId?: string;
clientSecret?: string;
scopes: string;
install: boolean;
yes: boolean;
force: boolean;
json: boolean;
showToken: boolean;
timeoutMs: number;
help: boolean;
agentError?: string;
argError?: string;
}
function parseArgs(args: string[]): ParsedFlags {
const out: ParsedFlags = {
name: DEFAULT_NAME,
agent: 'claude-code',
oauth: false,
register: false,
scopes: DEFAULT_SCOPES,
install: false,
yes: false,
force: false,
json: false,
showToken: false,
timeoutMs: DEFAULT_TIMEOUT_MS,
help: false,
};
// Read the value for a value-taking flag, refusing a missing value or one
// that is itself a flag (e.g. `--token --install` would otherwise silently
// consume `--install` as the token and leave install off). Shares `i` with
// the loop below, so it is declared in the function body, not the for-header.
let i = 0;
const takeValue = (flag: string): string | undefined => {
const v = args[i + 1];
if (v === undefined || v.startsWith('--')) {
out.argError = `${flag} requires a value.`;
return undefined;
}
i++;
return v;
};
for (; i < args.length; i++) {
const a = args[i];
switch (a) {
case '--help': case '-h': out.help = true; break;
case '--install': out.install = true; break;
case '--oauth': out.oauth = true; break;
case '--register': out.register = true; break;
case '--yes': case '-y': out.yes = true; break;
case '--force': out.force = true; break;
case '--json': out.json = true; break;
case '--show-token': out.showToken = true; break;
case '--token': { const v = takeValue('--token'); if (v !== undefined) out.token = v; break; }
case '--client-id': { const v = takeValue('--client-id'); if (v !== undefined) out.clientId = v; break; }
case '--client-secret': { const v = takeValue('--client-secret'); if (v !== undefined) out.clientSecret = v; break; }
case '--scopes': { const v = takeValue('--scopes'); if (v !== undefined) out.scopes = v; break; }
case '--name': { const v = takeValue('--name'); if (v !== undefined) out.name = v; break; }
case '--agent': {
const v = takeValue('--agent');
if (v === undefined) break;
if ((AGENT_IDS as string[]).includes(v)) out.agent = v as AgentId;
else out.agentError = `Unknown --agent '${v}'. Use one of: ${AGENT_IDS.join(', ')}.`;
break;
}
case '--timeout-ms': {
const raw = takeValue('--timeout-ms');
if (raw === undefined) break;
const n = parseInt(raw, 10);
if (Number.isFinite(n) && n > 0) out.timeoutMs = n;
break;
}
default:
if (!a.startsWith('-') && out.url === undefined) out.url = a;
break;
}
}
return out;
}
function fail(msg: string): never {
console.error(msg);
process.exit(1);
}
/** Resolve OAuth creds from explicit flags or by registering a client on the host. */
function resolveOAuthCreds(f: ParsedFlags, url: string, deps: ConnectDeps): OAuthCreds {
const issuer = issuerFromMcpUrl(url);
if (f.clientId && f.clientSecret) {
return { issuer, clientId: f.clientId, clientSecret: f.clientSecret };
}
if (f.clientId || f.clientSecret) {
fail('--oauth needs BOTH --client-id and --client-secret (or use --register to mint a client).');
}
if (f.register) {
const r = deps.registerOAuthClient(f.name, f.scopes);
if (!r.ok) {
fail(`Could not register an OAuth client (run this on the brain host where the DB lives): ${r.message}\n` +
`Or mint one manually: gbrain auth register-client ${f.name} --grant-types client_credentials --scopes "${f.scopes}"`);
}
return { issuer, clientId: r.clientId, clientSecret: r.clientSecret };
}
return fail(
'--oauth needs an OAuth client. Either:\n' +
` • --register (mint one on the host: gbrain auth register-client ${f.name} --grant-types client_credentials --scopes "${f.scopes}")\n` +
' • --client-id <id> --client-secret <secret> (use an existing client)',
);
}
export async function runConnect(args: string[], deps: ConnectDeps = defaultDeps): Promise<void> {
const f = parseArgs(args);
if (f.help) {
console.log(HELP);
return;
}
if (f.argError) fail(f.argError);
if (f.agentError) fail(f.agentError);
if (!isValidName(f.name)) {
fail(`Invalid --name '${f.name}'. Use a lowercase identifier matching ${NAME_RE}.`);
}
const norm = normalizeMcpUrl(f.url ?? '');
if (!norm.ok) fail(norm.error);
if (norm.warning) console.error(norm.warning);
const url = norm.url;
const spec = AGENT_SPECS[f.agent];
// ---- OAuth path (connector-style agents only; no --install) ----
if (f.oauth) {
if (!spec.supportsOAuth) {
fail(`--oauth (client credentials) is for connector-style agents (${AGENT_IDS.filter((a) => AGENT_SPECS[a].supportsOAuth).join(', ')}). ${spec.label} uses the bearer path — drop --oauth.`);
}
if (f.install) {
fail(`--install is not supported with --oauth. ${spec.label} is configured through its UI; this prints the OAuth connector fields to paste.`);
}
const oauth = resolveOAuthCreds(f, url, deps);
if (f.json) {
console.log(JSON.stringify(buildJson({ url, name: f.name, agent: f.agent, token: null, showToken: f.showToken, oauth, scopes: f.scopes }), null, 2));
} else {
console.log(buildConnectBlock({ agent: f.agent, name: f.name, url, token: null, oauth }));
}
return;
}
const mode = f.install ? 'install' : 'print';
const tok = resolveToken({ tokenFlag: f.token ?? null, env: deps.env(ENV_VAR) ?? null, mode });
if (tok.kind === 'error') fail(tok.error);
const token: string | null = tok.kind === 'literal' ? tok.token : null;
if (!f.install) {
if (f.json) {
console.log(JSON.stringify(buildJson({ url, name: f.name, agent: f.agent, token, showToken: f.showToken }), null, 2));
} else {
console.log(buildConnectBlock({ agent: f.agent, name: f.name, url, token }));
}
return;
}
// --install path. token is guaranteed literal here (install mode resolveToken).
const realToken = token as string;
if (!spec.installable) {
fail(`--install supports claude-code and codex. ${spec.label} is set up through its own UI — drop --install to print the setup steps.`);
}
const binary = spec.binary as string; // 'claude' | 'codex'
if (!deps.hasBinary(binary)) {
fail(`${spec.label} CLI ('${binary}') not found on PATH. Install ${spec.label}, or drop --install to print the command to run manually.`);
}
const exists = deps.runBinary(binary, ['mcp', 'get', f.name]).code === 0;
if (exists && !f.force) {
fail(`An MCP server named '${f.name}' already exists in ${spec.label}. Run '${binary} mcp remove ${f.name}' first, pass --name <other>, or --force to replace it.`);
}
if (!f.yes) {
if (!deps.isTTY()) {
// Non-interactive --install registers a credential-bearing MCP server and
// fires the token at a remote host — require an explicit --yes rather than
// silently proceeding when there's no TTY to confirm at.
fail('--install in a non-interactive shell requires --yes (refusing to register a credential-bearing MCP server without confirmation).');
}
const ok = await deps.promptYesNo(`Add MCP server '${f.name}' -> ${url} to ${spec.label}?`);
if (!ok) fail('Aborted.');
}
let removedExisting = false;
if (exists && f.force) {
const rm = deps.runBinary(binary, ['mcp', 'remove', f.name]);
if (rm.code !== 0) {
fail(`Could not replace existing server '${f.name}': ${redactToken(rm.stderr || rm.stdout, realToken)}`);
}
removedExisting = true;
}
const addArgv = f.agent === 'codex'
? buildCodexMcpAddArgv({ name: f.name, url, envVar: ENV_VAR })
: buildClaudeMcpAddArgv({ name: f.name, url, headerToken: realToken });
const add = deps.runBinary(binary, addArgv);
if (add.code !== 0) {
const note = removedExisting ? ` (note: the previous '${f.name}' was already removed — re-run to restore it)` : '';
fail(`'${binary} mcp add' failed${note}: ${redactToken(add.stderr || add.stdout, realToken)}`);
}
console.error(`Added MCP server '${f.name}' -> ${url}.`);
// Codex reads the token from the env var at runtime, not from its config.
// If the current env doesn't already carry it, the user must export it.
if (f.agent === 'codex' && deps.env(ENV_VAR) !== realToken) {
console.error(`Codex reads the token from $${ENV_VAR} at runtime. Add this to your shell profile so new sessions can reach the brain:`);
console.error(` export ${ENV_VAR}=<your-token>`);
}
// D4 smoke-test: prove the token actually authenticates a tool call now,
// instead of failing silently on the agent's first request.
const probe = await deps.probe(url, realToken, f.timeoutMs);
if (probe.ok) {
console.error(`Verified: ${probe.identity || 'brain reachable'}`);
console.error('');
console.error(LEARN_INSTRUCTION);
return;
}
// Server is registered, but end-to-end auth did not verify. Exit non-zero so
// scripts notice; the message never echoes the token.
console.error(
`Warning: registered '${f.name}', but the smoke-test did not verify (${probe.reason}): ${redactToken(probe.message, realToken)}`,
);
console.error('The agent will likely hit 401/errors until the token or URL is fixed.');
process.exit(1);
}

View File

@@ -374,6 +374,27 @@ export async function queryAgentClientSpend(engine: BrainEngine): Promise<AgentC
}));
}
/**
* Skill-publishing status for the startup banner + operator nudge. When OFF,
* connected agents (Codex / Claude Code / Perplexity / Cowork) cannot call
* `list_skills` / `get_skill`, so the host's skill catalog is INVISIBLE to them
* — the core tools (search / query / get_page / put_page / capture / think /
* find_experts) still work. Pure so the banner value + nudge copy are
* unit-tested without standing up a server. See `readMcpPublishSkills`
* (skill-catalog.ts) for the config resolution this status reflects.
*/
export function skillPublishStatus(publishSkills: boolean): { bannerValue: string; nudge: string | null } {
if (publishSkills) return { bannerValue: 'published', nudge: null };
return {
bannerValue: 'not published',
nudge:
"[serve-http] NOTE: skill publishing is OFF — connected agents can't call " +
'list_skills / get_skill, so this brains skill catalog is invisible to them ' +
'(core tools like search / query / think still work). Enable it with: ' +
'gbrain config set mcp.publish_skills true',
};
}
export async function runServeHttp(engine: BrainEngine, options: ServeHttpOptions) {
const { port, tokenTtl, enableDcr, publicUrl, logFullParams } = options;
// v0.34.1 (#864, D11): default bind flipped from 0.0.0.0 to 127.0.0.1.
@@ -398,6 +419,21 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
);
}
// Skill-publishing status for the banner + nudge. Mirrors readMcpPublishSkills
// (skill-catalog.ts): the DB plane (`gbrain config set`) wins over the file
// plane. When OFF, a connected coding agent can't see the host's skill
// catalog — surface that to the operator at startup rather than letting them
// discover it via an empty list_skills on the agent side.
let publishSkills = false;
try {
const dbVal = await engine.getConfig('mcp.publish_skills');
publishSkills = dbVal != null ? dbVal === 'true' : config?.mcp?.publish_skills === true;
} catch {
publishSkills = config?.mcp?.publish_skills === true;
}
const skillStatus = skillPublishStatus(publishSkills);
if (skillStatus.nudge) console.error(skillStatus.nudge);
// Engine-aware SQL adapter. Routes through engine.executeRaw on both
// Postgres and PGLite — the OAuth/admin/auth surface no longer requires
// a postgres.js singleton, so `gbrain serve --http` works against PGLite
@@ -2068,6 +2104,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
║ Issuer: ${issuerUrl.origin.padEnd(40)}
║ Clients: ${String((clientCount[0] as any).count).padEnd(40)}
║ DCR: ${(enableDcr ? 'enabled' : 'disabled').padEnd(40)}
║ Skills: ${skillStatus.bannerValue.padEnd(40)}
║ Token TTL: ${(tokenTtl + 's').padEnd(40)}
╠══════════════════════════════════════════════════════╣
║ Admin: http://localhost:${port}/admin${' '.repeat(Math.max(0, 19 - String(port).length))}

146
src/core/connect-probe.ts Normal file
View File

@@ -0,0 +1,146 @@
/**
* Raw-bearer MCP smoke probe for `gbrain connect --install` (D4).
*
* Purpose-built so a bad/expired token fails at setup time instead of
* silently on the agent's first tool call. It does the FULL handshake the
* existing helpers do NOT:
* - `src/core/remote-mcp-probe.ts:smokeTestMcp` only sends `initialize`,
* so it can't prove a tool call actually round-trips.
* - `src/core/mcp-client.ts:callRemoteTool` is OAuth-only (it mints
* client-credentials tokens) and ignores a raw bearer token.
*
* This probe connects the official MCP SDK Client over StreamableHTTP with a
* STATIC Authorization header (no OAuth, no discovery), then calls
* `get_brain_identity` — a read-scope, non-localOnly op reachable over plain
* bearer auth. `client.connect()` performs the `initialize` handshake and
* owns the streamable-http session id, so the subsequent `tools/call` lands
* on the same session without us hand-rolling session headers.
*
* Returns a discriminated result so the caller renders a precise warning.
* Never throws: every failure path maps to `{ ok: false, reason, message }`.
*/
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
export type ConnectProbeReason = 'auth' | 'unreachable' | 'timeout' | 'tool_error' | 'unknown';
/** Default smoke-probe timeout. Single source of truth shared with connect.ts. */
export const DEFAULT_PROBE_TIMEOUT_MS = 15_000;
/** Shared auth-rejection matcher so the thrown-error and tool-error paths agree. */
export function isAuthErrorMessage(message: string): boolean {
return /\b(401|403)\b|unauthor|invalid.token|forbidden/i.test(message);
}
export type ConnectProbeResult =
| { ok: true; identity: string }
| { ok: false; reason: ConnectProbeReason; message: string };
/**
* The single MCP round-trip, injectable for unit tests so the SDK + a live
* server aren't required to exercise the result-mapping + error-classification.
*/
export interface ProbeDeps {
connectAndCall: (
mcpUrl: string,
token: string,
signal: AbortSignal,
) => Promise<{ isError?: boolean; content?: unknown }>;
}
/**
* Map a thrown error message to a probe reason. Pure + exported so the
* classification is unit-testable without a network.
*/
export function classifyProbeError(message: string): ConnectProbeReason {
if (/timeout|abort/i.test(message)) return 'timeout';
if (isAuthErrorMessage(message)) return 'auth';
// undici/fetch + MCP SDK transport failures: DNS, ECONNREFUSED, TLS,
// getaddrinfo, and the SDK's friendly "Unable to connect..." wrapper.
if (/fetch failed|unable to connect|connection refused|failed to connect|could not connect|ENOTFOUND|ECONNREFUSED|ECONNRESET|EHOSTUNREACH|ETIMEDOUT|network|socket|tls|certificate/i.test(message)) {
return 'unreachable';
}
return 'unknown';
}
/** Pull the text payload out of an MCP tool result's content array. */
export function extractResultText(content: unknown): string {
if (!Array.isArray(content)) return '';
return content
.map((c) => (c && typeof c === 'object' && typeof (c as { text?: unknown }).text === 'string'
? (c as { text: string }).text
: ''))
.filter(Boolean)
.join('\n');
}
const DEFAULT_DEPS: ProbeDeps = {
connectAndCall: async (mcpUrl, token, signal) => {
const transport = new StreamableHTTPClientTransport(new URL(mcpUrl), {
requestInit: {
headers: { Authorization: `Bearer ${token}` },
signal,
},
});
const client = new Client(
{ name: 'gbrain-connect-probe', version: '1' },
{ capabilities: {} },
);
// close() lives in a finally that wraps connect() too — if connect()
// throws mid-handshake the transport/socket must still be torn down.
try {
await client.connect(transport);
// callTool's return is a wide union (incl. the legacy {toolResult}
// shape); we only read isError + content, so narrow at the boundary.
const res = await client.callTool({ name: 'get_brain_identity', arguments: {} });
return res as { isError?: boolean; content?: unknown };
} finally {
try { await client.close(); } catch { /* best-effort */ }
}
},
};
/**
* Probe `<mcpUrl>` with the bearer token by calling `get_brain_identity`.
* `timeoutMs` defaults to 15s (the smokeTestMcp default). Pass `deps` to stub
* the round-trip in unit tests.
*/
export async function probeBrainIdentity(
mcpUrl: string,
token: string,
opts: { timeoutMs?: number; deps?: ProbeDeps } = {},
): Promise<ConnectProbeResult> {
const deps = opts.deps ?? DEFAULT_DEPS;
const timeoutMs = opts.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
// Promise.race against a real timer: the AbortSignal alone does NOT cover
// client.connect()'s initialize/SSE handshake, so a server that accepts the
// socket then stalls before responding would hang the probe (and the whole
// --install) forever. The race makes the timeout guarantee actually hold —
// the abandoned connectAndCall promise settles in the background; the CLI
// exits regardless.
const timeoutGuard = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
controller.abort(new Error('timeout'));
// Message must contain "timeout" so classifyProbeError maps it correctly.
reject(new Error(`probe timeout after ${timeoutMs}ms`));
}, timeoutMs);
});
try {
const res = await Promise.race([deps.connectAndCall(mcpUrl, token, controller.signal), timeoutGuard]);
if (res.isError) {
const message = extractResultText(res.content) || 'unknown tool error';
const reason = isAuthErrorMessage(message) ? 'auth' : 'tool_error';
return { ok: false, reason, message };
}
const identity = extractResultText(res.content);
return { ok: true, identity };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return { ok: false, reason: classifyProbeError(message), message };
} finally {
if (timer) clearTimeout(timer);
}
}

View File

@@ -179,11 +179,17 @@ describe('pruneOldBatchRetryAuditFiles — codex H-8 actual pruning', () => {
});
});
test('no-op when audit dir does not exist (ENOENT)', () => {
const result = pruneOldBatchRetryAuditFiles(30, new Date());
// Even without the env override, the function never throws on missing dir.
// We just check it returns the empty result without throwing.
expect(result).toEqual({ removed: 0, kept: 0 });
test('no-op when audit dir does not exist (ENOENT)', async () => {
// Point GBRAIN_AUDIT_DIR at a guaranteed-missing subdir of the per-test
// tmpDir. Without this override the function reads the real ~/.gbrain/audit,
// so the assertion flakes on any dev machine that already has a real
// batch-retry-*.jsonl on disk (returns kept:1, not kept:0). Hermetic now,
// matching this file's header contract.
await withEnv({ GBRAIN_AUDIT_DIR: path.join(tmpDir, 'does-not-exist') }, async () => {
const result = pruneOldBatchRetryAuditFiles(30, new Date());
// The function never throws on a missing dir; it returns the empty result.
expect(result).toEqual({ removed: 0, kept: 0 });
});
});
});

View File

@@ -0,0 +1,38 @@
import { test, expect, describe } from 'bun:test';
import { parseAuthCreateArgs } from '../src/commands/auth.ts';
describe('parseAuthCreateArgs', () => {
test('bare name (no flag) resolves the name — regression for the dropped-name bug', () => {
// Pre-fix this returned name='' because rest[takesIdx+1] === rest[0] when
// takesIdx === -1, excluding the only positional from the search.
expect(parseAuthCreateArgs(['claude-code'])).toEqual({ name: 'claude-code', takesHolders: undefined });
});
test('name + --takes-holders', () => {
expect(parseAuthCreateArgs(['claude-code', '--takes-holders', 'world,garry'])).toEqual({
name: 'claude-code',
takesHolders: ['world', 'garry'],
});
});
test('--takes-holders before the name still finds the name', () => {
expect(parseAuthCreateArgs(['--takes-holders', 'world', 'claude-code'])).toEqual({
name: 'claude-code',
takesHolders: ['world'],
});
});
test('the takes-holders value is not mistaken for the name', () => {
// 'world' is the flag value, 'mybot' is the name.
expect(parseAuthCreateArgs(['--takes-holders', 'world', 'mybot']).name).toBe('mybot');
});
test('no name → empty string (caller prints usage)', () => {
expect(parseAuthCreateArgs([]).name).toBe('');
expect(parseAuthCreateArgs(['--takes-holders', 'world']).name).toBe('');
});
test('takes-holders trims + drops empties', () => {
expect(parseAuthCreateArgs(['n', '--takes-holders', ' world , , garry ']).takesHolders).toEqual(['world', 'garry']);
});
});

861
test/connect.test.ts Normal file
View File

@@ -0,0 +1,861 @@
import { test, expect, describe } from 'bun:test';
import {
normalizeMcpUrl,
isLinkLocalOrMetadata,
validateToken,
resolveToken,
isValidName,
buildClaudeMcpAddArgv,
buildCodexMcpAddArgv,
cmdString,
redactToken,
buildConnectBlock,
buildJson,
runConnect,
issuerFromMcpUrl,
type ConnectDeps,
AGENT_IDS,
ENV_VAR,
DEFAULT_SCOPES,
PLACEHOLDER_TOKEN,
PLACEHOLDER_SECRET,
REDACTED,
LEARN_INSTRUCTION,
} from '../src/commands/connect.ts';
import {
classifyProbeError,
extractResultText,
probeBrainIdentity,
type ProbeDeps,
} from '../src/core/connect-probe.ts';
describe('normalizeMcpUrl', () => {
test('bare host:port is rejected with a scheme hint', () => {
const r = normalizeMcpUrl('brain.example.com:3131');
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/https:\/\/brain\.example\.com:3131/);
});
test('localhost:port (no scheme) is rejected too', () => {
expect(normalizeMcpUrl('localhost:3131').ok).toBe(false);
});
test('https host without path appends /mcp', () => {
const r = normalizeMcpUrl('https://brain.example.com:3131');
expect(r).toEqual({ ok: true, url: 'https://brain.example.com:3131/mcp' });
});
test('existing /mcp is not doubled', () => {
const r = normalizeMcpUrl('https://brain.example.com/mcp');
expect(r).toEqual({ ok: true, url: 'https://brain.example.com/mcp' });
});
test('trailing slash on /mcp/ is tolerated', () => {
const r = normalizeMcpUrl('https://brain.example.com/mcp/');
expect(r).toEqual({ ok: true, url: 'https://brain.example.com/mcp' });
});
test('root path becomes /mcp', () => {
const r = normalizeMcpUrl('https://brain.example.com/');
expect(r).toEqual({ ok: true, url: 'https://brain.example.com/mcp' });
});
test('uppercase scheme/host + /MCP normalize to lowercase canonical', () => {
const r = normalizeMcpUrl('HTTPS://Brain.Example.COM/MCP');
expect(r).toEqual({ ok: true, url: 'https://brain.example.com/mcp' });
});
test('a non-/mcp base path errors and suggests the full URL', () => {
const r = normalizeMcpUrl('https://brain.example.com/gbrain');
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error).toMatch(/\/gbrain\/mcp/);
});
test('credentials in the URL are rejected', () => {
expect(normalizeMcpUrl('https://user:pass@brain.example.com/mcp').ok).toBe(false);
});
test('query strings are rejected', () => {
expect(normalizeMcpUrl('https://brain.example.com/mcp?key=1').ok).toBe(false);
});
test('fragment is stripped', () => {
const r = normalizeMcpUrl('https://brain.example.com/mcp#frag');
expect(r).toEqual({ ok: true, url: 'https://brain.example.com/mcp' });
});
test('non-http scheme is rejected', () => {
expect(normalizeMcpUrl('ftp://brain.example.com/mcp').ok).toBe(false);
});
test('http on a non-local host warns about plaintext token', () => {
const r = normalizeMcpUrl('http://brain.example.com/mcp');
expect(r.ok).toBe(true);
if (r.ok) expect(r.warning).toMatch(/unencrypted/i);
});
test('http on localhost does not warn', () => {
const r = normalizeMcpUrl('http://localhost:3131/mcp');
expect(r.ok).toBe(true);
if (r.ok) expect(r.warning).toBeUndefined();
});
test('empty input errors', () => {
expect(normalizeMcpUrl('').ok).toBe(false);
});
test('cloud-metadata / link-local hosts are rejected', () => {
expect(normalizeMcpUrl('http://169.254.169.254/mcp').ok).toBe(false);
expect(normalizeMcpUrl('http://[fe80::1]/mcp').ok).toBe(false);
const r = normalizeMcpUrl('http://169.254.169.254/mcp');
if (!r.ok) expect(r.error).toMatch(/link-local|metadata/i);
});
test('localhost and RFC1918/LAN hosts are still allowed (self-hosted brains)', () => {
expect(normalizeMcpUrl('http://localhost:3131/mcp').ok).toBe(true);
expect(normalizeMcpUrl('http://192.168.1.50:3131/mcp').ok).toBe(true);
expect(normalizeMcpUrl('https://10.0.0.5/mcp').ok).toBe(true);
});
test('IPv4-mapped IPv6 metadata addresses do not bypass the guard', () => {
// dotted and hex (a9fe == 169.254) IPv4-mapped forms
expect(isLinkLocalOrMetadata('::ffff:169.254.169.254')).toBe(true);
expect(isLinkLocalOrMetadata('::ffff:a9fe:a9fe')).toBe(true);
expect(isLinkLocalOrMetadata('[::ffff:169.254.169.254]')).toBe(true);
// a normal mapped LAN/public address is not flagged
expect(isLinkLocalOrMetadata('::ffff:192.168.1.5')).toBe(false);
});
});
describe('validateToken', () => {
test('accepts a normal token', () => {
expect(validateToken('gbrain_abc123').ok).toBe(true);
});
test('rejects empty', () => {
expect(validateToken('').ok).toBe(false);
expect(validateToken(' ').ok).toBe(false);
});
test('rejects whitespace (newline = header injection)', () => {
expect(validateToken('abc\ndef').ok).toBe(false);
expect(validateToken('abc def').ok).toBe(false);
expect(validateToken('abc\tdef').ok).toBe(false);
});
test('rejects control characters', () => {
expect(validateToken('abc\x00def').ok).toBe(false);
});
});
describe('resolveToken', () => {
test('--token flag wins', () => {
expect(resolveToken({ tokenFlag: 'tok', env: 'envtok', mode: 'print' })).toEqual({ kind: 'literal', token: 'tok' });
});
test('env used when no flag', () => {
expect(resolveToken({ tokenFlag: null, env: 'envtok', mode: 'install' })).toEqual({ kind: 'literal', token: 'envtok' });
});
test('print mode without token returns placeholder', () => {
expect(resolveToken({ tokenFlag: null, env: null, mode: 'print' })).toEqual({ kind: 'placeholder' });
});
test('install mode without token errors with a gbrain auth create hint', () => {
const r = resolveToken({ tokenFlag: null, env: null, mode: 'install' });
expect(r.kind).toBe('error');
if (r.kind === 'error') {
expect(r.error).toMatch(/gbrain auth create/);
expect(r.error).toMatch(ENV_VAR);
}
});
test('invalid token errors even in print mode', () => {
expect(resolveToken({ tokenFlag: 'bad tok', env: null, mode: 'print' }).kind).toBe('error');
});
});
describe('isValidName', () => {
test('accepts conservative identifiers', () => {
expect(isValidName('gbrain')).toBe(true);
expect(isValidName('team-brain_2')).toBe(true);
});
test('rejects bad names', () => {
expect(isValidName('-leading')).toBe(false);
expect(isValidName('Has Space')).toBe(false);
expect(isValidName('UPPER')).toBe(false);
expect(isValidName('')).toBe(false);
expect(isValidName('semi;colon')).toBe(false);
});
});
describe('argv + command string', () => {
test('claude argv shape', () => {
expect(buildClaudeMcpAddArgv({ name: 'gbrain', url: 'https://h/mcp', headerToken: 'TOK' })).toEqual([
'mcp', 'add', 'gbrain', '-t', 'http', 'https://h/mcp', '-H', 'Authorization: Bearer TOK',
]);
});
test('codex argv shape — env-var bearer, no token in argv', () => {
expect(buildCodexMcpAddArgv({ name: 'gbrain', url: 'https://h/mcp', envVar: ENV_VAR })).toEqual([
'mcp', 'add', 'gbrain', '--url', 'https://h/mcp', '--bearer-token-env-var', ENV_VAR,
]);
});
test('command string single-quotes the header (paste-safe)', () => {
const cmd = cmdString('claude', buildClaudeMcpAddArgv({ name: 'gbrain', url: 'https://h/mcp', headerToken: 'TOK' }));
expect(cmd).toBe("claude mcp add gbrain -t http https://h/mcp -H 'Authorization: Bearer TOK'");
});
test('a token with shell metacharacters cannot trigger command substitution on paste', () => {
const cmd = cmdString('claude', buildClaudeMcpAddArgv({ name: 'gbrain', url: 'https://h/mcp', headerToken: 'gbrain_$(touch /tmp/pwned)`x`' }));
// Single-quoted → the $() and backticks are inert literals, not double-quoted.
expect(cmd).toContain("'Authorization: Bearer gbrain_$(touch /tmp/pwned)`x`'");
expect(cmd).not.toContain('"Authorization');
});
});
describe('redactToken', () => {
test('replaces every occurrence', () => {
expect(redactToken('a TOK b TOK', 'TOK')).toBe(`a ${REDACTED} b ${REDACTED}`);
});
test('null token still scrubs Bearer-shaped values (defense in depth)', () => {
// Even without the literal token, a transformed Bearer echo is scrubbed.
expect(redactToken('failed: Bearer gbrain_xyz123', null)).toBe(`failed: Bearer ${REDACTED}`);
});
test('Bearer scrub catches a non-exact token echo', () => {
expect(redactToken('add failed near Bearer SOMETHINGELSE', 'tok')).toContain(`Bearer ${REDACTED}`);
});
});
describe('buildConnectBlock', () => {
test('claude-code with a literal token inlines it + learn instruction', () => {
const block = buildConnectBlock({ agent: 'claude-code', name: 'gbrain', url: 'https://h/mcp', token: 'TOK' });
expect(block).toContain("claude mcp add gbrain -t http https://h/mcp -H 'Authorization: Bearer TOK'");
expect(block).toContain(LEARN_INSTRUCTION);
expect(block).not.toContain(PLACEHOLDER_TOKEN);
expect(block).toMatch(/long-lived, full-access secret/);
});
test('claude-code without a token emits a placeholder + replace hint', () => {
const block = buildConnectBlock({ agent: 'claude-code', name: 'gbrain', url: 'https://h/mcp', token: null });
expect(block).toContain(PLACEHOLDER_TOKEN);
expect(block).toMatch(/gbrain auth create/);
});
test('generic agent emits URL + header lines, no claude command', () => {
const block = buildConnectBlock({ agent: 'generic', name: 'gbrain', url: 'https://h/mcp', token: 'TOK' });
expect(block).toContain('URL: https://h/mcp');
expect(block).toContain('Authorization: Bearer TOK');
expect(block).not.toContain('claude mcp add');
expect(block).toContain(LEARN_INSTRUCTION);
});
test('codex emits the codex command + env-var export, token only in export', () => {
const block = buildConnectBlock({ agent: 'codex', name: 'gbrain', url: 'https://h/mcp', token: 'TOK' });
expect(block).toContain('codex mcp add gbrain --url https://h/mcp --bearer-token-env-var GBRAIN_REMOTE_TOKEN');
expect(block).toContain('export GBRAIN_REMOTE_TOKEN=TOK');
// the codex command itself must not carry the token
expect(block).toMatch(/codex mcp add[^\n]*$/m);
expect(block).toContain(LEARN_INSTRUCTION);
expect(block).toMatch(/reads the token from \$GBRAIN_REMOTE_TOKEN/);
});
test('codex single-quotes a metachar token in the export line', () => {
const block = buildConnectBlock({ agent: 'codex', name: 'gbrain', url: 'https://h/mcp', token: 'gbrain_$(x)`y`' });
expect(block).toContain("export GBRAIN_REMOTE_TOKEN='gbrain_$(x)`y`'");
});
test('perplexity emits GUI connector steps with URL + token, no CLI command', () => {
const block = buildConnectBlock({ agent: 'perplexity', name: 'gbrain', url: 'https://h/mcp', token: 'TOK' });
expect(block).toMatch(/Settings.+Connectors/);
expect(block).toContain('URL: https://h/mcp');
expect(block).toContain('Token: TOK');
expect(block).not.toContain('mcp add');
expect(block).toContain(LEARN_INSTRUCTION);
// surfaces the v0.34 remote-reachability footgun (serve --bind 0.0.0.0)
expect(block).toContain('--bind 0.0.0.0');
expect(block).toMatch(/docs\/mcp\/PERPLEXITY\.md/);
});
});
describe('buildJson', () => {
test('redacts the token by default; claude has a command', () => {
const j = buildJson({ url: 'https://h/mcp', name: 'gbrain', agent: 'claude-code', token: 'SeKrEt9', showToken: false });
expect(j.token_present).toBe(true);
expect(j.token_redacted).toBe(true);
expect(j.env_var).toBe(ENV_VAR);
expect(typeof j.command).toBe('string');
expect(Array.isArray(j.command_argv)).toBe(true);
expect(JSON.stringify(j)).not.toContain('SeKrEt9');
expect(JSON.stringify(j)).toContain(REDACTED);
});
test('--show-token reveals the literal token', () => {
const j = buildJson({ url: 'https://h/mcp', name: 'gbrain', agent: 'claude-code', token: 'SeKrEt9', showToken: true });
expect(j.token_redacted).toBe(false);
expect(JSON.stringify(j)).toContain('Authorization: Bearer SeKrEt9');
});
test('no token → placeholder, token_present false', () => {
const j = buildJson({ url: 'https://h/mcp', name: 'gbrain', agent: 'claude-code', token: null, showToken: false });
expect(j.token_present).toBe(false);
expect(JSON.stringify(j)).toContain(PLACEHOLDER_TOKEN);
});
test('codex command carries the env-var name, never the token (even with --show-token)', () => {
const j = buildJson({ url: 'https://h/mcp', name: 'gbrain', agent: 'codex', token: 'SeKrEt9', showToken: true });
expect(j.command).toContain('--bearer-token-env-var GBRAIN_REMOTE_TOKEN');
expect(j.command).not.toContain('SeKrEt9'); // token is in the env-var, not the command
expect(j.header).toContain('Authorization: Bearer SeKrEt9'); // header field carries it under --show-token
});
test('perplexity has no runnable command', () => {
const j = buildJson({ url: 'https://h/mcp', name: 'gbrain', agent: 'perplexity', token: 'TOK', showToken: false });
expect(j.command).toBeNull();
expect(j.command_argv).toBeNull();
expect(j.header).toContain('Authorization: Bearer');
});
});
describe('OAuth helpers', () => {
test('issuerFromMcpUrl strips /mcp', () => {
expect(issuerFromMcpUrl('https://brain.example.com:3131/mcp')).toBe('https://brain.example.com:3131');
expect(issuerFromMcpUrl('https://brain.example.com/mcp')).toBe('https://brain.example.com');
});
test('perplexity oauth block: issuer + client id/secret, no bearer header', () => {
const block = buildConnectBlock({
agent: 'perplexity', name: 'gbrain', url: 'https://h/mcp', token: null,
oauth: { issuer: 'https://h', clientId: 'gbrain_cl_x', clientSecret: 'gbrain_cs_y' },
});
expect(block).toMatch(/Settings.+Connectors/);
expect(block).toContain('Issuer URL: https://h');
expect(block).toContain('Client ID: gbrain_cl_x');
expect(block).toContain('Client Secret: gbrain_cs_y');
expect(block).toContain('OAuth 2.1 (client credentials)');
expect(block).not.toContain('Authorization: Bearer');
expect(block).toContain(LEARN_INSTRUCTION);
});
test('generic oauth block emits the OAuth fields', () => {
const block = buildConnectBlock({
agent: 'generic', name: 'gbrain', url: 'https://h/mcp', token: null,
oauth: { issuer: 'https://h', clientId: 'gbrain_cl_x', clientSecret: 'gbrain_cs_y' },
});
expect(block).toContain('Issuer URL: https://h');
expect(block).toContain('Client ID: gbrain_cl_x');
expect(block).toContain('Client Secret: gbrain_cs_y');
});
test('oauth block placeholders a missing secret', () => {
const block = buildConnectBlock({
agent: 'perplexity', name: 'gbrain', url: 'https://h/mcp', token: null,
oauth: { issuer: 'https://h', clientId: 'gbrain_cl_x', clientSecret: null },
});
expect(block).toContain(PLACEHOLDER_SECRET);
});
test('buildJson oauth: redacts the secret by default, exposes issuer + scopes', () => {
const j = buildJson({
url: 'https://h/mcp', name: 'gbrain', agent: 'perplexity', token: null, showToken: false,
oauth: { issuer: 'https://h', clientId: 'gbrain_cl_x', clientSecret: 'SeKrEt9' }, scopes: 'read',
});
expect(j.auth).toBe('oauth');
expect(j.issuer_url).toBe('https://h');
expect(j.client_id).toBe('gbrain_cl_x');
expect(j.client_secret).toBe(REDACTED);
expect(j.secret_redacted).toBe(true);
expect(j.scopes).toBe('read');
expect(j.command).toBeNull();
expect(JSON.stringify(j)).not.toContain('SeKrEt9');
});
test('buildJson oauth --show-token reveals the secret', () => {
const j = buildJson({
url: 'https://h/mcp', name: 'gbrain', agent: 'perplexity', token: null, showToken: true,
oauth: { issuer: 'https://h', clientId: 'gbrain_cl_x', clientSecret: 'SeKrEt9' },
});
expect(j.client_secret).toBe('SeKrEt9');
expect(j.scopes).toBe(DEFAULT_SCOPES);
});
});
// ---------------------------------------------------------------------------
// connect-probe
// ---------------------------------------------------------------------------
describe('classifyProbeError', () => {
test('timeout/abort', () => {
expect(classifyProbeError('timeout after 15000ms')).toBe('timeout');
expect(classifyProbeError('The operation was aborted')).toBe('timeout');
});
test('auth', () => {
expect(classifyProbeError('HTTP 401 Unauthorized')).toBe('auth');
expect(classifyProbeError('403 forbidden')).toBe('auth');
});
test('unreachable', () => {
expect(classifyProbeError('fetch failed')).toBe('unreachable');
expect(classifyProbeError('getaddrinfo ENOTFOUND brain.example.com')).toBe('unreachable');
expect(classifyProbeError('connect ECONNREFUSED 127.0.0.1:3131')).toBe('unreachable');
// MCP SDK / undici friendly wrapper for a refused connection.
expect(classifyProbeError('Unable to connect. Is the computer able to access the url?')).toBe('unreachable');
});
test('unknown fallback', () => {
expect(classifyProbeError('something weird')).toBe('unknown');
});
});
describe('extractResultText', () => {
test('joins text content entries', () => {
expect(extractResultText([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])).toBe('a\nb');
});
test('non-array → empty', () => {
expect(extractResultText(null)).toBe('');
expect(extractResultText({})).toBe('');
});
});
describe('probeBrainIdentity (injected deps)', () => {
test('ok result extracts identity text', async () => {
const deps: ProbeDeps = {
connectAndCall: async () => ({ content: [{ type: 'text', text: 'brain: alice-example' }] }),
};
const r = await probeBrainIdentity('https://h/mcp', 'TOK', { deps });
expect(r).toEqual({ ok: true, identity: 'brain: alice-example' });
});
test('isError with 401 → auth', async () => {
const deps: ProbeDeps = {
connectAndCall: async () => ({ isError: true, content: [{ type: 'text', text: 'HTTP 401' }] }),
};
const r = await probeBrainIdentity('https://h/mcp', 'TOK', { deps });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toBe('auth');
});
test('thrown ENOTFOUND → unreachable', async () => {
const deps: ProbeDeps = {
connectAndCall: async () => { throw new Error('getaddrinfo ENOTFOUND h'); },
};
const r = await probeBrainIdentity('https://h/mcp', 'TOK', { deps });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toBe('unreachable');
});
test('isError with a non-auth message → tool_error', async () => {
const deps: ProbeDeps = {
connectAndCall: async () => ({ isError: true, content: [{ type: 'text', text: 'tool blew up: bad arguments' }] }),
};
const r = await probeBrainIdentity('https://h/mcp', 'TOK', { deps });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toBe('tool_error');
});
test('timeout timer fires → reason timeout (deterministic, no real sleep)', async () => {
const deps: ProbeDeps = {
connectAndCall: (_u, _t, signal) => new Promise((_res, rej) => {
signal.addEventListener('abort', () => rej(new Error('The operation was aborted')));
}),
};
const r = await probeBrainIdentity('https://h/mcp', 'TOK', { timeoutMs: 10, deps });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toBe('timeout');
});
test('a connectAndCall that ignores the abort signal still times out (Promise.race)', async () => {
// Simulates a transport whose connect()/SSE handshake never honors the
// signal — the probe must still resolve via the timeout race, not hang.
const deps: ProbeDeps = {
connectAndCall: () => new Promise(() => { /* never settles, ignores signal */ }),
};
const r = await probeBrainIdentity('https://h/mcp', 'TOK', { timeoutMs: 15, deps });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toBe('timeout');
});
});
// ---------------------------------------------------------------------------
// runConnect orchestrator (install path) — inject deps, stub process.exit
// ---------------------------------------------------------------------------
function captureConsole() {
const out: string[] = [];
const err: string[] = [];
const origLog = console.log;
const origErr = console.error;
console.log = (...a: unknown[]) => { out.push(a.join(' ')); };
console.error = (...a: unknown[]) => { err.push(a.join(' ')); };
return {
out, err,
restore() { console.log = origLog; console.error = origErr; },
};
}
async function runWithExitCapture(args: string[], deps: ConnectDeps): Promise<{ exitCode?: number; out: string[]; err: string[] }> {
const cap = captureConsole();
const origExit = process.exit;
let exitCode: number | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
process.exit = ((c?: number) => { exitCode = c ?? 0; throw new Error('__EXIT__'); }) as any;
try {
await runConnect(args, deps);
} catch (e) {
if ((e as Error).message !== '__EXIT__') { cap.restore(); process.exit = origExit; throw e; }
} finally {
cap.restore();
process.exit = origExit;
}
return { exitCode, out: cap.out, err: cap.err };
}
function installDeps(over: Partial<ConnectDeps> = {}): ConnectDeps {
return {
isTTY: () => false,
promptYesNo: async () => true,
hasBinary: () => true,
runBinary: (_binary, argv) => (argv[1] === 'get' ? { code: 1, stdout: '', stderr: '' } : { code: 0, stdout: '', stderr: '' }),
probe: async () => ({ ok: true, identity: 'brain: alice-example' }),
env: () => undefined, // tests control the env; real GBRAIN_REMOTE_TOKEN must not leak in
registerOAuthClient: () => ({ ok: true, clientId: 'gbrain_cl_minted', clientSecret: 'gbrain_cs_minted' }),
...over,
};
}
describe('runConnect --install', () => {
test('happy path: adds server, verifies, prints learn instruction', async () => {
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--token', 'gbrain_tok', '--install', '--yes'],
installDeps(),
);
expect(r.exitCode).toBeUndefined();
expect(r.err.join('\n')).toMatch(/Added MCP server 'gbrain'/);
expect(r.err.join('\n')).toMatch(/Verified/);
expect(r.err.join('\n')).toContain(LEARN_INSTRUCTION);
});
test('probe failure warns + exits 1 + never echoes the token', async () => {
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--token', 'gbrain_secret', '--install', '--yes'],
installDeps({ probe: async () => ({ ok: false, reason: 'auth', message: 'HTTP 401 for gbrain_secret' }) }),
);
expect(r.exitCode).toBe(1);
const all = [...r.out, ...r.err].join('\n');
expect(all).toMatch(/did not verify \(auth\)/);
expect(all).not.toContain('gbrain_secret');
expect(all).toContain(REDACTED);
});
test('missing claude binary fails fast', async () => {
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--token', 'tok', '--install', '--yes'],
installDeps({ hasBinary: () => false }),
);
expect(r.exitCode).toBe(1);
expect(r.err.join('\n')).toMatch(/not found on PATH/);
});
test('existing server name without --force is refused', async () => {
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--token', 'tok', '--install', '--yes'],
installDeps({ runBinary: () => ({ code: 0, stdout: '', stderr: '' }) }), // get returns 0 → exists
);
expect(r.exitCode).toBe(1);
expect(r.err.join('\n')).toMatch(/already exists/);
});
test('install without a token errors with the auth-create hint', async () => {
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--install', '--yes'],
installDeps(),
);
expect(r.exitCode).toBe(1);
expect(r.err.join('\n')).toMatch(/gbrain auth create/);
});
test('--force replaces an existing server then verifies', async () => {
const calls: string[][] = [];
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--token', 'tok', '--install', '--yes', '--force'],
installDeps({
runBinary: (_b, argv) => { calls.push(argv); return { code: 0, stdout: '', stderr: '' }; }, // get→0 (exists), remove→0, add→0
}),
);
expect(r.exitCode).toBeUndefined();
expect(calls.some((a) => a[1] === 'remove')).toBe(true);
expect(calls.some((a) => a[1] === 'add')).toBe(true);
expect(r.err.join('\n')).toMatch(/Added MCP server/);
});
test('--force remove failure aborts + redacts the token', async () => {
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--token', 'gbrain_secret', '--install', '--yes', '--force'],
installDeps({
runBinary: (_b, argv) => (argv[1] === 'remove'
? { code: 1, stdout: '', stderr: 'remove failed near gbrain_secret' }
: { code: 0, stdout: '', stderr: '' }),
}),
);
expect(r.exitCode).toBe(1);
const all = [...r.out, ...r.err].join('\n');
expect(all).toMatch(/Could not replace/);
expect(all).not.toContain('gbrain_secret');
});
test('claude mcp add failure aborts + redacts the token', async () => {
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--token', 'gbrain_secret', '--install', '--yes'],
installDeps({
runBinary: (_b, argv) => (argv[1] === 'add'
? { code: 1, stdout: '', stderr: 'add blew up with gbrain_secret' }
: { code: 1, stdout: '', stderr: '' }), // get→1 (not exists)
}),
);
expect(r.exitCode).toBe(1);
const all = [...r.out, ...r.err].join('\n');
expect(all).toMatch(/'claude mcp add' failed/);
expect(all).not.toContain('gbrain_secret');
});
test('TTY prompt decline aborts without adding', async () => {
const calls: string[][] = [];
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--token', 'tok', '--install'], // no --yes, TTY on
installDeps({
isTTY: () => true,
promptYesNo: async () => false,
runBinary: (_b, argv) => { calls.push(argv); return { code: argv[1] === 'get' ? 1 : 0, stdout: '', stderr: '' }; },
}),
);
expect(r.exitCode).toBe(1);
expect(r.err.join('\n')).toMatch(/Aborted/);
expect(calls.some((a) => a[1] === 'add')).toBe(false);
});
test('--install with --agent generic is rejected', async () => {
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--token', 'tok', '--install', '--yes', '--agent', 'generic'],
installDeps(),
);
expect(r.exitCode).toBe(1);
expect(r.err.join('\n')).toMatch(/--install supports claude-code and codex/);
});
test('--install with --agent perplexity is rejected (GUI connector)', async () => {
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--token', 'tok', '--install', '--yes', '--agent', 'perplexity'],
installDeps(),
);
expect(r.exitCode).toBe(1);
expect(r.err.join('\n')).toMatch(/Perplexity Computer is set up through its own UI/);
});
test('--agent codex --install runs the codex CLI and hints the env var', async () => {
const calls: Array<{ binary: string; argv: string[] }> = [];
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--token', 'gbrain_tok', '--install', '--yes', '--agent', 'codex'],
installDeps({
runBinary: (binary, argv) => { calls.push({ binary, argv }); return { code: argv[1] === 'get' ? 1 : 0, stdout: '', stderr: '' }; },
env: () => undefined, // GBRAIN_REMOTE_TOKEN not set → expect the export hint
}),
);
expect(r.exitCode).toBeUndefined();
// Uses the codex binary with the env-var bearer form (no token in argv).
const add = calls.find((c) => c.argv[1] === 'add');
expect(add?.binary).toBe('codex');
expect(add?.argv).toEqual(['mcp', 'add', 'gbrain', '--url', 'https://brain.example.com/mcp', '--bearer-token-env-var', 'GBRAIN_REMOTE_TOKEN']);
expect(JSON.stringify(add?.argv)).not.toContain('gbrain_tok');
expect(r.err.join('\n')).toMatch(/export GBRAIN_REMOTE_TOKEN/);
expect(r.err.join('\n')).toMatch(/Verified/);
});
test('--agent codex --install skips the env hint when GBRAIN_REMOTE_TOKEN already matches', async () => {
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--token', 'gbrain_tok', '--install', '--yes', '--agent', 'codex'],
installDeps({ env: (n) => (n === 'GBRAIN_REMOTE_TOKEN' ? 'gbrain_tok' : undefined) }),
);
expect(r.exitCode).toBeUndefined();
expect(r.err.join('\n')).not.toMatch(/Add this to your shell profile/);
});
test('non-interactive --install without --yes is refused', async () => {
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--token', 'tok', '--install'], // isTTY false (default), no --yes
installDeps(),
);
expect(r.exitCode).toBe(1);
expect(r.err.join('\n')).toMatch(/requires --yes/);
});
test('a flag-shaped --token value is rejected (no silent swallow)', async () => {
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--token', '--install'],
installDeps(),
);
expect(r.exitCode).toBe(1);
expect(r.err.join('\n')).toMatch(/--token requires a value/);
});
});
describe('runConnect print mode', () => {
test('prints the block to stdout with the literal token', async () => {
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--token', 'gbrain_tok'],
installDeps(),
);
expect(r.exitCode).toBeUndefined();
expect(r.out.join('\n')).toContain("claude mcp add gbrain -t http https://brain.example.com/mcp -H 'Authorization: Bearer gbrain_tok'");
});
test('--json redacts the token', async () => {
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--token', 'gbrain_secret', '--json'],
installDeps(),
);
const j = JSON.parse(r.out.join('\n'));
expect(j.token_redacted).toBe(true);
expect(r.out.join('\n')).not.toContain('gbrain_secret');
});
test('--help prints command-specific HELP, no exit', async () => {
const r = await runWithExitCapture(['--help'], installDeps());
expect(r.exitCode).toBeUndefined();
expect(r.out.join('\n')).toMatch(/gbrain connect/);
});
test('unknown --agent fails fast', async () => {
const r = await runWithExitCapture(['https://h/mcp', '--token', 't', '--agent', 'bogus'], installDeps());
expect(r.exitCode).toBe(1);
expect(r.err.join('\n')).toMatch(/Unknown --agent/);
});
test('invalid --name fails fast', async () => {
const r = await runWithExitCapture(['https://h/mcp', '--token', 't', '--name', 'Bad Name'], installDeps());
expect(r.exitCode).toBe(1);
expect(r.err.join('\n')).toMatch(/Invalid --name/);
});
test('bad URL exits 1 via the orchestrator', async () => {
const r = await runWithExitCapture(['brain.example.com:3131', '--token', 't'], installDeps());
expect(r.exitCode).toBe(1);
});
test('http non-local prints the plaintext-token warning but still proceeds', async () => {
const r = await runWithExitCapture(['http://brain.example.com/mcp', '--token', 't'], installDeps());
expect(r.exitCode).toBeUndefined();
expect(r.err.join('\n')).toMatch(/unencrypted/i);
});
test('invalid --timeout-ms falls back to the default (probe receives it)', async () => {
let seen = -1;
await runWithExitCapture(
['https://h/mcp', '--token', 't', '--install', '--yes', '--timeout-ms', 'abc'],
installDeps({ probe: async (_u, _t, ms) => { seen = ms; return { ok: true, identity: 'ok' }; } }),
);
expect(seen).toBe(15000);
});
test('--agent codex print mode emits the codex block', async () => {
const r = await runWithExitCapture(['https://brain.example.com/mcp', '--token', 'gbrain_tok', '--agent', 'codex'], installDeps());
expect(r.exitCode).toBeUndefined();
const out = r.out.join('\n');
expect(out).toContain('codex mcp add gbrain --url https://brain.example.com/mcp --bearer-token-env-var GBRAIN_REMOTE_TOKEN');
expect(out).toContain('export GBRAIN_REMOTE_TOKEN=gbrain_tok');
});
test('--agent perplexity print mode emits GUI connector steps', async () => {
const r = await runWithExitCapture(['https://brain.example.com/mcp', '--token', 'gbrain_tok', '--agent', 'perplexity'], installDeps());
expect(r.exitCode).toBeUndefined();
expect(r.out.join('\n')).toMatch(/Settings.+Connectors/);
});
});
describe('AGENT_IDS', () => {
test('exposes the four supported agents', () => {
expect(AGENT_IDS).toEqual(['claude-code', 'codex', 'perplexity', 'generic']);
});
});
describe('LEARN_INSTRUCTION names only real MCP tools', () => {
// The self-orientation block is pasted into a connected agent verbatim. Every
// tool it names MUST be MCP-exposed, or the agent calls an "unknown tool".
// The exposed set is pinned end-to-end by test/e2e/serve-stdio-roundtrip.ts.
test('names put_page (the real MCP write tool), not capture (CLI-only)', () => {
expect(LEARN_INSTRUCTION).toContain('put_page');
// `capture` is a CLI-only convenience wrapper, not an MCP tool — naming it
// here told connected agents to call a tool the server does not expose.
expect(LEARN_INSTRUCTION).not.toContain('capture');
});
test('still steers the agent to get_brain_identity + list_skills + brain-first search', () => {
expect(LEARN_INSTRUCTION).toContain('get_brain_identity');
expect(LEARN_INSTRUCTION).toContain('list_skills');
expect(LEARN_INSTRUCTION.toLowerCase()).toContain('search the brain before');
});
});
describe('runConnect --oauth', () => {
test('perplexity --oauth with BYO client id/secret prints the OAuth connector block', async () => {
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--agent', 'perplexity', '--oauth', '--client-id', 'gbrain_cl_x', '--client-secret', 'gbrain_cs_y'],
installDeps(),
);
expect(r.exitCode).toBeUndefined();
const out = r.out.join('\n');
expect(out).toContain('Issuer URL: https://brain.example.com');
expect(out).toContain('Client ID: gbrain_cl_x');
expect(out).toContain('Client Secret: gbrain_cs_y');
});
test('perplexity --oauth --register mints a client via the host and prints it', async () => {
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--agent', 'perplexity', '--oauth', '--register'],
installDeps(), // registerOAuthClient → gbrain_cl_minted / gbrain_cs_minted
);
expect(r.exitCode).toBeUndefined();
const out = r.out.join('\n');
expect(out).toContain('Client ID: gbrain_cl_minted');
expect(out).toContain('Client Secret: gbrain_cs_minted');
});
test('perplexity --oauth --register --json redacts the secret by default', async () => {
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--agent', 'perplexity', '--oauth', '--register', '--json'],
installDeps({ registerOAuthClient: () => ({ ok: true, clientId: 'gbrain_cl_x', clientSecret: 'gbrain_cs_secret' }) }),
);
const j = JSON.parse(r.out.join('\n'));
expect(j.auth).toBe('oauth');
expect(j.client_secret).toBe(REDACTED);
expect(r.out.join('\n')).not.toContain('gbrain_cs_secret');
});
test('--oauth without creds or --register fails with guidance', async () => {
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--agent', 'perplexity', '--oauth'],
installDeps(),
);
expect(r.exitCode).toBe(1);
expect(r.err.join('\n')).toMatch(/--register/);
expect(r.err.join('\n')).toMatch(/--client-id/);
});
test('--oauth with only --client-id fails (needs both)', async () => {
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--agent', 'perplexity', '--oauth', '--client-id', 'gbrain_cl_x'],
installDeps(),
);
expect(r.exitCode).toBe(1);
expect(r.err.join('\n')).toMatch(/BOTH --client-id and --client-secret/);
});
test('register failure surfaces the manual register-client command', async () => {
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--agent', 'perplexity', '--oauth', '--register'],
installDeps({ registerOAuthClient: () => ({ ok: false, message: 'No database connection' }) }),
);
expect(r.exitCode).toBe(1);
expect(r.err.join('\n')).toMatch(/gbrain auth register-client/);
});
test('--oauth is rejected for claude-code (uses bearer)', async () => {
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--agent', 'claude-code', '--oauth', '--register'],
installDeps(),
);
expect(r.exitCode).toBe(1);
expect(r.err.join('\n')).toMatch(/connector-style/);
});
test('--oauth is rejected for codex (uses bearer)', async () => {
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--agent', 'codex', '--oauth', '--register'],
installDeps(),
);
expect(r.exitCode).toBe(1);
expect(r.err.join('\n')).toMatch(/connector-style/);
});
test('--oauth + --install is rejected', async () => {
const r = await runWithExitCapture(
['https://brain.example.com/mcp', '--agent', 'perplexity', '--oauth', '--register', '--install', '--yes'],
installDeps(),
);
expect(r.exitCode).toBe(1);
expect(r.err.join('\n')).toMatch(/--install is not supported with --oauth/);
});
});

View File

@@ -0,0 +1,207 @@
/**
* E2E for `gbrain connect`'s D4 raw-bearer smoke probe (connect-probe.ts).
*
* Spins up a real `gbrain serve --http` against a hermetic PGLite brain (no
* Postgres / Docker), mints a legacy bearer token via `gbrain auth create`,
* then drives the real MCP SDK probe against `/mcp`:
* - real token → ok, returns get_brain_identity payload
* - wrong token → not ok, reason 'auth'
* - unreachable → not ok, reason 'unreachable' | 'timeout'
*
* This is the integration coverage the unit tests (injected deps) can't give:
* the actual StreamableHTTP initialize handshake + tools/call over bearer auth.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { spawn, spawnSync, execFileSync, type ChildProcess } from 'child_process';
import { mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { probeBrainIdentity } from '../../src/core/connect-probe.ts';
import { discoverOAuth, mintClientCredentialsToken } from '../../src/core/remote-mcp-probe.ts';
const PORT = 19735; // avoid the production 3131 + the oauth E2E's 19131
const BASE = `http://127.0.0.1:${PORT}`;
const MCP_URL = `${BASE}/mcp`;
describe('connect bearer probe E2E (PGLite + real serve --http)', () => {
let home: string;
let server: ChildProcess | null = null;
let token = '';
let oauthClientId = '';
let oauthClientSecret = '';
let serverReady = false;
beforeAll(async () => {
home = mkdtempSync(join(tmpdir(), 'gbrain-connect-e2e-'));
const env = { ...process.env, GBRAIN_HOME: home };
execFileSync('bun', ['run', 'src/cli.ts', 'init', '--pglite', '--no-embedding', '--non-interactive'], {
cwd: process.cwd(), env, stdio: 'ignore',
});
const authOut = execFileSync('bun', ['run', 'src/cli.ts', 'auth', 'create', 'e2e-connect'], {
cwd: process.cwd(), env, encoding: 'utf8',
});
token = (authOut.match(/gbrain_[a-f0-9]{64}/) ?? [''])[0];
if (!token) throw new Error(`auth create did not yield a token:\n${authOut}`);
// Register the OAuth client BEFORE spawning serve — PGLite is single-writer,
// so register-client can't open the brain once the server holds it.
const regOut = execFileSync('bun', [
'run', 'src/cli.ts', 'auth', 'register-client', 'e2e-perplexity-oauth',
'--grant-types', 'client_credentials', '--scopes', 'read write',
'--token-endpoint-auth-method', 'client_secret_post',
], { cwd: process.cwd(), env, encoding: 'utf8' });
oauthClientId = (regOut.match(/Client ID:\s+(\S+)/) ?? ['', ''])[1];
oauthClientSecret = (regOut.match(/Client Secret:\s+(\S+)/) ?? ['', ''])[1];
if (!oauthClientId || !oauthClientSecret) throw new Error(`register-client did not yield creds:\n${regOut}`);
server = spawn('bun', [
'run', 'src/cli.ts', 'serve', '--http',
'--bind', '127.0.0.1', '--port', String(PORT),
'--public-url', BASE,
], { cwd: process.cwd(), env, stdio: ['ignore', 'pipe', 'pipe'] });
let serr = '';
server.stderr?.on('data', (d: Buffer) => { serr += d.toString(); });
for (let i = 0; i < 60; i++) {
try {
const res = await fetch(`${BASE}/health`);
if (res.ok) { serverReady = true; break; }
} catch { /* not up yet */ }
await new Promise((r) => setTimeout(r, 500));
}
if (!serverReady) throw new Error(`serve --http did not become ready:\n${serr}`);
}, 60_000);
afterAll(() => {
if (server) { try { server.kill('SIGTERM'); } catch { /* best-effort */ } }
if (home) { try { rmSync(home, { recursive: true, force: true }); } catch { /* best-effort */ } }
});
test('real bearer token round-trips get_brain_identity', async () => {
expect(serverReady).toBe(true);
const r = await probeBrainIdentity(MCP_URL, token, { timeoutMs: 15_000 });
expect(r.ok).toBe(true);
if (r.ok) {
// get_brain_identity returns the version/engine counter packet.
expect(r.identity).toMatch(/version/);
expect(r.identity).toMatch(/pglite/);
}
}, 30_000);
test('wrong token classifies as auth', async () => {
expect(serverReady).toBe(true);
const r = await probeBrainIdentity(MCP_URL, 'gbrain_deadbeef', { timeoutMs: 15_000 });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toBe('auth');
}, 30_000);
test('unreachable host classifies as unreachable or timeout', async () => {
// 127.0.0.1:1 is reserved/closed — connection refused or fast timeout.
const r = await probeBrainIdentity('http://127.0.0.1:1/mcp', token, { timeoutMs: 4_000 });
expect(r.ok).toBe(false);
if (!r.ok) expect(['unreachable', 'timeout']).toContain(r.reason);
}, 15_000);
// -------------------------------------------------------------------------
// Real-CLI coverage: drive the actual `claude` / `codex` binaries through
// `gbrain connect --install` against the live server. Sandboxed via HOME /
// CODEX_HOME so the dev machine's real agent config is untouched. Skips
// gracefully when a binary isn't on PATH (e.g. CI without the CLIs).
// -------------------------------------------------------------------------
const hasBin = (b: string): boolean => {
try { execFileSync(b, ['--version'], { stdio: 'ignore' }); return true; } catch { return false; }
};
const HAS_CLAUDE = hasBin('claude');
const HAS_CODEX = hasBin('codex');
// Run `gbrain connect <args>` as a subprocess with extra env (HOME/CODEX_HOME
// sandbox + GBRAIN_REMOTE_TOKEN). spawnSync captures stderr too — connect's
// "Verified" / "Added" lines go to stderr.
const runConnectCli = (args: string[], extraEnv: Record<string, string>): { code: number; out: string } => {
const r = spawnSync('bun', ['run', 'src/cli.ts', 'connect', ...args], {
cwd: process.cwd(),
encoding: 'utf8',
env: { ...process.env, GBRAIN_HOME: home, ...extraEnv },
});
return { code: r.status ?? 1, out: `${r.stdout ?? ''}\n${r.stderr ?? ''}` };
};
(HAS_CLAUDE ? test : test.skip)('claude-code --install registers + connects against the live server', () => {
expect(serverReady).toBe(true);
const claudeHome = mkdtempSync(join(tmpdir(), 'gb-claude-'));
try {
const r = runConnectCli([MCP_URL, '--token', token, '--install', '--yes'], { HOME: claudeHome });
expect(r.code).toBe(0);
expect(r.out).toMatch(/Verified/);
// The real `claude` CLI actually registered the server.
const got = spawnSync('claude', ['mcp', 'get', 'gbrain'], { encoding: 'utf8', env: { ...process.env, HOME: claudeHome } });
expect(got.status).toBe(0);
expect(`${got.stdout ?? ''}${got.stderr ?? ''}`).toContain(`:${PORT}/mcp`);
} finally {
try { spawnSync('claude', ['mcp', 'remove', 'gbrain'], { env: { ...process.env, HOME: claudeHome } }); } catch { /* best-effort */ }
rmSync(claudeHome, { recursive: true, force: true });
}
}, 60_000);
(HAS_CODEX ? test : test.skip)('codex --install registers the env-var bearer against the live server', () => {
expect(serverReady).toBe(true);
const codexHome = mkdtempSync(join(tmpdir(), 'gb-codex-'));
try {
const r = runConnectCli([MCP_URL, '--token', token, '--agent', 'codex', '--install', '--yes'], { CODEX_HOME: codexHome, GBRAIN_REMOTE_TOKEN: token });
expect(r.code).toBe(0);
expect(r.out).toMatch(/Verified/);
// The real `codex` CLI registered the streamable-http server with the
// env-var bearer — and the token never lands in Codex config.
const got = spawnSync('codex', ['mcp', 'get', 'gbrain'], { encoding: 'utf8', env: { ...process.env, CODEX_HOME: codexHome } });
expect(got.status).toBe(0);
const text = `${got.stdout ?? ''}${got.stderr ?? ''}`;
expect(text).toContain('GBRAIN_REMOTE_TOKEN');
expect(text).not.toContain(token);
expect(text).toContain(`:${PORT}/mcp`);
} finally {
rmSync(codexHome, { recursive: true, force: true });
}
}, 60_000);
// Perplexity Computer is a GUI connector (Settings → Connectors, Pro account):
// there is no CLI to wire E2E. We can only assert `connect` prints the exact
// values the user pastes into the GUI.
test('perplexity print mode yields the GUI connector values (no CLI to wire E2E)', () => {
expect(serverReady).toBe(true);
const r = runConnectCli([MCP_URL, '--token', token, '--agent', 'perplexity'], {});
expect(r.code).toBe(0);
expect(r.out).toContain(`:${PORT}/mcp`);
expect(r.out).toContain(token); // print mode shows the token to paste into the connector
expect(r.out).toMatch(/Settings.+Connectors/);
}, 30_000);
// The OAuth path Perplexity actually uses, proven end-to-end against the live
// server: register a client → connect --oauth formats it → mint a real
// client-credentials access token via OAuth discovery + /token → call
// get_brain_identity with that token. This exercises the whole chain a
// Perplexity OAuth connector walks.
test('perplexity OAuth: connect --oauth → mint client-credentials token → tool call', async () => {
expect(serverReady).toBe(true);
// The OAuth client was registered in beforeAll (before serve took the
// PGLite write lock). Here: format it, then walk the connector's flow.
// 1. `connect --oauth` formats the connector block with the right issuer.
const conn = runConnectCli([MCP_URL, '--agent', 'perplexity', '--oauth', '--client-id', oauthClientId, '--client-secret', oauthClientSecret], {});
expect(conn.code).toBe(0);
expect(conn.out).toContain(`Issuer URL: ${BASE}`);
expect(conn.out).toContain(`Client ID: ${oauthClientId}`);
// 2. Mint a real access token the way a connector does, then call a tool.
const disco = await discoverOAuth(BASE, { timeoutMs: 10_000 });
expect(disco.ok).toBe(true);
if (!disco.ok) return;
const minted = await mintClientCredentialsToken(disco.metadata.token_endpoint, oauthClientId, oauthClientSecret, { scope: 'read write', timeoutMs: 10_000 });
expect(minted.ok).toBe(true);
if (!minted.ok) return;
const probed = await probeBrainIdentity(MCP_URL, minted.token.access_token, { timeoutMs: 15_000 });
expect(probed.ok).toBe(true);
if (probed.ok) expect(probed.identity).toMatch(/version/);
}, 60_000);
});

View File

@@ -0,0 +1,117 @@
/**
* E2E for the "standalone from nothing" funnel: `gbrain init --pglite` →
* `gbrain serve` (stdio) wired into a coding agent as an MCP subprocess.
*
* This is the canonical local path the docs encourage for Claude Code / Codex
* users with no remote brain:
*
* claude mcp add gbrain -- gbrain serve
* codex mcp add gbrain -- gbrain serve
*
* The `connect`/bearer E2E proves the REMOTE (HTTP) funnel. Nothing proved the
* LOCAL stdio funnel end-to-end: that a freshly-init'd PGLite brain, served
* over stdio, actually answers real MCP `tools/call`s through the official MCP
* SDK client (the same handshake Claude Code / Codex perform). The
* serve-stdio-lifecycle unit test only covers shutdown signalling.
*
* No Postgres / Docker. PGLite, hermetic temp HOME. Drives the real
* StdioClientTransport, so the MCP SDK spawns `gbrain serve` for us, runs the
* `initialize` handshake, and round-trips `tools/list` + `tools/call`.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { execFileSync } from 'child_process';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
// Distinctive token so keyword search can't accidentally match anything else.
const MARKER = 'qantani-marker-9f3z';
function textOf(result: unknown): string {
const content = (result as { content?: Array<{ type?: string; text?: string }> })?.content;
if (!Array.isArray(content)) return '';
return content.map((c) => (typeof c?.text === 'string' ? c.text : '')).join('\n');
}
describe('serve stdio round-trip E2E (local PGLite → real MCP tool calls)', () => {
let home: string;
let client: Client | null = null;
let transport: StdioClientTransport | null = null;
let connected = false;
beforeAll(async () => {
home = mkdtempSync(join(tmpdir(), 'gbrain-stdio-e2e-'));
const env = { ...process.env, GBRAIN_HOME: home };
// 1. Init a local PGLite brain (the "from nothing" step).
execFileSync('bun', ['run', 'src/cli.ts', 'init', '--pglite', '--no-embedding', '--non-interactive'], {
cwd: process.cwd(), env, stdio: 'ignore',
});
// 2. Seed one page so page_count > 0 and search has something to find.
// --no-embed keeps it hermetic (no embedding provider configured); the
// keyword path still finds the distinctive marker.
const notes = join(home, 'notes');
mkdirSync(notes, { recursive: true });
writeFileSync(
join(notes, 'marker.md'),
`---\ntitle: ${MARKER} note\n---\n\n# ${MARKER}\n\nThis page exists to prove ${MARKER} is retrievable over stdio MCP.\n`,
);
execFileSync('bun', ['run', 'src/cli.ts', 'import', notes, '--no-embed'], {
cwd: process.cwd(), env, stdio: 'ignore',
});
// 3. Let the MCP SDK spawn `gbrain serve` (stdio) and run the initialize
// handshake — exactly what `claude mcp add gbrain -- gbrain serve` does.
transport = new StdioClientTransport({
command: 'bun',
args: ['run', 'src/cli.ts', 'serve'],
cwd: process.cwd(),
env, // includes PATH (to find `bun`) + GBRAIN_HOME
});
client = new Client({ name: 'gbrain-stdio-e2e', version: '1.0.0' }, { capabilities: {} });
await client.connect(transport);
connected = true;
}, 60_000);
afterAll(async () => {
if (client) { try { await client.close(); } catch { /* best-effort */ } }
if (transport) { try { await transport.close(); } catch { /* best-effort */ } }
if (home) { try { rmSync(home, { recursive: true, force: true }); } catch { /* best-effort */ } }
});
test('initialize handshake + tools/list exposes the core retrieval tools', async () => {
expect(connected).toBe(true);
const { tools } = await client!.listTools();
const names = new Set(tools.map((t) => t.name));
// The core MCP tools the connect LEARN_INSTRUCTION promises always work.
// `capture` is deliberately NOT here — it's a CLI-only wrapper, not an MCP
// tool; the agent writes via put_page (regression guard for the stale
// LEARN_INSTRUCTION that named capture as an MCP tool).
for (const core of ['search', 'query', 'get_page', 'put_page', 'get_brain_identity', 'think', 'find_experts']) {
expect(names.has(core)).toBe(true);
}
expect(names.has('capture')).toBe(false); // CLI-only, must not be advertised as MCP
}, 30_000);
test('tools/call get_brain_identity returns version + engine + a populated counter', async () => {
expect(connected).toBe(true);
const res = await client!.callTool({ name: 'get_brain_identity', arguments: {} });
const text = textOf(res);
const id = JSON.parse(text) as { version: string; engine: string; page_count: number };
expect(typeof id.version).toBe('string');
expect(id.engine).toBe('pglite');
expect(id.page_count).toBeGreaterThanOrEqual(1); // the seeded page
}, 30_000);
test('tools/call search surfaces the seeded page (keyword path, no embeddings)', async () => {
expect(connected).toBe(true);
const res = await client!.callTool({ name: 'search', arguments: { query: MARKER, limit: 5 } });
const text = textOf(res);
// The result payload (slug / title / snippet) must mention the marker.
expect(text).toContain(MARKER);
}, 30_000);
});

View File

@@ -0,0 +1,48 @@
/**
* Unit coverage for the `gbrain serve --http` skill-publishing banner + nudge
* (`skillPublishStatus`). When skill publishing is OFF, a connected coding
* agent (Codex / Claude Code / Perplexity) can't call list_skills / get_skill,
* so the host's skill catalog is invisible to it. The operator should learn
* this at serve startup, not from an empty list on the agent side — which is
* the exact friction this nudge closes for the "add my coding agent to my
* existing brain" funnel.
*/
import { describe, test, expect } from 'bun:test';
import { skillPublishStatus } from '../src/commands/serve-http.ts';
describe('skillPublishStatus', () => {
test('publishing ON: banner says published, no nudge', () => {
const s = skillPublishStatus(true);
expect(s.bannerValue).toBe('published');
expect(s.nudge).toBeNull();
});
test('publishing OFF: banner says not published', () => {
const s = skillPublishStatus(false);
expect(s.bannerValue).toBe('not published');
expect(s.nudge).not.toBeNull();
});
test('OFF nudge carries the paste-ready fix command', () => {
const s = skillPublishStatus(false);
expect(s.nudge).toContain('gbrain config set mcp.publish_skills true');
});
test('OFF nudge names the affected tools so the operator understands the blast radius', () => {
const s = skillPublishStatus(false);
expect(s.nudge).toContain('list_skills');
expect(s.nudge).toContain('get_skill');
});
test('OFF nudge reassures that core tools still work (so operators do not over-react)', () => {
const s = skillPublishStatus(false);
expect(s.nudge!.toLowerCase()).toContain('core tools');
});
test('banner value fits the fixed-width startup box (≤ 40 chars after padEnd)', () => {
// The banner pads each value with .padEnd(40); a longer raw value would
// blow out the ASCII box. Guard the contract here.
expect(skillPublishStatus(true).bannerValue.length).toBeLessThanOrEqual(40);
expect(skillPublishStatus(false).bannerValue.length).toBeLessThanOrEqual(40);
});
});