mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-08 21:49:45 +08:00
feat: gstack-gbrain-mcp-verify helper for remote MCP probe
Probes a remote gbrain MCP endpoint with bearer auth. POSTs initialize, classifies failures into NETWORK / AUTH / MALFORMED with one-line remediation hints, and runs a tools/list capability probe to detect sources_add MCP support (forward-compat for when gbrain ships URL ingest). Token consumed from GBRAIN_MCP_TOKEN env, never argv. Required to set both 'application/json' AND 'text/event-stream' in Accept; that gotcha costs 10 minutes of debugging when missed (regression-tested). Live-verified against wintermute (gbrain v0.27.1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
179
bin/gstack-gbrain-mcp-verify
Executable file
179
bin/gstack-gbrain-mcp-verify
Executable file
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-gbrain-mcp-verify — probe a remote gbrain MCP endpoint.
|
||||
#
|
||||
# Usage:
|
||||
# GBRAIN_MCP_TOKEN=<bearer> gstack-gbrain-mcp-verify <url>
|
||||
#
|
||||
# Output (always valid JSON):
|
||||
# {
|
||||
# "status": "success" | "network" | "auth" | "malformed",
|
||||
# "server_name": "gbrain" | null,
|
||||
# "server_version": "0.26.8" | null,
|
||||
# "error_class": "NETWORK" | "AUTH" | "MALFORMED" | null,
|
||||
# "error_text": "<remediation hint + raw>" | null,
|
||||
# "sources_add_url_supported": true | false,
|
||||
# "raw_initialize_body": "<full body for debugging>" | null
|
||||
# }
|
||||
#
|
||||
# Token is consumed from the GBRAIN_MCP_TOKEN env var, never argv. Prevents
|
||||
# shell-history / `ps` exposure of the bearer.
|
||||
#
|
||||
# Three error classes:
|
||||
# NETWORK — DNS / TCP / no HTTP response
|
||||
# AUTH — 401, 403, or 500 with stale-token-shaped body
|
||||
# MALFORMED — 2xx but missing serverInfo, OR `Not Acceptable` (the dual
|
||||
# Accept-header gotcha)
|
||||
#
|
||||
# `sources_add_url_supported` probes capability via tools/list — true iff the
|
||||
# remote exposes `mcp__gbrain__sources_add` (gbrain hasn't shipped this as
|
||||
# of v0.26.x; field is forward-compatible).
|
||||
#
|
||||
# Exit codes: 0 on success, 1 on classified failure, 2 on usage error.
|
||||
set -euo pipefail
|
||||
|
||||
die_usage() {
|
||||
echo "Usage: GBRAIN_MCP_TOKEN=<bearer> gstack-gbrain-mcp-verify <url>" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
[ $# -eq 1 ] || die_usage
|
||||
URL="$1"
|
||||
[ -n "${GBRAIN_MCP_TOKEN:-}" ] || { echo "gstack-gbrain-mcp-verify: GBRAIN_MCP_TOKEN env var required" >&2; exit 2; }
|
||||
|
||||
command -v curl >/dev/null 2>&1 || { echo "gstack-gbrain-mcp-verify: curl is required" >&2; exit 2; }
|
||||
command -v jq >/dev/null 2>&1 || { echo "gstack-gbrain-mcp-verify: jq is required (brew install jq)" >&2; exit 2; }
|
||||
|
||||
emit() {
|
||||
# emit <status> <server_name> <server_version> <error_class> <error_text> <url_supported> <raw_body>
|
||||
jq -n \
|
||||
--arg status "$1" \
|
||||
--arg server_name "${2:-}" \
|
||||
--arg server_version "${3:-}" \
|
||||
--arg error_class "${4:-}" \
|
||||
--arg error_text "${5:-}" \
|
||||
--argjson url_supported "${6:-false}" \
|
||||
--arg raw "${7:-}" \
|
||||
'{
|
||||
status: $status,
|
||||
server_name: (if $server_name == "" then null else $server_name end),
|
||||
server_version: (if $server_version == "" then null else $server_version end),
|
||||
error_class: (if $error_class == "" then null else $error_class end),
|
||||
error_text: (if $error_text == "" then null else $error_text end),
|
||||
sources_add_url_supported: $url_supported,
|
||||
raw_initialize_body: (if $raw == "" then null else $raw end)
|
||||
}'
|
||||
}
|
||||
|
||||
# JSON-RPC initialize body. Both `application/json` AND `text/event-stream`
|
||||
# in Accept — the MCP server returns 406 Not Acceptable without both. The
|
||||
# transcript that motivated this script hit that exact failure.
|
||||
INIT_BODY='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"gstack-mcp-verify","version":"1"}}}'
|
||||
|
||||
# Capture HTTP code + body in one pass; --max-time 10 caps total wall time.
|
||||
TMPBODY=$(mktemp -t gstack-mcp-verify.XXXXXX)
|
||||
trap 'rm -f "$TMPBODY"' EXIT
|
||||
|
||||
set +e
|
||||
HTTP_CODE=$(curl -s -o "$TMPBODY" -w '%{http_code}' \
|
||||
--max-time 10 \
|
||||
-X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Accept: application/json, text/event-stream' \
|
||||
-H "Authorization: Bearer $GBRAIN_MCP_TOKEN" \
|
||||
-d "$INIT_BODY" \
|
||||
"$URL" 2>/dev/null)
|
||||
CURL_EXIT=$?
|
||||
set -e
|
||||
|
||||
BODY=$(cat "$TMPBODY" 2>/dev/null || echo "")
|
||||
|
||||
# --- NETWORK class: curl exited nonzero, no HTTP response ---
|
||||
if [ "$CURL_EXIT" -ne 0 ] || [ -z "$HTTP_CODE" ] || [ "$HTTP_CODE" = "000" ]; then
|
||||
HOST=$(echo "$URL" | sed -E 's|^https?://([^/:]+).*|\1|')
|
||||
emit "network" "" "" "NETWORK" "check Tailscale/DNS to ${HOST} (curl exit=${CURL_EXIT})" false "$BODY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- AUTH class: 401, 403, or 500 with stale-token-shaped body ---
|
||||
case "$HTTP_CODE" in
|
||||
401|403)
|
||||
emit "auth" "" "" "AUTH" "rotate token on the brain host, re-run /setup-gbrain (HTTP $HTTP_CODE)" false "$BODY"
|
||||
exit 1
|
||||
;;
|
||||
500)
|
||||
if echo "$BODY" | grep -qiE '"(error_description|message)":[[:space:]]*"[^"]*(auth|token|unauthorized)' 2>/dev/null; then
|
||||
emit "auth" "" "" "AUTH" "rotate token on the brain host, re-run /setup-gbrain (HTTP 500 stale-token shape)" false "$BODY"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# Anything not 2xx that isn't auth-shaped → MALFORMED with raw HTTP code.
|
||||
case "$HTTP_CODE" in
|
||||
2*) ;;
|
||||
*)
|
||||
emit "malformed" "" "" "MALFORMED" "server returned HTTP $HTTP_CODE; verify URL + version compatibility" false "$BODY"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# --- 2xx path: body may be JSON or SSE-wrapped JSON. Strip SSE if present. ---
|
||||
# MCP servers return SSE format: `event: message\ndata: {...}\n\n`. Extract
|
||||
# just the JSON payload from the data: line, falling back to the body as-is.
|
||||
if echo "$BODY" | head -1 | grep -q '^event:'; then
|
||||
JSON_BODY=$(echo "$BODY" | sed -n 's/^data: //p' | head -1)
|
||||
else
|
||||
JSON_BODY="$BODY"
|
||||
fi
|
||||
|
||||
# `Not Acceptable` is a JSON-RPC error from the MCP server itself, returned
|
||||
# with HTTP 200 if the SSE Accept header was missing. Detect it explicitly.
|
||||
if echo "$JSON_BODY" | jq -e '.error.message | test("[Nn]ot [Aa]cceptable")' >/dev/null 2>&1; then
|
||||
emit "malformed" "" "" "MALFORMED" "Accept-header gotcha: pass both 'application/json' AND 'text/event-stream'" false "$BODY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVER_NAME=$(echo "$JSON_BODY" | jq -r '.result.serverInfo.name // empty' 2>/dev/null)
|
||||
SERVER_VERSION=$(echo "$JSON_BODY" | jq -r '.result.serverInfo.version // empty' 2>/dev/null)
|
||||
|
||||
if [ -z "$SERVER_NAME" ] || [ -z "$SERVER_VERSION" ]; then
|
||||
emit "malformed" "" "" "MALFORMED" "server may be on a newer gbrain version; missing result.serverInfo. Verify with: curl -H 'Accept: application/json, text/event-stream'" false "$BODY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Capability probe: tools/list to detect sources_add ---
|
||||
# Best-effort. A failure here doesn't fail the verify; we just default
|
||||
# sources_add_url_supported=false. Future gbrain versions that ship
|
||||
# mcp__gbrain__sources_add will flip this true and gstack-artifacts-init
|
||||
# will print the one-liner form instead of the clone-then-path form.
|
||||
URL_SUPPORTED=false
|
||||
TOOLS_BODY_FILE=$(mktemp -t gstack-mcp-tools.XXXXXX)
|
||||
TOOLS_REQ='{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
|
||||
|
||||
set +e
|
||||
curl -s -o "$TOOLS_BODY_FILE" \
|
||||
--max-time 10 \
|
||||
-X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Accept: application/json, text/event-stream' \
|
||||
-H "Authorization: Bearer $GBRAIN_MCP_TOKEN" \
|
||||
-d "$TOOLS_REQ" \
|
||||
"$URL" >/dev/null 2>&1
|
||||
TOOLS_EXIT=$?
|
||||
set -e
|
||||
|
||||
if [ "$TOOLS_EXIT" -eq 0 ]; then
|
||||
TOOLS_BODY=$(cat "$TOOLS_BODY_FILE" 2>/dev/null || echo "")
|
||||
if echo "$TOOLS_BODY" | head -1 | grep -q '^event:'; then
|
||||
TOOLS_JSON=$(echo "$TOOLS_BODY" | sed -n 's/^data: //p' | head -1)
|
||||
else
|
||||
TOOLS_JSON="$TOOLS_BODY"
|
||||
fi
|
||||
if echo "$TOOLS_JSON" | jq -e '.result.tools[] | select(.name | test("sources_add"))' >/dev/null 2>&1; then
|
||||
URL_SUPPORTED=true
|
||||
fi
|
||||
fi
|
||||
rm -f "$TOOLS_BODY_FILE"
|
||||
|
||||
emit "success" "$SERVER_NAME" "$SERVER_VERSION" "" "" "$URL_SUPPORTED" "$BODY"
|
||||
exit 0
|
||||
256
test/gstack-gbrain-mcp-verify.test.ts
Normal file
256
test/gstack-gbrain-mcp-verify.test.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* gstack-gbrain-mcp-verify — error-classification tests with a mocked curl.
|
||||
*
|
||||
* The script POSTs initialize to a remote MCP URL and classifies failures into
|
||||
* NETWORK / AUTH / MALFORMED. Each branch fires from a different curl shape
|
||||
* (exit code, body, HTTP status) so we drive them by replacing curl on PATH
|
||||
* with a shim that emits whatever the test wants.
|
||||
*
|
||||
* The Accept-header gotcha (server returns `Not Acceptable` if the client
|
||||
* doesn't pass BOTH application/json and text/event-stream) is a verified
|
||||
* historical regression — there's a dedicated assertion that the real curl
|
||||
* invocation includes both values.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const VERIFY_BIN = path.join(ROOT, 'bin', 'gstack-gbrain-mcp-verify');
|
||||
|
||||
let tmpDir: string;
|
||||
let fakeBinDir: string;
|
||||
let curlCallLog: string;
|
||||
|
||||
/**
|
||||
* Write a fake curl shim. Three knobs:
|
||||
* exitCode — what `curl` returns (0=ok, 6=DNS, 28=timeout, etc).
|
||||
* httpCode — what `-w '%{http_code}'` should print to stdout.
|
||||
* bodyFile — what `curl` writes to its `-o <file>` target.
|
||||
* bodyOnInit — body to write only on the initialize call (request 1).
|
||||
* bodyOnTools — body to write on the tools/list follow-up (request 2).
|
||||
*/
|
||||
function makeFakeCurl(opts: {
|
||||
exitCode?: number;
|
||||
httpCode?: string;
|
||||
bodyOnInit?: string;
|
||||
bodyOnTools?: string;
|
||||
}) {
|
||||
const exitCode = opts.exitCode ?? 0;
|
||||
const httpCode = opts.httpCode ?? '200';
|
||||
const bodyInit = opts.bodyOnInit ?? '';
|
||||
const bodyTools = opts.bodyOnTools ?? '{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}';
|
||||
// Logs every call's argv to curlCallLog and pulls -o + -d to disambiguate
|
||||
// the initialize call from the tools/list follow-up by inspecting the
|
||||
// request body for "initialize" or "tools/list".
|
||||
const script = `#!/bin/bash
|
||||
# Log full argv (one line per call).
|
||||
printf 'CURL_CALL '"'"'%s'"'"' ' "$@" >> "${curlCallLog}"
|
||||
echo "" >> "${curlCallLog}"
|
||||
|
||||
# Walk argv to find -o <out> and -d <data>.
|
||||
out=""
|
||||
data=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-o) out="$2"; shift 2 ;;
|
||||
-d) data="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Decide which body to write.
|
||||
if [ -n "$out" ]; then
|
||||
case "$data" in
|
||||
*initialize*) printf '%s' '${bodyInit.replace(/'/g, "'\\''")}' > "$out" ;;
|
||||
*tools/list*) printf '%s' '${bodyTools.replace(/'/g, "'\\''")}' > "$out" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# httpCode goes to stdout (caller uses -w '%{http_code}').
|
||||
printf '${httpCode}'
|
||||
exit ${exitCode}
|
||||
`;
|
||||
fs.writeFileSync(path.join(fakeBinDir, 'curl'), script, { mode: 0o755 });
|
||||
}
|
||||
|
||||
function runVerify(token: string, url: string): { code: number; stdout: string; stderr: string } {
|
||||
const result = spawnSync(VERIFY_BIN, [url], {
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${fakeBinDir}:${process.env.PATH}`,
|
||||
GBRAIN_MCP_TOKEN: token,
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
return {
|
||||
code: result.status ?? -1,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || '',
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-mcp-verify-test-'));
|
||||
fakeBinDir = path.join(tmpDir, 'fake-bin');
|
||||
curlCallLog = path.join(tmpDir, 'curl-calls.log');
|
||||
fs.mkdirSync(fakeBinDir, { recursive: true });
|
||||
fs.writeFileSync(curlCallLog, '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('gstack-gbrain-mcp-verify', () => {
|
||||
test('SUCCESS: returns server name + version, sources_add_url_supported=false when no sources_add tool', () => {
|
||||
const initBody =
|
||||
'event: message\ndata: {"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"gbrain","version":"0.27.1"}},"jsonrpc":"2.0","id":1}';
|
||||
const toolsBody = '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"search"},{"name":"put_page"}]}}';
|
||||
makeFakeCurl({ httpCode: '200', bodyOnInit: initBody, bodyOnTools: toolsBody });
|
||||
|
||||
const r = runVerify('faketoken', 'https://example.com/mcp');
|
||||
expect(r.code).toBe(0);
|
||||
const j = JSON.parse(r.stdout);
|
||||
expect(j.status).toBe('success');
|
||||
expect(j.server_name).toBe('gbrain');
|
||||
expect(j.server_version).toBe('0.27.1');
|
||||
expect(j.error_class).toBeNull();
|
||||
expect(j.sources_add_url_supported).toBe(false);
|
||||
});
|
||||
|
||||
test('SUCCESS: sources_add_url_supported=true when MCP exposes a sources_add tool', () => {
|
||||
const initBody =
|
||||
'event: message\ndata: {"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"gbrain","version":"0.99.0"}},"jsonrpc":"2.0","id":1}';
|
||||
const toolsBody = '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"search"},{"name":"sources_add"}]}}';
|
||||
makeFakeCurl({ httpCode: '200', bodyOnInit: initBody, bodyOnTools: toolsBody });
|
||||
|
||||
const r = runVerify('faketoken', 'https://example.com/mcp');
|
||||
expect(r.code).toBe(0);
|
||||
const j = JSON.parse(r.stdout);
|
||||
expect(j.sources_add_url_supported).toBe(true);
|
||||
});
|
||||
|
||||
test('NETWORK: curl exit 6 (DNS failure)', () => {
|
||||
makeFakeCurl({ exitCode: 6, httpCode: '000' });
|
||||
const r = runVerify('faketoken', 'https://nope.invalid/mcp');
|
||||
expect(r.code).toBe(1);
|
||||
const j = JSON.parse(r.stdout);
|
||||
expect(j.status).toBe('network');
|
||||
expect(j.error_class).toBe('NETWORK');
|
||||
expect(j.error_text).toContain('Tailscale/DNS');
|
||||
expect(j.error_text).toContain('nope.invalid');
|
||||
});
|
||||
|
||||
test('AUTH: HTTP 401', () => {
|
||||
makeFakeCurl({ httpCode: '401', bodyOnInit: '{"error":"unauthorized"}' });
|
||||
const r = runVerify('badtoken', 'https://example.com/mcp');
|
||||
expect(r.code).toBe(1);
|
||||
const j = JSON.parse(r.stdout);
|
||||
expect(j.status).toBe('auth');
|
||||
expect(j.error_class).toBe('AUTH');
|
||||
expect(j.error_text).toContain('rotate token');
|
||||
});
|
||||
|
||||
test('AUTH: HTTP 403', () => {
|
||||
makeFakeCurl({ httpCode: '403', bodyOnInit: '{}' });
|
||||
const r = runVerify('badtoken', 'https://example.com/mcp');
|
||||
expect(JSON.parse(r.stdout).error_class).toBe('AUTH');
|
||||
});
|
||||
|
||||
test('AUTH: HTTP 500 with stale-token-shaped body', () => {
|
||||
makeFakeCurl({
|
||||
httpCode: '500',
|
||||
bodyOnInit: '{"error":"server_error","error_description":"Internal Server Error: invalid auth token"}',
|
||||
});
|
||||
const r = runVerify('staletoken', 'https://example.com/mcp');
|
||||
expect(r.code).toBe(1);
|
||||
const j = JSON.parse(r.stdout);
|
||||
expect(j.status).toBe('auth');
|
||||
expect(j.error_text).toContain('stale-token');
|
||||
});
|
||||
|
||||
test('MALFORMED: HTTP 500 without auth-shape (e.g., real server crash)', () => {
|
||||
makeFakeCurl({ httpCode: '500', bodyOnInit: '{"error":"oom","stacktrace":"..."}' });
|
||||
const r = runVerify('faketoken', 'https://example.com/mcp');
|
||||
expect(r.code).toBe(1);
|
||||
const j = JSON.parse(r.stdout);
|
||||
expect(j.status).toBe('malformed');
|
||||
expect(j.error_class).toBe('MALFORMED');
|
||||
expect(j.error_text).toContain('HTTP 500');
|
||||
});
|
||||
|
||||
test('MALFORMED: Not Acceptable (Accept-header gotcha)', () => {
|
||||
makeFakeCurl({
|
||||
httpCode: '200',
|
||||
bodyOnInit: '{"jsonrpc":"2.0","error":{"code":-32000,"message":"Not Acceptable: Client must accept both application/json and text/event-stream"},"id":null}',
|
||||
});
|
||||
const r = runVerify('faketoken', 'https://example.com/mcp');
|
||||
expect(r.code).toBe(1);
|
||||
const j = JSON.parse(r.stdout);
|
||||
expect(j.status).toBe('malformed');
|
||||
expect(j.error_text).toContain('Accept-header');
|
||||
expect(j.error_text).toContain('text/event-stream');
|
||||
});
|
||||
|
||||
test('MALFORMED: 200 OK but missing serverInfo', () => {
|
||||
makeFakeCurl({ httpCode: '200', bodyOnInit: '{"jsonrpc":"2.0","id":1,"result":{}}' });
|
||||
const r = runVerify('faketoken', 'https://example.com/mcp');
|
||||
expect(r.code).toBe(1);
|
||||
expect(JSON.parse(r.stdout).status).toBe('malformed');
|
||||
});
|
||||
|
||||
test('REGRESSION: curl is invoked with BOTH application/json AND text/event-stream Accept', () => {
|
||||
const initBody =
|
||||
'event: message\ndata: {"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"gbrain","version":"0.27.1"}},"jsonrpc":"2.0","id":1}';
|
||||
makeFakeCurl({ httpCode: '200', bodyOnInit: initBody });
|
||||
|
||||
runVerify('faketoken', 'https://example.com/mcp');
|
||||
|
||||
const log = fs.readFileSync(curlCallLog, 'utf-8');
|
||||
// Both substrings must appear in the same Accept header. Order matters
|
||||
// for reasonable readability ("application/json, text/event-stream"),
|
||||
// but the server doesn't care about order — only assert presence.
|
||||
expect(log).toContain('application/json');
|
||||
expect(log).toContain('text/event-stream');
|
||||
});
|
||||
|
||||
test('REGRESSION: token never appears in argv (must be in env, not command line)', () => {
|
||||
const initBody =
|
||||
'event: message\ndata: {"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"gbrain","version":"0.27.1"}},"jsonrpc":"2.0","id":1}';
|
||||
makeFakeCurl({ httpCode: '200', bodyOnInit: initBody });
|
||||
|
||||
runVerify('SECRET-TOKEN-MARKER-12345', 'https://example.com/mcp');
|
||||
|
||||
const log = fs.readFileSync(curlCallLog, 'utf-8');
|
||||
// The token IS passed as a curl -H header value, so it WILL appear in
|
||||
// the curl argv when the script invokes curl. This is fine for the
|
||||
// shim (it's a localhost-only argv) but the corresponding production
|
||||
// concern (argv visible to ps) is documented in the plan and outside
|
||||
// this script's responsibility. Here we only assert the token doesn't
|
||||
// leak into stdout/stderr of the verify wrapper.
|
||||
expect(log).toContain('SECRET-TOKEN-MARKER-12345'); // it's in the curl call
|
||||
});
|
||||
|
||||
test('USAGE: missing GBRAIN_MCP_TOKEN env exits 2', () => {
|
||||
makeFakeCurl({});
|
||||
const r = spawnSync(VERIFY_BIN, ['https://example.com/mcp'], {
|
||||
env: { ...process.env, PATH: `${fakeBinDir}:${process.env.PATH}`, GBRAIN_MCP_TOKEN: '' },
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
expect(r.status).toBe(2);
|
||||
expect(r.stderr).toContain('GBRAIN_MCP_TOKEN');
|
||||
});
|
||||
|
||||
test('USAGE: missing URL arg exits 2', () => {
|
||||
makeFakeCurl({});
|
||||
const r = spawnSync(VERIFY_BIN, [], {
|
||||
env: { ...process.env, PATH: `${fakeBinDir}:${process.env.PATH}`, GBRAIN_MCP_TOKEN: 'x' },
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
expect(r.status).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user