@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.
- package/LICENSE +202 -0
- package/README.md +90 -2
- package/bin/presmium-mcp.mjs +15 -0
- package/package.json +42 -3
- package/src/audio-authoring.ts +91 -0
- package/src/authoring-actions.ts +69 -0
- package/src/authoring-clients.ts +34 -0
- package/src/authoring.ts +157 -0
- package/src/cli.ts +101 -0
- package/src/client.ts +196 -0
- package/src/deck.ts +207 -0
- package/src/diagnostics.ts +101 -0
- package/src/focus.ts +99 -0
- package/src/history.ts +38 -0
- package/src/host.ts +100 -0
- package/src/image-authoring.ts +43 -0
- package/src/index.ts +4 -0
- package/src/mcp.ts +796 -0
- package/src/messages.ts +264 -0
- package/src/projects.ts +18 -0
- package/src/skills.ts +107 -0
- package/src/tool-meta.ts +2 -0
- package/src/transcription.ts +92 -0
- package/src/voice-authoring.ts +93 -0
package/src/authoring.ts
ADDED
|
@@ -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
|
+
}
|