Sign up (with export icon)

MCP server for your app's knowledge in Node.js

Show the table of contents

This example is the code behind Fetch data from your systems. It has three parts: a small MCP server that exposes two of your systems, a study registry and a product master, as two tools, the request that registers the server in a CKEditor AI environment, and a chat message that makes the agent call a tool. Read that page first for what the pieces are for.

The server below answers from records it holds in memory, so you can run the example without a study registry. In a real integration, the two tool handlers call the APIs of your own systems.

Dependencies

Copy link

You need a CKEditor AI environment, a token with ai:admin to register the server, and a token for an end user to send the chat message. The scripts need Node.js 18 or later. The server is an ES module, so the project needs "type": "module" in its package.json.

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 server

Copy link

The server registers two tools. Each tool has a description you write for the agent. The agent reads that description when it decides whether to call the tool. The server uses the Streamable HTTP transport, which CKEditor AI connects to, and it checks a bearer token on every request.

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 the product master. In production these records come from the product master's API.
const PRODUCTS = [
	{ code: 'VLM-050-TAB', name: 'Velmatra 50 mg film-coated tablet', inn: 'velmatinib', strength: '50 mg', form: 'film-coated tablet', markets: [ 'US', 'EU' ], notes: 'Starting dose in adults with moderate renal impairment.' },
	{ code: 'VLM-100-TAB', name: 'Velmatra 100 mg film-coated tablet', inn: 'velmatinib', strength: '100 mg', form: 'film-coated tablet', markets: [ 'US', 'EU', 'JP' ], notes: 'Standard maintenance dose, once daily with or without food.' },
	{ code: 'VLM-025-SOL', name: 'Velmatra 25 mg/mL oral solution', inn: 'velmatinib', strength: '25 mg/mL', form: 'oral solution', markets: [ 'EU' ], notes: 'For patients unable to swallow tablets. Not interchangeable with tablets on a mg-for-mg basis.' },
	{ code: 'ORX-200-CAP', name: 'Orvexan 200 mg hard capsule', inn: 'orvexanib', strength: '200 mg', form: 'hard capsule', markets: [ 'US' ], notes: 'Do not co-administer with velmatinib.' }
];

// Stand-in for the study registry.
const STUDIES = {
	'VLM-3021': {
		id: 'VLM-3021',
		title: 'Velmatinib versus placebo in adults with moderate to severe rheumatoid arthritis and an inadequate response to methotrexate',
		phase: 3,
		design: 'Randomized, double-blind, placebo-controlled, parallel-group, 52 weeks',
		protocolVersion: '3.0 (amendment 2, 14 May 2026)',
		arms: [
			{ name: 'Velmatinib 100 mg', regimen: '100 mg orally once daily, with background methotrexate', plannedParticipants: 300 },
			{ name: 'Velmatinib 50 mg', regimen: '50 mg orally once daily, with background methotrexate', plannedParticipants: 300 },
			{ name: 'Placebo', regimen: 'Matching placebo orally once daily, with background methotrexate', plannedParticipants: 300 }
		],
		primaryEndpoint: 'Proportion of participants achieving an ACR20 response at week 24',
		secondaryEndpoints: [
			'Change from baseline in DAS28-CRP at week 24',
			'Change from baseline in HAQ-DI at week 24',
			'Proportion of participants achieving ACR50 and ACR70 responses at week 52'
		],
		enrollment: { planned: 900, enrolled: 874, status: 'Enrollment complete' },
		sites: { active: 112, countries: 18 }
	}
};

