tautau-mcp 0.1.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 ADDED
@@ -0,0 +1,141 @@
1
+ # tautau-mcp
2
+
3
+ An MCP (Model Context Protocol) stdio server that gives LLM clients — Claude Code, Claude Desktop, Codex, anything that speaks MCP — access to [tautau](https://tautau.xyz) transcription:
4
+
5
+ - **YouTube links** → transcript (captions-first, returns in seconds)
6
+ - **Podcast / direct-audio URLs** → transcript (server-side speech-to-text, long jobs poll automatically)
7
+ - **Local audio files** (mp3/wav/m4a/ogg/webm/mp4, up to 25MB) → transcript
8
+
9
+ No API key, no account: usage is anonymous and quota-tracked per IP by the tautau service (5 lifetime URL transcripts, 5 lifetime dictations). When you hit the wall the tools say so and point at [signup](https://tautau.xyz/login) / [pricing](https://tautau.xyz/pricing) — a free account gets a daily allowance.
10
+
11
+ ## 30-second install
12
+
13
+ Node.js >= 18 required. The server runs via `npx` — there is nothing to install globally.
14
+
15
+ **One-shot installer** (detects Claude Code / Codex / Claude Desktop and wires them up, prompting per client):
16
+
17
+ ```bash
18
+ curl -fsSL https://tautau.xyz/mcp/install.sh | bash
19
+ # or from this repo:
20
+ bash mcp/install.sh --yes
21
+ ```
22
+
23
+ Or configure your client by hand:
24
+
25
+ **Claude Code**
26
+
27
+ ```bash
28
+ claude mcp add tautau -- npx -y tautau-mcp
29
+ ```
30
+
31
+ **Claude Desktop** — edit `claude_desktop_config.json`
32
+ (macOS: `~/Library/Application Support/Claude/`, Linux: `~/.config/Claude/`):
33
+
34
+ ```json
35
+ {
36
+ "mcpServers": {
37
+ "tautau": {
38
+ "command": "npx",
39
+ "args": ["-y", "tautau-mcp"]
40
+ }
41
+ }
42
+ }
43
+ ```
44
+
45
+ **Codex** — append to `~/.codex/config.toml`:
46
+
47
+ ```toml
48
+ [mcp_servers.tautau]
49
+ command = "npx"
50
+ args = ["-y", "tautau-mcp"]
51
+ ```
52
+
53
+ Restart the client after editing config. Verify with `get_quota` (see below).
54
+
55
+ ### Environment
56
+
57
+ | Variable | Default | Purpose |
58
+ | --- | --- | --- |
59
+ | `TAUTAU_API_BASE` | `https://tautau.xyz` | API base URL — only override for self-hosted/dev deployments |
60
+
61
+ ## Tools
62
+
63
+ ### `transcribe_url`
64
+
65
+ Transcribe a YouTube video, podcast episode, or direct audio URL.
66
+
67
+ | Argument | Type | Required | Notes |
68
+ | --- | --- | --- | --- |
69
+ | `url` | string | yes | Full `https://` link |
70
+ | `language` | string | no | BCP-47 hint, e.g. `"en"` |
71
+ | `cleanup` | boolean | no | AI polish: punctuation, filler removal |
72
+
73
+ YouTube resolves from captions inline; audio/podcast links return `pending` and the server polls every 3s for up to 6 minutes. Long transcripts are returned in full.
74
+
75
+ Example: *"Transcribe https://www.youtube.com/watch?v=UF8uR6Z6KLc"* →
76
+
77
+ ```
78
+ Title: Steve Jobs' 2005 Stanford Commencement Address
79
+ Duration: 15:04
80
+ Source: youtube-captions
81
+ Quota: 1/5 transcripts used on the anonymous plan
82
+
83
+ [full transcript text…]
84
+ ```
85
+
86
+ ### `transcribe_audio`
87
+
88
+ Transcribe a local audio file.
89
+
90
+ | Argument | Type | Required | Notes |
91
+ | --- | --- | --- | --- |
92
+ | `filePath` | string | yes | Absolute path; mp3/wav/m4a/ogg/webm/mp4, max 25MB |
93
+ | `language` | string | no | BCP-47 hint |
94
+ | `cleanup` | boolean | no | AI polish |
95
+
96
+ Example: *"Transcribe the voice memo at /Users/me/recordings/idea.m4a"*
97
+
98
+ ### `get_quota`
99
+
100
+ No arguments. Returns the current quota snapshot for your IP: plan, used/limit, remaining, and reset time.
101
+
102
+ ## Quotas & anonymity
103
+
104
+ - The server sends no credentials; tautau tracks anonymous usage by client IP.
105
+ - Anonymous: 5 lifetime YouTube/URL transcripts + 5 lifetime audio dictations.
106
+ - Free account: 5 URL transcripts/day + 15 min of audio per month — [sign up](https://tautau.xyz/login).
107
+ - Paid plans raise the audio-minute pool — [pricing](https://tautau.xyz/pricing).
108
+ - On HTTP 429 the tools return a readable message with used/limit/reset time and the relevant link. On 403 `sign_up_required` (anonymous audio-link jobs) they explain that a free account is needed.
109
+ - Anonymous transcripts are never persisted server-side; nothing is saved to a history.
110
+
111
+ ## Development
112
+
113
+ ```bash
114
+ cd mcp
115
+ npm install
116
+ npm run smoke # boots the server over stdio, lists tools, calls get_quota (read-only)
117
+ ```
118
+
119
+ From the repo root: `task mcp:smoke`.
120
+
121
+ Plain ESM JavaScript, zero build step, one runtime dependency (`@modelcontextprotocol/sdk`). The smoke test never burns transcribe quota — only `get_quota` and tool-listing run.
122
+
123
+ ## Marketplace listings
124
+
125
+ ### Smithery
126
+
127
+ 1. `smithery.yaml` ships in this package (stdio, no required config).
128
+ 2. Push the repo, then at [smithery.ai](https://smithery.ai) choose **Publish server → GitHub**, point it at this repo and set the server root to `mcp/`.
129
+ 3. Smithery builds from `smithery.yaml`; the optional `tautauApiBase` config field maps to `TAUTAU_API_BASE`.
130
+ 4. Verify the deployment's tool list shows `transcribe_url`, `transcribe_audio`, `get_quota`, then publish.
131
+
132
+ ### Official MCP servers registry
133
+
134
+ 1. Fork [modelcontextprotocol/servers](https://github.com/modelcontextprotocol/servers).
135
+ 2. Add a row to the **Third-Party Servers** section of the README: `tautau` — YouTube, podcast, and local-audio transcription ([tautau.xyz](https://tautau.xyz)), linking `https://github.com/castlemilk/tautau/tree/main/mcp`.
136
+ 3. Keep the list alphabetized and match the existing row format (name, one-line description, link).
137
+ 4. Open the PR against `main`; CI checks formatting only.
138
+
139
+ ## License
140
+
141
+ MIT
package/install.sh ADDED
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env bash
2
+ # tautau-mcp installer — configures MCP clients to use the tautau server.
3
+ # Usage: curl -fsSL https://tautau.xyz/mcp/install.sh | bash
4
+ # ./install.sh --yes # auto-accept every prompt
5
+ set -euo pipefail
6
+
7
+ YES=0
8
+ for arg in "$@"; do
9
+ case "$arg" in
10
+ --yes|-y) YES=1 ;;
11
+ --help|-h) echo "Usage: install.sh [--yes]"; exit 0 ;;
12
+ esac
13
+ done
14
+
15
+ bold() { printf '\033[1m%s\033[0m\n' "$1"; }
16
+ info() { printf ' %s\n' "$1"; }
17
+ skip() { printf ' → skipped: %s\n' "$1"; }
18
+
19
+ confirm() {
20
+ [ "$YES" = "1" ] && return 0
21
+ local reply
22
+ read -r -p "$1 [y/N] " reply </dev/tty || return 1
23
+ [[ "$reply" =~ ^[Yy]$ ]]
24
+ }
25
+
26
+ command -v node >/dev/null || { echo "node is required (>=18). Install Node.js first."; exit 1; }
27
+
28
+ bold "tautau-mcp installer"
29
+ info "The server itself runs via 'npx -y tautau-mcp' — nothing to install globally."
30
+ info "This script wires it into the MCP clients it finds on this machine."
31
+ echo
32
+
33
+ # ── Claude Code ──────────────────────────────────────────────────────────────
34
+ if command -v claude >/dev/null; then
35
+ bold "Claude Code detected"
36
+ info "Will run: claude mcp add tautau -- npx -y tautau-mcp"
37
+ if confirm "Configure Claude Code?"; then
38
+ if claude mcp add tautau -- npx -y tautau-mcp; then
39
+ info "Added 'tautau' to Claude Code MCP servers."
40
+ else
41
+ skip "claude mcp add failed (already registered? check 'claude mcp list')"
42
+ fi
43
+ else
44
+ skip "Claude Code"
45
+ fi
46
+ echo
47
+ fi
48
+
49
+ # ── Codex ────────────────────────────────────────────────────────────────────
50
+ CODEX_DIR="$HOME/.codex"
51
+ CODEX_CFG="$CODEX_DIR/config.toml"
52
+ bold "Codex"
53
+ info "Will append to $CODEX_CFG:"
54
+ info ' [mcp_servers.tautau]'
55
+ info ' command = "npx"'
56
+ info ' args = ["-y", "tautau-mcp"]'
57
+ if [ -f "$CODEX_CFG" ] && grep -q '^\[mcp_servers\.tautau\]' "$CODEX_CFG"; then
58
+ skip "a [mcp_servers.tautau] block already exists in $CODEX_CFG — leaving it untouched"
59
+ elif confirm "Configure Codex?"; then
60
+ mkdir -p "$CODEX_DIR"
61
+ [ -f "$CODEX_CFG" ] && cp "$CODEX_CFG" "$CODEX_CFG.bak-$(date +%Y%m%d%H%M%S)"
62
+ {
63
+ echo ''
64
+ echo '[mcp_servers.tautau]'
65
+ echo 'command = "npx"'
66
+ echo 'args = ["-y", "tautau-mcp"]'
67
+ } >> "$CODEX_CFG"
68
+ info "Appended [mcp_servers.tautau] to $CODEX_CFG"
69
+ else
70
+ skip "Codex"
71
+ fi
72
+ echo
73
+
74
+ # ── Claude Desktop ───────────────────────────────────────────────────────────
75
+ case "$(uname -s)" in
76
+ Darwin) CLAUDE_DESKTOP_CFG="$HOME/Library/Application Support/Claude/claude_desktop_config.json" ;;
77
+ Linux) CLAUDE_DESKTOP_CFG="$HOME/.config/Claude/claude_desktop_config.json" ;;
78
+ *) CLAUDE_DESKTOP_CFG="" ;;
79
+ esac
80
+
81
+ if [ -n "$CLAUDE_DESKTOP_CFG" ]; then
82
+ bold "Claude Desktop"
83
+ info "Will merge mcpServers.tautau into $CLAUDE_DESKTOP_CFG"
84
+ if confirm "Configure Claude Desktop?"; then
85
+ mkdir -p "$(dirname "$CLAUDE_DESKTOP_CFG")"
86
+ if [ -f "$CLAUDE_DESKTOP_CFG" ]; then
87
+ cp "$CLAUDE_DESKTOP_CFG" "$CLAUDE_DESKTOP_CFG.bak-$(date +%Y%m%d%H%M%S)"
88
+ info "Backed up existing config next to the original (*.bak-*)"
89
+ fi
90
+ result=$(node -e '
91
+ const fs = require("fs");
92
+ const p = process.argv[1];
93
+ let cfg = {};
94
+ try { cfg = JSON.parse(fs.readFileSync(p, "utf8")); } catch {}
95
+ cfg.mcpServers = cfg.mcpServers || {};
96
+ if (cfg.mcpServers.tautau) { console.log("EXISTS"); process.exit(0); }
97
+ cfg.mcpServers.tautau = { command: "npx", args: ["-y", "tautau-mcp"] };
98
+ fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n");
99
+ console.log("ADDED");
100
+ ' "$CLAUDE_DESKTOP_CFG")
101
+ if [ "$result" = "ADDED" ]; then
102
+ info "Added mcpServers.tautau to $CLAUDE_DESKTOP_CFG"
103
+ info "Restart Claude Desktop to pick it up."
104
+ else
105
+ skip "mcpServers.tautau already present in $CLAUDE_DESKTOP_CFG — leaving it untouched"
106
+ fi
107
+ else
108
+ skip "Claude Desktop"
109
+ fi
110
+ echo
111
+ fi
112
+
113
+ bold "Done."
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."
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "tautau-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for tautau — transcribe YouTube links, podcast/audio URLs, and local audio files to text from any MCP client",
5
+ "keywords": ["mcp", "model-context-protocol", "transcription", "speech-to-text", "youtube-transcript", "podcast-transcript", "tautau"],
6
+ "homepage": "https://tautau.xyz",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/castlemilk/tautau.git",
10
+ "directory": "mcp"
11
+ },
12
+ "license": "MIT",
13
+ "type": "module",
14
+ "bin": {
15
+ "tautau-mcp": "./src/index.js"
16
+ },
17
+ "files": ["src", "install.sh", "smithery.yaml"],
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "dependencies": {
22
+ "@modelcontextprotocol/sdk": "^1.17.0"
23
+ },
24
+ "scripts": {
25
+ "start": "node src/index.js",
26
+ "smoke": "node scripts/smoke.mjs"
27
+ }
28
+ }
package/smithery.yaml ADDED
@@ -0,0 +1,19 @@
1
+ # Smithery deployment config — https://smithery.ai
2
+ # tautau-mcp is a stdio MCP server; it needs no build step and no secrets.
3
+ startCommand:
4
+ type: stdio
5
+ configSchema:
6
+ type: object
7
+ properties:
8
+ tautauApiBase:
9
+ type: string
10
+ title: tautau API base URL
11
+ description: Override the tautau API endpoint (defaults to https://tautau.xyz). Only needed for self-hosted/dev deployments.
12
+ required: []
13
+ commandFunction: |-
14
+ (config) => ({
15
+ command: 'npx',
16
+ args: ['-y', 'tautau-mcp'],
17
+ env: config.tautauApiBase ? { TAUTAU_API_BASE: config.tautauApiBase } : {}
18
+ })
19
+ exampleConfig: {}
package/src/index.js ADDED
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tautau-mcp — MCP stdio server exposing tautau's transcription API.
4
+ *
5
+ * Thin wrapper over https://tautau.xyz (override with TAUTAU_API_BASE):
6
+ * POST /api/transcribe-url {url, language?, cleanup?} → completed | pending+jobId
7
+ * GET /api/transcribe-url?id=<jobId> → poll a pending job
8
+ * POST /api/transcribe multipart: file, language, cleanup=true
9
+ * GET /api/user/status → quota snapshot
10
+ *
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.
14
+ */
15
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
16
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
17
+ import {
18
+ CallToolRequestSchema,
19
+ ListToolsRequestSchema,
20
+ } from '@modelcontextprotocol/sdk/types.js';
21
+ import { readFile, stat } from 'node:fs/promises';
22
+ import { basename, extname } from 'node:path';
23
+
24
+ const API_BASE = (process.env.TAUTAU_API_BASE || 'https://tautau.xyz').replace(/\/+$/, '');
25
+ const LOGIN_URL = `${API_BASE}/login`;
26
+ const PRICING_URL = `${API_BASE}/pricing`;
27
+ const MAX_AUDIO_BYTES = 25 * 1024 * 1024; // whisper-style upstream cap
28
+ const POLL_INTERVAL_MS = 3_000;
29
+ const POLL_CEILING_MS = 6 * 60_000; // long podcast jobs need minutes
30
+
31
+ const MIME_BY_EXT = {
32
+ '.mp3': 'audio/mpeg',
33
+ '.wav': 'audio/wav',
34
+ '.m4a': 'audio/mp4',
35
+ '.ogg': 'audio/ogg',
36
+ '.webm': 'audio/webm',
37
+ '.mp4': 'video/mp4',
38
+ };
39
+
40
+ const ok = (text) => ({ content: [{ type: 'text', text }] });
41
+ const fail = (text) => ({ content: [{ type: 'text', text }], isError: true });
42
+
43
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
44
+
45
+ function fmtDuration(seconds) {
46
+ if (typeof seconds !== 'number' || !Number.isFinite(seconds)) return null;
47
+ const m = Math.floor(seconds / 60);
48
+ const s = Math.round(seconds % 60);
49
+ return `${m}:${String(s).padStart(2, '0')}`;
50
+ }
51
+
52
+ function quotaLine(q) {
53
+ if (!q || typeof q !== 'object') return null;
54
+ const used = q.used ?? 0;
55
+ const limit = q.limit ?? 0;
56
+ const unit = q.unit || 'units';
57
+ const plan = q.plan || 'unknown';
58
+ const resets = q.resetsAt ? `, resets ${q.resetsAt}` : '';
59
+ return `Quota: ${used}/${limit} ${unit} used on the ${plan} plan${resets}`;
60
+ }
61
+
62
+ /** Some 429s carry the quota JSON as a stringified message (connect envelope). */
63
+ function parseQuotaMessage(msg) {
64
+ if (typeof msg !== 'string') return null;
65
+ try {
66
+ const parsed = JSON.parse(msg);
67
+ return parsed && typeof parsed === 'object' && 'plan' in parsed ? parsed : null;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /** Turn a non-2xx tautau API response into a readable tool error. */
74
+ async function httpError(res) {
75
+ const body = await res.json().catch(() => ({}));
76
+ const quota = body.quota || parseQuotaMessage(body.message);
77
+ if (res.status === 429) {
78
+ const detail = quota ? quotaLine(quota) : null;
79
+ const plan = quota?.plan;
80
+ const cta =
81
+ plan === 'anonymous'
82
+ ? `Create a free account for a daily allowance: ${LOGIN_URL} — or see plans: ${PRICING_URL}`
83
+ : `Upgrade for more transcription time: ${PRICING_URL}`;
84
+ return fail(
85
+ [
86
+ 'tautau quota exhausted.',
87
+ detail,
88
+ body.error,
89
+ cta,
90
+ ]
91
+ .filter(Boolean)
92
+ .join('\n')
93
+ );
94
+ }
95
+ if (res.status === 403 || body.signUpRequired) {
96
+ return fail(
97
+ [
98
+ body.error ||
99
+ 'This transcription needs a free tautau account (anonymous audio-link jobs are not allowed).',
100
+ `Sign up free: ${LOGIN_URL}`,
101
+ ].join('\n')
102
+ );
103
+ }
104
+ return fail(body.error || body.message || `tautau API error (HTTP ${res.status})`);
105
+ }
106
+
107
+ async function postJson(path, payload) {
108
+ return fetch(`${API_BASE}${path}`, {
109
+ method: 'POST',
110
+ headers: { 'Content-Type': 'application/json' },
111
+ body: JSON.stringify(payload),
112
+ signal: AbortSignal.timeout(30_000),
113
+ });
114
+ }
115
+
116
+ function transcriptResult(data) {
117
+ const head = [];
118
+ if (data.title) head.push(`Title: ${data.title}`);
119
+ const dur = fmtDuration(data.durationSeconds);
120
+ if (dur) head.push(`Duration: ${dur}`);
121
+ if (data.source) head.push(`Source: ${data.source}`);
122
+ const quota = quotaLine(data.quota);
123
+ if (quota) head.push(quota);
124
+ const text = (data.text || '').trim();
125
+ return ok([head.join('\n'), text].filter(Boolean).join('\n\n'));
126
+ }
127
+
128
+ async function transcribeUrl({ url, language, cleanup }) {
129
+ const payload = { url };
130
+ if (language) payload.language = language;
131
+ if (typeof cleanup === 'boolean') payload.cleanup = cleanup;
132
+
133
+ let res;
134
+ try {
135
+ res = await postJson('/api/transcribe-url', payload);
136
+ } catch (err) {
137
+ return fail(`Could not reach ${API_BASE}: ${err.message}`);
138
+ }
139
+ if (!res.ok) return httpError(res);
140
+
141
+ let data = await res.json();
142
+ if (data.status === 'completed') return transcriptResult(data);
143
+
144
+ if (data.status !== 'pending' || !data.jobId) {
145
+ return fail(`Unexpected response from tautau: ${JSON.stringify(data).slice(0, 500)}`);
146
+ }
147
+
148
+ // Audio/podcast job: poll until completed/failed (3s cadence, 6 min ceiling)
149
+ const deadline = Date.now() + POLL_CEILING_MS;
150
+ while (Date.now() < deadline) {
151
+ await sleep(POLL_INTERVAL_MS);
152
+ let poll;
153
+ try {
154
+ poll = await fetch(`${API_BASE}/api/transcribe-url?id=${encodeURIComponent(data.jobId)}`, {
155
+ signal: AbortSignal.timeout(15_000),
156
+ });
157
+ } catch {
158
+ continue; // transient network blip — keep polling
159
+ }
160
+ if (!poll.ok) return httpError(poll);
161
+ data = await poll.json();
162
+ if (data.status === 'completed') return transcriptResult(data);
163
+ if (data.status === 'failed') {
164
+ return fail(`Transcription job failed: ${data.error || 'unknown error'}`);
165
+ }
166
+ }
167
+ return fail(
168
+ `Job ${data.jobId} is still pending after 6 minutes. Call transcribe_url again with the same URL to keep waiting on it.`
169
+ );
170
+ }
171
+
172
+ async function transcribeAudio({ filePath, language, cleanup }) {
173
+ const ext = extname(filePath).toLowerCase();
174
+ const mime = MIME_BY_EXT[ext];
175
+ if (!mime) {
176
+ return fail(
177
+ `Unsupported file type "${ext || '(none)'}". Supported: ${Object.keys(MIME_BY_EXT).join(', ')}`
178
+ );
179
+ }
180
+
181
+ let info;
182
+ try {
183
+ info = await stat(filePath);
184
+ } catch {
185
+ return fail(`File not found or unreadable: ${filePath}`);
186
+ }
187
+ if (!info.isFile()) return fail(`Not a file: ${filePath}`);
188
+ if (info.size > MAX_AUDIO_BYTES) {
189
+ return fail(
190
+ `File is ${(info.size / 1024 / 1024).toFixed(1)}MB — over the 25MB limit. Trim or compress it, or host it and use transcribe_url instead.`
191
+ );
192
+ }
193
+
194
+ const buffer = await readFile(filePath);
195
+ const form = new FormData();
196
+ form.append('file', new Blob([buffer], { type: mime }), basename(filePath));
197
+ if (language) form.append('language', language);
198
+ if (typeof cleanup === 'boolean') form.append('cleanup', String(cleanup));
199
+
200
+ let res;
201
+ try {
202
+ res = await fetch(`${API_BASE}/api/transcribe`, {
203
+ method: 'POST',
204
+ body: form,
205
+ signal: AbortSignal.timeout(120_000), // audio uploads + STT can take a while
206
+ });
207
+ } catch (err) {
208
+ return fail(`Could not reach ${API_BASE}: ${err.message}`);
209
+ }
210
+ if (!res.ok) return httpError(res);
211
+
212
+ const data = await res.json();
213
+ const text = (data.text || '').trim();
214
+ if (!text) return fail('tautau returned an empty transcript for this audio.');
215
+ const head = [];
216
+ const quota = quotaLine(data.quota);
217
+ if (quota) head.push(quota);
218
+ return ok([head.join('\n'), text].filter(Boolean).join('\n\n'));
219
+ }
220
+
221
+ async function getQuota() {
222
+ let res;
223
+ try {
224
+ res = await fetch(`${API_BASE}/api/user/status`, { signal: AbortSignal.timeout(15_000) });
225
+ } catch (err) {
226
+ return fail(`Could not reach ${API_BASE}: ${err.message}`);
227
+ }
228
+ if (!res.ok) return httpError(res);
229
+ const s = await res.json().catch(() => ({}));
230
+ const lines = [
231
+ `tautau quota snapshot (${API_BASE})`,
232
+ `Plan: ${s.plan ?? 'unknown'}${s.subscriptionStatus ? ` (subscription: ${s.subscriptionStatus})` : ''}`,
233
+ ];
234
+ if (typeof s.used === 'number' || typeof s.limit === 'number') {
235
+ const remaining =
236
+ typeof s.remaining === 'number'
237
+ ? s.remaining
238
+ : Math.max(0, (s.limit ?? 0) - (s.used ?? 0));
239
+ lines.push(
240
+ `Dictations: ${s.used ?? 0}/${s.limit ?? 0} ${s.unit || 'dictations'} used (${remaining} remaining)`
241
+ );
242
+ }
243
+ lines.push(`Resets: ${s.resetsAt || 'never (lifetime allowance for anonymous use)'}`);
244
+ if (s.plan === 'anonymous') {
245
+ lines.push(`Free account (daily allowance): ${LOGIN_URL} · Paid plans: ${PRICING_URL}`);
246
+ }
247
+ return ok(lines.join('\n'));
248
+ }
249
+
250
+ const TOOLS = [
251
+ {
252
+ name: 'transcribe_url',
253
+ description:
254
+ 'Transcribe a YouTube video, podcast episode, or direct audio URL to text via tautau. ' +
255
+ 'YouTube links resolve from captions (fast); podcast/audio links run server-side speech-to-text and may take minutes. ' +
256
+ 'Anonymous use is quota-limited per IP.',
257
+ inputSchema: {
258
+ type: 'object',
259
+ properties: {
260
+ url: { type: 'string', description: 'Full https:// URL of a YouTube video, podcast episode, or audio file' },
261
+ language: { type: 'string', description: 'Optional BCP-47 language hint, e.g. "en", "es"' },
262
+ cleanup: {
263
+ type: 'boolean',
264
+ description: 'Optional: run AI cleanup (punctuation, filler removal) on the transcript',
265
+ },
266
+ },
267
+ required: ['url'],
268
+ },
269
+ },
270
+ {
271
+ name: 'transcribe_audio',
272
+ description:
273
+ 'Transcribe a local audio file (mp3/wav/m4a/ogg/webm/mp4, up to 25MB) to text via tautau. ' +
274
+ 'Anonymous use is quota-limited per IP.',
275
+ inputSchema: {
276
+ type: 'object',
277
+ properties: {
278
+ filePath: { type: 'string', description: 'Absolute path to a local audio file' },
279
+ language: { type: 'string', description: 'Optional BCP-47 language hint, e.g. "en", "es"' },
280
+ cleanup: {
281
+ type: 'boolean',
282
+ description: 'Optional: run AI cleanup (punctuation, filler removal) on the transcript',
283
+ },
284
+ },
285
+ required: ['filePath'],
286
+ },
287
+ },
288
+ {
289
+ name: 'get_quota',
290
+ description: 'Check the current tautau transcription quota (anonymous usage is tracked per IP).',
291
+ inputSchema: { type: 'object', properties: {} },
292
+ },
293
+ ];
294
+
295
+ const server = new Server(
296
+ { name: 'tautau-mcp', version: '0.1.0' },
297
+ { capabilities: { tools: {} } }
298
+ );
299
+
300
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
301
+
302
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
303
+ const { name, arguments: args = {} } = req.params;
304
+ try {
305
+ switch (name) {
306
+ case 'transcribe_url':
307
+ if (!args.url || typeof args.url !== 'string') return fail('Missing required argument: url');
308
+ return await transcribeUrl(args);
309
+ case 'transcribe_audio':
310
+ if (!args.filePath || typeof args.filePath !== 'string')
311
+ return fail('Missing required argument: filePath');
312
+ return await transcribeAudio(args);
313
+ case 'get_quota':
314
+ return await getQuota();
315
+ default:
316
+ return fail(`Unknown tool: ${name}`);
317
+ }
318
+ } catch (err) {
319
+ return fail(`tautau-mcp error: ${err.message}`);
320
+ }
321
+ });
322
+
323
+ const transport = new StdioServerTransport();
324
+ await server.connect(transport);
325
+ console.error(`tautau-mcp running (API base: ${API_BASE})`);