| 1 | import type { Awaitable } from '@antfu/utils' |
| 2 | import type { LoadedSlidevData } from '@slidev/parser/fs' |
| 3 | import type { SlideInfo, SlidevConfig } from '@slidev/types' |
| 4 | import { McpServer } from '@modelcontextprotocol/server' |
| 5 | import { z } from 'zod' |
| 6 | import { applySlidePatch, insertSlide, moveSlide, removeSlide, resolveSlide } from './operations' |
| 7 | |
| 8 | export type SlidevMcpData = LoadedSlidevData & { config?: SlidevConfig } |
| 9 | |
| 10 | export interface SlidevMcpNav { |
| 11 | /** Current position of the live presentation */ |
| 12 | getState: () => { page: number, clicks: number } |
| 13 | /** Navigate all connected clients of the live presentation */ |
| 14 | go: (page: number, clicks: number) => void |
| 15 | } |
| 16 | |
| 17 | export interface SlidevMcpContext { |
| 18 | /** Slidev version */ |
| 19 | version: string |
| 20 | /** Absolute path of the entry markdown file */ |
| 21 | entry: string |
| 22 | /** Get the up-to-date slides data */ |
| 23 | getData: () => Awaitable<SlidevMcpData> |
| 24 | /** URL of the running dev server, if any */ |
| 25 | getServerUrl?: () => string | undefined |
| 26 | /** Live presentation navigation (only available with a running dev server) */ |
| 27 | nav?: SlidevMcpNav |
| 28 | } |
| 29 | |
| 30 | function result(data: any) { |
| 31 | return { |
| 32 | content: [{ |
| 33 | type: 'text' as const, |
| 34 | text: typeof data === 'string' ? data : JSON.stringify(data, null, 2), |
| 35 | }], |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | function slideSummary(slide: SlideInfo) { |
| 40 | return { |
| 41 | no: slide.index + 1, |
| 42 | title: slide.title ?? null, |
| 43 | ...slide.frontmatter.layout ? { layout: slide.frontmatter.layout } : {}, |
| 44 | file: slide.source.filepath, |
| 45 | hasNote: !!slide.source.note, |
| 46 | ...slide.importChain?.length ? { importedBySrcDirective: true } : {}, |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | const noSchema = z.number().int().min(1).describe('Slide number (1-based, as displayed in the presentation)') |
| 51 | const frontmatterSchema = z |
| 52 | .record(z.string(), z.any()) |
| 53 | .optional() |
| 54 | .describe('Slide frontmatter (YAML headmatter of the slide) as an object, e.g. { "layout": "two-cols" }') |
| 55 | |
| 56 | /** |
| 57 | * Create the Slidev MCP server exposing tools for agents to inspect, edit, |
| 58 | * and (with a running dev server) navigate a slides deck. |
| 59 | * |
| 60 | * The same tool set is served over the dev server HTTP endpoint (`/__mcp`) |
| 61 | * and the `slidev mcp` stdio command. |
| 62 | */ |
| 63 | export function createSlidevMcpServer(ctx: SlidevMcpContext): McpServer { |
| 64 | const server = new McpServer( |
| 65 | { |
| 66 | name: 'slidev', |
| 67 | version: ctx.version, |
| 68 | }, |
| 69 | { |
| 70 | instructions: [ |
| 71 | 'Tools for working with a Slidev (https://sli.dev) slides deck.', |
| 72 | 'A deck is a Markdown file where slides are separated by `---`; each slide can have YAML frontmatter, Markdown/Vue content, and a speaker note (trailing HTML comment).', |
| 73 | 'Slides are addressed by their rendered 1-based number, matching the slide numbers shown in the presentation.', |
| 74 | 'After editing tools run, a running dev server hot-reloads the presentation automatically.', |
| 75 | ].join('\n'), |
| 76 | }, |
| 77 | ) |
| 78 | |
| 79 | server.registerTool( |
| 80 | 'slidev-get-info', |
| 81 | { |
| 82 | title: 'Get deck info', |
| 83 | description: 'Get an overview of the Slidev deck: entry file, title, slide count, markdown files, and (when a dev server is running) the server URL and current position of the live presentation.', |
| 84 | annotations: { readOnlyHint: true }, |
| 85 | }, |
| 86 | async () => { |
| 87 | const data = await ctx.getData() |
| 88 | const nav = ctx.nav?.getState() |
| 89 | return result({ |
| 90 | slidevVersion: ctx.version, |
| 91 | entry: ctx.entry, |
| 92 | title: data.headmatter.title ?? data.slides[0]?.title ?? null, |
| 93 | theme: data.config?.theme ?? data.headmatter.theme ?? null, |
| 94 | totalSlides: data.slides.length, |
| 95 | markdownFiles: Object.keys(data.markdownFiles), |
| 96 | ...ctx.getServerUrl?.() |
| 97 | ? { |
| 98 | server: { |
| 99 | url: ctx.getServerUrl(), |
| 100 | // page 0 means no client has connected yet |
| 101 | currentPage: nav?.page || null, |
| 102 | currentClicks: nav?.page ? nav.clicks : null, |
| 103 | }, |
| 104 | } |
| 105 | : {}, |
| 106 | }) |
| 107 | }, |
| 108 | ) |
| 109 | |
| 110 | server.registerTool( |
| 111 | 'slidev-list-slides', |
| 112 | { |
| 113 | title: 'List slides', |
| 114 | description: 'List all slides of the deck with their number, title, layout, and source file. Slides hidden with `hide`/`disabled` frontmatter are not included.', |
| 115 | annotations: { readOnlyHint: true }, |
| 116 | }, |
| 117 | async () => { |
| 118 | const data = await ctx.getData() |
| 119 | return result(data.slides.map(slideSummary)) |
| 120 | }, |
| 121 | ) |
| 122 | |
| 123 | server.registerTool( |
| 124 | 'slidev-get-slide', |
| 125 | { |
| 126 | title: 'Get slide', |
| 127 | description: 'Get the full source of one slide: frontmatter, Markdown content, and speaker note.', |
| 128 | inputSchema: z.object({ no: noSchema }), |
| 129 | annotations: { readOnlyHint: true }, |
| 130 | }, |
| 131 | async ({ no }) => { |
| 132 | const data = await ctx.getData() |
| 133 | const slide = resolveSlide(data, no) |
| 134 | return result({ |
| 135 | ...slideSummary(slide), |
| 136 | frontmatter: slide.source.frontmatter, |
| 137 | content: slide.source.content.trim(), |
| 138 | note: slide.source.note ?? null, |
| 139 | ...slide.importChain?.length |
| 140 | ? { importedBy: slide.importChain.map(s => `${s.filepath}#${s.index + 1}`) } |
| 141 | : {}, |
| 142 | }) |
| 143 | }, |
| 144 | ) |
| 145 | |
| 146 | server.registerTool( |
| 147 | 'slidev-update-slide', |
| 148 | { |
| 149 | title: 'Update slide', |
| 150 | description: 'Update the content, speaker note, and/or frontmatter of a slide. Only the provided fields are changed. Pass an empty string to clear the content or note. In `frontmatter`, only the given keys are patched; pass `null` as a value to delete that key.', |
| 151 | inputSchema: z.object({ |
| 152 | no: noSchema, |
| 153 | content: z.string().optional().describe('New Markdown content of the slide (without frontmatter and note)'), |
| 154 | note: z.string().optional().describe('New speaker note (Markdown, stored as a trailing HTML comment)'), |
| 155 | frontmatter: frontmatterSchema, |
| 156 | }), |
| 157 | }, |
| 158 | async ({ no, content, note, frontmatter }) => { |
| 159 | if (content == null && note == null && frontmatter == null) |
| 160 | throw new Error('Nothing to update: provide at least one of `content`, `note`, or `frontmatter`.') |
| 161 | const data = await ctx.getData() |
| 162 | const { slide } = await applySlidePatch(data, no, { content, note, frontmatter }) |
| 163 | return result(`Updated slide ${no} in ${slide.source.filepath}.`) |
| 164 | }, |
| 165 | ) |
| 166 | |
| 167 | server.registerTool( |
| 168 | 'slidev-insert-slide', |
| 169 | { |
| 170 | title: 'Insert slide', |
| 171 | description: 'Insert a new slide after an existing slide (into the same markdown file). To add a slide at the very end, pass the last slide number.', |
| 172 | inputSchema: z.object({ |
| 173 | after: z.number().int().min(1).describe('Slide number (1-based) after which the new slide is inserted'), |
| 174 | content: z.string().describe('Markdown content of the new slide'), |
| 175 | frontmatter: frontmatterSchema, |
| 176 | note: z.string().optional().describe('Speaker note of the new slide'), |
| 177 | }), |
| 178 | }, |
| 179 | async ({ after, content, frontmatter, note }) => { |
| 180 | const data = await ctx.getData() |
| 181 | const { filepath } = await insertSlide(data, { after, content, frontmatter, note }) |
| 182 | return result(`Inserted a new slide after slide ${after} in ${filepath}. Slide numbers after it have shifted; list the slides again if needed.`) |
| 183 | }, |
| 184 | ) |
| 185 | |
| 186 | server.registerTool( |
| 187 | 'slidev-remove-slide', |
| 188 | { |
| 189 | title: 'Remove slide', |
| 190 | description: 'Remove a slide from the deck (deletes it from its source markdown file).', |
| 191 | inputSchema: z.object({ no: noSchema }), |
| 192 | annotations: { destructiveHint: true }, |
| 193 | }, |
| 194 | async ({ no }) => { |
| 195 | const data = await ctx.getData() |
| 196 | const { removed, filepath } = await removeSlide(data, no) |
| 197 | return result(`Removed slide ${no}${removed.title ? ` ("${removed.title}")` : ''} from ${filepath}. Slide numbers after it have shifted; list the slides again if needed.`) |
| 198 | }, |
| 199 | ) |
| 200 | |
| 201 | server.registerTool( |
| 202 | 'slidev-move-slide', |
| 203 | { |
| 204 | title: 'Move slide', |
| 205 | description: 'Move a slide before or after another slide to reorder the deck. Both slides must be in the same markdown file. To swap two adjacent slides, move one after the other.', |
| 206 | inputSchema: z.object({ |
| 207 | from: z.number().int().min(1).describe('Slide number (1-based) of the slide to move'), |
| 208 | before: z.number().int().min(1).optional().describe('Move the slide right before this slide number'), |
| 209 | after: z.number().int().min(1).optional().describe('Move the slide right after this slide number'), |
| 210 | }), |
| 211 | }, |
| 212 | async ({ from, before, after }) => { |
| 213 | const data = await ctx.getData() |
| 214 | const { filepath } = await moveSlide(data, { from, before, after }) |
| 215 | return result(`Moved slide ${from} ${before != null ? `before slide ${before}` : `after slide ${after}`} in ${filepath}. Slide numbers have shifted; list the slides again if needed.`) |
| 216 | }, |
| 217 | ) |
| 218 | |
| 219 | if (ctx.nav) { |
| 220 | const nav = ctx.nav |
| 221 | server.registerTool( |
| 222 | 'slidev-goto-slide', |
| 223 | { |
| 224 | title: 'Go to slide', |
| 225 | description: 'Navigate the live presentation (all connected browsers) to a given slide, e.g. to visually verify a slide after editing it.', |
| 226 | inputSchema: z.object({ |
| 227 | no: noSchema, |
| 228 | clicks: z.number().int().min(0).optional().describe('Click animation step to reveal (defaults to 0)'), |
| 229 | }), |
| 230 | annotations: { idempotentHint: true }, |
| 231 | }, |
| 232 | async ({ no, clicks }) => { |
| 233 | const data = await ctx.getData() |
| 234 | resolveSlide(data, no) // range check |
| 235 | nav.go(no, clicks ?? 0) |
| 236 | return result(`Navigated the presentation to slide ${no}${clicks ? ` (click ${clicks})` : ''}.`) |
| 237 | }, |
| 238 | ) |
| 239 | } |
| 240 | |
| 241 | return server |
| 242 | } |
| 243 |