@echofiles/echo-pdf 0.3.1 → 0.4.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/README.md CHANGED
@@ -48,6 +48,13 @@ npm i -g @echofiles/echo-pdf
48
48
  echo-pdf init --service-url https://echo-pdf.echofilesai.workers.dev
49
49
  ```
50
50
 
51
+ 本地一键启动服务(daemon):
52
+
53
+ ```bash
54
+ echo-pdf dev --port 8788
55
+ echo-pdf init --service-url http://127.0.0.1:8788
56
+ ```
57
+
51
58
  配置 API Key(仅保存在本机 CLI 配置,不会上报到服务端存储):
52
59
 
53
60
  ```bash
@@ -115,6 +122,20 @@ echo-pdf mcp call --tool pdf_extract_pages --args '{
115
122
  }'
116
123
  ```
117
124
 
125
+ ### 3.1.3 stdio MCP(支持本地文件路径)
126
+
127
+ stdio 模式会把本地 `path/filePath` 自动上传为 `fileId` 后再调用远端工具。
128
+
129
+ ```bash
130
+ echo-pdf mcp stdio
131
+ ```
132
+
133
+ 生成 Claude Desktop/Cursor 等可用的 stdio 配置片段:
134
+
135
+ ```bash
136
+ echo-pdf setup add claude-desktop --mode stdio
137
+ ```
138
+
118
139
  ### 3.2 给客户端生成 MCP 配置片段
119
140
 
120
141
  ```bash
@@ -172,6 +193,12 @@ curl -sS -X POST https://echo-pdf.echofilesai.workers.dev/api/files/upload \
172
193
 
173
194
  返回中会拿到 `file.id`。
174
195
 
196
+ CLI 等价命令:
197
+
198
+ ```bash
199
+ echo-pdf file upload ./sample.pdf
200
+ ```
201
+
175
202
  ### 5.2 提取页面图片
176
203
 
177
204
  ```bash
@@ -185,6 +212,18 @@ curl -sS -X POST https://echo-pdf.echofilesai.workers.dev/tools/call \
185
212
  }'
186
213
  ```
187
214
 
215
+ CLI(支持直接传本地路径):
216
+
217
+ ```bash
218
+ echo-pdf call --tool pdf_extract_pages --args '{"path":"./sample.pdf","pages":[1],"returnMode":"url"}'
219
+ ```
220
+
221
+ 下载产物:
222
+
223
+ ```bash
224
+ echo-pdf file get --file-id <FILE_ID> --out ./output.bin
225
+ ```
226
+
188
227
  ### 5.3 OCR
189
228
 
