AVAILABLE NOW: Spring 2026 Release
Roger Dunham
Published April 17, 2026
Updated April 17, 2026
8 min
Roger Dunham

Summary: This article, the third in a series, demonstrates how to extend an MCP server and HTTP server to integrate Apryse SDK functionality into AI applications. It covers implementing a document processing server to count PDF pages and convert HTML to PDF, then bridging it with an MCP server for AI tool utilization.

This is the third in a series of articles about how to make the functionality that is available within the Apryse SDK available to your AI application.
In the previous article we saw how we can create an MCP server that allows an AI application (such as Copilot Chat within VS Code) know about and use βtoolsβ that you have created.
So far, we have only created a very simple tool that adds two numbers together as a proof of concept.
In this article, we will build on what we have seen and extend our MCP server and HTTP server to expose functionality from the Apryse SDK.
Weβll start off with a trivial example and then show how we can do something more complex.
So, we will:
We could do much more as wellΒ asΒ the Apryse SDK is hugely powerful.
By the end of this article, you will be well on the way to being able to ask your AI chat tool to summarize a multiple-section PDF, add in customer feedback, and create a single consistent document.
But letβs not get ahead of ourselves.
There are multiple ways to implement this architecture, but in this walkthrough, weβll use two separate Node-based servers, each with a distinct role.
The first is the Document Processing Serverβa conventional HTTP server that exposes REST-style endpoints and performs the actual work. This is where the Apryse SDK runs, handling tasks such as loading files, converting documents, generating PDFs, and performing any other heavy lifting your application needs.
The second is the MCP Bridge Serverβa dedicated Model Context Protocol server that sits between your chat-based AI tool and the Document Processing Server. Its job is not to do the work itself, but to expose MCP tools that internally call the Document Processing Serverβs endpoints. In other words, the MCP Bridge Server translates AI tool requests into real API calls, returning clean, structured results back to the chat application.
While Iβm using Node, everything shown in this article could be implemented using Python if you prefer working with that language.
You can get the source code used in this article from this GitHub repo.
Letβs start by creating a method for counting the pages in a PDF. Weβll make it possible for the PDF to be locally available from the file system or specified as a URL.
Weβll need to install @pdftron/pdf-node (which contains the Apryse PDF functionality), as well as multer (to handle file transfers) and dotenv (so that we can store the Apryse license key in an environment variable separately from our code).
We then need to add a new endpoint 'pdf-page-count'.
There are a few things going on here.
If we start the server using Nodeserver.js , then now we have an endpoint where if we pass in the path to a PDF (or the URL) then we will get back the number of pages. We can test that using curl.

Figure 1:Β We can now count the number of pages in a PDF using our action endpoint. In this case, the PDF had 22 pages.
If we wanted, then we could create an app that utilizes that endpoint directly. Thatβs a perfectly valid use case. However, for this article, we are interested in getting an AI application to do that work.
As we saw in the previous article, we could just tell the AI application exactly where the endpoint is, but itβs much better to do that using an MCP server. That provides a standard way of defining tools and abstracts the implementation.
We created a simple MCP server in the previous article. We now just need to register the new tool that we have implemented, giving a name, a title, and a description.
We also need to specify an βinput schemaβ. In this case, it specifies that either a path to a local PDF or a URL must be passed to the function. It even gives us feedback if we fail to do so.
Next, we need to define the implementation. Since the PDF may be either a local file or a URL, we need to get a buffer that contains the data from the PDF, whether it was fetched from the URL or loaded from the file system.
That buffer is then passed to the function that calls the endpoint on the Document Processing Server.
When that function returns, the page count information is extracted and passed back as text.
Thereβs a fair bit to that, but much of the code is error handling and prep, rather than the actual code that does the work. Once we have that set up, we now have a tool that an AI Chat application can use.
Before we do that, letβs try things out in the MCP Inspector. We saw how to do that in the earlier article.

Figure 2: MCP Inspector showing the tools that we now have available.
Just as before, the MCP inspector uses the tool input schema to update its UI. When we select the βGet the number of pages in a PDFβ tool, it allows us to enter either a βpdf_pathβ or a βpdf_urlβ.
Weβll test that with a local PDF (a document called βblue.pdfβ) which has 12 pages.

