@echofiles/echo-pdf 0.4.0 → 0.4.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
@@ -144,6 +144,7 @@ echo-pdf setup add cursor
144
144
  echo-pdf setup add cline
145
145
  echo-pdf setup add windsurf
146
146
  echo-pdf setup add json
147
+ echo-pdf setup add claude-desktop --mode stdio
147
148
  ```
148
149
 
149
150
  `setup add` 输出的是配置片段,把它合并到对应客户端的 MCP 配置文件。
@@ -283,6 +284,10 @@ curl -sS -X POST https://echo-pdf.echofilesai.workers.dev/tools/call \
283
284
  - `ECHO_PDF_MCP_KEY`(可选,启用 MCP 鉴权)
284
285
  - `ECHO_PDF_WORKER_NAME`(CLI 默认 URL 推导)
285
286
 
287
+ 鉴权注意:
288
+
289
+ - 如果配置了 `authHeader/authEnv` 但未注入对应 secret,服务会返回配置错误(fail-closed),不会默认放行。
290
+
286
291
  ## 7. 本地开发与测试
287
292
 
288
293
  安装与开发:
package/bin/echo-pdf.js CHANGED
@@ -4,6 +4,8 @@ import fs from "node:fs"
4
4
  import os from "node:os"
5
5
  import path from "node:path"
6
6
  import { fileURLToPath } from "node:url"
7
+ import { downloadFile, postJson, uploadFile, withUploadedLocalFile } from "./lib/http.js"
8
+ import { runMcpStdio } from "./lib/mcp-stdio.js"
7
9
 
8
10
  const CONFIG_DIR = path.join(os.homedir(), ".config", "echo-pdf-cli")
9
11
  const CONFIG_FILE = path.join(CONFIG_DIR, "config.json")
@@ -163,25 +165,6 @@ const buildProviderApiKeys = (config, profileName) => {
163
165
  return providerApiKeys
164
166
  }
165
167
 
166
- const postJson = async (url, payload, extraHeaders = {}) => {
167
- const response = await fetch(url, {
168
- method: "POST",
169
- headers: { "Content-Type": "application/json", ...extraHeaders },
170
- body: JSON.stringify(payload),
171
- })
172
- const text = await response.text()
173
- let data
174
- try {
175
- data = JSON.parse(text)
176
- } catch {
177
- data = { raw: text }
178
- }
179
- if (!response.ok) {
180
- throw new Error(`${response.status} ${JSON.stringify(data)}`)
181
- }
182
- return data
183
- }
184
-
185
168
  const print = (data) => {
186
169
  process.stdout.write(`${JSON.stringify(data, null, 2)}\n`)
187
170
  }
@@ -209,57 +192,6 @@ const buildMcpRequest = (id, method, params = {}) => ({
209
192
  params,
210
193
  })
211
194
 
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
195
  const runDevServer = (port, host) => {
264
196
  const wranglerBin = path.resolve(__dirname, "../node_modules/.bin/wrangler")
265
197
  const wranglerArgs = ["dev", "--port", String(port), "--ip", host]
@@ -276,100 +208,13 @@ const runDevServer = (port, host) => {
276
208
  })
277
209
  }
278
210
 
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 () => {
211
+ const runMcpStdioCommand = async () => {
328
212
  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`)
213
+ await runMcpStdio({
214
+ serviceUrl: config.serviceUrl,
215
+ headers: buildMcpHeaders(),
216
+ postJson,
217
+ withUploadedLocalFile,
373
218
  })
374
219
  }
375
220
 
@@ -793,7 +638,7 @@ const main = async () => {
793
638
  }
794
639
 
795
640
  if (command === "mcp" && subcommand === "stdio") {
796
- await runMcpStdio()
641
+ await runMcpStdioCommand()
797
642
  return
798
643
  }
799
644
 
