Sign up (with export icon)

Document restyling job with MCP verification in Node.js

Show the table of contents

This example is the code behind Edit documents from your backend. A backend job sends filings to Document Processing with the style guide as the prompt, and an MCP server of your own checks the result. Read that page first for what the pieces are for. This page has the configuration, the prompt, and the job code.

Dependencies

Copy link

The job runs on Node.js 18 or newer, which has fetch built in, and calls an on-premises deployment with the MCP server in its configuration. It needs a token with ai:documents:process, or ai:documents:*, and ai:models:agent. The scripts use top-level await, so save them with the .mjs extension or set "type": "module" in package.json.

The stand-in checks server under The verification server needs these packages:

npm init -y
npm pkg set type=module
npm install express@5.2.1 @modelcontextprotocol/sdk@1.30.0 zod@4.5.4
Copy code

Example

Copy link

The verification server

Copy link

The server is defined in the mcp_servers block of the deployment configuration and authenticates with a static header, because a backend job has no signed-in user. Servers registered through the admin REST API work here too. See MCP tools for the options.

{
	"mcp_servers": {
		"filing-style-checks": {
			"url": "https://style-checks.firm.example/mcp",
			"headers": {
				"Authorization": "Bearer <service token>"
			},
			"options": {
				"callToolTimeout": 120
			},
			"allowedEnvironments": ["filings-production"]
		}
	}
}
Copy code

allowedEnvironments limits the server to the environments that process filings. If the server exposes a tool that must not run unattended, list it in tools.disabled. Without these two settings, every environment gets the server, and every tool on it runs in chat as well as in Document Processing.

The server exposes three tools. Each tool returns its findings as a list. A finding names the element by its data-id, so the agent can fix that element. Put data-id attributes on the elements of the documents you send. The response returns the same data-id attributes, and an element without one comes back without one.

Tool Checks
check_defined_terms Every defined term is introduced once, in quotes and bold, and used consistently afterwards
check_number_format Amounts in thousands with the unit stated once per table, negatives in parentheses, percentages to one decimal
check_cross_references Every “see Item 7” and “Note 12” points at a heading that exists

The server below is a stand-in, so you can run the page before you have real checks. It returns one violation on the first check_defined_terms call after it starts and none after that, so restart it to see the violation again. In production, the three handlers call your own checks. Save it as checks-server.js and start it with MCP_TOKEN="<service token>" node checks-server.js. The token is the one in the Authorization header of the configuration above.

import express from 'express';
import { z } from 'zod';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';

// Stand-in for your checks. The first defined-terms check reports one violation, the next one is clean.
let definedTermsCalls = 0;

function createServer() {
	const server = new McpServer( { name: 'filing-style-checks', version: '1.0.0' } );
	const inputSchema = { document: z.string().describe( 'The full HTML of the filing, with data-id attributes.' ) };

	server.registerTool( 'check_defined_terms', {
		description: 'Checks that every defined term is introduced once, in quotes and bold, and used consistently afterwards. ' +
			'Returns the violations, each with the data-id of the element.',
		inputSchema
	}, async () => {
		definedTermsCalls++;
		const violations = definedTermsCalls === 1 ? [ { dataId: 'e4', term: 'Credit Agreement', problem: 'used before definition' } ] : [];

		return { content: [ { type: 'text', text: JSON.stringify( { violations } ) } ] };
	} );

	server.registerTool( 'check_number_format', {
		description: 'Checks that amounts are in thousands with the unit stated once per table, negatives in parentheses, ' +
			'and percentages to one decimal. Returns the violations, each with the data-id of the element.',
		inputSchema
	}, async () => ( { content: [ { type: 'text', text: JSON.stringify( { violations: [] } ) } ] } ) );

	server.registerTool( 'check_cross_references', {
		description: 'Checks that every "see Item 7" and "Note 12" points at a heading that exists. ' +
			'Returns the violations, each with the data-id of the element.',
		inputSchema
	}, async () => ( { content: [ { type: 'text', text: JSON.stringify( { violations: [] } ) } ] } ) );

	return server;
}

const app = express();
app.use( express.json( { limit: '32mb' } ) );

app.use( '/mcp', ( req, res, next ) => {
	if ( req.headers.authorization !== `Bearer ${ process.env.MCP_TOKEN }` ) {
		res.sendStatus( 401 );
		return;
	}
	next();
} );

