@epic-web/workshop-mcp 6.54.0 → 6.54.2

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/tools.js CHANGED
@@ -12,15 +12,93 @@ import * as client from 'openid-client';
12
12
  import { z } from 'zod';
13
13
  import { quizMe, quizMeInputSchema } from "./prompts.js";
14
14
  import { diffBetweenAppsResource, exerciseContextResource, exerciseStepContextResource, exerciseStepProgressDiffResource, userAccessResource, userInfoResource, userProgressResource, workshopContextResource, } from "./resources.js";
15
+ import { formatToolDescription, toolDocs, } from "./server-metadata.js";
15
16
  import { handleWorkshopDirectory, readInWorkshop, safeReadFile, workshopDirectoryInputSchema, } from "./utils.js";
16
17
  // not enough support for this yet
17
18
  const clientSupportsEmbeddedResources = false;
18
- export function initTools(server) {
19
- server.registerTool('login', {
20
- description: `Allow the user to login (or sign up) to the epic workshop.`.trim(),
21
- inputSchema: {
22
- workshopDirectory: workshopDirectoryInputSchema,
19
+ function formatToolResponseText({ title, summary, details, nextSteps, }) {
20
+ const lines = [`## ${title}`, '', summary];
21
+ if (details?.length) {
22
+ lines.push('', ...details);
23
+ }
24
+ if (nextSteps?.length) {
25
+ lines.push('', 'Next steps:');
26
+ for (const step of nextSteps) {
27
+ lines.push(`- ${step}`);
28
+ }
29
+ }
30
+ return lines.join('\n').trim();
31
+ }
32
+ function createToolResponse({ toolName, summary, details, nextSteps, includeMetaNextSteps = true, content = [], structuredContent, statusEmoji = '✅', }) {
33
+ const meta = toolDocs[toolName];
34
+ const steps = nextSteps ?? (includeMetaNextSteps ? meta.nextSteps : []);
35
+ const summaryLine = statusEmoji === null ? summary : `${statusEmoji} ${summary}`;
36
+ const text = formatToolResponseText({
37
+ title: meta.title,
38
+ summary: summaryLine,
39
+ details,
40
+ nextSteps: steps,
41
+ });
42
+ return {
43
+ content: [{ type: 'text', text }, ...content],
44
+ structuredContent,
45
+ };
46
+ }
47
+ function createToolErrorResult(toolName, error) {
48
+ const meta = toolDocs[toolName];
49
+ const message = error instanceof Error ? error.message : String(error);
50
+ const nextSteps = meta.errorNextSteps ?? meta.nextSteps;
51
+ const response = createToolResponse({
52
+ toolName,
53
+ summary: `Error: ${message}`,
54
+ statusEmoji: '⚠️',
55
+ nextSteps,
56
+ includeMetaNextSteps: false,
57
+ structuredContent: {
58
+ tool: toolName,
59
+ error: message,
60
+ nextSteps,
23
61
  },
62
+ });
63
+ return { ...response, isError: true };
64
+ }
65
+ function parseResourceText(resource) {
66
+ if (typeof resource.text === 'string') {
67
+ try {
68
+ return JSON.parse(resource.text);
69
+ }
70
+ catch {
71
+ return resource.text;
72
+ }
73
+ }
74
+ return null;
75
+ }
76
+ function createResourceStructuredContent(resource) {
77
+ return {
78
+ uri: resource.uri,
79
+ mimeType: resource.mimeType,
80
+ data: parseResourceText(resource),
81
+ };
82
+ }
83
+ function registerTool(server, toolName, inputSchema, handler) {
84
+ const meta = toolDocs[toolName];
85
+ return server.registerTool(toolName, {
86
+ title: meta.title,
87
+ description: formatToolDescription(meta),
88
+ inputSchema,
89
+ annotations: meta.annotations,
90
+ }, async (args) => {
91
+ try {
92
+ return await handler(args);
93
+ }
94
+ catch (error) {
95
+ return createToolErrorResult(toolName, error);
96
+ }
97
+ });
98
+ }
99
+ export function initTools(server) {
100
+ registerTool(server, 'login', {
101
+ workshopDirectory: workshopDirectoryInputSchema,
24
102
  }, async ({ workshopDirectory }) => {
25
103
  await handleWorkshopDirectory(workshopDirectory);
26
104
  const { product: { host }, } = getWorkshopConfig();
@@ -28,14 +106,22 @@ export function initTools(server) {
28
106
  const config = await client.discovery(new URL(ISSUER), 'EPICSHOP_APP');
29
107
  const deviceResponse = await client.initiateDeviceAuthorization(config, {});
30
108
  void handleAuthFlow().catch(() => { });
31
- return {
32
- content: [
33
- {
34
- type: 'text',
35
- text: `Please go to ${deviceResponse.verification_uri_complete}. Verify the code on the page is "${deviceResponse.user_code}" to login.`,
36
- },
109
+ const verificationUrl = deviceResponse.verification_uri_complete;
110
+ const userCode = deviceResponse.user_code;
111
+ return createToolResponse({
112
+ toolName: 'login',
113
+ summary: 'Login started. Ask the user to complete device verification.',
114
+ details: [
115
+ `Verification URL: ${verificationUrl}`,
116
+ `User code: ${userCode}`,
117
+ `Expires in: ${deviceResponse.expires_in} seconds`,
37
118
  ],
38
- };
119
+ structuredContent: {
120
+ verificationUrl,
121
+ userCode,
122
+ expiresInSeconds: deviceResponse.expires_in,
123
+ },
124
+ });
39
125
  async function handleAuthFlow() {
40
126
  const UserInfoSchema = z.object({
41
127
  id: z.string(),
@@ -97,65 +183,32 @@ export function initTools(server) {
97
183
  }
98
184
  }
99
185
  });
100
- server.registerTool('logout', {
101
- description: `Allow the user to logout of the workshop (based on the workshop's host) and delete cache data.`,
102
- inputSchema: {
103
- workshopDirectory: workshopDirectoryInputSchema,
104
- },
186
+ registerTool(server, 'logout', {
187
+ workshopDirectory: workshopDirectoryInputSchema,
105
188
  }, async ({ workshopDirectory }) => {
106
189
  await handleWorkshopDirectory(workshopDirectory);
107
190
  await logout();
108
191
  await deleteCache();
109
- return {
110
- content: [{ type: 'text', text: 'Logged out' }],
111
- };
192
+ return createToolResponse({
193
+ toolName: 'logout',
194
+ summary: 'Logged out and cleared cached credentials.',
195
+ structuredContent: { loggedOut: true },
196
+ });
112
197
  });
113
- server.registerTool('set_playground', {
114
- description: `
115
- Sets the playground environment so the user can continue to that exercise or see
116
- what that step looks like in their playground environment.
117
-
118
- NOTE: this will override their current exercise step work in the playground!
119
-
120
- Generally, it is better to not provide an exerciseNumber, stepNumber, and type
121
- and let the user continue to the next exercise. Only provide these arguments if
122
- the user explicitely asks to go to a specific exercise or step. If the user asks
123
- to start an exercise, specify stepNumber 1 and type 'problem' unless otherwise
124
- directed.
125
-
126
- Argument examples:
127
- A. If logged in and there is an incomplete exercise step, set to next incomplete exercise step based on the user's progress - Most common
128
- - [No arguments]
129
- B. If not logged in or all exercises are complete, set to next exercise step from current (or first if there is none)
130
- - [No arguments]
131
- C. Set to a specific exercise step
132
- - exerciseNumber: 1
133
- - stepNumber: 1
134
- - type: 'solution'
135
- D. Set to the solution of the current exercise step
136
- - type: 'solution'
137
- E. Set to the second step problem of the current exercise
138
- - stepNumber: 2
139
- F. Set to the first step problem of the fifth exercise
140
- - exerciseNumber: 5
141
-
142
- An error will be returned if no app is found for the given arguments.
143
- `.trim(),
144
- inputSchema: {
145
- workshopDirectory: workshopDirectoryInputSchema,
146
- exerciseNumber: z.coerce
147
- .number()
148
- .optional()
149
- .describe('The exercise number to set the playground to'),
150
- stepNumber: z.coerce
151
- .number()
152
- .optional()
153
- .describe('The step number to set the playground to'),
154
- type: z
155
- .enum(['problem', 'solution'])
156
- .optional()
157
- .describe('The type of app to set the playground to'),
158
- },
198
+ registerTool(server, 'set_playground', {
199
+ workshopDirectory: workshopDirectoryInputSchema,
200
+ exerciseNumber: z.coerce
201
+ .number()
202
+ .optional()
203
+ .describe('Exercise number to open (1-based). Omit to use the next incomplete step.'),
204
+ stepNumber: z.coerce
205
+ .number()
206
+ .optional()
207
+ .describe('Step number to open within the exercise (1-based). Omit to keep the current step or advance.'),
208
+ type: z
209
+ .enum(['problem', 'solution'])
210
+ .optional()
211
+ .describe('Step type to open ("problem" or "solution"). Omit to keep the current type or default to problem.'),
159
212
  }, async ({ workshopDirectory, exerciseNumber, stepNumber, type }) => {
160
213
  await handleWorkshopDirectory(workshopDirectory);
161
214
  const authInfo = await getAuthInfo();
@@ -190,14 +243,19 @@ An error will be returned if no app is found for the given arguments.
190
243
  });
191
244
  invariant(exerciseApp, 'No exercise app found');
192
245
  await setPlayground(exerciseApp.fullPath);
193
- return {
194
- content: [
195
- {
196
- type: 'text',
197
- text: `Playground set to ${exerciseApp.exerciseNumber}.${exerciseApp.stepNumber}.${exerciseApp.type}`,
246
+ return createToolResponse({
247
+ toolName: 'set_playground',
248
+ summary: `Playground set to ${exerciseApp.exerciseNumber}.${exerciseApp.stepNumber}.${exerciseApp.type}.`,
249
+ structuredContent: {
250
+ playground: {
251
+ exerciseNumber: exerciseApp.exerciseNumber,
252
+ stepNumber: exerciseApp.stepNumber,
253
+ type: exerciseApp.type,
254
+ appName: exerciseApp.name,
255
+ fullPath: exerciseApp.fullPath,
198
256
  },
199
- ],
200
- };
257
+ },
258
+ });
201
259
  }
202
260
  if (nextProgress.type === 'instructions' ||
203
261
  nextProgress.type === 'finished') {
@@ -236,43 +294,38 @@ An error will be returned if no app is found for the given arguments.
236
294
  }
237
295
  invariant(desiredApp, `No app found for values derived by the arguments: ${exerciseNumber}.${stepNumber}.${type}`);
238
296
  await setPlayground(desiredApp.fullPath);
239
- return {
240
- content: [
241
- {
242
- type: 'text',
243
- text: `Playground set to ${desiredApp.name}.`,
297
+ return createToolResponse({
298
+ toolName: 'set_playground',
299
+ summary: `Playground set to ${desiredApp.name}.`,
300
+ structuredContent: {
301
+ playground: {
302
+ exerciseNumber: desiredApp.exerciseNumber,
303
+ stepNumber: desiredApp.stepNumber,
304
+ type: desiredApp.type,
305
+ appName: desiredApp.name,
306
+ fullPath: desiredApp.fullPath,
244
307
  },
245
- ],
246
- };
308
+ },
309
+ });
247
310
  });
248
- server.registerTool('update_progress', {
249
- description: `
250
- Intended to help you mark an Epic lesson as complete or incomplete.
251
-
252
- This will mark the Epic lesson as complete or incomplete and update the user's progress (get updated progress with the \`get_user_progress\` tool, the \`get_exercise_context\` tool, or the \`get_workshop_context\` tool).
253
- `.trim(),
254
- inputSchema: {
255
- workshopDirectory: workshopDirectoryInputSchema,
256
- epicLessonSlug: z
257
- .string()
258
- .describe('The slug of the Epic lesson to mark as complete (can be retrieved from the `get_exercise_context` tool or the `get_workshop_context` tool)'),
259
- complete: z
260
- .boolean()
261
- .optional()
262
- .default(true)
263
- .describe('Whether to mark the lesson as complete or incomplete (defaults to true)'),
264
- },
311
+ registerTool(server, 'update_progress', {
312
+ workshopDirectory: workshopDirectoryInputSchema,
313
+ epicLessonSlug: z
314
+ .string()
315
+ .describe('Lesson slug to update (from `get_exercise_context`, `get_workshop_context`, or `get_what_is_next`).'),
316
+ complete: z
317
+ .boolean()
318
+ .optional()
319
+ .default(true)
320
+ .describe('Mark complete or incomplete (default: true).'),
265
321
  }, async ({ workshopDirectory, epicLessonSlug, complete }) => {
266
322
  await handleWorkshopDirectory(workshopDirectory);
267
323
  await updateProgress({ lessonSlug: epicLessonSlug, complete });
268
- return {
269
- content: [
270
- {
271
- type: 'text',
272
- text: `Lesson with slug ${epicLessonSlug} marked as ${complete ? 'complete' : 'incomplete'}`,
273
- },
274
- ],
275
- };
324
+ return createToolResponse({
325
+ toolName: 'update_progress',
326
+ summary: `Lesson "${epicLessonSlug}" marked as ${complete ? 'complete' : 'incomplete'}.`,
327
+ structuredContent: { epicLessonSlug, complete },
328
+ });
276
329
  });
277
330
  // TODO: add a tool to run the dev/test script for the given app
278
331
  }
@@ -280,136 +333,125 @@ This will mark the Epic lesson as complete or incomplete and update the user's p
280
333
  // accessible via tools, but allowing the LLM to access them on demand is useful
281
334
  // for some situations.
282
335
  export function initResourceTools(server) {
283
- server.registerTool('get_workshop_context', {
284
- description: `
285
- Indended to help you get wholistic context of the topics covered in this
286
- workshop. This doesn't go into as much detail per exercise as the
287
- \`get_exercise_context\` tool, but it is a good starting point to orient
288
- yourself on the workshop as a whole.
289
- `.trim(),
290
- inputSchema: workshopContextResource.inputSchema,
291
- }, async ({ workshopDirectory }) => {
336
+ registerTool(server, 'get_workshop_context', workshopContextResource.inputSchema, async ({ workshopDirectory }) => {
292
337
  workshopDirectory = await handleWorkshopDirectory(workshopDirectory);
293
338
  const resource = await workshopContextResource.getResource({
294
339
  workshopDirectory,
295
340
  });
296
- return {
341
+ const structured = createResourceStructuredContent(resource);
342
+ const data = structured.data;
343
+ const exerciseCount = Array.isArray(data?.exercises)
344
+ ? data.exercises.length
345
+ : 0;
346
+ return createToolResponse({
347
+ toolName: 'get_workshop_context',
348
+ summary: 'Workshop context retrieved.',
349
+ details: exerciseCount ? [`Exercises: ${exerciseCount}`] : undefined,
297
350
  content: [getEmbeddedResourceContent(resource)],
298
- };
351
+ structuredContent: {
352
+ workshopContext: structured,
353
+ },
354
+ });
299
355
  });
300
- server.registerTool('get_exercise_context', {
301
- description: `
302
- Intended to help a student understand what they need to do for the current
303
- exercise step.
304
-
305
- This returns the instructions MDX content for the current exercise and each
306
- exercise step. If the user is has the paid version of the workshop, it will also
307
- include the transcript from each of the videos as well.
308
-
309
- The output for this will rarely change, so it's unnecessary to call this tool
310
- more than once.
311
-
312
- \`get_exercise_context\` is often best when used with the
313
- \`get_exercise_step_progress_diff\` tool to help a student understand what
314
- work they still need to do and answer any questions about the exercise.
315
- `.trim(),
316
- inputSchema: exerciseContextResource.inputSchema,
317
- }, async ({ workshopDirectory, exerciseNumber }) => {
356
+ registerTool(server, 'get_exercise_context', exerciseContextResource.inputSchema, async ({ workshopDirectory, exerciseNumber }) => {
318
357
  workshopDirectory = await handleWorkshopDirectory(workshopDirectory);
319
358
  const resource = await exerciseContextResource.getResource({
320
359
  workshopDirectory,
321
360
  exerciseNumber,
322
361
  });
323
- return {
362
+ const structured = createResourceStructuredContent(resource);
363
+ const data = structured.data;
364
+ const stepCount = Array.isArray(data?.steps) ? data.steps.length : 0;
365
+ const number = data?.exerciseInfo?.number;
366
+ return createToolResponse({
367
+ toolName: 'get_exercise_context',
368
+ summary: number
369
+ ? `Exercise ${number} context retrieved.`
370
+ : 'Exercise context retrieved.',
371
+ details: stepCount ? [`Steps: ${stepCount}`] : undefined,
324
372
  content: [getEmbeddedResourceContent(resource)],
325
- };
373
+ structuredContent: {
374
+ exerciseContext: structured,
375
+ },
376
+ });
326
377
  });
327
- server.registerTool('get_diff_between_apps', {
328
- description: `
329
- Intended to give context about the changes between two apps.
330
-
331
- The output is a git diff of the playground directory as BASE (their work in
332
- progress) against the solution directory as HEAD (the final state they're trying
333
- to achieve).
334
-
335
- The output is formatted as a git diff.
336
-
337
- App IDs are formatted as \`{exerciseNumber}.{stepNumber}.{type}\`.
338
-
339
- If the user asks for the diff for 2.3, then use 02.03.problem for app1 and 02.03.solution for app2.
340
- `,
341
- inputSchema: diffBetweenAppsResource.inputSchema,
342
- }, async ({ workshopDirectory, app1, app2 }) => {
378
+ registerTool(server, 'get_diff_between_apps', diffBetweenAppsResource.inputSchema, async ({ workshopDirectory, app1, app2 }) => {
343
379
  workshopDirectory = await handleWorkshopDirectory(workshopDirectory);
344
380
  const resource = await diffBetweenAppsResource.getResource({
345
381
  workshopDirectory,
346
382
  app1,
347
383
  app2,
348
384
  });
349
- return {
385
+ const diff = parseResourceText(resource);
386
+ const diffText = typeof diff === 'string' ? diff : JSON.stringify(diff ?? '');
387
+ return createToolResponse({
388
+ toolName: 'get_diff_between_apps',
389
+ summary: `Diff generated for ${app1} vs ${app2}.`,
390
+ details: diffText
391
+ ? [`Diff length: ${diffText.length} chars`]
392
+ : undefined,
350
393
  content: [getEmbeddedResourceContent(resource)],
351
- };
394
+ structuredContent: {
395
+ app1,
396
+ app2,
397
+ diff,
398
+ },
399
+ });
352
400
  });
353
- server.registerTool('get_exercise_step_progress_diff', {
354
- description: `
355
- Intended to help a student understand what work they still have to complete.
356
- `.trim(),
357
- inputSchema: exerciseStepProgressDiffResource.inputSchema,
358
- }, async ({ workshopDirectory }) => {
401
+ registerTool(server, 'get_exercise_step_progress_diff', exerciseStepProgressDiffResource.inputSchema, async ({ workshopDirectory }) => {
359
402
  workshopDirectory = await handleWorkshopDirectory(workshopDirectory);
360
403
  const resource = await exerciseStepProgressDiffResource.getResource({
361
404
  workshopDirectory,
362
405
  });
363
- return {
406
+ const diff = parseResourceText(resource);
407
+ const diffText = typeof diff === 'string' ? diff : JSON.stringify(diff ?? '');
408
+ return createToolResponse({
409
+ toolName: 'get_exercise_step_progress_diff',
410
+ summary: 'Progress diff generated for the current step.',
411
+ details: diffText
412
+ ? [`Diff length: ${diffText.length} chars`]
413
+ : undefined,
364
414
  content: [
365
415
  createText(getDiffInstructionText()),
366
416
  getEmbeddedResourceContent(resource),
367
417
  ],
368
- };
418
+ structuredContent: {
419
+ diff,
420
+ },
421
+ });
369
422
  });
370
- server.registerTool('get_exercise_step_context', {
371
- description: `
372
- Intended to help a student understand what they need to do for a specific
373
- exercise step.
374
-
375
- This returns the instructions MDX content for the specified exercise step's
376
- problem and solution. If the user has the paid version of the workshop, it will also
377
- include the transcript from each of the videos as well.
378
-
379
- The output for this will rarely change, so it's unnecessary to call this tool
380
- more than once for the same exercise step.
381
-
382
- \`get_exercise_step_context\` is often best when used with the
383
- \`get_exercise_step_progress_diff\` tool to help a student understand what
384
- work they still need to do and answer any questions about the exercise step.
385
- `.trim(),
386
- inputSchema: exerciseStepContextResource.inputSchema,
387
- }, async ({ workshopDirectory, exerciseNumber, stepNumber }) => {
423
+ registerTool(server, 'get_exercise_step_context', exerciseStepContextResource.inputSchema, async ({ workshopDirectory, exerciseNumber, stepNumber }) => {
388
424
  workshopDirectory = await handleWorkshopDirectory(workshopDirectory);
389
425
  const resource = await exerciseStepContextResource.getResource({
390
426
  workshopDirectory,
391
427
  exerciseNumber,
392
428
  stepNumber,
393
429
  });
394
- return {
430
+ const structured = createResourceStructuredContent(resource);
431
+ const data = structured.data;
432
+ const step = data?.stepInfo?.number;
433
+ const exercise = data?.exerciseInfo?.number;
434
+ return createToolResponse({
435
+ toolName: 'get_exercise_step_context',
436
+ summary: exercise && step
437
+ ? `Exercise ${exercise} step ${step} context retrieved.`
438
+ : 'Exercise step context retrieved.',
395
439
  content: [getEmbeddedResourceContent(resource)],
396
- };
440
+ structuredContent: {
441
+ exerciseStepContext: structured,
442
+ },
443
+ });
397
444
  });
398
- server.registerTool('view_video', {
399
- description: `
400
- Intended to help a student view a video.
401
-
402
- If the user ever asks you to show them a video, use this tool to do so.
403
- `.trim(),
404
- inputSchema: {
405
- videoUrl: z.string().describe(`
406
- The URL of the video to view. If you don't know the URL to use already, you can get this from the \`get_exercise_step_context\` tool or the \`get_exercise_context\` tool depending on whether you're trying to show the exercise intro/outro video or a specific step's problem/solution video. If you use the \`get_what_is_next\` tool, this will be handled automatically.
407
- `.trim()),
408
- },
445
+ registerTool(server, 'view_video', {
446
+ videoUrl: z
447
+ .string()
448
+ .describe('Video URL from exercise context or `get_what_is_next`.'),
409
449
  }, async ({ videoUrl }) => {
410
450
  const url = new URL('mcp-ui/epic-video', 'http://localhost:5639');
411
451
  url.searchParams.set('url', videoUrl);
412
- return {
452
+ return createToolResponse({
453
+ toolName: 'view_video',
454
+ summary: 'Video ready in the embedded player.',
413
455
  content: [
414
456
  createUIResource({
415
457
  content: {
@@ -420,16 +462,14 @@ The URL of the video to view. If you don't know the URL to use already, you can
420
462
  encoding: 'text',
421
463
  }),
422
464
  ],
423
- };
465
+ structuredContent: {
466
+ videoUrl,
467
+ iframeUrl: url.toString(),
468
+ },
469
+ });
424
470
  });
425
- server.registerTool('open_exercise_step_files', {
426
- title: 'Open Exercise Step Files',
427
- description: `
428
- Call this to open the files for the exercise step the playground is currently set to.
429
- `.trim(),
430
- inputSchema: {
431
- workshopDirectory: workshopDirectoryInputSchema,
432
- },
471
+ registerTool(server, 'open_exercise_step_files', {
472
+ workshopDirectory: workshopDirectoryInputSchema,
433
473
  }, async ({ workshopDirectory }) => {
434
474
  await handleWorkshopDirectory(workshopDirectory);
435
475
  const playgroundApp = await getPlaygroundApp();
@@ -445,125 +485,126 @@ Call this to open the files for the exercise step the playground is currently se
445
485
  const fullPath = path.join(playgroundApp.fullPath, file.path);
446
486
  await launchEditor(fullPath, file.line);
447
487
  }
448
- return {
449
- content: [
450
- {
451
- type: 'text',
452
- text: `Opened ${diffFiles.length} file${diffFiles.length === 1 ? '' : 's'}:\n${diffFiles.map((file) => `${file.path}:${file.line}`).join('\n')}`,
453
- },
454
- ],
455
- };
488
+ const openedFiles = diffFiles.map((file) => ({
489
+ path: file.path,
490
+ line: file.line,
491
+ }));
492
+ return createToolResponse({
493
+ toolName: 'open_exercise_step_files',
494
+ summary: `Opened ${diffFiles.length} file${diffFiles.length === 1 ? '' : 's'}.`,
495
+ details: openedFiles.map((file) => `- ${file.path}:${file.line}`),
496
+ structuredContent: {
497
+ count: openedFiles.length,
498
+ files: openedFiles,
499
+ },
500
+ });
456
501
  });
457
- server.registerTool('get_user_info', {
458
- description: `
459
- Intended to help you get information about the current user.
460
-
461
- This includes the user's name, email, etc. It's mostly useful to determine
462
- whether the user is logged in and know who they are.
463
-
464
- If the user is not logged in, tell them to log in by running the \`login\` tool.
465
- `.trim(),
466
- inputSchema: userInfoResource.inputSchema,
467
- }, async ({ workshopDirectory }) => {
502
+ registerTool(server, 'get_user_info', userInfoResource.inputSchema, async ({ workshopDirectory }) => {
468
503
  workshopDirectory = await handleWorkshopDirectory(workshopDirectory);
469
504
  const resource = await userInfoResource.getResource({ workshopDirectory });
470
- return {
505
+ const userInfoRaw = parseResourceText(resource);
506
+ const userInfo = userInfoRaw && typeof userInfoRaw === 'object'
507
+ ? userInfoRaw
508
+ : null;
509
+ const summary = userInfo
510
+ ? `User info retrieved for ${userInfo.email ?? 'unknown email'}.`
511
+ : 'No authenticated user found.';
512
+ return createToolResponse({
513
+ toolName: 'get_user_info',
514
+ summary,
515
+ statusEmoji: userInfo ? '✅' : '⚠️',
471
516
  content: [getEmbeddedResourceContent(resource)],
472
- };
517
+ structuredContent: {
518
+ userInfo,
519
+ },
520
+ });
473
521
  });
474
- server.registerTool('get_user_access', {
475
- description: `
476
- Will tell you whether the user has access to the paid features of the workshop.
477
-
478
- Paid features include:
479
- - Transcripts
480
- - Progress tracking
481
- - Access to videos
482
- - Access to the discord chat
483
- - Test tab support
484
- - Diff tab support
485
-
486
- Encourage the user to upgrade if they need access to the paid features.
487
- `.trim(),
488
- inputSchema: userAccessResource.inputSchema,
489
- }, async ({ workshopDirectory }) => {
522
+ registerTool(server, 'get_user_access', userAccessResource.inputSchema, async ({ workshopDirectory }) => {
490
523
  workshopDirectory = await handleWorkshopDirectory(workshopDirectory);
491
524
  const resource = await userAccessResource.getResource({
492
525
  workshopDirectory,
493
526
  });
494
- return {
527
+ const access = parseResourceText(resource);
528
+ const userHasAccess = typeof access?.userHasAccess === 'boolean'
529
+ ? access.userHasAccess
530
+ : undefined;
531
+ const statusEmoji = userHasAccess === false ? '⚠️' : '✅';
532
+ return createToolResponse({
533
+ toolName: 'get_user_access',
534
+ summary: typeof userHasAccess === 'boolean'
535
+ ? `User access: ${userHasAccess ? 'paid' : 'free'}`
536
+ : 'User access retrieved.',
537
+ statusEmoji,
495
538
  content: [getEmbeddedResourceContent(resource)],
496
- };
539
+ structuredContent: {
540
+ userHasAccess,
541
+ },
542
+ });
497
543
  });
498
- server.registerTool('get_user_progress', {
499
- description: `
500
- Intended to help you get the progress of the current user. Can often be helpful
501
- to know what the next step that needs to be completed is. Make sure to provide
502
- the user with the URL of relevant incomplete lessons so they can watch them and
503
- then mark them as complete.
504
- `.trim(),
505
- inputSchema: userProgressResource.inputSchema,
506
- }, async ({ workshopDirectory }) => {
544
+ registerTool(server, 'get_user_progress', userProgressResource.inputSchema, async ({ workshopDirectory }) => {
507
545
  workshopDirectory = await handleWorkshopDirectory(workshopDirectory);
508
546
  const resource = await userProgressResource.getResource({
509
547
  workshopDirectory,
510
548
  });
511
- return {
549
+ const progress = parseResourceText(resource);
550
+ const items = Array.isArray(progress)
551
+ ? progress
552
+ : [];
553
+ const incompleteCount = items.filter((item) => !item.epicCompletedAt).length;
554
+ return createToolResponse({
555
+ toolName: 'get_user_progress',
556
+ summary: `Progress retrieved. Incomplete items: ${incompleteCount}.`,
512
557
  content: [getEmbeddedResourceContent(resource)],
513
- };
558
+ structuredContent: {
559
+ progress: items,
560
+ incompleteCount,
561
+ },
562
+ });
514
563
  });
515
564
  }
516
565
  // Sometimes the user will ask the LLM to select a prompt to use so they don't have to.
517
566
  export function initPromptTools(server) {
518
- server.registerTool('get_quiz_instructions', {
519
- description: `
520
- If the user asks you to quiz them on a topic from the workshop, use this tool to
521
- retrieve the instructions for how to do so.
522
-
523
- - If the user asks for a specific exercise, supply that exercise number.
524
- - If they ask for a specific exericse, supply that exercise number.
525
- - If they ask for a topic and you don't know which exercise that topic is in, use \`get_workshop_context\` to get the list of exercises and their topics and then supply the appropriate exercise number.
526
- `.trim(),
527
- inputSchema: quizMeInputSchema,
528
- }, async ({ workshopDirectory, exerciseNumber }) => {
567
+ registerTool(server, 'get_quiz_instructions', quizMeInputSchema, async ({ workshopDirectory, exerciseNumber }) => {
529
568
  workshopDirectory = await handleWorkshopDirectory(workshopDirectory);
530
569
  const result = await quizMe({ workshopDirectory, exerciseNumber });
531
- return {
532
- // QUESTION: will a prompt ever return messages that have role: 'assistant'?
533
- // if so, this may be a little confusing for the LLM, but I can't think of a
534
- // good use case for that so 🤷‍♂️
535
- content: result.messages.map((m) => {
536
- if (m.content.type === 'resource') {
537
- return getEmbeddedResourceContent(m.content.resource);
538
- }
539
- return m.content;
540
- }),
541
- };
570
+ const promptContent = result.messages.map((m) => {
571
+ if (m.content.type === 'resource') {
572
+ return getEmbeddedResourceContent(m.content.resource);
573
+ }
574
+ return m.content;
575
+ });
576
+ return createToolResponse({
577
+ toolName: 'get_quiz_instructions',
578
+ summary: 'Quiz instructions prepared.',
579
+ details: [
580
+ exerciseNumber
581
+ ? `Exercise: ${exerciseNumber}`
582
+ : 'Exercise: random selection',
583
+ ],
584
+ content: promptContent,
585
+ structuredContent: {
586
+ exerciseNumber: exerciseNumber ?? null,
587
+ messages: result.messages,
588
+ },
589
+ });
542
590
  });
543
- server.registerTool('get_what_is_next', {
544
- title: 'Get What Is Next',
545
- description: `
546
- Intended to help you get the next step that the user needs to complete.
547
-
548
- This is often useful to know what the user should do next to continue their learning.
549
-
550
- This could be that they need to login, watch a video, complete an exercise, etc.
551
- `.trim(),
552
- inputSchema: {
553
- workshopDirectory: workshopDirectoryInputSchema,
554
- },
591
+ registerTool(server, 'get_what_is_next', {
592
+ workshopDirectory: workshopDirectoryInputSchema,
555
593
  }, async ({ workshopDirectory }) => {
556
594
  await handleWorkshopDirectory(workshopDirectory);
557
595
  const authInfo = await getAuthInfo();
558
596
  if (!authInfo) {
559
- return {
560
- content: [
561
- {
562
- type: 'text',
563
- text: 'The user is not logged in. Use the `login` tool to login the user.',
564
- },
565
- ],
566
- };
597
+ return createToolResponse({
598
+ toolName: 'get_what_is_next',
599
+ summary: 'User is not logged in.',
600
+ statusEmoji: '⚠️',
601
+ details: ['Use `login` to authenticate the user.'],
602
+ nextSteps: ['Call `login` to start device authorization.'],
603
+ includeMetaNextSteps: false,
604
+ structuredContent: {
605
+ status: 'not_authenticated',
606
+ },
607
+ });
567
608
  }
568
609
  const progress = await getProgress();
569
610
  const scoreProgress = (a) => {
@@ -586,21 +627,25 @@ This could be that they need to login, watch a video, complete an exercise, etc.
586
627
  });
587
628
  const nextProgress = sortedProgress.find((p) => !p.epicCompletedAt);
588
629
  if (!nextProgress) {
589
- return {
590
- content: [
591
- {
592
- type: 'text',
593
- text: `The user has completed the workshop. Congratulate them and invite them to ask you to quiz them on their understanding of the material. A summary of the material is below:`,
594
- },
595
- createText(await createWorkshopSummary()),
596
- ],
597
- };
630
+ return createToolResponse({
631
+ toolName: 'get_what_is_next',
632
+ summary: 'Workshop complete.',
633
+ details: ['Invite the user to request a quiz on the material.'],
634
+ includeMetaNextSteps: false,
635
+ content: [createText(await createWorkshopSummary())],
636
+ structuredContent: {
637
+ status: 'complete',
638
+ },
639
+ });
598
640
  }
599
641
  invariant(nextProgress.type !== 'unknown', `Next progress type is unknown. This is unexpected. Sorry, we don't know what to do here. Here's a summary of the workshop:\n\n${await createWorkshopSummary()}`);
600
642
  if (nextProgress.type === 'workshop-instructions') {
601
643
  const embedUrl = new URL('mcp-ui/epic-video', 'http://localhost:5639');
602
644
  embedUrl.searchParams.set('url', nextProgress.epicLessonUrl);
603
- return {
645
+ return createToolResponse({
646
+ toolName: 'get_what_is_next',
647
+ summary: 'User should complete workshop instructions.',
648
+ includeMetaNextSteps: false,
604
649
  content: [
605
650
  createText(`The user has just begun! They need to watch the workshop instructions video and read the instructions to get started. When they say they're done or ready for what's next, mark it as complete using the \`update_progress\` tool with the slug "${nextProgress.epicLessonSlug}" and then call \`get_what_is_next\` again to get the next step. Relevant info is below:`),
606
651
  createText(await createWorkshopSummary()),
@@ -614,12 +659,22 @@ This could be that they need to login, watch a video, complete an exercise, etc.
614
659
  },
615
660
  }),
616
661
  ],
617
- };
662
+ structuredContent: {
663
+ nextStep: {
664
+ type: nextProgress.type,
665
+ epicLessonSlug: nextProgress.epicLessonSlug,
666
+ epicLessonUrl: nextProgress.epicLessonUrl,
667
+ },
668
+ },
669
+ });
618
670
  }
619
671
  if (nextProgress.type === 'workshop-finished') {
620
672
  const embedUrl = new URL('mcp-ui/epic-video', 'http://localhost:5639');
621
673
  embedUrl.searchParams.set('url', nextProgress.epicLessonUrl);
622
- return {
674
+ return createToolResponse({
675
+ toolName: 'get_what_is_next',
676
+ summary: 'User should complete workshop finished instructions.',
677
+ includeMetaNextSteps: false,
623
678
  content: [
624
679
  createText(`The user has almost completed the workshop. They just need to watch the workshop finished video and read the finished instructions to get started. When they say they're done or ready for what's next, mark it as complete using the \`update_progress\` tool with the slug "${nextProgress.epicLessonSlug}" and then call \`get_what_is_next\` again to get the next step. Relevant info is below:`),
625
680
  createText(`Finished instructions:\n${await readInWorkshop('exercises', 'FINISHED.mdx')}`),
@@ -632,14 +687,24 @@ This could be that they need to login, watch a video, complete an exercise, etc.
632
687
  },
633
688
  }),
634
689
  ],
635
- };
690
+ structuredContent: {
691
+ nextStep: {
692
+ type: nextProgress.type,
693
+ epicLessonSlug: nextProgress.epicLessonSlug,
694
+ epicLessonUrl: nextProgress.epicLessonUrl,
695
+ },
696
+ },
697
+ });
636
698
  }
637
699
  const ex = nextProgress.exerciseNumber.toString().padStart(2, '0');
638
700
  if (nextProgress.type === 'instructions') {
639
701
  const embedUrl = new URL('mcp-ui/epic-video', 'http://localhost:5639');
640
702
  embedUrl.searchParams.set('url', nextProgress.epicLessonUrl);
641
703
  const exercise = await getExercise(nextProgress.exerciseNumber);
642
- return {
704
+ return createToolResponse({
705
+ toolName: 'get_what_is_next',
706
+ summary: `User should complete exercise ${ex} intro.`,
707
+ includeMetaNextSteps: false,
643
708
  content: [
644
709
  createText(`The user needs to complete the intro for exercise ${ex}. When they say they're done or ready for what's next, mark it as complete using the \`update_progress\` tool with the slug "${nextProgress.epicLessonSlug}" and then call \`get_what_is_next\` again to get the next step. Relevant info is below:`),
645
710
  createText(`Exercise instructions:\n${await readReadme(exercise?.fullPath)}`),
@@ -652,13 +717,24 @@ This could be that they need to login, watch a video, complete an exercise, etc.
652
717
  },
653
718
  }),
654
719
  ],
655
- };
720
+ structuredContent: {
721
+ nextStep: {
722
+ type: nextProgress.type,
723
+ exerciseNumber: nextProgress.exerciseNumber,
724
+ epicLessonSlug: nextProgress.epicLessonSlug,
725
+ epicLessonUrl: nextProgress.epicLessonUrl,
726
+ },
727
+ },
728
+ });
656
729
  }
657
730
  if (nextProgress.type === 'finished') {
658
731
  const embedUrl = new URL('mcp-ui/epic-video', 'http://localhost:5639');
659
732
  embedUrl.searchParams.set('url', nextProgress.epicLessonUrl);
660
733
  const exercise = await getExercise(nextProgress.exerciseNumber);
661
- return {
734
+ return createToolResponse({
735
+ toolName: 'get_what_is_next',
736
+ summary: `User should complete exercise ${ex} outro.`,
737
+ includeMetaNextSteps: false,
662
738
  content: [
663
739
  createText(`The user is almost finished with exercise ${ex}. They need to complete the outro for exercise ${ex}. Relevant info is below:`),
664
740
  createText(`Exercise finished instructions:\n${await readReadme(exercise?.fullPath)}`),
@@ -671,7 +747,15 @@ This could be that they need to login, watch a video, complete an exercise, etc.
671
747
  },
672
748
  }),
673
749
  ],
674
- };
750
+ structuredContent: {
751
+ nextStep: {
752
+ type: nextProgress.type,
753
+ exerciseNumber: nextProgress.exerciseNumber,
754
+ epicLessonSlug: nextProgress.epicLessonSlug,
755
+ epicLessonUrl: nextProgress.epicLessonUrl,
756
+ },
757
+ },
758
+ });
675
759
  }
676
760
  const st = nextProgress.stepNumber.toString().padStart(2, '0');
677
761
  if (nextProgress.type === 'step') {
@@ -682,7 +766,10 @@ This could be that they need to login, watch a video, complete an exercise, etc.
682
766
  solutionEmbedUrl.searchParams.set('url', `${nextProgress.epicLessonUrl}/solution`);
683
767
  const step = exercise?.steps.find((s) => s.stepNumber === nextProgress.stepNumber);
684
768
  invariant(step, `No step found for exercise ${nextProgress.exerciseNumber} step ${nextProgress.stepNumber}`);
685
- return {
769
+ return createToolResponse({
770
+ toolName: 'get_what_is_next',
771
+ summary: `User is on step ${st} of exercise ${ex}.`,
772
+ includeMetaNextSteps: false,
686
773
  content: [
687
774
  createText(`
688
775
  The user is on step ${st} of exercise ${ex}. To complete this step they need to:
@@ -716,7 +803,19 @@ Then you can call \`get_what_is_next\` again to get the next step.
716
803
  },
717
804
  }),
718
805
  ],
719
- };
806
+ structuredContent: {
807
+ nextStep: {
808
+ type: nextProgress.type,
809
+ exerciseNumber: nextProgress.exerciseNumber,
810
+ stepNumber: nextProgress.stepNumber,
811
+ epicLessonSlug: nextProgress.epicLessonSlug,
812
+ videoUrls: {
813
+ problem: nextProgress.epicLessonUrl,
814
+ solution: `${nextProgress.epicLessonUrl}/solution`,
815
+ },
816
+ },
817
+ },
818
+ });
720
819
  }
721
820
  throw new Error(`This is unexpected, but I do not know what the next step for the user is. Sorry!`);
722
821
  });