@unpuzzle/viddy-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/dist/index.js ADDED
@@ -0,0 +1,663 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import {
7
+ CallToolRequestSchema,
8
+ ListToolsRequestSchema
9
+ } from "@modelcontextprotocol/sdk/types.js";
10
+
11
+ // ../../packages/mcp-core/dist/index.js
12
+ import { z as z2 } from "zod";
13
+
14
+ // ../../packages/mcp-core/dist/tools.js
15
+ import { z } from "zod";
16
+ function qs(params) {
17
+ const sp = new URLSearchParams();
18
+ for (const [k, v] of Object.entries(params)) {
19
+ if (v !== void 0 && v !== "")
20
+ sp.set(k, String(v));
21
+ }
22
+ const s = sp.toString();
23
+ return s ? `?${s}` : "";
24
+ }
25
+ function seg(idOrSlug) {
26
+ return encodeURIComponent(idOrSlug);
27
+ }
28
+ var PingArgs = z.object({}).strict();
29
+ var PING = {
30
+ name: "ping",
31
+ description: "Check that the Viddy MCP server is alive and reachable. Returns the configured Viddy base URL and a timestamp. Requires no sign-in and makes no API call, so use it to separate 'the MCP is broken' from 'my Viddy account has no data' before digging further.",
32
+ inputSchema: PingArgs,
33
+ handler: async () => ({ ok: true, timestamp: (/* @__PURE__ */ new Date()).toISOString() })
34
+ };
35
+ var WhoamiArgs = z.object({}).strict();
36
+ var WHOAMI = {
37
+ name: "whoami",
38
+ description: "Return the Viddy account this connection is acting as: name, email, the granted scopes, and how many videos the account owns. Use it when the user asks 'am I connected', 'which account is this', or when a tool returns nothing and you want to confirm you are looking at the right account.",
39
+ inputSchema: WhoamiArgs,
40
+ handler: async (_args, api) => api.fetch("/api/v1/me")
41
+ };
42
+ var ListVideosArgs = z.object({
43
+ query: z.string().min(1).max(200).optional().describe("Case-insensitive substring match against the video title."),
44
+ limit: z.number().int().min(1).max(50).optional().describe("Page size. Default 20, max 50."),
45
+ cursor: z.string().min(1).optional().describe("Opaque cursor: pass `nextCursor` from the previous response to get the next page.")
46
+ }).strict();
47
+ var LIST_VIDEOS = {
48
+ name: "list_videos",
49
+ description: "List the videos the user owns, newest first. This is the starting point for almost every Viddy question: use it to find a video by title before calling get_transcript, get_video, or list_notes. Each row has id, title, slug, url (the shareable link), createdAt, durationSeconds, visibility (private/everyone/teams), source (upload/recording), hasTranscript, and noteCount. Pass `query` to filter by title. Returns { videos, nextCursor }; `nextCursor` is null on the last page.",
50
+ inputSchema: ListVideosArgs,
51
+ handler: async (args, api) => api.fetch(`/api/v1/videos${qs({ query: args.query, limit: args.limit, cursor: args.cursor })}`)
52
+ };
53
+ var GetVideoArgs = z.object({
54
+ id_or_slug: z.string().min(1).describe("The video's UUID, or its slug (the last path part of a viddy.lol/v/... link).")
55
+ }).strict();
56
+ var GET_VIDEO = {
57
+ name: "get_video",
58
+ description: "Fetch one video's metadata by id or slug: title, shareable url, duration, visibility, source, created date, whether a transcript exists and its status, and the note count. Use it when the user pastes a Viddy link or names a specific video and you need its details before pulling the transcript or notes. Returns 404 if the video does not exist or is not owned by this account.",
59
+ inputSchema: GetVideoArgs,
60
+ handler: async (args, api) => api.fetch(`/api/v1/videos/${seg(args.id_or_slug)}`)
61
+ };
62
+ var GetTranscriptArgs = z.object({
63
+ id_or_slug: z.string().min(1).describe("The video's UUID, or its slug (the last path part of a viddy.lol/v/... link)."),
64
+ format: z.enum(["text", "segments"]).optional().describe('"text" (default) returns one prose blob \u2014 best for summarizing. "segments" returns timestamped chunks [{start,end,text}] \u2014 use it when the user wants timecodes or quotes you can link to.'),
65
+ start_sec: z.number().min(0).optional().describe("Only include transcript from this many seconds into the video."),
66
+ end_sec: z.number().min(0).optional().describe("Only include transcript up to this many seconds into the video.")
67
+ }).strict();
68
+ var GET_TRANSCRIPT = {
69
+ name: "get_transcript",
70
+ description: "Get the transcript of a Viddy video (transcribed on-device by the desktop app). Returns { status, language, text } or { status, language, segments } depending on `format`. status is 'ready', 'pending' (still transcribing \u2014 try again later), 'failed', 'empty' (transcribed, but the recording has no speech \u2014 say so instead of guessing at content), or 'none' (never transcribed). For long recordings the text is capped at 60,000 characters and `truncated` is set \u2014 when that happens, call again with start_sec/end_sec to walk the video in chunks rather than asking for the whole thing.",
71
+ inputSchema: GetTranscriptArgs,
72
+ handler: async (args, api) => api.fetch(`/api/v1/videos/${seg(args.id_or_slug)}/transcript${qs({
73
+ format: args.format,
74
+ start_sec: args.start_sec,
75
+ end_sec: args.end_sec
76
+ })}`)
77
+ };
78
+ var SearchTranscriptsArgs = z.object({
79
+ query: z.string().min(2).max(200).describe("Text to look for inside transcripts. Matching is case-insensitive substring, not semantic \u2014 prefer distinctive words over whole sentences."),
80
+ limit: z.number().int().min(1).max(20).optional().describe("Maximum number of matching moments to return. Default 10, max 20.")
81
+ }).strict();
82
+ var SEARCH_TRANSCRIPTS = {
83
+ name: "search_transcripts",
84
+ description: "Search across the transcripts of every video the user owns and return the exact moments where a phrase was said. Use this for 'when did I talk about X' or 'which recording covers Y' \u2014 it is far cheaper than pulling whole transcripts. Each hit has the video (id, title, slug, url), the segment start/end in seconds, the matching segment text, and a snippet with surrounding context. Matching is case-insensitive substring, so search for distinctive keywords rather than full sentences.",
85
+ inputSchema: SearchTranscriptsArgs,
86
+ handler: async (args, api) => api.fetch(`/api/v1/transcripts/search${qs({ q: args.query, limit: args.limit })}`)
87
+ };
88
+ var ListNotesArgs = z.object({
89
+ id_or_slug: z.string().min(1).describe("The video's UUID, or its slug (the last path part of a viddy.lol/v/... link).")
90
+ }).strict();
91
+ var LIST_NOTES = {
92
+ name: "list_notes",
93
+ description: "List the timestamped notes viewers left on a video, in video order. Notes are the feedback people leave while watching a Viddy share link, each pinned to a moment. Use this for 'what feedback did I get on this video' or to collect review comments into a to-do list. Each note has the author (name and email), timestampSeconds, text, and createdAt.",
94
+ inputSchema: ListNotesArgs,
95
+ handler: async (args, api) => api.fetch(`/api/v1/videos/${seg(args.id_or_slug)}/notes`)
96
+ };
97
+ var TOOLS = [
98
+ PING,
99
+ WHOAMI,
100
+ LIST_VIDEOS,
101
+ GET_VIDEO,
102
+ GET_TRANSCRIPT,
103
+ SEARCH_TRANSCRIPTS,
104
+ LIST_NOTES
105
+ ];
106
+ var TOOL_NAMES = TOOLS.map((t) => t.name);
107
+
108
+ // ../../packages/mcp-core/dist/index.js
109
+ var UnknownToolError = class extends Error {
110
+ toolName;
111
+ constructor(toolName) {
112
+ super(`Unknown tool "${toolName}". Viddy exposes: ${TOOL_NAMES.join(", ")}.`);
113
+ this.toolName = toolName;
114
+ this.name = "UnknownToolError";
115
+ }
116
+ };
117
+ var ToolArgsError = class extends Error {
118
+ toolName;
119
+ issues;
120
+ constructor(toolName, issues) {
121
+ const detail = issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
122
+ super(`Invalid arguments for "${toolName}" \u2014 ${detail}`);
123
+ this.toolName = toolName;
124
+ this.issues = issues;
125
+ this.name = "ToolArgsError";
126
+ }
127
+ };
128
+ function getTool(name) {
129
+ return TOOLS.find((t) => t.name === name);
130
+ }
131
+ function toMcpToolList() {
132
+ return TOOLS.map((t) => ({
133
+ name: t.name,
134
+ description: t.description,
135
+ inputSchema: z2.toJSONSchema(t.inputSchema, {
136
+ io: "input",
137
+ target: "draft-7"
138
+ })
139
+ }));
140
+ }
141
+ async function runTool(name, args, api) {
142
+ const tool = getTool(name);
143
+ if (!tool)
144
+ throw new UnknownToolError(name);
145
+ const parsed = tool.inputSchema.safeParse(args ?? {});
146
+ if (!parsed.success)
147
+ throw new ToolArgsError(name, parsed.error.issues);
148
+ return tool.handler(parsed.data, api);
149
+ }
150
+
151
+ // src/config.ts
152
+ var CLIENT_ID = "viddy-mcp";
153
+ var DEFAULT_BASE_URL = "https://viddy.lol";
154
+ function getBaseUrl() {
155
+ const fromEnv = process.env["VIDDY_BASE_URL"];
156
+ if (fromEnv && fromEnv.length > 0) return fromEnv.replace(/\/+$/, "");
157
+ return DEFAULT_BASE_URL;
158
+ }
159
+ var REQUESTED_SCOPES = [
160
+ "videos.read",
161
+ "transcripts.read",
162
+ "annotations.read"
163
+ ];
164
+ var PACKAGE_NAME = "@unpuzzle/viddy-mcp";
165
+ var PACKAGE_VERSION = "0.1.0";
166
+
167
+ // src/auth.ts
168
+ import { mkdir, readFile, writeFile, chmod, rename, unlink } from "node:fs/promises";
169
+ import { dirname, join } from "node:path";
170
+ import envPaths from "env-paths";
171
+
172
+ // src/log.ts
173
+ function log(level, message, extra) {
174
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
175
+ const line = extra !== void 0 ? `[${ts}] ${level} ${message} ${safeJson(extra)}` : `[${ts}] ${level} ${message}`;
176
+ process.stderr.write(line + "\n");
177
+ }
178
+ function safeJson(value) {
179
+ try {
180
+ return JSON.stringify(value);
181
+ } catch {
182
+ return String(value);
183
+ }
184
+ }
185
+
186
+ // src/auth.ts
187
+ var PATHS = envPaths("viddy-mcp", { suffix: "" });
188
+ var AUTH_PATH = join(PATHS.config, "auth.json");
189
+ async function readFileOrNull() {
190
+ try {
191
+ const parsed = JSON.parse(await readFile(AUTH_PATH, "utf8"));
192
+ if (parsed?.schemaVersion !== 1) {
193
+ log("warn", "auth.json schemaVersion mismatch; ignoring stored tokens", {
194
+ path: AUTH_PATH
195
+ });
196
+ return null;
197
+ }
198
+ return parsed;
199
+ } catch (e) {
200
+ if (e?.code === "ENOENT") return null;
201
+ log("warn", "failed to read auth.json; treating as no stored tokens", {
202
+ path: AUTH_PATH,
203
+ error: e instanceof Error ? e.message : String(e)
204
+ });
205
+ return null;
206
+ }
207
+ }
208
+ async function writeAtomic(file) {
209
+ await mkdir(dirname(AUTH_PATH), { recursive: true, mode: 448 });
210
+ const tmp = `${AUTH_PATH}.${process.pid}.tmp`;
211
+ try {
212
+ await writeFile(tmp, JSON.stringify(file, null, 2), { encoding: "utf8", mode: 384 });
213
+ try {
214
+ await chmod(tmp, 384);
215
+ } catch {
216
+ }
217
+ await rename(tmp, AUTH_PATH);
218
+ } catch (e) {
219
+ await unlink(tmp).catch(() => {
220
+ });
221
+ throw e;
222
+ }
223
+ }
224
+ async function loadTokens() {
225
+ const file = await readFileOrNull();
226
+ if (!file) return null;
227
+ return file.byBaseUrl[getBaseUrl()] ?? null;
228
+ }
229
+ async function saveTokens(tokens) {
230
+ const file = await readFileOrNull() ?? { schemaVersion: 1, byBaseUrl: {} };
231
+ file.byBaseUrl[getBaseUrl()] = tokens;
232
+ await writeAtomic(file);
233
+ }
234
+ async function clearTokens() {
235
+ const file = await readFileOrNull();
236
+ if (!file) return;
237
+ delete file.byBaseUrl[getBaseUrl()];
238
+ await writeAtomic(file);
239
+ }
240
+ function authFilePath() {
241
+ return AUTH_PATH;
242
+ }
243
+ function accessTokenIsFresh(tokens) {
244
+ return tokens.expiresAt - Date.now() > 3e4;
245
+ }
246
+
247
+ // src/oauth.ts
248
+ import { createServer } from "node:http";
249
+ import { spawn } from "node:child_process";
250
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
251
+ import { platform } from "node:os";
252
+ function pkcePair() {
253
+ const verifier = randomBytes(32).toString("base64url");
254
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
255
+ return { verifier, challenge };
256
+ }
257
+ function stateMatches(a, b) {
258
+ const ab = Buffer.from(a, "utf8");
259
+ const bb = Buffer.from(b, "utf8");
260
+ if (ab.length !== bb.length) return false;
261
+ return timingSafeEqual(ab, bb);
262
+ }
263
+ function openBrowser(url) {
264
+ const override = process.env["VIDDY_BROWSER_CMD"];
265
+ if (override) {
266
+ spawn(override, [url], { stdio: "ignore", detached: true }).unref();
267
+ return;
268
+ }
269
+ const p = platform();
270
+ if (p === "darwin") {
271
+ spawn("open", [url], { stdio: "ignore", detached: true }).unref();
272
+ } else if (p === "win32") {
273
+ spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true }).unref();
274
+ } else {
275
+ spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
276
+ }
277
+ }
278
+ function escapeHtml(s) {
279
+ return s.replace(/[&<>"']/g, (c) => {
280
+ switch (c) {
281
+ case "&":
282
+ return "&amp;";
283
+ case "<":
284
+ return "&lt;";
285
+ case ">":
286
+ return "&gt;";
287
+ case '"':
288
+ return "&quot;";
289
+ default:
290
+ return "&#39;";
291
+ }
292
+ });
293
+ }
294
+ function renderCallbackHtml(ok, message) {
295
+ const title = ok ? "Connected" : "Sign-in error";
296
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title>
297
+ <style>
298
+ body{font-family:system-ui,sans-serif;max-width:440px;margin:80px auto;padding:0 20px;color:#111;line-height:1.5}
299
+ h1{font-size:20px;margin:0 0 10px}
300
+ p{color:#555}
301
+ .ok{color:#0f6b35}
302
+ .err{color:#b91c1c}
303
+ @media (prefers-color-scheme: dark){
304
+ body{background:#0b0b0d;color:#e8e8ea}
305
+ p{color:#a1a1aa}
306
+ .ok{color:#4ade80}
307
+ .err{color:#f87171}
308
+ }
309
+ </style></head><body>
310
+ <h1 class="${ok ? "ok" : "err"}">${ok ? "Connected to Viddy" : "Sign-in error"}</h1>
311
+ <p>${escapeHtml(message)}</p>
312
+ ${ok ? "<p>You can close this tab and return to your terminal.</p>" : ""}
313
+ </body></html>`;
314
+ }
315
+ async function runAuthorizationFlow(opts = {}) {
316
+ const timeoutMs = opts.timeoutMs ?? 5 * 60 * 1e3;
317
+ const baseUrl = getBaseUrl();
318
+ const { verifier, challenge } = pkcePair();
319
+ const state = randomBytes(16).toString("base64url");
320
+ const { server, redirectUri, codePromise } = await startCallbackServer(state);
321
+ let timer;
322
+ try {
323
+ const authorizeUrl = new URL("/api/oauth/authorize", baseUrl);
324
+ authorizeUrl.searchParams.set("client_id", CLIENT_ID);
325
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri);
326
+ authorizeUrl.searchParams.set("response_type", "code");
327
+ authorizeUrl.searchParams.set("scope", REQUESTED_SCOPES.join(" "));
328
+ authorizeUrl.searchParams.set("state", state);
329
+ authorizeUrl.searchParams.set("code_challenge", challenge);
330
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
331
+ log("info", "opening browser for Viddy consent");
332
+ process.stderr.write(
333
+ `
334
+ If your browser didn't open automatically, paste this URL:
335
+ ${authorizeUrl.toString()}
336
+
337
+ `
338
+ );
339
+ openBrowser(authorizeUrl.toString());
340
+ const code = await Promise.race([
341
+ codePromise,
342
+ new Promise((_, reject) => {
343
+ timer = setTimeout(
344
+ () => reject(new Error("Viddy sign-in timed out after 5 minutes. Run the `connect` tool to try again.")),
345
+ timeoutMs
346
+ );
347
+ })
348
+ ]);
349
+ const tokens = await exchangeCodeForTokens({ code, verifier, redirectUri });
350
+ await saveTokens(tokens);
351
+ log("info", "OAuth flow complete; tokens saved");
352
+ return tokens;
353
+ } finally {
354
+ if (timer) clearTimeout(timer);
355
+ server.close();
356
+ }
357
+ }
358
+ function startCallbackServer(expectedState) {
359
+ return new Promise((resolveOuter, rejectOuter) => {
360
+ let onCode;
361
+ let onError;
362
+ const codePromise = new Promise((res, rej) => {
363
+ onCode = res;
364
+ onError = rej;
365
+ });
366
+ const server = createServer((req, res) => {
367
+ if (!req.url) {
368
+ res.writeHead(400).end();
369
+ return;
370
+ }
371
+ const url = new URL(req.url, "http://127.0.0.1");
372
+ if (url.pathname !== "/callback") {
373
+ res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
374
+ res.end(renderCallbackHtml(false, "Unknown path on the local callback server."));
375
+ return;
376
+ }
377
+ const code = url.searchParams.get("code");
378
+ const state = url.searchParams.get("state");
379
+ const error = url.searchParams.get("error");
380
+ const errorDescription = url.searchParams.get("error_description");
381
+ if (error) {
382
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
383
+ res.end(renderCallbackHtml(false, `${error}: ${errorDescription ?? "sign-in failed"}`));
384
+ onError(new Error(`OAuth error: ${error} \u2014 ${errorDescription ?? ""}`));
385
+ return;
386
+ }
387
+ if (!code) {
388
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
389
+ res.end(renderCallbackHtml(false, "Authorization response did not include a code."));
390
+ onError(new Error("OAuth callback missing ?code="));
391
+ return;
392
+ }
393
+ if (!state || !stateMatches(state, expectedState)) {
394
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
395
+ res.end(renderCallbackHtml(false, "CSRF check failed: state mismatch."));
396
+ onError(new Error("OAuth callback state mismatch \u2014 possible CSRF"));
397
+ return;
398
+ }
399
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
400
+ res.end(renderCallbackHtml(true, "Authorization complete."));
401
+ onCode(code);
402
+ });
403
+ server.on("error", (e) => rejectOuter(e));
404
+ server.listen(0, "127.0.0.1", () => {
405
+ const addr = server.address();
406
+ if (!addr || typeof addr === "string") {
407
+ server.close();
408
+ rejectOuter(new Error("failed to read callback server port"));
409
+ return;
410
+ }
411
+ resolveOuter({
412
+ server,
413
+ redirectUri: `http://localhost:${addr.port}/callback`,
414
+ codePromise
415
+ });
416
+ });
417
+ });
418
+ }
419
+ async function postToken(body) {
420
+ const res = await fetch(`${getBaseUrl()}/api/oauth/token`, {
421
+ method: "POST",
422
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
423
+ body: body.toString()
424
+ });
425
+ let json;
426
+ try {
427
+ json = await res.json();
428
+ } catch {
429
+ throw new Error(`Viddy token endpoint returned a non-JSON response (HTTP ${res.status}).`);
430
+ }
431
+ if (!res.ok) {
432
+ throw new Error(
433
+ `${json["error"] ?? `HTTP ${res.status}`} \u2014 ${json["error_description"] ?? "token request rejected"}`
434
+ );
435
+ }
436
+ const t = json;
437
+ const now = Date.now();
438
+ return {
439
+ accessToken: t.access_token,
440
+ refreshToken: t.refresh_token,
441
+ expiresAt: now + t.expires_in * 1e3,
442
+ scope: t.scope,
443
+ grantedAt: now
444
+ };
445
+ }
446
+ async function exchangeCodeForTokens(args) {
447
+ return postToken(
448
+ new URLSearchParams({
449
+ grant_type: "authorization_code",
450
+ client_id: CLIENT_ID,
451
+ code: args.code,
452
+ code_verifier: args.verifier,
453
+ redirect_uri: args.redirectUri
454
+ })
455
+ );
456
+ }
457
+ async function refreshTokens(currentRefreshToken) {
458
+ const tokens = await postToken(
459
+ new URLSearchParams({
460
+ grant_type: "refresh_token",
461
+ client_id: CLIENT_ID,
462
+ refresh_token: currentRefreshToken
463
+ })
464
+ );
465
+ await saveTokens(tokens);
466
+ return tokens;
467
+ }
468
+
469
+ // src/api.ts
470
+ var ApiError = class extends Error {
471
+ constructor(message, status, body) {
472
+ super(message);
473
+ this.status = status;
474
+ this.body = body;
475
+ this.name = "ApiError";
476
+ }
477
+ };
478
+ var NotConnectedError = class extends Error {
479
+ constructor() {
480
+ super(
481
+ "Not connected to Viddy. Run the `connect` tool to sign in \u2014 or just ask for your videos again and consent will open in your browser."
482
+ );
483
+ this.name = "NotConnectedError";
484
+ }
485
+ };
486
+ async function ensureTokens(opts) {
487
+ let tokens = await loadTokens();
488
+ if (!tokens) {
489
+ if (opts.noAutoAuth) throw new NotConnectedError();
490
+ log("info", "no tokens found; starting OAuth flow");
491
+ tokens = await runAuthorizationFlow();
492
+ }
493
+ if (!accessTokenIsFresh(tokens)) {
494
+ log("info", "access token near expiry; refreshing");
495
+ try {
496
+ tokens = await refreshTokens(tokens.refreshToken);
497
+ } catch (e) {
498
+ log("warn", "refresh failed; clearing tokens", {
499
+ error: e instanceof Error ? e.message : String(e)
500
+ });
501
+ await clearTokens();
502
+ if (opts.noAutoAuth) throw new NotConnectedError();
503
+ tokens = await runAuthorizationFlow();
504
+ }
505
+ }
506
+ return tokens;
507
+ }
508
+ async function apiFetch(path, init = {}) {
509
+ const { noAutoAuth, headers: extraHeaders, ...rest } = init;
510
+ const tokens = await ensureTokens({ noAutoAuth: noAutoAuth ?? false });
511
+ const doRequest = (token) => fetch(`${getBaseUrl()}${path}`, {
512
+ ...rest,
513
+ headers: {
514
+ ...extraHeaders,
515
+ Authorization: `Bearer ${token}`,
516
+ Accept: "application/json"
517
+ }
518
+ });
519
+ let res = await doRequest(tokens.accessToken);
520
+ if (res.status === 401) {
521
+ log("info", "got 401; attempting refresh + replay");
522
+ let refreshed;
523
+ try {
524
+ refreshed = await refreshTokens(tokens.refreshToken);
525
+ } catch {
526
+ await clearTokens();
527
+ throw new NotConnectedError();
528
+ }
529
+ res = await doRequest(refreshed.accessToken);
530
+ }
531
+ if (!res.ok) {
532
+ let body = null;
533
+ try {
534
+ body = await res.json();
535
+ } catch {
536
+ body = await res.text().catch(() => null);
537
+ }
538
+ if (res.status === 403 && typeof body === "object" && body !== null && "reason" in body) {
539
+ const b = body;
540
+ if (b.reason === "insufficient_scope") {
541
+ throw new ApiError(
542
+ `This tool needs the "${b.required_scope}" scope, which the current connection doesn't have. Revoke Viddy MCP under Settings \u2192 Connected Apps on viddy.lol, then run \`connect\` again to grant it.`,
543
+ 403,
544
+ body
545
+ );
546
+ }
547
+ }
548
+ const code = typeof body === "object" && body !== null && "error" in body ? String(body.error) : `HTTP ${res.status}`;
549
+ const description = typeof body === "object" && body !== null && "error_description" in body ? String(body.error_description) : "";
550
+ throw new ApiError(description || `${code} (${res.status})`, res.status, body);
551
+ }
552
+ if (res.status === 204) return void 0;
553
+ return await res.json();
554
+ }
555
+ var apiClient = {
556
+ fetch: (path, init) => apiFetch(path, init)
557
+ };
558
+
559
+ // src/index.ts
560
+ function textResult(payload) {
561
+ return {
562
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }]
563
+ };
564
+ }
565
+ function errorResult(err) {
566
+ const message = err instanceof Error ? err.message : String(err);
567
+ const errorName = err instanceof Error ? err.name : "Error";
568
+ log("error", "tool call failed", { name: errorName, message });
569
+ const text = errorName === "NotConnectedError" ? `${errorName}: ${message}` : message;
570
+ return {
571
+ isError: true,
572
+ content: [{ type: "text", text }]
573
+ };
574
+ }
575
+ var LOCAL_TOOLS = [
576
+ {
577
+ name: "connect",
578
+ description: "Start the Viddy sign-in flow: opens the user's browser to the consent screen on viddy.lol. Use it when the user says 'connect to Viddy' or after `disconnect`. The other tools trigger this automatically when no connection exists, so calling it directly is rarely necessary.",
579
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
580
+ },
581
+ {
582
+ name: "disconnect",
583
+ description: "Forget the Viddy tokens stored on this machine. The user is asked to consent again on the next tool call. This does NOT revoke the grant on the server \u2014 for that, the user visits Settings \u2192 Connected Apps on viddy.lol. Use it for 'log out', 'disconnect', or 'forget my credentials'.",
584
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
585
+ }
586
+ ];
587
+ async function main() {
588
+ const server = new Server(
589
+ { name: PACKAGE_NAME, version: PACKAGE_VERSION },
590
+ { capabilities: { tools: {} } }
591
+ );
592
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
593
+ tools: [...toMcpToolList(), ...LOCAL_TOOLS]
594
+ }));
595
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
596
+ const { name } = request.params;
597
+ const args = request.params.arguments ?? {};
598
+ try {
599
+ if (name === "connect") {
600
+ const existing = await loadTokens();
601
+ if (existing) {
602
+ return textResult({
603
+ ok: true,
604
+ alreadyConnected: true,
605
+ baseUrl: getBaseUrl(),
606
+ scope: existing.scope,
607
+ message: "Already connected to Viddy. Use `disconnect` to forget these credentials.",
608
+ authFile: authFilePath()
609
+ });
610
+ }
611
+ const tokens = await runAuthorizationFlow();
612
+ return textResult({
613
+ ok: true,
614
+ message: "Connected to Viddy.",
615
+ baseUrl: getBaseUrl(),
616
+ scope: tokens.scope,
617
+ authFile: authFilePath()
618
+ });
619
+ }
620
+ if (name === "disconnect") {
621
+ const existing = await loadTokens();
622
+ if (!existing) {
623
+ return textResult({
624
+ ok: true,
625
+ wasConnected: false,
626
+ message: "No stored credentials \u2014 nothing to disconnect."
627
+ });
628
+ }
629
+ await clearTokens();
630
+ return textResult({
631
+ ok: true,
632
+ wasConnected: true,
633
+ message: `Cleared the Viddy credentials on this machine. To revoke the grant on the server too, visit ${getBaseUrl()}/settings/connected-apps.`
634
+ });
635
+ }
636
+ if (name === "ping") {
637
+ const result = await runTool(name, args, apiClient);
638
+ return textResult({
639
+ ...result,
640
+ package: PACKAGE_NAME,
641
+ version: PACKAGE_VERSION,
642
+ baseUrl: getBaseUrl(),
643
+ connected: await loadTokens() !== null
644
+ });
645
+ }
646
+ return textResult(await runTool(name, args, apiClient));
647
+ } catch (e) {
648
+ return errorResult(e);
649
+ }
650
+ });
651
+ const transport = new StdioServerTransport();
652
+ await server.connect(transport);
653
+ log("info", `${PACKAGE_NAME}@${PACKAGE_VERSION} connected over stdio`, {
654
+ baseUrl: getBaseUrl()
655
+ });
656
+ }
657
+ main().catch((err) => {
658
+ log("error", "fatal error in MCP server", {
659
+ message: err instanceof Error ? err.message : String(err),
660
+ stack: err instanceof Error ? err.stack : void 0
661
+ });
662
+ process.exit(1);
663
+ });
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/log.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Structured stderr logging.
3
+ *
4
+ * stdout carries the MCP protocol frames — anything written there corrupts the
5
+ * stream and the host drops the connection. Every diagnostic goes to stderr.
6
+ *
7
+ * This lives in its own module (rather than in index.ts, as the Vibe Marketing
8
+ * package does) so auth/oauth/api can import it without a cycle back through
9
+ * the server entry point.
10
+ */
11
+ export type LogLevel = "info" | "warn" | "error";
12
+ export declare function log(level: LogLevel, message: string, extra?: unknown): void;
13
+ //# sourceMappingURL=log.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"log.d.ts","sourceRoot":"","sources":["../src/log.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAEjD,wBAAgB,GAAG,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO,GAAG,IAAI,CAO3E"}