@nutrient-sdk/dws-mcp-server
Version:
MCP server for Nutrient DWS Processor API
130 lines (129 loc) • 6 kB
JavaScript
import axios from 'axios';
import FormData from 'form-data';
import { logger } from '../logger.js';
import { getVersion } from '../version.js';
import { StaticKeyCredentialProvider } from './credential-provider.js';
const MAX_RETRIES = 3;
const INITIAL_BACKOFF_MS = 1_000;
const MAX_BACKOFF_MS = 10_000;
/**
* HTTP client for the Nutrient Document Web Services (DWS) API.
*
* Handles authentication, content-type negotiation, and streaming responses.
* All responses are returned as streams (`responseType: 'stream'`).
*/
/** DWS endpoints are written without a leading slash; tolerate one anyway. */
function stripLeadingSlash(endpoint) {
return endpoint.startsWith('/') ? endpoint.slice(1) : endpoint;
}
export class DwsApiClient {
baseUrl;
provider;
httpClient;
constructor(options) {
this.baseUrl = options.baseUrl ?? 'https://api.nutrient.io';
this.provider = options.provider;
this.httpClient = options.httpClient ?? axios.create({ timeout: options.timeoutMs ?? 120_000 });
this.installTokenRejectedInterceptor(options.retryDelayMs ?? INITIAL_BACKOFF_MS);
}
/** Whether the underlying credential provider has a credential configured for `product`. */
supports(product) {
return this.provider.supports(product);
}
/** `'extraction/parse'` and `/extraction/parse'` both target the extraction product; everything else is processor. */
productFor(endpoint) {
const normalizedEndpoint = stripLeadingSlash(endpoint);
return normalizedEndpoint.startsWith('extraction/') ? 'extraction' : 'processor';
}
installTokenRejectedInterceptor(initialDelayMs) {
this.httpClient.interceptors.response.use(undefined, async (error) => {
const status = error?.response?.status;
if (status !== 401) {
throw error;
}
const config = error.config;
// Stashed by post/get when the request was issued — the failed request's own
// product, not a re-guess, so retrying a processor request never invalidates
// (or re-resolves) the extraction credential and vice versa. The fallback below
// only fires for requests issued outside post/get; config.url is the FULL url
// (baseUrl + endpoint), so it must be tested as a path segment, not a prefix.
const product = config.__dwsProduct ?? (/\/extraction\//.test(config.url ?? '') ? 'extraction' : 'processor');
// A static API key can't be refreshed — re-resolving yields the same rejected key,
// so a 401 is terminal. Fail fast instead of spending the retry budget and backoff
// on a request that cannot succeed.
if (!this.provider.canRefresh(product)) {
throw error;
}
const retryCount = config._retryCount ?? 0;
if (retryCount >= MAX_RETRIES) {
logger.warn('Max 401 retries reached, giving up', { retryCount });
throw error;
}
config._retryCount = retryCount + 1;
const delay = Math.min(initialDelayMs * 2 ** retryCount, MAX_BACKOFF_MS);
logger.info('401 response — invalidating token and retrying', {
attempt: config._retryCount,
delayMs: delay,
product,
});
await this.provider.invalidate(product);
await new Promise((resolve) => setTimeout(resolve, delay));
const token = await this.provider.token(product);
config.headers.Authorization = `Bearer ${token}`;
return this.httpClient.request(config);
});
}
async buildHeaders(product, payload) {
const token = await this.provider.token(product);
const headers = {
Authorization: `Bearer ${token}`,
'User-Agent': `NutrientDWSMCPServer/${getVersion()}`,
};
if (payload instanceof FormData) {
return {
...headers,
...payload.getHeaders(),
};
}
if (payload) {
headers['Content-Type'] = 'application/json';
}
return headers;
}
buildUrl(endpoint) {
const normalizedEndpoint = stripLeadingSlash(endpoint);
return new URL(normalizedEndpoint, this.baseUrl.endsWith('/') ? this.baseUrl : `${this.baseUrl}/`).toString();
}
/** POST to a DWS endpoint. Automatically sets Content-Type based on the payload type. */
async post(endpoint, data, extraHeaders) {
const product = this.productFor(endpoint);
const headers = await this.buildHeaders(product, data);
// Buffer the multipart body up front: a FormData stream is consumed after the first
// request attempt, so the 401 interceptor's retry would otherwise replay an empty body.
const body = data instanceof FormData ? data.getBuffer() : data;
return this.httpClient.post(this.buildUrl(endpoint), body, {
// Built headers win: extraHeaders adds endpoint-specific keys and must not
// be able to displace Authorization or the payload's Content-Type.
headers: { ...extraHeaders, ...headers },
responseType: 'stream',
__dwsProduct: product,
});
}
/** GET a DWS endpoint. */
async get(endpoint) {
const product = this.productFor(endpoint);
const headers = await this.buildHeaders(product);
return this.httpClient.get(this.buildUrl(endpoint), {
headers,
responseType: 'stream',
__dwsProduct: product,
});
}
}
/** Creates a {@link DwsApiClient} that authenticates with a static API key against the processor product. */
export function createApiClientFromApiKey(apiKey, baseUrl) {
return new DwsApiClient({
baseUrl,
provider: new StaticKeyCredentialProvider({ processor: apiKey }),
});
}