tautau-mcp 0.1.0 → 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.
package/README.md CHANGED
@@ -99,6 +99,26 @@ Example: *"Transcribe the voice memo at /Users/me/recordings/idea.m4a"*
99
99
 
100
100
  No arguments. Returns the current quota snapshot for your IP: plan, used/limit, remaining, and reset time.
101
101
 
102
+ ### `get_scan`
103
+
104
+ Fetch a page-scan debug bundle by its trace id.
105
+
106
+ | Argument | Type | Required | Notes |
107
+ | --- | --- | --- | --- |
108
+ | `scanId` | string | when signed out | The 32-char hex trace id the extension popup shows after **Scan this page**; omit when signed in to get your latest scan |
109
+
110
+ Returns the scan meta (page, status, bundle size, timings), the **service-side RPC trace** (every API call the extension made while the scan ran, with latencies), and a digest of the captured bundle: console errors, failed network requests (with error response bodies), pinned elements, voice/agent actions, the page outline, and the extension's own log. Signed out, the scan id is a capability — anyone holding it can read the scan. Signed in (`auth_login`), reads go through the owner API.
111
+
112
+ Example: *"Fetch tautau scan 9f2c… and tell me why the voice command didn't click the button"*
113
+
114
+ ### `auth_login` / `auth_status` / `auth_logout`
115
+
116
+ `auth_login` opens your browser for Google/email sign-in (same Firebase auth as the web app). The page hands the tokens to a **loopback callback** the MCP server is listening on — tokens never leave your machine. The session is stored at `~/.config/tautau/auth.json` (mode 0600) and auto-refreshes. `auth_status` shows who you're signed in as; `auth_logout` deletes the session.
117
+
118
+ ### `list_scans`
119
+
120
+ Requires `auth_login`. Lists your page scans, newest first (id, status, time, size, page). Pair with `get_scan` to debug: *"List my tautau scans and pull the latest one — why did the page command fail?"*
121
+
102
122
  ## Quotas & anonymity
103
123
 
104
124
  - The server sends no credentials; tautau tracks anonymous usage by client IP.
package/install.sh CHANGED
@@ -112,4 +112,4 @@ fi
112
112
 
113
113
  bold "Done."
114
114
  info "Verify any client with: npx -y tautau-mcp (Ctrl-C to stop; it speaks MCP on stdio)."
115
- info "Tools: transcribe_url, transcribe_audio, get_quota — anonymous quota is tracked per IP."
115
+ info "Tools: transcribe_url, transcribe_audio, get_quota, get_scan, list_scans + auth_login/status/logout — anonymous quota is tracked per IP; auth_login unlocks owner scans."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tautau-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "MCP server for tautau — transcribe YouTube links, podcast/audio URLs, and local audio files to text from any MCP client",
5
5
  "keywords": ["mcp", "model-context-protocol", "transcription", "speech-to-text", "youtube-transcript", "podcast-transcript", "tautau"],
6
6
  "homepage": "https://tautau.xyz",
package/src/index.js CHANGED
@@ -7,10 +7,15 @@
7
7
  * GET /api/transcribe-url?id=<jobId> → poll a pending job
8
8
  * POST /api/transcribe multipart: file, language, cleanup=true
9
9
  * GET /api/user/status → quota snapshot
10
+ * GET /api/scan/<id> → page-scan debug bundle (trace id = capability)
11
+ * GET /api/scans + /api/scans/<id> → owner scan list/read (Firebase bearer)
10
12
  *
11
- * No auth: usage is anonymous and IP-tracked by the tautau service
12
- * (5 lifetime URL transcripts / 5 lifetime dictations). Quota walls
13
- * come back as HTTP 429 with a {plan, used, limit, unit, resetsAt} body.
13
+ * Transcription needs no auth: usage is anonymous and IP-tracked by the
14
+ * tautau service (5 lifetime URL transcripts / 5 lifetime dictations). Quota
15
+ * walls come back as HTTP 429 with a {plan, used, limit, unit, resetsAt} body.
16
+ * Owner-level scan tools use auth_login: a browser sign-in against
17
+ * /cli-auth hands tokens to a loopback callback; the session is stored at
18
+ * ~/.config/tautau/auth.json and auto-refreshes.
14
19
  */
