Files
gbrain/scripts/smoke-test-mcp.ts
Garry Tan 3e21e9b69b feat: GBrain v0.6.0 — Remote MCP Server + 12 Bug Fixes (#28)
* fix: 7 bug fixes from Issue #9 and #22

- fix(mcp): use ListToolsRequestSchema/CallToolRequestSchema instead of string literals (Issue #9, PR #25)
- fix(mcp): handleToolCall reads dry_run from params instead of hardcoding false (#22 Bug #11)
- fix(search): keyword search returns best chunk per page via DISTINCT ON, not all chunks (#22 Bug #8)
- fix(search): dedup layer 1 keeps top 3 chunks per page instead of collapsing to 1 (#22 Bug #12)
- fix(engine): transaction uses scoped engine via Object.create, no shared state mutation (#22 Bug #2)
- fix(engine): upsertChunks uses UPSERT instead of DELETE+INSERT, preserves existing embeddings (#22 Bug #1)
- fix(slugs): validateSlug normalizes to lowercase, pathToSlug lowercases consistently (#22 Bug #4)
- schema: add unique index on content_chunks(page_id, chunk_index) for UPSERT support
- schema: add access_tokens and mcp_request_log tables via migration

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

* fix: embed schema.sql at build time, remove fs dependency from initSchema

initSchema() previously read schema.sql from disk at runtime via readFileSync,
which broke in compiled Bun binaries and Deno Edge Functions. Now uses a
generated schema-embedded.ts constant (run `bun run build:schema` to regenerate).

- Removes fs and path imports from postgres-engine.ts and db.ts
- Adds scripts/build-schema.sh for one-source-of-truth generation
- Adds build:schema npm script

Fixes Issue #22 Bug #6.

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

* fix: 5 more bug fixes from Issue #22

- fix(file_upload): call storage.upload() in all 3 paths (operation, CLI upload, CLI sync) with rollback semantics (#22 Bug #9)
- fix(import): use atomic index counter for parallel queue instead of array.shift() race, preserve checkpoint on errors (#22 Bug #3)
- fix(s3): replace unsigned fetch with @aws-sdk/client-s3 for proper SigV4 auth, supports R2/MinIO via forcePathStyle (#22 Bug #10)
- fix(redirect): verify remote file exists before deleting local copy, skip files not found in storage (#22 Bug #5)
- deps: add @aws-sdk/client-s3

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

* feat: remote MCP server via Supabase Edge Functions

Deploy GBrain as a serverless remote MCP endpoint on your existing Supabase
instance. One brain, accessible from Claude Desktop, Claude Code, Cowork,
Perplexity Computer, and any MCP client. Zero new infrastructure.

New files:
- supabase/functions/gbrain-mcp/index.ts — Edge Function with Hono + MCP SDK
- supabase/functions/gbrain-mcp/deno.json — Deno import map
- src/edge-entry.ts — curated bundle entry point (excludes fs-dependent modules)
- src/commands/auth.ts — standalone token management (create/list/revoke/test)
- scripts/deploy-remote.sh — one-script deployment
- .env.production.example — 3-value config template

Changes:
- config.ts: lazy-evaluate CONFIG_DIR (no homedir() at module scope)
- schema.sql: add access_tokens + mcp_request_log tables
- package.json: add build:edge script

Auth: bearer tokens via access_tokens table (SHA-256 hashed, per-client, revocable)
Transport: WebStandardStreamableHTTPServerTransport (stateless, Streamable HTTP)
Health: /health endpoint (unauth: 200/503, auth: postgres/pgvector/openai checks)
Excluded from remote: sync_brain, file_upload (may exceed 60s timeout)

Setup: clone, fill .env.production, run scripts/deploy-remote.sh, create token, done.

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

* docs: per-client MCP setup guides

- docs/mcp/DEPLOY.md — deployment walkthrough, auth, troubleshooting, latency table
- docs/mcp/CLAUDE_CODE.md — claude mcp add command
- docs/mcp/CLAUDE_DESKTOP.md — Settings > Integrations (NOT JSON config!)
- docs/mcp/CLAUDE_COWORK.md — remote + local bridge paths
- docs/mcp/PERPLEXITY.md — Perplexity Computer connector setup
- docs/mcp/CHATGPT.md — coming soon (requires OAuth 2.1, P0 TODO)
- docs/mcp/ALTERNATIVES.md — Tailscale Funnel + ngrok self-hosted options

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

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

GBrain v0.6.0: Remote MCP server via Supabase Edge Functions + 12 bug fixes.

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

* docs: add Remote MCP Server section to README

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

* docs: make document-release mandatory in CLAUDE.md, add MCP key files

Post-ship requirements section: document-release is NOT optional. Lists every
file that must be checked on every ship. A ship without updated docs is incomplete.

Also adds remote MCP server files to Key files section.

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

* fix: batch upsertChunks into single statement to prevent deadlocks

The per-chunk UPSERT loop caused deadlocks under parallel workers because
each INSERT ON CONFLICT acquired row-level locks sequentially. Multiple
workers upserting different pages could deadlock on the shared unique index.

Fix: batch all chunks into a single multi-row INSERT ON CONFLICT statement.
One round-trip, one lock acquisition. COALESCE preserves existing embeddings
when the new value is NULL.

Fixes CI failure: "E2E: Parallel Import > parallel import with --workers 4"

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

* fix: advisory lock in initSchema() prevents deadlock on concurrent DDL

When multiple processes call initSchema() concurrently (e.g., test setup +
CLI subprocess, or parallel workers during E2E tests), the schema SQL's
DROP TRIGGER + CREATE TRIGGER statements acquire AccessExclusiveLock on
different tables, causing deadlocks.

Fix: pg_advisory_lock(42) serializes all initSchema() calls within the
same database. The lock is session-scoped and released in a finally block.

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

* fix: add explicit test timeouts for CLI subprocess E2E tests

CLI subprocess tests (Setup Journey, Doctor Command, Parallel Import)
spawn `bun run src/cli.ts` which takes several seconds to JIT compile +
connect. The Bun test framework default 5000ms per-test timeout is too
tight for CI. Added 30-60s timeouts matching each subprocess's own
timeout to prevent false failures.

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

* fix: infinite recursion in config.ts exported getConfigDir/getConfigPath

The replace_all refactor created recursive functions: the exported
getConfigDir() called the private getConfigDir() which called itself.
Renamed exports to configDir()/configPath() to avoid shadowing.

Also adds scripts/smoke-test-mcp.ts — verified all 8 MCP tool calls
work against a real Postgres database.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:23:00 -10:00

97 lines
3.4 KiB
TypeScript

#!/usr/bin/env bun
/**
* Smoke test: verify MCP tool calls work against a real database.
* Usage: DATABASE_URL=... bun run scripts/smoke-test-mcp.ts
*/
import { PostgresEngine } from '../src/core/postgres-engine.ts';
import { handleToolCall } from '../src/mcp/server.ts';
const DB_URL = process.env.DATABASE_URL;
if (!DB_URL) { console.error('Set DATABASE_URL'); process.exit(1); }
const eng = new PostgresEngine();
await eng.connect({ database_url: DB_URL });
let passed = 0;
let failed = 0;
async function test(name: string, fn: () => Promise<void>) {
try {
await fn();
console.log(`${name}`);
passed++;
} catch (e: any) {
console.log(`${name}: ${e.message}`);
failed++;
}
}
console.log('MCP Smoke Test\n');
await test('get_stats returns counts', async () => {
const stats = await handleToolCall(eng, 'get_stats', {}) as any;
if (typeof stats.page_count !== 'number') throw new Error('page_count missing');
});
await test('put_page creates a page', async () => {
await handleToolCall(eng, 'put_page', {
slug: 'smoke/test-page',
content: '---\ntitle: Smoke Test Page\ntype: note\n---\n\nThis page was created by the MCP smoke test.',
});
});
await test('get_page retrieves the page', async () => {
const page = await handleToolCall(eng, 'get_page', { slug: 'smoke/test-page' }) as any;
if (page.title !== 'Smoke Test Page') throw new Error(`Wrong title: ${page.title}`);
});
await test('dry_run prevents mutation', async () => {
const result = await handleToolCall(eng, 'put_page', {
slug: 'smoke/should-not-exist',
content: '---\ntitle: Should Not Exist\ntype: note\n---\n\ndry run test',
dry_run: true,
}) as any;
if (!result.dry_run) throw new Error('dry_run flag not returned');
// Verify page was NOT created
try {
await handleToolCall(eng, 'get_page', { slug: 'smoke/should-not-exist' });
throw new Error('Page was created despite dry_run');
} catch (e: any) {
if (!e.message.includes('not found') && !e.code) throw e;
}
});
await test('search finds the page', async () => {
const results = await handleToolCall(eng, 'search', { query: 'smoke test' }) as any[];
if (!Array.isArray(results)) throw new Error('search should return array');
// Keyword search may or may not find it depending on search_vector trigger
});
await test('list_pages includes our page', async () => {
const pages = await handleToolCall(eng, 'list_pages', { limit: 100 }) as any[];
const found = pages.find((p: any) => p.slug === 'smoke/test-page');
if (!found) throw new Error('smoke/test-page not in list');
});
await test('add_tag and get_tags work', async () => {
await handleToolCall(eng, 'add_tag', { slug: 'smoke/test-page', tag: 'smoke-test' });
const tags = await handleToolCall(eng, 'get_tags', { slug: 'smoke/test-page' }) as string[];
if (!tags.includes('smoke-test')) throw new Error('tag not found');
});
await test('delete_page cleans up', async () => {
await handleToolCall(eng, 'delete_page', { slug: 'smoke/test-page' });
try {
await handleToolCall(eng, 'get_page', { slug: 'smoke/test-page' });
throw new Error('Page still exists after delete');
} catch (e: any) {
if (!e.message.includes('not found') && !e.code) throw e;
}
});
await eng.disconnect();
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
console.log('\n🧠 MCP smoke test passed!');