mobygate 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mcp-inspect.mjs — raw MCP tool response inspector
4
+ *
5
+ * Purpose: diagnose whether an MCP server (e.g. Paper, Pencil) actually
6
+ * returns binary image content, or whether the image is being dropped by
7
+ * a client-side bridge (e.g. Hermes) during normalization.
8
+ *
9
+ * This connects directly to the MCP server over stdio, bypassing Hermes
10
+ * entirely. If the tool returns image content here, the problem is
11
+ * client-side; if it returns empty, the problem is in the MCP server.
12
+ *
13
+ * USAGE
14
+ * # List available tools
15
+ * node mcp-inspect.mjs --cmd "<server-exe>" --args '["<arg1>",...]' --list
16
+ *
17
+ * # Call a tool and dump raw response
18
+ * node mcp-inspect.mjs --cmd "<server-exe>" --args '["<arg1>",...]' \
19
+ * --tool <tool-name> --params '{"key":"value"}'
20
+ *
21
+ * # Env vars for the child
22
+ * node mcp-inspect.mjs --cmd "..." --env '{"API_KEY":"foo"}' --tool ...
23
+ *
24
+ * EXAMPLE (hypothetical Paper MCP server)
25
+ * node mcp-inspect.mjs \
26
+ * --cmd "npx" --args '["-y","@paper/mcp-server"]' \
27
+ * --tool get_screenshot --params '{"artboard":"WL-0"}'
28
+ *
29
+ * OUTPUT
30
+ * - Summary of content blocks by type, with sizes
31
+ * - First/last 60 chars of any base64 image payload (to confirm bytes are real)
32
+ * - Full raw JSON response (pretty-printed) at the end
33
+ */
34
+
35
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
36
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
37
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
38
+ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
39
+
40
+ // ---------- Arg parsing ----------
41
+
42
+ function parseArgs(argv) {
43
+ const out = { list: false, params: {}, args: [], env: {}, transport: 'http' };
44
+ for (let i = 2; i < argv.length; i++) {
45
+ const a = argv[i];
46
+ const next = argv[i + 1];
47
+ switch (a) {
48
+ case '--cmd': out.cmd = next; i++; break;
49
+ case '--args': out.args = JSON.parse(next); i++; break;
50
+ case '--env': out.env = JSON.parse(next); i++; break;
51
+ case '--url': out.url = next; i++; break;
52
+ case '--transport': out.transport = next; i++; break; // http | sse
53
+ case '--tool': out.tool = next; i++; break;
54
+ case '--params': out.params = JSON.parse(next); i++; break;
55
+ case '--list': out.list = true; break;
56
+ case '--help':
57
+ case '-h':
58
+ printUsage();
59
+ process.exit(0);
60
+ default:
61
+ console.error(`Unknown arg: ${a}`);
62
+ printUsage();
63
+ process.exit(1);
64
+ }
65
+ }
66
+ if (!out.cmd && !out.url) { console.error('ERROR: provide --cmd (stdio) or --url (http/sse)'); printUsage(); process.exit(1); }
67
+ if (!out.list && !out.tool) { console.error('ERROR: provide --tool <name> or --list'); process.exit(1); }
68
+ return out;
69
+ }
70
+
71
+ function printUsage() {
72
+ console.error(`
73
+ Usage: node mcp-inspect.mjs --cmd "<exe>" [--args '["..."]'] [--env '{...}'] \\
74
+ (--list | --tool <name> [--params '{...}'])
75
+
76
+ Connects to an MCP server over stdio and dumps the raw response payload.
77
+ Useful to verify whether binary image data is reaching the MCP boundary
78
+ before any client-side normalization (e.g. Hermes) strips it.
79
+ `);
80
+ }
81
+
82
+ // ---------- Content-block summary ----------
83
+
84
+ function summarizeContent(content) {
85
+ if (!Array.isArray(content)) return `content is not an array: ${typeof content}`;
86
+ const rows = [];
87
+ for (let i = 0; i < content.length; i++) {
88
+ const block = content[i];
89
+ const type = block?.type || 'unknown';
90
+ if (type === 'text') {
91
+ const text = block.text || '';
92
+ rows.push(` [${i}] text — ${text.length} chars: ${JSON.stringify(text.slice(0, 80))}${text.length > 80 ? '…' : ''}`);
93
+ } else if (type === 'image') {
94
+ const data = block.data || '';
95
+ const mime = block.mimeType || '?';
96
+ const approxBytes = Math.floor(data.length * 0.75); // base64 → bytes
97
+ const head = data.slice(0, 60);
98
+ const tail = data.slice(-20);
99
+ rows.push(` [${i}] image — mime=${mime}, base64=${data.length} chars (~${approxBytes} bytes)`);
100
+ rows.push(` head: ${head}${data.length > 80 ? `…${tail}` : ''}`);
101
+ } else if (type === 'resource') {
102
+ rows.push(` [${i}] resource — uri=${block.resource?.uri || '?'}`);
103
+ } else {
104
+ rows.push(` [${i}] ${type} — ${JSON.stringify(block).slice(0, 140)}`);
105
+ }
106
+ }
107
+ return rows.join('\n') || ' (empty)';
108
+ }
109
+
110
+ // ---------- Main ----------
111
+
112
+ async function main() {
113
+ const opts = parseArgs(process.argv);
114
+
115
+ let transport;
116
+ if (opts.url) {
117
+ console.log(`[${opts.transport}] ${opts.url}`);
118
+ const url = new URL(opts.url);
119
+ transport = opts.transport === 'sse'
120
+ ? new SSEClientTransport(url)
121
+ : new StreamableHTTPClientTransport(url);
122
+ } else {
123
+ console.log(`[spawn] ${opts.cmd} ${opts.args.map((a) => JSON.stringify(a)).join(' ')}`);
124
+ transport = new StdioClientTransport({
125
+ command: opts.cmd,
126
+ args: opts.args,
127
+ env: { ...process.env, ...opts.env },
128
+ });
129
+ }
130
+
131
+ const client = new Client(
132
+ { name: 'mcp-inspect', version: '0.1.0' },
133
+ { capabilities: {} }
134
+ );
135
+
136
+ try {
137
+ await client.connect(transport);
138
+ console.log('[connected]');
139
+
140
+ if (opts.list) {
141
+ const res = await client.listTools();
142
+ console.log(`\n=== Tools (${res.tools?.length || 0}) ===`);
143
+ for (const t of res.tools || []) {
144
+ console.log(` ${t.name} — ${t.description?.slice(0, 80) || '(no description)'}`);
145
+ }
146
+ console.log('\n--- raw listTools response ---');
147
+ console.log(JSON.stringify(res, null, 2));
148
+ } else {
149
+ console.log(`\n[call] tool=${opts.tool} params=${JSON.stringify(opts.params)}`);
150
+ const started = Date.now();
151
+ const res = await client.callTool({ name: opts.tool, arguments: opts.params });
152
+ const dur = Date.now() - started;
153
+
154
+ console.log(`\n=== Response (${dur}ms) ===`);
155
+ console.log(`isError: ${res.isError === true ? 'YES' : 'no'}`);
156
+ console.log(`content blocks: ${Array.isArray(res.content) ? res.content.length : '(not an array)'}`);
157
+ if (Array.isArray(res.content)) {
158
+ const byType = {};
159
+ for (const b of res.content) byType[b.type || 'unknown'] = (byType[b.type || 'unknown'] || 0) + 1;
160
+ console.log(`by type: ${JSON.stringify(byType)}`);
161
+ }
162
+
163
+ console.log('\n--- content summary ---');
164
+ console.log(summarizeContent(res.content));
165
+
166
+ console.log('\n--- raw response JSON ---');
167
+ // Truncate image base64 in the pretty-print to keep output readable;
168
+ // we already showed head/tail above.
169
+ const safe = JSON.parse(JSON.stringify(res, (key, val) => {
170
+ if (key === 'data' && typeof val === 'string' && val.length > 200) {
171
+ return `${val.slice(0, 80)}…[${val.length - 100} chars elided]…${val.slice(-20)}`;
172
+ }
173
+ return val;
174
+ }));
175
+ console.log(JSON.stringify(safe, null, 2));
176
+ }
177
+ } catch (e) {
178
+ console.error(`\n[ERROR] ${e.message}`);
179
+ if (e.stack) console.error(e.stack.split('\n').slice(0, 4).join('\n'));
180
+ process.exit(1);
181
+ } finally {
182
+ try { await client.close(); } catch {}
183
+ }
184
+ }
185
+
186
+ main();
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "mobygate",
3
+ "version": "0.3.0",
4
+ "description": "OpenAI-compatible local proxy for Claude Max. The Möbius-strip gateway: OpenAI shape in, Claude Max out.",
5
+ "type": "module",
6
+ "main": "server.js",
7
+ "bin": {
8
+ "mobygate": "./bin/mobygate.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node server.js",
12
+ "dev": "node --watch server.js",
13
+ "up": "npm install && node server.js",
14
+ "auth:status": "node scripts/auth-status.js",
15
+ "auth:status:quick": "node scripts/auth-status.js --quick",
16
+ "auth:refresh": "node scripts/auth-refresh.js"
17
+ },
18
+ "dependencies": {
19
+ "@anthropic-ai/claude-agent-sdk": "^0.2.112",
20
+ "express": "^5.1.0",
21
+ "js-yaml": "^4.1.1",
22
+ "uuid": "^11.1.0"
23
+ },
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/khnfrhn/mobygate.git"
30
+ },
31
+ "homepage": "https://github.com/khnfrhn/mobygate#readme",
32
+ "bugs": {
33
+ "url": "https://github.com/khnfrhn/mobygate/issues"
34
+ },
35
+ "author": "Farhan Khan",
36
+ "license": "MIT",
37
+ "keywords": [
38
+ "claude",
39
+ "claude-max",
40
+ "anthropic",
41
+ "openai-compatible",
42
+ "proxy",
43
+ "gateway",
44
+ "llm",
45
+ "ai",
46
+ "agent-sdk",
47
+ "localhost",
48
+ "dashboard"
49
+ ],
50
+ "files": [
51
+ "bin",
52
+ "lib",
53
+ "scripts",
54
+ "launchd",
55
+ "server.js",
56
+ "index.html",
57
+ "mcp-inspect.mjs",
58
+ "README.md",
59
+ "CHANGELOG.md",
60
+ "LICENSE"
61
+ ]
62
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Auth helper — bridges the proxy to Claude Code's CLI auth machinery.
3
+ *
4
+ * The Claude Agent SDK reads OAuth credentials from the macOS keychain
5
+ * ("Claude Code-credentials") via the bundled CLI. The SDK is supposed to
6
+ * silently refresh the access token on 401, but in practice it sometimes
7
+ * eats the refresh path and surfaces the 401 to the caller. This module
8
+ * provides three things:
9
+ *
10
+ * getAuthStatus() — shells `claude auth status --json` (loggedIn,
11
+ * authMethod, email, etc.). The CLI does NOT report
12
+ * token expiry directly, so we treat "loggedIn:true"
13
+ * as necessary-but-not-sufficient and pair it with
14
+ * a real probe when the caller needs verification.
15
+ *
16
+ * forceRefresh() — fires a 1-token `claude -p` probe. If the access
17
+ * token is expired the CLI will silently refresh
18
+ * using the still-valid refresh token. Returns
19
+ * { ok, refreshed, durationMs, output } so callers
20
+ * can log loudly.
21
+ *
22
+ * runWithAuthRetry() — wrap an SDK `query()` consumer. On 401-shaped
23
+ * errors, call forceRefresh() and retry the
24
+ * producer once. Idempotent because we only retry
25
+ * the failure that comes BEFORE the first chunk
26
+ * is yielded — we never replay mid-stream.
27
+ */
28
+
29
+ import { spawn } from 'child_process';
30
+
31
+ // Default to the bare binary name so PATH lookup works on every platform.
32
+ // - macOS/Linux: typically ~/.local/bin/claude (on PATH after standard install)
33
+ // - Windows: claude.cmd or claude.exe on PATH after MSI / `npm install -g`
34
+ // If yours isn't on PATH, set CLAUDE_BIN to an absolute path.
35
+ const CLAUDE_BIN = process.env.CLAUDE_BIN || 'claude';
36
+ const IS_WINDOWS = process.platform === 'win32';
37
+ const PROBE_TIMEOUT_MS = 30_000;
38
+
39
+ function runClaude(args, { timeoutMs = PROBE_TIMEOUT_MS, input } = {}) {
40
+ return new Promise((resolve) => {
41
+ // On Windows, spawn can't execute .cmd/.bat shim wrappers without a
42
+ // shell. Rather than `shell: true` + args-array (deprecated in Node
43
+ // 20+ as DEP0190), we invoke cmd.exe explicitly with a quoted command
44
+ // string we build ourselves.
45
+ let proc;
46
+ if (IS_WINDOWS) {
47
+ const quote = (s) => /[\s"]/.test(s) ? `"${String(s).replace(/"/g, '""')}"` : String(s);
48
+ const cmdline = [quote(CLAUDE_BIN), ...args.map(quote)].join(' ');
49
+ proc = spawn('cmd.exe', ['/c', cmdline], { stdio: ['pipe', 'pipe', 'pipe'] });
50
+ } else {
51
+ proc = spawn(CLAUDE_BIN, args, { stdio: ['pipe', 'pipe', 'pipe'] });
52
+ }
53
+ let stdout = '';
54
+ let stderr = '';
55
+ let timedOut = false;
56
+ const timer = setTimeout(() => {
57
+ timedOut = true;
58
+ proc.kill('SIGKILL');
59
+ }, timeoutMs);
60
+ proc.stdout.on('data', (d) => { stdout += d.toString(); });
61
+ proc.stderr.on('data', (d) => { stderr += d.toString(); });
62
+ proc.on('error', (err) => {
63
+ clearTimeout(timer);
64
+ resolve({ code: -1, stdout, stderr: stderr + '\n' + err.message, timedOut });
65
+ });
66
+ proc.on('close', (code) => {
67
+ clearTimeout(timer);
68
+ resolve({ code, stdout, stderr, timedOut });
69
+ });
70
+ if (input != null) {
71
+ proc.stdin.write(input);
72
+ proc.stdin.end();
73
+ } else {
74
+ proc.stdin.end();
75
+ }
76
+ });
77
+ }
78
+
79
+ export async function getAuthStatus() {
80
+ const { code, stdout, stderr } = await runClaude(['auth', 'status', '--json'], { timeoutMs: 10_000 });
81
+ if (code !== 0) {
82
+ return { ok: false, loggedIn: false, error: stderr.trim() || `claude auth status exited ${code}` };
83
+ }
84
+ try {
85
+ const parsed = JSON.parse(stdout);
86
+ return { ok: true, ...parsed };
87
+ } catch (e) {
88
+ return { ok: false, loggedIn: false, error: `failed to parse claude auth status output: ${e.message}` };
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Force the SDK/CLI to refresh its access token.
94
+ *
95
+ * Strategy: fire a 1-token print-mode query. If the access token is expired,
96
+ * the CLI's auth layer will detect the 401 from Anthropic and silently swap
97
+ * in a fresh access token via the refresh_token grant. Then it succeeds.
98
+ * If the token was already fresh, this just returns instantly.
99
+ *
100
+ * We can't observe "did a refresh actually happen" from outside — the CLI
101
+ * doesn't print that. We only know "after this, the token is valid OR we
102
+ * couldn't make it valid".
103
+ */
104
+ export async function forceRefresh() {
105
+ const start = Date.now();
106
+ // -p forces print-mode round-trip. --max-turns 1 caps it at a single turn.
107
+ // We pipe '' to stdin to avoid hanging on TTY detection.
108
+ const { code, stdout, stderr, timedOut } = await runClaude(
109
+ ['-p', 'reply with the single word OK', '--max-turns', '1'],
110
+ { timeoutMs: 30_000, input: '' }
111
+ );
112
+ const durationMs = Date.now() - start;
113
+ if (timedOut) {
114
+ return { ok: false, refreshed: false, durationMs, error: 'probe timed out — refresh may still be in flight' };
115
+ }
116
+ if (code !== 0) {
117
+ return { ok: false, refreshed: false, durationMs, error: stderr.trim() || `probe exited ${code}`, output: stdout.trim() };
118
+ }
119
+ return { ok: true, refreshed: true, durationMs, output: stdout.trim() };
120
+ }
121
+
122
+ /**
123
+ * True if the error looks like an auth failure from Anthropic.
124
+ */
125
+ export function is401Error(err) {
126
+ if (!err) return false;
127
+ const msg = err.message || String(err);
128
+ if (err.status === 401 || err.statusCode === 401) return true;
129
+ if (/\b401\b/.test(msg)) return true;
130
+ if (/authentication_error|Invalid authentication credentials|invalid_grant|unauthorized/i.test(msg)) return true;
131
+ return false;
132
+ }
133
+
134
+ /**
135
+ * True if a completion result TEXT looks like an auth failure the SDK
136
+ * surfaced inline instead of throwing. Long-running proxies hit this
137
+ * after ~8h when the SDK's cached creds expire and Anthropic returns 401,
138
+ * but the SDK emits the error body as result text rather than an exception.
139
+ *
140
+ * Pattern seen in practice:
141
+ * "Failed to authenticate. API Error: 401 {\"type\":\"error\",..."
142
+ */
143
+ export function isAuthFailureText(text) {
144
+ if (!text || typeof text !== 'string') return false;
145
+ if (/Failed to authenticate.*401/i.test(text)) return true;
146
+ if (/"type"\s*:\s*"authentication_error"/i.test(text)) return true;
147
+ if (/Invalid authentication credentials/i.test(text)) return true;
148
+ return false;
149
+ }
150
+
151
+ // Thrown when we detect an auth failure in result-message text so the
152
+ // outer runWithAuthRetry wrapper treats it the same as a real 401 exception.
153
+ export class AuthFailureInResultText extends Error {
154
+ constructor(text) {
155
+ super(`Auth failure surfaced as result text: ${text.slice(0, 160)}`);
156
+ this.name = 'AuthFailureInResultText';
157
+ this.statusCode = 401;
158
+ this.resultText = text;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Wrap an SDK query() consumer with one-shot auth retry.
164
+ *
165
+ * Usage:
166
+ * await runWithAuthRetry({
167
+ * attempt: async () => {
168
+ * for await (const m of query({...})) { ... }
169
+ * },
170
+ * onRefreshing: () => console.log('refreshing...'),
171
+ * onRetry: () => console.log('retrying...'),
172
+ * });
173
+ *
174
+ * The `attempt` fn must be safe to invoke twice. If the first failure happens
175
+ * AFTER you've started writing to a response stream, you should set
176
+ * `bailIfStarted: () => boolean` so we don't retry mid-stream.
177
+ */
178
+ export async function runWithAuthRetry({ attempt, onRefreshing, onRetry, bailIfStarted }) {
179
+ try {
180
+ return await attempt();
181
+ } catch (err) {
182
+ if (!is401Error(err)) throw err;
183
+ if (bailIfStarted && bailIfStarted()) {
184
+ // Stream already started — can't safely retry.
185
+ throw err;
186
+ }
187
+ if (onRefreshing) onRefreshing(err);
188
+ const refresh = await forceRefresh();
189
+ if (!refresh.ok) {
190
+ const e = new Error(`401 from Anthropic and proxy refresh failed: ${refresh.error || 'unknown'}`);
191
+ e.cause = err;
192
+ e.refreshAttempt = refresh;
193
+ throw e;
194
+ }
195
+ if (onRetry) onRetry(refresh);
196
+ return await attempt();
197
+ }
198
+ }
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Standalone auth refresher — meant to be run from cron / launchd.
4
+ *
5
+ * Forces the Claude Code CLI to validate (and silently refresh) its OAuth
6
+ * access token. Prints a single JSON line so log output stays grep-able.
7
+ *
8
+ * Exit codes:
9
+ * 0 — token is valid (refreshed if needed)
10
+ * 2 — not logged in (manual `claude auth login` required)
11
+ * 3 — refresh probe failed (network? Anthropic outage?)
12
+ * 1 — unexpected error
13
+ *
14
+ * Usage:
15
+ * node scripts/auth-refresh.js
16
+ * npm run auth:refresh
17
+ */
18
+
19
+ import { getAuthStatus, forceRefresh } from './auth-helper.js';
20
+
21
+ function emit(obj) {
22
+ process.stdout.write(JSON.stringify({ ts: new Date().toISOString(), ...obj }) + '\n');
23
+ }
24
+
25
+ try {
26
+ const status = await getAuthStatus();
27
+ if (!status.ok || !status.loggedIn) {
28
+ emit({ stage: 'status', ok: false, status, hint: 'run: claude auth login' });
29
+ process.exit(2);
30
+ }
31
+ const refresh = await forceRefresh();
32
+ if (!refresh.ok) {
33
+ emit({ stage: 'refresh', ok: false, status, refresh });
34
+ process.exit(3);
35
+ }
36
+ emit({ stage: 'done', ok: true, email: status.email, subscription: status.subscriptionType, durationMs: refresh.durationMs });
37
+ process.exit(0);
38
+ } catch (e) {
39
+ emit({ stage: 'error', ok: false, error: e.message, stack: e.stack });
40
+ process.exit(1);
41
+ }
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI wrapper that prints auth status as JSON, including a real probe.
4
+ *
5
+ * Unlike `claude auth status --json` which only checks "does a token object
6
+ * exist on disk", this also fires a 1-token probe to verify the access
7
+ * token is actually accepted by Anthropic right now. That's the difference
8
+ * between "loggedIn:true" (cheap) and "verified:true" (real).
9
+ *
10
+ * Pass --quick to skip the probe.
11
+ *
12
+ * Usage:
13
+ * node scripts/auth-status.js
14
+ * node scripts/auth-status.js --quick
15
+ * npm run auth:status
16
+ */
17
+
18
+ import { getAuthStatus, forceRefresh } from './auth-helper.js';
19
+
20
+ const quick = process.argv.includes('--quick');
21
+
22
+ const status = await getAuthStatus();
23
+ let verified = null;
24
+ let probe = null;
25
+ if (!quick && status.ok && status.loggedIn) {
26
+ probe = await forceRefresh();
27
+ verified = !!probe.ok;
28
+ }
29
+
30
+ const out = {
31
+ ts: new Date().toISOString(),
32
+ ...status,
33
+ ...(quick ? {} : { verified, probeMs: probe?.durationMs, probeError: probe?.error }),
34
+ };
35
+ process.stdout.write(JSON.stringify(out, null, 2) + '\n');
36
+ process.exit(status.ok && status.loggedIn && (quick || verified) ? 0 : 1);