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/LICENSE +23 -0
- package/README.md +86 -0
- package/bin/mcp-yoto.mjs +4 -0
- package/dist/auth/adapter.d.ts +33 -0
- package/dist/auth/adapter.js +131 -0
- package/dist/auth/loopback.d.ts +28 -0
- package/dist/auth/loopback.js +103 -0
- package/dist/auth/open-browser.d.ts +1 -0
- package/dist/auth/open-browser.js +43 -0
- package/dist/auth/pkce.d.ts +10 -0
- package/dist/auth/pkce.js +19 -0
- package/dist/auth/session.d.ts +37 -0
- package/dist/auth/session.js +120 -0
- package/dist/auth/token-exchange.d.ts +27 -0
- package/dist/auth/token-exchange.js +26 -0
- package/dist/auth/token-store.d.ts +69 -0
- package/dist/auth/token-store.js +151 -0
- package/dist/config.d.ts +35 -0
- package/dist/config.js +51 -0
- package/dist/main.d.ts +12 -0
- package/dist/main.js +1958 -0
- package/dist/main.js.map +1 -0
- package/dist/resolve.d.ts +18 -0
- package/dist/resolve.js +141 -0
- package/dist/server.d.ts +21 -0
- package/dist/server.js +40 -0
- package/package.json +61 -0
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
|
+
"&": "&",
|
|
1237
|
+
"<": "<",
|
|
1238
|
+
">": ">",
|
|
1239
|
+
'"': """,
|
|
1240
|
+
"'": "'"
|
|
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
|