Skip to content

Commit 04a9eff

Browse files
authored
feat(cli): built-in MCP server (#2661)
1 parent c63cb12 commit 04a9eff

21 files changed

Lines changed: 1680 additions & 26 deletions

File tree

docs/builtin/cli.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ Format the markdown file. Note that this won't format the content of the slides,
8484

8585
- `[entry]` (`string`, default: `slides.md`): path to the slides markdown entry.
8686

87+
## `slidev mcp [entry]` {#mcp}
88+
89+
Start an MCP (Model Context Protocol) server over stdio for AI agents to inspect and edit the slides. See <LinkInline link="features/mcp" />.
90+
91+
- `[entry]` (`string`, default: `slides.md`): path to the slides markdown entry.
92+
8793
## `slidev theme [subcommand]` {#theme}
8894

8995
Theme-related operations.

docs/features/mcp.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
---
2+
relates:
3+
- guide/work-with-ai
4+
- Model Context Protocol: https://modelcontextprotocol.io/
5+
- features/vscode-extension
6+
since: v52.17.0
7+
tags: [editor, tool]
8+
description: |
9+
Built-in MCP server that lets AI agents inspect, edit, reorder, and navigate your slides.
10+
---
11+
12+
# MCP Server
13+
14+
Slidev ships a built-in [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) server, so any MCP-capable AI agent (Claude Code, Codex, Cursor, VS Code Copilot, etc.) can work with your slides through structured tools instead of raw text edits — reading slides, updating content and notes, inserting/removing/reordering slides, and even driving the live presentation.
15+
16+
## Via the Dev Server
17+
18+
When the dev server is running, the MCP server is available over HTTP (streamable transport) at:
19+
20+
```
21+
http://localhost:<port>/__mcp
22+
```
23+
24+
For example, register it with your agent:
25+
26+
::: code-group
27+
28+
```bash [Claude Code]
29+
claude mcp add --transport http slidev http://localhost:3030/__mcp
30+
```
31+
32+
```json [VS Code / Cursor]
33+
{
34+
"mcpServers": {
35+
"slidev": {
36+
"type": "http",
37+
"url": "http://localhost:3030/__mcp"
38+
}
39+
}
40+
}
41+
```
42+
43+
:::
44+
45+
With the dev server connected, agents can also use the `slidev-goto-slide` tool to navigate all connected browsers to a slide — handy for visually verifying a slide right after editing it. Edits made through the MCP tools are written back to your markdown files and hot-reloaded instantly.
46+
47+
To disable the endpoint, set in your headmatter:
48+
49+
```yaml
50+
---
51+
mcp: false
52+
---
53+
```
54+
55+
## Via Stdio
56+
57+
Without a dev server, you can start a standalone MCP server over stdio, operating directly on the markdown files:
58+
59+
```bash
60+
slidev mcp [entry]
61+
```
62+
63+
For example:
64+
65+
```json
66+
{
67+
"mcpServers": {
68+
"slidev": {
69+
"command": "npx",
70+
"args": ["slidev", "mcp", "slides.md"]
71+
}
72+
}
73+
}
74+
```
75+
76+
## Available Tools
77+
78+
| Tool | Description |
79+
| --------------------- | -------------------------------------------------------------------------------------------------- |
80+
| `slidev-get-info` | Deck overview: entry file, title, slide count, markdown files, dev server URL and current position |
81+
| `slidev-list-slides` | List all slides with number, title, layout, and source file |
82+
| `slidev-get-slide` | Full source of one slide: frontmatter, content, and note |
83+
| `slidev-update-slide` | Update the content, note, and/or frontmatter of a slide |
84+
| `slidev-insert-slide` | Insert a new slide after an existing one |
85+
| `slidev-remove-slide` | Remove a slide |
86+
| `slidev-move-slide` | Move a slide before/after another one to reorder the deck |
87+
| `slidev-goto-slide` | Navigate the live presentation to a slide (dev server only) |
88+
89+
Slides are addressed by their rendered 1-based number, matching the slide numbers shown in the presentation.

docs/guide/work-with-ai.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,25 @@
22

33
Thanks to Slidev being markdown-based, it works great with AI coding agents.
44

5+
## MCP Server
6+
7+
Slidev has a built-in <LinkInline link="features/mcp" /> that gives any MCP-capable agent structured tools to inspect, edit, reorder, and navigate your slides.
8+
9+
When the dev server is running, point your agent to `http://localhost:<port>/__mcp`:
10+
11+
```bash
12+
# e.g. for Claude Code
13+
claude mcp add --transport http slidev http://localhost:3030/__mcp
14+
```
15+
16+
Or run a standalone stdio server that operates on the files directly:
17+
18+
```bash
19+
slidev mcp slides.md
20+
```
21+
22+
See <LinkInline link="features/mcp" /> for the available tools and configuration.
23+
524
## Skills
625

726
Slidev provides official [skills](https://code.claude.com/docs/en/skills) for AI coding agents, enabling them to understand Slidev's syntax, features, and best practices when helping you create presentations.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"@antfu/eslint-config": "catalog:dev",
3232
"@antfu/ni": "catalog:prod",
3333
"@antfu/utils": "catalog:frontend",
34+
"@modelcontextprotocol/sdk": "catalog:prod",
3435
"@shikijs/markdown-it": "catalog:frontend",
3536
"@slidev/cli": "workspace:*",
3637
"@slidev/parser": "workspace:*",

packages/parser/src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export function getDefaultConfig(): SlidevConfig {
4545
htmlAttrs: {},
4646
transition: null,
4747
editor: true,
48+
mcp: true,
4849
contextMenu: null,
4950
wakeLock: true,
5051
remote: false,

packages/slidev/node/cli.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,18 @@ cli.command(
412412
},
413413
)
414414

415+
cli.command(
416+
'mcp [entry]',
417+
'Start an MCP (Model Context Protocol) server over stdio for AI agents to inspect and edit the slides',
418+
args => commonOptions(args)
419+
.strict()
420+
.help(),
421+
async ({ entry }) => {
422+
const { startMcpStdioServer } = await import('./mcp/stdio')
423+
await startMcpStdioServer(await resolveEntry(entry))
424+
},
425+
)
426+
415427
cli.command(
416428
'theme [subcommand]',
417429
'Theme related operations',
@@ -711,6 +723,8 @@ function printInfo(
711723
console.log(`${dim(' slides overview ')} > ${blue(`${baseUrl}${overviewPath}`)}`)
712724
if (options.utils.define.__SLIDEV_FEATURE_BROWSER_EXPORTER__)
713725
console.log(`${dim(' export slides')} > ${blue(`${baseUrl}/export/`)}`)
726+
if (options.mode === 'dev' && options.data.config.mcp !== false)
727+
console.log(`${dim(' mcp server (ai)')} > ${blue(`http://localhost:${bold(port)}/__mcp`)}`)
714728
if (options.inspect)
715729
console.log(`${dim(' vite inspector')} > ${yellow(`${baseUrl}/__inspect/`)}`)
716730

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import type { LoadedSlidevData } from '@slidev/parser/fs'
2+
import type { SlideInfo, SlidePatch, SourceSlideInfo } from '@slidev/types'
3+
import * as parser from '@slidev/parser/fs'
4+
import YAML from 'yaml'
5+
import { updateFrontmatterPatch } from '../utils'
6+
7+
/**
8+
* Resolve a rendered slide (1-based, as displayed in the presentation) or
9+
* throw a descriptive error.
10+
*/
11+
export function resolveSlide(data: LoadedSlidevData, no: number): SlideInfo {
12+
const slide = data.slides[no - 1]
13+
if (!slide)
14+
throw new Error(`Slide ${no} does not exist. The deck has ${data.slides.length} slides (1-${data.slides.length}).`)
15+
return slide
16+
}
17+
18+
function getMarkdown(data: LoadedSlidevData, source: SourceSlideInfo) {
19+
const md = data.markdownFiles[source.filepath]
20+
if (!md)
21+
throw new Error(`Markdown file not loaded: ${source.filepath}`)
22+
return md
23+
}
24+
25+
function assertNotEntryHeadmatter(data: LoadedSlidevData, source: SourceSlideInfo, action: string) {
26+
if (source.filepath === data.entry.filepath && data.entry.slides.indexOf(source) === 0) {
27+
throw new Error(
28+
`Cannot ${action} the first slide of the entry file: its frontmatter is the deck headmatter (global configuration). `
29+
+ `Edit its content with the update tool instead, or operate on the following slides.`,
30+
)
31+
}
32+
}
33+
34+
export interface SlidePatchResult {
35+
slide: SlideInfo
36+
fileContent: string
37+
}
38+
39+
/**
40+
* Apply a `SlidePatch` to the slide source and save the markdown file.
41+
*
42+
* Note this only mutates the *source* slide (`slide.source`), not the
43+
* rendered `SlideInfo`, so a running dev server will pick up the change from
44+
* disk like an external edit and push HMR updates to connected clients.
45+
*/
46+
export async function applySlidePatch(
47+
data: LoadedSlidevData,
48+
no: number,
49+
patch: SlidePatch,
50+
): Promise<SlidePatchResult> {
51+
const slide = resolveSlide(data, no)
52+
const source = slide.source
53+
54+
if (patch.content != null)
55+
source.content = patch.content
56+
if (patch.frontmatterRaw != null) {
57+
if (patch.frontmatterRaw.trim() === '') {
58+
source.frontmatterDoc = source.frontmatterStyle = undefined
59+
}
60+
else {
61+
const parsed = YAML.parseDocument(patch.frontmatterRaw)
62+
if (parsed.errors.length)
63+
throw new Error(`Invalid YAML frontmatter: ${parsed.errors.map(e => e.message).join('; ')}`)
64+
source.frontmatterDoc = parsed
65+
}
66+
}
67+
if (patch.note != null)
68+
source.note = patch.note
69+
if (patch.frontmatter)
70+
updateFrontmatterPatch(source, patch.frontmatter)
71+
72+
parser.prettifySlide(source)
73+
const fileContent = await parser.save(getMarkdown(data, source))
74+
return { slide, fileContent }
75+
}
76+
77+
export interface InsertSlideOptions {
78+
/**
79+
* Rendered slide number (1-based) after which the new slide is inserted.
80+
* The new slide is inserted into the same markdown file as this slide.
81+
*/
82+
after: number
83+
content: string
84+
frontmatter?: Record<string, any>
85+
note?: string
86+
}
87+
88+
export interface InsertSlideResult {
89+
source: SourceSlideInfo
90+
filepath: string
91+
fileContent: string
92+
}
93+
94+
/**
95+
* Insert a new slide after an existing one, in the same markdown file.
96+
*/
97+
export async function insertSlide(data: LoadedSlidevData, options: InsertSlideOptions): Promise<InsertSlideResult> {
98+
const anchor = resolveSlide(data, options.after)
99+
const md = getMarkdown(data, anchor.source)
100+
101+
const frontmatter = options.frontmatter ?? {}
102+
const hasFrontmatter = Object.keys(frontmatter).length > 0
103+
const doc = hasFrontmatter ? new YAML.Document(frontmatter) : undefined
104+
105+
const source: SourceSlideInfo = {
106+
filepath: anchor.source.filepath,
107+
index: 0, // recomputed on next load
108+
start: 0,
109+
contentStart: 0,
110+
end: 0,
111+
raw: '',
112+
revision: '',
113+
content: options.content,
114+
contentRaw: options.content,
115+
frontmatter,
116+
frontmatterDoc: doc,
117+
frontmatterStyle: doc ? 'frontmatter' : undefined,
118+
frontmatterRaw: doc?.toString(),
119+
note: options.note?.trim() || undefined,
120+
}
121+
parser.prettifySlide(source)
122+
123+
const anchorIdx = md.slides.indexOf(anchor.source)
124+
md.slides.splice(anchorIdx + 1, 0, source)
125+
const fileContent = await parser.save(md)
126+
return { source, filepath: md.filepath, fileContent }
127+
}
128+
129+
export interface RemoveSlideResult {
130+
removed: SlideInfo
131+
filepath: string
132+
fileContent: string
133+
}
134+
135+
/**
136+
* Remove a slide from its source markdown file.
137+
*/
138+
export async function removeSlide(data: LoadedSlidevData, no: number): Promise<RemoveSlideResult> {
139+
const slide = resolveSlide(data, no)
140+
assertNotEntryHeadmatter(data, slide.source, 'remove')
141+
const md = getMarkdown(data, slide.source)
142+
const idx = md.slides.indexOf(slide.source)
143+
if (idx < 0)
144+
throw new Error(`Slide ${no} is out of sync with its source file. Try again.`)
145+
md.slides.splice(idx, 1)
146+
const fileContent = await parser.save(md)
147+
return { removed: slide, filepath: md.filepath, fileContent }
148+
}
149+
150+
export interface MoveSlideOptions {
151+
/** Rendered slide number (1-based) of the slide to move */
152+
from: number
153+
/** Move the slide right before this rendered slide number */
154+
before?: number
155+
/** Move the slide right after this rendered slide number */
156+
after?: number
157+
}
158+
159+
export interface MoveSlideResult {
160+
moved: SlideInfo
161+
anchor: SlideInfo
162+
filepath: string
163+
fileContent: string
164+
}
165+
166+
/**
167+
* Move a slide before or after another slide within the same markdown file.
168+
*/
169+
export async function moveSlide(data: LoadedSlidevData, options: MoveSlideOptions): Promise<MoveSlideResult> {
170+
const { from, before, after } = options
171+
if ((before == null) === (after == null))
172+
throw new Error('Specify exactly one of `before` or `after`.')
173+
174+
const anchorNo = (before ?? after)!
175+
if (anchorNo === from)
176+
throw new Error('The `before`/`after` anchor must be a different slide than `from`.')
177+
178+
const slide = resolveSlide(data, from)
179+
const anchor = resolveSlide(data, anchorNo)
180+
181+
if (slide.source.filepath !== anchor.source.filepath) {
182+
throw new Error(
183+
`Cannot move a slide across markdown files: slide ${from} is in "${slide.source.filepath}" `
184+
+ `but slide ${anchorNo} is in "${anchor.source.filepath}" (imported with \`src:\`). `
185+
+ `Move it within its own file, or edit the \`src:\` imports in the entry file manually.`,
186+
)
187+
}
188+
189+
assertNotEntryHeadmatter(data, slide.source, 'move')
190+
if (before != null)
191+
assertNotEntryHeadmatter(data, anchor.source, 'insert a slide before')
192+
193+
const md = getMarkdown(data, slide.source)
194+
const fromIdx = md.slides.indexOf(slide.source)
195+
if (fromIdx < 0)
196+
throw new Error(`Slide ${from} is out of sync with its source file. Try again.`)
197+
md.slides.splice(fromIdx, 1)
198+
const anchorIdx = md.slides.indexOf(anchor.source)
199+
if (anchorIdx < 0)
200+
throw new Error(`Slide ${anchorNo} is out of sync with its source file. Try again.`)
201+
md.slides.splice(before != null ? anchorIdx : anchorIdx + 1, 0, slide.source)
202+
const fileContent = await parser.save(md)
203+
return { moved: slide, anchor, filepath: md.filepath, fileContent }
204+
}

0 commit comments

Comments
 (0)