15
20
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
16
21
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
@@ -18,8 +23,11 @@ import {
18
23
  CallToolRequestSchema,
19
24
  ListToolsRequestSchema,
20
25
  } from '@modelcontextprotocol/sdk/types.js';
21
- import { readFile, stat } from 'node:fs/promises';
22
- import { basename, extname } from 'node:path';
26
+ import { readFile, stat, writeFile, mkdir, rm } from 'node:fs/promises';
27
+ import { basename, extname, join } from 'node:path';
28
+ import http from 'node:http';
29
+ import os from 'node:os';
30
+ import { exec } from 'node:child_process';
23
31
 
24
32
  const API_BASE = (process.env.TAUTAU_API_BASE || 'https://tautau.xyz').replace(/\/+$/, '');
25
33
  const LOGIN_URL = `${API_BASE}/login`;
@@ -247,6 +255,299 @@ async function getQuota() {
247
255
  return ok(lines.join('\n'));
248
256
  }
249
257
 
258
+ // ── auth: Firebase session via the /cli-auth loopback callback ──────────
259
+ //
260
+ // auth_login starts a loopback listener and opens ${API_BASE}/cli-auth in the
261
+ // system browser; after Firebase sign-in the page bounces the tokens to the
262
+ // loopback callback (query string — a fragment never reaches an HTTP server).
263
+ // The session lives at ~/.config/tautau/auth.json (0600) and idTokens refresh
264
+ // via securetoken.googleapis.com, same as the Chrome extension.
265
+
266
+ const SESSION_PATH = join(os.homedir(), '.config', 'tautau', 'auth.json');
267
+ const LOGIN_TIMEOUT_MS = 3 * 60_000;
268
+
269
+ async function loadSession() {
270
+ try {
271
+ const s = JSON.parse(await readFile(SESSION_PATH, 'utf8'));
272
+ return s && s.refreshToken ? s : null;
273
+ } catch {
274
+ return null;
275
+ }
276
+ }
277
+
278
+ async function saveSession(s) {
279
+ await mkdir(join(SESSION_PATH, '..'), { recursive: true });
280
+ await writeFile(SESSION_PATH, JSON.stringify(s, null, 2), { mode: 0o600 });
281
+ }
282
+
283
+ /** A live Firebase idToken, refreshing via the refresh token when near expiry. Null = signed out. */
284
+ async function getValidToken() {
285
+ const s = await loadSession();
286
+ if (!s) return null;
287
+ if (Date.now() < (s.expiresAt || 0) - 60_000) return s.idToken;
288
+ try {
289
+ const res = await fetch(`https://securetoken.googleapis.com/v1/token?key=${encodeURIComponent(s.apiKey)}`, {
290
+ method: 'POST',
291
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
292
+ body: `grant_type=refresh_token&refresh_token=${encodeURIComponent(s.refreshToken)}`,
293
+ signal: AbortSignal.timeout(15_000),
294
+ });
295
+ if (!res.ok) return null; // refresh revoked/expired — force re-login
296
+ const data = await res.json();
297
+ const next = {
298
+ ...s,
299
+ idToken: data.id_token,
300
+ refreshToken: data.refresh_token || s.refreshToken,
301
+ expiresAt: Date.now() + Number(data.expires_in || 3600) * 1000,
302
+ };
303
+ await saveSession(next);
304
+ return next.idToken;
305
+ } catch {
306
+ return null;
307
+ }
308
+ }
309
+
310
+ function openBrowser(url) {
311
+ const quoted = `"${url.replace(/"/g, '\\"')}"`;
312
+ const cmd = process.platform === 'darwin' ? `open ${quoted}` : process.platform === 'win32' ? `start "" ${quoted}` : `xdg-open ${quoted}`;
313
+ exec(cmd, () => { /* a failed open still leaves the URL in the tool output */ });
314
+ }
315
+
316
+ async function authLogin() {
317
+ return new Promise((resolve) => {
318
+ let done = false;
319
+ const finish = (result) => {
320
+ if (done) return;
321
+ done = true;
322
+ clearTimeout(timer);
323
+ server.close();
324
+ resolve(result);
325
+ };
326
+ const server = http.createServer((req, res) => {
327
+ const u = new URL(req.url || '/', 'http://127.0.0.1');
328
+ if (u.pathname !== '/callback') {
329
+ res.writeHead(404).end();
330
+ return;
331
+ }
332
+ const idToken = u.searchParams.get('idToken') || '';
333
+ if (!idToken) {
334
+ res.writeHead(400, { 'Content-Type': 'text/html' });
335
+ res.end('<h2>tautau CLI sign-in failed — no token received. You can close this tab.</h2>');
336
+ return;
337
+ }
338
+ const session = {
339
+ idToken,
340
+ refreshToken: u.searchParams.get('refreshToken') || '',
341
+ expiresAt: Number(u.searchParams.get('expiresAt')) || Date.now() + 3500e3,
342
+ email: u.searchParams.get('email') || '',
343
+ apiKey: u.searchParams.get('apiKey') || '',
344
+ };
345
+ res.writeHead(200, { 'Content-Type': 'text/html' });
346
+ res.end(
347
+ '<div style="font-family:system-ui;text-align:center;margin-top:80px">' +
348
+ '<h2>tautau CLI connected</h2><p>Signed in as <b>' +
349
+ (session.email || 'your account') +
350
+ '</b>. You can close this tab and return to your agent.</p></div>'
351
+ );
352
+ saveSession(session)
353
+ .then(() => finish(ok(`Signed in as ${session.email || '(unknown account)'}. Session saved to ${SESSION_PATH} — owner-level tools (list_scans, authed get_scan) are now active.`)))
354
+ .catch((err) => finish(fail(`Token received but could not save the session: ${err.message}`)));
355
+ });
356
+ const timer = setTimeout(() => {
357
+ finish(fail(`Sign-in timed out after 3 minutes — run auth_login again.`));
358
+ }, LOGIN_TIMEOUT_MS);
359
+ server.listen(0, '127.0.0.1', () => {
360
+ const port = server.address().port;
361
+ const authUrl = `${API_BASE}/cli-auth?redirect_uri=${encodeURIComponent(`http://127.0.0.1:${port}/callback`)}`;
362
+ openBrowser(authUrl);
363
+ console.error(`[tautau-mcp] waiting for sign-in: ${authUrl}`);
364
+ });
365
+ server.on('error', (err) => finish(fail(`Could not start the loopback listener: ${err.message}`)));
366
+ });
367
+ }
368
+
369
+ async function authStatus() {
370
+ const s = await loadSession();
371
+ if (!s) return ok('Not signed in. Run auth_login to connect your tautau account.');
372
+ const token = await getValidToken();
373
+ if (!token) return ok(`Session for ${s.email || '(unknown)'} exists but the token could not be refreshed — run auth_login again.`);
374
+ return ok(`Signed in as ${s.email || '(unknown account)'} · idToken valid until ${new Date(s.expiresAt || 0).toISOString()} (auto-refreshes).`);
375
+ }
376
+
377
+ async function authLogout() {
378
+ await rm(SESSION_PATH, { force: true });
379
+ return ok('Signed out — the saved tautau session was deleted.');
380
+ }
381
+
382
+ /** Fetch helper with the Firebase bearer when signed in; null when signed out. */
383
+ async function authedFetch(path) {
384
+ const token = await getValidToken();
385
+ if (!token) return null;
386
+ return fetch(`${API_BASE}${path}`, {
387
+ headers: { Authorization: `Bearer ${token}` },
388
+ signal: AbortSignal.timeout(15_000),
389
+ });
390
+ }
391
+
392
+ async function listScans() {
393
+ const s = await loadSession();
394
+ if (!s) return fail('Not signed in — run auth_login first (opens a browser sign-in, tokens stay on this machine).');
395
+ let res;
396
+ try {
397
+ res = await authedFetch('/api/scans');
398
+ } catch (err) {
399
+ return fail(`Could not reach ${API_BASE}: ${err.message}`);
400
+ }
401
+ if (!res) return fail('Session expired — run auth_login again.');
402
+ if (!res.ok) return httpError(res);
403
+ const scans = await res.json().catch(() => []);
404
+ if (!Array.isArray(scans) || !scans.length) {
405
+ return ok('No page scans yet. In the extension popup, use "Scan this page" — scans appear here.');
406
+ }
407
+ const lines = [`tautau page scans (${scans.length}, newest first):`];
408
+ scans.slice(0, 25).forEach((sc) => {
409
+ lines.push(
410
+ ` ${sc.id} · ${sc.status || '?'} · ${sc.startedAt || '?'} · ${sc.bytes ? `${(Number(sc.bytes) / 1024).toFixed(1)}KB` : '—'} · ${sc.title || sc.url || '(untitled)'}`
411
+ );
412
+ });
413
+ if (scans.length > 25) lines.push(` … and ${scans.length - 25} more`);
414
+ lines.push('\nUse get_scan with one of these ids for the full trace + bundle digest.');
415
+ return ok(lines.join('\n'));
416
+ }
417
+
418
+
419
+ // ── get_scan: pull a page-scan debug bundle by its trace id ──────────────
420
+
421
+ const MAX_DIGEST_ITEMS = 15;
422
+
423
+ function fmtBundleTime(t) {
424
+ return typeof t === 'number' ? `+${t}ms` : '+?ms';
425
+ }
426
+
427
+ /** One line per event, capped — the digest is for an LLM context window. */
428
+ function digestEvents(events, predicate, format) {
429
+ const matched = (events || []).filter(predicate);
430
+ const shown = matched.slice(0, MAX_DIGEST_ITEMS).map(format);
431
+ if (matched.length > MAX_DIGEST_ITEMS) shown.push(`… and ${matched.length - MAX_DIGEST_ITEMS} more`);
432
+ return shown;
433
+ }
434
+
435
+ async function getScan({ scanId }) {
436
+ // Signed in → owner read (sees collecting scans, no IP throttle); the scan
437
+ // id defaults to your latest scan. Signed out → public capability read
438
+ // (the id IS the credential) and the id is required.
439
+ let id = scanId;
440
+ let res = null;
441
+ const token = await getValidToken();
442
+ if (token && !id) {
443
+ try {
444
+ const list = await authedFetch('/api/scans');
445
+ const scans = list && list.ok ? await list.json().catch(() => []) : [];
446
+ if (Array.isArray(scans) && scans.length) id = scans[0].id;
447
+ } catch { /* fall through to the missing-id error */ }
448
+ if (!id) return fail('No scans on your account yet — run one from the extension popup ("Scan this page").');
449
+ }
450
+ if (!id) return fail('Missing required argument: scanId (or run auth_login — get_scan then defaults to your latest scan).');
451
+ if (token) {
452
+ try {
453
+ const r = await authedFetch(`/api/scans/${encodeURIComponent(id)}`);
454
+ if (r && r.ok) res = r;
455
+ } catch { /* fall back to the public read below */ }
456
+ }
457
+ if (!res) {
458
+ try {
459
+ res = await fetch(`${API_BASE}/api/scan/${encodeURIComponent(id)}`, {
460
+ signal: AbortSignal.timeout(15_000),
461
+ });
462
+ } catch (err) {
463
+ return fail(`Could not reach ${API_BASE}: ${err.message}`);
464
+ }
465
+ }
466
+ if (!res.ok) return httpError(res);
467
+ const meta = await res.json().catch(() => ({}));
468
+
469
+ // The scan meta carries a 1h signed GET for the bundle — fetch it direct
470
+ // from GCS (can be up to 5MB, so it never proxies through the web app).
471
+ let bundle = null;
472
+ if (meta.sessionUrl) {
473
+ try {
474
+ const bres = await fetch(meta.sessionUrl, { signal: AbortSignal.timeout(30_000) });
475
+ if (bres.ok) bundle = await bres.json();
476
+ } catch { /* digest still carries meta + trace without the bundle */ }
477
+ }
478
+
479
+ const out = [];
480
+ out.push(`tautau page scan ${meta.id || id}`);
481
+ out.push(
482
+ `Status: ${meta.status || 'unknown'} · ${meta.bytes ? `${(Number(meta.bytes) / 1024).toFixed(1)}KB bundle` : 'no bundle'} · ` +
483
+ `${meta.startedAt || '?'} → ${meta.finishedAt || '…'}`
484
+ );
485
+ if (meta.url || meta.title) out.push(`Page: ${meta.title || '(untitled)'} — ${meta.url || ''}`);
486
+
487
+ const trace = Array.isArray(meta.trace) ? meta.trace : [];
488
+ out.push(`\nService trace (${trace.length} lines — RPCs the extension made while the scan ran):`);
489
+ out.push(trace.length ? trace.map((l) => ` ${l}`).join('\n') : ' (none — no traced API calls during this scan)');
490
+
491
+ if (bundle) {
492
+ const events = Array.isArray(bundle.events) ? bundle.events : [];
493
+ const s = bundle.summary || {};
494
+ out.push(
495
+ `\nBundle summary: ${s.consoleErrors ?? 0} console errors · ${s.consoleWarns ?? 0} warns · ` +
496
+ `${s.networkRequests ?? 0} requests (${s.networkFailures ?? 0} failed) · ${s.clicks ?? 0} clicks · ` +
497
+ `${s.pins ?? 0} pins · ${s.agentActions ?? 0} agent actions`
498
+ );
499
+
500
+ const consoleErrors = digestEvents(
501
+ events,
502
+ (e) => e.kind === 'console' && e.level === 'error',
503
+ (e) => ` ${fmtBundleTime(e.t)} ${e.text || ''}`
504
+ );
505
+ if (consoleErrors.length) out.push(`\nConsole errors:\n${consoleErrors.join('\n')}`);
506
+
507
+ const netFails = digestEvents(
508
+ events,
509
+ (e) => e.kind === 'network' && (e.status >= 400 || e.status === 0),
510
+ (e) =>
511
+ ` ${fmtBundleTime(e.t)} ${e.method || 'GET'} ${e.url || ''} → ${e.status || e.error || '?'}` +
512
+ (e.body ? `\n body: ${String(e.body).slice(0, 300)}` : '')
513
+ );
514
+ if (netFails.length) out.push(`\nNetwork failures:\n${netFails.join('\n')}`);
515
+
516
+ const pins = digestEvents(
517
+ events,
518
+ (e) => e.kind === 'element_pin',
519
+ (e) => ` ${fmtBundleTime(e.t)} <${e.tag || '?'}> ${e.selector || ''} — "${(e.text || '').slice(0, 120)}"`
520
+ );
521
+ if (pins.length) out.push(`\nPinned elements:\n${pins.join('\n')}`);
522
+
523
+ const agent = digestEvents(
524
+ events,
525
+ (e) => e.kind === 'agent',
526
+ (e) =>
527
+ ` ${fmtBundleTime(e.t)} "${e.command || ''}" → ${e.action || '?'}${e.index >= 0 ? ` [${e.index}]` : ''} ` +
528
+ `${e.ok ? 'ok' : 'FAILED'}${e.reason ? ` (${e.reason})` : ''}${e.selector ? ` on ${e.selector}` : ''}`
529
+ );
530
+ if (agent.length) out.push(`\nVoice/agent actions:\n${agent.join('\n')}`);
531
+
532
+ const snaps = Array.isArray(bundle.snapshots) ? bundle.snapshots : [];
533
+ if (snaps.length && snaps[0].outline) {
534
+ const o = snaps[0].outline;
535
+ const headings = (o.headings || []).slice(0, 10).map((h) => ` ${'#'.repeat(Math.min(h.level || 1, 6))} ${h.text || ''}`);
536
+ out.push(
537
+ `\nPage outline: ${(o.interactive || []).length} interactive elements, ` +
538
+ `${(snaps[0].assets || []).length} assets loaded${headings.length ? `\n${headings.join('\n')}` : ''}`
539
+ );
540
+ }
541
+
542
+ const extLog = Array.isArray(bundle.extensionLog) ? bundle.extensionLog : [];
543
+ if (extLog.length) out.push(`\nExtension log:\n${extLog.map((l) => ` ${l}`).join('\n')}`);
544
+ } else {
545
+ out.push('\n(Bundle could not be fetched — meta + service trace above are still authoritative.)');
546
+ }
547
+
548
+ return ok(out.join('\n'));
549
+ }
550
+
250
551
  const TOOLS = [
251
552
  {
252
553
  name: 'transcribe_url',
@@ -290,10 +591,50 @@ const TOOLS = [
290
591
  description: 'Check the current tautau transcription quota (anonymous usage is tracked per IP).',
291
592
  inputSchema: { type: 'object', properties: {} },
292
593
  },
594
+ {
595
+ name: 'get_scan',
596
+ description:
597
+ 'Fetch a tautau page-scan debug bundle by its trace id (from the extension popup "Scan this page"). ' +
598
+ 'Returns the scan meta, the service-side RPC trace, and a digest of the captured bundle: console errors, ' +
599
+ 'failed requests (with response bodies), pinned elements, voice/agent actions, page outline, and the extension log. ' +
600
+ 'Signed in (auth_login): owner read — omit scanId to get your latest scan. ' +
601
+ 'Signed out: the scan id is required and works as a capability — anyone holding it can read the scan.',
602
+ inputSchema: {
603
+ type: 'object',
604
+ properties: {
605
+ scanId: { type: 'string', description: 'The 32-char hex scan/trace id. Optional when signed in — defaults to your latest scan.' },
606
+ },
607
+ },
608
+ },
609
+ {
610
+ name: 'auth_login',
611
+ description:
612
+ 'Sign the MCP server into a tautau account: opens a browser window for Google/email sign-in and stores ' +
613
+ 'the session locally (~/.config/tautau/auth.json, tokens never leave the machine). Unlocks owner-level ' +
614
+ 'tools (list_scans, authed get_scan).',
615
+ inputSchema: { type: 'object', properties: {} },
616
+ },
617
+ {
618
+ name: 'auth_status',
619
+ description: 'Show which tautau account the MCP server is signed in as (and whether the token refreshes).',
620
+ inputSchema: { type: 'object', properties: {} },
621
+ },
622
+ {
623
+ name: 'auth_logout',
624
+ description: 'Sign the MCP server out and delete the locally stored tautau session.',
625
+ inputSchema: { type: 'object', properties: {} },
626
+ },
627
+ {
628
+ name: 'list_scans',
629
+ description:
630
+ 'List your tautau page scans (newest first: id, status, time, size, page). Requires auth_login. ' +
631
+ 'Use get_scan with one of the ids for the full trace + bundle digest.',
632
+ inputSchema: { type: 'object', properties: {} },
633
+ },
293
634
  ];
294
635
 
295
636
  const server = new Server(
296
- { name: 'tautau-mcp', version: '0.1.0' },
637
+ { name: 'tautau-mcp', version: '0.3.0' },
297
638
  { capabilities: { tools: {} } }
298
639
  );
299
640
 
@@ -312,6 +653,18 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
312
653
  return await transcribeAudio(args);
313
654
  case 'get_quota':
314
655
  return await getQuota();
656
+ case 'get_scan':
657
+ if (args.scanId !== undefined && typeof args.scanId !== 'string')
658
+ return fail('scanId must be a string');
659
+ return await getScan(args);
660
+ case 'auth_login':
661
+ return await authLogin();
662
+ case 'auth_status':
663
+ return await authStatus();
664
+ case 'auth_logout':
665
+ return await authLogout();
666
+ case 'list_scans':
667
+ return await listScans();
315
668
  default:
316
669
  return fail(`Unknown tool: ${name}`);
317
670
  }