@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/LICENSE +202 -0
- package/README.md +22 -2
- package/bin/presmium-mcp.mjs +15 -0
- package/package.json +41 -3
- package/src/client.ts +77 -0
- package/src/deck.ts +212 -0
- package/src/focus.ts +87 -0
- package/src/host.ts +73 -0
- package/src/index.ts +4 -0
- package/src/mcp.ts +560 -0
- package/src/messages.ts +35 -0
- package/src/stdio.ts +101 -0
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
|
+
}
|
package/src/messages.ts
ADDED
|
@@ -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
|
+
}
|