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

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.
@@ -0,0 +1,157 @@
1
+ import { AUTHORING_API_VERSION, DOCUMENT_OUTLINE_QUERY_SCHEMA, DOCUMENT_PART_QUERY_SCHEMA, DOCUMENT_READ_MAX_REQUEST_CHARS, DOCUMENT_READ_MAX_RESPONSE_CHARS, DOCUMENT_PAGE_CHANGE_SCHEMA, DOCUMENT_PAGE_CHANGE_APPLY_SCHEMA, DOCUMENT_PAGE_DUPLICATION_APPLY_SCHEMA, DOCUMENT_PAGE_DUPLICATION_SCHEMA, DOCUMENT_SCHEMA_VERSION } from '@lincyaw/kernel'
2
+ import { deckLocale } from '@lincyaw/kernel'
3
+ import { DOCUMENT_CHANGE_JSON_SCHEMA } from '@lincyaw/kernel/node'
4
+ import type { DocumentAuthoring, DocumentChange, DocumentOutlineQuery, DocumentPartQuery, DocumentPageChangePlan, DocumentPageChangeReference, DocumentPageChangeRequest, DocumentPageDuplicationPlan, DocumentPageDuplicationReference, DocumentPageDuplicationRequest } from '@lincyaw/kernel'
5
+ import type { Call, ToolSpec } from './mcp.ts'
6
+ import { documentChangeSummary, MCP_MESSAGES } from './messages.ts'
7
+
8
+ function service(call: Call): DocumentAuthoring {
9
+ if (!call.host.authoring) throw Object.assign(new Error('authoring-host-unavailable'), { status: 503 })
10
+ return call.host.authoring
11
+ }
12
+
13
+ const documentProperty = { type: 'string', description: 'The served document directory. Omit only for one document or a document-bound token.' }
14
+ function readQuery(call: Call) {
15
+ if (JSON.stringify(call.args).length > DOCUMENT_READ_MAX_REQUEST_CHARS) throw Object.assign(new Error('document-read-request-too-large'), { status: 413 })
16
+ const { document: _document, ...query } = call.args
17
+ return query
18
+ }
19
+ const schema = (apply = false): Record<string, unknown> => {
20
+ const { $defs } = structuredClone(DOCUMENT_CHANGE_JSON_SCHEMA) as { $defs: Record<string, unknown> }
21
+ return { type: 'object', properties: { document: documentProperty, change: { $ref: '#/$defs/DocumentChange' }, ...apply ? { planId: { type: 'string', minLength: 1 } } : {} }, required: apply ? ['change', 'planId'] : ['change'], additionalProperties: false, $defs }
22
+ }
23
+
24
+ /** Keep the default agent result independent of document size; full material is explicitly requested. */
25
+ function duplicationSummary(plan: DocumentPageDuplicationPlan, document: string) {
26
+ const resource = (part: string) => `presmium://page-duplication/${encodeURIComponent(plan.planId)}/${plan.digest}/${part}?document=${encodeURIComponent(document)}`
27
+ const counts = Object.fromEntries(Object.entries(plan.impact).map(([field, value]) => [field, Array.isArray(value) ? value.length : value]))
28
+ return { apiVersion: plan.apiVersion, action: plan.action, planId: plan.planId, changeId: plan.changeId, source: plan.source, digest: plan.digest, expiresAt: plan.expiresAt,
29
+ section: plan.section, title: plan.content.sections[plan.section.to]!.title, copiedIdentities: Object.keys(plan.mapping).length, impact: counts,
30
+ validation: { errors: plan.issues.filter(issue => issue.severity === 'error').length, warnings: plan.issues.filter(issue => issue.severity === 'warning').length,
31
+ sample: plan.issues.slice(0, 3).map(issue => ({ code: issue.code.slice(0, 80), path: issue.path.slice(0, 200), message: issue.message.slice(0, 160), severity: issue.severity })) },
32
+ resources: { candidate: resource('candidate'), mapping: resource('mapping'), details: resource('details') } }
33
+ }
34
+
35
+ function pageSummary(plan: DocumentPageChangePlan, document: string) {
36
+ const resource = (part: string) => `presmium://page-change/${encodeURIComponent(plan.planId)}/${plan.digest}/${part}?document=${encodeURIComponent(document)}`
37
+ const title = plan.content.sections[plan.sectionId]?.title
38
+ const effect = plan.deletion
39
+ return { apiVersion: plan.apiVersion, action: plan.action.type, planId: plan.planId, changeId: plan.changeId, source: plan.source, digest: plan.digest, expiresAt: plan.expiresAt,
40
+ target: { sectionId: plan.sectionId, ...title !== undefined ? { title: { preview: title.slice(0, 80), complete: title.length <= 80, totalChars: title.length } } : {},
41
+ index: plan.content.sectionOrder.indexOf(plan.sectionId), present: Boolean(plan.content.sections[plan.sectionId]) },
42
+ ...plan.created ? { created: plan.created, defaultTiming: plan.content.production.stepOverrides[plan.created.stepId] } : {},
43
+ impact: Object.fromEntries(Object.entries(plan.impact).map(([field, value]) => [field, Array.isArray(value) ? value.length : value])),
44
+ ...effect ? { deletion: { retainedEmpty: effect.retainedEmpty, removedBlocks: effect.blocks.length, removedSteps: effect.steps.length, removedNarrations: effect.narrations.length, retainedNarrations: effect.retainedNarrations.length,
45
+ removedCues: effect.cues.length, removedClips: effect.clips.length, detachedCommentAnchors: effect.detachedComments.length, retainedCommentThreads: true, assetsAndDefinitionsPreserved: true, referenceCount: effect.references.length, confirmationRequired: true } } : {},
46
+ validation: { errors: plan.issues.filter(issue => issue.severity === 'error').length, warnings: plan.issues.filter(issue => issue.severity === 'warning').length,
47
+ sample: plan.issues.slice(0, 3).map(issue => ({ code: issue.code.slice(0, 64), path: issue.path.slice(0, 100), message: issue.message.slice(0, 120), severity: issue.severity })) },
48
+ resources: { candidate: resource('candidate'), details: resource('details') },
49
+ next: effect ? 'Review this fixed deletion impact, then apply the same reference with confirmDeletion:true. No assets or discussions are deleted.' : 'Apply this unchanged plan reference, then read the target with the returned saved source.' }
50
+ }
51
+
52
+ /** The page tools never ask the model to echo candidate content or lose JSON to result pruning. */
53
+ async function pageCall(call: Call, work: () => Promise<unknown>) {
54
+ if (JSON.stringify(call.args).length > DOCUMENT_READ_MAX_REQUEST_CHARS) throw Object.assign(new Error('page-change-request-too-large'), { status: 413 })
55
+ try {
56
+ const result = await work()
57
+ if (JSON.stringify(result, null, 2).length > DOCUMENT_READ_MAX_RESPONSE_CHARS) throw Object.assign(new Error('page-change-response-too-large'), { status: 413 })
58
+ return result
59
+ } catch (error) {
60
+ const failure = error as { message?: string, status?: number, detail?: Record<string, unknown> }
61
+ if (JSON.stringify({ message: failure.message, detail: failure.detail }, null, 2).length <= 4096) throw error
62
+ throw Object.assign(new Error((failure.message ?? 'page-change-failed').slice(0, 120)), { status: failure.status ?? 500, detail: {
63
+ truncated: true, issueCount: failure.detail?.issueCount ?? (Array.isArray(failure.detail?.issues) ? failure.detail.issues.length : undefined), referenceCount: failure.detail?.referenceCount,
64
+ next: 'Inspect the canonical entities using source-bound read_document_part. Resolve the reported error and explicitly preflight again; no operation was automatically repeated.',
65
+ } })
66
+ }
67
+ }
68
+
69
+ export const AUTHORING_TOOLS: ToolSpec[] = [
70
+ {
71
+ name: 'get_authoring_capabilities', kind: 'read', structured: true,
72
+ description: 'The actual canonical authoring operations, limits, review and retry scope of this host. Does not compile, synthesize, transcribe or write. A compiler-only stdio host reports authoring unavailable.',
73
+ properties: { document: documentProperty },
74
+ run: async call => {
75
+ if (!call.host.authoring) return { apiVersion: AUTHORING_API_VERSION, documentSchemaVersion: DOCUMENT_SCHEMA_VERSION, available: false, reason: 'host-unavailable', actions: { read: false, outline: false, readPart: false, plan: false, apply: false, planPageDuplication: false, applyPageDuplication: false, planPageChange: false, applyPageChange: false, planAction: false, applyAction: false }, operations: [], replacement: false, fileChanges: false, idempotency: 'unavailable', review: 'unavailable' }
76
+ const capability = service(call).capabilities(await call.document(), call.caller.member)
77
+ if (!call.host.offers('operate')) return { ...capability, actions: { ...capability.actions, apply: false, applyPageDuplication: false, applyPageChange: false, applyAction: false }, applyUnavailableReason: 'operation-disabled' }
78
+ return capability
79
+ },
80
+ },
81
+ {
82
+ name: 'get_document', kind: 'read', structured: true,
83
+ description: 'The canonical saved lecture.json: stable entities, exact source version and fingerprint, content and validation issues. Independent of runtime compilation. Use this source for authoring; get_section/get_step read derived runtime data.',
84
+ properties: { document: documentProperty },
85
+ run: async call => service(call).read(await call.document(), call.caller.member),
86
+ },
87
+ {
88
+ name: 'get_document_outline', kind: 'read', structured: true,
89
+ description: 'Compact canonical authoring discovery, independent of compilation. Defaults to pages in sectionOrder; other collections include unused entities. Titles are explicitly marked previews, never complete content when shortened. Each pretty-JSON reply is at most 7168 UTF-16 characters. Retain the returned source; offset > 0 requires it and any source change refuses the page. Use read_document_part for complete fields/entities; get_section/get_step are derived runtime data.',
90
+ inputSchema: { ...DOCUMENT_OUTLINE_QUERY_SCHEMA, properties: { ...DOCUMENT_OUTLINE_QUERY_SCHEMA.properties, document: { ...documentProperty, maxLength: 200 } } },
91
+ run: async call => service(call).outline(await call.document(), readQuery(call) as DocumentOutlineQuery, call.caller.member),
92
+ },
93
+ {
94
+ name: 'read_document_part', kind: 'read', structured: true,
95
+ description: 'Reads an exact selected value of saved canonical authoring content, with required source on every request. Select a stable entity collection/id or a root field, then optional own JSON fields or canonical array indices. Unknown entities/fields return 404; no filesystem paths or prototype traversal. Small values use encoding:value. Large values use json-text-fragment: concatenate decoded text in offset order, then JSON.parse once; valueDigest is fnv1a64 of JSON.stringify(selected value). UTF-16 nextOffset never splits a valid surrogate pair; mid-pair offsets are rejected. Every reply is at most 7168 pretty-JSON UTF-16 characters. Source changes, including invalid JSON, return 409; no server snapshot ledger. Partial content is not a whole-document replacement.',
96
+ inputSchema: { ...DOCUMENT_PART_QUERY_SCHEMA, properties: { ...DOCUMENT_PART_QUERY_SCHEMA.properties, document: { ...documentProperty, maxLength: 200 } } },
97
+ run: async call => service(call).readPart(await call.document(), readQuery(call) as unknown as DocumentPartQuery, call.caller.member),
98
+ },
99
+ {
100
+ name: 'plan_document_change', kind: 'read', structured: true,
101
+ description: 'Preflights a versioned DocumentChange with canonical semantic operations or an explicit replacement and optional same-document text files. Validates candidate, identity, references, all file bases and transaction limits without saving or compiling. Returns planId, candidate, entity impact, source and predicted files. Existing-file bases are exact versions; null exclusively creates a new text file.',
102
+ inputSchema: schema(),
103
+ run: async call => service(call).plan(await call.document(), call.args.change as DocumentChange, call.caller.member),
104
+ },
105
+ {
106
+ name: 'plan_page_change', kind: 'read', structured: true,
107
+ description: 'Preflights one canonical page create, rename, move or delete through the same pure rules used by Web. Supply exact source and a unique id. Create fixes four IDs and the real 4000ms/500ms defaults; after:null appends. Move uses before:null to append. Delete reports owned content, retained/shared narrations, removed cues/clips and detached comment-anchor counts; all retained narration cue references, including unused narrations, block deletion. Last-page deletion keeps its title and an empty step. No opaque payload rewriting, asset deletion, compilation or model service. Returns a compact plan/source/digest/expiry/target/impact; full candidate/details are optional resources. Apply only that reference; never echo a document or silently replan after409. Replies fit7168 pretty-JSON UTF16 characters; inputs fit2048.',
108
+ inputSchema: { type: 'object', properties: { document: { ...documentProperty, maxLength: 200 }, request: DOCUMENT_PAGE_CHANGE_SCHEMA }, required: ['request'], additionalProperties: false },
109
+ run: call => pageCall(call, async () => { const document = await call.document(); return pageSummary(await service(call).planPageChange(document, call.args.request as DocumentPageChangeRequest, call.caller.member), document) }),
110
+ },
111
+ {
112
+ name: 'apply_page_change', kind: 'operate', structured: true,
113
+ description: 'Commits the exact page preflight using only planId/source/digest. Deletion additionally requires confirmDeletion:true, bound to this planned action and source; unresolved retained references are never silently removed. Reuses the original Web transaction, CAS, file/member scope, lock, history, agent review and bounded process-current-source receipts. Returns only compact saved-source/target/count/readback evidence. Retry the identical reference after a lost ACK; stale, expired, changed or unknown plans return409 and must not be silently regenerated. Saved is not compiled or played.',
114
+ inputSchema: { type: 'object', properties: { document: { ...documentProperty, maxLength: 200 }, request: DOCUMENT_PAGE_CHANGE_APPLY_SCHEMA }, required: ['request'], additionalProperties: false },
115
+ run: call => pageCall(call, async () => {
116
+ const document = await call.document(), own = service(call), capability = own.capabilities(document, call.caller.member)
117
+ if (!capability.actions.applyPageChange) throw Object.assign(new Error(capability.applyUnavailableReason === 'review-required' ? 'authoring-review-required' : 'authoring-permission-denied'), { status: capability.applyUnavailableReason ? 409 : 403 })
118
+ const summary = (await call.host.documents()).find(item => item.dir === document)
119
+ await call.approve('operate', messages => messages.applyPagePlan, { lang: summary?.lang ?? '', check: () => own.checkPageChange(document, call.args.request as DocumentPageChangeReference, call.caller.member) })
120
+ call.signal.throwIfAborted()
121
+ return own.applyPageChange(document, call.args.request as DocumentPageChangeReference, call.caller.member)
122
+ }),
123
+ },
124
+ {
125
+ name: 'plan_page_duplication', kind: 'read', structured: true,
126
+ description: 'Preflights duplication of one canonical page without saving or compiling. Supply the exact source, a unique change id, sectionId and copy title. The server fixes fresh IDs and returns a compact source/planId/digest/expiry/target/count summary. Commit only that reference using apply_page_duplication; never copy a full document through model arguments. Full candidate, copied-ID mapping and validation details are available through the returned resources on demand. Assets/definitions stay shared; opaque payloads and original comments stay unchanged. Known plans and receipts are bounded and process-local; do not silently replan after a 409.',
127
+ inputSchema: { type: 'object', properties: { document: documentProperty, request: DOCUMENT_PAGE_DUPLICATION_SCHEMA }, required: ['request'], additionalProperties: false },
128
+ run: async call => { const document = await call.document(); return duplicationSummary(await service(call).planPageDuplication(document, call.args.request as DocumentPageDuplicationRequest, call.caller.member), document) },
129
+ },
130
+ {
131
+ name: 'apply_page_duplication', kind: 'operate', structured: true,
132
+ description: 'Commits a page duplication using only the unchanged planId, source and digest from plan_page_duplication. The shared service retrieves its exact planned change and calls the existing apply/transaction/receipt path. Returns a compact actual saved-source, target and verified-count receipt. No full-document model input, new IDs, compilation or model service is involved. Unknown/changed/expired plans, stale source and required agent review are refused; retry the identical reference only within the advertised process-current-source scope.',
133
+ inputSchema: { type: 'object', properties: { document: documentProperty, request: DOCUMENT_PAGE_DUPLICATION_APPLY_SCHEMA }, required: ['request'], additionalProperties: false },
134
+ run: async call => {
135
+ const document = await call.document(), own = service(call), capability = own.capabilities(document, call.caller.member)
136
+ if (!capability.actions.applyPageDuplication) throw Object.assign(new Error(capability.applyUnavailableReason === 'review-required' ? 'authoring-review-required' : 'authoring-permission-denied'), { status: capability.applyUnavailableReason ? 409 : 403 })
137
+ const current = await own.read(document, call.caller.member)
138
+ await call.approve('operate', messages => messages.duplicatePage, { lang: current.content.metadata.lang ?? '', check: () => own.checkPageDuplication(document, call.args.request as DocumentPageDuplicationReference, call.caller.member) })
139
+ call.signal.throwIfAborted()
140
+ return own.applyPageDuplication(document, call.args.request as DocumentPageDuplicationReference, call.caller.member)
141
+ },
142
+ },
143
+ {
144
+ name: 'apply_document_change', kind: 'operate', structured: true,
145
+ description: 'Applies the unchanged DocumentChange and planId returned by plan_document_change through the same transaction as Web. Retains CAS, file scope, locking, rollback and history; reads saved source/files back. No compilation or paid service is requested. Retry the same id and payload only within the advertised idempotency scope; stale bases are refused. Required agent review is not yet supported by this action.',
146
+ inputSchema: schema(true),
147
+ run: async call => {
148
+ const document = await call.document(), own = service(call)
149
+ const capability = own.capabilities(document, call.caller.member)
150
+ if (!capability.actions.apply) throw Object.assign(new Error(capability.applyUnavailableReason === 'review-required' ? 'authoring-review-required' : 'authoring-permission-denied'), { status: capability.applyUnavailableReason ? 409 : 403 })
151
+ const current = await own.read(document, call.caller.member)
152
+ await call.approve('operate', messages => messages.applyDocumentChange, { lang: current.content.metadata.lang ?? '', detail: documentChangeSummary(MCP_MESSAGES[deckLocale(current.content.metadata.lang)], call.args.change as DocumentChange, current.content), check: () => own.checkApply(document, { change: call.args.change as DocumentChange, planId: String(call.args.planId) }, call.caller.member) })
153
+ call.signal.throwIfAborted()
154
+ return own.apply(document, { change: call.args.change as DocumentChange, planId: String(call.args.planId) }, call.caller.member)
155
+ },
156
+ },
157
+ ]
package/src/cli.ts ADDED
@@ -0,0 +1,101 @@
1
+ import { stat } from 'node:fs/promises'
2
+ import { dirname } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { parseArgs } from 'node:util'
5
+ import { APPROVAL_KINDS, errorMessage, LOCAL_AUTHOR } from '@lincyaw/kernel'
6
+ import type { ApprovalKind } from '@lincyaw/kernel'
7
+ import { fileVersion } from '@lincyaw/kernel/node'
8
+ import { buildSummary, buildToDist, Compiler, findPackageDir, resolveEntry, runBlock } from '@lincyaw/compiler'
9
+ import type { Compilation } from '@lincyaw/compiler'
10
+ import type { McpHost } from './host.ts'
11
+ import { serveMcpStdio } from './mcp.ts'
12
+ import { MCP_CLI_MESSAGES, systemLocale } from './messages.ts'
13
+
14
+ const OWN_DIR = fileURLToPath(new URL('..', import.meta.url))
15
+ const CLI_PACKAGE = '@lincyaw/cli'
16
+
17
+ const t = MCP_CLI_MESSAGES[systemLocale()]
18
+
19
+ async function stamps(files: string[]): Promise<string> {
20
+ const times = await Promise.all(files.map(file => stat(file, { bigint: true }).then(fileVersion, () => 'missing')))
21
+ return times.join(',')
22
+ }
23
+
24
+ export function compilerMcpHost(entry: string, allow: readonly ApprovalKind[]): McpHost {
25
+ const bundled = findPackageDir(CLI_PACKAGE, dirname(entry)) ?? findPackageDir(CLI_PACKAGE, OWN_DIR) ?? OWN_DIR
26
+ const compiler = new Compiler(entry, { bundled })
27
+ let inputs = [entry]
28
+ let latest: { compilation: Compilation, files: string[], stamp: string } | undefined
29
+ let queue = Promise.resolve()
30
+ const current = () => {
31
+ const next = queue.then(async () => {
32
+ if (latest && await stamps(latest.files) === latest.stamp) return latest.compilation
33
+ const before = await stamps(inputs)
34
+ const compilation = await compiler.compile()
35
+ const files = [...new Set([...compilation.sourceFiles, ...compilation.inputs.files])]
36
+ const stamp = await stamps(files)
37
+ const known = new Set(inputs)
38
+ const stable = files.length === known.size && files.every(file => known.has(file)) && await stamps(inputs) === before
39
+ inputs = files
40
+ latest = stable ? { compilation, files, stamp } : undefined
41
+ return compilation
42
+ })
43
+ queue = next.then(() => undefined, () => undefined)
44
+ return next
45
+ }
46
+ const only = async (document: string) => {
47
+ if (document !== '') throw new Error(`no document ${JSON.stringify(document)}: presmium-mcp serves one deck`)
48
+ return current()
49
+ }
50
+ return {
51
+ process: { command: 'presmium-mcp' },
52
+ async documents() {
53
+ if (!await stat(entry).then(info => info.isFile(), () => false)) return []
54
+ const { doc } = await current()
55
+ return [{ dir: '', title: doc.meta.title, lang: doc.meta.lang }]
56
+ },
57
+ async compile(document) {
58
+ const { doc } = await only(document)
59
+ return { doc, diagnostics: doc.diagnostics }
60
+ },
61
+ async check(document) {
62
+ const compilation = await only(document)
63
+ return [...compilation.doc.diagnostics, ...await compilation.check()]
64
+ },
65
+ async build(document, options) {
66
+ return buildSummary(await buildToDist(await only(document), { to: options.to }))
67
+ },
68
+ run: async (document, target, signal) => runBlock(await only(document), target, signal),
69
+ approve: async request => allow.includes(request.kind),
70
+ offers: kind => allow.includes(kind),
71
+ async tools() {
72
+ if (!await stat(entry).then(info => info.isFile(), () => false)) return { tools: [], service: () => undefined }
73
+ const compilation = await current()
74
+ return { tools: compilation.plugins.flatMap(loaded => loaded.plugin?.tools ?? []), service: compilation.service }
75
+ },
76
+ }
77
+ }
78
+
79
+ export async function main(argv: string[]): Promise<number | undefined> {
80
+ let deck: string
81
+ let allow: ApprovalKind[]
82
+ try {
83
+ const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { allow: { type: 'string' }, help: { type: 'boolean', short: 'h' } } })
84
+ if (values.help) {
85
+ process.stdout.write(`${t.usage}\n`)
86
+ return 0
87
+ }
88
+ if (positionals.length > 1) throw new Error(t.unexpectedArgument(positionals[1]!))
89
+ deck = resolveEntry(positionals[0] ?? '.')
90
+ allow = (values.allow?.split(',').map(kind => kind.trim()).filter(Boolean) ?? []) as ApprovalKind[]
91
+ const unknown = allow.find(kind => !APPROVAL_KINDS.includes(kind))
92
+ if (unknown) throw new Error(t.badAllow(APPROVAL_KINDS, unknown))
93
+ const exists = await stat(deck).then(info => info.isFile(), () => false)
94
+ if (!exists && !await stat(positionals[0] ?? '.').then(info => info.isDirectory(), () => false)) throw new Error(t.notFound(deck))
95
+ } catch (error) {
96
+ process.stderr.write(`presmium-mcp: ${errorMessage(error)}\n\n${t.usage}\n`)
97
+ return 2
98
+ }
99
+ serveMcpStdio(compilerMcpHost(deck, allow), { member: LOCAL_AUTHOR, mode: 'author', document: '' })
100
+ return undefined
101
+ }
package/src/client.ts ADDED
@@ -0,0 +1,196 @@
1
+ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
2
+ import { APPROVAL_KINDS, deckLocale, errorMessage, isRecord } from '@lincyaw/kernel'
3
+ import type { ApprovalKind } from '@lincyaw/kernel'
4
+ import { MCP_MESSAGES } from './messages.ts'
5
+ import { TOOL_APPROVAL_META, TOOL_KIND_META } from './tool-meta.ts'
6
+
7
+ export interface ListedTool {
8
+ name: string
9
+ description: string
10
+ inputSchema: Record<string, unknown>
11
+ kind: 'read' | ApprovalKind
12
+ approval?: 'ask' | 'allow'
13
+ }
14
+
15
+ export interface CalledTool {
16
+ isError: boolean
17
+ text: string
18
+ }
19
+
20
+ export interface PresmiumMcpClient {
21
+ tools(): Promise<ListedTool[]>
22
+ subscribeTools(changed: () => void): Promise<() => Promise<void>>
23
+ call(name: string, args: Record<string, unknown>, signal?: AbortSignal): Promise<CalledTool>
24
+ read(uri: string): Promise<unknown>
25
+ subscribe(uri: string, changed: () => void): Promise<() => Promise<void>>
26
+ close(): Promise<void>
27
+ }
28
+
29
+ const KINDS = new Set<string>(['read', ...APPROVAL_KINDS])
30
+ const CALL_TIMEOUT_MS = 10 * 60_000
31
+
32
+ export async function connectPresmiumMcp(url: string, token: string, name = 'presmium-dsh'): Promise<PresmiumMcpClient> {
33
+ const client = new Client({ name, version: '0.1.0' }, { versionNegotiation: { mode: 'auto' } })
34
+ await client.connect(new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers: { Authorization: `Bearer ${token}` } } }))
35
+ type Subscription = Awaited<ReturnType<Client['listen']>>
36
+ interface SubscriptionEntry {
37
+ key: string | symbol
38
+ label: string
39
+ filter: Parameters<Client['listen']>[0]
40
+ listeners: Set<() => void>
41
+ controller: AbortController
42
+ subscription?: Subscription
43
+ opening?: Promise<void>
44
+ queued: boolean
45
+ }
46
+ const toolsKey = Symbol('tools')
47
+ const subscriptions = new Map<string | symbol, SubscriptionEntry>()
48
+ let closed = false
49
+ const active = (entry: SubscriptionEntry) => !closed && subscriptions.get(entry.key) === entry && entry.listeners.size > 0
50
+ const changed = (entry: SubscriptionEntry) => {
51
+ if (!active(entry) || entry.queued) return
52
+ entry.queued = true
53
+ queueMicrotask(() => {
54
+ entry.queued = false
55
+ if (active(entry)) for (const listener of entry.listeners) listener()
56
+ })
57
+ }
58
+ const open = (entry: SubscriptionEntry): Promise<void> => {
59
+ if (entry.subscription) return Promise.resolve()
60
+ if (entry.opening) return entry.opening
61
+ const opening = client.listen(entry.filter, { signal: entry.controller.signal }).then(async (subscription) => {
62
+ if (!active(entry)) {
63
+ await subscription.close()
64
+ return
65
+ }
66
+ entry.subscription = subscription
67
+ void subscription.closed.then(async (reason) => {
68
+ if (entry.subscription !== subscription) return
69
+ entry.subscription = undefined
70
+ if (reason === 'local' || !active(entry)) return
71
+ changed(entry)
72
+ if (reason !== 'remote') return
73
+ await entry.opening
74
+ if (!active(entry)) return
75
+ try {
76
+ await open(entry)
77
+ changed(entry)
78
+ } catch (error) {
79
+ if (!active(entry)) return
80
+ console.error(`[presmium] the subscription to ${entry.label} did not reconnect: ${errorMessage(error)}`)
81
+ changed(entry)
82
+ }
83
+ })
84
+ }).finally(() => {
85
+ if (entry.opening === opening) entry.opening = undefined
86
+ })
87
+ entry.opening = opening
88
+ return opening
89
+ }
90
+ client.setNotificationHandler('notifications/resources/updated', (notification) => {
91
+ const entry = subscriptions.get(notification.params.uri)
92
+ if (entry) changed(entry)
93
+ })
94
+ client.setNotificationHandler('notifications/tools/list_changed', () => {
95
+ const entry = subscriptions.get(toolsKey)
96
+ if (entry) changed(entry)
97
+ })
98
+ client.onclose = () => { for (const entry of subscriptions.values()) changed(entry) }
99
+ const subscribe = async (key: string | symbol, label: string, filter: SubscriptionEntry['filter'], onChanged: () => void) => {
100
+ let entry = subscriptions.get(key)
101
+ if (!entry) {
102
+ entry = { key, label, filter, listeners: new Set(), controller: new AbortController(), queued: false }
103
+ subscriptions.set(key, entry)
104
+ }
105
+ const own = entry
106
+ const listener = () => onChanged()
107
+ own.listeners.add(listener)
108
+ const stop = async () => {
109
+ own.listeners.delete(listener)
110
+ if (own.listeners.size > 0 || subscriptions.get(key) !== own) return
111
+ subscriptions.delete(key)
112
+ own.controller.abort()
113
+ await own.subscription?.close()
114
+ }
115
+ try {
116
+ await open(own)
117
+ } catch (error) {
118
+ await stop()
119
+ throw error
120
+ }
121
+ return stop
122
+ }
123
+ return {
124
+ async tools() {
125
+ const listed: ListedTool[] = []
126
+ let cursor: string | undefined
127
+ do {
128
+ const page = await client.listTools(cursor ? { cursor } : undefined, { cacheMode: 'refresh' })
129
+ for (const tool of page.tools) {
130
+ const meta = isRecord(tool._meta) ? tool._meta : {}
131
+ const kind = typeof meta[TOOL_KIND_META] === 'string' && KINDS.has(meta[TOOL_KIND_META]) ? meta[TOOL_KIND_META] as ListedTool['kind'] : (tool.annotations?.readOnlyHint ? 'read' : 'operate')
132
+ const approval = meta[TOOL_APPROVAL_META] === 'allow' ? 'allow' : meta[TOOL_APPROVAL_META] === 'ask' ? 'ask' : undefined
133
+ listed.push({ name: tool.name, description: tool.description ?? tool.name, inputSchema: tool.inputSchema as Record<string, unknown>, kind, ...approval ? { approval } : {} })
134
+ }
135
+ cursor = page.nextCursor
136
+ } while (cursor)
137
+ return listed
138
+ },
139
+ async subscribeTools(onChanged) {
140
+ if (closed) throw new Error('the MCP client is closed')
141
+ if (client.getProtocolEra() !== 'modern') throw new Error('live tool subscriptions need MCP 2026-07-28 or later')
142
+ if (!client.getServerCapabilities()?.tools?.listChanged) throw new Error('this MCP server does not offer tool-list subscriptions')
143
+ return subscribe(toolsKey, 'tools', { toolsListChanged: true }, onChanged)
144
+ },
145
+ async call(tool, args, signal) {
146
+ const result = await client.callTool({ name: tool, arguments: args }, { signal, timeout: CALL_TIMEOUT_MS })
147
+ const content = Array.isArray(result.content) ? result.content as { type: string, text?: string }[] : []
148
+ return { isError: result.isError === true, text: content.map(part => part.text ?? '').join('\n') }
149
+ },
150
+ async read(uri) {
151
+ const { contents } = await client.readResource({ uri })
152
+ const text = (contents[0] as { text?: string } | undefined)?.text ?? ''
153
+ try {
154
+ return JSON.parse(text) as unknown
155
+ } catch {
156
+ return text
157
+ }
158
+ },
159
+ async subscribe(uri, onChanged) {
160
+ if (closed) throw new Error('the MCP client is closed')
161
+ if (client.getProtocolEra() !== 'modern') throw new Error('live resource subscriptions need MCP 2026-07-28 or later')
162
+ if (!client.getServerCapabilities()?.resources?.subscribe) throw new Error('this MCP server does not offer resource subscriptions')
163
+ return subscribe(uri, uri, { resourceSubscriptions: [uri] }, onChanged)
164
+ },
165
+ async close() {
166
+ closed = true
167
+ const entries = [...subscriptions.values()]
168
+ subscriptions.clear()
169
+ for (const entry of entries) entry.controller.abort()
170
+ try {
171
+ await Promise.all(entries.map(entry => entry.subscription?.close()))
172
+ } finally {
173
+ await client.close()
174
+ }
175
+ },
176
+ }
177
+ }
178
+
179
+ export function describeOperation(tool: string, args: Record<string, unknown>, lang?: string): string {
180
+ const m = MCP_MESSAGES[deckLocale(lang)]
181
+ const text = (value: unknown) => (typeof value === 'string' ? value : typeof value === 'number' ? String(value) : '')
182
+ switch (tool) {
183
+ case 'navigate': return m.navigate(text(args.section), typeof args.step === 'number' ? args.step : 0)
184
+ case 'run_code': return m.run(text(args.section), text(args.block))
185
+ case 'build': return m.build(m.renderers(Array.isArray(args.to) ? args.to.map(text) : []))
186
+ case 'apply_document_change': return m.applyDocumentChange
187
+ case 'apply_page_duplication': return m.duplicatePage
188
+ case 'apply_page_change': return m.applyPagePlan
189
+ case 'start_transcription': return m.startTranscription
190
+ case 'cancel_transcription': return m.cancelTranscription
191
+ case 'apply_transcription_adoption': return m.adoptTranscription
192
+ case 'classroom_start': return m.classroomStart(text(args.name))
193
+ case 'classroom_end': return m.classroomEnd
194
+ default: return m.tool(tool)
195
+ }
196
+ }