返回 slidev
mcp.ts
根目录 / packages / slidev / node / vite / mcp.ts
1 import type { ResolvedSlidevOptions } from '@slidev/types'
2 import type { Plugin, ViteDevServer } from 'vite'
3 import type { SlidevMcpContext } from '../mcp/server'
4 import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'
5 import { version } from '../../package.json'
6 import { createSlidevMcpServer } from '../mcp/server'
7 import { getServerRefState } from './serverRef'
8
9 export const MCP_ENDPOINT = '/__mcp'
10
11 // Keep in sync with `vite-plugin-vue-server-ref` internals
12 const SERVER_REF_WS_EVENT = 'vue-server-ref'
13 const SERVER_REF_NAV_MODULE_IDS = ['/@server-reactive/nav', '/@server-ref/nav']
14
15 /**
16 * Serves the Slidev MCP (Model Context Protocol) server on the dev server at
17 * `/__mcp` (streamable HTTP transport), so AI agents can inspect, edit, and
18 * navigate the slides. Can be disabled with `mcp: false` in the headmatter.
19 */
20 export function createMcpPlugin(
21 options: ResolvedSlidevOptions,
22 ): Plugin {
23 return {
24 name: 'slidev:mcp',
25 apply: 'serve',
26
27 configureServer(server) {
28 if (options.mode !== 'dev')
29 return
30
31 const ctx: SlidevMcpContext = {
32 version,
33 entry: options.entry,
34 getData: () => options.data,
35 getServerUrl: () => server.resolvedUrls?.local[0],
36 nav: {
37 getState: () => {
38 const nav = getServerRefState(options)?.nav
39 return { page: nav?.page ?? 0, clicks: nav?.clicks ?? 0 }
40 },
41 go: (page, clicks) => navigateClients(server, options, page, clicks),
42 },
43 }
44
45 server.middlewares.use(async (req, res, next) => {
46 const path = req.url?.split('?')[0]?.replace(/\/$/, '')
47 if (path !== MCP_ENDPOINT)
48 return next()
49
50 if (options.data.config.mcp === false) {
51 res.statusCode = 403
52 res.setHeader('Content-Type', 'application/json')
53 res.end(JSON.stringify({
54 jsonrpc: '2.0',
55 error: { code: -32000, message: 'The Slidev MCP server is disabled (`mcp: false` in the headmatter)' },
56 id: null,
57 }))
58 return
59 }
60
61 try {
62 // Stateless mode: a fresh server + transport pair per request
63 const mcp = createSlidevMcpServer(ctx)
64 const transport = new NodeStreamableHTTPServerTransport({
65 sessionIdGenerator: undefined,
66 })
67 res.on('close', () => {
68 transport.close()
69 mcp.close()
70 })
71 await mcp.connect(transport)
72 await transport.handleRequest(req, res)
73 }
74 catch (e) {
75 console.error('[slidev] Error handling MCP request:', e)
76 if (!res.headersSent) {
77 res.statusCode = 500
78 res.setHeader('Content-Type', 'application/json')
79 res.end(JSON.stringify({
80 jsonrpc: '2.0',
81 error: { code: -32603, message: 'Internal server error' },
82 id: null,
83 }))
84 }
85 }
86 })
87 },
88 }
89 }
90
91 /**
92 * Drive all connected clients to the given slide by patching the shared
93 * `nav` state, mimicking what `vite-plugin-vue-server-ref` does when a
94 * presenter or remote-control client navigates.
95 */
96 function navigateClients(
97 server: ViteDevServer,
98 options: ResolvedSlidevOptions,
99 page: number,
100 clicks: number,
101 ) {
102 const time = Date.now()
103
104 const nav = getServerRefState(options)?.nav
105 if (nav) {
106 nav.page = page
107 nav.clicks = clicks
108 nav.lastUpdate = { id: 'slidev-mcp', type: 'presenter', time }
109 }
110
111 // Refresh the initial state served to newly loaded clients
112 for (const id of SERVER_REF_NAV_MODULE_IDS) {
113 const module = server.moduleGraph.getModuleById(id)
114 if (module)
115 server.moduleGraph.invalidateModule(module)
116 }
117
118 // Clients ignore patches attributed to their own role (presenter/viewer),
119 // so send one patch as each role to reach both kinds of clients.
120 for (const type of ['presenter', 'viewer'] as const) {
121 server.hot.send({
122 type: 'custom',
123 event: SERVER_REF_WS_EVENT,
124 data: {
125 key: 'nav',
126 source: 'slidev-mcp',
127 patch: {
128 page,
129 clicks,
130 lastUpdate: { id: 'slidev-mcp', type, time },
131 },
132 timestamp: time,
133 },
134 })
135 }
136 }
137
137 lines TYPESCRIPT