openwriter 0.2.1 → 0.3.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.
@@ -1,37 +1,38 @@
1
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.
2
+ * Client-mode MCP server: lightweight proxy to the running primary server.
3
+ * Zero local imports fetches tool metadata via HTTP, proxies calls via HTTP.
4
+ * Used when another terminal already owns the port.
5
5
  */
6
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
7
7
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
- import { TOOL_REGISTRY } from './mcp.js';
8
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
9
9
  export async function startMcpClientServer(port) {
10
- const server = new McpServer({
11
- name: 'open-writer-client',
12
- version: '0.2.0',
13
- });
14
10
  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}` }] };
11
+ // Fetch tool metadata from the primary server
12
+ const res = await fetch(`${baseUrl}/api/mcp-tools`);
13
+ if (!res.ok)
14
+ throw new Error(`Failed to fetch tools from ${baseUrl}: ${res.status}`);
15
+ const { tools } = await res.json();
16
+ const server = new Server({ name: 'openwriter-client', version: '0.2.0' }, { capabilities: { tools: {} } });
17
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
18
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
19
+ try {
20
+ const callRes = await fetch(`${baseUrl}/api/mcp-call`, {
21
+ method: 'POST',
22
+ headers: { 'Content-Type': 'application/json' },
23
+ body: JSON.stringify({ tool: request.params.name, arguments: request.params.arguments }),
24
+ });
25
+ if (!callRes.ok) {
26
+ const text = await callRes.text();
27
+ return { content: [{ type: 'text', text: `Server error (${callRes.status}): ${text}` }] };
31
28
  }
32
- });
33
- }
34
- console.error(`[MCP-Client] Proxying ${TOOL_REGISTRY.length} tools to ${baseUrl}`);
29
+ return await callRes.json();
30
+ }
31
+ catch (err) {
32
+ return { content: [{ type: 'text', text: `Connection error: ${err.message}` }] };
33
+ }
34
+ });
35
+ console.error(`[MCP-Client] Proxying ${tools.length} tools to ${baseUrl}`);
35
36
  const transport = new StdioServerTransport();
36
37
  await server.connect(transport);
37
38
  }
@@ -3,16 +3,21 @@
3
3
  * Uses compact wire format for token efficiency.
4
4
  * Exports TOOL_REGISTRY for HTTP proxy (multi-session support).
5
5
  */
6
+ import { join } from 'path';
7
+ import { existsSync, mkdirSync, writeFileSync } from 'fs';
8
+ import { randomUUID } from 'crypto';
6
9
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
11
  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';
12
+ import { DATA_DIR, ensureDataDir } from './helpers.js';
13
+ import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus, getNodesByIds, getMetadata, setMetadata, applyChanges, applyTextEdits, updateDocument, save, markAllNodesAsPending, setAgentLock, } from './state.js';
14
+ import { listDocuments, switchDocument, createDocument, deleteDocument, openFile, getActiveFilename } from './documents.js';
15
+ import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished } from './ws.js';
16
+ import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc } from './workspaces.js';
17
+ import { addDocTag, removeDocTag, getDocTagsByFilename } from './state.js';
13
18
  import { importGoogleDoc } from './gdoc-import.js';
14
19
  import { toCompactFormat, compactNodes, parseMarkdownContent } from './compact.js';
15
- import { markdownToTiptap } from './markdown.js';
20
+ import { getUpdateInfo } from './update-check.js';
16
21
  export const TOOL_REGISTRY = [
17
22
  {
18
23
  name: 'read_pad',
@@ -26,7 +31,7 @@ export const TOOL_REGISTRY = [
26
31
  },
27
32
  {
28
33
  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.',
34
+ 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. Use afterNodeId: "end" to append to the document without knowing node IDs. Response includes lastNodeId for chaining subsequent inserts.',
30
35
  schema: {
31
36
  changes: z.array(z.object({
32
37
  operation: z.enum(['rewrite', 'insert', 'delete']),
@@ -43,7 +48,7 @@ export const TOOL_REGISTRY = [
43
48
  }
44
49
  return resolved;
45
50
  });
46
- const appliedCount = applyChanges(processed);
51
+ const { count: appliedCount, lastNodeId } = applyChanges(processed);
47
52
  broadcastPendingDocsChanged();
48
53
  return {
49
54
  content: [{
@@ -51,6 +56,7 @@ export const TOOL_REGISTRY = [
51
56
  text: JSON.stringify({
52
57
  success: appliedCount > 0,
53
58
  appliedCount,
59
+ ...(lastNodeId ? { lastNodeId } : {}),
54
60
  ...(appliedCount < processed.length ? { skipped: processed.length - appliedCount } : {}),
55
61
  }),
56
62
  }],
@@ -62,7 +68,10 @@ export const TOOL_REGISTRY = [
62
68
  description: 'Get the current status of the pad: word count, pending changes. Cheap call for polling.',
63
69
  schema: {},
64
70
  handler: async () => {
65
- return { content: [{ type: 'text', text: JSON.stringify(getStatus()) }] };
71
+ const status = getStatus();
72
+ const latestVersion = getUpdateInfo();
73
+ const payload = latestVersion ? { ...status, updateAvailable: latestVersion } : status;
74
+ return { content: [{ type: 'text', text: JSON.stringify(payload) }] };
66
75
  },
67
76
  },
68
77
  {
@@ -96,6 +105,7 @@ export const TOOL_REGISTRY = [
96
105
  filename: z.string().describe('Filename of the document to switch to (e.g. "My Essay.md")'),
97
106
  },
98
107
  handler: async ({ filename }) => {
108
+ broadcastWritingFinished(); // Clear any in-progress creation spinner
99
109
  const result = switchDocument(filename);
100
110
  broadcastDocumentSwitched(result.document, result.title, result.filename);
101
111
  const compact = toCompactFormat(result.document, result.title, getWordCount(), getPendingChangeCount());
@@ -104,29 +114,117 @@ export const TOOL_REGISTRY = [
104
114
  },
105
115
  {
106
116
  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 JSONif 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/.',
117
+ description: 'Create a new empty document and switch to it. Always provide a title. Saves the current document first. By default shows a sidebar spinner that persists until populate_document is called set empty=true to skip the spinner and switch immediately (use for template docs like tweets/articles that don\'t need agent content). If workspace is provided, the doc is automatically added to it (workspace is created if it doesn\'t exist). If container is also provided, the doc is placed inside that container (created if it doesn\'t exist).',
108
118
  schema: {
109
119
  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
120
  path: z.string().optional().describe('Absolute file path to create the document at (e.g. "C:/projects/doc.md"). If omitted, creates in ~/.openwriter/.'),
121
+ workspace: z.string().optional().describe('Workspace title to add this doc to. Creates the workspace if it doesn\'t exist.'),
122
+ container: z.string().optional().describe('Container name within the workspace (e.g. "Chapters", "Notes", "References"). Creates the container if it doesn\'t exist. Requires workspace.'),
123
+ empty: z.boolean().optional().describe('If true, skip the writing spinner and switch to the doc immediately. No need to call populate_document. Use for template docs (tweets, articles) that start empty.'),
124
+ },
125
+ handler: async ({ title, path, workspace, container, empty }) => {
126
+ // Resolve workspace/container up front so spinner renders in the right place
127
+ let wsTarget;
128
+ if (workspace) {
129
+ const ws = findOrCreateWorkspace(workspace);
130
+ let containerId = null;
131
+ if (container) {
132
+ const c = findOrCreateContainer(ws.filename, container);
133
+ containerId = c.containerId;
134
+ }
135
+ wsTarget = { wsFilename: ws.filename, containerId };
136
+ broadcastWorkspacesChanged(); // Browser sees container structure before spinner
137
+ }
138
+ if (!empty) {
139
+ broadcastWritingStarted(title || 'Untitled', wsTarget);
140
+ // Yield so the browser receives and renders the placeholder before heavy work
141
+ await new Promise((resolve) => setTimeout(resolve, 200));
142
+ }
143
+ try {
144
+ // Lock browser doc-updates: prevents race where browser sends a doc-update
145
+ // for the previous document but server has already switched active doc.
146
+ setAgentLock();
147
+ const result = createDocument(title, undefined, path);
148
+ // Auto-add to workspace if specified
149
+ let wsInfo = '';
150
+ if (wsTarget) {
151
+ addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title);
152
+ wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
153
+ }
154
+ if (empty) {
155
+ // Immediate switch — no spinner, no populate_document needed
156
+ save();
157
+ broadcastDocumentsChanged();
158
+ broadcastWorkspacesChanged();
159
+ broadcastDocumentSwitched(getDocument(), getTitle(), getActiveFilename());
160
+ return {
161
+ content: [{
162
+ type: 'text',
163
+ text: `Created "${result.title}" (${result.filename})${wsInfo} — ready.`,
164
+ }],
165
+ };
166
+ }
167
+ // Two-step flow: spinner persists until populate_document is called
168
+ setMetadata({ agentCreated: true });
169
+ save(); // Persist agentCreated flag to frontmatter
170
+ return {
171
+ content: [{
172
+ type: 'text',
173
+ text: `Created "${result.title}" (${result.filename})${wsInfo} — empty. Call populate_document to add content.`,
174
+ }],
175
+ };
176
+ }
177
+ catch (err) {
178
+ if (!empty)
179
+ broadcastWritingFinished();
180
+ throw err;
181
+ }
182
+ },
183
+ },
184
+ {
185
+ name: 'populate_document',
186
+ description: 'Populate the active document with content. Use after create_document (without content) to complete the two-step creation flow. Content appears as pending decorations for user review. Clears the sidebar creation spinner and shows the document.',
187
+ schema: {
188
+ content: z.any().describe('Document content: markdown string (preferred) or TipTap JSON doc object.'),
112
189
  },
113
- handler: async ({ title, content, path }) => {
114
- const result = createDocument(title, content, path);
115
- if (content) {
116
- const doc = getDocument();
190
+ handler: async ({ content }) => {
191
+ try {
192
+ let doc;
193
+ if (typeof content === 'string') {
194
+ doc = { type: 'doc', content: parseMarkdownContent(content) };
195
+ }
196
+ else if (content?.type === 'doc' && Array.isArray(content.content)) {
197
+ doc = content;
198
+ }
199
+ else {
200
+ broadcastWritingFinished();
201
+ return {
202
+ content: [{ type: 'text', text: 'Error: content must be a markdown string or TipTap JSON { type: "doc", content: [...] }' }],
203
+ };
204
+ }
205
+ setAgentLock(); // Block browser doc-updates during population
117
206
  markAllNodesAsPending(doc, 'insert');
118
207
  updateDocument(doc);
119
208
  save();
209
+ // Broadcast sidebar updates first (deferred from create_document) so the doc
210
+ // entry and spinner removal arrive in the same render cycle
211
+ broadcastDocumentsChanged();
212
+ broadcastWorkspacesChanged();
213
+ broadcastDocumentSwitched(doc, getTitle(), getActiveFilename());
214
+ broadcastPendingDocsChanged();
215
+ broadcastWritingFinished();
216
+ const wordCount = getWordCount();
217
+ return {
218
+ content: [{
219
+ type: 'text',
220
+ text: `Populated "${getTitle()}" — ${wordCount.toLocaleString()} words`,
221
+ }],
222
+ };
223
+ }
224
+ catch (err) {
225
+ broadcastWritingFinished();
226
+ throw err;
120
227
  }
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
228
  },
131
229
  },
132
230
  {
@@ -143,43 +241,22 @@ export const TOOL_REGISTRY = [
143
241
  },
144
242
  },
145
243
  {
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.',
244
+ name: 'delete_document',
245
+ description: 'Delete a document file. Moves to OS trash (Recycle Bin / macOS Trash). If deleting the active document, automatically switches to the most recent remaining doc. Cannot delete the last document. IMPORTANT: Always confirm with the user before calling this tool.',
148
246
  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;
247
+ filename: z.string().describe('Filename of the document to delete (e.g. "My Essay.md")'),
248
+ },
249
+ handler: async ({ filename }) => {
250
+ const result = await deleteDocument(filename);
251
+ if (result.switched && result.newDoc) {
252
+ broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
163
253
  }
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
- };
254
+ broadcastDocumentsChanged();
255
+ let text = `Deleted "${filename}" (moved to trash)`;
256
+ if (result.switched && result.newDoc) {
257
+ text += `. Switched to "${result.newDoc.filename}"`;
168
258
  }
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
- };
259
+ return { content: [{ type: 'text', text }] };
183
260
  },
184
261
  },
185
262
  {
@@ -217,6 +294,7 @@ export const TOOL_REGISTRY = [
217
294
  for (const key of removed)
218
295
  delete meta[key];
219
296
  save();
297
+ broadcastMetadataChanged(getMetadata());
220
298
  if (cleaned.title) {
221
299
  broadcastTitleChanged(cleaned.title);
222
300
  broadcastDocumentsChanged();
@@ -253,9 +331,26 @@ export const TOOL_REGISTRY = [
253
331
  return { content: [{ type: 'text', text: `Created workspace "${info.title}" (${info.filename})` }] };
254
332
  },
255
333
  },
334
+ {
335
+ name: 'delete_workspace',
336
+ description: 'Delete a workspace and all its document files. Files go to OS trash (Recycle Bin / macOS Trash). IMPORTANT: Always confirm with the user before calling this tool.',
337
+ schema: {
338
+ filename: z.string().describe('Workspace manifest filename (e.g. "my-novel-a1b2c3d4.json")'),
339
+ },
340
+ handler: async ({ filename }) => {
341
+ const result = await deleteWorkspace(filename);
342
+ broadcastWorkspacesChanged();
343
+ broadcastDocumentsChanged();
344
+ let text = `Deleted workspace "${filename}" and ${result.deletedFiles.length} files: ${result.deletedFiles.join(', ')}`;
345
+ if (result.skippedExternal.length > 0) {
346
+ text += `\nSkipped ${result.skippedExternal.length} external files (not owned by OpenWriter): ${result.skippedExternal.join(', ')}`;
347
+ }
348
+ return { content: [{ type: 'text', text }] };
349
+ },
350
+ },
256
351
  {
257
352
  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.',
353
+ description: 'Get the full structure of a workspace: tree of containers and docs, per-doc tags, plus context (characters, settings, rules). Use to understand workspace organization before writing.',
259
354
  schema: {
260
355
  filename: z.string().describe('Workspace manifest filename (e.g. "my-novel-a1b2c3d4.json")'),
261
356
  },
@@ -265,7 +360,9 @@ export const TOOL_REGISTRY = [
265
360
  const lines = [];
266
361
  for (const node of nodes) {
267
362
  if (node.type === 'doc') {
268
- lines.push(`${indent}${getDocTitle(node.file)} (${node.file})`);
363
+ const tags = getDocTagsByFilename(node.file);
364
+ const tagStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
365
+ lines.push(`${indent}${getDocTitle(node.file)} (${node.file})${tagStr}`);
269
366
  }
270
367
  else {
271
368
  lines.push(`${indent}[container] ${node.name} (id:${node.id})`);
@@ -276,13 +373,6 @@ export const TOOL_REGISTRY = [
276
373
  }
277
374
  const treeLines = renderTree(ws.root, ' ');
278
375
  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
376
  if (ws.context && Object.keys(ws.context).length > 0) {
287
377
  text += `\ncontext:\n${JSON.stringify(ws.context, null, 2)}`;
288
378
  }
@@ -350,29 +440,27 @@ export const TOOL_REGISTRY = [
350
440
  },
351
441
  {
352
442
  name: 'tag_doc',
353
- description: 'Add a tag to a document in a workspace. Tags are cross-cuttinga doc can have multiple tags.',
443
+ description: 'Add a tag to a document. Tags are stored in the document\'s frontmatter they travel with the file. A doc can have multiple tags.',
354
444
  schema: {
355
- workspaceFile: z.string().describe('Workspace manifest filename'),
356
- docFile: z.string().describe('Document filename'),
445
+ docFile: z.string().describe('Document filename (e.g. "Chapter 1.md")'),
357
446
  tag: z.string().describe('Tag name to add'),
358
447
  },
359
- handler: async ({ workspaceFile, docFile, tag }) => {
360
- tagDoc(workspaceFile, docFile, tag);
361
- broadcastWorkspacesChanged();
448
+ handler: async ({ docFile, tag }) => {
449
+ addDocTag(docFile, tag);
450
+ broadcastDocumentsChanged();
362
451
  return { content: [{ type: 'text', text: `Tagged "${docFile}" with [${tag}]` }] };
363
452
  },
364
453
  },
365
454
  {
366
455
  name: 'untag_doc',
367
- description: 'Remove a tag from a document in a workspace.',
456
+ description: 'Remove a tag from a document.',
368
457
  schema: {
369
- workspaceFile: z.string().describe('Workspace manifest filename'),
370
458
  docFile: z.string().describe('Document filename'),
371
459
  tag: z.string().describe('Tag name to remove'),
372
460
  },
373
- handler: async ({ workspaceFile, docFile, tag }) => {
374
- untagDoc(workspaceFile, docFile, tag);
375
- broadcastWorkspacesChanged();
461
+ handler: async ({ docFile, tag }) => {
462
+ removeDocTag(docFile, tag);
463
+ broadcastDocumentsChanged();
376
464
  return { content: [{ type: 'text', text: `Removed tag [${tag}] from "${docFile}"` }] };
377
465
  },
378
466
  },
@@ -429,8 +517,61 @@ export const TOOL_REGISTRY = [
429
517
  return { content: [{ type: 'text', text }] };
430
518
  },
431
519
  },
520
+ {
521
+ name: 'generate_image',
522
+ description: 'Generate an image using Gemini Imagen 4. Saves to ~/.openwriter/_images/. Optionally sets it as the active article\'s cover image atomically. Requires GEMINI_API_KEY env var.',
523
+ schema: {
524
+ prompt: z.string().max(1000).describe('Image generation prompt (max 1000 chars)'),
525
+ aspect_ratio: z.string().optional().describe('Aspect ratio (default "16:9"). Use "5:2" for article covers.'),
526
+ set_cover: z.boolean().optional().describe('If true, atomically set the generated image as the article cover (articleContext.coverImage in metadata).'),
527
+ },
528
+ handler: async ({ prompt, aspect_ratio, set_cover }) => {
529
+ const apiKey = process.env.GEMINI_API_KEY;
530
+ if (!apiKey) {
531
+ return { content: [{ type: 'text', text: 'Error: GEMINI_API_KEY environment variable is not set.' }] };
532
+ }
533
+ const { GoogleGenAI } = await import('@google/genai');
534
+ const ai = new GoogleGenAI({ apiKey });
535
+ const response = await ai.models.generateImages({
536
+ model: 'imagen-4.0-generate-001',
537
+ prompt,
538
+ config: {
539
+ numberOfImages: 1,
540
+ aspectRatio: (aspect_ratio || '16:9'),
541
+ },
542
+ });
543
+ const image = response.generatedImages?.[0];
544
+ if (!image?.image?.imageBytes) {
545
+ return { content: [{ type: 'text', text: 'Error: Gemini returned no image data.' }] };
546
+ }
547
+ // Save to ~/.openwriter/_images/
548
+ ensureDataDir();
549
+ const imagesDir = join(DATA_DIR, '_images');
550
+ if (!existsSync(imagesDir))
551
+ mkdirSync(imagesDir, { recursive: true });
552
+ const filename = `${randomUUID().slice(0, 8)}.png`;
553
+ const filePath = join(imagesDir, filename);
554
+ writeFileSync(filePath, Buffer.from(image.image.imageBytes, 'base64'));
555
+ const src = `/_images/${filename}`;
556
+ // Optionally set as article cover
557
+ if (set_cover) {
558
+ const meta = getMetadata();
559
+ const articleContext = meta.articleContext || {};
560
+ articleContext.coverImage = src;
561
+ setMetadata({ articleContext });
562
+ save();
563
+ broadcastMetadataChanged(getMetadata());
564
+ }
565
+ return {
566
+ content: [{
567
+ type: 'text',
568
+ text: JSON.stringify({ success: true, src, ...(set_cover ? { coverSet: true } : {}) }),
569
+ }],
570
+ };
571
+ },
572
+ },
432
573
  ];
433
- /** Register MCP tools from plugins. Call before startMcpServer(). */
574
+ /** Register MCP tools from plugins. Tools added after startMcpServer() won't be visible to existing MCP sessions. */
434
575
  export function registerPluginTools(tools) {
435
576
  for (const tool of tools) {
436
577
  TOOL_REGISTRY.push({
@@ -444,9 +585,18 @@ export function registerPluginTools(tools) {
444
585
  });
445
586
  }
446
587
  }
588
+ /** Remove MCP tools by name. Existing MCP stdio sessions won't see removal until reconnect. */
589
+ export function removePluginTools(names) {
590
+ const nameSet = new Set(names);
591
+ for (let i = TOOL_REGISTRY.length - 1; i >= 0; i--) {
592
+ if (nameSet.has(TOOL_REGISTRY[i].name)) {
593
+ TOOL_REGISTRY.splice(i, 1);
594
+ }
595
+ }
596
+ }
447
597
  export async function startMcpServer() {
448
598
  const server = new McpServer({
449
- name: 'open-writer',
599
+ name: 'openwriter',
450
600
  version: '0.2.0',
451
601
  });
452
602
  for (const tool of TOOL_REGISTRY) {
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Plugin discovery: scans the plugins/ directory for available plugins.
3
+ * Reads package.json metadata without importing or loading the plugin code.
4
+ */
5
+ import { existsSync, readdirSync, readFileSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { fileURLToPath } from 'url';
8
+ import { dirname } from 'path';
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+ /**
12
+ * Scan the plugins/ directory at the monorepo root.
13
+ * Returns metadata from each plugin's package.json without importing code.
14
+ * Returns [] if plugins/ doesn't exist (e.g. npm install scenario).
15
+ */
16
+ export function discoverPlugins() {
17
+ // At runtime: dist/server/ → ../../../.. → monorepo root → /plugins/
18
+ const pluginsDir = join(__dirname, '..', '..', '..', '..', 'plugins');
19
+ if (!existsSync(pluginsDir))
20
+ return [];
21
+ const results = [];
22
+ for (const entry of readdirSync(pluginsDir, { withFileTypes: true })) {
23
+ if (!entry.isDirectory())
24
+ continue;
25
+ const pkgPath = join(pluginsDir, entry.name, 'package.json');
26
+ if (!existsSync(pkgPath))
27
+ continue;
28
+ try {
29
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
30
+ if (!pkg.name)
31
+ continue;
32
+ results.push({
33
+ name: pkg.name,
34
+ dirName: entry.name,
35
+ version: pkg.version || '0.0.0',
36
+ description: pkg.description || '',
37
+ });
38
+ }
39
+ catch {
40
+ // Skip malformed package.json
41
+ }
42
+ }
43
+ return results;
44
+ }
45
+ /**
46
+ * Import a plugin by npm package name and extract its metadata.
47
+ * Returns the plugin's configSchema and full module export.
48
+ */
49
+ export async function loadPluginModule(name) {
50
+ try {
51
+ const mod = await import(name);
52
+ const plugin = mod.default || mod.plugin || mod;
53
+ if (!plugin.name || !plugin.version)
54
+ return null;
55
+ return {
56
+ plugin,
57
+ configSchema: plugin.configSchema || {},
58
+ };
59
+ }
60
+ catch (err) {
61
+ console.error(`[PluginDiscovery] Failed to import "${name}":`, err.message);
62
+ return null;
63
+ }
64
+ }