UNPKG

@nutrient-sdk/dws-mcp-server

Version:

MCP server for Nutrient DWS Processor API

327 lines (326 loc) 16.6 kB
import axios from 'axios'; import FormData from 'form-data'; import fs from 'fs'; import path from 'path'; import { resolveReadFilePath, resolveWriteFilePath } from '../fs/sandbox.js'; import { pipeToString, handleApiError } from './utils.js'; import { createSuccessResponse, createErrorResponse } from '../responses.js'; const EXTRACTION_ENDPOINT = 'extraction/parse'; const LOW_CONFIDENCE_THRESHOLD = 0.6; // Omitting this header pins the request to whichever spec version was current // when the API key was created, not the version this server was built against. const EXTRACTION_API_VERSION = '2026-05-25'; export const EXTRACTION_HEADERS = { 'x-nutrient-api-version': EXTRACTION_API_VERSION }; function formatExtractionError(body, httpStatus, cheaperModeHint) { const status = body.status ?? httpStatus; const lines = [`Data Extraction API error (HTTP ${status}): ${body.errorMessage}`]; // 402 is a balance problem, not a blip. Say so, or an agent reads a generic // failure and retries — burning another call that cannot succeed. if (status === 402) { lines.push('Out of Data Extraction credits. Retrying will not help — top up the Data Extraction balance, which is separate ' + `from the Processor API credits reported by check_credits.${cheaperModeHint ? ` ${cheaperModeHint}` : ''}`); } const details = body.errorDetails; if (details?.code) { lines.push(`Code: ${details.code}${details.source ? ` (source: ${details.source})` : ''}`); } for (const failing of details?.failingPaths ?? []) { lines.push(`Failing path ${failing.path}: ${failing.details}`); } if (body.requestId) { lines.push(`requestId: ${body.requestId}${body.runId ? `, runId: ${body.runId}` : ''}`); } return lines.join('\n'); } /** Advice appended to a 402 from `/extraction/parse`, whose cheapest mode is `text`. */ export const PARSE_CHEAPER_MODE_HINT = 'A cheaper mode (text: 1 credit/page, structure: 1.5) costs less per page than understand (9) or agentic (18).'; /** * Renders a failed Data Extraction call. * * Consumes the error stream itself rather than delegating to `handleApiError`, * which only recognizes the Processor envelope — the response body can be read * exactly once, so the two cannot both inspect it. * * `cheaperModeHint` is per-endpoint: the modes and per-page costs differ between * /extraction/parse and /extraction/extract, and naming a mode the caller's * endpoint rejects would send it into a guaranteed-failing retry. */ export async function handleExtractionApiError(error, cheaperModeHint) { if (!axios.isAxiosError(error) || !error.response?.data) { return handleApiError(error); } let body; try { body = await pipeToString(error.response.data); } catch (streamError) { return createErrorResponse(`Error reading the Data Extraction API error response: ${streamError instanceof Error ? streamError.message : String(streamError)}`); } let parsed; try { parsed = JSON.parse(body); } catch { return createErrorResponse(`Data Extraction API error (HTTP ${error.response.status}): ${body}`); } if (typeof parsed.errorMessage !== 'string') { return createErrorResponse(`Data Extraction API error (HTTP ${error.response.status}): ${body}`); } return createErrorResponse(formatExtractionError(parsed, error.response.status, cheaperModeHint)); } /** text mode defaults to markdown; every other mode defaults to spatial. Callers pass `format` or `formats`, never both. */ function resolveFormats(mode, format, formats) { if (formats) { return Array.from(new Set(formats)); } if (format) { return [format]; } return mode === 'text' ? ['markdown'] : ['spatial']; } /** Data Extraction credit usage, appended to the success message. */ export function formatCreditUsage(response) { const notes = []; const credits = response.usage?.data_extraction_credits; // Either half alone is worth reporting: the remaining balance is what stops an // agent from walking into a 402, and it must survive a response that omits cost. if (credits && (typeof credits.cost === 'number' || typeof credits.remainingCredits === 'number')) { const used = typeof credits.cost === 'number' ? `Used ${credits.cost} Data Extraction credit(s)` : 'Used an unreported number of Data Extraction credits'; const remaining = typeof credits.remainingCredits === 'number' ? `${credits.remainingCredits} remaining` : 'remaining unknown'; notes.push(`${used} (${remaining}). These are Data Extraction credits — a separate ` + 'balance from the Processor API credits reported by check_credits.'); const composition = response.usage?.price_composition; const parse = composition?.parse; const extract = composition?.extract; if (parse && extract) { notes.push(`Cost breakdown: parse ${parse.cost ?? '?'} + extract ${extract.cost ?? '?'} (per-page, ${extract.units ?? '?'} page(s)).`); } } return notes.length > 0 ? `\n\n${notes.join('\n')}` : ''; } /** * Build a decision-grade summary of a spatial extraction result. * * Deliberately excludes extracted document text — it reports only counts, * confidence signal, page geometry, and where the full result was written, so * sensitive content never lands in the agent transcript. */ function summarizeSpatial(response, outputPath, byteLength) { const elements = response.output?.elements ?? []; const typeCounts = {}; const pageIndexes = new Set(); let lowConfidence = 0; for (const element of elements) { const type = element.type ?? 'unknown'; typeCounts[type] = (typeCounts[type] ?? 0) + 1; if (typeof element.confidence === 'number' && element.confidence < LOW_CONFIDENCE_THRESHOLD) { lowConfidence += 1; } if (typeof element.page?.pageIndex === 'number') { pageIndexes.add(element.page.pageIndex); } } const pageCount = response.metrics?.pagesProcessed ?? pageIndexes.size; const typeSummary = Object.entries(typeCounts) .map(([type, count]) => `${type}: ${count}`) .join(', '); return [ `Extracted ${elements.length} elements across ${pageCount} page(s) and wrote the full spatial JSON to ${outputPath} (${byteLength} bytes).`, `Element types: ${typeSummary || 'none'}.`, `Low-confidence elements (confidence < ${LOW_CONFIDENCE_THRESHOLD}): ${lowConfidence}.`, `The document content is not included here — use extract_fields to pull specific field values.`, ].join('\n'); } /** Writes `data` to `resolvedPath`, creating parent directories as needed. */ export async function writeToResolvedPath(resolvedPath, data) { const outputDir = path.dirname(resolvedPath); await fs.promises.mkdir(outputDir, { recursive: true }); await fs.promises.writeFile(resolvedPath, data); } export const SAME_PATH_ERROR = 'Error: outputPath must be different from the input filePath — writing the extraction there would destroy the source document.'; /** * Renders a write failure that happened *after* the extraction succeeded and was * billed. A bare filesystem error reads as a failed call, so the agent retries * and pays for the same extraction twice. */ export function billedWriteFailure(resolvedPath, error) { return createErrorResponse(`Error: the extraction succeeded and was billed, but writing the result to ${resolvedPath} failed: ` + `${error instanceof Error ? error.message : String(error)}. ` + 'Retrying the extraction will be billed again — free up space or pass a different outputPath.'); } /** * Renders a 2xx response that is missing an expected output field. The call * already succeeded and was billed, so a bare error would invite a retry that * pays again — surface the billed framing and the credit usage instead. */ export function billedMalformedResponse(missingField, creditUsage) { return createErrorResponse('Error: the extraction succeeded and was billed, but the Data Extraction API response was malformed ' + `(missing ${missingField}). Retrying will be billed again — this is a server-side response problem, ` + `not a transient error.${creditUsage}`); } /** * Calls the Nutrient DWS Data Extraction API (`POST /extraction/parse`). * * Spatial output is written to `outputPath` and summarized inline; markdown * output is returned inline (or written to `outputPath` when given). When * both are requested, the spatial file is written and the summary also notes * the markdown that landed alongside it under `output.markdown`. */ export async function performParseDocumentCall(args, apiClient) { const { filePath, url, mode, format, formats, language, maxLanguages, maxScripts, includeWords, useHtmlTables, enableSemanticBlockFormatting, includeHeadersAndFooters, extractWordsFromPictures, outputPath, } = args; if (filePath && url) { return createErrorResponse('Error: provide exactly one of filePath or url, not both.'); } if (!filePath && !url) { return createErrorResponse('Error: provide exactly one of filePath or url.'); } if (format && formats) { return createErrorResponse('Error: provide only one of format or formats — the Data Extraction API rejects a request with both set.'); } if (language !== undefined && (maxLanguages !== undefined || maxScripts !== undefined)) { return createErrorResponse('Error: maxLanguages and maxScripts only apply when language is left unset (auto-detect). Remove language, or drop these options.'); } if (mode === 'text' && (maxLanguages !== undefined || maxScripts !== undefined)) { return createErrorResponse('Error: maxLanguages and maxScripts tune OCR language auto-detection, which text mode does not perform. ' + 'Drop them, or use structure/understand/agentic.'); } const resolvedFormats = resolveFormats(mode, format, formats); const formatSet = new Set(resolvedFormats); if (mode === 'text' && formatSet.has('spatial')) { return createErrorResponse('Error: text mode only supports markdown output. Use a different mode for spatial output.'); } if (formatSet.has('spatial') && !outputPath) { return createErrorResponse('Error: spatial output requires outputPath — the element list can be large and is written to a file.'); } // Resolve any provided output path first (fail early on a sandbox escape, // before the API call). Required for spatial, optional for markdown. let resolvedOutputPath; if (outputPath) { try { resolvedOutputPath = await resolveWriteFilePath(outputPath); } catch (error) { return createErrorResponse(`Error: ${error instanceof Error ? error.message : String(error)}`); } } let fileBuffer; let fileName; if (filePath) { try { const resolvedInputPath = await resolveReadFilePath(filePath); if (resolvedInputPath === resolvedOutputPath) { return createErrorResponse(SAME_PATH_ERROR); } fileBuffer = await fs.promises.readFile(resolvedInputPath); fileName = path.basename(resolvedInputPath); } catch (error) { return createErrorResponse(`Error with input file ${filePath}: ${error instanceof Error ? error.message : String(error)}`); } } const output = resolvedFormats.length === 1 ? { format: resolvedFormats[0] } : { formats: resolvedFormats }; if (formatSet.has('spatial') && includeWords !== undefined) { output.includeWords = includeWords; } if (formatSet.has('markdown')) { // Markdown-only knobs: send only when the caller set them, never a zod // default — useHtmlTables/enableSemanticBlockFormatting default to true // server-side, so sending an implicit `false` would silently change output. if (useHtmlTables !== undefined) output.useHtmlTables = useHtmlTables; if (enableSemanticBlockFormatting !== undefined) output.enableSemanticBlockFormatting = enableSemanticBlockFormatting; if (includeHeadersAndFooters !== undefined) output.includeHeadersAndFooters = includeHeadersAndFooters; if (extractWordsFromPictures !== undefined) output.extractWordsFromPictures = extractWordsFromPictures; } const instructions = { mode, output }; if (mode !== 'text') { const options = {}; if (language !== undefined) options.language = language; if (maxLanguages !== undefined) options.maxLanguages = maxLanguages; if (maxScripts !== undefined) options.maxScripts = maxScripts; if (Object.keys(options).length > 0) { instructions.options = options; } } // Only the billable call is guarded by the API error handler: a failure after // it has succeeded is not an API error, and rendering it as one hides the fact // that the extraction was already paid for. let body; try { let response; if (filePath) { const form = new FormData(); form.append('file', fileBuffer, { filename: fileName }); form.append('instructions', JSON.stringify(instructions)); response = await apiClient.post(EXTRACTION_ENDPOINT, form, EXTRACTION_HEADERS); } else { response = await apiClient.post(EXTRACTION_ENDPOINT, { ...instructions, url }, EXTRACTION_HEADERS); } body = await pipeToString(response.data); } catch (error) { return handleExtractionApiError(error, PARSE_CHEAPER_MODE_HINT); } let parsed; try { parsed = JSON.parse(body); } catch { return createErrorResponse('Error: the Data Extraction API returned a response that could not be parsed as JSON.'); } const creditUsage = formatCreditUsage(parsed); if (formatSet.has('spatial') && resolvedOutputPath) { // Guard against a 2xx response that is not a spatial result, so we never // overwrite the target file with a non-extraction body. if (!Array.isArray(parsed.output?.elements)) { return billedMalformedResponse('the spatial element list (output.elements)', creditUsage); } // Validate every requested format before writing, so an incomplete // response never leaves a file on disk behind an error result — the // caller would retry and be billed for the extraction twice. const markdown = formatSet.has('markdown') ? parsed.output?.markdown : undefined; if (formatSet.has('markdown') && typeof markdown !== 'string') { return billedMalformedResponse('markdown output (output.markdown)', creditUsage); } // Write the raw response body: avoids re-serializing a potentially large // payload and preserves every field the API returned. When markdown was // also requested, that same body already carries output.markdown. try { await writeToResolvedPath(resolvedOutputPath, body); } catch (error) { return billedWriteFailure(resolvedOutputPath, error); } let summary = summarizeSpatial(parsed, resolvedOutputPath, Buffer.byteLength(body)); if (typeof markdown === 'string') { summary += `\nAlso wrote ${Buffer.byteLength(markdown)} bytes of Markdown to the same file, under output.markdown.`; } return createSuccessResponse(summary + creditUsage); } // Markdown-only. const markdown = parsed.output?.markdown; if (typeof markdown !== 'string') { return billedMalformedResponse('markdown output (output.markdown)', creditUsage); } // Honor outputPath for markdown too — a large document returned inline // would overflow the conversation. Only return inline when no path given. if (resolvedOutputPath) { try { await writeToResolvedPath(resolvedOutputPath, markdown); } catch (error) { return billedWriteFailure(resolvedOutputPath, error); } return createSuccessResponse(`Wrote ${Buffer.byteLength(markdown)} bytes of Markdown to ${resolvedOutputPath}.${creditUsage}`); } return createSuccessResponse(markdown + creditUsage); }