Skip to content

Commit 1ee4bf8

Browse files
committed
more progress
1 parent b9d97ec commit 1ee4bf8

3 files changed

Lines changed: 261 additions & 59 deletions

File tree

Binary file not shown.

exercises/99.finished/01.solution.finished/src/index.test.ts

Lines changed: 247 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import {
88
CreateMessageRequestSchema,
99
type CreateMessageResult,
1010
ElicitRequestSchema,
11+
ProgressNotificationSchema,
12+
PromptListChangedNotificationSchema,
13+
ResourceListChangedNotificationSchema,
1114
ResourceUpdatedNotificationSchema,
15+
ToolListChangedNotificationSchema,
1216
} from '@modelcontextprotocol/sdk/types.js'
1317
import { test, expect } from 'vitest'
1418
import { type z } from 'zod'
@@ -55,7 +59,7 @@ test('Tool Definition', async () => {
5559
const [firstTool] = list.tools
5660
invariant(firstTool, '🚨 No tools found')
5761

58-
expect(firstTool).toEqual(
62+
expect(firstTool, '🚨 firstTool should be a create_entry tool').toEqual(
5963
expect.objectContaining({
6064
name: expect.stringMatching(/^create_entry$/i),
6165
description: expect.stringMatching(/^create a new journal entry$/i),
@@ -138,7 +142,10 @@ test('Sampling', async () => {
138142
const request = await messageRequestDeferred.promise
139143

140144
try {
141-
expect(request).toEqual(
145+
expect(
146+
request,
147+
'🚨 request should be a sampling/createMessage request',
148+
).toEqual(
142149
expect.objectContaining({
143150
method: 'sampling/createMessage',
144151
params: expect.objectContaining({
@@ -334,8 +341,14 @@ test('Resource subscriptions: entry and tag', async () => {
334341
entryNotification.promise,
335342
])
336343

337-
expect(tagNotif.params.uri).toBe(tagUri)
338-
expect(entryNotif.params.uri).toBe(entryUri)
344+
expect(
345+
tagNotif.params.uri,
346+
'🚨 Tag notification uri should be the tag URI',
347+
).toBe(tagUri)
348+
expect(
349+
entryNotif.params.uri,
350+
'🚨 Entry notification uri should be the entry URI',
351+
).toBe(entryUri)
339352

340353
// Unsubscribe and trigger another update
341354
notifications.length = 0
@@ -351,7 +364,10 @@ test('Resource subscriptions: entry and tag', async () => {
351364
})
352365
// Wait a short time to ensure no notifications are received
353366
await new Promise((r) => setTimeout(r, 200))
354-
expect(notifications).toHaveLength(0)
367+
expect(
368+
notifications,
369+
'🚨 No notifications should be received after unsubscribing',
370+
).toHaveLength(0)
355371
})
356372

357373
test('Elicitation: delete_entry confirmation', async () => {
@@ -395,43 +411,24 @@ test('Elicitation: delete_entry confirmation', async () => {
395411
'success' in structuredContent,
396412
'🚨 structuredContent missing success field',
397413
)
398-
expect(structuredContent.success).toBe(true)
414+
expect(
415+
structuredContent.success,
416+
'🚨 structuredContent.success should be true after deleting an entry',
417+
).toBe(true)
399418

400419
invariant(elicitationRequest, '🚨 No elicitation request was sent')
401420
const params = elicitationRequest.params
402421
invariant(params, '🚨 elicitationRequest missing params')
403-
invariant(
404-
typeof params.message === 'string',
405-
'🚨 elicitationRequest.params.message must be a string',
406-
)
407-
invariant(
408-
params.message.match(/Are you sure you want to delete entry/i),
409-
'🚨 elicitationRequest.params.message does not match expected confirmation prompt',
410-
)
411-
expect(params.message).toMatch(/Are you sure you want to delete entry/i)
412422

413-
invariant(
423+
expect(
424+
params.message,
425+
'🚨 elicitationRequest.params.message should match expected confirmation prompt',
426+
).toMatch(/Are you sure you want to delete entry/i)
427+
428+
expect(
414429
params.requestedSchema,
415-
'🚨 elicitationRequest.params.requestedSchema is missing',
416-
)
417-
invariant(
418-
params.requestedSchema.type === 'object',
419-
'🚨 elicitationRequest.params.requestedSchema.type must be "object"',
420-
)
421-
invariant(
422-
params.requestedSchema.properties &&
423-
typeof params.requestedSchema.properties === 'object',
424-
'🚨 elicitationRequest.params.requestedSchema.properties must be an object',
425-
)
426-
invariant(
427-
'confirmed' in params.requestedSchema.properties,
428-
'🚨 elicitationRequest.params.requestedSchema.properties must include confirmed',
429-
)
430-
invariant(
431-
params.requestedSchema.properties.confirmed.type === 'boolean',
432-
'🚨 elicitationRequest.params.requestedSchema.properties.confirmed.type must be boolean',
433-
)
434-
expect(params.requestedSchema).toEqual(
430+
'🚨 elicitationRequest.params.requestedSchema should match expected schema',
431+
).toEqual(
435432
expect.objectContaining({
436433
type: 'object',
437434
properties: expect.objectContaining({
@@ -470,18 +467,222 @@ test('Elicitation: delete_tag decline', async () => {
470467
arguments: { id: tag.id },
471468
})
472469
const structuredContent = deleteResult.structuredContent as any
473-
invariant(
474-
structuredContent,
475-
'🚨 No structuredContent returned from delete_tag',
476-
)
477-
invariant(
478-
'success' in structuredContent,
479-
'🚨 structuredContent missing success field',
480-
)
481-
expect(structuredContent.success).toBe(false)
470+
471+
expect(
472+
structuredContent.success,
473+
'🚨 structuredContent.success should be false after declining to delete a tag',
474+
).toBe(false)
482475
invariant(
483476
'message' in structuredContent,
484477
'🚨 structuredContent missing message field',
485478
)
486-
expect(structuredContent.message).toMatch(/cancelled/i)
479+
expect(
480+
structuredContent.message,
481+
'🚨 structuredContent.message should include "cancelled"',
482+
).toMatch(/cancelled/i)
483+
})
484+
485+
test('ListChanged notification: resources', async () => {
486+
await using setup = await setupClient()
487+
const { client } = setup
488+
489+
const resourceListChanged = await deferred<any>()
490+
client.setNotificationHandler(
491+
ResourceListChangedNotificationSchema,
492+
(notification) => {
493+
resourceListChanged.resolve(notification)
494+
},
495+
)
496+
497+
// Trigger a DB change that should enable resources
498+
await client.callTool({
499+
name: 'create_tag',
500+
arguments: {
501+
name: faker.lorem.word(),
502+
description: faker.lorem.sentence(),
503+
},
504+
})
505+
await client.callTool({
506+
name: 'create_entry',
507+
arguments: {
508+
title: faker.lorem.words(3),
509+
content: faker.lorem.paragraphs(2),
510+
},
511+
})
512+
513+
let resourceNotif
514+
try {
515+
resourceNotif = await Promise.race([
516+
resourceListChanged.promise,
517+
AbortSignal.timeout(2000),
518+
])
519+
} catch {
520+
throw new Error(
521+
'🚨 Did not receive resources/listChanged notification when expected. Make sure your server calls sendResourceListChanged when resources change.',
522+
)
523+
}
524+
expect(
525+
resourceNotif,
526+
'🚨 Did not receive resources/listChanged notification when expected. Make sure your server calls sendResourceListChanged when resources change.',
527+
).toBeDefined()
528+
})
529+
530+
test('ListChanged notification: tools', async () => {
531+
await using setup = await setupClient()
532+
const { client } = setup
533+
534+
const toolListChanged = await deferred<any>()
535+
client.setNotificationHandler(
536+
ToolListChangedNotificationSchema,
537+
(notification) => {
538+
toolListChanged.resolve(notification)
539+
},
540+
)
541+
542+
// Trigger a DB change that should enable tools
543+
await client.callTool({
544+
name: 'create_tag',
545+
arguments: {
546+
name: faker.lorem.word(),
547+
description: faker.lorem.sentence(),
548+
},
549+
})
550+
await client.callTool({
551+
name: 'create_entry',
552+
arguments: {
553+
title: faker.lorem.words(3),
554+
content: faker.lorem.paragraphs(2),
555+
},
556+
})
557+
558+
let toolNotif
559+
try {
560+
toolNotif = await Promise.race([
561+
toolListChanged.promise,
562+
AbortSignal.timeout(2000),
563+
])
564+
} catch {
565+
throw new Error(
566+
'🚨 Did not receive tools/listChanged notification when expected. Make sure your server notifies clients when tools are enabled/disabled.',
567+
)
568+
}
569+
expect(
570+
toolNotif,
571+
'🚨 Did not receive tools/listChanged notification when expected. Make sure your server notifies clients when tools are enabled/disabled.',
572+
).toBeDefined()
573+
})
574+
575+
test('ListChanged notification: prompts', async () => {
576+
await using setup = await setupClient()
577+
const { client } = setup
578+
579+
const promptListChanged = await deferred<any>()
580+
client.setNotificationHandler(
581+
PromptListChangedNotificationSchema,
582+
(notification) => {
583+
promptListChanged.resolve(notification)
584+
},
585+
)
586+
587+
// Trigger a DB change that should enable prompts
588+
await client.callTool({
589+
name: 'create_tag',
590+
arguments: {
591+
name: faker.lorem.word(),
592+
description: faker.lorem.sentence(),
593+
},
594+
})
595+
await client.callTool({
596+
name: 'create_entry',
597+
arguments: {
598+
title: faker.lorem.words(3),
599+
content: faker.lorem.paragraphs(2),
600+
},
601+
})
602+
603+
let promptNotif
604+
try {
605+
promptNotif = await Promise.race([
606+
promptListChanged.promise,
607+
AbortSignal.timeout(2000),
608+
])
609+
} catch {
610+
throw new Error(
611+
'🚨 Did not receive prompts/listChanged notification when expected. Make sure your server notifies clients when prompts are enabled/disabled.',
612+
)
613+
}
614+
expect(
615+
promptNotif,
616+
'🚨 Did not receive prompts/listChanged notification when expected. Make sure your server notifies clients when prompts are enabled/disabled.',
617+
).toBeDefined()
618+
})
619+
620+
test('Progress notification: create_wrapped_video (mock)', async () => {
621+
await using setup = await setupClient()
622+
const { client } = setup
623+
624+
const progressDeferred = await deferred<any>()
625+
client.setNotificationHandler(ProgressNotificationSchema, (notification) => {
626+
progressDeferred.resolve(notification)
627+
})
628+
629+
// Ensure the tool is enabled by creating a tag and an entry first
630+
await client.callTool({
631+
name: 'create_tag',
632+
arguments: {
633+
name: faker.lorem.word(),
634+
description: faker.lorem.sentence(),
635+
},
636+
})
637+
await client.callTool({
638+
name: 'create_entry',
639+
arguments: {
640+
title: faker.lorem.words(3),
641+
content: faker.lorem.paragraphs(2),
642+
},
643+
})
644+
645+
// Call the tool with mockTime: 500
646+
const progressToken = faker.string.uuid()
647+
await client.callTool({
648+
name: 'create_wrapped_video',
649+
arguments: {
650+
mockTime: 500,
651+
},
652+
_meta: {
653+
progressToken,
654+
},
655+
})
656+
657+
let progressNotif
658+
try {
659+
progressNotif = await Promise.race([
660+
progressDeferred.promise,
661+
AbortSignal.timeout(2000),
662+
])
663+
} catch {
664+
throw new Error(
665+
'🚨 Did not receive progress notification for create_wrapped_video (mock). Make sure your tool sends progress updates when running in mock mode.',
666+
)
667+
}
668+
expect(
669+
progressNotif,
670+
'🚨 Did not receive progress notification for create_wrapped_video (mock).',
671+
).toBeDefined()
672+
expect(
673+
typeof progressNotif.params.progress,
674+
'🚨 progress should be a number',
675+
).toBe('number')
676+
expect(
677+
progressNotif.params.progress,
678+
'🚨 progress should be a number between 0 and 1',
679+
).toBeGreaterThanOrEqual(0)
680+
expect(
681+
progressNotif.params.progress,
682+
'🚨 progress should be a number between 0 and 1',
683+
).toBeLessThanOrEqual(1)
684+
expect(
685+
progressNotif.params.progressToken,
686+
'🚨 progressToken should be a string',
687+
).toBe(progressToken)
487688
})

0 commit comments

Comments
 (0)