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:
150
CHANGELOG.md
150
CHANGELOG.md
@@ -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.**
|
||||
|
||||
31
README.md
31
README.md
@@ -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:
|
||||
|
||||
22
TODOS.md
22
TODOS.md
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
71
docs/mcp/CODEX.md
Normal 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`.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
235
docs/tutorials/connect-coding-agent.md
Normal file
235
docs/tutorials/connect-coding-agent.md
Normal 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
@@ -141,5 +141,5 @@
|
||||
"bun": ">=1.3.10"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "0.42.1.0"
|
||||
"version": "0.42.2.0"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
15
src/cli.ts
15
src/cli.ts
@@ -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)
|
||||
|
||||
@@ -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
766
src/commands/connect.ts
Normal 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);
|
||||
}
|
||||
@@ -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 brain’s 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
146
src/core/connect-probe.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
38
test/auth-create-args.test.ts
Normal file
38
test/auth-create-args.test.ts
Normal 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
861
test/connect.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
207
test/e2e/connect-bearer.test.ts
Normal file
207
test/e2e/connect-bearer.test.ts
Normal 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);
|
||||
});
|
||||
117
test/e2e/serve-stdio-roundtrip.test.ts
Normal file
117
test/e2e/serve-stdio-roundtrip.test.ts
Normal 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);
|
||||
});
|
||||
48
test/serve-skills-publish-nudge.test.ts
Normal file
48
test/serve-skills-publish-nudge.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user