@nutrient-sdk/dws-mcp-server
Version:
MCP server for Nutrient DWS Processor API
200 lines (199 loc) • 9.21 kB
JavaScript
import FormData from 'form-data';
import fs from 'fs';
import path from 'path';
import { resolveReadFilePath, resolveWriteFilePath } from '../fs/sandbox.js';
import { pipeToString } from './utils.js';
import { createSuccessResponse, createErrorResponse } from '../responses.js';
import { EXTRACTION_HEADERS, SAME_PATH_ERROR, billedWriteFailure, handleExtractionApiError, formatCreditUsage, writeToResolvedPath, } from './parse.js';
const EXTRACT_STRUCTURED_ENDPOINT = 'extraction/extract';
const NOT_FOUND_DISPLAY_LIMIT = 10;
// This endpoint has no `text` mode, and bills a fixed extract component on top
// of the parse component — so it cannot reuse the parse tool's 402 advice.
const EXTRACT_CHEAPER_MODE_HINT = 'The cheapest parse mode here is structure (1.5 credits/page) versus understand (9) or agentic (18); ' +
'this endpoint bills that parse component plus a fixed extract component per page. There is no text mode.';
/**
* Tallies citation `match` values across `output.metadata` and lists the
* field paths that came back `not_found`.
*
* `output.metadata` mirrors `output.data`'s shape, with a citation object
* (identified by its `match` string) at every scalar leaf.
*/
function summarizeGrounding(metadata) {
const counts = {};
const notFoundPaths = [];
function walk(node, fieldPath) {
if (node === null || typeof node !== 'object') {
return;
}
const match = node.match;
if (typeof match === 'string') {
counts[match] = (counts[match] ?? 0) + 1;
if (match === 'not_found') {
notFoundPaths.push(fieldPath);
}
return;
}
if (Array.isArray(node)) {
node.forEach((item, index) => walk(item, `${fieldPath}[${index}]`));
return;
}
for (const [key, value] of Object.entries(node)) {
walk(value, fieldPath ? `${fieldPath}.${key}` : key);
}
}
walk(metadata, '');
return { counts, notFoundPaths };
}
// Field paths come from the caller's own schema, not the document — naming
// them (e.g. "invoice.lineItems[2].amount") leaks no document content, only
// the shape the caller already specified. The cited values themselves are
// never included here.
function formatGroundingSignal(metadata) {
const { counts, notFoundPaths } = summarizeGrounding(metadata);
const totalLeaves = Object.values(counts).reduce((sum, count) => sum + count, 0);
if (totalLeaves === 0) {
return '';
}
const countsLine = Object.entries(counts)
.map(([match, count]) => `${match}: ${count}`)
.join(', ');
const lines = [`Citation match summary (${totalLeaves} field(s)): ${countsLine}.`];
if (notFoundPaths.length > 0) {
const shown = notFoundPaths.slice(0, NOT_FOUND_DISPLAY_LIMIT);
const remaining = notFoundPaths.length - shown.length;
lines.push(`Not found: ${shown.join(', ')}${remaining > 0 ? ` (+${remaining} more)` : ''}.`);
}
return lines.join('\n');
}
/**
* Calls the Nutrient DWS Data Extraction API (`POST /extraction/extract`) to pull
* fields matching a caller-supplied JSON schema out of a document, with per-field
* citations.
*
* `output.data` is always returned inline — it is the answer, and is bounded by
* the caller's own schema. Citations (`output.metadata`) and `output.pages` are
* only written to `outputPath`, since they can be large and add little value
* without the document open alongside them.
*/
export async function performExtractFieldsCall(args, apiClient) {
const { filePath, url, schema, instructions: freeTextInstructions, mode, language, maxLanguages, maxScripts, includeCitations, strict, multimodal, 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 (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.');
}
// Resolve any provided output path first (fail early on a sandbox escape,
// before the API call).
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 parseConfigOptions = {};
if (language !== undefined)
parseConfigOptions.language = language;
if (maxLanguages !== undefined)
parseConfigOptions.maxLanguages = maxLanguages;
if (maxScripts !== undefined)
parseConfigOptions.maxScripts = maxScripts;
const parseConfig = { mode };
if (Object.keys(parseConfigOptions).length > 0) {
parseConfig.options = parseConfigOptions;
}
// Sent only when the caller set them — includeCitations defaults to true
// server-side, so a zod default of false would silently disable citations
// for every caller who never touched this field.
const extractOptions = {};
if (includeCitations !== undefined)
extractOptions.includeCitations = includeCitations;
if (strict !== undefined)
extractOptions.strict = strict;
if (multimodal !== undefined)
extractOptions.multimodal = multimodal;
const instructions = { schema, parseConfig };
if (freeTextInstructions !== undefined) {
instructions.instructions = freeTextInstructions;
}
if (Object.keys(extractOptions).length > 0) {
instructions.options = extractOptions;
}
// 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(EXTRACT_STRUCTURED_ENDPOINT, form, EXTRACTION_HEADERS);
}
else {
response = await apiClient.post(EXTRACT_STRUCTURED_ENDPOINT, { ...instructions, url }, EXTRACTION_HEADERS);
}
body = await pipeToString(response.data);
}
catch (error) {
return handleExtractionApiError(error, EXTRACT_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.');
}
// Guard against a 2xx response that is not an extraction result, so we
// never overwrite the target file with a non-extraction body and never
// report a false success inline.
const data = parsed.output?.data;
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
return createErrorResponse('Error: the Data Extraction API response did not contain an object at output.data. Nothing was written.');
}
const creditUsage = formatCreditUsage(parsed);
const metadata = parsed.output?.metadata;
const groundingSignal = formatGroundingSignal(metadata);
const metadataIsEmpty = metadata === undefined || metadata === null || (typeof metadata === 'object' && Object.keys(metadata).length === 0);
const lines = [JSON.stringify(data, null, 2)];
if (groundingSignal) {
lines.push(groundingSignal);
}
if (resolvedOutputPath) {
try {
await writeToResolvedPath(resolvedOutputPath, body);
}
catch (error) {
return billedWriteFailure(resolvedOutputPath, error);
}
lines.push(`The full response was written to ${resolvedOutputPath} (${Buffer.byteLength(body)} bytes)` +
`${metadataIsEmpty ? ' — page geometry under output.pages; no citations were returned.' : ', with per-field citations under output.metadata and page geometry under output.pages.'}`);
}
else if (!metadataIsEmpty) {
lines.push('Per-field citations were returned but omitted from this reply. Pass outputPath to keep them.');
}
return createSuccessResponse(lines.join('\n\n') + creditUsage);
}