app.post( '/mcp', async ( req, res ) => {
	const server = createServer();
	const transport = new StreamableHTTPServerTransport( { sessionIdGenerator: undefined } );

	res.on( 'close', () => {
		transport.close();
		server.close();
	} );

	await server.connect( transport );
	await transport.handleRequest( req, res, req.body );
} );

app.get( '/mcp', ( req, res ) => res.sendStatus( 405 ) );
app.delete( '/mcp', ( req, res ) => res.sendStatus( 405 ) );

app.listen( 3020, () => console.log( 'Checks server listening on http://localhost:3020/mcp' ) );
Copy code

If the service runs on the same host, register the server as http://localhost:3020/mcp. If the service runs in Docker, localhost is the container. Use http://host.docker.internal:3020/mcp on Docker Desktop, or the address of the host.

The prompt

Copy link

The style guide is the prompt. Its last paragraph tells the agent to run the checks and fix what they report. Save it as style-guide.txt.

Restyle this filing to the firm's style guide. Change presentation only. Do not add, remove, or reorder content, and do not touch numbers except their formatting.

Rules:
1. Defined terms are introduced once, in quotation marks and bold, then used consistently. Refer to the registrant as "the Company".
2. Amounts are presented in thousands, with the unit stated once per table. Negative amounts go in parentheses. Percentages have one decimal place.
3. Cross-references use the form "see Item 7" or "see Note 12" and point at headings that exist in this document.
4. Headings follow the SEC item numbering already in the document. Do not renumber.

Before you finish, run check_defined_terms, check_number_format, and check_cross_references on the edited document. Fix every violation they report and run them again. Finish only when all three report no violations, or explain in the summary what could not be fixed and why.
Copy code

When you no longer change the prompt, store the prompt in a context and send the context id in each request instead of the text. When the style guide changes, edit the context.

The job

Copy link

The job reads every file in ./inbox. For each file, it calls Document Processing and writes the edited document and the summary to ./review. Create the two folders, put your filings in ./inbox as HTML files, and save the job as job.mjs.

mkdir -p inbox review
Copy code
import { readFile, writeFile, readdir } from 'node:fs/promises';

const HOST = process.env.CKEDITOR_AI_HOST;	// your on-premises CKEditor AI
const TOKEN = process.env.CKEDITOR_AI_TOKEN;  // from your token endpoint
const STYLE_GUIDE = await readFile( './style-guide.txt', 'utf8' );

for ( const file of await readdir( './inbox' ) ) {
	const html = await readFile( `./inbox/${ file }`, 'utf8' );

	const response = await fetch( `${ HOST }/v1/documents/process`, {
		method: 'POST',
		headers: { 'Authorization': `Bearer ${ TOKEN }`, 'Content-Type': 'application/json' },
		body: JSON.stringify( {
			content: [ { type: 'document', content: html } ],
			prompt: STYLE_GUIDE,
			model: 'agent-1'
		} ),
		signal: AbortSignal.timeout( 10 * 60 * 1000 ) // the service allows up to 10 minutes per call
	} );

	if ( !response.ok ) {
		console.error( `${ file }: ${ response.status } ${ await response.text() }` );
		continue;
	}

	const { documents: [ result ] } = await response.json();

	await writeFile( `./review/${ file }`, result.document );
	await writeFile( `./review/${ file }.summary.txt`, result.summary );
	console.log( `${ file }: ${ result.summary.split( '\n' )[ 0 ] }` );
}
Copy code

Each call is stateless. The document, the prompt, and the model go in every request, so the job can retry a failed call or run calls in parallel. Set a long timeout. Several rounds of checks on a long filing take minutes.

If a long document needs small edits only, add "responseFormat": "arf" to the request body. The response then contains only the changed elements, each with its data-id. See Fragment responses.

Usage

Copy link

Run the job with the two environment variables set:

CKEDITOR_AI_HOST=<your-url> CKEDITOR_AI_TOKEN=<token> node job.mjs
Copy code

The job prints one line per filing, the file name and the first line of the summary:

10-K-2025.html: Restyled the filing to follow the firm's defined-term, number-formatting, and cross-reference conventions while preserving content order and SEC item numbering. Final validation found no remaining violations in the three requested categories.
Copy code

The result

Copy link

Document Processing returns the edited filing and a summary. The job writes the document value to ./review/<file> and the summary value to ./review/<file>.summary.txt. The summary lists anything the checks still report. Send those filings to a reviewer.

