UNPKG

@nutrient-sdk/dws-mcp-server

Version:

MCP server for Nutrient DWS Processor API

130 lines (129 loc) 6 kB
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 }), }); }