@lincyaw/mcp 0.0.0-stage → 1.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/host.ts ADDED
@@ -0,0 +1,73 @@
1
+ import type {
2
+ AgentMode,
3
+ AgentTool,
4
+ ApprovalKind,
5
+ ApprovalRequest,
6
+ Finding,
7
+ Focus,
8
+ IRDocument,
9
+ Member,
10
+ RunResult,
11
+ ServiceToken,
12
+ } from '@lincyaw/kernel'
13
+
14
+ export interface McpCaller {
15
+ member: Member
16
+ mode: AgentMode
17
+ confirmed?: boolean
18
+ document?: string
19
+ }
20
+
21
+ export interface McpFocus {
22
+ document: string
23
+ focus: Focus[]
24
+ at: number
25
+ }
26
+
27
+ export interface McpDocument {
28
+ dir: string
29
+ title: string
30
+ lang?: string
31
+ tags?: string[]
32
+ }
33
+
34
+ export interface McpPosition {
35
+ document: string
36
+ member: string
37
+ slide: number
38
+ section?: string
39
+ step: number
40
+ trail?: string[]
41
+ view?: string
42
+ role?: string
43
+ at: number
44
+ }
45
+
46
+ export interface McpPluginTools {
47
+ tools: AgentTool[]
48
+ service<T>(token: ServiceToken<T>): T | undefined
49
+ }
50
+
51
+ export interface McpProcess {
52
+ command: 'dev' | 'serve' | 'presmium-mcp'
53
+ }
54
+
55
+ export interface McpHost {
56
+ process?: McpProcess
57
+ documents(): Promise<McpDocument[]>
58
+ compile(document: string): Promise<{ doc: IRDocument, diagnostics: Finding[] }>
59
+ check(document: string): Promise<Finding[]>
60
+ build(document: string, options: { to?: string[] }): Promise<{ outputs: { renderer: string, outDir: string }[], findings: Finding[] }>
61
+ run(document: string, target: { section: string, block: string }, signal?: AbortSignal): Promise<RunResult>
62
+ approve(request: ApprovalRequest, signal?: AbortSignal): Promise<boolean>
63
+ offers(kind: ApprovalKind): boolean
64
+ approval?(kind: ApprovalKind): 'off' | 'ask' | 'allow'
65
+ tools?(document?: string): Promise<McpPluginTools>
66
+ positions?(document: string | undefined, member: Member): McpPosition[]
67
+ watchPositions?(listener: () => void): () => void
68
+ focus?(document?: string): McpFocus | undefined
69
+ watchFocus?(listener: () => void): () => void
70
+ classroom?(document: string): Record<string, unknown>
71
+ navigate?(document: string, position: { slide: number, step: number, view?: string, section?: string, trail?: string[] }, member: Member): number
72
+ session?(document: string, action: 'start' | 'end', name: string | undefined, member: Member): number
73
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export type { McpCaller, McpDocument, McpFocus, McpHost, McpPluginTools, McpPosition, McpProcess } from './host.ts'
2
+ export { EXCERPT_LINES, excerptOf, focusReport, resolveFocus } from './focus.ts'
3
+ export { createPresmiumMcp, FOCUS_URI, listTools, MCP_VERSION, OPERATE_TOOLS, POSITION_URI, presmiumServer, READ_TOOLS, serveMcpStdio, TOOL_APPROVAL_META, TOOL_KIND_META, TOOL_TABLE } from './mcp.ts'
4
+ export type { McpToolInfo, PresmiumMcp } from './mcp.ts'
package/src/mcp.ts ADDED
@@ -0,0 +1,560 @@
1
+ import { createRequire } from 'node:module'
2
+ import { createMcpHandler, fromJsonSchema, McpServer, ResourceTemplate, UriTemplate } from '@modelcontextprotocol/server'
3
+ import type { AuthInfo, CallToolResult, JsonSchemaType, Variables } from '@modelcontextprotocol/server'
4
+ import { serveStdio } from '@modelcontextprotocol/server/stdio'
5
+ import { deckLocale, docBlocks, errorMessage, isRecord, stepKeys, stepTrail, trailStep } from '@lincyaw/kernel'
6
+ import type { AgentTool, ApprovalKind, ApprovalScope, CodeNode, Finding, IRDocument, Member, MemberRole } from '@lincyaw/kernel'
7
+ import { codeRef, deckInfo, diagnosticsOf, findBlock, findSection, locate, outputsOf, place, runInfo, search, sectionDetail, sectionSummary, stepDetail } from './deck.ts'
8
+ import type { McpCaller, McpDocument, McpHost, McpProcess } from './host.ts'
9
+ import { MCP_MESSAGES } from './messages.ts'
10
+ import type { McpMessages } from './messages.ts'
11
+
12
+ export const MCP_VERSION: string = (createRequire(import.meta.url)('../package.json') as { version: string }).version
13
+ export const POSITION_URI = 'presmium://position'
14
+ export const FOCUS_URI = 'presmium://focus'
15
+
16
+ export const READ_TOOLS = [
17
+ 'list_documents', 'deck_info', 'list_sections', 'get_section', 'get_step', 'search',
18
+ 'get_code_ref', 'get_outputs', 'get_diagnostics', 'locate_source', 'get_position', 'get_classroom', 'get_focus',
19
+ ] as const
20
+
21
+ export const OPERATE_TOOLS = ['navigate', 'run_code', 'build', 'classroom_start', 'classroom_end'] as const
22
+
23
+ type Args = Record<string, unknown>
24
+
25
+ interface Call {
26
+ host: McpHost
27
+ caller: McpCaller
28
+ args: Args
29
+ signal: AbortSignal
30
+ document(): Promise<string>
31
+ compiled(): Promise<{ doc: IRDocument, diagnostics: Finding[] }>
32
+ approve(kind: ApprovalKind, title: (messages: McpMessages) => string, options?: { scope?: ApprovalScope, detail?: string }): Promise<void>
33
+ }
34
+
35
+ interface ToolSpec {
36
+ name: string
37
+ description: string
38
+ properties?: Record<string, unknown>
39
+ required?: string[]
40
+ kind: 'read' | ApprovalKind
41
+ run(call: Call): Promise<unknown>
42
+ }
43
+
44
+ export interface McpToolInfo {
45
+ name: string
46
+ description: string
47
+ inputSchema: Record<string, unknown>
48
+ kind: 'read' | ApprovalKind
49
+ }
50
+
51
+ export const TOOL_KIND_META = 'presmium/kind'
52
+ export const TOOL_APPROVAL_META = 'presmium/approval'
53
+
54
+ const DOCUMENT = { document: { type: 'string', description: 'The document directory (list_documents); omit it when the host serves one document.' } }
55
+ const SECTION = { section: { type: 'string', description: 'The section id (list_sections), or its 1-based number.' } }
56
+ const STEP = { step: { type: 'integer', minimum: 0, description: 'The step, from 0 (the section\'s initial state).' } }
57
+ const BLOCK = { block: { type: 'string', description: 'The block id (get_section).' } }
58
+
59
+ const rank: Record<MemberRole, number> = { reader: 0, editor: 1, owner: 2 }
60
+ const hasRole = (member: Member, role: MemberRole) => member.roles.some(own => rank[own] >= rank[role])
61
+
62
+ function operates(caller: McpCaller): boolean {
63
+ return hasRole(caller.member, 'editor')
64
+ }
65
+
66
+ const str = (value: unknown) => (typeof value === 'string' && value !== '' ? value : undefined)
67
+ const int = (value: unknown) => (typeof value === 'number' && Number.isInteger(value) ? value : undefined)
68
+
69
+ function focus(call: Call, document?: string) {
70
+ const found = call.host.focus?.(document)
71
+ return found ? { document: found.document, focus: found.focus, reported: new Date(found.at).toISOString() } : { focus: null }
72
+ }
73
+
74
+ async function position(call: Call, document?: string) {
75
+ const found = call.host.positions?.(document, call.caller.member)[0]
76
+ if (!found) return { position: null }
77
+ const { doc } = await call.host.compile(found.document)
78
+ const id = found.section ?? doc.sections.find(s => s.index === found.slide)?.id
79
+ const section = id === undefined ? undefined : doc.sections.find(s => s.id === id)
80
+ const keys = section && found.trail ? stepKeys(section) : undefined
81
+ const step = keys && stepTrail(keys, found.step).join('\n') !== found.trail!.join('\n') ? trailStep(keys, found.trail!) : found.step
82
+ return {
83
+ position: {
84
+ document: found.document,
85
+ section: section?.id ?? null,
86
+ title: section?.title ?? null,
87
+ step,
88
+ steps: section?.steps ?? null,
89
+ ...found.view ? { view: found.view } : {},
90
+ ...found.role ? { role: found.role } : {},
91
+ reported: new Date(found.at).toISOString(),
92
+ },
93
+ }
94
+ }
95
+
96
+ const TOOLS: ToolSpec[] = [
97
+ {
98
+ name: 'list_documents',
99
+ description: 'The documents (decks) this host serves: directory, title, language, tags.',
100
+ kind: 'read',
101
+ run: async ({ host }) => ({ documents: (await host.documents()).map(d => ({ document: d.dir, title: d.title, ...d.lang ? { lang: d.lang } : {}, ...d.tags?.length ? { tags: d.tags } : {} })) }),
102
+ },
103
+ {
104
+ name: 'deck_info',
105
+ description: 'A deck at a glance: title, language, entry file, numbers of sections and steps, plugins, renderers, counts of diagnostics.',
106
+ properties: { ...DOCUMENT },
107
+ kind: 'read',
108
+ run: async call => deckInfo((await call.compiled()).doc, await call.document()),
109
+ },
110
+ {
111
+ name: 'list_sections',
112
+ description: 'The sections in order: id, number, title, heading depth, number of steps and the source position.',
113
+ properties: { ...DOCUMENT },
114
+ kind: 'read',
115
+ run: async (call) => {
116
+ const { doc } = await call.compiled()
117
+ return { sections: doc.sections.map(s => sectionSummary(doc, s)) }
118
+ },
119
+ },
120
+ {
121
+ name: 'get_section',
122
+ description: 'A section: its narration by step, and every block on screen with its text, code, code reference, renderer limits (only) and source position.',
123
+ properties: { ...DOCUMENT, ...SECTION },
124
+ required: ['section'],
125
+ kind: 'read',
126
+ run: async (call) => {
127
+ const { doc } = await call.compiled()
128
+ return sectionDetail(doc, findSection(doc, str(call.args.section) ?? ''))
129
+ },
130
+ },
131
+ {
132
+ name: 'get_step',
133
+ description: 'One step of a section: what is on screen at that step (with highlighted lines and code versions) and the narration that belongs to it.',
134
+ properties: { ...DOCUMENT, ...SECTION, ...STEP },
135
+ required: ['section'],
136
+ kind: 'read',
137
+ run: async (call) => {
138
+ const { doc } = await call.compiled()
139
+ return stepDetail(doc, findSection(doc, str(call.args.section) ?? ''), int(call.args.step) ?? 0)
140
+ },
141
+ },
142
+ {
143
+ name: 'search',
144
+ description: 'Searches the deck with the web form\'s search index (titles, narration, prose and code of each section); returns sections by relevance.',
145
+ properties: { ...DOCUMENT, query: { type: 'string' }, limit: { type: 'integer', minimum: 1, maximum: 50 } },
146
+ required: ['query'],
147
+ kind: 'read',
148
+ run: async call => search((await call.compiled()).doc, str(call.args.query) ?? '', int(call.args.limit)),
149
+ },
150
+ {
151
+ name: 'get_code_ref',
152
+ description: 'A code block: its code, and the code of the workspace it references (anchor, file, lines, resolution status, drift, permalink).',
153
+ properties: { ...DOCUMENT, ...SECTION, ...BLOCK },
154
+ required: ['section', 'block'],
155
+ kind: 'read',
156
+ run: async (call) => {
157
+ const { doc } = await call.compiled()
158
+ return codeRef(doc, findSection(doc, str(call.args.section) ?? ''), str(call.args.block) ?? '')
159
+ },
160
+ },
161
+ {
162
+ name: 'get_outputs',
163
+ description: 'The recorded outputs of a section\'s code blocks (or of one block): streams, exit, rich outputs (binary data named only).',
164
+ properties: { ...DOCUMENT, ...SECTION, ...BLOCK },
165
+ required: ['section'],
166
+ kind: 'read',
167
+ run: async (call) => {
168
+ const { doc } = await call.compiled()
169
+ return outputsOf(findSection(doc, str(call.args.section) ?? ''), str(call.args.block))
170
+ },
171
+ },
172
+ {
173
+ name: 'get_diagnostics',
174
+ description: 'The diagnostics of the compilation and the plugins\' checks, with their source positions.',
175
+ properties: { ...DOCUMENT },
176
+ kind: 'read',
177
+ run: async (call) => {
178
+ const { doc } = await call.compiled()
179
+ return { diagnostics: diagnosticsOf(doc, await call.host.check(await call.document())) }
180
+ },
181
+ },
182
+ {
183
+ name: 'locate_source',
184
+ description: 'Where a section, the blocks of a step, a block, or the code blocks referencing an anchor are in the source: file (relative to the deck directory) and lines.',
185
+ properties: { ...DOCUMENT, ...SECTION, ...STEP, ...BLOCK, anchor: { type: 'string', description: 'A code anchor such as region:src/a.py#name.' } },
186
+ kind: 'read',
187
+ run: async (call) => {
188
+ const { doc } = await call.compiled()
189
+ return locate(doc, { section: str(call.args.section), step: int(call.args.step), block: str(call.args.block), anchor: str(call.args.anchor) })
190
+ },
191
+ },
192
+ {
193
+ name: 'get_position',
194
+ description: 'Where the presentation is: the section and step the presenting windows last reported (yours first), or null when no window shows the deck.',
195
+ properties: { ...DOCUMENT },
196
+ kind: 'read',
197
+ run: async call => position(call, str(call.args.document)),
198
+ },
199
+ {
200
+ name: 'get_classroom',
201
+ description: 'The classroom\'s summaries that the open pages report, by plugin (no learner identities).',
202
+ properties: { ...DOCUMENT },
203
+ kind: 'read',
204
+ run: async call => ({ classroom: call.host.classroom?.(await call.document()) ?? {} }),
205
+ },
206
+ {
207
+ name: 'get_focus',
208
+ description: 'What the author points at in the workbench (a selection of the source, blocks picked in a preview): each focus with its source range, the excerpt read from the file, its section, block and step; null when nothing is pointed at.',
209
+ properties: { document: { type: 'string', description: 'The document directory (list_documents); omit it for the latest focus of any document.' } },
210
+ kind: 'read',
211
+ run: async call => focus(call, str(call.args.document) === undefined ? call.caller.document : await call.document()),
212
+ },
213
+ {
214
+ name: 'navigate',
215
+ description: 'Moves the presentation (the windows presenting the deck) to a section and step, once a person allows it.',
216
+ properties: { ...DOCUMENT, ...SECTION, ...STEP },
217
+ required: ['section'],
218
+ kind: 'operate',
219
+ run: async (call) => {
220
+ const document = await call.document()
221
+ const { doc } = await call.compiled()
222
+ const section = findSection(doc, str(call.args.section) ?? '')
223
+ const step = Math.min(int(call.args.step) ?? 0, section.steps)
224
+ const own = call.host.positions?.(document, call.caller.member).filter(p => p.member === call.caller.member.id) ?? []
225
+ if (own.length === 0) throw new Error('none of your windows shows the deck: open it first')
226
+ await call.approve('operate', m => m.navigate(section.title, step))
227
+ const windows = call.host.navigate!(document, { slide: section.index, step, section: section.id, trail: stepTrail(stepKeys(section), step) }, call.caller.member)
228
+ if (windows === 0) throw new Error('none of your windows shows the deck')
229
+ return { section: section.id, step, windows }
230
+ },
231
+ },
232
+ {
233
+ name: 'run_code',
234
+ description: 'Runs one code block now with the host\'s executors (recordings are neither read nor written), once a person allows it; returns its output.',
235
+ properties: { ...DOCUMENT, ...SECTION, ...BLOCK },
236
+ required: ['section', 'block'],
237
+ kind: 'operate',
238
+ run: async (call) => {
239
+ const document = await call.document()
240
+ const { doc } = await call.compiled()
241
+ const section = findSection(doc, str(call.args.section) ?? '')
242
+ const block = findBlock(section, str(call.args.block) ?? '')
243
+ if (block.node.type !== 'code') throw new Error(`block ${block.id} of section ${section.id} is not code`)
244
+ await call.approve('operate', m => m.run(section.title, block.id), { scope: 'content', detail: runPlan(doc, block.node) })
245
+ return { section: section.id, block: block.id, ...runInfo(await call.host.run(document, { section: section.id, block: block.id }, call.signal)) }
246
+ },
247
+ },
248
+ {
249
+ name: 'build',
250
+ description: 'Builds the deck with its renderers (or those named), once a person allows it; returns the output directories and the findings.',
251
+ properties: { ...DOCUMENT, to: { type: 'array', items: { type: 'string' }, description: 'Renderer ids, e.g. ["web"]; default: the deck\'s renderers.' } },
252
+ kind: 'operate',
253
+ run: async (call) => {
254
+ const document = await call.document()
255
+ const to = Array.isArray(call.args.to) ? call.args.to.filter((id): id is string => typeof id === 'string') : undefined
256
+ await call.approve('operate', m => m.build(to?.join(', ') || m.allRenderers))
257
+ const { doc } = await call.compiled()
258
+ const report = await call.host.build(document, { to })
259
+ return {
260
+ outputs: report.outputs.map(o => ({ renderer: o.renderer, outDir: place(doc, { file: o.outDir, line: 1 }).file })),
261
+ diagnostics: diagnosticsOf(doc, report.findings),
262
+ }
263
+ },
264
+ },
265
+ {
266
+ name: 'classroom_start',
267
+ description: 'Starts the classroom in the presenting windows, once a person allows it.',
268
+ properties: { ...DOCUMENT, name: { type: 'string', description: 'The classroom\'s name.' } },
269
+ kind: 'operate',
270
+ run: async call => classroom(call, 'start'),
271
+ },
272
+ {
273
+ name: 'classroom_end',
274
+ description: 'Ends the classroom in the presenting windows, once a person allows it.',
275
+ properties: { ...DOCUMENT },
276
+ kind: 'operate',
277
+ run: async call => classroom(call, 'end'),
278
+ },
279
+ ]
280
+
281
+ async function classroom(call: Call, action: 'start' | 'end') {
282
+ const document = await call.document()
283
+ const presenting = call.host.positions?.(document, call.caller.member).filter(p => p.member === call.caller.member.id && (p.role === 'presenter' || p.role === 'audience')) ?? []
284
+ if (presenting.length === 0) throw new Error('none of your windows presents the deck: open its present or presenter view first')
285
+ const name = str(call.args.name) ?? ''
286
+ await call.approve('operate', m => (action === 'start' ? m.classroomStart(name) : m.classroomEnd))
287
+ return { classroom: action, windows: call.host.session!(document, action, name || undefined, call.caller.member) }
288
+ }
289
+
290
+ function runPlan(doc: IRDocument, node: CodeNode): string {
291
+ const nodes: { id: string, code: CodeNode }[] = []
292
+ const session = node.run?.session
293
+ for (const { block } of docBlocks(doc)) {
294
+ if (block.node === node) {
295
+ nodes.push({ id: block.id, code: node })
296
+ break
297
+ }
298
+ if (session && block.node.type === 'code' && block.node.run?.session === session && block.node.run.when !== 'live') nodes.push({ id: block.id, code: block.node })
299
+ }
300
+ const one = ({ id, code }: { id: string, code: CodeNode }) => [
301
+ nodes.length > 1 ? `# ${id}` : '',
302
+ code.run?.args && Object.keys(code.run.args).length ? `# args: ${JSON.stringify(code.run.args)}` : '',
303
+ code.run?.stdin !== undefined ? `# stdin: ${JSON.stringify(code.run.stdin)}` : '',
304
+ code.value,
305
+ ].filter(Boolean).join('\n')
306
+ return nodes.map(one).join('\n\n')
307
+ }
308
+
309
+ function supported(host: McpHost, name: string): boolean {
310
+ switch (name) {
311
+ case 'get_position': return Boolean(host.positions)
312
+ case 'get_classroom': return Boolean(host.classroom)
313
+ case 'navigate': return Boolean(host.positions && host.navigate)
314
+ case 'classroom_start':
315
+ case 'classroom_end': return Boolean(host.positions && host.session)
316
+ default: return true
317
+ }
318
+ }
319
+
320
+ function json(data: unknown): string {
321
+ return typeof data === 'string' ? data : JSON.stringify(data, null, 2)
322
+ }
323
+
324
+ async function answer(run: () => Promise<unknown>): Promise<CallToolResult> {
325
+ try {
326
+ return { content: [{ type: 'text', text: json(await run()) }] }
327
+ } catch (error) {
328
+ return { isError: true, content: [{ type: 'text', text: errorMessage(error) }] }
329
+ }
330
+ }
331
+
332
+ function schemaOf(properties: Record<string, unknown> = {}, required: string[] = []): Record<string, unknown> {
333
+ return { type: 'object', properties, ...required.length ? { required } : {} }
334
+ }
335
+
336
+ export const TOOL_TABLE: readonly McpToolInfo[] = TOOLS.map(tool => ({ name: tool.name, description: tool.description, inputSchema: schemaOf(tool.properties, tool.required), kind: tool.kind }))
337
+
338
+ function pluginInfo(tool: AgentTool): McpToolInfo {
339
+ const schema = isRecord(tool.input) ? tool.input : {}
340
+ return { name: tool.name, description: tool.description, inputSchema: { ...schema, type: 'object', properties: { ...DOCUMENT, ...isRecord(schema.properties) ? schema.properties : {} } }, kind: tool.kind }
341
+ }
342
+
343
+ interface Offered {
344
+ builtIn: ToolSpec[]
345
+ plugins: AgentTool[]
346
+ documents: McpDocument[]
347
+ }
348
+
349
+ async function offer(host: McpHost, caller: McpCaller): Promise<Offered> {
350
+ const documents = await host.documents().catch(() => [])
351
+ const only = caller.document ?? (documents.length === 1 ? documents[0]!.dir : undefined)
352
+ const offered = (kind: 'read' | ApprovalKind) => kind === 'read' || (operates(caller) && host.offers(kind))
353
+ const builtIn = TOOLS.filter(tool => offered(tool.kind) && supported(host, tool.name))
354
+ const plugins: AgentTool[] = []
355
+ const names = new Set(TOOLS.map(tool => tool.name))
356
+ for (const tool of (await host.tools?.(only).catch(() => undefined))?.tools ?? []) {
357
+ if (names.has(tool.name) || !offered(tool.kind)) continue
358
+ names.add(tool.name)
359
+ plugins.push(tool)
360
+ }
361
+ return { builtIn, plugins, documents }
362
+ }
363
+
364
+ export async function listTools(host: McpHost, caller: McpCaller, document = caller.document): Promise<McpToolInfo[]> {
365
+ const { builtIn, plugins } = await offer(host, document === undefined ? caller : { ...caller, document })
366
+ return [...builtIn.map(tool => TOOL_TABLE.find(info => info.name === tool.name)!), ...plugins.map(pluginInfo)]
367
+ }
368
+
369
+ function recompiles(process: McpProcess | undefined): string {
370
+ switch (process?.command) {
371
+ case 'dev': return 'presmium dev compiles the deck again on save.'
372
+ case 'serve': return 'presmium serve compiles a document again when its files change.'
373
+ case 'presmium-mcp': return 'presmium-mcp compiles the deck again at the next call once its files changed.'
374
+ default: return 'The host compiles the deck again when its files change.'
375
+ }
376
+ }
377
+
378
+ function instructions(host: McpHost, caller: McpCaller, tools: { name: string, kind: 'read' | ApprovalKind }[], title?: string): string {
379
+ const pages = Boolean(host.positions)
380
+ const reads = tools.filter(tool => tool.kind === 'read').map(tool => tool.name)
381
+ const operations = tools.filter(tool => tool.kind !== 'read').map(tool => tool.name)
382
+ return [
383
+ `Presmium: ${title ? `the lecture deck "${title}"` : 'lecture decks'} as tools.`,
384
+ `Read tools: ${reads.join(', ')}.`,
385
+ `The same data is offered as resources: presmium://documents, presmium://deck, presmium://sections, presmium://sections/{section}, presmium://sections/{section}/steps/{step}, presmium://sections/{section}/outputs, presmium://diagnostics, presmium://ir (the compiled deck)${pages ? ', presmium://position (subscribe to it to hear when the presentation moves)' : ''}, presmium://focus (what the author points at in the workbench; subscribe to it to hear of a new one)${host.classroom ? ', presmium://classroom' : ''}. With several documents, add ?document=<dir> to a deck's resource (presmium://sections?document=<dir>).`,
386
+ 'Sections are named by id (list_sections); steps count from 0. Paths are relative to the deck directory.',
387
+ !operates(caller)
388
+ ? 'This token reads only.'
389
+ : operations.length === 0
390
+ ? (pages ? 'No operation is offered here.' : 'No operation is offered: presmium-mcp offers the kinds of operation its --allow names.')
391
+ : caller.confirmed
392
+ ? `Operate tools: ${operations.join(', ')}. Your host confirms each by Presmium's approval policy before it calls it.`
393
+ : pages
394
+ ? `Operate tools: ${operations.join(', ')}. Each may first wait for a person to allow it (in the page), and fails when refused.`
395
+ : `Operate tools: ${operations.join(', ')}, allowed as presmium-mcp --allow says.`,
396
+ `No tool writes files: edit the deck's source with your own file tools; ${recompiles(host.process)} With several documents, name one with \`document\` (list_documents).`,
397
+ ].join('\n')
398
+ }
399
+
400
+ const RESOURCE = { mimeType: 'application/json' }
401
+
402
+ class DocumentTemplate extends UriTemplate {
403
+ readonly #path: UriTemplate
404
+
405
+ constructor(uri: string) {
406
+ super(`${uri}{?document}`)
407
+ this.#path = new UriTemplate(uri)
408
+ }
409
+
410
+ override match(uri: string): Variables | null {
411
+ const query = uri.indexOf('?')
412
+ const variables = this.#path.match(query < 0 ? uri : uri.slice(0, query))
413
+ if (!variables) return null
414
+ const document = query < 0 ? null : new URLSearchParams(uri.slice(query + 1)).get('document')
415
+ return document === null ? variables : { ...variables, document }
416
+ }
417
+ }
418
+
419
+ function argsOf(variables: Variables): Args {
420
+ const args: Args = Object.fromEntries(Object.entries(variables).map(([key, value]) => [key, key === 'document' ? String(value) : decodeURIComponent(String(value))]))
421
+ if (args.step !== undefined) args.step = Number(args.step)
422
+ return args
423
+ }
424
+
425
+ export async function presmiumServer(host: McpHost, caller: McpCaller): Promise<McpServer> {
426
+ const { builtIn, plugins, documents } = await offer(host, caller)
427
+ const meta = (kind: 'read' | ApprovalKind) => ({
428
+ [TOOL_KIND_META]: kind,
429
+ ...caller.confirmed && kind !== 'read' ? { [TOOL_APPROVAL_META]: host.approval?.(kind) ?? 'ask' } : {},
430
+ })
431
+
432
+ const server = new McpServer({ name: 'presmium', version: MCP_VERSION }, {
433
+ capabilities: { resources: { subscribe: true } },
434
+ instructions: instructions(host, caller, [...builtIn, ...plugins], documents.length === 1 ? documents[0]!.title : undefined),
435
+ })
436
+
437
+ const call = (args: Args, signal: AbortSignal): Call => {
438
+ let document: Promise<string> | undefined
439
+ let compiled: Promise<{ doc: IRDocument, diagnostics: Finding[] }> | undefined
440
+ const self: Call = {
441
+ host,
442
+ caller,
443
+ args,
444
+ signal,
445
+ document: () => document ??= resolveDocument(host, str(args.document), caller.document),
446
+ compiled: () => compiled ??= self.document().then(dir => host.compile(dir)),
447
+ async approve(kind, title, options = {}) {
448
+ if (caller.confirmed) return
449
+ const { doc } = await self.compiled()
450
+ const messages = MCP_MESSAGES[deckLocale(doc.meta.lang)]
451
+ const allowed = await host.approve({
452
+ kind,
453
+ scope: options.scope ?? 'content',
454
+ title: title(messages),
455
+ detail: options.detail ?? messages.detail(caller.member.name),
456
+ from: 'presmium.mcp',
457
+ member: caller.member.id,
458
+ }, signal)
459
+ if (!allowed) throw new Error('the operation was not allowed')
460
+ },
461
+ }
462
+ return self
463
+ }
464
+
465
+ for (const tool of builtIn) {
466
+ server.registerTool(tool.name, {
467
+ description: tool.description,
468
+ inputSchema: fromJsonSchema<Args>(schemaOf(tool.properties, tool.required) as JsonSchemaType),
469
+ annotations: { readOnlyHint: tool.kind === 'read' },
470
+ _meta: meta(tool.kind),
471
+ }, (args, ctx) => answer(() => tool.run(call(args, ctx.mcpReq.signal))))
472
+ }
473
+ for (const tool of plugins) {
474
+ server.registerTool(tool.name, {
475
+ description: tool.description,
476
+ inputSchema: fromJsonSchema<Args>(pluginInfo(tool).inputSchema as JsonSchemaType),
477
+ annotations: { readOnlyHint: tool.kind === 'read' },
478
+ _meta: meta(tool.kind),
479
+ }, (args, ctx) => answer(() => pluginTool(call(args, ctx.mcpReq.signal), tool)))
480
+ }
481
+
482
+ type Read = (call: Call) => Promise<unknown>
483
+ const contents = (url: URL, data: unknown) => ({ contents: [{ uri: url.href, mimeType: 'application/json', text: json(data) }] })
484
+ const template = (name: string, uri: string, read: Read) => {
485
+ server.registerResource(name, new ResourceTemplate(new DocumentTemplate(uri), { list: undefined }), RESOURCE, async (url, variables, ctx) => contents(url, await read(call(argsOf(variables), ctx.mcpReq.signal))))
486
+ }
487
+ const resource = (name: string, uri: string, read: Read, ofDocument = true) => {
488
+ server.registerResource(name, uri, RESOURCE, async (url, ctx) => contents(url, await read(call({}, ctx.mcpReq.signal))))
489
+ if (ofDocument) template(`${name}-of-document`, uri, read)
490
+ }
491
+ const read = (name: string): Read => c => TOOLS.find(tool => tool.name === name)!.run(c)
492
+ resource('documents', 'presmium://documents', read('list_documents'), false)
493
+ resource('deck', 'presmium://deck', read('deck_info'))
494
+ resource('sections', 'presmium://sections', read('list_sections'))
495
+ resource('diagnostics', 'presmium://diagnostics', read('get_diagnostics'))
496
+ resource('ir', 'presmium://ir', async c => (await c.compiled()).doc)
497
+ if (host.positions) resource('position', POSITION_URI, c => position(c), false)
498
+ resource('focus', FOCUS_URI, async c => focus(c, c.caller.document), false)
499
+ if (host.classroom) resource('classroom', 'presmium://classroom', read('get_classroom'))
500
+ const section = (name: string, uri: string, tool: string) => template(name, uri, read(tool))
501
+ section('section', 'presmium://sections/{section}', 'get_section')
502
+ section('step', 'presmium://sections/{section}/steps/{step}', 'get_step')
503
+ section('outputs', 'presmium://sections/{section}/outputs', 'get_outputs')
504
+ return server
505
+ }
506
+
507
+ async function pluginTool(call: Call, tool: AgentTool): Promise<unknown> {
508
+ const document = await call.document()
509
+ const own = await call.host.tools!(document)
510
+ const found = own.tools.find(t => t.name === tool.name)
511
+ if (!found) throw new Error(`the document ${JSON.stringify(document)} has no tool ${tool.name}`)
512
+ if (found.kind !== 'read' && !(operates(call.caller) && call.host.offers(found.kind))) throw new Error(`${tool.name} is not offered to this token`)
513
+ const { document: _document, ...input } = call.args
514
+ if (found.kind !== 'read') await call.approve(found.kind, m => m.tool(found.name), { detail: JSON.stringify(input) })
515
+ return found.run(input, { document, member: call.caller.member, service: own.service, signal: call.signal })
516
+ }
517
+
518
+ async function resolveDocument(host: McpHost, named: string | undefined, bound?: string): Promise<string> {
519
+ if (bound !== undefined) {
520
+ if (named !== undefined && named !== bound) throw new Error(`this token is for the document ${JSON.stringify(bound)}, not ${JSON.stringify(named)}`)
521
+ return bound
522
+ }
523
+ const documents = await host.documents()
524
+ if (named !== undefined) {
525
+ if (!documents.some(d => d.dir === named)) throw new Error(`no document ${JSON.stringify(named)}; list_documents names them`)
526
+ return named
527
+ }
528
+ if (documents.length === 1) return documents[0]!.dir
529
+ throw new Error(documents.length === 0 ? 'no document yet (the deck may still be compiling)' : 'several documents: name one with `document` (list_documents), or a resource\'s with ?document=<dir>')
530
+ }
531
+
532
+ export interface PresmiumMcp {
533
+ fetch(request: Request, caller: McpCaller): Promise<Response>
534
+ close(): Promise<void>
535
+ }
536
+
537
+ export function createPresmiumMcp(host: McpHost): PresmiumMcp {
538
+ const handler = createMcpHandler(({ authInfo }) => presmiumServer(host, callerOf(authInfo)))
539
+ const stops = [
540
+ host.watchPositions?.(() => void handler.notify.resourceUpdated(POSITION_URI)),
541
+ host.watchFocus?.(() => void handler.notify.resourceUpdated(FOCUS_URI)),
542
+ ]
543
+ return {
544
+ fetch: (request, caller) => handler.fetch(request, { authInfo: { token: '', clientId: caller.member.id, scopes: [caller.mode], extra: { caller } } }),
545
+ async close() {
546
+ for (const stop of stops) stop?.()
547
+ await handler.close()
548
+ },
549
+ }
550
+ }
551
+
552
+ function callerOf(authInfo: AuthInfo | undefined): McpCaller {
553
+ const caller = authInfo?.extra?.caller as McpCaller | undefined
554
+ if (!caller) throw new Error('an MCP request without its caller')
555
+ return caller
556
+ }
557
+
558
+ export function serveMcpStdio(host: McpHost, caller: McpCaller): { close(): Promise<void> } {
559
+ return serveStdio(() => presmiumServer(host, caller))
560
+ }
@@ -0,0 +1,35 @@
1
+ import type { Locale } from '@lincyaw/kernel'
2
+
3
+ export interface McpMessages {
4
+ navigate(section: string, step: number): string
5
+ run(section: string, block: string): string
6
+ build(renderers: string): string
7
+ allRenderers: string
8
+ classroomStart(name: string): string
9
+ classroomEnd: string
10
+ tool(name: string): string
11
+ detail(member: string): string
12
+ }
13
+
14
+ export const MCP_MESSAGES: Record<Locale, McpMessages> = {
15
+ 'zh-CN': {
16
+ navigate: (section, step) => `放映跳转到「${section}」${step > 0 ? `第 ${step} 步` : ''}`,
17
+ run: (section, block) => `运行「${section}」一节的 ${block}`,
18
+ build: renderers => `构建文稿(${renderers})`,
19
+ allRenderers: '全部渲染端',
20
+ classroomStart: name => `开始课堂${name ? `「${name}」` : ''}`,
21
+ classroomEnd: '结束课堂',
22
+ tool: name => `agent 工具 ${name}`,
23
+ detail: member => `MCP 客户端,令牌属于 ${member}`,
24
+ },
25
+ 'en': {
26
+ navigate: (section, step) => `Move the presentation to "${section}"${step > 0 ? `, step ${step}` : ''}`,
27
+ run: (section, block) => `Run ${block} of "${section}"`,
28
+ build: renderers => `Build the deck (${renderers})`,
29
+ allRenderers: 'all renderers',
30
+ classroomStart: name => `Start the classroom${name ? ` "${name}"` : ''}`,
31
+ classroomEnd: 'End the classroom',
32
+ tool: name => `Agent tool ${name}`,
33
+ detail: member => `An MCP client, with a token of ${member}`,
34
+ },
35
+ }