function createServer() {
	const server = new McpServer( { name: 'study-registry', version: '1.0.0' } );

	server.registerTool( 'get_study', {
		title: 'Get study record',
		description: 'Returns the current record of a clinical study from the study registry: phase, design, protocol version, ' +
			'treatment arms with dosing, primary and secondary endpoints, enrollment, and site count. ' +
			'Use it whenever a document needs the study design, methods, or enrollment figures. ' +
			'Takes the protocol number as shown in the registry, for example VLM-3021.',
		inputSchema: { protocolId: z.string().describe( 'The protocol number, for example VLM-3021.' ) }
	}, async ( { protocolId } ) => {
		const study = STUDIES[ protocolId.toUpperCase() ];

		if ( !study ) {
			return { content: [ { type: 'text', text: `No study with protocol number ${ protocolId } exists in the registry.` } ], isError: true };
		}

		// Study-level summary only. Never return participant-level data.
		return { content: [ { type: 'text', text: JSON.stringify( study ) } ] };
	} );

	server.registerTool( 'search_products', {
		title: 'Search the product master',
		description: 'Searches the product master by product name, INN, product code, or keyword and returns up to five matches ' +
			'with strength, formulation, the markets where each is approved, and prescribing notes. ' +
			'Use it to check whether a product or strength is approved in a market, or to find the exact approved product name.',
		inputSchema: { query: z.string().describe( 'Words from the product name, INN, or code, for example "50 mg" or "velmatinib".' ) }
	}, async ( { query } ) => {
		const words = query.toLowerCase().split( /\s+/ );
		const matches = PRODUCTS
			.filter( item => words.some( word => `${ item.code } ${ item.name } ${ item.inn } ${ item.notes }`.toLowerCase().includes( word ) ) )
			.slice( 0, 5 );

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

	return server;
}

const app = express();
app.use( express.json() );

// CKEditor AI sends this header on every call. Reject everything else.
app.use( '/mcp', ( req, res, next ) => {
	if ( req.headers.authorization !== `Bearer ${ process.env.MCP_TOKEN }` ) {
		res.sendStatus( 401 );
		return;
	}
	next();
} );

// Stateless mode: a fresh server and transport per request, so no state is shared between calls.
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 );
} );

// A stateless server has no session stream to open or close.
app.get( '/mcp', ( req, res ) => res.sendStatus( 405 ) );
app.delete( '/mcp', ( req, res ) => res.sendStatus( 405 ) );

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

Three parts of that code matter most:

  • Tool descriptions: the description is everything the agent knows about the system behind the tool. Say what the tool returns and when to use it, in the words a user’s message would contain.
  • Text results: the model reads what the tool returns, so keep it short, structured, and complete for the question. get_study returns the whole study-level record in one call, and it never returns participant-level data.
  • Error results: isError: true tells the agent that the call failed, and the text says why. The agent then reports that the study does not exist.

Save the code as server.js. Set MCP_TOKEN to the token you want CKEditor AI to send, then start the server:

MCP_TOKEN="<service token>" node server.js
Copy code

The server prints MCP server listening on http://localhost:3020/mcp. The url you register in the next step must be reachable from where CKEditor AI runs:

  • SaaS: the service calls your server from its own network, so localhost is not reachable. Give the server a public HTTPS address, for example through a tunnel for a local test.
  • On-premises: a plain http:// address on your network works, such as http://localhost:3020/mcp when the service runs on the same host. 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.

Usage

Copy link

Register it in the environment

Copy link

Registration is one request with a token that has the ai:admin scope. The name value identifies the server in every later request, and CKEditor AI puts it in front of each tool name the agent sees. The header is the one your server checks. See MCP tools for all options, including OAuth for servers that must know which user is asking.

Conversations and Document Processing use servers registered this way. Actions and reviews do not use MCP tools.

The steps in this section are one Node script. It reads the service URL from CKEDITOR_AI_HOST, an administrator’s token from CKEDITOR_AI_ADMIN_TOKEN, and an end user’s token from CKEDITOR_AI_TOKEN. The end user’s token needs ai:conversations:read, ai:conversations:write, and ai:models:agent, the Basic user set.

const HOST = process.env.CKEDITOR_AI_HOST;				// https://ai.cke-cs.com, https://ai.cke-cs-eu.com, or your on-premises CKEditor AI
const ADMIN_TOKEN = process.env.CKEDITOR_AI_ADMIN_TOKEN;  // a token with ai:admin
const TOKEN = process.env.CKEDITOR_AI_TOKEN;			  // an end user's token

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

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

	return response.json();
}

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 post( '/v1/admin/mcp-servers', {
	name: 'study-registry',
	url: 'https://registry.pharma.example/mcp',
	headers: {
		Authorization: 'Bearer <service token>'
	}
}, ADMIN_TOKEN );
Copy code
{
	"name": "study-registry"
}
Copy code

The server is registered for this environment only. Register it in your staging environment first, and in production after the tools behave as you expect.

Run one message through it

Copy link

This step runs three requests: create a conversation, upload a clinical study report with an empty Methods section, and ask for the study design of protocol VLM-3021. The message names only the protocol. Nothing in the request enables the tools, because CKEditor AI offers every registered server in every conversation in the environment.

await post( '/v1/conversations', { id: 'vlm-3021-study-report' }, TOKEN );

const { id: documentId } = await post( '/v1/conversations/vlm-3021-study-report/documents', {
	content: '<h1>Clinical study report: VLM-3021</h1><h2>1. Synopsis</h2><p>This report presents the results of study VLM-3021, a phase 3 study of velmatinib in adults with moderate to severe rheumatoid arthritis.</p><h2>2. Methods</h2><p>To be completed.</p><h2>3. Results</h2><p>To be completed after database lock.</p>'
}, TOKEN );
Copy code
{ "id": "DSXo3XSN-9aWiu8KLwM3u" }
Copy code