@@ -0,0 +1,72 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+
4
+ export const postJson = async (url, payload, extraHeaders = {}) => {
5
+ const response = await fetch(url, {
6
+ method: "POST",
7
+ headers: { "Content-Type": "application/json", ...extraHeaders },
8
+ body: JSON.stringify(payload),
9
+ })
10
+ const text = await response.text()
11
+ let data
12
+ try {
13
+ data = JSON.parse(text)
14
+ } catch {
15
+ data = { raw: text }
16
+ }
17
+ if (!response.ok) {
18
+ throw new Error(`${response.status} ${JSON.stringify(data)}`)
19
+ }
20
+ return data
21
+ }
22
+
23
+ export const uploadFile = async (serviceUrl, filePath) => {
24
+ const absPath = path.resolve(process.cwd(), filePath)
25
+ const bytes = fs.readFileSync(absPath)
26
+ const filename = path.basename(absPath)
27
+ const form = new FormData()
28
+ form.append("file", new Blob([bytes]), filename)
29
+ const response = await fetch(`${serviceUrl}/api/files/upload`, { method: "POST", body: form })
30
+ const text = await response.text()
31
+ let data
32
+ try {
33
+ data = JSON.parse(text)
34
+ } catch {
35
+ data = { raw: text }
36
+ }
37
+ if (!response.ok) {
38
+ throw new Error(`${response.status} ${JSON.stringify(data)}`)
39
+ }
40
+ return data
41
+ }
42
+
43
+ export const downloadFile = async (serviceUrl, fileId, outputPath) => {
44
+ const response = await fetch(`${serviceUrl}/api/files/get?fileId=${encodeURIComponent(fileId)}&download=1`)
45
+ if (!response.ok) {
46
+ const text = await response.text()
47
+ throw new Error(`${response.status} ${text}`)
48
+ }
49
+ const bytes = Buffer.from(await response.arrayBuffer())
50
+ const absOut = path.resolve(process.cwd(), outputPath)
51
+ fs.mkdirSync(path.dirname(absOut), { recursive: true })
52
+ fs.writeFileSync(absOut, bytes)
53
+ return absOut
54
+ }
55
+
56
+ export const withUploadedLocalFile = async (serviceUrl, tool, args) => {
57
+ const nextArgs = { ...(args || {}) }
58
+ if (tool.startsWith("pdf_")) {
59
+ const localPath = typeof nextArgs.path === "string"
60
+ ? nextArgs.path
61
+ : (typeof nextArgs.filePath === "string" ? nextArgs.filePath : "")
62
+ if (localPath && !nextArgs.fileId && !nextArgs.url && !nextArgs.base64) {
63
+ const upload = await uploadFile(serviceUrl, localPath)
64
+ const fileId = upload?.file?.id
65
+ if (!fileId) throw new Error(`upload failed for local path: ${localPath}`)
66
+ nextArgs.fileId = fileId
67
+ delete nextArgs.path
68
+ delete nextArgs.filePath
69
+ }
70
+ }
71
+ return nextArgs
72
+ }
@@ -0,0 +1,99 @@
1
+ const mcpReadLoop = (onMessage, onError) => {
2
+ let buffer = Buffer.alloc(0)
3
+ let expectedLength = null
4
+ process.stdin.on("data", (chunk) => {
5
+ buffer = Buffer.concat([buffer, chunk])
6
+ while (true) {
7
+ if (expectedLength === null) {
8
+ const headerEnd = buffer.indexOf("\r\n\r\n")
9
+ if (headerEnd === -1) break
10
+ const headerRaw = buffer.slice(0, headerEnd).toString("utf-8")
11
+ const lines = headerRaw.split("\r\n")
12
+ const cl = lines.find((line) => line.toLowerCase().startsWith("content-length:"))
13
+ if (!cl) {
14
+ onError(new Error("Missing Content-Length"))
15
+ buffer = buffer.slice(headerEnd + 4)
16
+ continue
17
+ }
18
+ expectedLength = Number(cl.split(":")[1]?.trim() || "0")
19
+ buffer = buffer.slice(headerEnd + 4)
20
+ }
21
+ if (!Number.isFinite(expectedLength) || expectedLength < 0) {
22
+ onError(new Error("Invalid Content-Length"))
23
+ expectedLength = null
24
+ continue
25
+ }
26
+ if (buffer.length < expectedLength) break
27
+ const body = buffer.slice(0, expectedLength).toString("utf-8")
28
+ buffer = buffer.slice(expectedLength)
29
+ expectedLength = null
30
+ try {
31
+ const maybePromise = onMessage(JSON.parse(body))
32
+ if (maybePromise && typeof maybePromise.then === "function") {
33
+ maybePromise.catch(onError)
34
+ }
35
+ } catch (error) {
36
+ onError(error)
37
+ }
38
+ }
39
+ })
40
+ }
41
+
42
+ const mcpWrite = (obj) => {
43
+ const body = Buffer.from(JSON.stringify(obj))
44
+ const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`)
45
+ process.stdout.write(header)
46
+ process.stdout.write(body)
47
+ }
48
+
49
+ export const runMcpStdio = async (deps) => {
50
+ const {
51
+ serviceUrl,
52
+ headers,
53
+ postJson,
54
+ withUploadedLocalFile,
55
+ } = deps
56
+ mcpReadLoop(async (msg) => {
57
+ const method = msg?.method
58
+ const id = Object.hasOwn(msg || {}, "id") ? msg.id : null
59
+ if (msg?.jsonrpc !== "2.0" || typeof method !== "string") {
60
+ mcpWrite({ jsonrpc: "2.0", id, error: { code: -32600, message: "Invalid Request" } })
61
+ return
62
+ }
63
+ if (method === "notifications/initialized") return
64
+ if (method === "initialize" || method === "tools/list") {
65
+ const data = await postJson(`${serviceUrl}/mcp`, msg, headers)
66
+ mcpWrite(data)
67
+ return
68
+ }
69
+ if (method === "tools/call") {
70
+ try {
71
+ const tool = String(msg?.params?.name || "")
72
+ const args = (msg?.params?.arguments && typeof msg.params.arguments === "object")
73
+ ? msg.params.arguments
74
+ : {}
75
+ const preparedArgs = await withUploadedLocalFile(serviceUrl, tool, args)
76
+ const payload = {
77
+ ...msg,
78
+ params: {
79
+ ...(msg.params || {}),
80
+ arguments: preparedArgs,
81
+ },
82
+ }
83
+ const data = await postJson(`${serviceUrl}/mcp`, payload, headers)
84
+ mcpWrite(data)
85
+ } catch (error) {
86
+ mcpWrite({
87
+ jsonrpc: "2.0",
88
+ id,
89
+ error: { code: -32603, message: error instanceof Error ? error.message : String(error) },
90
+ })
91
+ }
92
+ return
93
+ }
94
+ const data = await postJson(`${serviceUrl}/mcp`, msg, headers)
95
+ mcpWrite(data)
96
+ }, (error) => {
97
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
98
+ })
99
+ }
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.4.0",
4
+ "version": "0.4.1",
5
5
  "type": "module",
6
6
  "publishConfig": {
7
7
  "access": "public"
package/src/index.ts CHANGED
@@ -69,11 +69,25 @@ const sanitizeDownloadFilename = (filename: string): string => {
69
69
  return cleaned.length > 0 ? cleaned : "download.bin"
70
70
  }
71
71
 
72
- const isFileGetAuthorized = (request: Request, env: Env, config: { authHeader?: string; authEnv?: string }): boolean => {
73
- if (!config.authHeader || !config.authEnv) return true
72
+ const fileGetAuthState = (
73
+ request: Request,
74
+ env: Env,
75
+ config: { authHeader?: string; authEnv?: string }
76
+ ): { ok: true } | { ok: false; status: number; code: string; message: string } => {
77
+ if (!config.authHeader || !config.authEnv) return { ok: true }
74
78
  const required = env[config.authEnv]
75
- if (typeof required !== "string" || required.length === 0) return true
76
- return request.headers.get(config.authHeader) === required
79
+ if (typeof required !== "string" || required.length === 0) {
80
+ return {
81
+ ok: false,
82
+ status: 500,
83
+ code: "AUTH_MISCONFIGURED",
84
+ message: `file get auth is configured but env "${config.authEnv}" is missing`,
85
+ }
86
+ }
87
+ if (request.headers.get(config.authHeader) !== required) {
88
+ return { ok: false, status: 401, code: "UNAUTHORIZED", message: "Unauthorized" }
89
+ }
90
+ return { ok: true }
77
91
  }
78
92
 
79
93
  const sseResponse = (stream: ReadableStream<Uint8Array>): Response =>
@@ -188,7 +202,7 @@ export default {
188
202
  preferredProvider,
189
203
  typeof body.model === "string" ? body.model : undefined
190
204
  )
191
- if (name.startsWith("pdf_")) {
205
+ if (name === "pdf_ocr_pages" || name === "pdf_tables_to_latex") {
192
206
  if (typeof args.provider !== "string" || args.provider.length === 0) {
193
207
  args.provider = preferredProvider
194
208
  }
@@ -221,7 +235,7 @@ export default {
221
235
  const models = await listProviderModels(config, env, provider, runtimeKeys)
222
236
  return json({ provider, models })
223
237
  } catch (error) {
224
- return json({ error: toError(error) }, 500)
238
+ return jsonError(error, 500)
225
239
  }
226
240
  }
227
241
 
@@ -324,8 +338,9 @@ export default {
324
338
 
325
339
  if (request.method === "GET" && url.pathname === "/api/files/get") {
326
340
  const fileGetConfig = config.service.fileGet ?? {}
327
- if (!isFileGetAuthorized(request, env, fileGetConfig)) {
328
- return json({ error: "Unauthorized", code: "UNAUTHORIZED" }, 401)
341
+ const auth = fileGetAuthState(request, env, fileGetConfig)
342
+ if (!auth.ok) {
343
+ return json({ error: auth.message, code: auth.code }, auth.status)
329
344
  }
330
345
  const fileId = url.searchParams.get("fileId") || ""
331
346
  if (!fileId) return json({ error: "Missing fileId" }, 400)
package/src/mcp-server.ts CHANGED
@@ -38,11 +38,20 @@ const err = (
38
38
  const asObj = (v: unknown): Record<string, unknown> =>
39
39
  typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {}
40
40
 
41
- const maybeAuthorized = (request: Request, env: Env, config: EchoPdfConfig): boolean => {
42
- if (!config.mcp.authHeader || !config.mcp.authEnv) return true
41
+ const authState = (
42
+ request: Request,
43
+ env: Env,
44
+ config: EchoPdfConfig
45
+ ): { ok: true } | { ok: false; status: number; message: string } => {
46
+ if (!config.mcp.authHeader || !config.mcp.authEnv) return { ok: true }
43
47
  const required = env[config.mcp.authEnv]
44
- if (typeof required !== "string" || required.length === 0) return true
45
- return request.headers.get(config.mcp.authHeader) === required
48
+ if (typeof required !== "string" || required.length === 0) {
49
+ return { ok: false, status: 500, message: `MCP auth is configured but env "${config.mcp.authEnv}" is missing` }
50
+ }
51
+ if (request.headers.get(config.mcp.authHeader) !== required) {
52
+ return { ok: false, status: 401, message: "Unauthorized" }
53
+ }
54
+ return { ok: true }
46
55
  }
47
56
 
48
57
  const resolvePublicBaseUrl = (request: Request, configured?: string): string =>
@@ -64,8 +73,9 @@ export const handleMcpRequest = async (
64
73
  config: EchoPdfConfig,
65
74
  fileStore: FileStore
66
75
  ): Promise<Response> => {
67
- if (!maybeAuthorized(request, env, config)) {
68
- return new Response("Unauthorized", { status: 401 })
76
+ const auth = authState(request, env, config)
77
+ if (!auth.ok) {
78
+ return new Response(auth.message, { status: auth.status })
69
79
  }
70
80
 
71
81
  let body: JsonRpcRequest
@@ -85,6 +95,9 @@ export const handleMcpRequest = async (
85
95
  if (typeof method !== "string" || method.length === 0) {
86
96
  return err(id, -32600, "Invalid Request: method is required")
87
97
  }
98
+ if (method.startsWith("notifications/")) {
99
+ return new Response(null, { status: 204 })
100
+ }
88
101
  const params = asObj(body.params)
89
102
 
90
103
  if (method === "initialize") {