@jieunmarslim/server-editable-slides 0.1.3 → 0.2.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/index.js +251 -52
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -13,6 +13,9 @@ const BASE_URL =
|
|
|
13
13
|
process.env.EDITABLE_SLIDES_URL ||
|
|
14
14
|
'https://editable-slides-mcp-1056428002550.us-central1.run.app';
|
|
15
15
|
|
|
16
|
+
// In-memory mapping of active conversion jobs to local file origins
|
|
17
|
+
const activeJobs = new Map();
|
|
18
|
+
|
|
16
19
|
function getAuthToken() {
|
|
17
20
|
if (process.env.EDITABLE_SLIDES_API_KEY) {
|
|
18
21
|
return process.env.EDITABLE_SLIDES_API_KEY;
|
|
@@ -29,7 +32,20 @@ function getAuthToken() {
|
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
function getProject() {
|
|
32
|
-
|
|
35
|
+
if (process.env.EDITABLE_SLIDES_PROJECT) {
|
|
36
|
+
return process.env.EDITABLE_SLIDES_PROJECT;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const p = execSync('gcloud config get-value project', {
|
|
40
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
41
|
+
})
|
|
42
|
+
.toString()
|
|
43
|
+
.trim();
|
|
44
|
+
if (p && p !== '(unset)') return p;
|
|
45
|
+
} catch {}
|
|
46
|
+
throw new Error(
|
|
47
|
+
'No GCP project detected for Gemini Enterprise Agent Platform (GEAP) billing. Set your project via `gcloud config set project <PROJECT_ID>` or export EDITABLE_SLIDES_PROJECT.',
|
|
48
|
+
);
|
|
33
49
|
}
|
|
34
50
|
|
|
35
51
|
async function callRemoteMcp(method, params, apiKey) {
|
|
@@ -71,14 +87,72 @@ async function callRemoteMcp(method, params, apiKey) {
|
|
|
71
87
|
return data.result;
|
|
72
88
|
}
|
|
73
89
|
|
|
90
|
+
async function uploadLocalFile(filePath, apiKey) {
|
|
91
|
+
if (!fs.existsSync(filePath)) {
|
|
92
|
+
throw new Error(`File not found: ${filePath}`);
|
|
93
|
+
}
|
|
94
|
+
const fileName = path.basename(filePath);
|
|
95
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
96
|
+
const mimeType =
|
|
97
|
+
ext === '.pdf'
|
|
98
|
+
? 'application/pdf'
|
|
99
|
+
: ext === '.jpg' || ext === '.jpeg'
|
|
100
|
+
? 'image/jpeg'
|
|
101
|
+
: 'image/png';
|
|
102
|
+
const fileBytes = await fs.promises.readFile(filePath);
|
|
103
|
+
|
|
104
|
+
console.error(`[EditNBLM] Requesting secure upload URL for "${fileName}"...`);
|
|
105
|
+
const uploadResp = await callRemoteMcp(
|
|
106
|
+
'tools/call',
|
|
107
|
+
{
|
|
108
|
+
name: 'request_upload_url',
|
|
109
|
+
arguments: { fileName, mimeType },
|
|
110
|
+
},
|
|
111
|
+
apiKey,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const parsed = JSON.parse(uploadResp.content[0].text);
|
|
115
|
+
const { uploadUrl, fileUrl: uploadedFileUrl } = parsed;
|
|
116
|
+
|
|
117
|
+
console.error(
|
|
118
|
+
`[EditNBLM] Uploading document (${(fileBytes.length / 1024).toFixed(1)} KB)...`,
|
|
119
|
+
);
|
|
120
|
+
const putRes = await fetch(uploadUrl, {
|
|
121
|
+
method: 'PUT',
|
|
122
|
+
headers: { 'Content-Type': mimeType },
|
|
123
|
+
body: fileBytes,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (!putRes.ok) {
|
|
127
|
+
throw new Error(`Upload failed: ${putRes.status} ${putRes.statusText}`);
|
|
128
|
+
}
|
|
129
|
+
return { fileName, fileUrl: uploadedFileUrl, isPdf: ext === '.pdf' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function downloadPptxHelper(downloadUrl, targetPath) {
|
|
133
|
+
try {
|
|
134
|
+
console.error(`[EditNBLM] Saving PowerPoint file to "${targetPath}"...`);
|
|
135
|
+
const res = await fetch(downloadUrl);
|
|
136
|
+
if (res.ok) {
|
|
137
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
138
|
+
await fs.promises.writeFile(targetPath, buf);
|
|
139
|
+
return targetPath;
|
|
140
|
+
}
|
|
141
|
+
} catch (err) {
|
|
142
|
+
console.error(`[EditNBLM] Failed to download PPTX locally: ${err.message}`);
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
74
147
|
const server = new McpServer({
|
|
75
148
|
name: 'editable-slides',
|
|
76
149
|
version: '1.0.0',
|
|
77
150
|
});
|
|
78
151
|
|
|
152
|
+
// 1. Fast Synchronous Slide Conversion (Single Image)
|
|
79
153
|
server.tool(
|
|
80
154
|
'create_slides_from_image',
|
|
81
|
-
'Convert a presentation slide image
|
|
155
|
+
'Convert a single presentation slide image (PNG/JPG) synchronously into an editable deck. For multi-page PDFs or multi-slide documents, this tool automatically triggers background conversion to prevent client timeouts. DO NOT write code or install packages; present the output directly or use existing Google Drive MCP tools.',
|
|
82
156
|
{
|
|
83
157
|
filePath: z
|
|
84
158
|
.string()
|
|
@@ -99,54 +173,56 @@ server.tool(
|
|
|
99
173
|
},
|
|
100
174
|
async ({ filePath, fileUrl, format, apiKey }) => {
|
|
101
175
|
let targetUrl = fileUrl;
|
|
176
|
+
let originalName = fileUrl ? path.basename(fileUrl) : 'presentation';
|
|
177
|
+
let isPdf = false;
|
|
102
178
|
|
|
103
179
|
if (filePath && !targetUrl) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
180
|
+
const uploaded = await uploadLocalFile(filePath, apiKey);
|
|
181
|
+
targetUrl = uploaded.fileUrl;
|
|
182
|
+
originalName = uploaded.fileName;
|
|
183
|
+
isPdf = uploaded.isPdf;
|
|
184
|
+
} else if (targetUrl && targetUrl.toLowerCase().endsWith('.pdf')) {
|
|
185
|
+
isPdf = true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!targetUrl) {
|
|
189
|
+
throw new Error('Either filePath or fileUrl must be provided.');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// If document is a multi-page PDF, route to async pipeline to prevent 120s MCP timeout
|
|
193
|
+
if (isPdf) {
|
|
194
|
+
console.error('[EditNBLM] Multi-page PDF detected. Starting background pipeline to prevent timeout...');
|
|
195
|
+
const startResult = await callRemoteMcp(
|
|
119
196
|
'tools/call',
|
|
120
197
|
{
|
|
121
|
-
name: '
|
|
122
|
-
arguments: {
|
|
198
|
+
name: 'start_slide_conversion',
|
|
199
|
+
arguments: {
|
|
200
|
+
fileUrl: targetUrl,
|
|
201
|
+
fileName: originalName,
|
|
202
|
+
format: format || 'slides',
|
|
203
|
+
},
|
|
123
204
|
},
|
|
124
205
|
apiKey,
|
|
125
206
|
);
|
|
126
207
|
|
|
127
|
-
const parsed = JSON.parse(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (!putRes.ok) {
|
|
140
|
-
throw new Error(`Upload failed: ${putRes.status} ${putRes.statusText}`);
|
|
208
|
+
const parsed = JSON.parse(startResult.content[0].text);
|
|
209
|
+
if (parsed.jobId) {
|
|
210
|
+
activeJobs.set(parsed.jobId, { filePath, fileName: originalName });
|
|
211
|
+
parsed.note =
|
|
212
|
+
'Multi-page PDF detected. High-performance conversion started in background to prevent client timeouts. ' +
|
|
213
|
+
'STRICT MANDATE FOR AGENT: Immediately call `check_slide_conversion` with this jobId to track progress until done. ' +
|
|
214
|
+
'DO NOT write custom scripts or install packages.';
|
|
215
|
+
parsed.agentInstructions = [
|
|
216
|
+
`Call check_slide_conversion with jobId: "${parsed.jobId}" to follow page-by-page progress.`,
|
|
217
|
+
'When check_slide_conversion returns status "done", the finished presentation will be automatically saved locally.',
|
|
218
|
+
];
|
|
219
|
+
startResult.content[0].text = JSON.stringify(parsed, null, 2);
|
|
141
220
|
}
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (!targetUrl) {
|
|
146
|
-
throw new Error('Either filePath or fileUrl must be provided.');
|
|
221
|
+
return startResult;
|
|
147
222
|
}
|
|
148
223
|
|
|
149
|
-
|
|
224
|
+
// Synchronous single-image conversion
|
|
225
|
+
console.error(`[EditNBLM] Converting image to ${format || 'slides'}...`);
|
|
150
226
|
const result = await callRemoteMcp(
|
|
151
227
|
'tools/call',
|
|
152
228
|
{
|
|
@@ -159,20 +235,12 @@ server.tool(
|
|
|
159
235
|
apiKey,
|
|
160
236
|
);
|
|
161
237
|
|
|
162
|
-
// If a PPTX download URL was returned and the caller used a local filePath,
|
|
163
|
-
// automatically download the .pptx directly to the local disk and guide the
|
|
164
|
-
// agent to use Google Drive MCP to convert it into a live Google Slide.
|
|
165
238
|
try {
|
|
166
239
|
const parsed = JSON.parse(result.content[0].text);
|
|
167
240
|
if (parsed.downloadUrl && filePath) {
|
|
168
241
|
const localPptxPath = filePath.replace(/\.[^/.]+$/, '') + '.pptx';
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
if (pptxRes.ok) {
|
|
172
|
-
const buf = Buffer.from(await pptxRes.arrayBuffer());
|
|
173
|
-
await fs.promises.writeFile(localPptxPath, buf);
|
|
174
|
-
parsed.localPptxPath = localPptxPath;
|
|
175
|
-
}
|
|
242
|
+
await downloadPptxHelper(parsed.downloadUrl, localPptxPath);
|
|
243
|
+
parsed.localPptxPath = localPptxPath;
|
|
176
244
|
}
|
|
177
245
|
if (parsed.downloadUrl || parsed.localPptxPath) {
|
|
178
246
|
parsed.agentInstructions = [
|
|
@@ -184,20 +252,151 @@ server.tool(
|
|
|
184
252
|
}
|
|
185
253
|
result.content[0].text = JSON.stringify(parsed, null, 2);
|
|
186
254
|
} catch {
|
|
187
|
-
//
|
|
255
|
+
// Retain original result
|
|
188
256
|
}
|
|
189
257
|
|
|
190
258
|
return result;
|
|
191
259
|
},
|
|
192
260
|
);
|
|
193
261
|
|
|
262
|
+
// 2. Start Asynchronous Slide Conversion (for PDFs & heavy decks)
|
|
263
|
+
server.tool(
|
|
264
|
+
'start_slide_conversion',
|
|
265
|
+
'Begin converting an image or multi-page PDF (e.g. 15-page presentation) into an editable presentation deck. Accepts a local filePath or remote fileUrl. Returns a jobId immediately in ~1 second without client timeouts. Follow up by calling check_slide_conversion with the returned jobId.',
|
|
266
|
+
{
|
|
267
|
+
filePath: z
|
|
268
|
+
.string()
|
|
269
|
+
.optional()
|
|
270
|
+
.describe('Local file path to the slide image or PDF (e.g. /path/to/asdf.pdf)'),
|
|
271
|
+
fileUrl: z
|
|
272
|
+
.string()
|
|
273
|
+
.optional()
|
|
274
|
+
.describe('Public HTTP(S) or gs:// URL of the slide image or PDF'),
|
|
275
|
+
fileName: z
|
|
276
|
+
.string()
|
|
277
|
+
.optional()
|
|
278
|
+
.describe('Original file name if known'),
|
|
279
|
+
format: z
|
|
280
|
+
.enum(['slides', 'pptx'])
|
|
281
|
+
.optional()
|
|
282
|
+
.describe("What to produce: 'slides' for a Google Slides deck (default), or 'pptx'"),
|
|
283
|
+
apiKey: z
|
|
284
|
+
.string()
|
|
285
|
+
.optional()
|
|
286
|
+
.describe('Optional API key or access token for authentication'),
|
|
287
|
+
},
|
|
288
|
+
async ({ filePath, fileUrl, fileName, format, apiKey }) => {
|
|
289
|
+
let targetUrl = fileUrl;
|
|
290
|
+
let targetFileName = fileName;
|
|
291
|
+
|
|
292
|
+
if (filePath && !targetUrl) {
|
|
293
|
+
const uploaded = await uploadLocalFile(filePath, apiKey);
|
|
294
|
+
targetUrl = uploaded.fileUrl;
|
|
295
|
+
targetFileName = targetFileName || uploaded.fileName;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!targetUrl) {
|
|
299
|
+
throw new Error('Either filePath or fileUrl must be provided.');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
console.error(`[EditNBLM] Starting background conversion for "${targetFileName || targetUrl}"...`);
|
|
303
|
+
const startResult = await callRemoteMcp(
|
|
304
|
+
'tools/call',
|
|
305
|
+
{
|
|
306
|
+
name: 'start_slide_conversion',
|
|
307
|
+
arguments: {
|
|
308
|
+
fileUrl: targetUrl,
|
|
309
|
+
fileName: targetFileName,
|
|
310
|
+
format: format || 'slides',
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
apiKey,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
const parsed = JSON.parse(startResult.content[0].text);
|
|
318
|
+
if (parsed.jobId) {
|
|
319
|
+
activeJobs.set(parsed.jobId, { filePath, fileName: targetFileName });
|
|
320
|
+
parsed.agentInstructions = [
|
|
321
|
+
`Call check_slide_conversion with jobId: "${parsed.jobId}" to follow page-by-page progress.`,
|
|
322
|
+
'DO NOT write scripts or install packages while waiting.',
|
|
323
|
+
];
|
|
324
|
+
startResult.content[0].text = JSON.stringify(parsed, null, 2);
|
|
325
|
+
}
|
|
326
|
+
} catch {}
|
|
327
|
+
|
|
328
|
+
return startResult;
|
|
329
|
+
},
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
// 3. Check Slide Conversion Progress & Auto-Download
|
|
333
|
+
server.tool(
|
|
334
|
+
'check_slide_conversion',
|
|
335
|
+
'Report the progress of a conversion started by start_slide_conversion. Call this repeatedly until status is "done". When complete, automatically downloads the editable PowerPoint (.pptx) file to local disk.',
|
|
336
|
+
{
|
|
337
|
+
jobId: z
|
|
338
|
+
.string()
|
|
339
|
+
.describe('The jobId returned by start_slide_conversion or create_slides_from_image'),
|
|
340
|
+
sinceUpdatedAt: z
|
|
341
|
+
.number()
|
|
342
|
+
.optional()
|
|
343
|
+
.describe('updatedAt from previous check to wait for new progress updates'),
|
|
344
|
+
apiKey: z
|
|
345
|
+
.string()
|
|
346
|
+
.optional()
|
|
347
|
+
.describe('Optional API key or access token for authentication'),
|
|
348
|
+
},
|
|
349
|
+
async ({ jobId, sinceUpdatedAt, apiKey }) => {
|
|
350
|
+
const result = await callRemoteMcp(
|
|
351
|
+
'tools/call',
|
|
352
|
+
{
|
|
353
|
+
name: 'check_slide_conversion',
|
|
354
|
+
arguments: { jobId, sinceUpdatedAt },
|
|
355
|
+
},
|
|
356
|
+
apiKey,
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const parsed = JSON.parse(result.content[0].text);
|
|
361
|
+
const jobInfo = activeJobs.get(jobId);
|
|
362
|
+
|
|
363
|
+
if (parsed.status === 'done') {
|
|
364
|
+
if (parsed.downloadUrl) {
|
|
365
|
+
let destPath = null;
|
|
366
|
+
if (jobInfo?.filePath) {
|
|
367
|
+
destPath = jobInfo.filePath.replace(/\.[^/.]+$/, '') + '.pptx';
|
|
368
|
+
} else {
|
|
369
|
+
const safeName = (jobInfo?.fileName || 'presentation').replace(/\.[^/.]+$/, '');
|
|
370
|
+
destPath = path.resolve(process.cwd(), `${safeName}.pptx`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
await downloadPptxHelper(parsed.downloadUrl, destPath);
|
|
374
|
+
parsed.localPptxPath = destPath;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
parsed.agentInstructions = [
|
|
378
|
+
'TASK FINISHED: The presentation has been successfully generated and saved as an editable PowerPoint (.pptx) file.',
|
|
379
|
+
parsed.localPptxPath ? `Local file: ${parsed.localPptxPath}` : null,
|
|
380
|
+
'STRICT MANDATE FOR AGENT: DO NOT write custom scripts, install Python/Node packages, or execute terminal commands to convert this file.',
|
|
381
|
+
'1. If you have an active Google Drive MCP tool (e.g. drive.uploadFile or gws-drive), call it now to upload the .pptx file with Google Slides conversion.',
|
|
382
|
+
'2. If you DO NOT have a Google Drive MCP tool, present the local file path and download URL to the user directly.',
|
|
383
|
+
].filter(Boolean);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
result.content[0].text = JSON.stringify(parsed, null, 2);
|
|
387
|
+
} catch {}
|
|
388
|
+
|
|
389
|
+
return result;
|
|
390
|
+
},
|
|
391
|
+
);
|
|
392
|
+
|
|
194
393
|
async function main() {
|
|
195
394
|
const transport = new StdioServerTransport();
|
|
196
395
|
await server.connect(transport);
|
|
197
|
-
console.error('[
|
|
396
|
+
console.error('[EditNBLM] Ready and connected to EditNBLM Cloud.');
|
|
198
397
|
}
|
|
199
398
|
|
|
200
399
|
main().catch((err) => {
|
|
201
|
-
console.error('[
|
|
400
|
+
console.error('[EditNBLM] Fatal error:', err);
|
|
202
401
|
process.exit(1);
|
|
203
402
|
});
|