Figure 3: The sample document in a WebViewer based app. It has 12 pages.
Letβs add the path to the file, then click on Run Tool.
After a moment, the result appears telling us that the document has 12 pages.

Figure 4: MCP Inspector creates fields where we can enter parameters that the tool needs. When we run it, we get the number of pages in the PDF, in this case 12.
Awesome! Our code is working.
Now letβs head back into Copilot Chat with VS Code. As Iβve created a new MCP server, Iβll let Copilot know about that (or if I had just edited the existing one, then I might need to restart it).
Now we can ask βWhat tools do you know about for counting the pages in PDFs?β, and it knows what to do.

Figure 5: Copilot Chat now knows how to count pages within a PDF.
That means that we can now get Copilot to do something more complex, such as getting the number of pages in two separate PDFs then adding them together.

Figure 6: Copilot can now do more complex things, chaining together multiple tools to solve a problem.
Copilot will think about this for a moment, then get the page counts as requested, then decide, for itself, to use the βadd badlyβ function that we created previously.

Figure 7: Copilot can now use the tools that we have created. You may need to authorize it to do so.
Without any extra effort on our part, Copilot worked out that it should use the tool that we had created to get the number of pages in each PDF, then use our (rather artificial) tool for adding those numbers together.
In the earlier article, I explained that I wrote a tool that adds numbers together in a specific, slightly incorrect, way, so that we could be sure that Copilot was using our tools and not some other tool that it knows about]
Itβs good practice to verify the results that AI generates. If you manually open and look at those two PDFs, then we find that one has 12 pages, and the other has 9.Β Copilot gave us exactly the answer that was expected. Awesome.
Great question, it could be just about anywhere!
For now, though, letβs add one more thing to our HTTP serverβthe ability to take a URL, and create a PDF from the HTML.
Since we are using the Apryse SDK, itβs easy to create PDFs from HTML. We just need to download and install the HTML2PDF module. That is one of a number of add-on modules that are available that offer βadvanced functionalityβ. Others include Advanced Imaging (which lets you work with a whole range of image formats), Structured Output (which converts PDFs to Office) and Data Extraction (which allows you to extract tabular data, structure and form fields from documents).
The HTML2PDF module is shipped as a zip file. I extracted the contents and placed them into a new folder βlibs/HTML2PDFWindowsβ in my project.

Figure 8: The contents of the HTML2PDF module, copied into a new folder in my project.
Now I just need to add a new endpoint βconvert-urlβ, which will leverage the HTML2PDF converter.
This is a little more to it than just counting pages in an existing PDF. I wonβt go into details, so you might like to look at the documentation that explains more about how HTML to PDF conversion works.
You will, however, need to let PDFNet know where to find the HTML2PDF module. We do it by calling setModulePath.
await PDFNet.HTML2PDF.setModulePath('libs/HTML2PDFWindows');Note that, generally, there should not be a leading slash in that path.
We could test this out with curl, but letβs go straight to setting up the MCP server so that it can make this endpoint available.
Just as before, we need to register the tool, giving it a title description and input schema.
And we need to specify the actual tool handler. Iβll just include the happy path code here, to keep things simple. For production, you would need to handle the various errors or other responses.
Weβve specified the endpoint, passed in the URL, then extracted the arrayBuffer from the response, and returned that to the calling application as a Base64 Encoded string.
Letβs see that working in the MCP Inspector.
We just need to connect to the server, choose our new tool, enter a URL, and then click on Run Tool.

Figure 9: MCP Inspector showing the result of converting an HTML page into a PDF. The data for the PDF is shown here as a Base64 encoded string.
It returns a Base64 encode string. Thatβs OK; the AI Agent will know how to decode that.
If you want to prove that it works, then you can copy that string into a text file, extract the bytes, and save the PDF manually.

Figure 10: If you want to check the output, then you can save the base64 encode string, convert that to bytes and save the result as a PDF.
If you do that, then you can see that we really did create a PDF from the URL.

