@lincyaw/mcp 1.0.0-rc.0 → 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/README.md CHANGED
@@ -2,21 +2,89 @@
2
2
 
3
3
  Presmium's agent environment: a document's tools and resources over MCP, for agents such as Claude Code, Codex or VS Code.
4
4
 
5
- Part of [Presmium](https://github.com/Lincyaw/presmium), which turns lectures written in MyST Markdown into web pages, slides, Jupyter notebooks and narrated lectures.
5
+ Part of [Presmium](https://github.com/Lincyaw/presmium), which compiles canonical lecture.json teaching documents into web pages, slides, Jupyter notebooks and narrated playback.
6
6
 
7
- ## Usage
7
+ ## Connect
8
8
 
9
- `presmium dev` and `presmium serve` serve it at `/mcp` and print its address and the file that holds its token. Without a server, run it over stdio:
9
+ There are two servers.
10
+
11
+ **Workspace server (HTTP).** `presmium dev` and `presmium serve` serve MCP at `/mcp` over Streamable HTTP and print its address and the file that holds its bearer token:
10
12
 
11
13
  ```sh
12
- pnpm exec presmium-mcp <document directory>
14
+ presmium serve
15
+ # mcp <url>/mcp (its token is in <state file>)
13
16
  ```
14
17
 
15
- The reading tools (`list_sections`, `get_section`, `get_step`, `search`, `get_code_ref`, `get_outputs`, `get_diagnostics`, `locate_source` and others) are always there; `--allow operate` also provides `run_code` and `build`.
18
+ Point the client at that URL with `Authorization: Bearer <token>`. This server has everything below: authoring, history, projects, transcription, voiceover, audio and images, builds, the author's browser runtime and focus, and several documents (name one with `document`; see `list_documents`).
19
+
20
+ **Compiler-only server (stdio).** Without a running server, serve one document on stdio:
21
+
22
+ ```sh
23
+ pnpm exec presmium-mcp <document directory> [--allow operate,spend]
24
+ ```
25
+
26
+ It compiles the document itself and offers reading, diagnostics, skills, `run_code` and `build`. It has no workspace services: authoring, history, transcription, voiceover and audio tools report themselves unavailable. Operations are offered only for the kinds `--allow` names. For skills alone, pass an existing empty directory.
27
+
28
+ Plugin tools appear once their compilation is ready; follow tool-list change notifications.
29
+
30
+ ## Tools
31
+
32
+ | Group | Main tools |
33
+ | --- | --- |
34
+ | Skills | `list_skills`, `read_skill` |
35
+ | Reading | `list_documents`, `deck_info`, `list_sections`, `get_section`, `get_step`, `search`, `get_code_ref`, `get_outputs`, `locate_source` |
36
+ | Diagnostics | `get_diagnostics` (reads the snapshot), `check_document` (runs compilation and plugin checks) |
37
+ | Runtime and focus | `get_runtime`, `get_focus`, `get_position`, `navigate` |
38
+ | Run and build | `run_code`, `build`, `get_builds`, `cancel_build` |
39
+ | Classroom | `get_classroom`, `classroom_start`, `classroom_end` |
40
+ | Canonical reading | `get_authoring_capabilities`, `get_document_outline`, `read_document_part`, `get_document` (full document, may exceed a tool-result budget) |
41
+ | Document changes | `plan_document_change`, `apply_document_change` |
42
+ | Pages | `plan_page_change`, `apply_page_change`, `plan_page_duplication`, `apply_page_duplication` |
43
+ | Comments, voice settings, component parameters, caption timing | `plan_authoring_action`, `apply_authoring_action`, `read_authoring_action_impact` |
44
+ | History | `get_document_history`, `read_history_file` |
45
+ | Projects | `get_project_capabilities`, `plan_project_change`, `apply_project_change` |
46
+ | Transcription | `get_transcription_capabilities`, `start_transcription`, `get_transcriptions`, `get_transcription`, `get_transcription_result`, `cancel_transcription`, `plan_transcription_adoption`, `apply_transcription_adoption` |
47
+ | Voice and voiceover | `get_voice_authoring_capabilities`, `get_voice_catalog`, `get_voiceover_status`, `start_voiceover`, `get_voiceover_job`, `get_voiceover_jobs`, `cancel_voiceover`, `quote_voice_preview`, `preview_voice` |
48
+ | Audio and clips | `get_audio_authoring_capabilities`, `inspect_workspace_audio`, `import_workspace_audio`, `inspect_document_audio`, `adopt_recording`, `read_production_plan`, `validate_media_clip_change`, `change_media_clip` |
49
+ | Images | `get_image_authoring_capabilities`, `inspect_workspace_image`, `import_workspace_image` |
50
+ | Browser clients | `get_authoring_clients`, `request_authoring_client`, `get_authoring_client_request` |
51
+
52
+ `get_section` and `get_step` read derived runtime data. To edit, read canonical content with `get_document_outline` and `read_document_part`, then plan and apply. Every change follows the same pattern: a `plan_*` call returns a bounded plan with source, plan id and digest; the matching `apply_*` call commits exactly that plan through the same service the Web editor uses, and reports the saved result. A saved result does not prove compilation or playback. Deletions also need `confirmDeletion: true`. A source change between plan and apply returns 409; plan again instead of retrying with a new id.
53
+
54
+ ## Resources
55
+
56
+ | URI | Content |
57
+ | --- | --- |
58
+ | `presmium://documents` | Documents served |
59
+ | `presmium://deck` | Deck summary |
60
+ | `presmium://sections`, `presmium://sections/{section}`, `presmium://sections/{section}/steps/{step}`, `presmium://sections/{section}/outputs` | Sections, steps and recorded outputs |
61
+ | `presmium://ir` | Compiled deck (reading it prepares the IR when needed) |
62
+ | `presmium://diagnostics` | Current diagnostic snapshot; subscribe to hear of changes |
63
+ | `presmium://runtime` | Reports from the author's open browser windows |
64
+ | `presmium://focus` | What the author points at in the workbench; subscribable |
65
+ | `presmium://position` | Presentation position; subscribable |
66
+ | `presmium://classroom` | Classroom state |
67
+ | `presmium://skills`, `presmium://skills/{name}/{path}` | Skill catalog and paged skill files |
68
+ | `presmium://authoring-action/…`, `presmium://page-change/…`, `presmium://page-duplication/…` | Full candidates and impact of a plan |
69
+ | `presmium://voice-preview/{id}` | Audio of a generated voice preview |
70
+
71
+ With several documents, add `?document=<dir>`, for example `presmium://sections?document=lectures/intro`.
72
+
73
+ ## Approval
74
+
75
+ Each tool has a kind: `read`, or one of the approval kinds `operate`, `spend`, `write` and `agent`. Read tools run without asking. A read-only token sees no operations. On the workspace server, every other tool waits for the author to allow it in the page, or for the client's host to confirm it by Presmium's approval policy; a refusal fails with 403 and retrying asks again. Spending actions (`start_transcription`, `start_voiceover`, `preview_voice`) need their own approval. A token bound to one document cannot act on another. On stdio, `--allow` decides which kinds are offered and allowed.
76
+
77
+ ## Not supported
78
+
79
+ - Compiler-only stdio has no workspace services (authoring, history, projects, transcription, voiceover, audio).
80
+ - Retry receipts and plans are kept per process only.
81
+ - Configurations that require agent review reject canonical apply.
82
+ - Large generic `apply_document_change` inputs and results are not bounded.
16
83
 
17
84
  ## Documentation
18
85
 
19
- - [Author guide](https://github.com/Lincyaw/presmium/blob/main/site/guide/lecture.en.md): agents in your editor
86
+ - [Author actions](../../docs/authoring-actions.md): the shared plan, commit and verify protocol, limits, recording, transcription, voiceover, audio, history and diagnostics.
87
+ - [Author guide](https://github.com/Lincyaw/presmium/blob/main/site/guide/locales/en/lecture.json): agents in your editor.
20
88
 
21
89
  ## License
22
90
 
@@ -10,6 +10,6 @@ process.emitWarning = (warning, ...args) => {
10
10
  }
11
11
 
12
12
  const jiti = createJiti(import.meta.url)
13
- const { main } = await jiti.import('../src/stdio.ts')
13
+ const { main } = await jiti.import('../src/cli.ts')
14
14
  const code = await main(process.argv.slice(2))
15
15
  if (code !== undefined) process.exitCode = code
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincyaw/mcp",
3
- "version": "1.0.0-rc.0",
3
+ "version": "1.0.0-rc.1",
4
4
  "description": "Presmium's agent environment: a document's tools and resources over MCP, and presmium-mcp over stdio",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -38,7 +38,8 @@
38
38
  "@modelcontextprotocol/server": "2.3.1",
39
39
  "jiti": "^2.7.0",
40
40
  "minisearch": "7.2.0",
41
- "@lincyaw/compiler": "1.0.0-rc.0",
42
- "@lincyaw/kernel": "1.0.0-rc.0"
41
+ "@lincyaw/skills": "1.0.0-rc.1",
42
+ "@lincyaw/kernel": "1.0.0-rc.1",
43
+ "@lincyaw/compiler": "1.0.0-rc.1"
43
44
  }
44
45
  }
@@ -0,0 +1,91 @@
1
+ import { DOCUMENT_RECORDING_ADOPTION_SCHEMA, DOCUMENT_AUDIO_INSPECTION_SCHEMA, DOCUMENT_AUDIO_WORKSPACE_IMPORT_SCHEMA, DOCUMENT_WORKSPACE_AUDIO_INSPECTION_SCHEMA, DOCUMENT_MEDIA_CLIP_VALIDATION_SCHEMA, DOCUMENT_MEDIA_CLIP_CHANGE_SCHEMA, DOCUMENT_PRODUCTION_PLAN_SCHEMA } from '@lincyaw/kernel'
2
+ import type { DocumentRecordingAdoption, DocumentAudioAuthoring, DocumentAudioInspect, DocumentWorkspaceAudioImport, DocumentWorkspaceAudioInspect, DocumentMediaClipValidation, DocumentMediaClipEdit, DocProductionPlanQuery } from '@lincyaw/kernel'
3
+ import type { Call, ToolSpec } from './mcp.ts'
4
+
5
+ const document = { type: 'string', description: 'The served document directory; file paths remain workspace-relative within this selected document.' }
6
+ const input = (properties: Record<string, unknown>, required: string[]) => ({ type: 'object', properties: { document, ...properties }, required, additionalProperties: false })
7
+ const service = (call: Call): DocumentAudioAuthoring => {
8
+ if (!call.host.audioAuthoring) throw Object.assign(new Error('audio-authoring-host-unavailable'), { status: 503 })
9
+ return call.host.audioAuthoring
10
+ }
11
+
12
+ export const AUDIO_AUTHORING_TOOLS: ToolSpec[] = [
13
+ {
14
+ name: 'adopt_recording', kind: 'operate', structured: true,
15
+ description: 'Explicitly adopts one whole local audio asset as the narration source for one stable step. Optional sourceRange edits only an already adopted recording of this same asset in this step, against real inspected duration. Requires exact saved source and original SHA-256; preserves all narration, pronunciation, cues, ASR provenance and media clips. Trimmed narration keeps whole-step display captions with alignment:none and requires caption review; it does not align words or reuse whole-file ASR timing. Uses the shared Web canonical CAS/history/receipt boundary and cannot bypass required review. Unsupported cue timing is refused; no compilation, recognition, synthesis or implicit playback occurs. Read the real production plan after explicitly preparing IR to verify playback.',
16
+ inputSchema: input({ request: structuredClone(DOCUMENT_RECORDING_ADOPTION_SCHEMA) }, ['request']),
17
+ run: async call => {
18
+ const selected = await call.document(), request = call.args.request as DocumentRecordingAdoption, authoring = call.host.authoring?.capabilities(selected, call.caller.member)
19
+ if (authoring && !authoring.actions.apply) throw Object.assign(new Error(authoring.applyUnavailableReason === 'review-required' ? 'authoring-review-required' : 'authoring-permission-denied'), { status: authoring.applyUnavailableReason ? 409 : 403 })
20
+ const target = (await call.host.documents()).find(document => document.dir === selected)
21
+ await call.approve('operate', messages => messages.adoptRecording(request.stepId), { lang: target?.lang ?? '', detail: target?.title ?? selected, check: () => service(call).checkRecordingAdoption(selected, request, call.caller.member, call.signal) })
22
+ call.signal.throwIfAborted()
23
+ return service(call).adoptRecording(selected, request, call.caller.member, call.signal)
24
+ },
25
+ },
26
+ {
27
+ name: 'get_audio_authoring_capabilities', kind: 'read', structured: true,
28
+ description: 'Reports real canonical audio authoring support, local file scope, size limits, review requirements and receipt lifetime. Does not compile scenes, read audio bytes or recognize speech. Compiler-only stdio reports this workspace service unavailable.',
29
+ inputSchema: input({}, []),
30
+ run: async call => {
31
+ if (!call.host.audioAuthoring) return { available: false, status: 'unavailable', reason: 'host-unavailable', actions: { inspect: false, inspectWorkspace: false, importWorkspace: false, readProductionPlan: false, validateClipChange: false, changeClip: false, adoptRecording: false } }
32
+ const capability = await service(call).capabilities(await call.document(), call.caller.member)
33
+ return { ...capability, actions: { ...capability.actions, importWorkspace: capability.actions.importWorkspace && call.host.offers('operate'), changeClip: capability.actions.changeClip && call.host.offers('operate'), adoptRecording: capability.actions.adoptRecording && call.host.offers('operate') },
34
+ ...capability.actions.importWorkspace && !call.host.offers('operate') ? { importUnavailableReason: 'operation-disabled' } : {},
35
+ ...capability.actions.changeClip && !call.host.offers('operate') ? { clipChangeUnavailableReason: 'operation-disabled' } : {} }
36
+ },
37
+ },
38
+ {
39
+ name: 'inspect_document_audio', kind: 'read', structured: true,
40
+ description: 'Inspects a declared audio asset from the saved canonical document, including unused assets. Verifies exact source and actual bytes, duration and format through the same media service Web uses. No scene compilation or speech call.',
41
+ inputSchema: input({ request: structuredClone(DOCUMENT_AUDIO_INSPECTION_SCHEMA) }, ['request']),
42
+ run: async call => service(call).inspect(await call.document(), call.args.request as DocumentAudioInspect, call.caller.member, call.signal),
43
+ },
44
+ {
45
+ name: 'inspect_workspace_audio', kind: 'read', structured: true,
46
+ description: 'Inspects one permitted regular audio file in the selected document workspace scope, without importing it. Returns actual source, original SHA-256, byte length and media metadata; pass its exact file object to import_workspace_audio. No canonical asset identity is assigned, no scene is compiled and no speech provider is called.',
47
+ inputSchema: input({ request: structuredClone(DOCUMENT_WORKSPACE_AUDIO_INSPECTION_SCHEMA) }, ['request']),
48
+ run: async call => service(call).inspectWorkspace(await call.document(), call.args.request as DocumentWorkspaceAudioInspect, call.caller.member, call.signal),
49
+ },
50
+ {
51
+ name: 'read_production_plan', kind: 'read', structured: true,
52
+ description: 'Reads the real cache-only production plan for an exact saved source and an existing IR snapshot, optionally requiring a known IR revision. Returns the actual binding, confirmed or unknown step ranges and diagnostics without compiling, rendering or synthesizing. Missing IR is unavailable; explicitly read presmium://ir?document=<directory> when the snapshot needs preparation or refresh, then call this tool with source. Use its returned binding for media-clip changes; estimated timing is not confirmed timing.',
53
+ inputSchema: input({ request: structuredClone(DOCUMENT_PRODUCTION_PLAN_SCHEMA) }, ['request']),
54
+ run: async call => service(call).readProductionPlan(await call.document(), call.args.request as DocProductionPlanQuery, call.caller.member, call.signal),
55
+ },
56
+ {
57
+ name: 'validate_media_clip_change', kind: 'read', structured: true,
58
+ description: 'Checks one clip add, update or removal against exact saved source without writing. Upserts recheck real asset bytes and the current production binding, source range and confirmed step duration; removal needs no production plan. Returns the concrete before/after clip and affected steps/assets. This check creates no retained plan and apply checks everything again.',
59
+ inputSchema: input({ request: structuredClone(DOCUMENT_MEDIA_CLIP_VALIDATION_SCHEMA) }, ['request']),
60
+ run: async call => service(call).validateClipChange(await call.document(), call.args.request as DocumentMediaClipValidation, call.caller.member, call.signal),
61
+ },
62
+ {
63
+ name: 'change_media_clip', kind: 'operate', structured: true,
64
+ description: 'Adds, updates or removes one media clip after host authorization, using the same real validation, canonical CAS, lock, history transaction and receipts as Web. Uses agent origin and cannot bypass required review. Upserts require a current real production binding; removal remains available to repair a bad plan. Returns saved-source readback; it does not claim the new production plan or playback is ready.',
65
+ inputSchema: input({ request: structuredClone(DOCUMENT_MEDIA_CLIP_CHANGE_SCHEMA) }, ['request']),
66
+ run: async call => {
67
+ const selected = await call.document(), request = call.args.request as DocumentMediaClipEdit, authoring = call.host.authoring?.capabilities(selected, call.caller.member)
68
+ if (authoring && !authoring.actions.apply) throw Object.assign(new Error(authoring.applyUnavailableReason === 'review-required' ? 'authoring-review-required' : 'authoring-permission-denied'), { status: authoring.applyUnavailableReason ? 409 : 403 })
69
+ const clipId = 'clip' in request ? request.clip.id : request.removeClipId
70
+ const current = await call.host.authoring?.read(selected, call.caller.member)
71
+ const action = 'removeClipId' in request ? 'remove' : current?.content.production.mediaClips.some(clip => clip.id === clipId) ? 'update' : 'add'
72
+ const target = (await call.host.documents()).find(document => document.dir === selected)
73
+ await call.approve('operate', messages => messages.changeMediaClip(action, clipId), { lang: target?.lang ?? '', detail: target?.title ?? selected, check: () => service(call).checkClipChange(selected, request, call.caller.member, call.signal) })
74
+ call.signal.throwIfAborted()
75
+ return service(call).changeClip(selected, request, call.caller.member, call.signal)
76
+ },
77
+ },
78
+ {
79
+ name: 'import_workspace_audio', kind: 'operate', structured: true,
80
+ description: 'Imports one existing regular workspace audio file allowed by the selected document scope, after host authorization. Requires exact canonical source, expected original SHA-256 and byte length. Shares Web media validation, binary/document CAS, rollback and receipts; history origin is agent and required review cannot be bypassed. Returns saved-source and actual-file verification, without clips, narration, recognition, synthesis or playback.',
81
+ inputSchema: input({ request: structuredClone(DOCUMENT_AUDIO_WORKSPACE_IMPORT_SCHEMA) }, ['request']),
82
+ run: async call => {
83
+ const selected = await call.document(), request = call.args.request as DocumentWorkspaceAudioImport, authoring = call.host.authoring?.capabilities(selected, call.caller.member)
84
+ if (authoring && !authoring.actions.apply) throw Object.assign(new Error(authoring.applyUnavailableReason === 'review-required' ? 'authoring-review-required' : 'authoring-permission-denied'), { status: authoring.applyUnavailableReason ? 409 : 403 })
85
+ const target = (await call.host.documents()).find(document => document.dir === selected)
86
+ await call.approve('operate', messages => messages.importAudio(request.file.path.split('/').at(-1) ?? request.file.path), { lang: target?.lang ?? '', detail: `${target?.title ?? selected}\n${request.file.path}`, check: () => service(call).checkWorkspaceImport(selected, request, call.caller.member, call.signal) })
87
+ call.signal.throwIfAborted()
88
+ return service(call).importWorkspace(selected, request, call.caller.member, call.signal)
89
+ },
90
+ },
91
+ ]
@@ -0,0 +1,69 @@
1
+ import { DOCUMENT_ACTION_MAX_REQUEST_CHARS, DOCUMENT_ACTION_MAX_RESPONSE_CHARS } from '@lincyaw/kernel'
2
+ import type { DocumentAuthoringActionEffect, DocumentAuthoringActionPlan, DocumentAuthoringActionRequest, DocumentAuthoringActionReference, DocumentComponentImpactQuery } from '@lincyaw/kernel'
3
+ import { DOCUMENT_AUTHORING_ACTION_SCHEMA, DOCUMENT_AUTHORING_ACTION_APPLY_SCHEMA, DOCUMENT_AUTHORING_ACTION_IMPACT_SCHEMA } from '@lincyaw/kernel/node'
4
+ import type { Call, ToolSpec } from './mcp.ts'
5
+
6
+ function service(call: Call) {
7
+ if (!call.host.authoring) throw Object.assign(new Error('authoring-host-unavailable'), { status: 503 })
8
+ return call.host.authoring
9
+ }
10
+ function schema(source: unknown): Record<string, unknown> {
11
+ const { $schema: _schema, $defs, ...request } = structuredClone(source) as Record<string, unknown>
12
+ return { type: 'object', additionalProperties: false, properties: { document: { type: 'string', maxLength: 200 }, request }, required: ['request'], ...$defs ? { $defs } : {} }
13
+ }
14
+ async function bounded(call: Call, work: () => Promise<unknown>) {
15
+ if (JSON.stringify(call.args).length > DOCUMENT_ACTION_MAX_REQUEST_CHARS) throw Object.assign(new Error('authoring-action-request-too-large'), { status: 413, detail: { limits: { maxRequestChars: DOCUMENT_ACTION_MAX_REQUEST_CHARS }, next: 'Submit only changed fields; keep the local draft when an edited value exceeds the limit.' } })
16
+ try {
17
+ const value = await work()
18
+ if (JSON.stringify(value, null, 2).length > DOCUMENT_ACTION_MAX_RESPONSE_CHARS) throw Object.assign(new Error('authoring-action-response-too-large'), { status: 413 })
19
+ return value
20
+ } catch (error) {
21
+ const failure = error as { detail?: unknown }
22
+ if (failure.detail && JSON.stringify(failure.detail).length > 2048) failure.detail = { truncated: true, next: 'Read complete canonical entities with read_document_part, then explicitly retry from the current source.' }
23
+ throw error
24
+ }
25
+ }
26
+ function effectSummary(effect: DocumentAuthoringActionEffect) {
27
+ if (effect.kind !== 'component') return effect
28
+ const { paths, affected, overrides, references, ...target } = effect.value
29
+ const counts = (value: Record<string, string[]>) => Object.fromEntries(Object.entries(value).map(([name, values]) => [name, values.length]))
30
+ return { kind: 'component', value: { ...target, paths: paths.length, affected: counts(affected), overrides: counts(overrides), references: counts(references) } }
31
+ }
32
+ function summary(plan: DocumentAuthoringActionPlan, document: string) {
33
+ const resource = (part: string) => `presmium://authoring-action/${encodeURIComponent(plan.planId)}/${plan.digest}/${part}?document=${encodeURIComponent(document)}`
34
+ return { apiVersion: plan.apiVersion, planId: plan.planId, changeId: plan.changeId, source: plan.source, digest: plan.digest, expiresAt: plan.expiresAt,
35
+ action: plan.action.type, effect: effectSummary(plan.effect), confirmationRequired: plan.action.type === 'comment.delete',
36
+ impact: Object.fromEntries(Object.entries(plan.impact).map(([key, value]) => [key, Array.isArray(value) ? value.length : value])),
37
+ validation: { errors: plan.issues.filter(issue => issue.severity === 'error').length, warnings: plan.issues.filter(issue => issue.severity === 'warning').length,
38
+ 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 })) },
39
+ resources: plan.effect.kind === 'component' ? { impact: resource('impact/affected.steps/0') } : { candidate: resource('candidate'), details: resource('details') },
40
+ next: plan.action.type === 'comment.delete' ? 'Review this exact deletion and apply the unchanged reference with confirmDeletion:true.' : plan.effect.kind === 'component' ? 'Read the actual inherited-step and Definition-reference impact, apply this unchanged reference, then read the target with the returned saved source.' : 'Apply this unchanged plan reference, then read the target with the returned saved source. Voice settings are not synthesis or recording adoption.' }
41
+ }
42
+ export const AUTHORING_ACTION_TOOLS: readonly ToolSpec[] = [
43
+ {
44
+ name: 'plan_authoring_action', kind: 'read', structured: true,
45
+ description: 'Preflights canonical comments, voice.set, manual caption timing or component actions through shared pure Web rules. Use exact source and a fresh intent id. Components support component.parameters.patch for base values, component.step-parameters.patch/reset for a stable step and component.definition.use for an existing Definition. Parameter changes are bounded set/remove path arrays for schema-declared editable fields; read-only ancestors/descendants and undeclared changes are refused. Whole-object edits preserve unknown fields. Exact whole-array reorders or item deletion are supported; ambiguous mixed reorder/edit must be split. This is not a whole-document or whole-parameters echo; arrays/objects are valid values and existing array items may be changed or removed. Preserve unknown parameters and revalidate the complete inherited graph. Step edits can affect descendants; read the returned actual impact, never claim only this step. Independent implementation copies are unavailable. Comment content is canonical RichTextNode[]; the service fixes IDs, trusted authors and dates. Voice accepts tts/none/null; recording requires adopt_recording. caption.timing.set uses the current stable Step and its read_production_plan captionTiming basis; from/to partition the entire ordered canonical narration display in UTF16 grapheme boundaries, and startMs/endMs are non-overlapping author times relative to that confirmed step. Real audio is required; cross-step speech is unavailable. This creates manual segment times, never word alignment. caption.timing.reset returns to the automatic projection. The service rechecks real cache-only timing at preflight/apply; stale bases cannot rebind implicitly. Returns compact counts/reference and a bounded impact resource, no scene compile or provider. Request<=4096 JSON chars, result<=7168 pretty JSON UTF16 chars.',
46
+ inputSchema: schema(DOCUMENT_AUTHORING_ACTION_SCHEMA),
47
+ run: call => bounded(call, async () => { const document = await call.document(); return summary(await service(call).planAction(document, call.args.request as DocumentAuthoringActionRequest, call.caller.member), document) }),
48
+ },
49
+ {
50
+ name: 'apply_authoring_action', kind: 'operate', structured: true,
51
+ description: 'Applies only the unchanged planId/source/digest returned by plan_authoring_action. Comment deletion also requires confirmDeletion:true after reviewing that exact plan. Reuses the original authoring transaction, member/doc scope, CAS, review gate, history, bounded receipts and exact saved-candidate readback. Other plan families are refused. Retry this exact reference after lost ACK; stale/expired/changed/unknown plans cannot silently replan or reallocate IDs. Saved voice configuration is not generation, provider capability, playback or validated recording adoption.',
52
+ inputSchema: schema(DOCUMENT_AUTHORING_ACTION_APPLY_SCHEMA),
53
+ run: call => bounded(call, async () => {
54
+ const document = await call.document(), own = service(call), capability = own.capabilities(document, call.caller.member)
55
+ if (!capability.actions.applyAction) throw Object.assign(new Error(capability.applyUnavailableReason === 'review-required' ? 'authoring-review-required' : 'authoring-permission-denied'), { status: capability.applyUnavailableReason ? 409 : 403 })
56
+ const entry = (await call.host.documents()).find(item => item.dir === document)
57
+ await call.approve('operate', messages => messages.applyAuthoringAction, { lang: entry?.lang ?? '', check: () => own.checkAction(document, call.args.request as DocumentAuthoringActionReference, call.caller.member) })
58
+ call.signal.throwIfAborted()
59
+ const answer = await own.applyAction(document, call.args.request as DocumentAuthoringActionReference, call.caller.member)
60
+ return { ...answer, effect: effectSummary(answer.effect) }
61
+ }),
62
+ },
63
+ {
64
+ name: 'read_authoring_action_impact', kind: 'read', structured: true,
65
+ description: 'Reads a bounded page of the fixed component plan’s actual affected blocks/steps/pages, preserved/materialized/reset override StepIDs or before/after Definition references. Use its exact planId/digest, collection and nextOffset. Returns plan source plus current source/state; an old plan is not permission to apply. No compilation or new plan. The same retained plan, member/document scope and expiry rules apply; this creates no snapshot ledger.',
66
+ inputSchema: schema(DOCUMENT_AUTHORING_ACTION_IMPACT_SCHEMA),
67
+ run: call => bounded(call, async () => service(call).readActionImpact(await call.document(), call.args.request as DocumentComponentImpactQuery, call.caller.member)),
68
+ },
69
+ ]
@@ -0,0 +1,34 @@
1
+ import { AUTHORING_CLIENT_MAX_REQUEST_CHARS, AUTHORING_CLIENT_MAX_RESPONSE_CHARS, AUTHORING_CLIENT_QUERY_SCHEMA, AUTHORING_CLIENT_REQUEST_QUERY_SCHEMA, AUTHORING_CLIENT_REQUEST_SCHEMA } from '@lincyaw/kernel'
2
+ import { validateAuthoringClientRequest } from '@lincyaw/kernel/node'
3
+ import type { AuthoringClients } from '@lincyaw/kernel'
4
+ import type { Call, ToolSpec } from './mcp.ts'
5
+
6
+ const documentProperty = { type: 'string', maxLength: 200, description: 'Served document directory; empty string means the root document.' }
7
+ const readSchema = (schema: typeof AUTHORING_CLIENT_QUERY_SCHEMA) => ({ ...schema, properties: { document: documentProperty, ...schema.properties as object } })
8
+ const service = (call: Call): AuthoringClients => { if (!call.host.authoringClients) throw Object.assign(new Error('authoring-client-host-unavailable'), { status: 503 }); return call.host.authoringClients }
9
+ const query = (call: Call) => { const { document: _document, ...value } = call.args; return value }
10
+ async function bounded(call: Call, run: () => Promise<unknown>) {
11
+ if (JSON.stringify(call.args).length > AUTHORING_CLIENT_MAX_REQUEST_CHARS) throw Object.assign(new Error('client-request-too-large'), { status: 413 })
12
+ const answer = await run()
13
+ if (JSON.stringify(answer, null, 2).length > AUTHORING_CLIENT_MAX_RESPONSE_CHARS) throw Object.assign(new Error('client-response-too-large'), { status: 413 })
14
+ return answer
15
+ }
16
+ export const AUTHORING_CLIENT_TOOLS: ToolSpec[] = [
17
+ { name: 'get_authoring_clients', kind: 'read', structured: true, inputSchema: readSchema(AUTHORING_CLIENT_QUERY_SCHEMA),
18
+ description: 'Reads bounded last reported state of actual Studio document clients, scoped to this document and member. No editor text, drafts, credentials or server-estimated playhead. Inspect connected, epoch, connectionId, snapshot.source/revision and actual actions; offline reports are observations, never current capability. Use nextAfter for additional clients. No clients means unavailable. No compilation, open-document or UI mode change. Request<=4096 JSON chars; result<=7168 pretty UTF16 chars.',
19
+ run: call => bounded(call, async () => service(call).list(await call.document(), query(call), call.caller.member)) },
20
+ { name: 'request_authoring_client', kind: 'operate', structured: true,
21
+ inputSchema: { type: 'object', additionalProperties: false, properties: { document: documentProperty, request: AUTHORING_CLIENT_REQUEST_SCHEMA }, required: ['request'] },
22
+ description: 'Explicitly requests one advertised browser action with exact clientId/epoch, CanonicalSource, connectionId/revision and intent requestId. Requires operate approval. Returns sent admission, never completed execution; poll get_authoring_client_request and inspect state, verification and current. Only a browser-owned channel can ACK. preview.prepare explicitly compiles/prepares the current clean saved source, pauses first, and preserves mode/position; no synthesis or playback. Its real prepared source/IR revision is in snapshot.preview. Input, a public modal, document switch, disconnect, Escape or the 30s request deadline cancels adoption; the human prepare default remains 45s. Playback needs an already valid exact plan/binding and real frame acknowledgement; other actions never auto-compile or enter Video/Present. source.flush flushes only canonical source edits, not comment/timing/recording form drafts. comment-drafts.inspect explicitly returns only comment-backup metadata; comment-drafts.read explicitly returns version/token-bound UTF-16 JSON-text fragments with UTF-8 SHA-256 and lengths. Assemble all fragments before parsing. component-drafts.inspect explicitly returns component-property backup metadata only, with target/version/storage, current (this client bank versus a backup) and sourceCurrent. component-drafts.read requires its exact id/revision/token/blockId/optional stepId reference and returns only the parameter value as presmium-component-parameters-json-v1 fragments, including current session-only input. Baselines, scope identities and pending internals are never exposed. The inspect/read actions do not mutate drafts. component-drafts.restore requires an exact backup reference plus expectedCurrent (the exact current reference or null), strictly forks before adopting and preserves the current branch; pending/session-only/IME input or an unavailable target is refused. component-drafts.discard requires the exact reference and current boolean, retiring only that current revision or backup without retiring its unselected origins. comment-drafts.restore is available only for an already mounted exact native reply/edit composer advertised by inspect (composerId + expectedCurrent reference/null); it strictly forks and verifies native editor adoption, without submitting a comment or changing document selection. New-comment composer restoration remains unavailable. comment-drafts.discard retires only an exact backup with no active native composer for that target; active/pending/IME cases require the human owner. These actions do not apply parameters to canonical source, rebase stale drafts, navigate, focus or flush forms. Use the human properties Apply only after reviewing the restored draft. Cross-window state is observed, not globally atomic. Default client listings and passive reports never include draft content. Draft changes invalidate client revision and old references; old successful receipts are historical when current=false. No complete local form/draft inspection is claimed. Read state again after any refused/failed/expired result. Identical intent retries return retained receipts without re-execution (64 process-local, same epoch); changed intent, stale source/state, disconnect or replaced epoch is refused. Never retry play or prepare with a new ID just because an ACK was lost.',
23
+ run: call => bounded(call, async () => {
24
+ const document = await call.document(), request = call.args.request
25
+ if (!validateAuthoringClientRequest(request)) throw Object.assign(new Error('client-request-invalid'), { status: 400 })
26
+ const lang = (await call.host.documents()).find(item => item.dir === document)?.lang ?? 'zh-CN'
27
+ await call.approve('operate', messages => messages.authoringClientAction(request.action.type), { lang })
28
+ call.signal.throwIfAborted()
29
+ return service(call).request(document, request, call.caller.member)
30
+ }) },
31
+ { name: 'get_authoring_client_request', kind: 'read', structured: true, inputSchema: readSchema(AUTHORING_CLIENT_REQUEST_QUERY_SCHEMA),
32
+ description: 'Reads this exact retained browser request receipt: sent/executing/done/refused/failed/expired. HTTP admission is not success. browser-acknowledged means an actual client observation; current additionally checks that client epoch/connection/state still matches. A stale completed receipt never describes a newly opened document. No action is re-dispatched by reading. Read the client again for subsequent commands; recording, new-comment restore, active-comment discard and other unwired draft families cannot be inferred from these explicit comment/component reads or playback.',
33
+ run: call => bounded(call, async () => service(call).get(await call.document(), query(call) as { clientId: string, epoch: string, requestId: string }, call.caller.member)) },
34
+ ]
@@ -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
+ ]