{
  "documents": [
	{
	  "document": "<h1 data-id=\"e1\">FORM 10-K</h1>\n<p data-id=\"e2\">ANNUAL REPORT PURSUANT TO SECTION 13 OR 15(d) OF THE SECURITIES EXCHANGE ACT OF 1934</p>\n<p data-id=\"e3\">For the fiscal year ended December 31, 2025</p>\n<p data-id=\"e4\"><strong>“the Company”</strong> is filing this Annual Report on Form 10-K. …",
	  "summary": "Restyled the filing to follow the firm’s defined-term, number-formatting, and cross-reference conventions while preserving content order and SEC item numbering. Final validation found no remaining violations in the three requested categories."
	}
  ]
}
Copy code

Watch the tools work

Copy link

The streaming endpoint takes the same request body and sends an event for each tool call. Save the body as request.json, with the style guide as the prompt:

{
	"content": [ { "type": "document", "content": "<h1 data-id=\"e1\">FORM 10-K</h1>…" } ],
	"prompt": "<the style guide>",
	"model": "agent-1"
}
Copy code

The script below sends that request, reads CKEDITOR_AI_HOST and CKEDITOR_AI_TOKEN from the environment like the job, and prints each event and its data line as they arrive. Save it as stream.mjs and run it with node stream.mjs.

import { readFile } from 'node:fs/promises';

const HOST = process.env.CKEDITOR_AI_HOST;	// your on-premises CKEditor AI
const TOKEN = process.env.CKEDITOR_AI_TOKEN;  // from your token endpoint

async function stream( path, body ) {
	const response = await fetch( `${ HOST }${ path }`, {
		method: 'POST',
		headers: { 'Authorization': `Bearer ${ TOKEN }`, 'Content-Type': 'application/json', 'Accept': 'text/event-stream' },
		body: JSON.stringify( body )
	} );

	if ( !response.ok ) {
		throw new Error( `${ response.status } ${ await response.text() }` );
	}

	const decoder = new TextDecoder();
	let buffer = '';

	for await ( const chunk of response.body ) {
		buffer += decoder.decode( chunk, { stream: true } );

		let end;
		while ( ( end = buffer.indexOf( '\n\n' ) ) >= 0 ) {
			const lines = buffer.slice( 0, end ).split( '\n' );
			buffer = buffer.slice( end + 2 );

			const event = lines.find( line => line.startsWith( 'event: ' ) )?.slice( 7 );
			const data = lines.find( line => line.startsWith( 'data: ' ) )?.slice( 6 );

			console.log( `event: ${ event }\ndata: ${ data }\n` );
		}
	}
}

await stream( '/v1/documents/process/stream', JSON.parse( await readFile( './request.json', 'utf8' ) ) );
Copy code
The events of one run, from the first check to the final document.
event: metadata
data: {"id":"9Etmn6d0t554Rp92GjGav"}

event: document-delta
data: {"document":"<h1 data-id=\"e1\">FORM 10-K</h1>\n<p data-id=\"e2\">ANNUAL REPORT PURSUANT TO SECTION 13 OR 15(d) OF THE SECURITIES EXCHANGE ACT OF 1934</p>…"}

event: document-read
data: {"method":"regex"}

event: mcp-tool-result
data: {"toolName":"filing-style-checks-check_defined_terms","result":"{\"data\":{\"violations\":[{\"dataId\":\"e412\",\"term\":\"Credit Agreement\",\"problem\":\"used before definition\"}]},\"attributes\":{}}","success":true}

event: document-delta
data: {"document":"<h1 data-id=\"e1\">FORM 10-K</h1>…"}

event: mcp-tool-result
data: {"toolName":"filing-style-checks-check_defined_terms","result":"{\"data\":{\"violations\":[]},\"attributes\":{}}","success":true}

event: text-delta
data: {"textDelta":"Restyled "}

event: text-delta
data: {"textDelta":"the "}

...

event: document
data: {"document":"<h1 data-id=\"e1\">FORM 10-K</h1>…","summary":"Restyled the filing to follow the firm’s defined-term, number-formatting, and cross-reference conventions while preserving content order and SEC item numbering. Final validation found no remaining violations in the three requested categories."}
Copy code

The first check finds a term that is used before its definition. The agent fixes the term, checks again, and the second run is clean. A document-read event means the agent read a part of the document. Each document-delta event contains the whole document in its current state, so on a long filing these events are large. If the request sends one document, the final document event contains document and summary at the top level. The documents array appears only when a request sends several documents.