Figure 11: The PDF that was created from the URL by our action endpoint.
Great! MCP inspector can access the code that uses the Apryse SDK.
And we have still only scratched the surface of what is possible. For example, we could extend what we have seen and:
The list goes on and on.
To make this functionality available to an AI Chat tool, you just need to create a document processing service that exposes an endpoint, and an MCP server that describes how to use the endpoint.
Once you have done that, you can let your Chat agent know about the tools that are exposed, and then you can ask it to do things such as βThere is a commissioned report with 6 sections, where the customer wants a key takeaway page inserted before each section and relevant customer feedback pulled in to support whatever those key takeaways are.β
The Chat agent, could then use a mixture of the tools that it already knew about (such as for working with databases), with the new ones available from an MCP server to develop a workflow that:
With no other interaction, the AI application can then create a polished report that contains the extracted information.
That truly is awesome.
If youβve got any questions, then you can contact me at blog-feedback@apryse.com.
PRODUCTS
Platform Integrations
End User Applications
Popular Content
RESOURCES
// Core endpoint: POST /pdf-page-count (multipart form with `file`)
app.post('pdf-page-count', upload.single('file'), async (req, res) => {
β― β― const started = Date.now();
β― β― let tmpFilePath = null;
β― β― try {
β― β― β― β― if (!req.file) {
β― β― β― β― β― β― return res.status(400).json({ ok: false, error: 'Missing file (expected multipart field "file")' });
β― β― β― β― }
β― β― β― β― tmpFilePath = req.file.path;
await ensurePdfNetInitialized();
β― β― β― β― // Encapsulate the Apryse work in runWithCleanup to automatically manage native resources
β― β― β― β― β― const result = await PDFNet.runWithCleanup(async () => {
β― β― β― β― β― β― β― β― const doc = await PDFNet.PDFDoc.createFromFilePath(tmpFilePath);
β― β― β― β― β― β― β― β― const pageCount = await doc.getPageCount();
β― β― β― β― β― β― β― β― return { page_count: pageCount };
β― β― β― β― })
β― β― β― β― const elapsed = Date.now() - started;
β― β― β― β― return res.json({
β― β― β― β― β― β― ok: true,
β― β― β― β― β― β― page_count: result.page_count,
β― β― β― β― β― β― meta: {
β― β― β― β― β― β― β― β― file_name: req.file.originalname,
β― β― β― β― β― β― β― β― content_type: req.file.mimetype,
β― β― β― β― β― β― β― β― processing_ms: elapsed
β― β― β― β― β― β― }
β― β― β― β― });
β― β― } catch (err) {
β― β― β― β― const message = (err && err.message) ? err.message : String(err);
β― β― β― β― return res.status(500).json({ ok: false, error: message });
β― β― } finally {
β― β― β― β― if (tmpFilePath) {
β― β― β― β― β― β― // Best-effort temp cleanup
β― β― β― β― β― β― fs.unlink(tmpFilePath).catch(() => { });
β― β― β― β― }
β― β― }
});server.registerTool(
β― 'pdf_pagecount',
β― {
β― β― title: 'Get the number of pages in a PDF',
β― β― description:
β― β― β― 'Uploads a PDF (by local path or URL) and returns the number of pages within the PDF.',
inputSchema: //defined below
β― }, inputSchema: z.object({
β― β― β― β― pdf_path: z.string().describe('Absolute path to a local PDF').optional(),
β― β― β― β― pdf_url: z.string().url().describe('HTTP(S) URL to a PDF').optional(),
β― β― β― })
β― β― β― .refine((v) => v.pdf_path || v.pdf_url, {
β― β― β― β― message: 'Provide either pdf_path or pdf_url',
β― β― β― }), let buffer, filename;
β― β― β― if (pdf_path) {
β― β― β― β― // Read local file
β― β― β― β― const { readFile } = await import('node:fs/promises');
β― β― β― β― buffer = await readFile(pdf_path);
β― β― β― β― filename = pdf_path.split(/[\\/]/).pop() || 'document.pdf';
β― β― β― } else {
β― β― β― β― // Download from URL
β― β― β― β― const r = await fetch(pdf_url);
β― β― β― β― if (!r.ok) throw new Error(`Failed to download PDF: ${r.status}`);
β― β― β― β― const ab = await r.arrayBuffer();
β― β― β― β― buffer = new Uint8Array(ab);
β― β― β― β― try {
β― β― β― β― β― filename = new URL(pdf_url).pathname.split('/').pop() || 'document.pdf';
β― β― β― β― } catch {
β― β― β― β― β― filename = 'document.pdf';
β― β― β― β― }
β― β― β― } async function getPageCount(buffer, filename ) {
β― const PAGE_COUNT_URL = 'http://localhost:3000/pdf-page-count';
β― const form = new FormData();
β― const blob = new Blob([buffer], { type: 'application/pdf' });
β― form.append('file', blob, filename);
β― const resp = await fetch(PAGE_COUNT_URL, { method: 'POST', body: form });
β― if (!resp.ok) {
β― β― throw new Error(`Upstream /pdf-page-count failed: ${resp.status} ${await resp.text()}`);
β― }
β― return resp.json(); // Expect something like { ok: true, text: "..." }
}// Forward to your Express endpoint (field "file")
β― β― β― const resPageCount = await getPageCount(buffer, filename);
β― β― β― // Extract text or provide a safe fallback
β― β― β― β―const text =
β― β― β― typeof resPageCount?.text === 'string' ? resPageCount.text : JSON.stringify(resPageCount);
β― β― β― // Return MCP content (text block) and optional structured data
β― β― β― return {
β― β― β― β― content: [{ type: 'text', text }],
β― β― β― β― isError: false,
β― β― β― }; app.post('/convert-url', async (req, res) => {
β― await ensurePdfNetInitialized();
β― const { url } = req.body || {};
β― if (!url || typeof url !== 'string') {
β― β― return res.status(400).json({ error: 'Missing or invalid "url"' });
β― }
β― await PDFNet.HTML2PDF.setModulePath('libs/HTML2PDFWindows');
β― try {
β― β― await PDFNet.runWithCleanup(async () => {
β― β― β― const doc = await PDFNet.PDFDoc.create();
β― β― β― const converter = await PDFNet.HTML2PDF.create();
β― β― β― await converter.insertFromUrl(url);
β― β― β― await converter.convert(doc);
β― β― β― const pdfBuffer = await doc.saveMemoryBuffer(
β― β― β― β― PDFNet.SDFDoc.SaveOptions.e_linearized
β― β― β― );
β― β― β― res.set('Content-Type', 'application/pdf');
β― β― β― res.set('Content-Disposition', 'attachment; filename="converted.pdf"');
β― β― β― res.send(Buffer.from(pdfBuffer));
β― β― });
β― } catch (e) {
β― β― console.error(e);
β― β― res.status(500).json({ error: 'Conversion failed' });
β― }
});server.registerTool(
β― 'convert_url',
β― {
β― β― title: 'Converts a URL into a PDF',
β― β― description: 'Converts a web URL to a PDF and returns it as an embedded resource.',
β― β― // Input schema (validated automatically)
β― β― inputSchema: z.object({
β― β― β― url: z.string().url().describe('Publicly reachable URL to convert to PDF'),
β― β―}),
β― },
...β―async ({ url}) => {
β― β― const endpoint = 'http://localhost:3000/convert-url';
β― β― const ac = new AbortController();
β― β― const timeoutMs = 60_000;
β― β― const timer = setTimeout(() => ac.abort(), timeoutMs);
β― β― let resp;
β― β― try {
β― β― β― resp = await fetch(endpoint, {
β― β― β― β― method: 'POST',
β― β― β― β― headers: { 'Content-Type': 'application/json'},
β― β― β― β― body: JSON.stringify({ url }),
β― β― β― β― signal: ac.signal
β― β― β― });
β― β― } finally {
β― β― β― clearTimeout(timer);
β― β― }
const finalName = β―'converted.pdf';
β― β― // Read bytes and encode base64 for MCP resource
β― β― const ab = await resp.arrayBuffer();
β― β― const base64 = Buffer.from(ab).toString('base64');
β― β― return {
β― β― β― content: [
β― β― β― β― {
β― β― β― β― β― type: 'resource',
β― β― β― β― β― resource: {
β― β― β― β― β― β― mimeType: 'application/pdf',
β― β― β― β― β― β― blob: base64
β― β― β― β― β― }
β― β― β― β― },
β― β― β― β― { type: 'text', text: `Converted ${url} β ${finalName} (${Math.ceil(ab.byteLength / 1024)} KB)` }
β― β― β― ]
β― β― };
});