mcp-yoto 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/main.js ADDED
@@ -0,0 +1,1958 @@
1
+ import { createRequire } from 'module';
2
+ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
3
+ import { z } from 'zod';
4
+ import { createServer as createServer$1 } from 'http';
5
+ import { spawn } from 'child_process';
6
+ import { randomBytes, createHash } from 'crypto';
7
+ import { readFile, mkdir, copyFile, writeFile, rename, chmod, rm, stat, open } from 'fs/promises';
8
+ import { homedir } from 'os';
9
+ import { dirname, join, basename, isAbsolute, resolve } from 'path';
10
+ import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
11
+ import { createReadStream } from 'fs';
12
+ import { Readable } from 'stream';
13
+
14
+ // src/main.ts
15
+ function registerPrompts(server) {
16
+ server.registerPrompt("bedtime_playlist_card", {
17
+ title: "Bedtime playlist card",
18
+ description: "Draft a new Yoto card from a list of audio files or URLs, ready to upload as a bedtime playlist.",
19
+ argsSchema: {
20
+ files: z.string().describe("The audio files/URLs to include, one per line or comma-separated, in play order."),
21
+ childName: z.string().optional().describe("The child's name, for a personalised card title.")
22
+ }
23
+ }, async (args) => {
24
+ const childPart = args.childName ? ` for ${args.childName}` : "";
25
+ const titleHint = args.childName ? ` mentioning ${args.childName}` : "";
26
+ return {
27
+ messages: [
28
+ {
29
+ role: "user",
30
+ content: {
31
+ type: "text",
32
+ text: `Build a bedtime playlist card${childPart} from these tracks, in order:
33
+
34
+ ${args.files}
35
+
36
+ For each one: call yoto_upload_audio (or yoto_add_track once the card exists) to get a mediaRef, then call yoto_create_card with all the resulting tracks. Give the card a warm, bedtime-appropriate title${titleHint}, and ask before uploading anything.`
37
+ }
38
+ }
39
+ ]
40
+ };
41
+ });
42
+ server.registerPrompt("audit_card", {
43
+ title: "Audit a Yoto card",
44
+ description: "Review one Yoto card's chapters, tracks and icons for anything that looks off.",
45
+ argsSchema: {
46
+ cardId: z.string().describe("The Yoto card to audit.")
47
+ }
48
+ }, async (args) => ({
49
+ messages: [
50
+ {
51
+ role: "user",
52
+ content: {
53
+ type: "text",
54
+ text: `Call yoto_get_card with cardId "${args.cardId}", then check: every chapter has a title and at least one track; track durations look sensible (not zero, not implausibly long); icons (icon16x16) are set where it would help a child navigate; chapter and track ordering makes sense. Report anything worth fixing, and ask before calling yoto_update_card.`
55
+ }
56
+ }
57
+ ]
58
+ }));
59
+ }
60
+ var displaySchema = z.looseObject({ icon16x16: z.string().nullable().optional() }).nullable().optional();
61
+ var trackSchema = z.looseObject({
62
+ key: z.string(),
63
+ title: z.string(),
64
+ trackUrl: z.string(),
65
+ type: z.string().optional(),
66
+ duration: z.number().optional(),
67
+ fileSize: z.number().optional(),
68
+ channels: z.string().optional(),
69
+ format: z.string().optional(),
70
+ overlayLabel: z.string().optional(),
71
+ display: displaySchema
72
+ });
73
+ var chapterSchema = z.looseObject({
74
+ key: z.string(),
75
+ title: z.string(),
76
+ overlayLabel: z.string().optional(),
77
+ tracks: z.array(trackSchema).default([]),
78
+ duration: z.number().optional(),
79
+ fileSize: z.number().optional(),
80
+ display: displaySchema
81
+ });
82
+ var cardContentSchema = z.looseObject({
83
+ chapters: z.array(chapterSchema).default([])
84
+ });
85
+ var cardSchema = z.looseObject({
86
+ cardId: z.string(),
87
+ title: z.string(),
88
+ createdAt: z.string().optional(),
89
+ updatedAt: z.string().optional(),
90
+ deleted: z.boolean().optional(),
91
+ content: cardContentSchema.optional(),
92
+ metadata: z.record(z.string(), z.unknown()).optional()
93
+ });
94
+ var listCardsResponseSchema = z.looseObject({ cards: z.array(cardSchema).default([]) });
95
+ var getCardResponseSchema = z.looseObject({ card: cardSchema });
96
+ var upsertCardResponseSchema = z.looseObject({ card: cardSchema });
97
+ var deviceSchema = z.looseObject({
98
+ deviceId: z.string(),
99
+ name: z.string().optional(),
100
+ online: z.boolean().optional(),
101
+ deviceFamily: z.string().optional(),
102
+ deviceType: z.string().optional(),
103
+ releaseChannel: z.string().optional(),
104
+ description: z.string().optional()
105
+ });
106
+ var listDevicesResponseSchema = z.looseObject({
107
+ devices: z.array(deviceSchema).default([])
108
+ });
109
+ var deviceConfigSchema = z.record(z.string(), z.unknown());
110
+ var getDeviceConfigResponseSchema = z.looseObject({
111
+ device: z.looseObject({
112
+ deviceId: z.string().optional(),
113
+ config: deviceConfigSchema.optional()
114
+ }).optional()
115
+ });
116
+ var iconSchema = z.looseObject({
117
+ mediaId: z.string(),
118
+ displayIconId: z.string().optional(),
119
+ title: z.string().optional(),
120
+ url: z.string().optional(),
121
+ publicTags: z.array(z.string()).optional()
122
+ });
123
+ var listPublicIconsResponseSchema = z.looseObject({
124
+ displayIcons: z.array(iconSchema).default([])
125
+ });
126
+ var uploadIconResponseSchema = z.looseObject({ displayIcon: iconSchema });
127
+ var familyLibraryGroupSchema = z.looseObject({
128
+ groupId: z.string().optional(),
129
+ name: z.string().optional()
130
+ });
131
+ var listFamilyLibraryResponseSchema = z.looseObject({
132
+ groups: z.array(familyLibraryGroupSchema).default([])
133
+ });
134
+ var uploadUrlResponseSchema = z.looseObject({
135
+ upload: z.looseObject({
136
+ uploadId: z.string(),
137
+ uploadUrl: z.string().nullable()
138
+ })
139
+ });
140
+ var transcodedResponseSchema = z.looseObject({
141
+ transcode: z.looseObject({
142
+ transcodedSha256: z.string().optional(),
143
+ transcodedInfo: z.looseObject({
144
+ duration: z.number().optional(),
145
+ fileSize: z.number().optional(),
146
+ channels: z.string().optional(),
147
+ format: z.string().optional()
148
+ }).optional()
149
+ })
150
+ });
151
+
152
+ // ../../packages/core/dist/yoto/endpoints.js
153
+ async function listMyCards(client) {
154
+ const result = await client.request({
155
+ method: "GET",
156
+ path: "/content/mine",
157
+ schema: listCardsResponseSchema
158
+ });
159
+ return result.cards;
160
+ }
161
+ async function getCard(client, cardId) {
162
+ const result = await client.request({
163
+ method: "GET",
164
+ path: `/content/${encodeURIComponent(cardId)}`,
165
+ schema: getCardResponseSchema
166
+ });
167
+ return result.card;
168
+ }
169
+ async function upsertCard(client, body) {
170
+ const result = await client.request({
171
+ method: "POST",
172
+ path: "/content",
173
+ body,
174
+ schema: upsertCardResponseSchema,
175
+ idempotent: typeof body.cardId === "string" && body.cardId.length > 0
176
+ });
177
+ return result.card;
178
+ }
179
+ async function deleteCard(client, cardId) {
180
+ await client.request({
181
+ method: "DELETE",
182
+ path: `/content/${encodeURIComponent(cardId)}`,
183
+ idempotent: true
184
+ });
185
+ }
186
+ async function listPublicIcons(client) {
187
+ const result = await client.request({
188
+ method: "GET",
189
+ path: "/media/displayIcons/user/yoto",
190
+ schema: listPublicIconsResponseSchema
191
+ });
192
+ return result.displayIcons;
193
+ }
194
+ async function uploadCustomIcon(client, bytes, contentType, options = {}) {
195
+ const result = await client.request({
196
+ method: "POST",
197
+ path: "/media/displayIcons/user/me/upload",
198
+ query: { autoConvert: options.autoConvert ?? false, filename: options.filename },
199
+ // Wrapped in a Blob rather than passed as a raw Uint8Array: TypeScript's
200
+ // lib.dom BodyInit union doesn't line up with the generic-parameterised
201
+ // Uint8Array<ArrayBufferLike> this project's TS/lib versions infer for a
202
+ // bare `Uint8Array` type, but a Blob sidesteps that friction cleanly and
203
+ // uploads the identical bytes.
204
+ rawBody: new Blob([bytes]),
205
+ contentType,
206
+ schema: uploadIconResponseSchema
207
+ });
208
+ return result.displayIcon;
209
+ }
210
+ async function listDevices(client) {
211
+ const result = await client.request({
212
+ method: "GET",
213
+ path: "/device-v2/devices/mine",
214
+ schema: listDevicesResponseSchema
215
+ });
216
+ return result.devices;
217
+ }
218
+ async function getDeviceConfig(client, deviceId) {
219
+ const result = await client.request({
220
+ method: "GET",
221
+ path: `/device-v2/${encodeURIComponent(deviceId)}/config`,
222
+ schema: getDeviceConfigResponseSchema
223
+ });
224
+ return result.device?.config ?? {};
225
+ }
226
+ async function listFamilyLibrary(client) {
227
+ const result = await client.request({
228
+ method: "GET",
229
+ path: "/card/family/library/groups",
230
+ schema: listFamilyLibraryResponseSchema
231
+ });
232
+ return result.groups;
233
+ }
234
+ async function getUploadUrl(client, options = {}) {
235
+ return client.request({
236
+ method: "GET",
237
+ path: "/media/transcode/audio/uploadUrl",
238
+ query: options,
239
+ schema: uploadUrlResponseSchema
240
+ });
241
+ }
242
+ async function getTranscodedStatus(client, uploadId, loudnorm) {
243
+ return client.request({
244
+ method: "GET",
245
+ path: `/media/upload/${encodeURIComponent(uploadId)}/transcoded`,
246
+ query: { loudnorm },
247
+ schema: transcodedResponseSchema,
248
+ idempotent: true
249
+ });
250
+ }
251
+
252
+ // ../../packages/core/dist/resources.js
253
+ function registerResources(server, deps) {
254
+ server.registerResource("yoto-cards", "yoto://cards", {
255
+ title: "Your Yoto cards",
256
+ description: "The signed-in parent's MYO (Make Your Own) cards.",
257
+ mimeType: "application/json"
258
+ }, async (uri) => {
259
+ const cards = await listMyCards(deps.client);
260
+ return {
261
+ contents: [
262
+ {
263
+ uri: uri.toString(),
264
+ mimeType: "application/json",
265
+ text: JSON.stringify(cards, null, 2)
266
+ }
267
+ ]
268
+ };
269
+ });
270
+ server.registerResource("yoto-card", new ResourceTemplate("yoto://card/{cardId}", { list: void 0 }), {
271
+ title: "A Yoto card",
272
+ description: "One card's full details -- every chapter and track.",
273
+ mimeType: "application/json"
274
+ }, async (uri, variables) => {
275
+ const cardId = Array.isArray(variables.cardId) ? variables.cardId[0] : variables.cardId;
276
+ const card = await getCard(deps.client, String(cardId));
277
+ return {
278
+ contents: [
279
+ {
280
+ uri: uri.toString(),
281
+ mimeType: "application/json",
282
+ text: JSON.stringify(card, null, 2)
283
+ }
284
+ ]
285
+ };
286
+ });
287
+ server.registerResource("yoto-icons-public", "yoto://icons/public", {
288
+ title: "Yoto's public icon catalogue",
289
+ description: "Searchable 16x16 pixel-art icons available to every account.",
290
+ mimeType: "application/json"
291
+ }, async (uri) => {
292
+ const icons = await listPublicIcons(deps.client);
293
+ return {
294
+ contents: [
295
+ {
296
+ uri: uri.toString(),
297
+ mimeType: "application/json",
298
+ text: JSON.stringify(icons, null, 2)
299
+ }
300
+ ]
301
+ };
302
+ });
303
+ }
304
+
305
+ // ../../packages/core/dist/errors.js
306
+ var YotoError = class extends Error {
307
+ code;
308
+ retryable;
309
+ hint;
310
+ status;
311
+ constructor(message, options) {
312
+ super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
313
+ this.name = "YotoError";
314
+ this.code = options.code;
315
+ this.retryable = options.retryable ?? false;
316
+ this.hint = options.hint;
317
+ this.status = options.status;
318
+ }
319
+ };
320
+ function isYotoError(value) {
321
+ return value instanceof YotoError;
322
+ }
323
+ function toToolResult(error) {
324
+ if (isYotoError(error)) {
325
+ const suffix = error.hint ? ` (${error.hint})` : "";
326
+ return {
327
+ isError: true,
328
+ content: [{ type: "text", text: `${error.code}: ${error.message}${suffix}` }],
329
+ structuredContent: { code: error.code, message: error.message, hint: error.hint }
330
+ };
331
+ }
332
+ const message = error instanceof Error ? error.message : String(error);
333
+ return {
334
+ isError: true,
335
+ content: [{ type: "text", text: `UPSTREAM_ERROR: ${message}` }],
336
+ structuredContent: { code: "UPSTREAM_ERROR", message }
337
+ };
338
+ }
339
+
340
+ // ../../packages/core/dist/tools/_helpers.js
341
+ var DEFAULT_ICON_BASE_URL = "https://raw.githubusercontent.com/danpillay87/mcp-yoto/main/apps/worker/public/icons";
342
+ function defineTool(server, spec, deps) {
343
+ const iconBaseUrl = deps.iconBaseUrl ?? DEFAULT_ICON_BASE_URL;
344
+ const registerTool = server.registerTool.bind(server);
345
+ registerTool(spec.name, {
346
+ title: spec.title,
347
+ description: spec.description,
348
+ inputSchema: spec.inputSchema,
349
+ outputSchema: spec.outputSchema,
350
+ annotations: spec.annotations,
351
+ icons: [{ src: `${iconBaseUrl}/${spec.group}.png`, mimeType: "image/png", sizes: ["64x64"] }]
352
+ }, async (args) => {
353
+ try {
354
+ const output = await spec.handler(args, { logger: deps.logger });
355
+ const text = spec.summary(output, args);
356
+ return { content: [{ type: "text", text }], structuredContent: output };
357
+ } catch (error) {
358
+ if (isYotoError(error))
359
+ return toToolResult(error);
360
+ deps.logger.error(`Tool ${spec.name} failed unexpectedly`, {
361
+ error: error instanceof Error ? error.message : String(error)
362
+ });
363
+ return toToolResult(new YotoError(error instanceof Error ? error.message : String(error), {
364
+ code: "UPSTREAM_ERROR"
365
+ }));
366
+ }
367
+ });
368
+ }
369
+ var READ_ONLY_ANNOTATIONS = {
370
+ readOnlyHint: true,
371
+ destructiveHint: false,
372
+ idempotentHint: true,
373
+ openWorldHint: true
374
+ };
375
+ function createAuthTools(deps) {
376
+ const yotoStatus = {
377
+ name: "yoto_status",
378
+ title: "Check Yoto sign-in status",
379
+ description: "Reports whether this connection is signed in to Yoto, which mode it's running in (cli or remote), where the credential is stored, its granted scopes, and whether the Yoto API is currently reachable.",
380
+ group: "auth",
381
+ annotations: READ_ONLY_ANNOTATIONS,
382
+ inputSchema: z.object({}),
383
+ outputSchema: z.object({
384
+ signedIn: z.boolean(),
385
+ mode: z.enum(["cli", "remote"]),
386
+ tokenStore: z.enum(["keychain", "file", "remote"]).optional(),
387
+ expiresAt: z.number().optional(),
388
+ scopes: z.array(z.string()).optional(),
389
+ hint: z.string().optional(),
390
+ apiReachable: z.boolean().optional()
391
+ }),
392
+ summary: (output) => output.signedIn ? `Signed in to Yoto (${output.mode} mode)${output.apiReachable === false ? " -- API unreachable right now" : ""}.` : `Not signed in to Yoto.${output.hint ? ` ${output.hint}` : ""}`,
393
+ handler: async () => {
394
+ const status = await deps.auth.status();
395
+ let apiReachable;
396
+ if (status.signedIn) {
397
+ try {
398
+ await listMyCards(deps.client);
399
+ apiReachable = true;
400
+ } catch {
401
+ apiReachable = false;
402
+ }
403
+ }
404
+ return { ...status, apiReachable };
405
+ }
406
+ };
407
+ const yotoSignIn = {
408
+ name: "yoto_sign_in",
409
+ title: "Sign in to Yoto",
410
+ description: "CLI mode: launches a local PKCE sign-in flow (opens your browser to Yoto's login page and waits for the callback). Remote mode: this connector runs on a server with nothing to launch, so it returns instructions to sign in from your AI client's connector settings instead -- that flow redirects to Yoto's own login page too.",
411
+ group: "auth",
412
+ annotations: {
413
+ readOnlyHint: false,
414
+ destructiveHint: false,
415
+ idempotentHint: false,
416
+ openWorldHint: true
417
+ },
418
+ inputSchema: z.object({
419
+ openBrowser: z.boolean().optional().describe("CLI only: open the sign-in URL automatically. Default true.")
420
+ }),
421
+ outputSchema: z.object({
422
+ url: z.string().optional(),
423
+ message: z.string()
424
+ }),
425
+ // Include the URL in the tool's text, not just structuredContent.url --
426
+ // an MCP client typically only renders the text back to the human, and
427
+ // that's the one thing a headless/no-default-browser box needs to see.
428
+ summary: (output) => output.url ? `${output.message}
429
+
430
+ ${output.url}` : output.message,
431
+ handler: async (args) => {
432
+ if (deps.mode === "remote") {
433
+ return {
434
+ message: "This is a remote connector -- sign in from your AI client's connector/integration settings, which redirects you to Yoto's own login page. There's nothing to run here."
435
+ };
436
+ }
437
+ if (!deps.auth.signIn) {
438
+ throw new YotoError("Sign-in isn't available on this connection.", {
439
+ code: "UNSUPPORTED_IN_MODE"
440
+ });
441
+ }
442
+ return deps.auth.signIn({ openBrowser: args.openBrowser });
443
+ }
444
+ };
445
+ const yotoSignOut = {
446
+ name: "yoto_sign_out",
447
+ title: "Sign out of Yoto",
448
+ description: "CLI mode: deletes the locally stored Yoto credential (keychain or file). Remote mode: this server never stores your Yoto token at all, so it returns instructions for disconnecting from your AI client and revoking access at Yoto directly. Requires confirm: true.",
449
+ group: "auth",
450
+ annotations: {
451
+ readOnlyHint: false,
452
+ destructiveHint: true,
453
+ idempotentHint: true,
454
+ openWorldHint: true
455
+ },
456
+ inputSchema: z.object({
457
+ confirm: z.boolean().describe("Must be true -- this clears your stored Yoto credential.")
458
+ }),
459
+ outputSchema: z.object({ message: z.string() }),
460
+ summary: (output) => output.message,
461
+ handler: async (args) => {
462
+ if (!args.confirm) {
463
+ throw new YotoError("Pass confirm: true to sign out.", {
464
+ code: "VALIDATION",
465
+ hint: "This clears your locally stored Yoto credential."
466
+ });
467
+ }
468
+ if (deps.mode === "remote") {
469
+ return {
470
+ message: "This server never stores your Yoto token -- disconnect it from your AI client's connector settings, and optionally revoke access in your Yoto account's security settings."
471
+ };
472
+ }
473
+ if (!deps.auth.signOut) {
474
+ throw new YotoError("Sign-out isn't available on this connection.", {
475
+ code: "UNSUPPORTED_IN_MODE"
476
+ });
477
+ }
478
+ await deps.auth.signOut();
479
+ return { message: "Signed out of Yoto. Run yoto_sign_in to reconnect." };
480
+ }
481
+ };
482
+ return [yotoStatus, yotoSignIn, yotoSignOut];
483
+ }
484
+ var cardIdSchema = z.string().min(1, "cardId must not be empty");
485
+ z.object({
486
+ groups: z.array(familyLibraryGroupSchema),
487
+ note: z.string()
488
+ });
489
+ var FAMILY_LIBRARY_NOTE = 'Family library groups are shared collections, not individual MYO cards -- use yoto_get_card only with a cardId from source: "myo".';
490
+ async function summariseFamilyLibrary(client) {
491
+ const groups = await listFamilyLibrary(client);
492
+ return { groups, note: FAMILY_LIBRARY_NOTE };
493
+ }
494
+
495
+ // ../../packages/core/dist/tools/content.js
496
+ function createContentTools(deps) {
497
+ const yotoListCards = {
498
+ name: "yoto_list_cards",
499
+ title: "List your Yoto cards",
500
+ description: `Lists cards in your own MYO (Make Your Own) library, or the shared family library's groups. Family groups are collections, not individual cards -- see the returned note when source is "family".`,
501
+ group: "content",
502
+ annotations: {
503
+ readOnlyHint: true,
504
+ destructiveHint: false,
505
+ idempotentHint: true,
506
+ openWorldHint: true
507
+ },
508
+ inputSchema: z.object({
509
+ source: z.enum(["myo", "family"]).default("myo"),
510
+ limit: z.number().int().positive().max(200).optional(),
511
+ cursor: z.string().optional().describe("Reserved for future pagination; currently unused.")
512
+ }),
513
+ outputSchema: z.object({
514
+ source: z.enum(["myo", "family"]),
515
+ cards: z.array(cardSchema).optional(),
516
+ groups: z.array(z.looseObject({ groupId: z.string().optional(), name: z.string().optional() })).optional(),
517
+ note: z.string().optional()
518
+ }),
519
+ summary: (output) => output.source === "family" ? `${output.groups?.length ?? 0} family library group(s).` : `${output.cards?.length ?? 0} card(s) in your MYO library.`,
520
+ handler: async (args) => {
521
+ if (args.source === "family") {
522
+ const { groups, note } = await summariseFamilyLibrary(deps.client);
523
+ return { source: "family", groups, note };
524
+ }
525
+ const cards = await listMyCards(deps.client);
526
+ return { source: "myo", cards: args.limit ? cards.slice(0, args.limit) : cards };
527
+ }
528
+ };
529
+ const yotoGetCard = {
530
+ name: "yoto_get_card",
531
+ title: "Get a Yoto card's details",
532
+ description: "Fetches one MYO card's full details, including every chapter and track.",
533
+ group: "content",
534
+ annotations: {
535
+ readOnlyHint: true,
536
+ destructiveHint: false,
537
+ idempotentHint: true,
538
+ openWorldHint: true
539
+ },
540
+ inputSchema: z.object({ cardId: cardIdSchema }),
541
+ outputSchema: z.object({ card: cardSchema }),
542
+ summary: (output) => `"${output.card.title}" -- ${output.card.content?.chapters.length ?? 0} chapter(s).`,
543
+ handler: async (args) => ({ card: await getCard(deps.client, args.cardId) })
544
+ };
545
+ const yotoCreateCard = {
546
+ name: "yoto_create_card",
547
+ title: "Create a new Yoto card",
548
+ description: "Creates a new MYO card with the given title, and optionally an initial set of tracks (each a mediaRef from yoto_upload_audio, one track per chapter) and a default icon for those chapters.",
549
+ group: "content",
550
+ annotations: {
551
+ readOnlyHint: false,
552
+ destructiveHint: false,
553
+ idempotentHint: false,
554
+ openWorldHint: true
555
+ },
556
+ inputSchema: z.object({
557
+ title: z.string().min(1),
558
+ tracks: z.array(z.object({
559
+ mediaRef: z.string().min(1).describe("A yoto:#<sha256> reference from yoto_upload_audio."),
560
+ title: z.string().optional()
561
+ })).optional(),
562
+ iconRef: z.string().optional().describe("A yoto:#<mediaId> icon reference, e.g. from yoto_upload_icon.")
563
+ }),
564
+ outputSchema: z.object({ card: cardSchema }),
565
+ summary: (output) => `Created "${output.card.title}" (${output.card.cardId}).`,
566
+ handler: async (args) => {
567
+ const tracks = args.tracks ?? [];
568
+ const chapters = tracks.map((track, index) => {
569
+ const key = String(index + 1).padStart(2, "0");
570
+ const title = track.title ?? args.title;
571
+ return {
572
+ key,
573
+ title,
574
+ overlayLabel: String(index + 1),
575
+ ...args.iconRef ? { display: { icon16x16: args.iconRef } } : {},
576
+ tracks: [
577
+ {
578
+ key,
579
+ title,
580
+ trackUrl: track.mediaRef,
581
+ type: "audio",
582
+ overlayLabel: String(index + 1),
583
+ ...args.iconRef ? { display: { icon16x16: args.iconRef } } : {}
584
+ }
585
+ ]
586
+ };
587
+ });
588
+ const card = await upsertCard(deps.client, { title: args.title, content: { chapters } });
589
+ return { card };
590
+ }
591
+ };
592
+ const yotoUpdateCard = {
593
+ name: "yoto_update_card",
594
+ title: "Update a Yoto card",
595
+ description: "Shallow-merges patch fields (e.g. title, or a full replacement content.chapters array to rename/reorder tracks or set icons) onto an existing card, then saves it.",
596
+ group: "content",
597
+ annotations: {
598
+ readOnlyHint: false,
599
+ destructiveHint: true,
600
+ idempotentHint: true,
601
+ openWorldHint: true
602
+ },
603
+ inputSchema: z.object({
604
+ cardId: cardIdSchema,
605
+ patch: z.record(z.string(), z.unknown()).describe("Fields to merge onto the existing card, e.g. { title } or { content }.")
606
+ }),
607
+ outputSchema: z.object({ card: cardSchema }),
608
+ summary: (output) => `Updated "${output.card.title}" (${output.card.cardId}).`,
609
+ handler: async (args) => {
610
+ const existing = await getCard(deps.client, args.cardId);
611
+ const merged = { ...existing, ...args.patch, cardId: args.cardId };
612
+ const card = await upsertCard(deps.client, merged);
613
+ return { card };
614
+ }
615
+ };
616
+ const yotoDeleteCard = {
617
+ name: "yoto_delete_card",
618
+ title: "Delete a Yoto card",
619
+ description: "Permanently deletes a MYO card from the parent's Yoto library. Requires confirm: true.",
620
+ group: "content",
621
+ annotations: {
622
+ readOnlyHint: false,
623
+ destructiveHint: true,
624
+ idempotentHint: true,
625
+ openWorldHint: true
626
+ },
627
+ inputSchema: z.object({
628
+ cardId: cardIdSchema,
629
+ confirm: z.boolean().describe("Must be true -- this permanently deletes the card.")
630
+ }),
631
+ outputSchema: z.object({ cardId: z.string(), deleted: z.boolean() }),
632
+ summary: (output) => `Deleted card ${output.cardId}.`,
633
+ handler: async (args) => {
634
+ if (!args.confirm) {
635
+ throw new YotoError("Pass confirm: true to delete this card.", {
636
+ code: "VALIDATION",
637
+ hint: "This permanently deletes the card from the parent's Yoto library."
638
+ });
639
+ }
640
+ await deleteCard(deps.client, args.cardId);
641
+ return { cardId: args.cardId, deleted: true };
642
+ }
643
+ };
644
+ return [yotoListCards, yotoGetCard, yotoCreateCard, yotoUpdateCard, yotoDeleteCard];
645
+ }
646
+ var READ_ONLY_ANNOTATIONS2 = {
647
+ readOnlyHint: true,
648
+ destructiveHint: false,
649
+ idempotentHint: true,
650
+ openWorldHint: true
651
+ };
652
+ function createDeviceTools(deps) {
653
+ const yotoListDevices = {
654
+ name: "yoto_list_devices",
655
+ title: "List your Yoto devices",
656
+ description: "Lists the family's Yoto players -- name, online status, and device type. View-only.",
657
+ group: "devices",
658
+ annotations: READ_ONLY_ANNOTATIONS2,
659
+ inputSchema: z.object({}),
660
+ outputSchema: z.object({ devices: z.array(deviceSchema) }),
661
+ summary: (output) => `${output.devices.length} device(s).`,
662
+ handler: async () => ({ devices: await listDevices(deps.client) })
663
+ };
664
+ const yotoGetDeviceConfig = {
665
+ name: "yoto_get_device_config",
666
+ title: "Get a Yoto device's configuration",
667
+ description: "Fetches one player's device configuration (clock face, volume limits, right-hand-button shortcuts, etc). This is a beta Yoto endpoint that needs a device-management scope this server intentionally never requests, so expect a FORBIDDEN_SCOPE result with a hint rather than data.",
668
+ group: "devices",
669
+ annotations: READ_ONLY_ANNOTATIONS2,
670
+ inputSchema: z.object({ deviceId: z.string().min(1) }),
671
+ outputSchema: z.object({ config: z.record(z.string(), z.unknown()) }),
672
+ summary: () => "Fetched device configuration.",
673
+ handler: async (args) => {
674
+ try {
675
+ return { config: await getDeviceConfig(deps.client, args.deviceId) };
676
+ } catch (error) {
677
+ if (isYotoError(error) && error.code === "FORBIDDEN_SCOPE") {
678
+ throw new YotoError("Device configuration needs a scope this connection doesn't have.", {
679
+ code: "FORBIDDEN_SCOPE",
680
+ status: error.status,
681
+ hint: "Yoto's device-config endpoint is in beta and requires family:devices:manage, which this server deliberately doesn't request (it only asks for family:devices:view, to stay eligible for Yoto's Verified listing). Use the Yoto app's right-hand-button shortcuts screen instead."
682
+ });
683
+ }
684
+ throw error;
685
+ }
686
+ }
687
+ };
688
+ return [yotoListDevices, yotoGetDeviceConfig];
689
+ }
690
+ var httpsUrl = z.string().url().refine((value) => value.startsWith("https://"), { message: "Must be an https:// URL." });
691
+ function createIconTools(deps) {
692
+ const yotoSearchIcons = {
693
+ name: "yoto_search_icons",
694
+ title: "Search Yoto's icon catalogue",
695
+ description: "Searches Yoto's public catalogue of 16x16 pixel-art icons by title or tag, for use as a chapter/track icon (iconRef) when creating or updating a card.",
696
+ group: "icons",
697
+ annotations: {
698
+ readOnlyHint: true,
699
+ destructiveHint: false,
700
+ idempotentHint: true,
701
+ openWorldHint: true
702
+ },
703
+ inputSchema: z.object({
704
+ query: z.string().optional().describe("Free-text match against the icon's title and tags."),
705
+ tags: z.array(z.string()).optional().describe("Icon must carry every one of these tags."),
706
+ limit: z.number().int().positive().max(100).optional()
707
+ }),
708
+ outputSchema: z.object({ icons: z.array(iconSchema) }),
709
+ summary: (output) => `${output.icons.length} icon(s) found.`,
710
+ handler: async (args) => {
711
+ const all = await listPublicIcons(deps.client);
712
+ let filtered = all;
713
+ if (args.query) {
714
+ const query = args.query.toLowerCase();
715
+ filtered = filtered.filter((icon) => icon.title?.toLowerCase().includes(query) || icon.publicTags?.some((tag) => tag.toLowerCase().includes(query)));
716
+ }
717
+ if (args.tags?.length) {
718
+ const wantedTags = args.tags;
719
+ filtered = filtered.filter((icon) => wantedTags.every((tag) => icon.publicTags?.includes(tag)));
720
+ }
721
+ return { icons: args.limit ? filtered.slice(0, args.limit) : filtered };
722
+ }
723
+ };
724
+ const imageInputShape = deps.mode === "cli" ? {
725
+ imagePath: z.string().min(1).describe("Absolute path to a local image file (PNG, JPEG, or SVG).")
726
+ } : {
727
+ imageUrl: httpsUrl.describe("An https:// URL to an image file (PNG, JPEG, or SVG) -- this connector runs remotely, so give a link, not a file path.")
728
+ };
729
+ const yotoUploadIcon = {
730
+ name: "yoto_upload_icon",
731
+ title: "Upload a custom Yoto icon",
732
+ description: "Uploads a custom 16x16 pixel-art icon to your Yoto account, returning a mediaId usable as an iconRef on cards and tracks. " + (deps.mode === "cli" ? "Runs locally, so it takes a file path." : "This connector runs remotely, so it takes a link, not a file path."),
733
+ group: "icons",
734
+ annotations: {
735
+ readOnlyHint: false,
736
+ destructiveHint: false,
737
+ idempotentHint: false,
738
+ openWorldHint: true
739
+ },
740
+ inputSchema: z.object({
741
+ ...imageInputShape,
742
+ title: z.string().min(1).describe("Sent to Yoto as the uploaded file's name."),
743
+ autoConvert: z.boolean().optional().describe("Let Yoto auto-convert the image to its 16x16 icon format. Default false.")
744
+ }),
745
+ outputSchema: z.object({ mediaId: z.string(), url: z.string().optional() }),
746
+ summary: (output) => `Uploaded icon -- ${output.mediaId}.`,
747
+ handler: async (args) => {
748
+ const input = deps.mode === "cli" ? { imagePath: args.imagePath } : { imageUrl: args.imageUrl };
749
+ const image = await deps.resolveImage(input);
750
+ const icon = await uploadCustomIcon(deps.client, image.bytes, image.contentType, {
751
+ filename: args.title,
752
+ autoConvert: args.autoConvert
753
+ });
754
+ return { mediaId: icon.mediaId, url: icon.url };
755
+ }
756
+ };
757
+ return [yotoSearchIcons, yotoUploadIcon];
758
+ }
759
+
760
+ // ../../packages/core/dist/yoto/media.js
761
+ function defaultSleep(ms) {
762
+ return new Promise((resolve) => setTimeout(resolve, ms));
763
+ }
764
+ async function uploadAudio(client, source, options = {}) {
765
+ const pollIntervalMs = options.pollIntervalMs ?? 750;
766
+ const timeoutMs = options.timeoutMs ?? 6e4;
767
+ const loudnorm = options.loudnorm ?? false;
768
+ const now = options.now ?? (() => Date.now());
769
+ const sleep = options.sleep ?? defaultSleep;
770
+ const { upload } = await getUploadUrl(client, { filename: source.filename });
771
+ if (upload.uploadUrl) {
772
+ const putHeaders = { "Content-Type": source.contentType };
773
+ const putResponse = await client.fetchImpl(upload.uploadUrl, {
774
+ method: "PUT",
775
+ // `duplex: "half"` is required by fetch implementations (undici, the
776
+ // Workers runtime) to stream a body, but isn't in lib.dom's RequestInit
777
+ // type yet.
778
+ // biome-ignore lint/suspicious/noExplicitAny: streaming-body fetch option missing from lib.dom types.
779
+ ...{ duplex: "half" },
780
+ body: source.stream,
781
+ headers: putHeaders
782
+ });
783
+ if (!putResponse.ok) {
784
+ throw new YotoError(`Uploading audio to Yoto's storage failed (HTTP ${putResponse.status})`, {
785
+ code: "UPSTREAM_ERROR",
786
+ retryable: true,
787
+ status: putResponse.status
788
+ });
789
+ }
790
+ }
791
+ const deadline = now() + timeoutMs;
792
+ for (; ; ) {
793
+ const status = await getTranscodedStatus(client, upload.uploadId, loudnorm);
794
+ const sha = status.transcode.transcodedSha256;
795
+ if (sha) {
796
+ return { mediaRef: `yoto:#${sha}`, transcodedInfo: status.transcode.transcodedInfo ?? {} };
797
+ }
798
+ if (now() >= deadline) {
799
+ throw new YotoError("Yoto didn't finish transcoding the audio in time", {
800
+ code: "TRANSCODE_TIMEOUT",
801
+ retryable: true,
802
+ hint: "Try again, or upload a shorter/smaller file."
803
+ });
804
+ }
805
+ await sleep(pollIntervalMs);
806
+ }
807
+ }
808
+ function bytesStartWith(bytes, offset, pattern) {
809
+ if (bytes.length < offset + pattern.length)
810
+ return false;
811
+ for (let i = 0; i < pattern.length; i++) {
812
+ if (bytes[offset + i] !== pattern[i])
813
+ return false;
814
+ }
815
+ return true;
816
+ }
817
+ var ASCII = (text) => Array.from(text, (c) => c.charCodeAt(0));
818
+ function sniffAudio(bytes, filename) {
819
+ if (bytesStartWith(bytes, 0, ASCII("ID3")))
820
+ return { valid: true, format: "mp3" };
821
+ if (bytes.length >= 2 && bytes[0] === 255 && ((bytes[1] ?? 0) & 224) === 224)
822
+ return { valid: true, format: "mp3" };
823
+ if (bytesStartWith(bytes, 4, ASCII("ftyp")))
824
+ return { valid: true, format: "m4a" };
825
+ if (bytesStartWith(bytes, 0, ASCII("RIFF")) && bytesStartWith(bytes, 8, ASCII("WAVE"))) {
826
+ return { valid: true, format: "wav" };
827
+ }
828
+ if (bytesStartWith(bytes, 0, ASCII("OggS")))
829
+ return { valid: true, format: "ogg" };
830
+ throw new YotoError(`Could not recognise${filename ? ` "${filename}"` : " this file"} as a supported audio format`, { code: "INVALID_AUDIO", hint: "Supported formats: MP3, M4A/AAC, WAV, OGG." });
831
+ }
832
+ function sniffImage(bytes, filename) {
833
+ if (bytesStartWith(bytes, 0, [137, 80, 78, 71, 13, 10, 26, 10])) {
834
+ return { valid: true, format: "png" };
835
+ }
836
+ if (bytesStartWith(bytes, 0, [255, 216, 255]))
837
+ return { valid: true, format: "jpeg" };
838
+ const head = new TextDecoder("utf-8", { fatal: false }).decode(bytes.slice(0, 256)).trimStart().toLowerCase();
839
+ if (head.startsWith("<?xml") || head.startsWith("<svg"))
840
+ return { valid: true, format: "svg" };
841
+ throw new YotoError(`Could not recognise${filename ? ` "${filename}"` : " this file"} as a supported image format`, { code: "INVALID_IMAGE", hint: "Supported formats: PNG, JPEG, SVG." });
842
+ }
843
+
844
+ // ../../packages/core/dist/tools/media.js
845
+ var httpsUrl2 = z.string().url().refine((value) => value.startsWith("https://"), { message: "Must be an https:// URL." });
846
+ function audioInputSchema(mode) {
847
+ return mode === "cli" ? z.object({
848
+ audioFilePath: z.string().min(1).describe("Absolute path to a local audio file (MP3, M4A/AAC, WAV, or OGG).")
849
+ }) : z.object({
850
+ audioUrl: httpsUrl2.describe("An https:// URL to an audio file (MP3, M4A/AAC, WAV, or OGG) -- this connector runs remotely, so give a link, not a file path.")
851
+ });
852
+ }
853
+ function toAudioInput(mode, args) {
854
+ return mode === "cli" ? { audioFilePath: args.audioFilePath } : { audioUrl: args.audioUrl };
855
+ }
856
+ function createMediaTools(deps) {
857
+ const yotoUploadAudio = {
858
+ name: "yoto_upload_audio",
859
+ title: "Upload audio to Yoto",
860
+ description: "Uploads an audio file to Yoto and waits for it to finish transcoding, returning a yoto:#<sha256> mediaRef usable as a track when creating or updating a card. " + (deps.mode === "cli" ? "Runs locally, so it takes a file path." : "This connector runs remotely, so it takes a link, not a file path."),
861
+ group: "media",
862
+ annotations: {
863
+ readOnlyHint: false,
864
+ destructiveHint: false,
865
+ idempotentHint: false,
866
+ openWorldHint: true
867
+ },
868
+ inputSchema: audioInputSchema(deps.mode).extend({
869
+ loudnorm: z.boolean().optional().describe("Apply loudness normalisation. Default false.")
870
+ }),
871
+ outputSchema: z.object({
872
+ mediaRef: z.string(),
873
+ duration: z.number().optional(),
874
+ fileSize: z.number().optional(),
875
+ format: z.string().optional()
876
+ }),
877
+ summary: (output) => `Uploaded audio -- ${output.mediaRef}${output.duration ? ` (${output.duration}s)` : ""}.`,
878
+ handler: async (args) => {
879
+ const source = await deps.resolveAudio(toAudioInput(deps.mode, args));
880
+ const result = await uploadAudio(deps.client, source, { loudnorm: args.loudnorm });
881
+ return {
882
+ mediaRef: result.mediaRef,
883
+ duration: result.transcodedInfo.duration,
884
+ fileSize: result.transcodedInfo.fileSize,
885
+ format: result.transcodedInfo.format
886
+ };
887
+ }
888
+ };
889
+ const yotoAddTrack = {
890
+ name: "yoto_add_track",
891
+ title: "Add a track to a Yoto card",
892
+ description: "Uploads an audio file and appends it as a new chapter/track on an existing MYO card. " + (deps.mode === "cli" ? "Runs locally, so it takes a file path." : "This connector runs remotely, so it takes a link, not a file path."),
893
+ group: "media",
894
+ annotations: {
895
+ readOnlyHint: false,
896
+ destructiveHint: false,
897
+ idempotentHint: false,
898
+ openWorldHint: true
899
+ },
900
+ inputSchema: audioInputSchema(deps.mode).extend({
901
+ cardId: cardIdSchema,
902
+ trackTitle: z.string().optional(),
903
+ iconRef: z.string().optional().describe("A yoto:#<mediaId> icon reference, e.g. from yoto_upload_icon.")
904
+ }),
905
+ outputSchema: z.object({ card: cardSchema }),
906
+ summary: (output) => `Added a track to "${output.card.title}" (${output.card.cardId}).`,
907
+ handler: async (args) => {
908
+ const source = await deps.resolveAudio(toAudioInput(deps.mode, args));
909
+ const uploaded = await uploadAudio(deps.client, source);
910
+ const existing = await getCard(deps.client, args.cardId);
911
+ const chapters = existing.content?.chapters ?? [];
912
+ const nextNumber = chapters.length + 1;
913
+ const key = String(nextNumber).padStart(2, "0");
914
+ const title = args.trackTitle ?? source.filename ?? `Track ${nextNumber}`;
915
+ const newChapter = {
916
+ key,
917
+ title,
918
+ overlayLabel: String(nextNumber),
919
+ ...args.iconRef ? { display: { icon16x16: args.iconRef } } : {},
920
+ tracks: [
921
+ {
922
+ key,
923
+ title,
924
+ trackUrl: uploaded.mediaRef,
925
+ type: "audio",
926
+ overlayLabel: String(nextNumber),
927
+ ...args.iconRef ? { display: { icon16x16: args.iconRef } } : {}
928
+ }
929
+ ]
930
+ };
931
+ const card = await upsertCard(deps.client, {
932
+ ...existing,
933
+ cardId: args.cardId,
934
+ content: { ...existing.content, chapters: [...chapters, newChapter] }
935
+ });
936
+ return { card };
937
+ }
938
+ };
939
+ return [yotoUploadAudio, yotoAddTrack];
940
+ }
941
+
942
+ // ../../packages/core/dist/tools/index.js
943
+ function createTools(deps) {
944
+ return [
945
+ ...createAuthTools(deps),
946
+ ...createContentTools(deps),
947
+ ...createMediaTools(deps),
948
+ ...createIconTools(deps),
949
+ ...createDeviceTools(deps)
950
+ ];
951
+ }
952
+ var YOTO_SCOPES = [
953
+ "profile",
954
+ "offline_access",
955
+ "user:content:view",
956
+ "user:content:manage",
957
+ "user:icons:manage",
958
+ "family:library:view",
959
+ "family:devices:view"
960
+ ];
961
+ z.object({
962
+ signedIn: z.boolean(),
963
+ mode: z.enum(["cli", "remote"]),
964
+ tokenStore: z.enum(["keychain", "file", "remote"]).optional(),
965
+ expiresAt: z.number().optional(),
966
+ scopes: z.array(z.string()).optional(),
967
+ hint: z.string().optional()
968
+ });
969
+ z.object({
970
+ url: z.string().optional(),
971
+ message: z.string()
972
+ });
973
+
974
+ // ../../packages/core/dist/logging.js
975
+ var LEVEL_ORDER = { debug: 0, info: 1, warn: 2, error: 3 };
976
+ var TOKEN_PATTERN = /eyJ[a-zA-Z0-9_-]{10,}(?:\.[a-zA-Z0-9_-]{10,}){0,2}|[a-zA-Z0-9_-]{32,}/g;
977
+ function redact(input) {
978
+ return input.replace(TOKEN_PATTERN, "[redacted]");
979
+ }
980
+ function redactValue(value) {
981
+ if (typeof value === "string")
982
+ return redact(value);
983
+ return JSON.parse(redact(JSON.stringify(value)));
984
+ }
985
+ var NOOP_SINK = () => {
986
+ };
987
+ function createLogger(options = {}) {
988
+ const minLevel = options.level ?? "info";
989
+ const sink = options.sink ?? NOOP_SINK;
990
+ function write(level, message, data) {
991
+ if (LEVEL_ORDER[level] < LEVEL_ORDER[minLevel])
992
+ return;
993
+ const safeMessage = redact(message);
994
+ const safeData = data === void 0 ? void 0 : redactValue(data);
995
+ sink({ level, message: safeMessage, data: safeData });
996
+ }
997
+ return {
998
+ debug: (message, data) => write("debug", message, data),
999
+ info: (message, data) => write("info", message, data),
1000
+ warn: (message, data) => write("warn", message, data),
1001
+ error: (message, data) => write("error", message, data)
1002
+ };
1003
+ }
1004
+ var noopLogger = createLogger({ sink: NOOP_SINK });
1005
+
1006
+ // ../../packages/core/dist/yoto/client.js
1007
+ var MAX_ATTEMPTS = 3;
1008
+ var RETRY_AFTER_CAP_MS = 3e4;
1009
+ var BACKOFF_BASE_MS = 300;
1010
+ var BACKOFF_CAP_MS = 8e3;
1011
+ function computeFullJitterDelayMs(attempt, options = {}) {
1012
+ const base = options.base ?? BACKOFF_BASE_MS;
1013
+ const cap = options.cap ?? BACKOFF_CAP_MS;
1014
+ const random = options.random ?? Math.random;
1015
+ const upperBound = Math.min(cap, base * 2 ** (attempt - 1));
1016
+ return random() * upperBound;
1017
+ }
1018
+ function parseRetryAfterMs(value, now) {
1019
+ if (!value)
1020
+ return void 0;
1021
+ const asSeconds = Number(value);
1022
+ if (!Number.isNaN(asSeconds))
1023
+ return Math.max(0, asSeconds * 1e3);
1024
+ const asDate = Date.parse(value);
1025
+ if (!Number.isNaN(asDate))
1026
+ return Math.max(0, asDate - now());
1027
+ return void 0;
1028
+ }
1029
+ var HINTS = {
1030
+ AUTH_EXPIRED: "Run yoto_sign_in to refresh your Yoto session.",
1031
+ FORBIDDEN_SCOPE: "This connection doesn't have the Yoto scope this action needs.",
1032
+ RATE_LIMITED: "Yoto is rate-limiting requests right now; try again shortly.",
1033
+ NOT_FOUND: "That Yoto resource wasn't found.",
1034
+ UPSTREAM_ERROR: "Yoto's API returned an unexpected error."
1035
+ };
1036
+ function defaultSleep2(ms) {
1037
+ return new Promise((resolve) => setTimeout(resolve, ms));
1038
+ }
1039
+ var YotoClient = class {
1040
+ getToken;
1041
+ fetchImpl;
1042
+ baseUrl;
1043
+ logger;
1044
+ now;
1045
+ sleepImpl;
1046
+ constructor(options) {
1047
+ this.getToken = options.getToken;
1048
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
1049
+ this.baseUrl = options.baseUrl ?? "https://api.yotoplay.com";
1050
+ this.logger = options.logger ?? noopLogger;
1051
+ this.now = options.now ?? (() => Date.now());
1052
+ this.sleepImpl = options.sleep ?? defaultSleep2;
1053
+ }
1054
+ buildUrl(path, query) {
1055
+ const url = new URL(path.startsWith("http") ? path : `${this.baseUrl}${path}`);
1056
+ if (query) {
1057
+ for (const [key, value] of Object.entries(query)) {
1058
+ if (value !== void 0)
1059
+ url.searchParams.set(key, String(value));
1060
+ }
1061
+ }
1062
+ return url.toString();
1063
+ }
1064
+ async toUpstreamError(response, code) {
1065
+ let detail = "";
1066
+ try {
1067
+ const text = await response.text();
1068
+ if (text) {
1069
+ try {
1070
+ const parsed = JSON.parse(text);
1071
+ detail = parsed.message ?? parsed.error ?? text;
1072
+ } catch {
1073
+ detail = text;
1074
+ }
1075
+ }
1076
+ } catch {
1077
+ }
1078
+ const retryable = code === "RATE_LIMITED" || code === "UPSTREAM_ERROR";
1079
+ return new YotoError(`Yoto API returned ${response.status}${detail ? `: ${detail}` : ""}`, {
1080
+ code,
1081
+ retryable,
1082
+ status: response.status,
1083
+ hint: HINTS[code]
1084
+ });
1085
+ }
1086
+ async parseBody(response, schema, method, path) {
1087
+ if (response.status === 204)
1088
+ return void 0;
1089
+ const text = await response.text();
1090
+ let json;
1091
+ try {
1092
+ json = text ? JSON.parse(text) : void 0;
1093
+ } catch (cause) {
1094
+ throw new YotoError(`Yoto returned invalid JSON for ${method} ${path}`, {
1095
+ code: "UPSTREAM_ERROR",
1096
+ cause,
1097
+ hint: "This may be a transient Yoto API issue -- try again."
1098
+ });
1099
+ }
1100
+ if (!schema)
1101
+ return json;
1102
+ const result = schema.safeParse(json);
1103
+ if (!result.success) {
1104
+ throw new YotoError(`Yoto's response for ${method} ${path} didn't match the expected shape`, {
1105
+ code: "UPSTREAM_ERROR",
1106
+ cause: result.error,
1107
+ hint: "Yoto may have changed their API response shape."
1108
+ });
1109
+ }
1110
+ return result.data;
1111
+ }
1112
+ async request(options) {
1113
+ const idempotent = options.idempotent ?? options.method === "GET";
1114
+ const url = this.buildUrl(options.path, options.query);
1115
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
1116
+ let response;
1117
+ try {
1118
+ const token = await this.getToken();
1119
+ const headers = {
1120
+ Authorization: `Bearer ${token}`,
1121
+ Accept: "application/json",
1122
+ ...options.headers
1123
+ };
1124
+ let body;
1125
+ if (options.rawBody !== void 0) {
1126
+ body = options.rawBody;
1127
+ if (options.contentType)
1128
+ headers["Content-Type"] = options.contentType;
1129
+ } else if (options.body !== void 0) {
1130
+ body = JSON.stringify(options.body);
1131
+ headers["Content-Type"] = "application/json";
1132
+ }
1133
+ response = await this.fetchImpl(url, { method: options.method, headers, body });
1134
+ } catch (cause) {
1135
+ if (isYotoError(cause))
1136
+ throw cause;
1137
+ if (!idempotent || attempt >= MAX_ATTEMPTS) {
1138
+ throw new YotoError(`Network request to Yoto failed: ${options.method} ${options.path}`, {
1139
+ code: "UPSTREAM_ERROR",
1140
+ retryable: true,
1141
+ cause,
1142
+ hint: "Check network connectivity and try again."
1143
+ });
1144
+ }
1145
+ this.logger.warn("Yoto request failed, retrying", {
1146
+ method: options.method,
1147
+ path: options.path,
1148
+ attempt
1149
+ });
1150
+ await this.sleepImpl(computeFullJitterDelayMs(attempt));
1151
+ continue;
1152
+ }
1153
+ if (response.ok) {
1154
+ return this.parseBody(response, options.schema, options.method, options.path);
1155
+ }
1156
+ if (response.status === 429) {
1157
+ if (!idempotent || attempt >= MAX_ATTEMPTS) {
1158
+ throw await this.toUpstreamError(response, "RATE_LIMITED");
1159
+ }
1160
+ const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"), this.now);
1161
+ await this.sleepImpl(Math.min(retryAfterMs ?? computeFullJitterDelayMs(attempt), RETRY_AFTER_CAP_MS));
1162
+ continue;
1163
+ }
1164
+ if (response.status >= 500) {
1165
+ if (!idempotent || attempt >= MAX_ATTEMPTS) {
1166
+ throw await this.toUpstreamError(response, "UPSTREAM_ERROR");
1167
+ }
1168
+ await this.sleepImpl(computeFullJitterDelayMs(attempt));
1169
+ continue;
1170
+ }
1171
+ if (response.status === 401)
1172
+ throw await this.toUpstreamError(response, "AUTH_EXPIRED");
1173
+ if (response.status === 403)
1174
+ throw await this.toUpstreamError(response, "FORBIDDEN_SCOPE");
1175
+ if (response.status === 404)
1176
+ throw await this.toUpstreamError(response, "NOT_FOUND");
1177
+ throw await this.toUpstreamError(response, "UPSTREAM_ERROR");
1178
+ }
1179
+ throw new YotoError(`Request to Yoto failed after ${MAX_ATTEMPTS} attempts`, {
1180
+ code: "UPSTREAM_ERROR"
1181
+ });
1182
+ }
1183
+ /**
1184
+ * Generic cursor-pagination helper. None of the 14 tools' endpoints
1185
+ * currently paginate (Yoto returns full collections for content/devices/
1186
+ * icons), but this keeps the shape ready for the day one does, and is
1187
+ * exercised directly in tests.
1188
+ */
1189
+ async *paginate(fetchPage) {
1190
+ let cursor;
1191
+ do {
1192
+ const page = await fetchPage(cursor);
1193
+ for (const item of page.items)
1194
+ yield item;
1195
+ cursor = page.nextCursor;
1196
+ } while (cursor);
1197
+ }
1198
+ };
1199
+
1200
+ // ../../packages/core/dist/index.js
1201
+ function registerAll(server, deps) {
1202
+ const iconBaseUrl = deps.iconBaseUrl ?? DEFAULT_ICON_BASE_URL;
1203
+ for (const spec of createTools(deps)) {
1204
+ defineTool(server, spec, { logger: deps.logger, iconBaseUrl });
1205
+ }
1206
+ registerResources(server, deps);
1207
+ registerPrompts(server);
1208
+ }
1209
+ function createServer(deps, options) {
1210
+ const iconBaseUrl = deps.iconBaseUrl ?? options.iconBaseUrl ?? DEFAULT_ICON_BASE_URL;
1211
+ const server = new McpServer({
1212
+ name: options.name,
1213
+ title: options.title ?? "Yoto",
1214
+ version: options.version,
1215
+ icons: [{ src: `${iconBaseUrl}/server.png`, mimeType: "image/png", sizes: ["64x64"] }],
1216
+ websiteUrl: options.websiteUrl ?? "https://github.com/danpillay87/mcp-yoto"
1217
+ }, { capabilities: { tools: {}, resources: {}, prompts: {} } });
1218
+ registerAll(server, { ...deps, iconBaseUrl });
1219
+ return server;
1220
+ }
1221
+ var LoopbackError = class extends Error {
1222
+ code;
1223
+ hint;
1224
+ constructor(message, code, hint) {
1225
+ super(message);
1226
+ this.name = "LoopbackError";
1227
+ this.code = code;
1228
+ this.hint = hint;
1229
+ }
1230
+ };
1231
+ var SUCCESS_HTML = `<!doctype html><html><head><meta charset="utf-8"><title>Signed in to Yoto</title></head><body style="font-family:system-ui,sans-serif;padding:3rem;max-width:32rem;margin:0 auto"><h1>You're signed in \u2014 you can close this tab.</h1><p>Head back to your AI client -- mcp-yoto is ready.</p></body></html>`;
1232
+ function failureHtml(message) {
1233
+ return `<!doctype html><html><head><meta charset="utf-8"><title>Sign-in failed</title></head><body style="font-family:system-ui,sans-serif;padding:3rem;max-width:32rem;margin:0 auto"><h1>Sign-in failed</h1><p>${escapeHtml(message)}</p></body></html>`;
1234
+ }
1235
+ var HTML_ESCAPES = {
1236
+ "&": "&amp;",
1237
+ "<": "&lt;",
1238
+ ">": "&gt;",
1239
+ '"': "&quot;",
1240
+ "'": "&#39;"
1241
+ };
1242
+ function escapeHtml(value) {
1243
+ return value.replace(/[&<>"']/g, (ch) => HTML_ESCAPES[ch] ?? ch);
1244
+ }
1245
+ function sendHtml(res, status, body) {
1246
+ res.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
1247
+ res.end(body);
1248
+ }
1249
+ function awaitLoopbackCallback(options) {
1250
+ const timeoutMs = options.timeoutMs ?? 3e5;
1251
+ const setTimeoutImpl = options.setTimeoutImpl ?? setTimeout;
1252
+ const clearTimeoutImpl = options.clearTimeoutImpl ?? clearTimeout;
1253
+ return new Promise((resolve, reject) => {
1254
+ let settled = false;
1255
+ let timer;
1256
+ const server = createServer$1(handleRequest);
1257
+ function finish(action) {
1258
+ if (settled) return;
1259
+ settled = true;
1260
+ if (timer !== void 0) clearTimeoutImpl(timer);
1261
+ server.close();
1262
+ action();
1263
+ }
1264
+ function handleRequest(req, res) {
1265
+ const url = new URL(req.url ?? "/", `http://127.0.0.1:${options.port}`);
1266
+ if (url.pathname !== "/callback") {
1267
+ res.writeHead(404).end();
1268
+ return;
1269
+ }
1270
+ const upstreamError = url.searchParams.get("error");
1271
+ if (upstreamError) {
1272
+ sendHtml(res, 200, failureHtml(`Yoto returned an error: ${upstreamError}`));
1273
+ finish(
1274
+ () => reject(
1275
+ new LoopbackError(
1276
+ `Yoto returned an error during sign-in: ${upstreamError}`,
1277
+ "CALLBACK_ERROR"
1278
+ )
1279
+ )
1280
+ );
1281
+ return;
1282
+ }
1283
+ const code = url.searchParams.get("code");
1284
+ const state = url.searchParams.get("state");
1285
+ if (!code || state !== options.expectedState) {
1286
+ sendHtml(res, 400, failureHtml("This sign-in link is stale or was tampered with."));
1287
+ finish(
1288
+ () => reject(
1289
+ new LoopbackError(
1290
+ "Sign-in callback state did not match -- stale or invalid attempt.",
1291
+ "CALLBACK_ERROR",
1292
+ "Run yoto_sign_in again."
1293
+ )
1294
+ )
1295
+ );
1296
+ return;
1297
+ }
1298
+ sendHtml(res, 200, SUCCESS_HTML);
1299
+ finish(() => resolve({ code }));
1300
+ }
1301
+ server.once("error", (err) => {
1302
+ if (err.code === "EADDRINUSE") {
1303
+ finish(
1304
+ () => reject(
1305
+ new LoopbackError(
1306
+ `Port ${options.port} is already in use.`,
1307
+ "PORT_IN_USE",
1308
+ "Set YOTO_REDIRECT_PORT to a free port that's also registered at Yoto, or close whatever's using it."
1309
+ )
1310
+ )
1311
+ );
1312
+ return;
1313
+ }
1314
+ finish(() => reject(new LoopbackError(err.message, "CALLBACK_ERROR")));
1315
+ });
1316
+ server.listen(options.port, "127.0.0.1", () => {
1317
+ timer = setTimeoutImpl(() => {
1318
+ finish(
1319
+ () => reject(
1320
+ new LoopbackError(
1321
+ "Timed out waiting for the Yoto sign-in callback.",
1322
+ "AUTH_TIMEOUT",
1323
+ "Run yoto_sign_in again."
1324
+ )
1325
+ )
1326
+ );
1327
+ }, timeoutMs);
1328
+ });
1329
+ });
1330
+ }
1331
+ function openInBrowser(url) {
1332
+ try {
1333
+ let child;
1334
+ if (process.platform === "win32") {
1335
+ child = spawn("rundll32.exe", ["url.dll,FileProtocolHandler", url], {
1336
+ shell: false,
1337
+ detached: true,
1338
+ stdio: "ignore"
1339
+ });
1340
+ } else if (process.platform === "darwin") {
1341
+ child = spawn("open", [url], { shell: false, detached: true, stdio: "ignore" });
1342
+ } else {
1343
+ child = spawn("xdg-open", [url], { shell: false, detached: true, stdio: "ignore" });
1344
+ }
1345
+ child.on("error", () => {
1346
+ });
1347
+ child.unref();
1348
+ } catch {
1349
+ }
1350
+ }
1351
+ function codeChallengeFromVerifier(verifier) {
1352
+ return createHash("sha256").update(verifier).digest("base64url");
1353
+ }
1354
+ function createPkcePair() {
1355
+ const verifier = randomBytes(32).toString("base64url");
1356
+ return { verifier, challenge: codeChallengeFromVerifier(verifier) };
1357
+ }
1358
+ function createState() {
1359
+ return randomBytes(16).toString("base64url");
1360
+ }
1361
+
1362
+ // src/auth/token-exchange.ts
1363
+ async function postTokenRequest(fetchImpl, tokenUrl, params) {
1364
+ const response = await fetchImpl(tokenUrl, {
1365
+ method: "POST",
1366
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1367
+ body: new URLSearchParams(params)
1368
+ });
1369
+ if (response.ok) {
1370
+ return { ok: true, data: await response.json() };
1371
+ }
1372
+ let error;
1373
+ try {
1374
+ error = await response.json();
1375
+ } catch {
1376
+ }
1377
+ return { ok: false, status: response.status, error };
1378
+ }
1379
+
1380
+ // src/auth/session.ts
1381
+ var REFRESH_SKEW_MS = 10 * 60 * 1e3;
1382
+ function decodeJwtExpMs(token) {
1383
+ const parts = token.split(".");
1384
+ if (parts.length !== 3) return void 0;
1385
+ try {
1386
+ const payloadJson = Buffer.from(parts[1] ?? "", "base64url").toString("utf-8");
1387
+ const payload = JSON.parse(payloadJson);
1388
+ return typeof payload.exp === "number" ? payload.exp * 1e3 : void 0;
1389
+ } catch {
1390
+ return void 0;
1391
+ }
1392
+ }
1393
+ var Session = class {
1394
+ config;
1395
+ tokenStore;
1396
+ fetchImpl;
1397
+ now;
1398
+ inFlightRefresh;
1399
+ constructor(options) {
1400
+ this.config = options.config;
1401
+ this.tokenStore = options.tokenStore;
1402
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
1403
+ this.now = options.now ?? (() => Date.now());
1404
+ }
1405
+ /**
1406
+ * Returns a live access token, refreshing first if the current one is
1407
+ * within 10 minutes of expiry (or the store carries no readable expiry
1408
+ * at all, which fails safe by treating it as due for refresh).
1409
+ */
1410
+ async getAccessToken() {
1411
+ const stored = await this.tokenStore.load();
1412
+ if (!stored) {
1413
+ throw new YotoError("Not signed in to Yoto.", {
1414
+ code: "NOT_AUTHENTICATED",
1415
+ hint: "Run yoto_sign_in."
1416
+ });
1417
+ }
1418
+ const expiresAt = decodeJwtExpMs(stored.accessToken) ?? stored.expiresAt;
1419
+ const now = this.now();
1420
+ if (expiresAt !== void 0 && expiresAt - REFRESH_SKEW_MS > now) {
1421
+ return stored.accessToken;
1422
+ }
1423
+ if (!stored.refreshToken) {
1424
+ if (expiresAt !== void 0 && expiresAt > now) return stored.accessToken;
1425
+ throw new YotoError("Your Yoto session has expired.", {
1426
+ code: "AUTH_EXPIRED",
1427
+ hint: "Run yoto_sign_in."
1428
+ });
1429
+ }
1430
+ return this.refresh(stored.refreshToken);
1431
+ }
1432
+ /** Single-flight: concurrent callers share the one in-flight refresh instead of each starting their own. */
1433
+ refresh(refreshToken) {
1434
+ if (!this.inFlightRefresh) {
1435
+ this.inFlightRefresh = this.doRefresh(refreshToken).finally(() => {
1436
+ this.inFlightRefresh = void 0;
1437
+ });
1438
+ }
1439
+ return this.inFlightRefresh;
1440
+ }
1441
+ async doRefresh(refreshToken) {
1442
+ const result = await postTokenRequest(this.fetchImpl, `${this.config.authBase}/oauth/token`, {
1443
+ grant_type: "refresh_token",
1444
+ refresh_token: refreshToken,
1445
+ client_id: this.config.clientId
1446
+ });
1447
+ if (!result.ok) {
1448
+ if (result.error?.error === "invalid_grant") {
1449
+ await this.tokenStore.clear();
1450
+ throw new YotoError("Yoto rejected the stored refresh token.", {
1451
+ code: "AUTH_EXPIRED",
1452
+ hint: "Run yoto_sign_in."
1453
+ });
1454
+ }
1455
+ throw new YotoError(`Yoto token refresh failed (HTTP ${result.status}).`, {
1456
+ code: "UPSTREAM_ERROR",
1457
+ retryable: true,
1458
+ status: result.status
1459
+ });
1460
+ }
1461
+ const data = result.data;
1462
+ const newTokens = {
1463
+ accessToken: data.access_token,
1464
+ refreshToken: data.refresh_token ?? refreshToken,
1465
+ expiresAt: data.expires_in !== void 0 ? this.now() + data.expires_in * 1e3 : void 0,
1466
+ scope: data.scope
1467
+ };
1468
+ await this.tokenStore.save(newTokens);
1469
+ return newTokens.accessToken;
1470
+ }
1471
+ };
1472
+ var SERVICE = "mcp-yoto";
1473
+ var KeyringStore = class {
1474
+ constructor(account) {
1475
+ this.account = account;
1476
+ }
1477
+ account;
1478
+ kind = "keychain";
1479
+ async entry() {
1480
+ const { AsyncEntry } = await import('@napi-rs/keyring');
1481
+ return new AsyncEntry(SERVICE, this.account);
1482
+ }
1483
+ async load() {
1484
+ const entry = await this.entry();
1485
+ const raw = await entry.getPassword();
1486
+ if (!raw) return void 0;
1487
+ try {
1488
+ return JSON.parse(raw);
1489
+ } catch {
1490
+ return void 0;
1491
+ }
1492
+ }
1493
+ async save(tokens) {
1494
+ const entry = await this.entry();
1495
+ await entry.setPassword(JSON.stringify(tokens));
1496
+ }
1497
+ async clear() {
1498
+ const entry = await this.entry();
1499
+ await entry.deletePassword();
1500
+ }
1501
+ };
1502
+ function defaultTokenFilePath() {
1503
+ if (process.platform === "win32") {
1504
+ const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
1505
+ return join(appData, "mcp-yoto", "tokens.json");
1506
+ }
1507
+ return join(homedir(), ".config", "mcp-yoto", "tokens.json");
1508
+ }
1509
+ var FileStore = class {
1510
+ kind = "file";
1511
+ filePath;
1512
+ constructor(filePath = defaultTokenFilePath()) {
1513
+ this.filePath = filePath;
1514
+ }
1515
+ async load() {
1516
+ const primary = await this.readFrom(this.filePath);
1517
+ if (primary) return primary;
1518
+ return this.readFrom(`${this.filePath}.last-good`);
1519
+ }
1520
+ async readFrom(path) {
1521
+ let raw;
1522
+ try {
1523
+ raw = await readFile(path, "utf-8");
1524
+ } catch {
1525
+ return void 0;
1526
+ }
1527
+ const stripped = raw.replace(/^/, "");
1528
+ try {
1529
+ return JSON.parse(stripped);
1530
+ } catch {
1531
+ return void 0;
1532
+ }
1533
+ }
1534
+ async save(tokens) {
1535
+ await mkdir(dirname(this.filePath), { recursive: true });
1536
+ try {
1537
+ await copyFile(this.filePath, `${this.filePath}.last-good`);
1538
+ } catch {
1539
+ }
1540
+ const tmp = `${this.filePath}.tmp`;
1541
+ await writeFile(tmp, JSON.stringify(tokens, null, 2), "utf-8");
1542
+ await rename(tmp, this.filePath);
1543
+ if (process.platform !== "win32") {
1544
+ await chmod(this.filePath, 384);
1545
+ }
1546
+ }
1547
+ async clear() {
1548
+ for (const path of [this.filePath, `${this.filePath}.last-good`, `${this.filePath}.tmp`]) {
1549
+ try {
1550
+ await rm(path);
1551
+ } catch {
1552
+ }
1553
+ }
1554
+ }
1555
+ };
1556
+ async function createTokenStore(options) {
1557
+ if (options.fileOverride) {
1558
+ return new FileStore(options.fileOverride);
1559
+ }
1560
+ try {
1561
+ const keyring = new KeyringStore(options.account);
1562
+ await keyring.load();
1563
+ return keyring;
1564
+ } catch (error) {
1565
+ const filePath = defaultTokenFilePath();
1566
+ options.logger.warn(
1567
+ `System keychain unavailable -- falling back to a local file at ${filePath} (0600 permissions on macOS/Linux; scoped to your Windows user profile).`,
1568
+ { error: error instanceof Error ? error.message : String(error) }
1569
+ );
1570
+ return new FileStore(filePath);
1571
+ }
1572
+ }
1573
+
1574
+ // src/auth/adapter.ts
1575
+ function redirectUri(port) {
1576
+ return `http://127.0.0.1:${port}/callback`;
1577
+ }
1578
+ function loopbackErrorToYotoError(error) {
1579
+ if (error instanceof LoopbackError) {
1580
+ if (error.code === "PORT_IN_USE") {
1581
+ return new YotoError(error.message, { code: "PORT_IN_USE", hint: error.hint, cause: error });
1582
+ }
1583
+ if (error.code === "AUTH_TIMEOUT") {
1584
+ return new YotoError(error.message, { code: "VALIDATION", hint: error.hint, cause: error });
1585
+ }
1586
+ return new YotoError(error.message, { code: "UPSTREAM_ERROR", hint: error.hint, cause: error });
1587
+ }
1588
+ const message = error instanceof Error ? error.message : String(error);
1589
+ return new YotoError(message, { code: "UPSTREAM_ERROR", cause: error });
1590
+ }
1591
+ async function createCliAuthAdapter(options) {
1592
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
1593
+ const now = options.now ?? (() => Date.now());
1594
+ const tokenStore = await createTokenStore({
1595
+ account: options.config.clientId,
1596
+ logger: options.logger,
1597
+ fileOverride: options.config.tokenFileOverride
1598
+ });
1599
+ const session = new Session({
1600
+ config: { clientId: options.config.clientId, authBase: options.config.authBase },
1601
+ tokenStore,
1602
+ fetchImpl,
1603
+ now
1604
+ });
1605
+ async function signIn(opts) {
1606
+ const { verifier, challenge } = createPkcePair();
1607
+ const state = createState();
1608
+ const port = options.config.redirectPort;
1609
+ const authorizeUrl = new URL(`${options.config.authBase}/authorize`);
1610
+ authorizeUrl.searchParams.set("audience", options.config.audience);
1611
+ authorizeUrl.searchParams.set("scope", YOTO_SCOPES.join(" "));
1612
+ authorizeUrl.searchParams.set("response_type", "code");
1613
+ authorizeUrl.searchParams.set("client_id", options.config.clientId);
1614
+ authorizeUrl.searchParams.set("code_challenge", challenge);
1615
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
1616
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri(port));
1617
+ authorizeUrl.searchParams.set("state", state);
1618
+ const url = authorizeUrl.toString();
1619
+ opts?.onAuthorizeUrl?.(url);
1620
+ const callbackPromise = awaitLoopbackCallback({ port, expectedState: state });
1621
+ const shouldOpenBrowser = (opts?.openBrowser ?? true) && !options.config.noBrowser;
1622
+ if (shouldOpenBrowser) openInBrowser(url);
1623
+ let code;
1624
+ try {
1625
+ ({ code } = await callbackPromise);
1626
+ } catch (error) {
1627
+ throw loopbackErrorToYotoError(error);
1628
+ }
1629
+ const result = await postTokenRequest(fetchImpl, `${options.config.authBase}/oauth/token`, {
1630
+ grant_type: "authorization_code",
1631
+ client_id: options.config.clientId,
1632
+ code,
1633
+ code_verifier: verifier,
1634
+ redirect_uri: redirectUri(port)
1635
+ });
1636
+ if (!result.ok) {
1637
+ throw new YotoError(
1638
+ `Yoto rejected the sign-in code (HTTP ${result.status}${result.error?.error ? `: ${result.error.error}` : ""}).`,
1639
+ { code: "UPSTREAM_ERROR", status: result.status }
1640
+ );
1641
+ }
1642
+ await tokenStore.save({
1643
+ accessToken: result.data.access_token,
1644
+ refreshToken: result.data.refresh_token,
1645
+ expiresAt: result.data.expires_in !== void 0 ? now() + result.data.expires_in * 1e3 : void 0,
1646
+ scope: result.data.scope ?? YOTO_SCOPES.join(" ")
1647
+ });
1648
+ return { url, message: "Signed in to Yoto." };
1649
+ }
1650
+ async function signOut() {
1651
+ await tokenStore.clear();
1652
+ }
1653
+ return {
1654
+ mode: "cli",
1655
+ tokenStore,
1656
+ async getAccessToken() {
1657
+ return session.getAccessToken();
1658
+ },
1659
+ async status() {
1660
+ const stored = await tokenStore.load();
1661
+ if (!stored) {
1662
+ return {
1663
+ signedIn: false,
1664
+ mode: "cli",
1665
+ tokenStore: tokenStore.kind,
1666
+ hint: "Run yoto_sign_in to connect your Yoto account."
1667
+ };
1668
+ }
1669
+ const expiresAt = decodeJwtExpMs(stored.accessToken) ?? stored.expiresAt;
1670
+ return {
1671
+ signedIn: true,
1672
+ mode: "cli",
1673
+ tokenStore: tokenStore.kind,
1674
+ expiresAt,
1675
+ scopes: stored.scope ? stored.scope.split(" ") : void 0
1676
+ };
1677
+ },
1678
+ signIn,
1679
+ signOut
1680
+ };
1681
+ }
1682
+
1683
+ // src/config.ts
1684
+ var DEFAULT_CLIENT_ID = "xl4YsMpHEMnn7ubVhsRu8NhBwfUFPf8J";
1685
+ var YOTO_AUTH_BASE = "https://login.yotoplay.com";
1686
+ var AUDIENCE = "https://api.yotoplay.com";
1687
+ var DEFAULT_REDIRECT_PORT = 8791;
1688
+ var DEFAULT_ICON_BASE_URL2 = "https://raw.githubusercontent.com/danpillay87/mcp-yoto/main/apps/worker/public/icons";
1689
+ function parsePort(raw, fallback) {
1690
+ if (!raw) return fallback;
1691
+ const parsed = Number(raw);
1692
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
1693
+ throw new Error(
1694
+ `YOTO_REDIRECT_PORT must be an integer between 1 and 65535, got ${JSON.stringify(raw)}.`
1695
+ );
1696
+ }
1697
+ return parsed;
1698
+ }
1699
+ function parseLogLevel(raw) {
1700
+ return raw === "debug" || raw === "warn" || raw === "error" ? raw : "info";
1701
+ }
1702
+ function parseBoolFlag(raw) {
1703
+ return raw === "1" || raw?.toLowerCase() === "true";
1704
+ }
1705
+ function loadConfig(env = process.env) {
1706
+ return {
1707
+ clientId: env.YOTO_CLIENT_ID || DEFAULT_CLIENT_ID,
1708
+ authBase: YOTO_AUTH_BASE,
1709
+ audience: AUDIENCE,
1710
+ redirectPort: parsePort(env.YOTO_REDIRECT_PORT, DEFAULT_REDIRECT_PORT),
1711
+ noBrowser: parseBoolFlag(env.YOTO_NO_BROWSER),
1712
+ logLevel: parseLogLevel(env.LOG_LEVEL),
1713
+ iconBaseUrl: env.MCP_YOTO_ICON_BASE || DEFAULT_ICON_BASE_URL2,
1714
+ scope: YOTO_SCOPES.join(" "),
1715
+ tokenFileOverride: env.MCP_YOTO_TOKEN_FILE || void 0
1716
+ };
1717
+ }
1718
+ var MAX_AUDIO_BYTES = 500 * 1024 * 1024;
1719
+ var MAX_IMAGE_BYTES = 5 * 1024 * 1024;
1720
+ var SNIFF_BYTES = 4096;
1721
+ function expandHome(rawPath) {
1722
+ if (rawPath === "~") return homedir();
1723
+ if (rawPath.startsWith("~/") || rawPath.startsWith("~\\")) {
1724
+ return resolve(homedir(), rawPath.slice(2));
1725
+ }
1726
+ return rawPath;
1727
+ }
1728
+ function toAbsolutePath(rawPath) {
1729
+ const expanded = expandHome(rawPath);
1730
+ if (expanded.startsWith("\\\\") || isAbsolute(expanded)) return expanded;
1731
+ return resolve(process.cwd(), expanded);
1732
+ }
1733
+ async function readHead(path, length) {
1734
+ const handle = await open(path, "r");
1735
+ try {
1736
+ const buffer = Buffer.alloc(length);
1737
+ const { bytesRead } = await handle.read(buffer, 0, length, 0);
1738
+ return buffer.subarray(0, bytesRead);
1739
+ } finally {
1740
+ await handle.close();
1741
+ }
1742
+ }
1743
+ async function statOrValidationError(path, label) {
1744
+ let stats;
1745
+ try {
1746
+ stats = await stat(path);
1747
+ } catch (error) {
1748
+ throw new YotoError(`Could not find the ${label} at "${path}".`, {
1749
+ code: "VALIDATION",
1750
+ cause: error,
1751
+ hint: "Check the path and try again."
1752
+ });
1753
+ }
1754
+ if (!stats.isFile()) {
1755
+ throw new YotoError(`"${path}" is not a file.`, { code: "VALIDATION" });
1756
+ }
1757
+ return { size: stats.size };
1758
+ }
1759
+ function audioContentType(format) {
1760
+ switch (format) {
1761
+ case "mp3":
1762
+ return "audio/mpeg";
1763
+ case "m4a":
1764
+ return "audio/mp4";
1765
+ case "wav":
1766
+ return "audio/wav";
1767
+ case "ogg":
1768
+ return "audio/ogg";
1769
+ default:
1770
+ return "application/octet-stream";
1771
+ }
1772
+ }
1773
+ function imageContentType(format) {
1774
+ switch (format) {
1775
+ case "png":
1776
+ return "image/png";
1777
+ case "jpeg":
1778
+ return "image/jpeg";
1779
+ case "svg":
1780
+ return "image/svg+xml";
1781
+ default:
1782
+ return "application/octet-stream";
1783
+ }
1784
+ }
1785
+ async function resolveAudio(input) {
1786
+ if ("audioUrl" in input) {
1787
+ throw new YotoError("This CLI reads local files -- pass audioFilePath, not audioUrl.", {
1788
+ code: "VALIDATION",
1789
+ hint: "audioUrl is for the remote (paste-a-link) connector only."
1790
+ });
1791
+ }
1792
+ const path = toAbsolutePath(input.audioFilePath);
1793
+ const { size } = await statOrValidationError(path, "audio file");
1794
+ if (size > MAX_AUDIO_BYTES) {
1795
+ throw new YotoError(
1796
+ `"${path}" is ${(size / (1024 * 1024)).toFixed(1)} MB, over the 500 MB limit.`,
1797
+ {
1798
+ code: "VALIDATION"
1799
+ }
1800
+ );
1801
+ }
1802
+ const head = await readHead(path, SNIFF_BYTES);
1803
+ const sniffed = sniffAudio(head, basename(path));
1804
+ const stream = Readable.toWeb(createReadStream(path));
1805
+ return {
1806
+ stream,
1807
+ size,
1808
+ contentType: audioContentType(sniffed.format),
1809
+ filename: basename(path)
1810
+ };
1811
+ }
1812
+ async function resolveImage(input) {
1813
+ if ("imageUrl" in input) {
1814
+ throw new YotoError("This CLI reads local files -- pass imagePath, not imageUrl.", {
1815
+ code: "VALIDATION",
1816
+ hint: "imageUrl is for the remote (paste-a-link) connector only."
1817
+ });
1818
+ }
1819
+ const path = toAbsolutePath(input.imagePath);
1820
+ const { size } = await statOrValidationError(path, "image file");
1821
+ if (size > MAX_IMAGE_BYTES) {
1822
+ throw new YotoError(`"${path}" is over the 5 MB icon limit.`, { code: "VALIDATION" });
1823
+ }
1824
+ const buffer = await readFile(path);
1825
+ const bytes = new Uint8Array(
1826
+ buffer.buffer,
1827
+ buffer.byteOffset,
1828
+ buffer.byteLength
1829
+ );
1830
+ const sniffed = sniffImage(bytes, basename(path));
1831
+ return { bytes, contentType: imageContentType(sniffed.format), filename: basename(path) };
1832
+ }
1833
+
1834
+ // src/server.ts
1835
+ var require2 = createRequire(import.meta.url);
1836
+ var { version } = require2("../package.json");
1837
+ function createCliServer(options) {
1838
+ return createServer(
1839
+ {
1840
+ auth: options.auth,
1841
+ client: options.client,
1842
+ logger: options.logger,
1843
+ iconBaseUrl: options.iconBaseUrl,
1844
+ resolveAudio,
1845
+ resolveImage,
1846
+ mode: "cli"
1847
+ },
1848
+ {
1849
+ name: "mcp-yoto",
1850
+ version,
1851
+ title: "Yoto (Works with Yoto)",
1852
+ iconBaseUrl: options.iconBaseUrl,
1853
+ websiteUrl: "https://github.com/danpillay87/mcp-yoto"
1854
+ }
1855
+ );
1856
+ }
1857
+ async function runStdioServer(options) {
1858
+ const server = createCliServer(options);
1859
+ const transport = new StdioServerTransport();
1860
+ await server.connect(transport);
1861
+ }
1862
+
1863
+ // src/main.ts
1864
+ var require3 = createRequire(import.meta.url);
1865
+ var { version: version2 } = require3("../package.json");
1866
+ var HELP_TEXT = `mcp-yoto ${version2} -- MCP server for Yoto (cards, tracks, icons, devices)
1867
+
1868
+ Usage:
1869
+ npx mcp-yoto Run the MCP server over stdio (for MCP clients)
1870
+ npx mcp-yoto login Sign in to Yoto (opens your browser)
1871
+ npx mcp-yoto logout Remove the locally stored Yoto credential
1872
+ npx mcp-yoto status Print sign-in status as JSON (to stdout)
1873
+ npx mcp-yoto --version Print the installed version
1874
+ npx mcp-yoto --help Show this help
1875
+
1876
+ Environment variables:
1877
+ YOTO_CLIENT_ID Override the Yoto dev-app client id
1878
+ YOTO_REDIRECT_PORT Loopback port used during sign-in (default 8791;
1879
+ must match the redirect URI registered at Yoto)
1880
+ YOTO_NO_BROWSER Set to 1 to print the sign-in URL instead of
1881
+ opening a browser automatically
1882
+ LOG_LEVEL debug | info | warn | error (default info)
1883
+ MCP_YOTO_ICON_BASE Base URL tool icons are served from
1884
+
1885
+ Your Yoto credential is stored in your OS keychain when available, or in a
1886
+ local file otherwise (run "status" to see which). Nothing is ever printed
1887
+ to stdout except JSON-RPC frames (default mode) or the "status" JSON.
1888
+ `;
1889
+ function stderrLogSink(line) {
1890
+ const suffix = line.data ? ` ${JSON.stringify(line.data)}` : "";
1891
+ process.stderr.write(`[mcp-yoto] ${line.level}: ${line.message}${suffix}
1892
+ `);
1893
+ }
1894
+ async function main(argv = process.argv.slice(2)) {
1895
+ const [command] = argv;
1896
+ if (command === "--version" || command === "-v") {
1897
+ console.error(version2);
1898
+ return;
1899
+ }
1900
+ if (command === "--help" || command === "-h") {
1901
+ console.error(HELP_TEXT);
1902
+ return;
1903
+ }
1904
+ const config = loadConfig();
1905
+ const logLevel = config.logLevel;
1906
+ const logger = createLogger({ level: logLevel, sink: stderrLogSink });
1907
+ const auth = await createCliAuthAdapter({ config, logger });
1908
+ const client = new YotoClient({
1909
+ getToken: () => auth.getAccessToken(),
1910
+ baseUrl: config.audience,
1911
+ logger
1912
+ });
1913
+ if (command === "login") {
1914
+ if (!auth.signIn) throw new Error("Sign-in isn't available on this connection.");
1915
+ const result = await auth.signIn({
1916
+ openBrowser: !config.noBrowser,
1917
+ // Fires synchronously as soon as the authorize URL is built, well
1918
+ // before the loopback callback ever arrives -- see adapter.ts. With
1919
+ // YOTO_NO_BROWSER=1 (or no default handler registered) this is the
1920
+ // only place the user ever sees the link.
1921
+ onAuthorizeUrl: (url) => {
1922
+ process.stderr.write(
1923
+ "Sign in to Yoto in your browser. If it did not open, use this link:\n"
1924
+ );
1925
+ process.stderr.write(`${url}
1926
+ `);
1927
+ }
1928
+ });
1929
+ if (result.url) process.stderr.write(`Sign-in URL: ${result.url}
1930
+ `);
1931
+ process.stderr.write(`${result.message}
1932
+ `);
1933
+ return;
1934
+ }
1935
+ if (command === "logout") {
1936
+ if (!auth.signOut) throw new Error("Sign-out isn't available on this connection.");
1937
+ await auth.signOut();
1938
+ process.stderr.write("Signed out of Yoto.\n");
1939
+ return;
1940
+ }
1941
+ if (command === "status") {
1942
+ const status = await auth.status();
1943
+ console.log(JSON.stringify(status, null, 2));
1944
+ return;
1945
+ }
1946
+ if (command) {
1947
+ process.stderr.write(`Unknown command: ${command}
1948
+
1949
+ ${HELP_TEXT}`);
1950
+ process.exitCode = 1;
1951
+ return;
1952
+ }
1953
+ await runStdioServer({ auth, client, logger, iconBaseUrl: config.iconBaseUrl });
1954
+ }
1955
+
1956
+ export { DEFAULT_CLIENT_ID, main };
1957
+ //# sourceMappingURL=main.js.map
1958
+ //# sourceMappingURL=main.js.map