# Document restyling job with MCP verification in Node.js

This example is the code behind [Edit documents from your backend](../../guides/ckeditor-ai/verify-with-your-tools.md). A backend job sends filings to [Document Processing](../../guides/ckeditor-ai/document-processing.md) 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.

<a id="dependencies">

## Dependencies

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](#the-verification-server) needs these packages:

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

<a id="example">

## Example

<a id="the-verification-server">

### The verification server

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](../../guides/ckeditor-ai/extensions/mcp-tools.md) work here too. See [MCP tools](../../onpremises/ckeditor-ai-onpremises/mcp-tools.md) for the options.

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

`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.

```js
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' ) );
```

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.

<a id="the-prompt">

### The prompt

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`.

```text
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.
```

When you no longer change the prompt, store the prompt in a [context](../../guides/ckeditor-ai/extensions/context-library.md) and send the context id in each request instead of the text. When the style guide changes, edit the context.

<a id="the-job">

### The job

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`.

```bash
mkdir -p inbox review
```

```js
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 ] }` );
}
```

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](../../guides/ckeditor-ai/fragment-responses.md).

<a id="usage">

## Usage

Run the job with the two environment variables set:

```bash
CKEDITOR_AI_HOST=<your-url> CKEDITOR_AI_TOKEN=<token> node job.mjs
```

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.
```

<a id="the-result">

### The result

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.

```json
{
  "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."
	}
  ]
}
```

<a id="watch-the-tools-work">

### Watch the tools work

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:

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

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`.

```js
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' ) ) );
```

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."}
```

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.

---

Full index of the Cloud Services documentation: [llms.txt](../../../llms.txt)
