| 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 |