transcribego-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/LICENSE ADDED
@@ -0,0 +1,48 @@
1
+ TranscribeGo MCP License
2
+
3
+ Copyright (c) 2026 Rather Labs. All rights reserved.
4
+
5
+ 1. Definitions.
6
+ "Software" means the transcribego-mcp software made available by Rather Labs
7
+ under this License, in source or object form, together with its
8
+ documentation. "You" means the individual or entity exercising rights under
9
+ this License.
10
+
11
+ 2. Grant of Use.
12
+ Rather Labs grants You a worldwide, royalty-free, non-exclusive,
13
+ non-transferable, and non-sublicensable license to install and run
14
+ unmodified copies of the Software, for any purpose including commercial
15
+ purposes, solely as distributed by Rather Labs. This grant includes the
16
+ right to make the incidental copies strictly necessary to install and run
17
+ the Software (for example, downloading it from a package registry and
18
+ loading it into memory for execution).
19
+
20
+ 3. Restrictions.
21
+ Except as expressly permitted in Section 2, or to the extent applicable law
22
+ prohibits the restriction, You may not:
23
+ (a) copy, publish, distribute, sublicense, sell, rent, lease, or otherwise
24
+ make the Software available to any third party;
25
+ (b) modify, adapt, translate, or create derivative works based on the
26
+ Software;
27
+ (c) reverse engineer, decompile, or disassemble the Software, except to the
28
+ extent applicable law expressly permits despite this limitation;
29
+ (d) remove, obscure, or alter any copyright, trademark, or other proprietary
30
+ notice contained in the Software.
31
+
32
+ 4. Reservation of Rights.
33
+ The Software is licensed, not sold. Rather Labs retains all right, title, and
34
+ interest in and to the Software, including all intellectual property rights.
35
+ No rights are granted to You except as expressly set out in this License.
36
+
37
+ 5. Termination.
38
+ This License terminates automatically if You breach any of its terms. Upon
39
+ termination You must cease all use of the Software and destroy all copies in
40
+ Your possession or control.
41
+
42
+ 6. Disclaimer of Warranty and Limitation of Liability.
43
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
44
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
45
+ FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL
46
+ RATHER LABS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN
47
+ AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
48
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # transcribego-mcp
2
+
3
+ Local MCP (stdio) proxy for [TranscribeGo](https://transcribego.com). It forwards every tool of the remote TranscribeGo MCP (`transcribe_audio`, `transcribe_url`, `get_transcription_job`) and adds one local tool:
4
+
5
+ - **`transcribe_file { path }`** — transcribe an audio/video file from your disk. The file is uploaded directly to blob storage (up to 1 GB; the bytes never pass through the TranscribeGo server) and a transcription job is created. Poll `get_transcription_job` with the returned `jobId`.
6
+
7
+ ## Usage
8
+
9
+ ### Claude Code
10
+
11
+ ```sh
12
+ claude mcp add transcribego -- npx -y transcribego-mcp
13
+ ```
14
+
15
+ ### Claude Desktop / Cursor (`mcpServers` config)
16
+
17
+ ```json
18
+ {
19
+ "mcpServers": {
20
+ "transcribego": {
21
+ "command": "npx",
22
+ "args": ["-y", "transcribego-mcp"]
23
+ }
24
+ }
25
+ }
26
+ ```
27
+
28
+ On first use the proxy opens your browser to authorize with your TranscribeGo account (OAuth + PKCE). Tokens are stored in `~/.transcribego-mcp/credentials.json` (owner-only permissions) and refreshed automatically.
29
+
30
+ ## Supported files
31
+
32
+ `.mp3`, `.mpga`, `.m4a`, `.mp4`, `.m4v`, `.ogg`, `.oga`, `.opus`, `.wav`, `.webm` — up to 1 GB. For other containers pass an explicit `mimeType` accepted by TranscribeGo.
33
+
34
+ Rate limits are enforced server-side (~5 files/min).
35
+
36
+ ## Configuration
37
+
38
+ | Environment variable | Default | Purpose |
39
+ | --- | --- | --- |
40
+ | `TRANSCRIBEGO_URL` | `https://transcribego.com` | TranscribeGo origin (use a preview deployment URL for testing) |
41
+ | `TRANSCRIBEGO_MCP_DIR` | `~/.transcribego-mcp` | Where credentials are stored |
42
+
43
+ ## CLI
44
+
45
+ ```sh
46
+ npx transcribego-mcp # start the stdio MCP server (what MCP clients run)
47
+ npx transcribego-mcp --reset-auth # forget stored credentials (forces a fresh login)
48
+ npx transcribego-mcp --help
49
+ ```
50
+
51
+ ## Troubleshooting
52
+
53
+ - **Browser never opened** — the authorization URL is also printed to stderr; open it manually.
54
+ - **Stuck or invalid credentials** — run `npx transcribego-mcp --reset-auth` and retry.
55
+ - **Multiple MCP clients** — each running proxy shares the same credentials file. Token refresh is serialized across processes with a file lock (`refresh.lock` in the credentials directory), so concurrent instances never invalidate each other's tokens.
56
+
57
+ ## Development
58
+
59
+ ```sh
60
+ npm install
61
+ npm test # vitest
62
+ npm run build # tsc → dist/
63
+ node dist/index.js
64
+ ```
65
+
66
+ Upload parameters (per-user pathname prefix, upload URL, size limits, supported MIME types) come from the remote `get_upload_info` tool at transcription time — the proxy never hardcodes them. The flow for `transcribe_file` is: validate the local file → `get_upload_info` → direct-to-Blob upload → `transcribe_audio { blobUrl, fileSizeBytes, … }`.
@@ -0,0 +1,60 @@
1
+ import { mkdirSync, rmSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { setTimeout as sleep } from 'node:timers/promises';
4
+ /**
5
+ * Inter-process mutex via atomic mkdir. Needed because refresh tokens rotate
6
+ * on use and the server revokes the whole token family on reuse detection —
7
+ * two transcribego-mcp instances (e.g. Claude Desktop + Cursor) refreshing
8
+ * concurrently with the same stored token would revoke each other. The
9
+ * instance holding the lock refreshes; the others then re-read the rotated
10
+ * tokens from the store.
11
+ */
12
+ const RETRY_INTERVAL_MS = 150;
13
+ /** A refresh is a single HTTP round trip; anything older than this is a crashed holder. */
14
+ const STALE_LOCK_MS = 30_000;
15
+ const ACQUIRE_TIMEOUT_MS = 60_000;
16
+ export class LockTimeoutError extends Error {
17
+ constructor(lockPath) {
18
+ super(`Timed out waiting for lock ${lockPath}`);
19
+ this.name = 'LockTimeoutError';
20
+ }
21
+ }
22
+ function tryAcquire(lockPath) {
23
+ try {
24
+ mkdirSync(lockPath);
25
+ return true;
26
+ }
27
+ catch (error) {
28
+ // First run: the data directory itself may not exist yet.
29
+ if (error.code === 'ENOENT') {
30
+ mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
31
+ return tryAcquire(lockPath);
32
+ }
33
+ return false;
34
+ }
35
+ }
36
+ function removeIfStale(lockPath) {
37
+ try {
38
+ if (Date.now() - statSync(lockPath).mtimeMs > STALE_LOCK_MS) {
39
+ rmSync(lockPath, { recursive: true, force: true });
40
+ }
41
+ }
42
+ catch {
43
+ // Already gone — the holder released it between our check and removal.
44
+ }
45
+ }
46
+ export async function withFileLock(lockPath, fn) {
47
+ const deadline = Date.now() + ACQUIRE_TIMEOUT_MS;
48
+ while (!tryAcquire(lockPath)) {
49
+ if (Date.now() > deadline)
50
+ throw new LockTimeoutError(lockPath);
51
+ removeIfStale(lockPath);
52
+ await sleep(RETRY_INTERVAL_MS);
53
+ }
54
+ try {
55
+ return await fn();
56
+ }
57
+ finally {
58
+ rmSync(lockPath, { recursive: true, force: true });
59
+ }
60
+ }
@@ -0,0 +1,304 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import { spawn } from 'node:child_process';
4
+ import { CALLBACK_PORTS, CALLBACK_PATH, OAUTH_SCOPES, callbackUri } from '../config.js';
5
+ import { log } from '../log.js';
6
+ import { withFileLock } from './lock.js';
7
+ import { generatePkcePair } from './pkce.js';
8
+ /** Refresh slightly before the 15-minute expiry to absorb clock skew and latency. */
9
+ const EXPIRY_SLACK_MS = 30_000;
10
+ const AUTHORIZE_TIMEOUT_MS = 5 * 60_000;
11
+ export class OAuthError extends Error {
12
+ code;
13
+ constructor(message, code) {
14
+ super(message);
15
+ this.code = code;
16
+ this.name = 'OAuthError';
17
+ }
18
+ }
19
+ async function fetchJson(url, init) {
20
+ const response = await fetch(url, init);
21
+ let body = null;
22
+ try {
23
+ body = await response.json();
24
+ }
25
+ catch {
26
+ // Non-JSON body (e.g. HTML error page); leave null.
27
+ }
28
+ return { status: response.status, body };
29
+ }
30
+ /**
31
+ * RFC 9728 + RFC 8414 discovery. The resource URL (and therefore the `resource`
32
+ * parameter, which the server matches exactly) may live on a different origin
33
+ * than the issuer, so never derive it from baseUrl — always read the metadata.
34
+ */
35
+ export async function discoverEndpoints(baseUrl) {
36
+ const candidates = [
37
+ `${baseUrl}/.well-known/oauth-protected-resource/mcp`,
38
+ `${baseUrl}/.well-known/oauth-protected-resource`,
39
+ ];
40
+ let resource = null;
41
+ let issuer = null;
42
+ for (const url of candidates) {
43
+ const { status, body } = await fetchJson(url);
44
+ if (status !== 200 || typeof body !== 'object' || body === null)
45
+ continue;
46
+ const metadata = body;
47
+ if (typeof metadata.resource === 'string' && Array.isArray(metadata.authorization_servers)) {
48
+ resource = metadata.resource;
49
+ issuer = String(metadata.authorization_servers[0]);
50
+ break;
51
+ }
52
+ }
53
+ if (!resource || !issuer) {
54
+ throw new OAuthError(`Could not discover OAuth metadata from ${baseUrl} — is the URL correct?`);
55
+ }
56
+ const { status, body } = await fetchJson(`${issuer}/.well-known/oauth-authorization-server`);
57
+ if (status !== 200 || typeof body !== 'object' || body === null) {
58
+ throw new OAuthError(`Could not fetch authorization server metadata from ${issuer}`);
59
+ }
60
+ const as = body;
61
+ const authorizationEndpoint = as.authorization_endpoint;
62
+ const tokenEndpoint = as.token_endpoint;
63
+ const registrationEndpoint = as.registration_endpoint;
64
+ if (typeof authorizationEndpoint !== 'string' ||
65
+ typeof tokenEndpoint !== 'string' ||
66
+ typeof registrationEndpoint !== 'string') {
67
+ throw new OAuthError('Authorization server metadata is missing required endpoints');
68
+ }
69
+ return { resource, authorizationEndpoint, tokenEndpoint, registrationEndpoint };
70
+ }
71
+ async function registerClient(endpoints) {
72
+ const redirectUris = CALLBACK_PORTS.map((port) => callbackUri(port));
73
+ const { status, body } = await fetchJson(endpoints.registrationEndpoint, {
74
+ method: 'POST',
75
+ headers: { 'content-type': 'application/json' },
76
+ body: JSON.stringify({
77
+ client_name: 'TranscribeGo local MCP',
78
+ redirect_uris: redirectUris,
79
+ grant_types: ['authorization_code', 'refresh_token'],
80
+ response_types: ['code'],
81
+ token_endpoint_auth_method: 'none',
82
+ }),
83
+ });
84
+ const registered = body;
85
+ if (status !== 201 || typeof registered?.client_id !== 'string') {
86
+ throw new OAuthError(`Dynamic client registration failed (HTTP ${status})`);
87
+ }
88
+ return {
89
+ clientId: registered.client_id,
90
+ redirectUris: Array.isArray(registered.redirect_uris)
91
+ ? registered.redirect_uris.map(String)
92
+ : redirectUris,
93
+ };
94
+ }
95
+ function openBrowser(url) {
96
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
97
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
98
+ try {
99
+ spawn(command, args, { stdio: 'ignore', detached: true }).unref();
100
+ }
101
+ catch {
102
+ // Browser launch is best-effort; the URL is also printed to stderr.
103
+ }
104
+ }
105
+ function listenOnFirstFreePort(redirectUris) {
106
+ const loopbackUris = redirectUris.filter((uri) => uri.startsWith('http://127.0.0.1:'));
107
+ if (loopbackUris.length === 0) {
108
+ throw new OAuthError('Stored client has no loopback redirect URIs; run with --reset-auth');
109
+ }
110
+ return new Promise((resolve, reject) => {
111
+ const tryNext = (index) => {
112
+ const uri = loopbackUris[index];
113
+ if (!uri) {
114
+ reject(new OAuthError('All registered callback ports are busy; close other transcribego-mcp instances'));
115
+ return;
116
+ }
117
+ const port = Number(new URL(uri).port);
118
+ const server = createServer();
119
+ server.once('error', () => {
120
+ server.close();
121
+ tryNext(index + 1);
122
+ });
123
+ server.listen(port, '127.0.0.1', () => resolve({ server, redirectUri: uri }));
124
+ };
125
+ tryNext(0);
126
+ });
127
+ }
128
+ function waitForCallback(server, expectedState) {
129
+ return new Promise((resolve, reject) => {
130
+ const timeout = setTimeout(() => {
131
+ reject(new OAuthError('Timed out waiting for the browser authorization (5 minutes)'));
132
+ server.close();
133
+ }, AUTHORIZE_TIMEOUT_MS);
134
+ server.on('request', (req, res) => {
135
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
136
+ if (url.pathname !== CALLBACK_PATH) {
137
+ res.writeHead(404).end();
138
+ return;
139
+ }
140
+ const finish = (message) => {
141
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
142
+ res.end(`<html><body style="font-family:sans-serif"><p>${message}</p><p>You can close this tab.</p></body></html>`);
143
+ clearTimeout(timeout);
144
+ // Let the response flush before tearing the server down.
145
+ setImmediate(() => server.close());
146
+ };
147
+ const error = url.searchParams.get('error');
148
+ if (error) {
149
+ finish('Authorization failed.');
150
+ reject(new OAuthError(`Authorization was rejected: ${url.searchParams.get('error_description') ?? error}`, error));
151
+ return;
152
+ }
153
+ const code = url.searchParams.get('code');
154
+ if (url.searchParams.get('state') !== expectedState || !code) {
155
+ finish('Invalid authorization response.');
156
+ reject(new OAuthError('Authorization response had a missing code or mismatched state'));
157
+ return;
158
+ }
159
+ finish('TranscribeGo MCP is now connected.');
160
+ resolve(code);
161
+ });
162
+ });
163
+ }
164
+ function parseTokenResponse(status, body) {
165
+ const data = body;
166
+ if (status !== 200 || !data || typeof data.access_token !== 'string' || typeof data.refresh_token !== 'string') {
167
+ throw new OAuthError(`Token request failed (HTTP ${status}): ${data?.error_description ?? data?.error ?? 'unknown error'}`, data?.error);
168
+ }
169
+ return data;
170
+ }
171
+ /**
172
+ * Manages the whole public-client OAuth lifecycle against the TranscribeGo
173
+ * authorization server: one-time dynamic registration, interactive PKCE
174
+ * authorization through a loopback redirect, and refresh-token rotation.
175
+ */
176
+ export class OAuthClient {
177
+ baseUrl;
178
+ store;
179
+ endpoints = null;
180
+ inFlight = null;
181
+ constructor(baseUrl, store) {
182
+ this.baseUrl = baseUrl;
183
+ this.store = store;
184
+ }
185
+ async getEndpoints() {
186
+ if (!this.endpoints) {
187
+ this.endpoints = await discoverEndpoints(this.baseUrl);
188
+ }
189
+ return this.endpoints;
190
+ }
191
+ /**
192
+ * Returns a currently-valid access token, refreshing or running the
193
+ * interactive flow as needed. Concurrent callers share one attempt so a
194
+ * burst of tool calls cannot trigger parallel refreshes (rotation would
195
+ * revoke the whole token family).
196
+ */
197
+ async getAccessToken() {
198
+ if (!this.inFlight) {
199
+ this.inFlight = this.acquireToken().finally(() => {
200
+ this.inFlight = null;
201
+ });
202
+ }
203
+ return this.inFlight;
204
+ }
205
+ /** Drops the cached access token so the next call refreshes (e.g. after a 401). */
206
+ invalidateAccessToken() {
207
+ this.store.update({ accessToken: undefined, accessTokenExpiresAt: undefined });
208
+ }
209
+ async acquireToken() {
210
+ // Refresh tokens rotate on use and reuse revokes the whole family, so the
211
+ // read-refresh-persist sequence must be exclusive across processes. The
212
+ // interactive flow stays outside the lock: it can take minutes and starts
213
+ // a fresh token family, so it cannot race a rotation.
214
+ const refreshed = await withFileLock(this.store.lockPath, async () => {
215
+ // Re-read under the lock: another instance may have just rotated.
216
+ const stored = this.store.read();
217
+ if (stored?.accessToken && (stored.accessTokenExpiresAt ?? 0) > Date.now() + EXPIRY_SLACK_MS) {
218
+ return stored.accessToken;
219
+ }
220
+ if (!stored?.refreshToken)
221
+ return null;
222
+ try {
223
+ return await this.refresh(stored);
224
+ }
225
+ catch (error) {
226
+ if (error instanceof OAuthError && error.code === 'invalid_grant') {
227
+ log('Refresh token is no longer valid; starting interactive login');
228
+ return null;
229
+ }
230
+ throw error;
231
+ }
232
+ });
233
+ if (refreshed)
234
+ return refreshed;
235
+ return this.interactiveAuthorize();
236
+ }
237
+ async refresh(stored) {
238
+ const endpoints = await this.getEndpoints();
239
+ const { status, body } = await fetchJson(endpoints.tokenEndpoint, {
240
+ method: 'POST',
241
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
242
+ body: new URLSearchParams({
243
+ grant_type: 'refresh_token',
244
+ refresh_token: stored.refreshToken ?? '',
245
+ client_id: stored.clientId,
246
+ resource: endpoints.resource,
247
+ }),
248
+ });
249
+ const tokens = parseTokenResponse(status, body);
250
+ this.persistTokens(stored, tokens);
251
+ return tokens.access_token;
252
+ }
253
+ async interactiveAuthorize() {
254
+ const endpoints = await this.getEndpoints();
255
+ let stored = this.store.read();
256
+ if (!stored?.clientId) {
257
+ const registration = await registerClient(endpoints);
258
+ stored = { ...registration };
259
+ this.store.write(stored);
260
+ log('Registered as a new OAuth client');
261
+ }
262
+ const { server, redirectUri } = await listenOnFirstFreePort(stored.redirectUris);
263
+ const pkce = generatePkcePair();
264
+ const state = randomBytes(16).toString('base64url');
265
+ const authorizeUrl = new URL(endpoints.authorizationEndpoint);
266
+ authorizeUrl.search = new URLSearchParams({
267
+ response_type: 'code',
268
+ client_id: stored.clientId,
269
+ redirect_uri: redirectUri,
270
+ scope: OAUTH_SCOPES.join(' '),
271
+ state,
272
+ code_challenge: pkce.challenge,
273
+ code_challenge_method: 'S256',
274
+ resource: endpoints.resource,
275
+ }).toString();
276
+ log(`Opening browser to authorize: ${authorizeUrl.toString()}`);
277
+ openBrowser(authorizeUrl.toString());
278
+ const code = await waitForCallback(server, state);
279
+ const { status, body } = await fetchJson(endpoints.tokenEndpoint, {
280
+ method: 'POST',
281
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
282
+ body: new URLSearchParams({
283
+ grant_type: 'authorization_code',
284
+ code,
285
+ redirect_uri: redirectUri,
286
+ client_id: stored.clientId,
287
+ code_verifier: pkce.verifier,
288
+ resource: endpoints.resource,
289
+ }),
290
+ });
291
+ const tokens = parseTokenResponse(status, body);
292
+ this.persistTokens(stored, tokens);
293
+ log('Authorization complete');
294
+ return tokens.access_token;
295
+ }
296
+ persistTokens(base, tokens) {
297
+ this.store.write({
298
+ ...base,
299
+ accessToken: tokens.access_token,
300
+ accessTokenExpiresAt: Date.now() + tokens.expires_in * 1000,
301
+ refreshToken: tokens.refresh_token,
302
+ });
303
+ }
304
+ }
@@ -0,0 +1,9 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ /** RFC 7636 S256: verifier is 43-128 chars of base64url; challenge = BASE64URL(SHA256(verifier)). */
3
+ export function generatePkcePair() {
4
+ const verifier = randomBytes(32).toString('base64url');
5
+ return { verifier, challenge: challengeFromVerifier(verifier) };
6
+ }
7
+ export function challengeFromVerifier(verifier) {
8
+ return createHash('sha256').update(verifier).digest('base64url');
9
+ }
@@ -0,0 +1,49 @@
1
+ import { mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ const FILE_NAME = 'credentials.json';
4
+ export class TokenStore {
5
+ dataDir;
6
+ baseUrl;
7
+ filePath;
8
+ /** Mutex directory used to serialize token refresh across processes. */
9
+ lockPath;
10
+ constructor(dataDir, baseUrl) {
11
+ this.dataDir = dataDir;
12
+ this.baseUrl = baseUrl;
13
+ this.filePath = path.join(dataDir, FILE_NAME);
14
+ this.lockPath = path.join(dataDir, 'refresh.lock');
15
+ }
16
+ read() {
17
+ const file = this.readFile();
18
+ return file.servers[this.baseUrl] ?? null;
19
+ }
20
+ write(auth) {
21
+ const file = this.readFile();
22
+ file.servers[this.baseUrl] = auth;
23
+ mkdirSync(this.dataDir, { recursive: true, mode: 0o700 });
24
+ writeFileSync(this.filePath, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
25
+ }
26
+ update(patch) {
27
+ const current = this.read();
28
+ if (!current)
29
+ return null;
30
+ const next = { ...current, ...patch };
31
+ this.write(next);
32
+ return next;
33
+ }
34
+ clear() {
35
+ rmSync(this.filePath, { force: true });
36
+ }
37
+ readFile() {
38
+ try {
39
+ const parsed = JSON.parse(readFileSync(this.filePath, 'utf8'));
40
+ if (parsed && parsed.version === 1 && parsed.servers && typeof parsed.servers === 'object') {
41
+ return parsed;
42
+ }
43
+ }
44
+ catch {
45
+ // Missing or corrupt file: start fresh.
46
+ }
47
+ return { version: 1, servers: {} };
48
+ }
49
+ }
package/dist/config.js ADDED
@@ -0,0 +1,24 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ const DEFAULT_BASE_URL = 'https://transcribego.com';
4
+ /** OAuth scopes the proxy needs: transcribe (also gates /api/upload) + job polling. */
5
+ export const OAUTH_SCOPES = ['mcp:transcribe', 'mcp:jobs:read'];
6
+ /**
7
+ * Loopback ports for the OAuth redirect. The server requires redirect URIs to
8
+ * exactly match a registered URI (no wildcard ports), and dynamic client
9
+ * registration is rate-limited — so we register all of these once and pick
10
+ * whichever is free at authorization time.
11
+ */
12
+ export const CALLBACK_PORTS = [43821, 43822, 43823, 43824, 43825];
13
+ export const CALLBACK_PATH = '/callback';
14
+ export function callbackUri(port) {
15
+ return `http://127.0.0.1:${port}${CALLBACK_PATH}`;
16
+ }
17
+ function stripTrailingSlash(value) {
18
+ return value.endsWith('/') ? value.slice(0, -1) : value;
19
+ }
20
+ export function resolveConfig(env = process.env) {
21
+ const baseUrl = stripTrailingSlash(env.TRANSCRIBEGO_URL?.trim() || DEFAULT_BASE_URL);
22
+ const dataDir = env.TRANSCRIBEGO_MCP_DIR?.trim() || path.join(os.homedir(), '.transcribego-mcp');
23
+ return { baseUrl, dataDir };
24
+ }
package/dist/files.js ADDED
@@ -0,0 +1,63 @@
1
+ import { statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ /**
4
+ * Extension → MIME type, restricted to the server's allowed upload content
5
+ * types (CONFIG.audio.supportedMimeTypes in the TranscribeGo app). The blob
6
+ * client token rejects anything else at upload time, so we fail fast here
7
+ * with a clearer message.
8
+ */
9
+ const EXT_TO_MIME = {
10
+ '.mp3': 'audio/mpeg',
11
+ '.mpga': 'audio/mpeg',
12
+ '.m4a': 'audio/x-m4a',
13
+ '.mp4': 'video/mp4',
14
+ '.m4v': 'video/mp4',
15
+ '.ogg': 'audio/ogg',
16
+ '.oga': 'audio/ogg',
17
+ '.opus': 'audio/ogg',
18
+ '.wav': 'audio/wav',
19
+ '.webm': 'audio/webm',
20
+ };
21
+ export const SUPPORTED_EXTENSIONS = Object.keys(EXT_TO_MIME);
22
+ export class FileValidationError extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ this.name = 'FileValidationError';
26
+ }
27
+ }
28
+ export function mimeTypeForExtension(extension) {
29
+ return EXT_TO_MIME[extension.toLowerCase()] ?? null;
30
+ }
31
+ /**
32
+ * Validates a local path for the transcribe_file tool: must exist, be a
33
+ * regular file, and have a supported audio/video extension (unless the caller
34
+ * overrides the MIME type explicitly).
35
+ */
36
+ export function inspectAudioFile(rawPath, mimeTypeOverride) {
37
+ const absolutePath = path.resolve(rawPath);
38
+ let stats;
39
+ try {
40
+ stats = statSync(absolutePath);
41
+ }
42
+ catch {
43
+ throw new FileValidationError(`File not found: ${absolutePath}`);
44
+ }
45
+ if (!stats.isFile()) {
46
+ throw new FileValidationError(`Not a regular file: ${absolutePath}`);
47
+ }
48
+ if (stats.size === 0) {
49
+ throw new FileValidationError(`File is empty: ${absolutePath}`);
50
+ }
51
+ const extension = path.extname(absolutePath).toLowerCase();
52
+ const mimeType = mimeTypeOverride?.trim() || mimeTypeForExtension(extension);
53
+ if (!mimeType) {
54
+ throw new FileValidationError(`Unsupported file extension "${extension || '(none)'}". Supported: ${SUPPORTED_EXTENSIONS.join(', ')} — or pass mimeType explicitly.`);
55
+ }
56
+ return {
57
+ absolutePath,
58
+ fileName: path.basename(absolutePath),
59
+ extension,
60
+ mimeType,
61
+ sizeBytes: stats.size,
62
+ };
63
+ }
package/dist/index.js ADDED
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ import { resolveConfig } from './config.js';
3
+ import { log } from './log.js';
4
+ import { OAuthClient } from './auth/oauth.js';
5
+ import { TokenStore } from './auth/token-store.js';
6
+ import { RemoteMcpClient } from './remote-client.js';
7
+ import { BlobUploader } from './upload/upload.js';
8
+ import { ProxyServer } from './server.js';
9
+ function printHelp() {
10
+ process.stderr.write(`transcribego-mcp — local MCP (stdio) proxy for TranscribeGo
11
+
12
+ Usage:
13
+ npx transcribego-mcp Start the stdio MCP server
14
+ npx transcribego-mcp --reset-auth Forget stored credentials and exit
15
+ npx transcribego-mcp --help Show this help
16
+
17
+ Environment:
18
+ TRANSCRIBEGO_URL TranscribeGo origin (default: https://transcribego.com)
19
+ TRANSCRIBEGO_MCP_DIR Credentials directory (default: ~/.transcribego-mcp)
20
+ `);
21
+ }
22
+ async function main() {
23
+ const argv = process.argv.slice(2);
24
+ if (argv.includes('--help') || argv.includes('-h')) {
25
+ printHelp();
26
+ return;
27
+ }
28
+ const config = resolveConfig();
29
+ const store = new TokenStore(config.dataDir, config.baseUrl);
30
+ if (argv.includes('--reset-auth')) {
31
+ store.clear();
32
+ log(`Cleared stored credentials for ${config.baseUrl}`);
33
+ return;
34
+ }
35
+ const oauth = new OAuthClient(config.baseUrl, store);
36
+ const remote = new RemoteMcpClient(oauth);
37
+ const uploader = new BlobUploader(oauth);
38
+ const server = new ProxyServer(remote, uploader);
39
+ log(`Proxying to ${config.baseUrl}`);
40
+ await server.connect();
41
+ }
42
+ main().catch((error) => {
43
+ log(`Fatal: ${error instanceof Error ? error.message : String(error)}`);
44
+ process.exit(1);
45
+ });
package/dist/log.js ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * All diagnostics go to stderr: stdout is the MCP stdio transport channel and
3
+ * must carry nothing but protocol frames.
4
+ */
5
+ export function log(message) {
6
+ process.stderr.write(`[transcribego-mcp] ${message}\n`);
7
+ }
@@ -0,0 +1,72 @@
1
+ import { log } from './log.js';
2
+ const JSON_RPC_VERSION = '2.0';
3
+ /** Must match the remote server (MCP_PROTOCOL_VERSION in the TranscribeGo app). */
4
+ export const MCP_PROTOCOL_VERSION = '2025-06-18';
5
+ export class RemoteRpcError extends Error {
6
+ code;
7
+ data;
8
+ constructor(message, code, data) {
9
+ super(message);
10
+ this.code = code;
11
+ this.data = data;
12
+ this.name = 'RemoteRpcError';
13
+ }
14
+ }
15
+ /**
16
+ * Minimal JSON-RPC 2.0 client for the remote TranscribeGo MCP endpoint
17
+ * (stateless POST per request, no SSE). Attaches the bearer token and
18
+ * transparently retries once on 401 — access tokens only last 15 minutes,
19
+ * so mid-session expiry is routine, not exceptional.
20
+ */
21
+ export class RemoteMcpClient {
22
+ oauth;
23
+ nextId = 1;
24
+ constructor(oauth) {
25
+ this.oauth = oauth;
26
+ }
27
+ async call(method, params) {
28
+ const endpoints = await this.oauth.getEndpoints();
29
+ let token = await this.oauth.getAccessToken();
30
+ let response = await this.post(endpoints.resource, token, method, params);
31
+ if (response.status === 401) {
32
+ log('Remote MCP returned 401; refreshing token and retrying');
33
+ this.oauth.invalidateAccessToken();
34
+ token = await this.oauth.getAccessToken();
35
+ response = await this.post(endpoints.resource, token, method, params);
36
+ }
37
+ if (response.status === 401) {
38
+ throw new RemoteRpcError('Not authorized by TranscribeGo even after refreshing credentials. Run "npx transcribego-mcp --reset-auth" and try again.', -32000);
39
+ }
40
+ if (response.status === 204 || response.body === null) {
41
+ return null;
42
+ }
43
+ const payload = response.body;
44
+ if (payload.error) {
45
+ throw new RemoteRpcError(payload.error.message, payload.error.code, payload.error.data);
46
+ }
47
+ return payload.result ?? null;
48
+ }
49
+ async post(url, token, method, params) {
50
+ const response = await fetch(url, {
51
+ method: 'POST',
52
+ headers: {
53
+ 'content-type': 'application/json',
54
+ authorization: `Bearer ${token}`,
55
+ },
56
+ body: JSON.stringify({
57
+ jsonrpc: JSON_RPC_VERSION,
58
+ id: this.nextId++,
59
+ method,
60
+ ...(params === undefined ? {} : { params }),
61
+ }),
62
+ });
63
+ let body = null;
64
+ try {
65
+ body = await response.json();
66
+ }
67
+ catch {
68
+ // 204 or non-JSON error body.
69
+ }
70
+ return { status: response.status, body };
71
+ }
72
+ }
package/dist/server.js ADDED
@@ -0,0 +1,130 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { CallToolRequestSchema, ListToolsRequestSchema, McpError, ErrorCode, } from '@modelcontextprotocol/sdk/types.js';
4
+ import { log } from './log.js';
5
+ import { FileValidationError, SUPPORTED_EXTENSIONS, inspectAudioFile } from './files.js';
6
+ import { RemoteRpcError } from './remote-client.js';
7
+ import { OAuthError } from './auth/oauth.js';
8
+ import { UploadInfoError, parseUploadInfo } from './upload/upload-info.js';
9
+ const TRANSCRIBE_FILE_TOOL = {
10
+ name: 'transcribe_file',
11
+ description: 'Transcribe a local audio/video file. Uploads the file from disk to TranscribeGo (supports large files, up to 1 GB) and creates a transcription job. Poll get_transcription_job with the returned jobId until it completes.',
12
+ inputSchema: {
13
+ type: 'object',
14
+ properties: {
15
+ path: {
16
+ type: 'string',
17
+ description: `Path to the local audio/video file. Supported extensions: ${SUPPORTED_EXTENSIONS.join(', ')}.`,
18
+ },
19
+ mimeType: {
20
+ type: 'string',
21
+ description: 'Optional MIME type override when the file extension is missing or misleading.',
22
+ },
23
+ durationHintMs: {
24
+ type: 'number',
25
+ description: 'Optional known duration in milliseconds, if already available.',
26
+ },
27
+ },
28
+ required: ['path'],
29
+ additionalProperties: false,
30
+ },
31
+ };
32
+ function errorResult(message) {
33
+ return { content: [{ type: 'text', text: JSON.stringify({ error: message }) }], isError: true };
34
+ }
35
+ export class ProxyServer {
36
+ remote;
37
+ uploader;
38
+ server;
39
+ remoteTools = null;
40
+ constructor(remote, uploader) {
41
+ this.remote = remote;
42
+ this.uploader = uploader;
43
+ this.server = new Server({ name: 'transcribego-mcp', version: '0.1.0' }, { capabilities: { tools: {} } });
44
+ this.server.setRequestHandler(ListToolsRequestSchema, () => this.listTools());
45
+ this.server.setRequestHandler(CallToolRequestSchema, (request) => this.callTool(request.params.name, request.params.arguments));
46
+ }
47
+ async connect() {
48
+ await this.server.connect(new StdioServerTransport());
49
+ log('stdio server ready');
50
+ }
51
+ async listTools() {
52
+ if (!this.remoteTools) {
53
+ const result = (await this.wrapRemote(() => this.remote.call('tools/list')));
54
+ if (!result || !Array.isArray(result.tools)) {
55
+ throw new McpError(ErrorCode.InternalError, 'Remote MCP returned an invalid tool list');
56
+ }
57
+ this.remoteTools = result.tools;
58
+ }
59
+ return { tools: [...this.remoteTools, TRANSCRIBE_FILE_TOOL] };
60
+ }
61
+ async callTool(name, args) {
62
+ if (name === TRANSCRIBE_FILE_TOOL.name) {
63
+ return this.transcribeFile(args);
64
+ }
65
+ const result = await this.wrapRemote(() => this.remote.call('tools/call', { name, arguments: args }));
66
+ return result;
67
+ }
68
+ async transcribeFile(args) {
69
+ const { path, mimeType, durationHintMs } = (args ?? {});
70
+ if (typeof path !== 'string' || !path.trim()) {
71
+ throw new McpError(ErrorCode.InvalidParams, 'transcribe_file requires a "path" string argument');
72
+ }
73
+ try {
74
+ const file = inspectAudioFile(path, typeof mimeType === 'string' ? mimeType : undefined);
75
+ const info = parseUploadInfo(await this.remote.call('tools/call', { name: 'get_upload_info', arguments: {} }));
76
+ if (file.sizeBytes > info.maxSizeBytes) {
77
+ return errorResult(`File is ${Math.ceil(file.sizeBytes / 1024 / 1024)} MB but the maximum upload size is ${Math.floor(info.maxSizeBytes / 1024 / 1024)} MB`);
78
+ }
79
+ if (!info.supportedMimeTypes.includes(file.mimeType)) {
80
+ return errorResult(`MIME type ${file.mimeType} is not supported. Supported types: ${info.supportedMimeTypes.join(', ')}`);
81
+ }
82
+ const { blobUrl } = await this.uploader.uploadFile(file, info);
83
+ // fileSizeBytes always: it lets the server route >100 MB files to
84
+ // background processing without buffering them first.
85
+ const result = await this.remote.call('tools/call', {
86
+ name: 'transcribe_audio',
87
+ arguments: {
88
+ blobUrl,
89
+ fileName: file.fileName,
90
+ mimeType: file.mimeType,
91
+ fileSizeBytes: file.sizeBytes,
92
+ ...(typeof durationHintMs === 'number' && durationHintMs > 0
93
+ ? { durationHintMs: Math.round(durationHintMs) }
94
+ : {}),
95
+ },
96
+ });
97
+ return result;
98
+ }
99
+ catch (error) {
100
+ // Tool-shaped failures (bad file, paywall, rate limit) go back as tool
101
+ // results so the calling model can read and relay them.
102
+ if (error instanceof FileValidationError || error instanceof UploadInfoError) {
103
+ return errorResult(error.message);
104
+ }
105
+ if (error instanceof OAuthError) {
106
+ return errorResult(`TranscribeGo authorization failed: ${error.message}`);
107
+ }
108
+ if (error instanceof RemoteRpcError) {
109
+ throw new McpError(ErrorCode.InternalError, error.message, error.data);
110
+ }
111
+ const message = error instanceof Error ? error.message : String(error);
112
+ return errorResult(`Upload failed: ${message}`);
113
+ }
114
+ }
115
+ /** Converts remote/auth failures into MCP protocol errors for proxied methods. */
116
+ async wrapRemote(operation) {
117
+ try {
118
+ return await operation();
119
+ }
120
+ catch (error) {
121
+ if (error instanceof RemoteRpcError) {
122
+ throw new McpError(ErrorCode.InternalError, error.message, error.data);
123
+ }
124
+ if (error instanceof OAuthError) {
125
+ throw new McpError(ErrorCode.InternalError, `TranscribeGo authorization failed: ${error.message}`);
126
+ }
127
+ throw error;
128
+ }
129
+ }
130
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Upload parameters come from the remote `get_upload_info` tool (scope
3
+ * mcp:transcribe): the server derives everything from its own config plus the
4
+ * bearer's userId, so the client never guesses its `mcp-uploads/{userId}/`
5
+ * prefix. The call has no rate-limit bucket, so we fetch it fresh per upload
6
+ * instead of caching a value that could go stale.
7
+ */
8
+ export class UploadInfoError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = 'UploadInfoError';
12
+ }
13
+ }
14
+ /**
15
+ * Parses the MCP tool result of `get_upload_info` (JSON payload inside
16
+ * content[0].text, the usual MCP pattern).
17
+ */
18
+ export function parseUploadInfo(result) {
19
+ const toolResult = result;
20
+ const text = toolResult?.content?.[0]?.text;
21
+ if (typeof text !== 'string') {
22
+ throw new UploadInfoError('get_upload_info returned no content');
23
+ }
24
+ let payload;
25
+ try {
26
+ payload = JSON.parse(text);
27
+ }
28
+ catch {
29
+ throw new UploadInfoError('get_upload_info returned non-JSON content');
30
+ }
31
+ if (toolResult?.isError) {
32
+ const message = typeof payload.error === 'string' ? payload.error : text;
33
+ throw new UploadInfoError(`get_upload_info failed: ${message}`);
34
+ }
35
+ const { handleUploadUrl, pathnamePrefix, maxSizeBytes, inlineMaxSizeBytes, supportedMimeTypes, uploadRateLimitPerMinute } = payload;
36
+ if (typeof handleUploadUrl !== 'string' ||
37
+ typeof pathnamePrefix !== 'string' ||
38
+ !pathnamePrefix.endsWith('/') ||
39
+ typeof maxSizeBytes !== 'number' ||
40
+ typeof inlineMaxSizeBytes !== 'number' ||
41
+ !Array.isArray(supportedMimeTypes) ||
42
+ supportedMimeTypes.some((item) => typeof item !== 'string')) {
43
+ throw new UploadInfoError('get_upload_info returned an unexpected shape');
44
+ }
45
+ return {
46
+ handleUploadUrl,
47
+ pathnamePrefix,
48
+ maxSizeBytes,
49
+ inlineMaxSizeBytes,
50
+ supportedMimeTypes: supportedMimeTypes,
51
+ uploadRateLimitPerMinute: typeof uploadRateLimitPerMinute === 'number' ? uploadRateLimitPerMinute : 5,
52
+ };
53
+ }
@@ -0,0 +1,42 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { upload } from '@vercel/blob/client';
4
+ import { log } from '../log.js';
5
+ /** Above this size, split into parallel parts with per-part retry. */
6
+ const MULTIPART_THRESHOLD_BYTES = 32 * 1024 * 1024;
7
+ /**
8
+ * Uploads a local file straight to Vercel Blob using the client-upload
9
+ * protocol: the server's handleUploadUrl mints a scoped token (authenticated
10
+ * with the MCP bearer), then the bytes go directly to Blob storage without
11
+ * passing through the TranscribeGo server. All parameters (URL, per-user
12
+ * pathname prefix) come from get_upload_info.
13
+ */
14
+ export class BlobUploader {
15
+ oauth;
16
+ constructor(oauth) {
17
+ this.oauth = oauth;
18
+ }
19
+ async uploadFile(file, info) {
20
+ const accessToken = await this.oauth.getAccessToken();
21
+ // Never reuse pathnames: the server expects unique uploads and sweeps
22
+ // abandoned ones after 24h, so no client-side cleanup is needed.
23
+ const pathname = `${info.pathnamePrefix}${randomUUID()}${file.extension}`;
24
+ log(`Uploading ${file.fileName} (${Math.ceil(file.sizeBytes / 1024 / 1024)} MB) to ${pathname}`);
25
+ let lastLoggedPercent = -10;
26
+ const result = await upload(pathname, createReadStream(file.absolutePath), {
27
+ access: 'public',
28
+ handleUploadUrl: info.handleUploadUrl,
29
+ headers: { authorization: `Bearer ${accessToken}` },
30
+ contentType: file.mimeType,
31
+ multipart: file.sizeBytes > MULTIPART_THRESHOLD_BYTES,
32
+ onUploadProgress: ({ percentage }) => {
33
+ if (percentage >= lastLoggedPercent + 10) {
34
+ lastLoggedPercent = percentage;
35
+ log(`Upload progress: ${Math.floor(percentage)}%`);
36
+ }
37
+ },
38
+ });
39
+ log(`Upload complete: ${result.url}`);
40
+ return { blobUrl: result.url };
41
+ }
42
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "transcribego-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Local MCP (stdio) proxy for TranscribeGo: forwards tools to the remote TranscribeGo MCP and adds local audio file uploads via direct-to-Blob transfer.",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "homepage": "https://transcribego.com",
7
+ "type": "module",
8
+ "bin": {
9
+ "transcribego-mcp": "dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "scripts": {
20
+ "build": "rm -rf dist && tsc -p tsconfig.json",
21
+ "typecheck": "tsc -p tsconfig.json --noEmit",
22
+ "test": "vitest run",
23
+ "prepare": "npm run build",
24
+ "prepublishOnly": "npm run test && npm run build"
25
+ },
26
+ "keywords": [
27
+ "mcp",
28
+ "model-context-protocol",
29
+ "transcription",
30
+ "transcribego",
31
+ "audio"
32
+ ],
33
+ "dependencies": {
34
+ "@modelcontextprotocol/sdk": "^1.29.0",
35
+ "@vercel/blob": "^2.5.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^24.0.0",
39
+ "typescript": "^5.6.0",
40
+ "vitest": "^3.0.0"
41
+ }
42
+ }