返回 slidev
mcp.test.ts
根目录 / test / mcp.test.ts
1 import { mkdtemp, readFile, writeFile } from 'node:fs/promises'
2 import { tmpdir } from 'node:os'
3 import { join } from 'node:path'
4 import { Client, InMemoryTransport } from '@modelcontextprotocol/client'
5 import { describe, expect, it, vi } from 'vitest'
6 import { load } from '../packages/parser/src/fs'
7 import { applySlidePatch, insertSlide, moveSlide, removeSlide } from '../packages/slidev/node/mcp/operations'
8 import { createSlidevMcpServer } from '../packages/slidev/node/mcp/server'
9
10 const ENTRY_MD = `---
11 theme: default
12 title: Test Deck
13 ---
14
15 # Slide 1
16
17 ---
18
19 # Slide 2
20
21 Some content
22
23 <!--
24 note 2
25 -->
26
27 ---
28 layout: two-cols
29 ---
30
31 # Slide 3
32
33 ---
34 src: ./sub.md
35 ---
36
37 ---
38
39 # Slide 6
40 `
41
42 const SUB_MD = `# Sub 1
43
44 ---
45
46 # Sub 2
47 `
48
49 async function createDeck() {
50 const dir = await mkdtemp(join(tmpdir(), 'slidev-mcp-test-'))
51 const entry = join(dir, 'slides.md')
52 await writeFile(entry, ENTRY_MD, 'utf-8')
53 await writeFile(join(dir, 'sub.md'), SUB_MD, 'utf-8')
54 const loadData = () => load({ userRoot: dir, roots: [dir] }, entry)
55 return { dir, entry, loadData }
56 }
57
58 describe('mcp slide operations', () => {
59 it('loads the fixture deck as expected', async () => {
60 const { loadData } = await createDeck()
61 const data = await loadData()
62 expect(data.slides.map(s => s.title)).toEqual(
63 ['Test Deck', 'Slide 2', 'Slide 3', 'Sub 1', 'Sub 2', 'Slide 6'],
64 )
65 })
66
67 it('patches slide content and preserves the note', async () => {
68 const { loadData } = await createDeck()
69 await applySlidePatch(await loadData(), 2, { content: '# Slide 2 Updated' })
70
71 const data = await loadData()
72 expect(data.slides[1].content.trim()).toBe('# Slide 2 Updated')
73 expect(data.slides[1].note).toBe('note 2')
74 })
75
76 it('patches the note and frontmatter of a slide', async () => {
77 const { loadData } = await createDeck()
78 await applySlidePatch(await loadData(), 3, {
79 note: 'a new note',
80 frontmatter: { layout: 'center', background: 'blue' },
81 })
82
83 let data = await loadData()
84 expect(data.slides[2].note).toBe('a new note')
85 expect(data.slides[2].frontmatter).toEqual({ layout: 'center', background: 'blue' })
86
87 // `null` deletes a frontmatter key
88 await applySlidePatch(data, 3, { frontmatter: { background: null } })
89 data = await loadData()
90 expect(data.slides[2].frontmatter).toEqual({ layout: 'center' })
91 })
92
93 it('patches the entry first slide without breaking the headmatter', async () => {
94 const { loadData } = await createDeck()
95 await applySlidePatch(await loadData(), 1, { note: 'cover note' })
96
97 const data = await loadData()
98 expect(data.headmatter.title).toBe('Test Deck')
99 expect(data.slides[0].note).toBe('cover note')
100 })
101
102 it('patches a slide imported with src: into its own file', async () => {
103 const { dir, loadData } = await createDeck()
104 await applySlidePatch(await loadData(), 4, { content: '# Sub 1 Changed' })
105
106 expect(await readFile(join(dir, 'sub.md'), 'utf-8')).toContain('# Sub 1 Changed')
107 expect(await readFile(join(dir, 'slides.md'), 'utf-8')).not.toContain('# Sub 1 Changed')
108 const data = await loadData()
109 expect(data.slides[3].content.trim()).toBe('# Sub 1 Changed')
110 })
111
112 it('rejects invalid YAML in frontmatterRaw', async () => {
113 const { loadData } = await createDeck()
114 await expect(applySlidePatch(await loadData(), 2, { frontmatterRaw: 'foo: [unclosed' }))
115 .rejects
116 .toThrow(/Invalid YAML/)
117 })
118
119 it('rejects out-of-range slide numbers', async () => {
120 const { loadData } = await createDeck()
121 await expect(applySlidePatch(await loadData(), 99, { content: 'x' }))
122 .rejects
123 .toThrow(/does not exist/)
124 })
125
126 it('inserts a slide after an existing one', async () => {
127 const { loadData } = await createDeck()
128 await insertSlide(await loadData(), {
129 after: 2,
130 content: '# Inserted',
131 frontmatter: { layout: 'center' },
132 note: 'inserted note',
133 })
134
135 const data = await loadData()
136 expect(data.slides.map(s => s.title)).toEqual(
137 ['Test Deck', 'Slide 2', 'Inserted', 'Slide 3', 'Sub 1', 'Sub 2', 'Slide 6'],
138 )
139 expect(data.slides[2].frontmatter).toEqual({ layout: 'center' })
140 expect(data.slides[2].note).toBe('inserted note')
141 })
142
143 it('inserts a slide at the end of the deck', async () => {
144 const { loadData } = await createDeck()
145 await insertSlide(await loadData(), { after: 6, content: '# The End' })
146
147 const data = await loadData()
148 expect(data.slides.at(-1)?.title).toBe('The End')
149 })
150
151 it('removes a slide', async () => {
152 const { loadData } = await createDeck()
153 await removeSlide(await loadData(), 3)
154
155 const data = await loadData()
156 expect(data.slides.map(s => s.title)).toEqual(
157 ['Test Deck', 'Slide 2', 'Sub 1', 'Sub 2', 'Slide 6'],
158 )
159 })
160
161 it('refuses to remove the entry first slide (headmatter)', async () => {
162 const { loadData } = await createDeck()
163 await expect(removeSlide(await loadData(), 1)).rejects.toThrow(/headmatter/)
164 })
165
166 it('moves a slide after another one', async () => {
167 const { loadData } = await createDeck()
168 await moveSlide(await loadData(), { from: 2, after: 6 })
169
170 const data = await loadData()
171 expect(data.slides.map(s => s.title)).toEqual(
172 ['Test Deck', 'Slide 3', 'Sub 1', 'Sub 2', 'Slide 6', 'Slide 2'],
173 )
174 })
175
176 it('moves a slide before another one', async () => {
177 const { loadData } = await createDeck()
178 await moveSlide(await loadData(), { from: 3, before: 2 })
179
180 const data = await loadData()
181 expect(data.slides.map(s => s.title)).toEqual(
182 ['Test Deck', 'Slide 3', 'Slide 2', 'Sub 1', 'Sub 2', 'Slide 6'],
183 )
184 })
185
186 it('moves slides within an imported file', async () => {
187 const { loadData } = await createDeck()
188 await moveSlide(await loadData(), { from: 5, before: 4 })
189
190 const data = await loadData()
191 expect(data.slides.map(s => s.title)).toEqual(
192 ['Test Deck', 'Slide 2', 'Slide 3', 'Sub 2', 'Sub 1', 'Slide 6'],
193 )
194 })
195
196 it('refuses to move slides across files', async () => {
197 const { loadData } = await createDeck()
198 await expect(moveSlide(await loadData(), { from: 4, before: 2 }))
199 .rejects
200 .toThrow(/across markdown files/)
201 })
202
203 it('refuses to move the entry first slide or insert before it', async () => {
204 const { loadData } = await createDeck()
205 await expect(moveSlide(await loadData(), { from: 1, after: 2 }))
206 .rejects
207 .toThrow(/headmatter/)
208 await expect(moveSlide(await loadData(), { from: 3, before: 1 }))
209 .rejects
210 .toThrow(/headmatter/)
211 })
212
213 it('requires exactly one of before/after', async () => {
214 const { loadData } = await createDeck()
215 await expect(moveSlide(await loadData(), { from: 2 }))
216 .rejects
217 .toThrow(/exactly one/)
218 await expect(moveSlide(await loadData(), { from: 2, before: 3, after: 4 }))
219 .rejects
220 .toThrow(/exactly one/)
221 })
222 })
223
224 describe('mcp server', () => {
225 async function createServerAndClient(nav?: { getState: () => { page: number, clicks: number }, go: (page: number, clicks: number) => void }) {
226 const { entry, loadData } = await createDeck()
227 const server = createSlidevMcpServer({
228 version: '0.0.0-test',
229 entry,
230 getData: () => loadData(),
231 nav,
232 })
233 const client = new Client({ name: 'test-client', version: '0.0.0' })
234 const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
235 await Promise.all([
236 server.connect(serverTransport),
237 client.connect(clientTransport),
238 ])
239 return { client }
240 }
241
242 function textOf(result: any) {
243 return result.content[0].text as string
244 }
245
246 it('exposes the expected tools', async () => {
247 const { client } = await createServerAndClient()
248 const { tools } = await client.listTools()
249 expect(tools.map(t => t.name).sort()).toEqual([
250 'slidev-get-info',
251 'slidev-get-slide',
252 'slidev-insert-slide',
253 'slidev-list-slides',
254 'slidev-move-slide',
255 'slidev-remove-slide',
256 'slidev-update-slide',
257 ])
258 })
259
260 it('exposes goto-slide only when navigation is available', async () => {
261 const { client } = await createServerAndClient({ getState: () => ({ page: 1, clicks: 0 }), go: () => {} })
262 const { tools } = await client.listTools()
263 expect(tools.map(t => t.name)).toContain('slidev-goto-slide')
264 })
265
266 it('gets deck info', async () => {
267 const { client } = await createServerAndClient()
268 const info = JSON.parse(textOf(await client.callTool({ name: 'slidev-get-info', arguments: {} })))
269 expect(info.title).toBe('Test Deck')
270 expect(info.totalSlides).toBe(6)
271 expect(info.theme).toBe('default')
272 expect(info.markdownFiles).toHaveLength(2)
273 })
274
275 it('lists slides', async () => {
276 const { client } = await createServerAndClient()
277 const slides = JSON.parse(textOf(await client.callTool({ name: 'slidev-list-slides', arguments: {} })))
278 expect(slides).toHaveLength(6)
279 expect(slides[1]).toMatchObject({ no: 2, title: 'Slide 2', hasNote: true })
280 expect(slides[3]).toMatchObject({ no: 4, title: 'Sub 1', importedBySrcDirective: true })
281 })
282
283 it('gets and updates a slide', async () => {
284 const { client } = await createServerAndClient()
285 const slide = JSON.parse(textOf(await client.callTool({ name: 'slidev-get-slide', arguments: { no: 3 } })))
286 expect(slide).toMatchObject({ no: 3, title: 'Slide 3', layout: 'two-cols' })
287
288 const update = await client.callTool({ name: 'slidev-update-slide', arguments: { no: 3, content: '# Slide 3 via MCP' } })
289 expect(update.isError).toBeFalsy()
290
291 const updated = JSON.parse(textOf(await client.callTool({ name: 'slidev-get-slide', arguments: { no: 3 } })))
292 expect(updated.content).toBe('# Slide 3 via MCP')
293 })
294
295 it('returns tool errors for invalid calls', async () => {
296 const { client } = await createServerAndClient()
297 const result = await client.callTool({ name: 'slidev-get-slide', arguments: { no: 99 } })
298 expect(result.isError).toBe(true)
299 expect(textOf(result)).toMatch(/does not exist/)
300 })
301
302 it('navigates the live presentation', async () => {
303 const go = vi.fn()
304 const { client } = await createServerAndClient({ getState: () => ({ page: 2, clicks: 1 }), go })
305
306 const result = await client.callTool({ name: 'slidev-goto-slide', arguments: { no: 4 } })
307 expect(result.isError).toBeFalsy()
308 expect(go).toHaveBeenCalledWith(4, 0)
309
310 const info = JSON.parse(textOf(await client.callTool({ name: 'slidev-get-info', arguments: {} })))
311 expect(info.server).toBeUndefined() // no server url in this harness
312
313 await expect(client.callTool({ name: 'slidev-goto-slide', arguments: { no: 99 } }))
314 .resolves
315 .toMatchObject({ isError: true })
316 })
317 })
318
318 lines TYPESCRIPT