Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ The codebase is **fully typed**. Treat any type error as a build failure.
- **Settings decoupling** — each class should define and accept its own settings, thus decoupled from global config
- **Always prefer awaits over promise chains** — use `await` + `try/catch` instead of `.then()` / `.catch()` chains; `Promise.all` is fine
- **No nested functions** — define helpers at module scope (prefixed with `_` if private) rather than inside other functions
- **JSON-returning tools must use `outputTarget`** — any tool that produces JSON takes the shared `outputTargetSchema` param (`inline` | `file` | `both`, default `inline`) and returns via the `jsonResult` helper in `node-version/src/tools/jsonOutput.ts`.
Inline returns parsed data under `data` so Claude can chain steps; `file`/`both` write to disk and return `outputFilename`.
Default inline so transient flows don't litter the working folder. (Binary outputs like Excel/PDF remain file-only and are orthogonal to `outputTarget`.)

## Sub-Agents

Expand Down
2 changes: 1 addition & 1 deletion node-version/src/handlers/platformHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export interface PdfMetadata {
}

export interface WatermarkParams {
readonly boundingBox?: [number, number, number, number] | null;
readonly boundingBox?: number[] | null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this broader definition better?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a local script which uses an agent to test the tool before I create a PR, when it was trying to load the tuple, it's not compatible for some reason.

readonly centerOnPage?: boolean;
readonly contentDepth?: 'above_existing' | 'below_existing';
readonly fitToPageWidth?: boolean;
Expand Down
14 changes: 14 additions & 0 deletions node-version/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,19 @@ export const singleFileOutputSchema = z.object({
.describe('Filename of the output file (written to the same directory as the input)'),
});

export const outputTargetSchema = z
.enum(['inline', 'file', 'both'])
.default('inline')
.describe(
"Where to send the extracted output (JSON or text). 'inline' (default) returns the " +
'data directly in the tool result so you can use it immediately or feed it into a ' +
"follow-up step. 'file' writes the data to a file on disk and returns only the " +
"filename (use when the user wants to keep the data). 'both' does both. " +
"Prefer 'inline' for chained or transient work; choose 'file'/'both' only when " +
'the user explicitly wants the result saved.',
);
Comment thread
RogerThomas marked this conversation as resolved.

export type OutputTarget = z.infer<typeof outputTargetSchema>;

export type SingleFileInput = z.infer<typeof singleFileInputSchema>;
export type SingleFileOutput = z.infer<typeof singleFileOutputSchema>;
112 changes: 75 additions & 37 deletions node-version/src/tools/extractions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { z } from 'zod';
import type { AppContext } from '../context.js';
import { getDep } from '../context.js';
import { handleToolError, UserFacingError } from '../errors.js';
import { singleFileInputSchema } from '../models.js';
import { outputTargetSchema, singleFileInputSchema } from '../models.js';
import { jsonResult } from './jsonOutput.js';
import {
createFormsExcel,
createTablesExcel,
Expand All @@ -32,7 +33,10 @@ export function register(server: McpServer, context: AppContext): void {
'get_pdf_metadata',
{
description: 'Use this tool when the user asks for the metadata of a PDF file.',
inputSchema: singleFileInputSchema.shape,
inputSchema: {
...singleFileInputSchema.shape,
outputTarget: outputTargetSchema,
},
annotations: READ_ONLY('Get PDF Metadata'),
},
async (args) => {
Expand All @@ -42,12 +46,15 @@ export function register(server: McpServer, context: AppContext): void {

const inputBytes = filesHandler.read(args.inputPath);
const metadata = await platformHandler.getPdfMetadata(inputBytes);
const outputPath = filesHandler.write(args.inputPath, metadata, {

return jsonResult({
outputTarget: args.outputTarget,
data: JSON.parse(metadata.toString()),
bytes: metadata,
filesHandler,
inputPath: args.inputPath,
stemSuffix: 'metadata',
ext: 'json',
});

return _successResult(path.basename(outputPath));
} catch (err) {
return handleToolError('get_pdf_metadata', err);
}
Expand All @@ -67,6 +74,7 @@ export function register(server: McpServer, context: AppContext): void {
.describe(
"Output format: 'excel' (always the default) or 'json' if explicitly requested",
),
outputTarget: outputTargetSchema,
},
annotations: READ_ONLY('Extract PDF Forms'),
},
Expand All @@ -80,25 +88,28 @@ export function register(server: McpServer, context: AppContext): void {
language: args.language,
});

let outputPath: string;
if (args.outputFormat === 'excel') {
const formsResult = JSON.parse(result.toString()) as FormsResult;
if (formsResult.fields.length === 0) {
throw new UserFacingError('No data available to generate Excel output');
}
const excelBytes = await createFormsExcel(formsResult, path.basename(args.inputPath));
outputPath = filesHandler.write(args.inputPath, excelBytes, {
const outputPath = filesHandler.write(args.inputPath, excelBytes, {
stemSuffix: 'forms',
ext: 'xlsx',
});
} else {
outputPath = filesHandler.write(args.inputPath, result, {
stemSuffix: 'forms',
ext: 'json',
});
return _successResult(path.basename(outputPath), { dataType: 'forms' });
}

return _successResult(path.basename(outputPath), { dataType: 'forms' });
return jsonResult({
outputTarget: args.outputTarget,
data: JSON.parse(result.toString()),
bytes: result,
filesHandler,
inputPath: args.inputPath,
stemSuffix: 'forms',
extra: { dataType: 'forms' },
});
} catch (err) {
return handleToolError('extract_pdf_forms', err);
}
Expand All @@ -121,6 +132,7 @@ export function register(server: McpServer, context: AppContext): void {
.describe(
"Output format: 'excel' (always the default) or 'json' if explicitly requested",
),
outputTarget: outputTargetSchema,
},
annotations: READ_ONLY('Extract PDF Tables'),
},
Expand All @@ -133,25 +145,28 @@ export function register(server: McpServer, context: AppContext): void {
const params = args.pageIndices !== undefined ? { pageIndices: args.pageIndices } : {};
const result = await platformHandler.extractPdfData(inputBytes, 'tables', params);

let outputPath: string;
if (args.outputFormat === 'excel') {
const tablesResult = JSON.parse(result.toString()) as TablesResult;
if (tablesResult.tables.length === 0) {
throw new UserFacingError('No data available to generate Excel output');
}
const excelBytes = await createTablesExcel(tablesResult, path.basename(args.inputPath));
outputPath = filesHandler.write(args.inputPath, excelBytes, {
const outputPath = filesHandler.write(args.inputPath, excelBytes, {
stemSuffix: 'tables',
ext: 'xlsx',
});
} else {
outputPath = filesHandler.write(args.inputPath, result, {
stemSuffix: 'tables',
ext: 'json',
});
return _successResult(path.basename(outputPath), { dataType: 'tables' });
}

return _successResult(path.basename(outputPath), { dataType: 'tables' });
return jsonResult({
outputTarget: args.outputTarget,
data: JSON.parse(result.toString()),
bytes: result,
filesHandler,
inputPath: args.inputPath,
stemSuffix: 'tables',
extra: { dataType: 'tables' },
});
} catch (err) {
return handleToolError('extract_pdf_tables', err);
}
Expand All @@ -172,6 +187,7 @@ export function register(server: McpServer, context: AppContext): void {
.boolean()
.default(false)
.describe('Whether to use reading order for text extraction'),
outputTarget: outputTargetSchema,
},
annotations: READ_ONLY('Extract PDF Text'),
},
Expand All @@ -193,12 +209,22 @@ export function register(server: McpServer, context: AppContext): void {
const wordCount = extractedText.split(/\s+/).filter((w) => w.length > 0).length;
const characterCount = extractedText.length;

const outputPath = filesHandler.write(args.inputPath, Buffer.from(extractedText), {
stemSuffix: 'text',
ext: 'txt',
});
const structured: Record<string, unknown> = { wordCount, characterCount };
if (args.outputTarget === 'inline' || args.outputTarget === 'both') {
structured.data = extractedText;
}
if (args.outputTarget === 'file' || args.outputTarget === 'both') {
Comment thread
RogerThomas marked this conversation as resolved.
const outputPath = filesHandler.write(args.inputPath, Buffer.from(extractedText), {
stemSuffix: 'text',
ext: 'txt',
});
structured.outputFilename = path.basename(outputPath);
}

return _successResult(path.basename(outputPath), { wordCount, characterCount });
return {
structuredContent: structured,
content: [{ type: 'text' as const, text: JSON.stringify(structured) }],
};
} catch (err) {
return handleToolError('extract_pdf_text', err);
}
Expand All @@ -211,7 +237,10 @@ export function register(server: McpServer, context: AppContext): void {
description:
'Use this tool to extract structured invoice or expense data from a PDF, ' +
'such as vendor details, line items, totals, and dates.',
inputSchema: singleFileInputSchema.shape,
inputSchema: {
...singleFileInputSchema.shape,
outputTarget: outputTargetSchema,
},
annotations: READ_ONLY('Extract Invoice Data'),
},
async (args) => {
Expand All @@ -221,12 +250,15 @@ export function register(server: McpServer, context: AppContext): void {

const inputBytes = filesHandler.read(args.inputPath);
const result = await platformHandler.extractExpenseData(inputBytes);
const outputPath = filesHandler.write(args.inputPath, result, {

return jsonResult({
outputTarget: args.outputTarget,
data: JSON.parse(result.toString()),
bytes: result,
filesHandler,
inputPath: args.inputPath,
stemSuffix: 'invoice',
ext: 'json',
});

return _successResult(path.basename(outputPath));
} catch (err) {
return handleToolError('extract_invoice_data', err);
}
Expand All @@ -240,10 +272,13 @@ export function register(server: McpServer, context: AppContext): void {
{
description:
'Use this tool to search for specific text strings in a PDF and get their locations. ' +
'Returns a JSON file with bounding box coordinates for each match.',
'Returns bounding box coordinates for each match. By default (outputTarget "inline") ' +
'the matches are returned directly in the result; set outputTarget to "file" or "both" ' +
'to also write them to a JSON file on disk.',
inputSchema: {
...singleFileInputSchema.shape,
texts: z.array(z.string()).min(1).describe('List of text strings to search for in the PDF'),
outputTarget: outputTargetSchema,
},
annotations: READ_ONLY('Search Text in PDF'),
},
Expand All @@ -259,12 +294,15 @@ export function register(server: McpServer, context: AppContext): void {
const totalMatches = parsed.textBoxes.length;
const uniqueTextsFound = new Set(parsed.textBoxes.map((b) => b.text)).size;

const outputPath = filesHandler.write(args.inputPath, resultBuffer, {
return jsonResult({
outputTarget: args.outputTarget,
data: parsed,
bytes: resultBuffer,
filesHandler,
inputPath: args.inputPath,
stemSuffix: 'search',
ext: 'json',
extra: { totalMatches, uniqueTextsFound },
});

return _successResult(path.basename(outputPath), { totalMatches, uniqueTextsFound });
} catch (err) {
return handleToolError('search_text_in_pdf', err);
}
Expand Down
36 changes: 36 additions & 0 deletions node-version/src/tools/jsonOutput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import path from 'node:path';
import type { FilesHandler } from '../handlers/filesHandler.js';
import type { OutputTarget } from '../models.js';

export interface JsonResultOptions {
readonly outputTarget: OutputTarget;
readonly data: unknown;
readonly bytes: Buffer;
readonly filesHandler: FilesHandler;
readonly inputPath: string;
readonly stemSuffix: string;
readonly extra?: Record<string, unknown>;
}

export function jsonResult(options: JsonResultOptions): {
structuredContent: Record<string, unknown>;
content: [{ type: 'text'; text: string }];
} {
const { outputTarget, data, bytes, filesHandler, inputPath, stemSuffix, extra } = options;

const structured: Record<string, unknown> = { ...extra };

if (outputTarget === 'inline' || outputTarget === 'both') {
structured.data = data;
}

if (outputTarget === 'file' || outputTarget === 'both') {
const outputPath = filesHandler.write(inputPath, bytes, { stemSuffix, ext: 'json' });
structured.outputFilename = path.basename(outputPath);
}

return {
structuredContent: structured,
content: [{ type: 'text' as const, text: JSON.stringify(structured) }],
};
}
29 changes: 17 additions & 12 deletions node-version/src/tools/pii.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { z } from 'zod';
import type { AppContext } from '../context.js';
import { getDep } from '../context.js';
import { handleToolError, UserFacingError } from '../errors.js';
import { singleFileInputSchema } from '../models.js';
import { outputTargetSchema, singleFileInputSchema } from '../models.js';
import { jsonResult } from './jsonOutput.js';

interface _PIIBox {
PIIType: string;
Expand Down Expand Up @@ -59,13 +60,16 @@ export function register(server: McpServer, context: AppContext): void {
{
description:
'Use this tool to extract PII (Personally Identifiable Information) from a PDF file. ' +
'Returns a JSON file with detected PII entities, bounding boxes, and confidence scores.',
'Returns detected PII entities, bounding boxes, and confidence scores. By default ' +
'(outputTarget "inline") the detections are returned directly in the result; set ' +
'outputTarget to "file" or "both" to also write them to a JSON file on disk.',
inputSchema: {
...singleFileInputSchema.shape,
language: z
.enum(['en', 'es'])
.default('en')
.describe('Language code for PII detection (en=English, es=Spanish)'),
outputTarget: outputTargetSchema,
},
Comment thread
RogerThomas marked this conversation as resolved.
annotations: READ_ONLY('Extract PII'),
},
Expand All @@ -91,15 +95,14 @@ export function register(server: McpServer, context: AppContext): void {
) / 1000
: 0;

const outputPath = filesHandler.write(args.inputPath, piiJson, {
return jsonResult({
outputTarget: args.outputTarget,
data: piiResult,
bytes: piiJson,
filesHandler,
inputPath: args.inputPath,
stemSuffix: 'pii',
ext: 'json',
});

return _successResult(path.basename(outputPath), {
totalEntities,
entitiesByType,
averageConfidence,
extra: { totalEntities, entitiesByType, averageConfidence },
});
} catch (err) {
return handleToolError('extract_pii', err);
Expand All @@ -126,8 +129,10 @@ export function register(server: McpServer, context: AppContext): void {
.string()
.optional()
.describe(
'Full path to PII detection JSON file (from extract_pii tool). ' +
'If provided, redactions will be extracted automatically from this file.',
'Full path to PII detection JSON file (from extract_pii with outputTarget ' +
"'file' or 'both'). If provided, redactions will be extracted automatically " +
'from this file. To redact only a subset of detected PII (e.g. just names ' +
'and emails), omit this and pass the chosen bounding boxes via redactions instead.',
),
},
annotations: DESTRUCTIVE('Redact PDF'),
Expand Down
3 changes: 2 additions & 1 deletion node-version/src/tools/transformations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,8 @@ export function register(server: McpServer, context: AppContext): void {
'Supported formats: JPG, JPEG, PNG, TIFF, BMP, GIF, SVG.',
),
boundingBox: z
.tuple([z.number(), z.number(), z.number(), z.number()])
.array(z.number())
.length(4)
.optional()
.nullable()
.describe(
Expand Down
Loading
Loading