The reply is a stream. The script prints each event and its data line as they arrive.

await stream( '/v1/conversations/vlm-3021-study-report/messages', {
	prompt: 'Add the study design to the Methods section for protocol VLM-3021.',
	model: 'agent-1',
	content: [ { type: 'document', id: documentId } ]
} );
Copy code

The tool call arrives as its own mcp-tool-result event. toolName is the server name and the tool name joined with a hyphen. result is a JSON string, and the service puts the tool’s output under its data key. The edit follows as a modification-delta. Its textDelta holds the updated document. Elements the agent inserts can carry data-id="new-element", as the new paragraph does below. On other runs the paragraph comes back without it. Put data-id attributes on the elements you upload to get a fragment with stable ids instead. See Fragment responses.

The stream: one tool call, then the edit.
event: message-metadata
data: {"messageId":"kpjOKyCkSQ8-tq66OZw0b"}

event: mcp-tool-result
data: {"toolName":"study-registry-get_study","result":"{\"data\":{\"id\":\"VLM-3021\",\"title\":\"Velmatinib versus placebo in adults with moderate to severe rheumatoid arthritis and an inadequate response to methotrexate\",\"phase\":3,\"design\":\"Randomized, double-blind, placebo-controlled, parallel-group, 52 weeks\",\"protocolVersion\":\"3.0 (amendment 2, 14 May 2026)\",\"arms\":[{\"name\":\"Velmatinib 100 mg\",\"regimen\":\"100 mg orally once daily, with background methotrexate\",\"plannedParticipants\":300},…],\"primaryEndpoint\":\"Proportion of participants achieving an ACR20 response at week 24\",…,\"enrollment\":{\"planned\":900,\"enrolled\":874,\"status\":\"Enrollment complete\"},\"sites\":{\"active\":112,\"countries\":18}},\"attributes\":{}}","success":true}

event: modification-delta
data: {"id":"B_w3DBiEEGjmyqlCFXulY","documentId":"DSXo3XSN-9aWiu8KLwM3u","textDelta":"<h1>Clinical study report: VLM-3021</h1><h2>1. Synopsis</h2><p>This report presents the results of study VLM-3021, a phase 3 study of velmatinib in adults with moderate to severe rheumatoid arthritis.</p><h2>2. Methods</h2><p data-id=\"new-element\">Study VLM-3021 was a 52-week, phase 3, randomised, double-blind, placebo-controlled, parallel-group study in adults with moderate to severe rheumatoid arthritis and an inadequate response to methotrexate. Participants received velmatinib 100 mg orally once daily, velmatinib 50 mg orally once daily, or matching placebo orally once daily, each with background methotrexate. Planned enrolment was 900 participants across 112 active sites in 18 countries.</p><h2>3. Results</h2><p>To be completed after database lock.</p>"}

event: conversation-title
data: {"conversationTitle":"Adding Study Design to Protocol VLM3021 Methods"}
Copy code

The design, the arms, and the enrollment figures came from the tool. This reply has no chat text. When the agent also answers in words, the reply contains text-delta events as well. Change the prompt to “Is the 50 mg tablet approved in Japan?”, and the agent calls search_products and answers in the chat. It does not change the document. Ask the agent to shorten the synopsis, and it calls no tool at all.

Check what is registered

Copy link

The listing shows every server in the environment. disabledTools and callToolTimeout appear only when you set them. Without callToolTimeout, a tool call times out after 60 seconds. A call that runs longer than the timeout fails, and CKEditor AI tells the agent that it failed. To hide one tool, send PUT /v1/admin/mcp-servers/study-registry with disabledTools. The request must also contain url, which PUT requires.

const servers = await fetch( `${ HOST }/v1/admin/mcp-servers`, {
	headers: { 'Authorization': `Bearer ${ ADMIN_TOKEN }` }
} );

console.log( await servers.json() );
Copy code
{
	"items": [
		{
			"name": "study-registry",
			"url": "https://registry.pharma.example/mcp",
			"createdAt": "2026-09-08T06:28:44.825Z",
			"updatedAt": "2026-09-08T06:28:44.825Z"
		}
	]
}
Copy code

See MCP tools for OAuth, timeouts, and disabled tools. The Document restyling job with MCP verification in Node.js example calls a registered server from a batch job, and Style guide context in Node.js gives the agent written knowledge instead of live data.