返回 slidev
server.ts
根目录 / packages / slidev / node / mcp / server.ts
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
243 lines TYPESCRIPT