190
229
  ```bash
package/bin/echo-pdf.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { spawn } from "node:child_process"
2
3
  import fs from "node:fs"
3
4
  import os from "node:os"
4
5
  import path from "node:path"
@@ -208,6 +209,170 @@ const buildMcpRequest = (id, method, params = {}) => ({
208
209
  params,
209
210
  })
210
211
 
212
+ const uploadFile = async (serviceUrl, filePath) => {
213
+ const absPath = path.resolve(process.cwd(), filePath)
214
+ const bytes = fs.readFileSync(absPath)
215
+ const filename = path.basename(absPath)
216
+ const form = new FormData()
217
+ form.append("file", new Blob([bytes]), filename)
218
+ const response = await fetch(`${serviceUrl}/api/files/upload`, { method: "POST", body: form })
219
+ const text = await response.text()
220
+ let data
221
+ try {
222
+ data = JSON.parse(text)
223
+ } catch {
224
+ data = { raw: text }
225
+ }
226
+ if (!response.ok) {
227
+ throw new Error(`${response.status} ${JSON.stringify(data)}`)
228
+ }
229
+ return data
230
+ }
231
+
232
+ const downloadFile = async (serviceUrl, fileId, outputPath) => {
233
+ const response = await fetch(`${serviceUrl}/api/files/get?fileId=${encodeURIComponent(fileId)}&download=1`)
234
+ if (!response.ok) {
235
+ const text = await response.text()
236
+ throw new Error(`${response.status} ${text}`)
237
+ }
238
+ const bytes = Buffer.from(await response.arrayBuffer())
239
+ const absOut = path.resolve(process.cwd(), outputPath)
240
+ fs.mkdirSync(path.dirname(absOut), { recursive: true })
241
+ fs.writeFileSync(absOut, bytes)
242
+ return absOut
243
+ }
244
+
245
+ const withUploadedLocalFile = async (serviceUrl, tool, args) => {
246
+ const nextArgs = { ...(args || {}) }
247
+ if (tool.startsWith("pdf_")) {
248
+ const localPath = typeof nextArgs.path === "string"
249
+ ? nextArgs.path
250
+ : (typeof nextArgs.filePath === "string" ? nextArgs.filePath : "")
251
+ if (localPath && !nextArgs.fileId && !nextArgs.url && !nextArgs.base64) {
252
+ const upload = await uploadFile(serviceUrl, localPath)
253
+ const fileId = upload?.file?.id
254
+ if (!fileId) throw new Error(`upload failed for local path: ${localPath}`)
255
+ nextArgs.fileId = fileId
256
+ delete nextArgs.path
257
+ delete nextArgs.filePath
258
+ }
259
+ }
260
+ return nextArgs
261
+ }
262
+
263
+ const runDevServer = (port, host) => {
264
+ const wranglerBin = path.resolve(__dirname, "../node_modules/.bin/wrangler")
265
+ const wranglerArgs = ["dev", "--port", String(port), "--ip", host]
266
+ const cmd = fs.existsSync(wranglerBin) ? wranglerBin : "npx"
267
+ const args = fs.existsSync(wranglerBin) ? wranglerArgs : ["-y", "wrangler", ...wranglerArgs]
268
+ const child = spawn(cmd, args, {
269
+ stdio: "inherit",
270
+ env: process.env,
271
+ cwd: process.cwd(),
272
+ })
273
+ child.on("exit", (code, signal) => {
274
+ if (signal) process.kill(process.pid, signal)
275
+ process.exit(code ?? 0)
276
+ })
277
+ }
278
+
279
+ const mcpReadLoop = (onMessage, onError) => {
280
+ let buffer = Buffer.alloc(0)
281
+ let expectedLength = null
282
+ process.stdin.on("data", (chunk) => {
283
+ buffer = Buffer.concat([buffer, chunk])
284
+ while (true) {
285
+ if (expectedLength === null) {
286
+ const headerEnd = buffer.indexOf("\r\n\r\n")
287
+ if (headerEnd === -1) break
288
+ const headerRaw = buffer.slice(0, headerEnd).toString("utf-8")
289
+ const lines = headerRaw.split("\r\n")
290
+ const cl = lines.find((line) => line.toLowerCase().startsWith("content-length:"))
291
+ if (!cl) {
292
+ onError(new Error("Missing Content-Length"))
293
+ buffer = buffer.slice(headerEnd + 4)
294
+ continue
295
+ }
296
+ expectedLength = Number(cl.split(":")[1]?.trim() || "0")
297
+ buffer = buffer.slice(headerEnd + 4)
298
+ }
299
+ if (!Number.isFinite(expectedLength) || expectedLength < 0) {
300
+ onError(new Error("Invalid Content-Length"))
301
+ expectedLength = null
302
+ continue
303
+ }
304
+ if (buffer.length < expectedLength) break
305
+ const body = buffer.slice(0, expectedLength).toString("utf-8")
306
+ buffer = buffer.slice(expectedLength)
307
+ expectedLength = null
308
+ try {
309
+ const maybePromise = onMessage(JSON.parse(body))
310
+ if (maybePromise && typeof maybePromise.then === "function") {
311
+ maybePromise.catch(onError)
312
+ }
313
+ } catch (error) {
314
+ onError(error)
315
+ }
316
+ }
317
+ })
318
+ }
319
+
320
+ const mcpWrite = (obj) => {
321
+ const body = Buffer.from(JSON.stringify(obj))
322
+ const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`)
323
+ process.stdout.write(header)
324
+ process.stdout.write(body)
325
+ }
326
+
327
+ const runMcpStdio = async () => {
328
+ const config = loadConfig()
329
+ const serviceUrl = config.serviceUrl
330
+ const headers = buildMcpHeaders()
331
+ mcpReadLoop(async (msg) => {
332
+ const method = msg?.method
333
+ const id = Object.hasOwn(msg || {}, "id") ? msg.id : null
334
+ if (msg?.jsonrpc !== "2.0" || typeof method !== "string") {
335
+ mcpWrite({ jsonrpc: "2.0", id, error: { code: -32600, message: "Invalid Request" } })
336
+ return
337
+ }
338
+ if (method === "notifications/initialized") return
339
+ if (method === "initialize" || method === "tools/list") {
340
+ const data = await postJson(`${serviceUrl}/mcp`, msg, headers)
341
+ mcpWrite(data)
342
+ return
343
+ }
344
+ if (method === "tools/call") {
345
+ try {
346
+ const tool = String(msg?.params?.name || "")
347
+ const args = (msg?.params?.arguments && typeof msg.params.arguments === "object")
348
+ ? msg.params.arguments
349
+ : {}
350
+ const preparedArgs = await withUploadedLocalFile(serviceUrl, tool, args)
351
+ const payload = {
352
+ ...msg,
353
+ params: {
354
+ ...(msg.params || {}),
355
+ arguments: preparedArgs,
356
+ },
357
+ }
358
+ const data = await postJson(`${serviceUrl}/mcp`, payload, headers)
359
+ mcpWrite(data)
360
+ } catch (error) {
361
+ mcpWrite({
362
+ jsonrpc: "2.0",
363
+ id,
364
+ error: { code: -32603, message: error instanceof Error ? error.message : String(error) },
365
+ })
366
+ }
367
+ return
368
+ }
369
+ const data = await postJson(`${serviceUrl}/mcp`, msg, headers)
370
+ mcpWrite(data)
371
+ }, (error) => {
372
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
373
+ })
374
+ }
375
+
211
376
  const parseConfigValue = (raw, type = "auto") => {
212
377
  if (type === "string") return String(raw)
213
378
  if (type === "number") {
@@ -302,6 +467,7 @@ const usage = () => {
302
467
  process.stdout.write(`echo-pdf CLI\n\n`)
303
468
  process.stdout.write(`Commands:\n`)
304
469
  process.stdout.write(` init [--service-url URL]\n`)
470
+ process.stdout.write(` dev [--port 8788] [--host 127.0.0.1]\n`)
305
471
  process.stdout.write(` provider set --provider <${PROVIDER_SET_NAMES.join("|")}> --api-key <KEY> [--profile name]\n`)
306
472
  process.stdout.write(` provider use --provider <${PROVIDER_ALIASES.join("|")}> [--profile name]\n`)
307
473
  process.stdout.write(` provider list [--profile name]\n`)
@@ -312,13 +478,29 @@ const usage = () => {
312
478
  process.stdout.write(` model list [--profile name]\n`)
313
479
  process.stdout.write(` tools\n`)
314
480
  process.stdout.write(` call --tool <name> --args '<json>' [--provider alias] [--model model] [--profile name]\n`)
481
+ process.stdout.write(` file upload <local.pdf>\n`)
482
+ process.stdout.write(` file get --file-id <id> --out <path>\n`)
315
483
  process.stdout.write(` mcp initialize\n`)
316
484
  process.stdout.write(` mcp tools\n`)
317
485
  process.stdout.write(` mcp call --tool <name> --args '<json>'\n`)
486
+ process.stdout.write(` mcp stdio\n`)
318
487
  process.stdout.write(` setup add <claude-desktop|claude-code|cursor|cline|windsurf|gemini|json>\n`)
319
488
  }
320
489
 
321
- const setupSnippet = (tool, serviceUrl) => {
490
+ const setupSnippet = (tool, serviceUrl, mode = "http") => {
491
+ if (mode === "stdio") {
492
+ return {
493
+ mcpServers: {
494
+ "echo-pdf": {
495
+ command: "echo-pdf",
496
+ args: ["mcp", "stdio"],
497
+ env: {
498
+ ECHO_PDF_SERVICE_URL: serviceUrl,
499
+ },
500
+ },
501
+ },
502
+ }
503
+ }
322
504
  const transport = {
323
505
  type: "streamable-http",
324
506
  url: `${serviceUrl}/mcp`,
@@ -405,6 +587,14 @@ const main = async () => {
405
587
  return
406
588
  }
407
589
 
590
+ if (command === "dev") {
591
+ const port = typeof flags.port === "string" ? Number(flags.port) : 8788
592
+ const host = typeof flags.host === "string" ? flags.host : "127.0.0.1"
593
+ if (!Number.isFinite(port) || port <= 0) throw new Error("dev --port must be positive number")
594
+ runDevServer(Math.floor(port), host)
595
+ return
596
+ }
597
+
408
598
  if (command === "provider" && subcommand === "set") {
409
599
  const providerAlias = resolveProviderAliasInput(flags.provider)
410
600
  const apiKey = flags["api-key"]
@@ -538,15 +728,42 @@ const main = async () => {
538
728
  const tool = flags.tool
539
729
  if (typeof tool !== "string") throw new Error("call requires --tool")
540
730
  const args = typeof flags.args === "string" ? JSON.parse(flags.args) : {}
731
+ const preparedArgs = await withUploadedLocalFile(config.serviceUrl, tool, args)
541
732
  const provider = resolveProviderAlias(profile, flags.provider)
542
733
  const model = typeof flags.model === "string" ? flags.model : resolveDefaultModel(profile, provider)
543
734
  const providerApiKeys = buildProviderApiKeys(config, profileName)
544
- const payload = buildToolCallRequest({ tool, args, provider, model, providerApiKeys })
735
+ const payload = buildToolCallRequest({ tool, args: preparedArgs, provider, model, providerApiKeys })
545
736
  const data = await postJson(`${config.serviceUrl}/tools/call`, payload)
546
737
  print(data)
547
738
  return
548
739
  }
549
740
 
741
+ if (command === "file") {
742
+ const action = rest[0] || ""
743
+ const config = loadConfig()
744
+ if (action === "upload") {
745
+ const filePath = rest[1]
746
+ if (!filePath) throw new Error("file upload requires a path")
747
+ const data = await uploadFile(config.serviceUrl, filePath)
748
+ print({
749
+ fileId: data?.file?.id || "",
750
+ filename: data?.file?.filename || path.basename(filePath),
751
+ sizeBytes: data?.file?.sizeBytes || 0,
752
+ file: data?.file || null,
753
+ })
754
+ return
755
+ }
756
+ if (action === "get") {
757
+ const fileId = typeof flags["file-id"] === "string" ? flags["file-id"] : ""
758
+ const out = typeof flags.out === "string" ? flags.out : ""
759
+ if (!fileId || !out) throw new Error("file get requires --file-id and --out")
760
+ const savedTo = await downloadFile(config.serviceUrl, fileId, out)
761
+ print({ ok: true, fileId, savedTo })
762
+ return
763
+ }
764
+ throw new Error("file command supports: upload|get")
765
+ }
766
+
550
767
  if (command === "mcp" && subcommand === "initialize") {
551
768
  const config = loadConfig()
552
769
  const data = await postJson(`${config.serviceUrl}/mcp`, buildMcpRequest(1, "initialize"), buildMcpHeaders())
@@ -575,11 +792,18 @@ const main = async () => {
575
792
  return
576
793
  }
577
794
 
795
+ if (command === "mcp" && subcommand === "stdio") {
796
+ await runMcpStdio()
797
+ return
798
+ }
799
+
578
800
  if (command === "setup" && subcommand === "add") {
579
801
  const tool = rest[0]
580
802
  if (!tool) throw new Error("setup add requires tool name")
581
803
  const config = loadConfig()
582
- print(setupSnippet(tool, config.serviceUrl))
804
+ const mode = typeof flags.mode === "string" ? flags.mode : "http"
805
+ if (!["http", "stdio"].includes(mode)) throw new Error("setup add --mode must be http|stdio")
806
+ print(setupSnippet(tool, config.serviceUrl, mode))
583
807
  return
584
808
  }
585
809
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@echofiles/echo-pdf",
3
3
  "description": "MCP-first PDF agent on Cloudflare Workers with CLI and web demo.",
4
- "version": "0.3.1",
4
+ "version": "0.4.0",
5
5
  "type": "module",
6
6
  "publishConfig": {
7
7
  "access": "public"