openwriter 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/pad.js +64 -0
- package/dist/client/assets/index-DNJs7lC-.js +205 -0
- package/dist/client/assets/index-WweytMO1.css +1 -0
- package/dist/client/index.html +16 -0
- package/dist/server/compact.js +214 -0
- package/dist/server/documents.js +230 -0
- package/dist/server/export-html-template.js +109 -0
- package/dist/server/export-routes.js +96 -0
- package/dist/server/gdoc-import.js +200 -0
- package/dist/server/git-sync.js +272 -0
- package/dist/server/helpers.js +87 -0
- package/dist/server/image-upload.js +55 -0
- package/dist/server/index.js +315 -0
- package/dist/server/link-routes.js +116 -0
- package/dist/server/markdown-parse.js +405 -0
- package/dist/server/markdown-serialize.js +263 -0
- package/dist/server/markdown.js +6 -0
- package/dist/server/mcp-client.js +37 -0
- package/dist/server/mcp.js +457 -0
- package/dist/server/plugin-loader.js +36 -0
- package/dist/server/plugin-types.js +5 -0
- package/dist/server/state.js +749 -0
- package/dist/server/sync-routes.js +75 -0
- package/dist/server/text-edit.js +249 -0
- package/dist/server/version-routes.js +79 -0
- package/dist/server/versions.js +198 -0
- package/dist/server/workspace-routes.js +176 -0
- package/dist/server/workspace-tags.js +33 -0
- package/dist/server/workspace-tree.js +200 -0
- package/dist/server/workspace-types.js +38 -0
- package/dist/server/workspaces.js +257 -0
- package/dist/server/ws.js +211 -0
- package/package.json +88 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-mode MCP server: proxies all tool calls to the running pad server via HTTP.
|
|
3
|
+
* Used when another terminal already owns port 5050.
|
|
4
|
+
* No state, no express, no WebSocket — pure stdio-to-HTTP proxy.
|
|
5
|
+
*/
|
|
6
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
7
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
8
|
+
import { TOOL_REGISTRY } from './mcp.js';
|
|
9
|
+
export async function startMcpClientServer(port) {
|
|
10
|
+
const server = new McpServer({
|
|
11
|
+
name: 'open-writer-client',
|
|
12
|
+
version: '0.2.0',
|
|
13
|
+
});
|
|
14
|
+
const baseUrl = `http://localhost:${port}`;
|
|
15
|
+
for (const tool of TOOL_REGISTRY) {
|
|
16
|
+
server.tool(tool.name, tool.description, tool.schema, async (args) => {
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetch(`${baseUrl}/api/mcp-call`, {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: { 'Content-Type': 'application/json' },
|
|
21
|
+
body: JSON.stringify({ tool: tool.name, arguments: args }),
|
|
22
|
+
});
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
const text = await res.text();
|
|
25
|
+
return { content: [{ type: 'text', text: `Server error (${res.status}): ${text}` }] };
|
|
26
|
+
}
|
|
27
|
+
return await res.json();
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
return { content: [{ type: 'text', text: `Connection error: ${err.message}` }] };
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
console.error(`[MCP-Client] Proxying ${TOOL_REGISTRY.length} tools to ${baseUrl}`);
|
|
35
|
+
const transport = new StdioServerTransport();
|
|
36
|
+
await server.connect(transport);
|
|
37
|
+
}
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP stdio server: tool registry + stdio transport.
|
|
3
|
+
* Uses compact wire format for token efficiency.
|
|
4
|
+
* Exports TOOL_REGISTRY for HTTP proxy (multi-session support).
|
|
5
|
+
*/
|
|
6
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
7
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, getMetadata, setMetadata, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, } from './state.js';
|
|
10
|
+
import { listDocuments, switchDocument, createDocument, openFile, getActiveFilename } from './documents.js';
|
|
11
|
+
import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastPendingDocsChanged } from './ws.js';
|
|
12
|
+
import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, addContainerToWorkspace, tagDoc, untagDoc, moveDoc } from './workspaces.js';
|
|
13
|
+
import { importGoogleDoc } from './gdoc-import.js';
|
|
14
|
+
import { toCompactFormat, compactNodes, parseMarkdownContent } from './compact.js';
|
|
15
|
+
import { markdownToTiptap } from './markdown.js';
|
|
16
|
+
export const TOOL_REGISTRY = [
|
|
17
|
+
{
|
|
18
|
+
name: 'read_pad',
|
|
19
|
+
description: 'Read the current document. Returns compact tagged-line format with [type:id] per node, inline markdown formatting. Much more token-efficient than JSON.',
|
|
20
|
+
schema: {},
|
|
21
|
+
handler: async () => {
|
|
22
|
+
const doc = getDocument();
|
|
23
|
+
const compact = toCompactFormat(doc, getTitle(), getWordCount(), getPendingChangeCount());
|
|
24
|
+
return { content: [{ type: 'text', text: compact }] };
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'write_to_pad',
|
|
29
|
+
description: 'Preferred tool for all document edits. Send 3-8 changes per call for responsive feel. Multiple rapid calls better than one monolithic call. Content can be a markdown string (preferred) or TipTap JSON. Markdown strings are auto-converted. Changes appear as pending decorations the user accepts or rejects.',
|
|
30
|
+
schema: {
|
|
31
|
+
changes: z.array(z.object({
|
|
32
|
+
operation: z.enum(['rewrite', 'insert', 'delete']),
|
|
33
|
+
nodeId: z.string().optional(),
|
|
34
|
+
afterNodeId: z.string().optional(),
|
|
35
|
+
content: z.any().optional(),
|
|
36
|
+
})).describe('Array of node changes. Content accepts markdown strings or TipTap JSON.'),
|
|
37
|
+
},
|
|
38
|
+
handler: async ({ changes }) => {
|
|
39
|
+
const processed = changes.map((change) => {
|
|
40
|
+
const resolved = { ...change };
|
|
41
|
+
if (typeof resolved.content === 'string') {
|
|
42
|
+
resolved.content = parseMarkdownContent(resolved.content);
|
|
43
|
+
}
|
|
44
|
+
return resolved;
|
|
45
|
+
});
|
|
46
|
+
const appliedCount = applyChanges(processed);
|
|
47
|
+
broadcastPendingDocsChanged();
|
|
48
|
+
return {
|
|
49
|
+
content: [{
|
|
50
|
+
type: 'text',
|
|
51
|
+
text: JSON.stringify({
|
|
52
|
+
success: appliedCount > 0,
|
|
53
|
+
appliedCount,
|
|
54
|
+
...(appliedCount < processed.length ? { skipped: processed.length - appliedCount } : {}),
|
|
55
|
+
}),
|
|
56
|
+
}],
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'get_pad_status',
|
|
62
|
+
description: 'Get the current status of the pad: word count, pending changes. Cheap call for polling.',
|
|
63
|
+
schema: {},
|
|
64
|
+
handler: async () => {
|
|
65
|
+
return { content: [{ type: 'text', text: JSON.stringify(getStatus()) }] };
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'get_nodes',
|
|
70
|
+
description: 'Get specific nodes by ID. Returns compact tagged-line format per node.',
|
|
71
|
+
schema: {
|
|
72
|
+
nodeIds: z.array(z.string()).describe('Array of node IDs to retrieve'),
|
|
73
|
+
},
|
|
74
|
+
handler: async ({ nodeIds }) => {
|
|
75
|
+
return { content: [{ type: 'text', text: compactNodes(getNodesByIds(nodeIds)) }] };
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'list_documents',
|
|
80
|
+
description: 'List all documents in the workspace. Shows filename, word count, last modified date, and which document is active.',
|
|
81
|
+
schema: {},
|
|
82
|
+
handler: async () => {
|
|
83
|
+
const docs = listDocuments();
|
|
84
|
+
const lines = docs.map((d) => {
|
|
85
|
+
const active = d.isActive ? ' (active)' : '';
|
|
86
|
+
const date = d.lastModified.split('T')[0];
|
|
87
|
+
return ` ${d.filename}${active} — ${d.wordCount.toLocaleString()} words — ${date}`;
|
|
88
|
+
});
|
|
89
|
+
return { content: [{ type: 'text', text: `documents:\n${lines.join('\n') || ' (none)'}` }] };
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: 'switch_document',
|
|
94
|
+
description: 'Switch to a different document by filename. Saves the current document first. Returns a compact read of the newly active document.',
|
|
95
|
+
schema: {
|
|
96
|
+
filename: z.string().describe('Filename of the document to switch to (e.g. "My Essay.md")'),
|
|
97
|
+
},
|
|
98
|
+
handler: async ({ filename }) => {
|
|
99
|
+
const result = switchDocument(filename);
|
|
100
|
+
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
101
|
+
const compact = toCompactFormat(result.document, result.title, getWordCount(), getPendingChangeCount());
|
|
102
|
+
return { content: [{ type: 'text', text: `Switched to "${result.title}"\n\n${compact}` }] };
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: 'create_document',
|
|
107
|
+
description: 'Create a new document and switch to it. Always provide a title — documents without one show as "Untitled". Saves the current document first. Accepts optional content as markdown string or TipTap JSON — if provided, the document is created with that content. Without content, creates an empty document. Use `path` to create the file at a specific location instead of ~/.openwriter/.',
|
|
108
|
+
schema: {
|
|
109
|
+
title: z.string().optional().describe('Title for the new document. Defaults to "Untitled".'),
|
|
110
|
+
content: z.any().optional().describe('Initial content: markdown string (preferred) or TipTap JSON doc object. If omitted, document starts empty.'),
|
|
111
|
+
path: z.string().optional().describe('Absolute file path to create the document at (e.g. "C:/projects/doc.md"). If omitted, creates in ~/.openwriter/.'),
|
|
112
|
+
},
|
|
113
|
+
handler: async ({ title, content, path }) => {
|
|
114
|
+
const result = createDocument(title, content, path);
|
|
115
|
+
if (content) {
|
|
116
|
+
const doc = getDocument();
|
|
117
|
+
markAllNodesAsPending(doc, 'insert');
|
|
118
|
+
updateDocument(doc);
|
|
119
|
+
save();
|
|
120
|
+
}
|
|
121
|
+
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
122
|
+
broadcastPendingDocsChanged();
|
|
123
|
+
const wordCount = getWordCount();
|
|
124
|
+
return {
|
|
125
|
+
content: [{
|
|
126
|
+
type: 'text',
|
|
127
|
+
text: `Created "${result.title}" (${result.filename})${wordCount > 0 ? ` — ${wordCount} words` : ''}`,
|
|
128
|
+
}],
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: 'open_file',
|
|
134
|
+
description: 'Open an existing .md file from any location on disk. Saves the current document first, then loads the file and sets it as active. The file appears in the sidebar and edits save back to the original path.',
|
|
135
|
+
schema: {
|
|
136
|
+
path: z.string().describe('Absolute path to the .md file to open (e.g. "C:/projects/blog/post.md")'),
|
|
137
|
+
},
|
|
138
|
+
handler: async ({ path }) => {
|
|
139
|
+
const result = openFile(path);
|
|
140
|
+
broadcastDocumentSwitched(result.document, result.title, result.filename);
|
|
141
|
+
const compact = toCompactFormat(result.document, result.title, getWordCount(), getPendingChangeCount());
|
|
142
|
+
return { content: [{ type: 'text', text: `Opened "${result.title}" from ${path}\n\n${compact}` }] };
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: 'replace_document',
|
|
147
|
+
description: 'Only for importing external content into a new/blank document. Never use to edit a document you already wrote — use write_to_pad instead. Accepts markdown string (preferred) or TipTap JSON. Optionally updates the title.',
|
|
148
|
+
schema: {
|
|
149
|
+
content: z.any().describe('New document content: markdown string (preferred) or TipTap JSON { type: "doc", content: [...] }'),
|
|
150
|
+
title: z.string().optional().describe('New title for the document. If omitted, title is unchanged (or extracted from markdown frontmatter).'),
|
|
151
|
+
},
|
|
152
|
+
handler: async ({ content, title }) => {
|
|
153
|
+
let doc;
|
|
154
|
+
let newTitle = title;
|
|
155
|
+
if (typeof content === 'string') {
|
|
156
|
+
const parsed = markdownToTiptap(content);
|
|
157
|
+
doc = parsed.document;
|
|
158
|
+
if (!newTitle && parsed.title !== 'Untitled')
|
|
159
|
+
newTitle = parsed.title;
|
|
160
|
+
}
|
|
161
|
+
else if (content?.type === 'doc' && Array.isArray(content.content)) {
|
|
162
|
+
doc = content;
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
return {
|
|
166
|
+
content: [{ type: 'text', text: JSON.stringify({ success: false, error: 'content must be a markdown string or TipTap JSON { type: "doc", content: [...] }' }) }],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const status = getWordCount() === 0 ? 'insert' : 'rewrite';
|
|
170
|
+
markAllNodesAsPending(doc, status);
|
|
171
|
+
updateDocument(doc);
|
|
172
|
+
if (newTitle)
|
|
173
|
+
setMetadata({ title: newTitle });
|
|
174
|
+
save();
|
|
175
|
+
broadcastDocumentSwitched(doc, newTitle || getTitle(), getActiveFilename());
|
|
176
|
+
broadcastPendingDocsChanged();
|
|
177
|
+
return {
|
|
178
|
+
content: [{
|
|
179
|
+
type: 'text',
|
|
180
|
+
text: `Document replaced — ${getWordCount().toLocaleString()} words${newTitle ? `, title: "${newTitle}"` : ''}`,
|
|
181
|
+
}],
|
|
182
|
+
};
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: 'get_metadata',
|
|
187
|
+
description: 'Get the JSON frontmatter metadata for the active document. Returns all key-value pairs stored in frontmatter (title, summary, characters, tags, etc.). Useful for understanding document context without reading full content.',
|
|
188
|
+
schema: {},
|
|
189
|
+
handler: async () => {
|
|
190
|
+
const metadata = getMetadata();
|
|
191
|
+
return { content: [{ type: 'text', text: Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : '{}' }] };
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
name: 'set_metadata',
|
|
196
|
+
description: 'Update frontmatter metadata on the active document. Merges with existing metadata — only provided keys are changed. Use for summaries, character lists, tags, arc notes, or any organizational data. Saves to disk immediately.',
|
|
197
|
+
schema: {
|
|
198
|
+
metadata: z.record(z.any()).describe('Key-value pairs to merge into frontmatter. Set a key to null to remove it.'),
|
|
199
|
+
},
|
|
200
|
+
handler: async ({ metadata: updates }) => {
|
|
201
|
+
const setKeys = [];
|
|
202
|
+
const removed = [];
|
|
203
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
204
|
+
if (value === null || value === undefined) {
|
|
205
|
+
removed.push(key);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
setKeys.push(key);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const cleaned = {};
|
|
212
|
+
for (const key of setKeys)
|
|
213
|
+
cleaned[key] = updates[key];
|
|
214
|
+
if (Object.keys(cleaned).length > 0)
|
|
215
|
+
setMetadata(cleaned);
|
|
216
|
+
const meta = getMetadata();
|
|
217
|
+
for (const key of removed)
|
|
218
|
+
delete meta[key];
|
|
219
|
+
save();
|
|
220
|
+
if (cleaned.title) {
|
|
221
|
+
broadcastTitleChanged(cleaned.title);
|
|
222
|
+
broadcastDocumentsChanged();
|
|
223
|
+
}
|
|
224
|
+
const keys = Object.keys(cleaned);
|
|
225
|
+
const parts = [];
|
|
226
|
+
if (keys.length > 0)
|
|
227
|
+
parts.push(`set: ${keys.join(', ')}`);
|
|
228
|
+
if (removed.length > 0)
|
|
229
|
+
parts.push(`removed: ${removed.join(', ')}`);
|
|
230
|
+
return { content: [{ type: 'text', text: `Metadata updated (${parts.join('; ')})` }] };
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
name: 'list_workspaces',
|
|
235
|
+
description: 'List all workspaces. Returns filename, title, and doc count.',
|
|
236
|
+
schema: {},
|
|
237
|
+
handler: async () => {
|
|
238
|
+
const workspaces = listWorkspaces();
|
|
239
|
+
const lines = workspaces.map((w) => ` ${w.filename} — "${w.title}" — ${w.docCount} docs`);
|
|
240
|
+
return { content: [{ type: 'text', text: `workspaces:\n${lines.join('\n') || ' (none)'}` }] };
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
name: 'create_workspace',
|
|
245
|
+
description: 'Create a new workspace. Workspaces are flexible containers for documents — add containers and tags after creation.',
|
|
246
|
+
schema: {
|
|
247
|
+
title: z.string().describe('Workspace title'),
|
|
248
|
+
voiceProfileId: z.string().optional().describe('Author\'s Voice profile ID (future use)'),
|
|
249
|
+
},
|
|
250
|
+
handler: async ({ title, voiceProfileId }) => {
|
|
251
|
+
const info = createWorkspace({ title, voiceProfileId });
|
|
252
|
+
broadcastWorkspacesChanged();
|
|
253
|
+
return { content: [{ type: 'text', text: `Created workspace "${info.title}" (${info.filename})` }] };
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: 'get_workspace_structure',
|
|
258
|
+
description: 'Get the full structure of a workspace: tree of containers and docs, tags index, plus context (characters, settings, rules). Use to understand workspace organization before writing.',
|
|
259
|
+
schema: {
|
|
260
|
+
filename: z.string().describe('Workspace manifest filename (e.g. "my-novel-a1b2c3d4.json")'),
|
|
261
|
+
},
|
|
262
|
+
handler: async ({ filename }) => {
|
|
263
|
+
const ws = getWorkspace(filename);
|
|
264
|
+
function renderTree(nodes, indent) {
|
|
265
|
+
const lines = [];
|
|
266
|
+
for (const node of nodes) {
|
|
267
|
+
if (node.type === 'doc') {
|
|
268
|
+
lines.push(`${indent}${getDocTitle(node.file)} (${node.file})`);
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
lines.push(`${indent}[container] ${node.name} (id:${node.id})`);
|
|
272
|
+
lines.push(...renderTree(node.items, indent + ' '));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return lines;
|
|
276
|
+
}
|
|
277
|
+
const treeLines = renderTree(ws.root, ' ');
|
|
278
|
+
let text = `workspace: "${ws.title}"\nstructure:\n${treeLines.join('\n') || ' (empty)'}`;
|
|
279
|
+
const tagEntries = Object.entries(ws.tags);
|
|
280
|
+
if (tagEntries.length > 0) {
|
|
281
|
+
text += '\ntags:';
|
|
282
|
+
for (const [tag, files] of tagEntries) {
|
|
283
|
+
text += `\n ${tag}: ${files.join(', ')}`;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (ws.context && Object.keys(ws.context).length > 0) {
|
|
287
|
+
text += `\ncontext:\n${JSON.stringify(ws.context, null, 2)}`;
|
|
288
|
+
}
|
|
289
|
+
return { content: [{ type: 'text', text }] };
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: 'get_item_context',
|
|
294
|
+
description: 'Get progressive disclosure context for a document in a workspace: workspace-level context (characters, settings, rules) and tags. Use before writing to understand context.',
|
|
295
|
+
schema: {
|
|
296
|
+
workspaceFile: z.string().describe('Workspace manifest filename'),
|
|
297
|
+
docFile: z.string().describe('Document filename within the workspace'),
|
|
298
|
+
},
|
|
299
|
+
handler: async ({ workspaceFile, docFile }) => {
|
|
300
|
+
return { content: [{ type: 'text', text: JSON.stringify(getItemContext(workspaceFile, docFile), null, 2) }] };
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
name: 'add_doc',
|
|
305
|
+
description: 'Add a document to a workspace. Optionally place it inside a container.',
|
|
306
|
+
schema: {
|
|
307
|
+
workspaceFile: z.string().describe('Workspace manifest filename'),
|
|
308
|
+
docFile: z.string().describe('Document filename to add (e.g. "Chapter 1.md")'),
|
|
309
|
+
containerId: z.string().optional().describe('Container ID to add into (null = root level)'),
|
|
310
|
+
title: z.string().optional().describe('Display title for the doc'),
|
|
311
|
+
},
|
|
312
|
+
handler: async ({ workspaceFile, docFile, containerId, title }) => {
|
|
313
|
+
addDoc(workspaceFile, containerId ?? null, docFile, title || docFile.replace(/\.md$/, ''));
|
|
314
|
+
broadcastWorkspacesChanged();
|
|
315
|
+
return {
|
|
316
|
+
content: [{ type: 'text', text: `Added "${docFile}" to workspace${containerId ? ` in container ${containerId}` : ''}` }],
|
|
317
|
+
};
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
name: 'update_workspace_context',
|
|
322
|
+
description: 'Update a workspace\'s context section (characters, settings, rules). Merges with existing context — only provided keys are changed.',
|
|
323
|
+
schema: {
|
|
324
|
+
workspaceFile: z.string().describe('Workspace manifest filename'),
|
|
325
|
+
context: z.object({
|
|
326
|
+
characters: z.record(z.string()).optional().describe('Character name → description'),
|
|
327
|
+
settings: z.record(z.string()).optional().describe('Setting name → description'),
|
|
328
|
+
rules: z.array(z.string()).optional().describe('Writing rules for this workspace'),
|
|
329
|
+
}).describe('Context fields to merge'),
|
|
330
|
+
},
|
|
331
|
+
handler: async ({ workspaceFile, context }) => {
|
|
332
|
+
updateWorkspaceContext(workspaceFile, context);
|
|
333
|
+
const keys = Object.keys(context).filter((k) => context[k] !== undefined);
|
|
334
|
+
return { content: [{ type: 'text', text: `Workspace context updated (${keys.join(', ')})` }] };
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
name: 'create_container',
|
|
339
|
+
description: 'Create a container (folder) inside a workspace. Max nesting depth: 3.',
|
|
340
|
+
schema: {
|
|
341
|
+
workspaceFile: z.string().describe('Workspace manifest filename'),
|
|
342
|
+
name: z.string().describe('Container name (e.g. "Chapters", "Research")'),
|
|
343
|
+
parentContainerId: z.string().optional().describe('Parent container ID for nesting (null = root level)'),
|
|
344
|
+
},
|
|
345
|
+
handler: async ({ workspaceFile, name, parentContainerId }) => {
|
|
346
|
+
const result = addContainerToWorkspace(workspaceFile, parentContainerId ?? null, name);
|
|
347
|
+
broadcastWorkspacesChanged();
|
|
348
|
+
return { content: [{ type: 'text', text: `Created container "${name}" (id:${result.containerId})` }] };
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
name: 'tag_doc',
|
|
353
|
+
description: 'Add a tag to a document in a workspace. Tags are cross-cutting — a doc can have multiple tags.',
|
|
354
|
+
schema: {
|
|
355
|
+
workspaceFile: z.string().describe('Workspace manifest filename'),
|
|
356
|
+
docFile: z.string().describe('Document filename'),
|
|
357
|
+
tag: z.string().describe('Tag name to add'),
|
|
358
|
+
},
|
|
359
|
+
handler: async ({ workspaceFile, docFile, tag }) => {
|
|
360
|
+
tagDoc(workspaceFile, docFile, tag);
|
|
361
|
+
broadcastWorkspacesChanged();
|
|
362
|
+
return { content: [{ type: 'text', text: `Tagged "${docFile}" with [${tag}]` }] };
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
name: 'untag_doc',
|
|
367
|
+
description: 'Remove a tag from a document in a workspace.',
|
|
368
|
+
schema: {
|
|
369
|
+
workspaceFile: z.string().describe('Workspace manifest filename'),
|
|
370
|
+
docFile: z.string().describe('Document filename'),
|
|
371
|
+
tag: z.string().describe('Tag name to remove'),
|
|
372
|
+
},
|
|
373
|
+
handler: async ({ workspaceFile, docFile, tag }) => {
|
|
374
|
+
untagDoc(workspaceFile, docFile, tag);
|
|
375
|
+
broadcastWorkspacesChanged();
|
|
376
|
+
return { content: [{ type: 'text', text: `Removed tag [${tag}] from "${docFile}"` }] };
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
name: 'move_doc',
|
|
381
|
+
description: 'Move a document to a different container within the same workspace, or to root level.',
|
|
382
|
+
schema: {
|
|
383
|
+
workspaceFile: z.string().describe('Workspace manifest filename'),
|
|
384
|
+
docFile: z.string().describe('Document filename to move'),
|
|
385
|
+
targetContainerId: z.string().optional().describe('Target container ID (omit for root level)'),
|
|
386
|
+
afterFile: z.string().optional().describe('Place after this file (omit for beginning)'),
|
|
387
|
+
},
|
|
388
|
+
handler: async ({ workspaceFile, docFile, targetContainerId, afterFile }) => {
|
|
389
|
+
moveDoc(workspaceFile, docFile, targetContainerId ?? null, afterFile ?? null);
|
|
390
|
+
broadcastWorkspacesChanged();
|
|
391
|
+
return { content: [{ type: 'text', text: `Moved "${docFile}"${targetContainerId ? ` to container ${targetContainerId}` : ' to root'}` }] };
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
name: 'edit_text',
|
|
396
|
+
description: 'Apply fine-grained text edits within a node. Find text by exact match and replace it, or add/remove marks on matched text. More precise than rewriting the whole node.',
|
|
397
|
+
schema: {
|
|
398
|
+
nodeId: z.string().describe('ID of the node to edit'),
|
|
399
|
+
edits: z.array(z.object({
|
|
400
|
+
find: z.string().describe('Exact text to find within the node'),
|
|
401
|
+
replace: z.string().optional().describe('Replacement text (omit to keep text, just change marks)'),
|
|
402
|
+
addMark: z.object({
|
|
403
|
+
type: z.string(),
|
|
404
|
+
attrs: z.record(z.any()).optional(),
|
|
405
|
+
}).optional().describe('Mark to add to the matched text (e.g. link, bold)'),
|
|
406
|
+
removeMark: z.string().optional().describe('Mark type to remove from matched text'),
|
|
407
|
+
})).describe('Array of text edits to apply'),
|
|
408
|
+
},
|
|
409
|
+
handler: async ({ nodeId, edits }) => {
|
|
410
|
+
return { content: [{ type: 'text', text: JSON.stringify(applyTextEdits(nodeId, edits)) }] };
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
name: 'import_gdoc',
|
|
415
|
+
description: 'Import a Google Doc into OpenWriter. Accepts raw Google Doc JSON (from Google Docs API). If the doc has multiple HEADING_1 sections, splits into chapter files and creates a book manifest. Otherwise imports as a single document.',
|
|
416
|
+
schema: {
|
|
417
|
+
document: z.any().describe('Raw Google Doc JSON object (must have body.content)'),
|
|
418
|
+
title: z.string().optional().describe('Book title. Defaults to the Google Doc title.'),
|
|
419
|
+
},
|
|
420
|
+
handler: async ({ document, title }) => {
|
|
421
|
+
const result = importGoogleDoc(document, title);
|
|
422
|
+
broadcastDocumentsChanged();
|
|
423
|
+
broadcastWorkspacesChanged();
|
|
424
|
+
const lines = result.files.map((f, i) => ` ${i + 1}. ${f.filename} (${f.wordCount.toLocaleString()} words)`);
|
|
425
|
+
let text = `Imported "${result.title}" — ${result.files.length} file(s), mode: ${result.mode}`;
|
|
426
|
+
if (result.workspaceFilename)
|
|
427
|
+
text += `\nWorkspace manifest: ${result.workspaceFilename}`;
|
|
428
|
+
text += `\n\n${lines.join('\n')}`;
|
|
429
|
+
return { content: [{ type: 'text', text }] };
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
];
|
|
433
|
+
/** Register MCP tools from plugins. Call before startMcpServer(). */
|
|
434
|
+
export function registerPluginTools(tools) {
|
|
435
|
+
for (const tool of tools) {
|
|
436
|
+
TOOL_REGISTRY.push({
|
|
437
|
+
name: tool.name,
|
|
438
|
+
description: tool.description,
|
|
439
|
+
schema: {},
|
|
440
|
+
handler: async (args) => {
|
|
441
|
+
const result = await tool.handler(args);
|
|
442
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
export async function startMcpServer() {
|
|
448
|
+
const server = new McpServer({
|
|
449
|
+
name: 'open-writer',
|
|
450
|
+
version: '0.2.0',
|
|
451
|
+
});
|
|
452
|
+
for (const tool of TOOL_REGISTRY) {
|
|
453
|
+
server.tool(tool.name, tool.description, tool.schema, tool.handler);
|
|
454
|
+
}
|
|
455
|
+
const transport = new StdioServerTransport();
|
|
456
|
+
await server.connect(transport);
|
|
457
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin loader: resolves, imports, and validates OpenWriter plugins.
|
|
3
|
+
*/
|
|
4
|
+
function resolvePluginConfig(plugin, globalConfig) {
|
|
5
|
+
const resolved = {};
|
|
6
|
+
if (!plugin.configSchema)
|
|
7
|
+
return resolved;
|
|
8
|
+
for (const [key, field] of Object.entries(plugin.configSchema)) {
|
|
9
|
+
// Priority: globalConfig (CLI flags) → env var → empty
|
|
10
|
+
const envVal = field.env ? process.env[field.env] : undefined;
|
|
11
|
+
const value = globalConfig[key] || envVal || '';
|
|
12
|
+
if (value)
|
|
13
|
+
resolved[key] = value;
|
|
14
|
+
}
|
|
15
|
+
return resolved;
|
|
16
|
+
}
|
|
17
|
+
export async function loadPlugins(names, globalConfig) {
|
|
18
|
+
const plugins = [];
|
|
19
|
+
const errors = [];
|
|
20
|
+
for (const name of names) {
|
|
21
|
+
try {
|
|
22
|
+
const mod = await import(name);
|
|
23
|
+
const plugin = mod.default || mod.plugin || mod;
|
|
24
|
+
if (!plugin.name || !plugin.version) {
|
|
25
|
+
errors.push(`Plugin "${name}" missing name or version`);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const config = resolvePluginConfig(plugin, globalConfig);
|
|
29
|
+
plugins.push({ plugin, config });
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
errors.push(`Failed to load plugin "${name}": ${err.message}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { plugins, errors };
|
|
36
|
+
}
|