((resolve, reject) => {
+ const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
+ try {
+ const url = new URL(req.url ?? '/', `http://localhost`)
+ if (url.pathname !== '/callback') {
+ res.writeHead(404)
+ res.end('Not found')
+ return
+ }
+
+ const error = url.searchParams.get('error')
+ if (error) {
+ const description = url.searchParams.get('error_description') ?? error
+ res.writeHead(400, { 'Content-Type': 'text/html' })
+ res.end(`Authorization Failed
${description}
You can close this tab.
`)
+ server.close()
+ reject(new Error(`OAuth authorization failed: ${description}`))
+ return
+ }
+
+ const returnedState = url.searchParams.get('state')
+ if (returnedState !== state) {
+ res.writeHead(400, { 'Content-Type': 'text/html' })
+ res.end('Invalid State
OAuth state mismatch. Please try again.
')
+ server.close()
+ reject(new Error('OAuth state mismatch'))
+ return
+ }
+
+ const code = url.searchParams.get('code')
+ if (!code) {
+ res.writeHead(400, { 'Content-Type': 'text/html' })
+ res.end('Missing Code
No authorization code received.
')
+ server.close()
+ reject(new Error('No authorization code received'))
+ return
+ }
+
+ const cbPort = config.callbackPort ?? DEFAULT_CALLBACK_PORT
+ const redirectUri = `http://localhost:${cbPort}/callback`
+
+ const credentials = await exchangeCodeForToken(config, clientId, code, codeVerifier, redirectUri)
+
+ res.writeHead(200, { 'Content-Type': 'text/html' })
+ res.end('Authenticated!
You can close this tab and return to your terminal.
')
+ server.close()
+ resolve(credentials)
+ } catch (err) {
+ res.writeHead(500, { 'Content-Type': 'text/html' })
+ res.end('Error
Something went wrong during authentication.
')
+ server.close()
+ reject(err)
+ }
+ })
+
+ const callbackPort = config.callbackPort ?? DEFAULT_CALLBACK_PORT
+
+ server.listen(callbackPort, '127.0.0.1', async () => {
+ const redirectUri = `http://localhost:${callbackPort}/callback`
+ const authorizeUrl = buildAuthorizeUrl(config, clientId, redirectUri, codeChallenge, state)
+
+ logger.info('OAuth callback server listening', { port: callbackPort, redirectUri })
+ logger.debug('Authorize URL', { authorizeUrl })
+
+ // Dynamic import to avoid bundling issues — `open` is an ESM-only package
+ const { default: open } = await import('open')
+ logger.info('Opening browser for Nutrient authentication...')
+ await open(authorizeUrl)
+ })
+
+ server.on('error', reject)
+ })
+}
+
+/**
+ * Returns a valid Nutrient DWS API access token.
+ *
+ * Checks cached credentials first, attempts token refresh if expired,
+ * and falls back to a browser-based OAuth flow if no valid token is available.
+ */
+export async function getToken(config: NutrientOAuthConfig): Promise {
+ const credentialsPath = config.credentialsPath ?? DEFAULT_CREDENTIALS_PATH
+
+ // 0. Resolve client ID (from config, cached DCR, or fresh DCR registration)
+ const clientId = await resolveClientId(config)
+
+ logger.debug('getToken called', { clientId, credentialsPath })
+
+ // 1. Check cached token
+ const cached = await readCachedCredentials(credentialsPath)
+
+ if (cached) {
+ // 2. Valid token — return it
+ if (!isTokenExpired(cached)) {
+ logger.debug('Using cached token (not expired)')
+ return cached.accessToken
+ }
+
+ logger.debug('Cached token expired', { expiresAt: cached.expiresAt ? new Date(cached.expiresAt).toISOString() : 'unknown' })
+
+ // 3. Expired but has refresh token — try refresh
+ if (cached.refreshToken) {
+ logger.info('Attempting token refresh')
+ const refreshed = await refreshAccessToken(config, clientId, cached.refreshToken)
+ if (refreshed) {
+ logger.info('Token refreshed successfully')
+ await writeCachedCredentials(credentialsPath, refreshed)
+ return refreshed.accessToken
+ }
+ logger.warn('Token refresh failed, falling back to browser flow')
+ }
+ } else {
+ logger.info('No cached credentials found')
+ }
+
+ // 4. No valid token — browser OAuth flow
+ logger.info('Starting browser OAuth flow', { authorizeUrl: config.authorizeUrl, clientId })
+ const credentials = await performBrowserOAuthFlow(config, clientId)
+ logger.info('Browser OAuth flow completed successfully')
+ await writeCachedCredentials(credentialsPath, credentials)
+ return credentials.accessToken
+}
diff --git a/src/dws/ai-redact.ts b/src/dws/ai-redact.ts
index e175b91..e1dd86b 100644
--- a/src/dws/ai-redact.ts
+++ b/src/dws/ai-redact.ts
@@ -5,7 +5,7 @@ import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
import { handleApiError, handleFileResponse } from './utils.js'
import { createErrorResponse } from '../responses.js'
import { resolveReadFilePath, resolveWriteFilePath } from '../fs/sandbox.js'
-import { callNutrientApi } from './api.js'
+import { DwsApiClient } from './client.js'
/**
* Performs an AI redaction call to the Nutrient DWS AI Redact API.
@@ -14,6 +14,7 @@ export async function performAiRedactCall(
filePath: string,
criteria: string,
outputPath: string,
+ apiClient: DwsApiClient,
stage?: boolean,
apply?: boolean,
): Promise {
@@ -28,9 +29,7 @@ export async function performAiRedactCall(
// Guard against output overwriting input
if (resolvedInputPath === resolvedOutputPath) {
- return createErrorResponse(
- 'Error: Output path must be different from input path to prevent data corruption.',
- )
+ return createErrorResponse('Error: Output path must be different from input path to prevent data corruption.')
}
const fileBuffer = await fs.promises.readFile(resolvedInputPath)
@@ -51,7 +50,7 @@ export async function performAiRedactCall(
formData.append('file1', fileBuffer, { filename: fileName })
formData.append('data', JSON.stringify(dataPayload))
- const response = await callNutrientApi('ai/redact', formData)
+ const response = await apiClient.post('ai/redact', formData)
return handleFileResponse(response, resolvedOutputPath, 'AI redaction completed successfully. Output saved to')
} catch (e: unknown) {
diff --git a/src/dws/api.ts b/src/dws/api.ts
index b8540d9..e4dd77c 100644
--- a/src/dws/api.ts
+++ b/src/dws/api.ts
@@ -1,31 +1,29 @@
-import FormData from 'form-data'
-import axios from 'axios'
-import { getApiKey } from './utils.js'
-import { getVersion } from '../version.js'
+import { DwsApiClient, createApiClientFromApiKey, createApiClientFromTokenResolver } from './client.js'
/**
- * Makes an API call to the Nutrient API
- * @param endpoint The API endpoint to call (e.g., 'sign', 'build')
- * @param data The data to send (FormData or JSON object)
- * @returns The API response
+ * Discriminated union describing how to authenticate with the DWS API.
+ *
+ * - Provide `apiKey` for static API-key auth (stdio mode, static HTTP mode).
+ * - Provide `tokenResolver` for dynamic token auth (JWT/OAuth mode).
*/
-export async function callNutrientApi(endpoint: string, data: FormData | Record) {
- const apiKey = getApiKey()
- const isFormData = data instanceof FormData
+export type ApiClientAuthContext =
+ | {
+ apiKey: string
+ baseUrl?: string
+ }
+ | {
+ tokenResolver: () => Promise
+ baseUrl?: string
+ }
- const defaultHeaders: Record = {
- Authorization: `Bearer ${apiKey}`,
- 'User-Agent': `NutrientDWSMCPServer/${getVersion()}`,
+/**
+ * Factory that creates a {@link DwsApiClient} from an auth context.
+ * Selects the appropriate authentication strategy based on the context shape.
+ */
+export function createApiClient(context: ApiClientAuthContext): DwsApiClient {
+ if ('apiKey' in context) {
+ return createApiClientFromApiKey(context.apiKey, context.baseUrl)
}
- const headers: Record = isFormData
- ? defaultHeaders
- : {
- ...defaultHeaders,
- 'Content-Type': 'application/json',
- }
- return axios.post(`https://api.nutrient.io/${endpoint}`, data, {
- headers,
- responseType: 'stream',
- })
+ return createApiClientFromTokenResolver(context.tokenResolver, context.baseUrl)
}
diff --git a/src/dws/build.ts b/src/dws/build.ts
index 66c3696..636aac5 100644
--- a/src/dws/build.ts
+++ b/src/dws/build.ts
@@ -7,12 +7,16 @@ import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
import { FileReference } from './types.js'
import { createErrorResponse } from '../responses.js'
import { resolveReadFilePath, resolveWriteFilePath } from '../fs/sandbox.js'
-import { callNutrientApi } from './api.js'
+import { DwsApiClient } from './client.js'
/**
* Performs a build call to the Nutrient DWS Processor API
*/
-export async function performBuildCall(instructions: Instructions, outputFilePath: string): Promise {
+export async function performBuildCall(
+ instructions: Instructions,
+ outputFilePath: string,
+ apiClient: DwsApiClient,
+): Promise {
const { instructions: adjustedInstructions, fileReferences } = await processInstructions(instructions)
if (fileReferences.size === 0) {
@@ -22,7 +26,7 @@ export async function performBuildCall(instructions: Instructions, outputFilePat
try {
// We resolve the output path first to fail early
const resolvedOutputPath = await resolveWriteFilePath(outputFilePath)
- const response = await makeApiBuildCall(adjustedInstructions, fileReferences)
+ const response = await makeApiBuildCall(adjustedInstructions, fileReferences, apiClient)
if (adjustedInstructions.output?.type === 'json-content') {
return handleJsonContentResponse(response)
@@ -131,11 +135,15 @@ async function processFileReference(reference: string): Promise {
/**
* Make the API call to the build endpoint
*/
-async function makeApiBuildCall(instructions: Instructions, fileReferences: Map) {
+async function makeApiBuildCall(
+ instructions: Instructions,
+ fileReferences: Map,
+ apiClient: DwsApiClient,
+) {
const allInputsAreUrls = Array.from(fileReferences.values()).every((fileRef) => fileRef.url)
if (allInputsAreUrls) {
- return callNutrientApi('build', instructions)
+ return apiClient.post('build', instructions)
} else {
const formData = new FormData()
formData.append('instructions', JSON.stringify(instructions))
@@ -146,6 +154,6 @@ async function makeApiBuildCall(instructions: Instructions, fileReferences: Map<
}
}
- return callNutrientApi('build', formData)
+ return apiClient.post('build', formData)
}
}
diff --git a/src/dws/client.ts b/src/dws/client.ts
new file mode 100644
index 0000000..9e784f1
--- /dev/null
+++ b/src/dws/client.ts
@@ -0,0 +1,96 @@
+import axios, { AxiosInstance, AxiosResponse } from 'axios'
+import FormData from 'form-data'
+import { getVersion } from '../version.js'
+
+/** Async function that returns a bearer token for authenticating with the DWS API. */
+export type DwsTokenResolver = () => Promise
+
+export type DwsApiClientOptions = {
+ /** DWS API base URL. Defaults to `https://api.nutrient.io`. */
+ baseUrl?: string
+ /** Provides the bearer token for each request. Called on every API call. */
+ tokenResolver: DwsTokenResolver
+ /** Optional custom Axios instance (useful for testing or proxy configuration). */
+ httpClient?: AxiosInstance
+}
+
+/**
+ * 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'`).
+ */
+export class DwsApiClient {
+ private readonly baseUrl: string
+ private readonly tokenResolver: DwsTokenResolver
+ private readonly httpClient: AxiosInstance
+
+ constructor(options: DwsApiClientOptions) {
+ this.baseUrl = options.baseUrl ?? 'https://api.nutrient.io'
+ this.tokenResolver = options.tokenResolver
+ this.httpClient = options.httpClient ?? axios.create()
+ }
+
+ private async buildHeaders(payload?: FormData | Record) {
+ const token = await this.tokenResolver()
+
+ const headers: Record = {
+ Authorization: `Bearer ${token}`,
+ 'User-Agent': `NutrientDWSMCPServer/${getVersion()}`,
+ }
+
+ if (payload instanceof FormData) {
+ return {
+ ...headers,
+ ...payload.getHeaders(),
+ }
+ }
+
+ if (payload) {
+ headers['Content-Type'] = 'application/json'
+ }
+
+ return headers
+ }
+
+ private buildUrl(endpoint: string): string {
+ const normalizedEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : 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: string, data: FormData | Record): Promise {
+ const headers = await this.buildHeaders(data)
+
+ return this.httpClient.post(this.buildUrl(endpoint), data, {
+ headers,
+ responseType: 'stream',
+ })
+ }
+
+ /** GET a DWS endpoint. */
+ async get(endpoint: string): Promise {
+ const headers = await this.buildHeaders()
+
+ return this.httpClient.get(this.buildUrl(endpoint), {
+ headers,
+ responseType: 'stream',
+ })
+ }
+}
+
+/** Creates a {@link DwsApiClient} that authenticates with a static API key. */
+export function createApiClientFromApiKey(apiKey: string, baseUrl?: string): DwsApiClient {
+ return new DwsApiClient({
+ baseUrl,
+ tokenResolver: async () => apiKey,
+ })
+}
+
+/** Creates a {@link DwsApiClient} that resolves a fresh token on each request (e.g. for JWT/OAuth flows). */
+export function createApiClientFromTokenResolver(tokenResolver: DwsTokenResolver, baseUrl?: string): DwsApiClient {
+ return new DwsApiClient({
+ baseUrl,
+ tokenResolver,
+ })
+}
diff --git a/src/dws/credits.ts b/src/dws/credits.ts
index 87ff6b4..176db9a 100644
--- a/src/dws/credits.ts
+++ b/src/dws/credits.ts
@@ -1,7 +1,6 @@
-import axios from 'axios'
-import { getApiKey, pipeToString } from './utils.js'
-import { getVersion } from '../version.js'
+import { pipeToString } from './utils.js'
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
+import { DwsApiClient } from './client.js'
/**
* Account info response from DWS API (GET /account/info)
@@ -32,16 +31,8 @@ export function sanitizeAccountInfo(data: AccountInfoResponse): Omit {
- const apiKey = getApiKey()
-
- const response = await axios.get('https://api.nutrient.io/account/info', {
- headers: {
- Authorization: `Bearer ${apiKey}`,
- 'User-Agent': `NutrientDWSMCPServer/${getVersion()}`,
- },
- responseType: 'stream',
- })
+export async function performCheckCreditsCall(apiClient: DwsApiClient): Promise {
+ const response = await apiClient.get('account/info')
const raw = await pipeToString(response.data)
diff --git a/src/dws/sign.ts b/src/dws/sign.ts
index 6c29263..d344055 100644
--- a/src/dws/sign.ts
+++ b/src/dws/sign.ts
@@ -2,10 +2,10 @@ import FormData from 'form-data'
import { handleApiError, handleFileResponse } from './utils.js'
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
import { SignatureOptions } from '../schemas.js'
-import { callNutrientApi } from './api.js'
import { resolveReadFilePath, resolveWriteFilePath } from '../fs/sandbox.js'
import fs from 'fs'
import path from 'path'
+import { DwsApiClient } from './client.js'
/**
* Performs a sign call to the Nutrient DWS API
@@ -13,6 +13,7 @@ import path from 'path'
export async function performSignCall(
filePath: string,
outputFilePath: string,
+ apiClient: DwsApiClient,
signatureOptions: SignatureOptions = { signatureType: 'cms', flatten: false },
watermarkImagePath?: string,
graphicImagePath?: string,
@@ -36,7 +37,7 @@ export async function performSignCall(
await addFileToFormData(formData, 'graphic', graphicImagePath)
}
- const response = await callNutrientApi('sign', formData)
+ const response = await apiClient.post('sign', formData)
return handleFileResponse(response, resolvedOutputPath, 'File signed successfully')
} catch (e: unknown) {
diff --git a/src/dws/utils.ts b/src/dws/utils.ts
index cea5dac..d69c27d 100644
--- a/src/dws/utils.ts
+++ b/src/dws/utils.ts
@@ -35,18 +35,6 @@ export async function pipeToBuffer(responseData: Readable): Promise {
})
}
-/**
- * Validates that the API key is set in the environment
- * @returns Object with error information if API key is not set
- */
-export function getApiKey(): string {
- if (!process.env.NUTRIENT_DWS_API_KEY) {
- throw new Error('NUTRIENT_DWS_API_KEY not set in environment')
- }
-
- return process.env.NUTRIENT_DWS_API_KEY
-}
-
/**
* Handles API errors and converts them to a standard format
* @returns Object with error information
diff --git a/src/http/authMiddleware.ts b/src/http/authMiddleware.ts
new file mode 100644
index 0000000..950a554
--- /dev/null
+++ b/src/http/authMiddleware.ts
@@ -0,0 +1,46 @@
+import type { RequestHandler } from 'express'
+import { Environment } from '../utils/environment.js'
+import { createJwtAuthMiddleware } from './jwtAuth.js'
+
+function addAudienceWithTrailingSlashVariants(target: Set, value: string) {
+ const trimmed = value.trim().replace(/\/+$/, '')
+ if (!trimmed) {
+ return
+ }
+
+ target.add(trimmed)
+ target.add(`${trimmed}/`)
+}
+
+export function buildJwtAudiences(resourceUrl: string): string[] {
+ const audiences = new Set(['dws-mcp'])
+ addAudienceWithTrailingSlashVariants(audiences, resourceUrl)
+
+ try {
+ const parsed = new URL(resourceUrl)
+ addAudienceWithTrailingSlashVariants(audiences, parsed.origin)
+
+ const normalizedPath = parsed.pathname.replace(/\/+$/, '')
+ if (normalizedPath && normalizedPath !== '/') {
+ addAudienceWithTrailingSlashVariants(audiences, `${parsed.origin}${normalizedPath}`)
+ }
+ } catch {
+ // Keep best-effort audience list when resourceUrl is not a valid URL.
+ }
+
+ return Array.from(audiences)
+}
+
+export function createAuthMiddleware(environment: Environment): RequestHandler {
+ if (!environment.issuer) {
+ throw new Error('JWT auth requires ISSUER (defaults to AUTH_SERVER_URL)')
+ }
+
+ return createJwtAuthMiddleware({
+ jwksUrl: environment.jwksUrl,
+ issuer: environment.issuer,
+ audience: buildJwtAudiences(environment.resourceUrl),
+ requiredScope: 'mcp:invoke',
+ resourceMetadataUrl: environment.protectedResourceMetadataUrl,
+ })
+}
diff --git a/src/http/authUtils.ts b/src/http/authUtils.ts
new file mode 100644
index 0000000..5d5cd3b
--- /dev/null
+++ b/src/http/authUtils.ts
@@ -0,0 +1,18 @@
+import { createHash } from 'node:crypto'
+
+export function hashPrincipal(input: string): string {
+ return createHash('sha256').update(input).digest('hex')
+}
+
+export function parseBearerToken(authHeader?: string): string | undefined {
+ if (!authHeader) {
+ return undefined
+ }
+
+ const [scheme, token] = authHeader.split(/\s+/, 2)
+ if (!scheme || !token || scheme.toLowerCase() !== 'bearer') {
+ return undefined
+ }
+
+ return token
+}
diff --git a/src/http/jwtAuth.ts b/src/http/jwtAuth.ts
new file mode 100644
index 0000000..08337e9
--- /dev/null
+++ b/src/http/jwtAuth.ts
@@ -0,0 +1,123 @@
+import type { RequestHandler } from 'express'
+import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'
+import { createRemoteJWKSet, jwtVerify, JWTPayload } from 'jose'
+import { RequestWithAuth } from './types.js'
+import { buildWwwAuthenticateHeader } from './protectedResource.js'
+import { hashPrincipal, parseBearerToken } from './authUtils.js'
+
+function parseScopes(payload: JWTPayload): string[] {
+ if (typeof payload.scope !== 'string') {
+ return []
+ }
+
+ return payload.scope
+ .split(/\s+/)
+ .map((scope) => scope.trim())
+ .filter(Boolean)
+}
+
+function parseAllowedTools(payload: JWTPayload): string[] | undefined {
+ const rawClaim = payload.allowed_tools
+
+ if (Array.isArray(rawClaim)) {
+ const tools = rawClaim.filter((tool): tool is string => typeof tool === 'string' && tool.trim().length > 0)
+ return tools.length > 0 ? tools : undefined
+ }
+
+ if (typeof rawClaim === 'string') {
+ const tools = rawClaim
+ .split(/[\s,]+/)
+ .map((tool) => tool.trim())
+ .filter(Boolean)
+
+ return tools.length > 0 ? tools : undefined
+ }
+
+ return undefined
+}
+
+function toAuthInfo(token: string, payload: JWTPayload): AuthInfo {
+ const sub = typeof payload.sub === 'string' ? payload.sub : ''
+ const azp = typeof payload.azp === 'string' ? payload.azp : ''
+ const sid = typeof payload.sid === 'string' ? payload.sid : ''
+
+ return {
+ token,
+ clientId: azp || sub || 'unknown-client',
+ scopes: parseScopes(payload),
+ expiresAt: typeof payload.exp === 'number' ? payload.exp : undefined,
+ extra: {
+ allowedTools: parseAllowedTools(payload),
+ principalFingerprint: hashPrincipal(`${sub}|${azp}|${sid}`),
+ subject: sub,
+ authorizedParty: azp,
+ sessionId: sid,
+ },
+ }
+}
+
+export function createJwtAuthMiddleware(options: {
+ jwksUrl: string
+ issuer: string
+ audience: string | string[]
+ requiredScope: string
+ resourceMetadataUrl: string
+}): RequestHandler {
+ const jwks = createRemoteJWKSet(new URL(options.jwksUrl))
+
+ return async (req, res, next) => {
+ const token = parseBearerToken(req.headers.authorization)
+
+ if (!token) {
+ res.set('WWW-Authenticate', buildWwwAuthenticateHeader({ resourceMetadataUrl: options.resourceMetadataUrl }))
+ res.status(401).json({
+ error: 'invalid_token',
+ error_description: 'Missing or malformed Authorization header',
+ })
+ return
+ }
+
+ try {
+ const { payload } = await jwtVerify(token, jwks, {
+ issuer: options.issuer,
+ audience: options.audience,
+ clockTolerance: '30s',
+ })
+
+ const scopes = parseScopes(payload)
+ if (!scopes.includes(options.requiredScope)) {
+ res.set(
+ 'WWW-Authenticate',
+ buildWwwAuthenticateHeader({
+ resourceMetadataUrl: options.resourceMetadataUrl,
+ error: 'invalid_token',
+ errorDescription: `Required scope "${options.requiredScope}" is missing`,
+ scope: options.requiredScope,
+ }),
+ )
+ res.status(401).json({
+ error: 'invalid_token',
+ error_description: `Required scope "${options.requiredScope}" is missing`,
+ })
+ return
+ }
+
+ ;(req as RequestWithAuth).auth = toAuthInfo(token, payload)
+ next()
+ } catch (error) {
+ const errorDescription = error instanceof Error ? error.message : 'Invalid token'
+ res.set(
+ 'WWW-Authenticate',
+ buildWwwAuthenticateHeader({
+ resourceMetadataUrl: options.resourceMetadataUrl,
+ error: 'invalid_token',
+ errorDescription,
+ }),
+ )
+ res.status(401).json({
+ error: 'invalid_token',
+ error_description: errorDescription,
+ })
+ }
+ }
+}
diff --git a/src/http/protectedResource.ts b/src/http/protectedResource.ts
new file mode 100644
index 0000000..2dbed73
--- /dev/null
+++ b/src/http/protectedResource.ts
@@ -0,0 +1,45 @@
+import type { RequestHandler } from 'express'
+
+type ProtectedResourceConfig = {
+ resourceUrl: string
+ authServerUrl: string
+ resourceMetadataUrl: string
+}
+
+export function createProtectedResourceHandler(config: ProtectedResourceConfig): RequestHandler {
+ return (_req, res) => {
+ res.json({
+ resource: config.resourceUrl,
+ authorization_servers: [config.authServerUrl],
+ })
+ }
+}
+
+function quote(value: string): string {
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
+}
+
+export function buildWwwAuthenticateHeader(options: {
+ resourceMetadataUrl: string
+ error?: string
+ errorDescription?: string
+ scope?: string
+}) {
+ const params: string[] = []
+
+ if (options.error) {
+ params.push(`error="${quote(options.error)}"`)
+ }
+
+ if (options.errorDescription) {
+ params.push(`error_description="${quote(options.errorDescription)}"`)
+ }
+
+ if (options.scope) {
+ params.push(`scope="${quote(options.scope)}"`)
+ }
+
+ params.push(`resource_metadata="${quote(options.resourceMetadataUrl)}"`)
+
+ return `Bearer ${params.join(', ')}`
+}
diff --git a/src/http/requestLogger.ts b/src/http/requestLogger.ts
new file mode 100644
index 0000000..1431e32
--- /dev/null
+++ b/src/http/requestLogger.ts
@@ -0,0 +1,88 @@
+import type { RequestHandler } from 'express'
+import { randomUUID } from 'node:crypto'
+import { logger as globalLogger, setRequestId } from '../logger.js'
+
+type HttpLogLevel = 'debug' | 'info'
+type HttpLoggerMeta = Record
+type HttpLogger = (level: HttpLogLevel, message: string, meta?: HttpLoggerMeta) => void
+
+function parseDebugFlag(value?: string): boolean {
+ if (!value) {
+ return false
+ }
+
+ const normalized = value.trim().toLowerCase()
+ return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'
+}
+
+export function isMcpDebugLoggingEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
+ return parseDebugFlag(env.MCP_DEBUG_LOGGING)
+}
+
+function defaultLogger(level: HttpLogLevel, message: string, meta?: HttpLoggerMeta) {
+ const payload = meta ? `${message} ${JSON.stringify(meta)}` : message
+ globalLogger.log({ level, message: payload })
+}
+
+function inspectBody(body: unknown) {
+ if (typeof body === 'string') {
+ return body
+ }
+
+ if (Buffer.isBuffer(body)) {
+ return body.toString('utf8')
+ }
+
+ try {
+ return JSON.stringify(body)
+ } catch {
+ return String(body)
+ }
+}
+
+function sendInterceptor(
+ res: Parameters[1],
+ send: Parameters[1]['send'],
+ onSend: (content: unknown) => void,
+) {
+ return ((content?: unknown) => {
+ onSend(content)
+ res.send = send
+ return res.send(content as never)
+ }) as typeof res.send
+}
+
+export function createRequestLoggerMiddleware(options?: { logger?: HttpLogger }): RequestHandler {
+ const logger = options?.logger ?? defaultLogger
+
+ return (req, res, next) => {
+ const requestIdHeader = req.headers['x-request-id']
+ const requestId = (typeof requestIdHeader === 'string' ? requestIdHeader : requestIdHeader?.[0]) ?? randomUUID()
+
+ setRequestId(requestId)
+ res.setHeader('x-request-id', requestId)
+
+ logger('info', `<<< ${req.method} ${req.url}`)
+
+ if (req.body !== undefined) {
+ logger('debug', inspectBody(req.body))
+ }
+
+ let responseBody: unknown
+
+ res.send = sendInterceptor(res, res.send.bind(res), (content) => {
+ responseBody = content
+ })
+
+ res.on('finish', () => {
+ setRequestId(requestId)
+ logger('info', `>>> Sent ${res.statusCode}`)
+
+ if (responseBody !== undefined) {
+ logger('debug', inspectBody(responseBody))
+ }
+ })
+
+ next()
+ }
+}
diff --git a/src/http/types.ts b/src/http/types.ts
new file mode 100644
index 0000000..47ba39c
--- /dev/null
+++ b/src/http/types.ts
@@ -0,0 +1,43 @@
+import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'
+import type { Request } from 'express'
+
+export type McpAuthInfoExtra = {
+ allowedTools?: string[]
+ principalFingerprint?: string
+ subject?: string
+ authorizedParty?: string
+ sessionId?: string
+ [key: string]: unknown
+}
+
+export type McpAuthInfo = AuthInfo & {
+ extra?: McpAuthInfoExtra
+}
+
+export type RequestWithAuth = Request & {
+ auth?: McpAuthInfo
+}
+
+export function getAllowedTools(authInfo?: AuthInfo): string[] | undefined {
+ const tools = (authInfo?.extra as McpAuthInfoExtra | undefined)?.allowedTools
+ if (!Array.isArray(tools) || tools.length === 0) {
+ return undefined
+ }
+
+ const validTools = tools.filter((tool): tool is string => typeof tool === 'string' && tool.length > 0)
+ return validTools.length > 0 ? validTools : undefined
+}
+
+export function getPrincipalFingerprint(authInfo?: AuthInfo): string | undefined {
+ const fingerprint = (authInfo?.extra as McpAuthInfoExtra | undefined)?.principalFingerprint
+ return typeof fingerprint === 'string' && fingerprint.length > 0 ? fingerprint : undefined
+}
+
+export function isToolAllowed(toolName: string, authInfo?: AuthInfo): boolean {
+ const allowedTools = getAllowedTools(authInfo)
+ if (!allowedTools) {
+ return true
+ }
+
+ return allowedTools.includes(toolName)
+}
diff --git a/src/index.ts b/src/index.ts
index 4d7f537..a0bebf6 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -3,11 +3,18 @@
/**
* Nutrient DWS API MCP Server
*
- * This server provides a Model Context Protocol (MCP) interface to the Nutrient DWS Processor API.
+ * Supports stdio and Streamable HTTP MCP transports.
*/
+import express, { Request, Response } from 'express'
+import { randomUUID } from 'node:crypto'
+import { fileURLToPath } from 'node:url'
+import { resolve } from 'node:path'
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
+import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
+import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js'
+import { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'
import {
AiRedactArgsSchema,
BuildAPIArgsSchema,
@@ -24,24 +31,50 @@ import { setSandboxDirectory } from './fs/sandbox.js'
import { createErrorResponse } from './responses.js'
import { getVersion } from './version.js'
import { parseSandboxPath } from './utils/sandbox.js'
+import { createApiClient } from './dws/api.js'
+import { DwsApiClient } from './dws/client.js'
+import { getToken, type NutrientOAuthConfig } from './auth/nutrient-oauth.js'
+import { createAuthMiddleware } from './http/authMiddleware.js'
+import { createProtectedResourceHandler } from './http/protectedResource.js'
+import { createRequestLoggerMiddleware, isMcpDebugLoggingEnabled } from './http/requestLogger.js'
+import { getAllowedTools, getPrincipalFingerprint, isToolAllowed, RequestWithAuth } from './http/types.js'
+import { Environment, getEnvironment } from './utils/environment.js'
+import { logger } from './logger.js'
-const server = new McpServer(
- {
- name: 'nutrient-dws-mcp-server',
- version: getVersion(),
- },
- {
- capabilities: {
- tools: {},
- logging: {},
- },
- },
-)
+type ServerMode = 'stdio' | 'http'
+
+type HttpSessionContext = {
+ server: McpServer
+ transport: StreamableHTTPServerTransport
+ principalFingerprint: string
+}
-function addToolsToServer(server: McpServer, sandboxEnabled: boolean = false) {
- server.tool(
- 'document_processor',
- `Processes documents using Nutrient DWS Processor API. Reads from and writes to file system or sandbox (if enabled).
+type RunServerResult = {
+ mode: ServerMode
+ close: () => Promise
+}
+
+function buildPermissionDeniedResponse(toolName: string) {
+ return createErrorResponse(`Permission denied: Tool "${toolName}" is not allowed for this token.`)
+}
+
+function canInvokeTool(toolName: string, authInfo?: AuthInfo) {
+ return isToolAllowed(toolName, authInfo)
+}
+
+function addToolsToServer(options: {
+ server: McpServer
+ sandboxEnabled: boolean
+ apiClient: DwsApiClient
+ allowedTools?: string[]
+}) {
+ const { server, sandboxEnabled, apiClient, allowedTools } = options
+ const shouldRegisterTool = (toolName: string) => !allowedTools || allowedTools.includes(toolName)
+
+ if (shouldRegisterTool('document_processor')) {
+ server.tool(
+ 'document_processor',
+ `Processes documents using Nutrient DWS Processor API. Reads from and writes to file system or sandbox (if enabled).
Features:
• Import XFDF annotations
@@ -52,19 +85,25 @@ Features:
• Redaction creation and application
Output formats: PDF, PDF/A, images (PNG, JPEG, WebP), JSON extraction, Office (DOCX, XLSX, PPTX)`,
- BuildAPIArgsSchema.shape,
- async ({ instructions, outputPath }) => {
- try {
- return performBuildCall(instructions, outputPath)
- } catch (error) {
- return createErrorResponse(`Error: ${error instanceof Error ? error.message : String(error)}`)
- }
- },
- )
+ BuildAPIArgsSchema.shape,
+ async ({ instructions, outputPath }, extra) => {
+ if (!canInvokeTool('document_processor', extra.authInfo)) {
+ return buildPermissionDeniedResponse('document_processor')
+ }
- server.tool(
- 'document_signer',
- `Digitally signs PDF files using Nutrient DWS Sign API. Reads from and writes to file system or sandbox (if enabled).
+ try {
+ return await performBuildCall(instructions, outputPath, apiClient)
+ } catch (error) {
+ return createErrorResponse(`Error: ${error instanceof Error ? error.message : String(error)}`)
+ }
+ },
+ )
+ }
+
+ if (shouldRegisterTool('document_signer')) {
+ server.tool(
+ 'document_signer',
+ `Digitally signs PDF files using Nutrient DWS Sign API. Reads from and writes to file system or sandbox (if enabled).
Signature types:
• CMS/PKCS#7 (standard digital signatures)
@@ -79,19 +118,32 @@ Appearance options:
Positioning:
• Place on specific page coordinates
• Use existing signature form fields`,
- SignAPIArgsSchema.shape,
- async ({ filePath, signatureOptions, watermarkImagePath, graphicImagePath, outputPath }) => {
- try {
- return performSignCall(filePath, outputPath, signatureOptions, watermarkImagePath, graphicImagePath)
- } catch (error) {
- return createErrorResponse(`Error: ${error instanceof Error ? error.message : String(error)}`)
- }
- },
- )
+ SignAPIArgsSchema.shape,
+ async ({ filePath, signatureOptions, watermarkImagePath, graphicImagePath, outputPath }, extra) => {
+ if (!canInvokeTool('document_signer', extra.authInfo)) {
+ return buildPermissionDeniedResponse('document_signer')
+ }
+
+ try {
+ return await performSignCall(
+ filePath,
+ outputPath,
+ apiClient,
+ signatureOptions,
+ watermarkImagePath,
+ graphicImagePath,
+ )
+ } catch (error) {
+ return createErrorResponse(`Error: ${error instanceof Error ? error.message : String(error)}`)
+ }
+ },
+ )
+ }
- server.tool(
- 'ai_redactor',
- `AI-powered document redaction using Nutrient DWS AI Redaction API. Reads from and writes to file system or sandbox (if enabled).
+ if (shouldRegisterTool('ai_redactor')) {
+ server.tool(
+ 'ai_redactor',
+ `AI-powered document redaction using Nutrient DWS AI Redaction API. Reads from and writes to file system or sandbox (if enabled).
Automatically detects and permanently removes sensitive information from documents using AI analysis.
Detected content types include:
@@ -102,105 +154,503 @@ Detected content types include:
• Any custom criteria you specify
By default (when neither stage nor apply is set), redactions are detected and immediately applied. Set stage to true to detect and stage redactions without applying them. Set apply to true to apply previously staged redactions.`,
- AiRedactArgsSchema.shape,
- async ({ filePath, criteria, outputPath, stage, apply }) => {
- try {
- return performAiRedactCall(filePath, criteria, outputPath, stage, apply)
- } catch (error) {
- return createErrorResponse(`Error: ${error instanceof Error ? error.message : String(error)}`)
- }
- },
- )
+ AiRedactArgsSchema.shape,
+ async ({ filePath, criteria, outputPath, stage, apply }, extra) => {
+ if (!canInvokeTool('ai_redactor', extra.authInfo)) {
+ return buildPermissionDeniedResponse('ai_redactor')
+ }
- server.tool(
- 'check_credits',
- `Check your Nutrient DWS API credit balance and usage for the current billing period.
+ try {
+ return await performAiRedactCall(filePath, criteria, outputPath, apiClient, stage, apply)
+ } catch (error) {
+ return createErrorResponse(`Error: ${error instanceof Error ? error.message : String(error)}`)
+ }
+ },
+ )
+ }
+
+ if (shouldRegisterTool('check_credits')) {
+ server.tool(
+ 'check_credits',
+ `Check your Nutrient DWS API credit balance and usage for the current billing period.
Returns: subscription type, total credits, used credits, and remaining credits.`,
- CheckCreditsArgsSchema.shape,
- async () => {
- try {
- return performCheckCreditsCall()
- } catch (error) {
- return createErrorResponse(`Error: ${error instanceof Error ? error.message : String(error)}`)
- }
- },
- )
+ CheckCreditsArgsSchema.shape,
+ async (_args, extra) => {
+ if (!canInvokeTool('check_credits', extra.authInfo)) {
+ return buildPermissionDeniedResponse('check_credits')
+ }
- if (sandboxEnabled) {
- server.tool(
- 'sandbox_file_tree',
- 'Returns the file tree of the sandbox directory. It will recurse into subdirectories and return a list of files and directories.',
- {},
- async () => performDirectoryTreeCall('.'),
+ try {
+ return await performCheckCreditsCall(apiClient)
+ } catch (error) {
+ return createErrorResponse(`Error: ${error instanceof Error ? error.message : String(error)}`)
+ }
+ },
)
- } else {
+ }
+
+ if (sandboxEnabled) {
+ if (shouldRegisterTool('sandbox_file_tree')) {
+ server.tool(
+ 'sandbox_file_tree',
+ 'Returns the file tree of the sandbox directory. It will recurse into subdirectories and return a list of files and directories.',
+ {},
+ async (_args, extra) => {
+ if (!canInvokeTool('sandbox_file_tree', extra.authInfo)) {
+ return buildPermissionDeniedResponse('sandbox_file_tree')
+ }
+
+ return performDirectoryTreeCall('.')
+ },
+ )
+ }
+ } else if (shouldRegisterTool('directory_tree')) {
server.tool(
'directory_tree',
'Returns the directory tree of a given path. All paths are resolved relative to root directory.',
DirectoryTreeArgsSchema.shape,
- async ({ path }) => performDirectoryTreeCall(path),
+ async ({ path }, extra) => {
+ if (!canInvokeTool('directory_tree', extra.authInfo)) {
+ return buildPermissionDeniedResponse('directory_tree')
+ }
+
+ return performDirectoryTreeCall(path)
+ },
)
}
}
-async function parseCommandLineArgs() {
- const args = process.argv.slice(2)
+function createMcpServer(options: { sandboxEnabled: boolean; apiClient: DwsApiClient; allowedTools?: string[] }) {
+ const server = new McpServer(
+ {
+ name: 'nutrient-dws-mcp-server',
+ version: getVersion(),
+ },
+ {
+ capabilities: {
+ tools: {},
+ logging: {},
+ },
+ },
+ )
- try {
- const sandboxDir = parseSandboxPath(args, process.env.SANDBOX_PATH) || null
- return { sandboxDir }
- } catch (error) {
- await server.server.sendLoggingMessage({
- level: 'error',
- data: `Error: ${error instanceof Error ? error.message : String(error)}`,
- })
- process.exit(1)
+ addToolsToServer({
+ server,
+ sandboxEnabled: options.sandboxEnabled,
+ apiClient: options.apiClient,
+ allowedTools: options.allowedTools,
+ })
+
+ return server
+}
+
+function getSessionId(req: Request): string | undefined {
+ const headerValue = req.headers['mcp-session-id']
+
+ if (Array.isArray(headerValue)) {
+ return headerValue[0]
}
+
+ return headerValue
}
-export async function runServer() {
- const { sandboxDir } = await parseCommandLineArgs()
+function isInitializeRequest(body: unknown): boolean {
+ if (!body || typeof body !== 'object') {
+ return false
+ }
- if (sandboxDir) {
+ // Handle JSON-RPC batch requests (array of messages)
+ if (Array.isArray(body)) {
+ return body.length > 0 && typeof body[0] === 'object' && body[0] !== null && body[0].method === 'initialize'
+ }
+
+ const request = body as { method?: unknown }
+ return request.method === 'initialize'
+}
+
+function sendJsonRpcError(res: Response, code: number, message: string, id: string | number | null = null) {
+ res.status(400).json({
+ jsonrpc: '2.0',
+ error: {
+ code,
+ message,
+ },
+ id,
+ })
+}
+
+function createSessionApiClient(options: {
+ environment: Environment
+ authInfo: AuthInfo
+}): DwsApiClient {
+ const { environment, authInfo } = options
+
+ return createApiClient({
+ baseUrl: environment.dwsApiBaseUrl,
+ tokenResolver: async () => authInfo.token,
+ })
+}
+
+export function createHttpApp(options: { environment: Environment; sandboxEnabled: boolean }) {
+ const { environment, sandboxEnabled } = options
+
+ const sessions = new Map()
+
+ const app = createMcpExpressApp({
+ host: environment.host,
+ allowedHosts: environment.allowedHosts.length > 0 ? environment.allowedHosts : undefined,
+ })
+
+ app.use(express.json({ limit: '25mb' }))
+
+ // CORS: Permissive policy for local-first MCP server; tighten for hosted deployments
+ app.use((_req, res, next) => {
+ res.header('Access-Control-Allow-Origin', '*')
+ res.header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS')
+ res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Mcp-Session-Id')
+ res.header('Access-Control-Expose-Headers', 'Mcp-Session-Id')
+ if (_req.method === 'OPTIONS') {
+ res.sendStatus(204)
+ return
+ }
+ next()
+ })
+
+ if (isMcpDebugLoggingEnabled(process.env)) {
+ app.use(createRequestLoggerMiddleware())
+ }
+
+ app.get('/health', (_req, res) => {
+ res.json({ status: 'ok', version: getVersion() })
+ })
+
+ app.get(
+ '/.well-known/oauth-protected-resource',
+ createProtectedResourceHandler({
+ resourceUrl: environment.resourceUrl,
+ authServerUrl: environment.authServerUrl,
+ resourceMetadataUrl: environment.protectedResourceMetadataUrl,
+ }),
+ )
+
+ const authMiddleware = createAuthMiddleware(environment)
+
+ const handleExistingSessionRequest = async (req: Request, res: Response, parsedBody?: unknown) => {
+ const sessionId = getSessionId(req)
+ if (!sessionId) {
+ res.status(400).send('Missing MCP session ID')
+ return
+ }
+
+ const sessionContext = sessions.get(sessionId)
+ if (!sessionContext) {
+ console.warn(`Session miss: unknown session ID ${sessionId} (active sessions: ${sessions.size})`)
+ res.status(404).send('Unknown MCP session ID')
+ return
+ }
+
+ const authInfo = (req as RequestWithAuth).auth
+ const principalFingerprint = getPrincipalFingerprint(authInfo)
+ if (!principalFingerprint) {
+ res.status(401).send('Missing principal fingerprint')
+ return
+ }
+
+ if (principalFingerprint !== sessionContext.principalFingerprint) {
+ res.status(403).send('Session is bound to a different principal')
+ return
+ }
+
+ await sessionContext.transport.handleRequest(req, res, parsedBody)
+ }
+
+ app.post('/mcp', authMiddleware, async (req, res) => {
try {
- await setSandboxDirectory(sandboxDir)
+ const sessionId = getSessionId(req)
+
+ if (sessionId) {
+ await handleExistingSessionRequest(req, res, req.body)
+ return
+ }
+
+ if (!isInitializeRequest(req.body)) {
+ sendJsonRpcError(res, -32000, 'Bad Request: No valid session ID provided', null)
+ return
+ }
+
+ const authInfo = (req as RequestWithAuth).auth
+ const principalFingerprint = getPrincipalFingerprint(authInfo)
+
+ if (!authInfo || !principalFingerprint) {
+ res.status(401).send('Missing auth context')
+ return
+ }
+
+ const allowedTools = getAllowedTools(authInfo)
+ const apiClient = createSessionApiClient({
+ environment,
+ authInfo,
+ })
+
+ const server = createMcpServer({
+ sandboxEnabled,
+ apiClient,
+ allowedTools,
+ })
+
+ const transport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: () => randomUUID(),
+ onsessioninitialized: (newSessionId) => {
+ sessions.set(newSessionId, {
+ server,
+ transport,
+ principalFingerprint,
+ })
+ },
+ onsessionclosed: async (closedSessionId) => {
+ const context = sessions.get(closedSessionId)
+ if (context) {
+ sessions.delete(closedSessionId)
+ await context.server.close().catch(() => {})
+ }
+ },
+ })
+
+ transport.onclose = () => {
+ const currentSessionId = transport.sessionId
+ if (!currentSessionId) {
+ return
+ }
+
+ const context = sessions.get(currentSessionId)
+ if (!context) {
+ return
+ }
+
+ sessions.delete(currentSessionId)
+ void context.server.close().catch(() => {})
+ }
+
+ await server.connect(transport)
+ await transport.handleRequest(req, res, req.body)
} catch (error) {
- console.error(`Error setting sandbox directory: ${error instanceof Error ? error.message : String(error)}`)
- process.exit(1)
+ console.error('Error handling MCP POST request:', error)
+ if (!res.headersSent) {
+ res.status(500).json({
+ jsonrpc: '2.0',
+ error: {
+ code: -32603,
+ message: 'Internal server error',
+ },
+ id: null,
+ })
+ }
}
- } else {
- console.warn(
- 'Info: No sandbox directory specified. File operations will not be restricted.\n' +
- 'Sandboxed mode is recommended - To enable sandboxed mode and restrict file operations, set SANDBOX_PATH environment variable',
- )
+ })
+
+ app.get('/mcp', authMiddleware, async (req, res) => {
+ try {
+ await handleExistingSessionRequest(req, res)
+ } catch (error) {
+ console.error('Error handling MCP GET request:', error)
+ if (!res.headersSent) {
+ res.status(500).send('Internal server error')
+ }
+ }
+ })
+
+ app.delete('/mcp', authMiddleware, async (req, res) => {
+ try {
+ await handleExistingSessionRequest(req, res)
+ } catch (error) {
+ console.error('Error handling MCP DELETE request:', error)
+ if (!res.headersSent) {
+ res.status(500).send('Internal server error')
+ }
+ }
+ })
+
+ const close = async () => {
+ const closePromises = [...sessions.values()].map(async (context) => {
+ await context.transport.close().catch(() => {})
+ await context.server.close().catch(() => {})
+ })
+
+ await Promise.all(closePromises)
+ sessions.clear()
}
- addToolsToServer(server, sandboxDir !== null)
+ return { app, close }
+}
- const transport = new StdioServerTransport()
- await server.connect(transport)
+async function parseCommandLineArgs() {
+ const args = process.argv.slice(2)
+ const sandboxDir = parseSandboxPath(args, process.env.SANDBOX_PATH) || null
+ return { sandboxDir }
+}
- return server
+async function prepareSandbox(sandboxDir: string | null) {
+ if (sandboxDir) {
+ await setSandboxDirectory(sandboxDir)
+ return
+ }
+
+ console.warn(
+ 'Info: No sandbox directory specified. File operations will not be restricted.\n' +
+ 'Sandboxed mode is recommended - To enable sandboxed mode and restrict file operations, set SANDBOX_PATH environment variable',
+ )
}
-runServer()
- .then(async (server) => {
- server.server.getClientCapabilities()
- await server.server.sendLoggingMessage({
- level: 'info',
- data: `Nutrient DWS MCP Server ${getVersion()} running.`,
+function createStdioApiClient(environment: Environment): DwsApiClient {
+ if (environment.nutrientApiKey) {
+ return createApiClient({
+ apiKey: environment.nutrientApiKey,
+ baseUrl: environment.dwsApiBaseUrl,
})
+ }
+
+ const oauthConfig: NutrientOAuthConfig = {
+ authorizeUrl: `${environment.authServerUrl}/oauth/authorize`,
+ tokenUrl: `${environment.authServerUrl}/oauth/token`,
+ registrationUrl: `${environment.authServerUrl}/oauth/register`,
+ clientId: environment.clientId,
+ scopes: ['mcp:invoke', 'offline_access'],
+ resource: environment.dwsApiBaseUrl,
+ }
+
+ return createApiClient({
+ tokenResolver: () => getToken(oauthConfig),
+ baseUrl: environment.dwsApiBaseUrl,
+ })
+}
+
+async function runStdioServer(options: {
+ sandboxEnabled: boolean
+ environment: Environment
+}): Promise {
+ const { sandboxEnabled, environment } = options
+
+ logger.info('Starting stdio transport', {
+ version: getVersion(),
+ authMethod: environment.nutrientApiKey ? 'api-key' : 'oauth-browser-flow',
+ sandboxEnabled,
+ dwsApiBaseUrl: environment.dwsApiBaseUrl,
})
- .catch((error) => {
- console.error('Fatal error running server:', error)
- process.exit(1)
+
+ const apiClient = createStdioApiClient(environment)
+
+ const server = createMcpServer({
+ sandboxEnabled,
+ apiClient,
})
-process.stdin.on('close', async () => {
+ const transport = new StdioServerTransport()
+ await server.connect(transport)
+
+ logger.info('stdio transport connected')
+
await server.server.sendLoggingMessage({
level: 'info',
- data: `Nutrient DWS MCP Server ${getVersion()} closed.`,
+ data: `Nutrient DWS MCP Server ${getVersion()} running on stdio transport.`,
+ })
+
+ return {
+ mode: 'stdio',
+ close: async () => {
+ await server.close()
+ },
+ }
+}
+
+async function runHttpServer(options: { sandboxEnabled: boolean; environment: Environment }): Promise {
+ const { sandboxEnabled, environment } = options
+ const { app, close: closeSessions } = createHttpApp({ environment, sandboxEnabled })
+
+ const httpServer = app.listen(environment.port, environment.host)
+
+ await new Promise((resolvePromise, rejectPromise) => {
+ httpServer.once('listening', () => resolvePromise())
+ httpServer.once('error', (error) => rejectPromise(error))
+ })
+
+ console.log(
+ `Nutrient DWS MCP Server ${getVersion()} running on HTTP transport at http://${environment.host}:${environment.port}/mcp`,
+ )
+
+ return {
+ mode: 'http',
+ close: async () => {
+ await closeSessions()
+ await new Promise((resolvePromise, rejectPromise) => {
+ httpServer.close((error) => {
+ if (error) {
+ rejectPromise(error)
+ return
+ }
+
+ resolvePromise()
+ })
+ })
+ },
+ }
+}
+
+export async function runServer(): Promise {
+ const environment = getEnvironment()
+ const { sandboxDir } = await parseCommandLineArgs()
+
+ await prepareSandbox(sandboxDir)
+
+ const sandboxEnabled = sandboxDir !== null
+
+ if (environment.transportMode === 'http') {
+ return runHttpServer({ sandboxEnabled, environment })
+ }
+
+ return runStdioServer({ sandboxEnabled, environment })
+}
+
+function isMainModule() {
+ const entryFile = process.argv[1]
+ if (!entryFile) {
+ return false
+ }
+
+ return resolve(fileURLToPath(import.meta.url)) === resolve(entryFile)
+}
+
+if (isMainModule()) {
+ let activeServer: RunServerResult | undefined
+
+ runServer()
+ .then((result) => {
+ activeServer = result
+ })
+ .catch((error) => {
+ console.error('Fatal error running server:', error)
+ process.exit(1)
+ })
+
+ process.on('SIGINT', async () => {
+ if (activeServer) {
+ await activeServer.close().catch(() => {})
+ }
+
+ process.exit(0)
+ })
+
+ process.on('SIGTERM', async () => {
+ if (activeServer) {
+ await activeServer.close().catch(() => {})
+ }
+
+ process.exit(0)
+ })
+
+ process.stdin.on('close', async () => {
+ if (activeServer?.mode === 'stdio') {
+ await activeServer.close().catch(() => {})
+ }
})
- await server.close()
-})
+}
diff --git a/src/logger.ts b/src/logger.ts
new file mode 100644
index 0000000..73768a5
--- /dev/null
+++ b/src/logger.ts
@@ -0,0 +1,76 @@
+import { AsyncLocalStorage } from 'node:async_hooks'
+import { join } from 'node:path'
+import { tmpdir } from 'node:os'
+import winston from 'winston'
+
+type RequestContext = {
+ requestId?: string
+}
+
+const asyncLocalStorage = new AsyncLocalStorage()
+
+/**
+ * Sets the request ID used for logging for the current asynchronous execution context.
+ */
+export function setRequestId(requestId: string) {
+ const store = asyncLocalStorage.getStore()
+
+ if (store) {
+ store.requestId = requestId
+ return
+ }
+
+ asyncLocalStorage.enterWith({ requestId })
+}
+
+function getRequestId() {
+ const store = asyncLocalStorage.getStore()
+ return store?.requestId ?? null
+}
+
+const customMessageFormat = winston.format.printf(({ level, message, timestamp }) => {
+ const requestId = getRequestId()
+ const serializedMessage = typeof message === 'string' ? message : JSON.stringify(message)
+
+ if (requestId) {
+ return `${timestamp} [${level}]: ${serializedMessage} requestId=${requestId}`
+ }
+
+ return `${timestamp} [${level}]: ${serializedMessage}`
+})
+
+const isStdioMode = process.env.MCP_TRANSPORT !== 'http'
+const logFilePath = process.env.MCP_LOG_FILE || (isStdioMode ? join(tmpdir(), 'nutrient-dws-mcp-server.log') : undefined)
+
+function createTransports(): winston.transport[] {
+ // In stdio mode, Console transport interferes with MCP protocol — use file only
+ if (logFilePath) {
+ return [
+ new winston.transports.File({
+ filename: logFilePath,
+ format: winston.format.combine(
+ winston.format.timestamp({ format: 'HH:mm:ss.SSS' }),
+ customMessageFormat,
+ ),
+ }),
+ ]
+ }
+
+ return [
+ new winston.transports.Console({
+ format: winston.format.combine(
+ winston.format.timestamp({ format: 'HH:mm:ss.SSS' }),
+ winston.format.colorize(),
+ winston.format.json(),
+ customMessageFormat,
+ ),
+ }),
+ ]
+}
+
+export const logger = winston.createLogger({
+ level: process.env.LOG_LEVEL || 'debug',
+ format: winston.format.json(),
+ defaultMeta: { service: 'dws-mcp-server' },
+ transports: createTransports(),
+})
diff --git a/src/utils/environment.ts b/src/utils/environment.ts
new file mode 100644
index 0000000..f22a0d1
--- /dev/null
+++ b/src/utils/environment.ts
@@ -0,0 +1,106 @@
+import { z } from 'zod'
+
+export type TransportMode = 'stdio' | 'http'
+export type TokenEndpointAuthMethod = 'client_secret_basic' | 'private_key_jwt'
+
+export type Environment = {
+ transportMode: TransportMode
+ port: number
+ host: string
+ allowedHosts: string[]
+ nutrientApiKey?: string
+ dwsApiBaseUrl: string
+ resourceUrl: string
+ authServerUrl: string
+ protectedResourceMetadataUrl: string
+ jwksUrl: string
+ issuer?: string
+ tokenEndpointAuthMethod: TokenEndpointAuthMethod
+ clientId?: string
+ clientSecret?: string
+ clientAssertionPrivateKey?: string
+ clientAssertionAlg?: string
+ clientAssertionKid?: string
+}
+
+const RawEnvironmentSchema = z.object({
+ MCP_TRANSPORT: z.enum(['stdio', 'http']).default('stdio'),
+ PORT: z.coerce.number().int().positive().default(3000),
+ MCP_HOST: z.string().default('127.0.0.1'),
+ MCP_ALLOWED_HOSTS: z.string().optional(),
+ NUTRIENT_DWS_API_KEY: z.string().optional(),
+ DWS_API_BASE_URL: z.string().url().default('https://api.nutrient.io'),
+ RESOURCE_URL: z.string().url().default('http://localhost:3000/mcp'),
+ AUTH_SERVER_URL: z.string().url().default('https://api.nutrient.io'),
+ JWKS_URL: z.string().url().default('https://api.nutrient.io/.well-known/jwks.json'),
+ ISSUER: z.string().url().optional(),
+ TOKEN_ENDPOINT_AUTH_METHOD: z.enum(['client_secret_basic', 'private_key_jwt']).default('client_secret_basic'),
+ CLIENT_ID: z.string().optional(),
+ CLIENT_SECRET: z.string().optional(),
+ CLIENT_ASSERTION_PRIVATE_KEY: z.string().optional(),
+ CLIENT_ASSERTION_ALG: z.string().default('RS256'),
+ CLIENT_ASSERTION_KID: z.string().optional(),
+})
+
+type RawEnvironment = z.infer
+
+let cachedEnvironment: Environment | undefined
+
+function splitList(value?: string): string[] {
+ if (!value) {
+ return []
+ }
+
+ return value
+ .split(/[\s,]+/)
+ .map((entry) => entry.trim())
+ .filter(Boolean)
+}
+
+function getProtectedResourceMetadataUrl(resourceUrl: string): string {
+ return new URL('/.well-known/oauth-protected-resource', resourceUrl).toString()
+}
+
+
+function parseEnvironment(rawEnv: NodeJS.ProcessEnv): Environment {
+ const raw = RawEnvironmentSchema.parse(rawEnv)
+
+ const allowedHosts = splitList(raw.MCP_ALLOWED_HOSTS)
+
+ return {
+ transportMode: raw.MCP_TRANSPORT,
+ port: raw.PORT,
+ host: raw.MCP_HOST,
+ allowedHosts,
+ nutrientApiKey: raw.NUTRIENT_DWS_API_KEY,
+ dwsApiBaseUrl: raw.DWS_API_BASE_URL,
+ resourceUrl: raw.RESOURCE_URL,
+ authServerUrl: raw.AUTH_SERVER_URL,
+ protectedResourceMetadataUrl: getProtectedResourceMetadataUrl(raw.RESOURCE_URL),
+ jwksUrl: raw.JWKS_URL,
+ issuer: raw.ISSUER ?? raw.AUTH_SERVER_URL,
+ tokenEndpointAuthMethod: raw.TOKEN_ENDPOINT_AUTH_METHOD,
+ clientId: raw.CLIENT_ID,
+ clientSecret: raw.CLIENT_SECRET,
+ clientAssertionPrivateKey: raw.CLIENT_ASSERTION_PRIVATE_KEY,
+ clientAssertionAlg: raw.CLIENT_ASSERTION_ALG,
+ clientAssertionKid: raw.CLIENT_ASSERTION_KID,
+ }
+}
+
+export function getEnvironment(): Environment {
+ if (!cachedEnvironment) {
+ cachedEnvironment = parseEnvironment(process.env)
+ }
+
+ return cachedEnvironment
+}
+
+export function resetEnvironmentForTests() {
+ cachedEnvironment = undefined
+}
+
+export function getAllowedToolsFromEnvironmentList(value?: string): string[] | undefined {
+ const tools = splitList(value)
+ return tools.length > 0 ? tools : undefined
+}
diff --git a/tests/authMiddleware.test.ts b/tests/authMiddleware.test.ts
new file mode 100644
index 0000000..f57c87d
--- /dev/null
+++ b/tests/authMiddleware.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest'
+import { buildJwtAudiences } from '../src/http/authMiddleware.js'
+
+describe('buildJwtAudiences', () => {
+ it('includes root and path audience variants for resource URLs', () => {
+ const audiences = buildJwtAudiences('http://localhost:3000/mcp')
+
+ expect(audiences).toEqual(
+ expect.arrayContaining([
+ 'dws-mcp',
+ 'http://localhost:3000',
+ 'http://localhost:3000/',
+ 'http://localhost:3000/mcp',
+ 'http://localhost:3000/mcp/',
+ ]),
+ )
+ })
+
+ it('keeps defaults for non-URL resource values', () => {
+ const audiences = buildJwtAudiences('dws-mcp-dev')
+
+ expect(audiences).toEqual(expect.arrayContaining(['dws-mcp', 'dws-mcp-dev', 'dws-mcp-dev/']))
+ })
+})
diff --git a/tests/build-api-examples.test.ts b/tests/build-api-examples.test.ts
index a9c54c4..9119ff8 100644
--- a/tests/build-api-examples.test.ts
+++ b/tests/build-api-examples.test.ts
@@ -5,15 +5,19 @@ import path from 'path'
import { performBuildCall } from '../src/dws/build.js'
import { BuildAPIArgs } from '../src/schemas.js'
import { setSandboxDirectory } from '../src/fs/sandbox.js'
+import { createApiClient } from '../src/dws/api.js'
+import { DwsApiClient } from '../src/dws/client.js'
dotenvConfig()
describe('performBuildCall with build-api-examples', () => {
let outputDirectory: string
+ let apiClient: DwsApiClient
beforeAll(async () => {
const assetsDir = path.join(__dirname, `assets`)
await setSandboxDirectory(assetsDir)
+ apiClient = createApiClient({ apiKey: process.env.NUTRIENT_DWS_API_KEY! })
outputDirectory = `test-output-${new Date().toISOString().replace(/[:.]/g, '-')}`
})
@@ -80,7 +84,7 @@ describe('performBuildCall with build-api-examples', () => {
it.each(fileOutputExamples)('should process $name', async ({ example }) => {
const { instructions, outputPath } = example
- const result = await performBuildCall(instructions, `${outputDirectory}/${outputPath}`)
+ const result = await performBuildCall(instructions, `${outputDirectory}/${outputPath}`, apiClient)
expect(result).toEqual(
expect.objectContaining({
@@ -98,7 +102,7 @@ describe('performBuildCall with build-api-examples', () => {
it.each(jsonOutputExamples)('should process $name', async ({ example }) => {
const { instructions } = example
- const result = await performBuildCall(instructions, 'dummy_path.pdf')
+ const result = await performBuildCall(instructions, 'dummy_path.pdf', apiClient)
expect(result).toEqual(
expect.objectContaining({
diff --git a/tests/environment.test.ts b/tests/environment.test.ts
new file mode 100644
index 0000000..f516452
--- /dev/null
+++ b/tests/environment.test.ts
@@ -0,0 +1,64 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { getEnvironment, resetEnvironmentForTests } from '../src/utils/environment.js'
+
+describe('environment', () => {
+ const originalEnv = process.env
+
+ beforeEach(() => {
+ process.env = { ...originalEnv }
+ resetEnvironmentForTests()
+ })
+
+ afterEach(() => {
+ process.env = originalEnv
+ resetEnvironmentForTests()
+ })
+
+ it('parses default stdio configuration', () => {
+ process.env.NUTRIENT_DWS_API_KEY = 'dws-key'
+
+ const environment = getEnvironment()
+
+ expect(environment.transportMode).toBe('stdio')
+ expect(environment.nutrientApiKey).toBe('dws-key')
+ })
+
+ it('defaults JWKS URL to api.nutrient.io in HTTP mode', () => {
+ process.env.MCP_TRANSPORT = 'http'
+
+ const environment = getEnvironment()
+
+ expect(environment.jwksUrl).toBe('https://api.nutrient.io/.well-known/jwks.json')
+ })
+
+ it('accepts private_key_jwt mode without client secret', () => {
+ process.env.MCP_TRANSPORT = 'http'
+ process.env.JWKS_URL = 'https://auth.example.com/.well-known/jwks.json'
+ process.env.CLIENT_ID = 'client-id'
+ process.env.TOKEN_ENDPOINT_AUTH_METHOD = 'private_key_jwt'
+ process.env.CLIENT_ASSERTION_PRIVATE_KEY = '-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----'
+
+ const environment = getEnvironment()
+
+ expect(environment.tokenEndpointAuthMethod).toBe('private_key_jwt')
+ expect(environment.clientSecret).toBeUndefined()
+ expect(environment.clientAssertionPrivateKey).toContain('BEGIN PRIVATE KEY')
+ })
+
+ it('defaults issuer to AUTH_SERVER_URL', () => {
+ process.env.MCP_TRANSPORT = 'http'
+
+ const environment = getEnvironment()
+
+ expect(environment.issuer).toBe('https://api.nutrient.io')
+ })
+
+ it('allows overriding issuer', () => {
+ process.env.MCP_TRANSPORT = 'http'
+ process.env.ISSUER = 'https://custom-issuer.example.com'
+
+ const environment = getEnvironment()
+
+ expect(environment.issuer).toBe('https://custom-issuer.example.com')
+ })
+})
diff --git a/tests/httpTransport.test.ts b/tests/httpTransport.test.ts
new file mode 100644
index 0000000..9d1c7e7
--- /dev/null
+++ b/tests/httpTransport.test.ts
@@ -0,0 +1,273 @@
+import { createServer, type Server } from 'node:http'
+import request from 'supertest'
+import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
+import { exportJWK, generateKeyPair, SignJWT } from 'jose'
+import { createHttpApp } from '../src/index.js'
+import { Environment } from '../src/utils/environment.js'
+
+// ── Test JWKS server ──────────────────────────────────────────────────────────
+
+let jwksServer: Server
+let jwksUrl: string
+let testKeyPair: Awaited>
+let testKid: string
+
+beforeAll(async () => {
+ testKid = 'test-key-1'
+ testKeyPair = await generateKeyPair('RS256')
+
+ const publicJwk = await exportJWK(testKeyPair.publicKey)
+ publicJwk.kid = testKid
+ publicJwk.use = 'sig'
+ publicJwk.alg = 'RS256'
+
+ const jwksPayload = JSON.stringify({ keys: [publicJwk] })
+
+ jwksServer = createServer((_req, res) => {
+ res.writeHead(200, { 'Content-Type': 'application/json' })
+ res.end(jwksPayload)
+ })
+
+ await new Promise((resolve) => {
+ jwksServer.listen(0, '127.0.0.1', () => resolve())
+ })
+
+ const address = jwksServer.address()
+ if (!address || typeof address === 'string') {
+ throw new Error('JWKS server did not bind')
+ }
+
+ jwksUrl = `http://127.0.0.1:${address.port}/.well-known/jwks.json`
+})
+
+afterAll(async () => {
+ await new Promise((resolve) => jwksServer.close(() => resolve()))
+})
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+const TEST_ISSUER = 'https://auth.example.com'
+const TEST_RESOURCE_URL = 'https://mcp.example.com/mcp'
+
+async function signTestJwt(overrides: Record = {}, subject = 'user-1') {
+ const builder = new SignJWT({
+ scope: 'mcp:invoke',
+ azp: 'test-client',
+ ...overrides,
+ })
+ .setProtectedHeader({ alg: 'RS256', kid: testKid })
+ .setIssuer(TEST_ISSUER)
+ .setSubject(subject)
+ .setAudience(TEST_RESOURCE_URL)
+ .setIssuedAt()
+ .setExpirationTime('5m')
+
+ return builder.sign(testKeyPair.privateKey)
+}
+
+function createEnvironment(overrides: Partial = {}): Environment {
+ return {
+ transportMode: 'http',
+ port: 3000,
+ host: '127.0.0.1',
+ allowedHosts: [],
+ nutrientApiKey: 'dws-api-key',
+ dwsApiBaseUrl: 'https://api.nutrient.io',
+ resourceUrl: TEST_RESOURCE_URL,
+ authServerUrl: TEST_ISSUER,
+ protectedResourceMetadataUrl: 'https://mcp.example.com/.well-known/oauth-protected-resource',
+ jwksUrl,
+ issuer: TEST_ISSUER,
+ tokenEndpointAuthMethod: 'client_secret_basic',
+ clientId: undefined,
+ clientSecret: undefined,
+ clientAssertionPrivateKey: undefined,
+ clientAssertionAlg: undefined,
+ clientAssertionKid: undefined,
+ ...overrides,
+ }
+}
+
+const initializeRequest = {
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'initialize',
+ params: {
+ protocolVersion: '2025-03-26',
+ capabilities: {
+ tools: {},
+ },
+ clientInfo: {
+ name: 'vitest-client',
+ version: '1.0.0',
+ },
+ },
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe('http transport', () => {
+ let closeApp: (() => Promise) | undefined
+
+ afterEach(async () => {
+ if (closeApp) {
+ await closeApp()
+ closeApp = undefined
+ }
+ })
+
+ async function initializeSession(app: Parameters[0], token: string) {
+ const response = await request(app)
+ .post('/mcp')
+ .set('authorization', `Bearer ${token}`)
+ .set('accept', 'application/json, text/event-stream')
+ .send(initializeRequest)
+
+ expect(response.status).toBe(200)
+
+ const sessionId = response.headers['mcp-session-id']
+ expect(typeof sessionId).toBe('string')
+
+ await request(app)
+ .post('/mcp')
+ .set('authorization', `Bearer ${token}`)
+ .set('mcp-session-id', sessionId as string)
+ .set('accept', 'application/json, text/event-stream')
+ .send({
+ jsonrpc: '2.0',
+ method: 'notifications/initialized',
+ params: {},
+ })
+
+ return sessionId as string
+ }
+
+ it('serves health and protected resource metadata endpoints', async () => {
+ const { app, close } = createHttpApp({ environment: createEnvironment(), sandboxEnabled: false })
+ closeApp = close
+
+ const healthResponse = await request(app).get('/health')
+ expect(healthResponse.status).toBe(200)
+ expect(healthResponse.body.status).toBe('ok')
+
+ const metadataResponse = await request(app).get('/.well-known/oauth-protected-resource')
+ expect(metadataResponse.status).toBe(200)
+ expect(metadataResponse.body).toEqual({
+ resource: TEST_RESOURCE_URL,
+ authorization_servers: [TEST_ISSUER],
+ })
+ })
+
+ it('returns 401 and WWW-Authenticate on unauthenticated /mcp', async () => {
+ const { app, close } = createHttpApp({ environment: createEnvironment(), sandboxEnabled: false })
+ closeApp = close
+
+ const response = await request(app).post('/mcp').send(initializeRequest)
+
+ expect(response.status).toBe(401)
+ expect(response.headers['www-authenticate']).toContain('resource_metadata=')
+ })
+
+ it('binds MCP session to principal fingerprint', async () => {
+ const { app, close } = createHttpApp({ environment: createEnvironment(), sandboxEnabled: false })
+ closeApp = close
+
+ const token1 = await signTestJwt({}, 'user-1')
+ const token2 = await signTestJwt({}, 'user-2')
+
+ const sessionId = await initializeSession(app, token1)
+
+ const response = await request(app)
+ .post('/mcp')
+ .set('authorization', `Bearer ${token2}`)
+ .set('mcp-session-id', sessionId)
+ .set('accept', 'application/json')
+ .send({
+ jsonrpc: '2.0',
+ id: 2,
+ method: 'tools/list',
+ params: {},
+ })
+
+ expect(response.status).toBe(403)
+ expect(response.text).toContain('different principal')
+ })
+
+ it('filters tools/list according to allowed tools in JWT', async () => {
+ const { app, close } = createHttpApp({ environment: createEnvironment(), sandboxEnabled: false })
+ closeApp = close
+
+ const token = await signTestJwt({ allowed_tools: ['check_credits'] })
+ const sessionId = await initializeSession(app, token)
+
+ const response = await request(app)
+ .post('/mcp')
+ .set('authorization', `Bearer ${token}`)
+ .set('mcp-session-id', sessionId)
+ .set('accept', 'application/json, text/event-stream')
+ .send({
+ jsonrpc: '2.0',
+ id: 2,
+ method: 'tools/list',
+ params: {},
+ })
+
+ expect(response.status).toBe(200)
+
+ const toolsFromJson = response.body?.result?.tools
+ let tools: Array<{ name: string }> = Array.isArray(toolsFromJson) ? toolsFromJson : []
+
+ if (tools.length === 0 && response.text) {
+ const dataLines = response.text
+ .split('\n')
+ .map((line) => line.trim())
+ .filter((line) => line.startsWith('data:'))
+
+ for (const line of dataLines) {
+ const payload = line.slice('data:'.length).trim()
+ if (!payload) {
+ continue
+ }
+
+ const parsed = JSON.parse(payload) as { result?: { tools?: Array<{ name: string }> } }
+ if (Array.isArray(parsed.result?.tools)) {
+ tools = parsed.result.tools
+ break
+ }
+ }
+ }
+
+ const toolNames = tools.map((tool: { name: string }) => tool.name)
+
+ expect(toolNames).toEqual(['check_credits'])
+ })
+
+ it('cleans up session on DELETE /mcp', async () => {
+ const { app, close } = createHttpApp({ environment: createEnvironment(), sandboxEnabled: false })
+ closeApp = close
+
+ const token = await signTestJwt()
+ const sessionId = await initializeSession(app, token)
+
+ const deleteResponse = await request(app)
+ .delete('/mcp')
+ .set('authorization', `Bearer ${token}`)
+ .set('mcp-session-id', sessionId)
+
+ expect(deleteResponse.status).toBe(200)
+
+ const postResponse = await request(app)
+ .post('/mcp')
+ .set('authorization', `Bearer ${token}`)
+ .set('mcp-session-id', sessionId)
+ .set('accept', 'application/json')
+ .send({
+ jsonrpc: '2.0',
+ id: 3,
+ method: 'tools/list',
+ params: {},
+ })
+
+ expect(postResponse.status).toBe(404)
+ })
+})
diff --git a/tests/jwtAuth.test.ts b/tests/jwtAuth.test.ts
new file mode 100644
index 0000000..7f286e4
--- /dev/null
+++ b/tests/jwtAuth.test.ts
@@ -0,0 +1,192 @@
+import express from 'express'
+import request from 'supertest'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { createServer, Server } from 'node:http'
+import { createJwtAuthMiddleware } from '../src/http/jwtAuth.js'
+import { RequestWithAuth } from '../src/http/types.js'
+import { generateKeyPair, exportJWK, JWK, SignJWT } from 'jose'
+
+describe('jwt auth middleware', () => {
+ let jwksServer: Server
+ let jwksUrl: string
+ let issuer: string
+ let privateKey: CryptoKey
+ let publicJwk: JWK
+
+ beforeAll(async () => {
+ const keyPair = await generateKeyPair('RS256')
+ privateKey = keyPair.privateKey
+ publicJwk = await exportJWK(keyPair.publicKey)
+ publicJwk.kid = 'test-key'
+ publicJwk.alg = 'RS256'
+ publicJwk.use = 'sig'
+
+ jwksServer = createServer((req, res) => {
+ if (req.url === '/jwks') {
+ res.writeHead(200, { 'content-type': 'application/json' })
+ res.end(JSON.stringify({ keys: [publicJwk] }))
+ return
+ }
+
+ res.writeHead(404)
+ res.end()
+ })
+
+ await new Promise((resolve) => {
+ jwksServer.listen(0, '127.0.0.1', () => resolve())
+ })
+
+ const address = jwksServer.address()
+ if (!address || typeof address === 'string') {
+ throw new Error('Failed to start JWKS server')
+ }
+
+ issuer = `http://127.0.0.1:${address.port}`
+ jwksUrl = `${issuer}/jwks`
+ })
+
+ afterAll(async () => {
+ await new Promise((resolve, reject) => {
+ jwksServer.close((error) => {
+ if (error) {
+ reject(error)
+ return
+ }
+
+ resolve()
+ })
+ })
+ })
+
+ async function createToken(overrides: Record = {}) {
+ const now = Math.floor(Date.now() / 1000)
+
+ return new SignJWT({
+ sub: 'user-1',
+ azp: 'client-1',
+ sid: 'session-1',
+ iss: issuer,
+ aud: 'dws-mcp',
+ scope: 'mcp:invoke',
+ exp: now + 300,
+ ...overrides,
+ })
+ .setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
+ .sign(privateKey)
+ }
+
+ function createApp(audience: string | string[] = 'dws-mcp') {
+ const app = express()
+ app.use(
+ createJwtAuthMiddleware({
+ jwksUrl,
+ issuer,
+ audience,
+ requiredScope: 'mcp:invoke',
+ resourceMetadataUrl: `${issuer}/.well-known/oauth-protected-resource`,
+ }),
+ )
+
+ app.get('/protected', (req, res) => {
+ const authInfo = (req as RequestWithAuth).auth
+ res.json({
+ clientId: authInfo?.clientId,
+ scopes: authInfo?.scopes,
+ allowedTools: authInfo?.extra?.allowedTools,
+ })
+ })
+
+ return app
+ }
+
+ it('accepts valid JWTs', async () => {
+ const token = await createToken()
+ const app = createApp()
+
+ const response = await request(app).get('/protected').set('authorization', `Bearer ${token}`)
+
+ expect(response.status).toBe(200)
+ expect(response.body.clientId).toBe('client-1')
+ expect(response.body.scopes).toContain('mcp:invoke')
+ })
+
+ it('accepts JWTs whose audience matches the resource URL when configured', async () => {
+ const resourceUrl = 'http://localhost:3000/mcp'
+ const token = await createToken({ aud: resourceUrl })
+ const app = createApp(['dws-mcp', resourceUrl])
+
+ const response = await request(app).get('/protected').set('authorization', `Bearer ${token}`)
+
+ expect(response.status).toBe(200)
+ expect(response.body.clientId).toBe('client-1')
+ })
+
+ it('rejects JWTs with wrong audience', async () => {
+ const token = await createToken({ aud: 'wrong-audience' })
+ const app = createApp()
+
+ const response = await request(app).get('/protected').set('authorization', `Bearer ${token}`)
+
+ expect(response.status).toBe(401)
+ expect(response.body.error).toBe('invalid_token')
+ })
+
+ it('rejects JWTs without required scope', async () => {
+ const token = await createToken({ scope: 'other:scope' })
+ const app = createApp()
+
+ const response = await request(app).get('/protected').set('authorization', `Bearer ${token}`)
+
+ expect(response.status).toBe(401)
+ expect(response.body.error).toBe('invalid_token')
+ })
+
+ it('rejects expired JWTs', async () => {
+ const now = Math.floor(Date.now() / 1000)
+ const token = await createToken({ exp: now - 120 })
+ const app = createApp()
+
+ const response = await request(app).get('/protected').set('authorization', `Bearer ${token}`)
+
+ expect(response.status).toBe(401)
+ expect(response.body.error).toBe('invalid_token')
+ })
+
+ it('maps allowed_tools claim to AuthInfo.extra.allowedTools', async () => {
+ const token = await createToken({ allowed_tools: ['check_credits', 'document_processor'] })
+ const app = createApp()
+
+ const response = await request(app).get('/protected').set('authorization', `Bearer ${token}`)
+
+ expect(response.status).toBe(200)
+ expect(response.body.allowedTools).toEqual(['check_credits', 'document_processor'])
+ })
+
+ it('falls back to unknown-client when sub and azp are missing', async () => {
+ const token = await createToken({ sub: undefined, azp: undefined, sid: undefined })
+ const app = express()
+
+ app.use(
+ createJwtAuthMiddleware({
+ jwksUrl,
+ issuer,
+ audience: 'dws-mcp',
+ requiredScope: 'mcp:invoke',
+ resourceMetadataUrl: `${issuer}/.well-known/oauth-protected-resource`,
+ }),
+ )
+
+ app.get('/protected', (req, res) => {
+ const authInfo = (req as RequestWithAuth).auth
+ res.json({
+ clientId: authInfo?.clientId,
+ extra: authInfo?.extra,
+ })
+ })
+
+ const response = await request(app).get('/protected').set('authorization', `Bearer ${token}`)
+
+ expect(response.status).toBe(200)
+ expect(response.body.clientId).toBe('unknown-client')
+ })
+})
diff --git a/tests/protectedResource.test.ts b/tests/protectedResource.test.ts
new file mode 100644
index 0000000..ba333f5
--- /dev/null
+++ b/tests/protectedResource.test.ts
@@ -0,0 +1,35 @@
+import express from 'express'
+import request from 'supertest'
+import { describe, expect, it } from 'vitest'
+import { buildWwwAuthenticateHeader, createProtectedResourceHandler } from '../src/http/protectedResource.js'
+
+describe('protected resource metadata', () => {
+ it('serves RFC9728 metadata document', async () => {
+ const app = express()
+
+ app.get(
+ '/.well-known/oauth-protected-resource',
+ createProtectedResourceHandler({
+ resourceUrl: 'https://mcp.nutrient.io/mcp',
+ authServerUrl: 'https://api.nutrient.io',
+ resourceMetadataUrl: 'https://mcp.nutrient.io/.well-known/oauth-protected-resource',
+ }),
+ )
+
+ const response = await request(app).get('/.well-known/oauth-protected-resource')
+
+ expect(response.status).toBe(200)
+ expect(response.body).toEqual({
+ resource: 'https://mcp.nutrient.io/mcp',
+ authorization_servers: ['https://api.nutrient.io'],
+ })
+ })
+
+ it('builds WWW-Authenticate header with resource metadata', () => {
+ const header = buildWwwAuthenticateHeader({
+ resourceMetadataUrl: 'https://mcp.nutrient.io/.well-known/oauth-protected-resource',
+ })
+
+ expect(header).toBe('Bearer resource_metadata="https://mcp.nutrient.io/.well-known/oauth-protected-resource"')
+ })
+})
diff --git a/tests/requestLogger.test.ts b/tests/requestLogger.test.ts
new file mode 100644
index 0000000..7687256
--- /dev/null
+++ b/tests/requestLogger.test.ts
@@ -0,0 +1,68 @@
+import express from 'express'
+import request from 'supertest'
+import { describe, expect, it } from 'vitest'
+import { createRequestLoggerMiddleware, isMcpDebugLoggingEnabled } from '../src/http/requestLogger.js'
+
+type LogEntry = {
+ level: 'debug' | 'info'
+ message: string
+}
+
+describe('request logger middleware', () => {
+ it('logs request and response in readable arrow format', async () => {
+ const entries: LogEntry[] = []
+ const logger = (level: 'debug' | 'info', message: string) => {
+ entries.push({ level, message })
+ }
+
+ const app = express()
+ app.use(express.json())
+ app.use(createRequestLoggerMiddleware({ logger }))
+ app.post('/mcp', (req, res) => {
+ res.status(200).json({
+ ok: true,
+ echo: req.body,
+ })
+ })
+
+ const response = await request(app)
+ .post('/mcp')
+ .set('authorization', 'Bearer super-secret')
+ .set('x-request-id', 'request-123')
+ .send({ jsonrpc: '2.0', method: 'initialize' })
+
+ expect(response.status).toBe(200)
+ expect(response.headers['x-request-id']).toBe('request-123')
+
+ expect(entries).toContainEqual({ level: 'info', message: '<<< POST /mcp' })
+
+ expect(entries).toContainEqual({
+ level: 'debug',
+ message: JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }),
+ })
+
+ expect(entries).toContainEqual({ level: 'info', message: '>>> Sent 200' })
+
+ expect(entries).toContainEqual({
+ level: 'debug',
+ message: JSON.stringify({
+ ok: true,
+ echo: { jsonrpc: '2.0', method: 'initialize' },
+ }),
+ })
+ })
+})
+
+describe('isMcpDebugLoggingEnabled', () => {
+ it('recognizes common truthy values', () => {
+ expect(isMcpDebugLoggingEnabled({ MCP_DEBUG_LOGGING: 'true' })).toBe(true)
+ expect(isMcpDebugLoggingEnabled({ MCP_DEBUG_LOGGING: '1' })).toBe(true)
+ expect(isMcpDebugLoggingEnabled({ MCP_DEBUG_LOGGING: 'on' })).toBe(true)
+ })
+
+ it('returns false for unset or falsey values', () => {
+ expect(isMcpDebugLoggingEnabled({})).toBe(false)
+ expect(isMcpDebugLoggingEnabled({ MCP_DEBUG_LOGGING: 'false' })).toBe(false)
+ expect(isMcpDebugLoggingEnabled({ MCP_DEBUG_LOGGING: '0' })).toBe(false)
+ })
+})
diff --git a/tests/signing-api-examples.test.ts b/tests/signing-api-examples.test.ts
index 14d5cef..507bd80 100644
--- a/tests/signing-api-examples.test.ts
+++ b/tests/signing-api-examples.test.ts
@@ -5,15 +5,19 @@ import { performSignCall } from '../src/dws/sign.js'
import { SignAPIArgs } from '../src/schemas.js'
import path from 'path'
import { setSandboxDirectory } from '../src/fs/sandbox.js'
+import { createApiClient } from '../src/dws/api.js'
+import { DwsApiClient } from '../src/dws/client.js'
dotenvConfig()
describe('performSignCall with signing-api-examples', () => {
let outputDirectory: string
+ let apiClient: DwsApiClient
beforeAll(async () => {
const assetsDir = path.join(__dirname, `assets`)
await setSandboxDirectory(assetsDir)
+ apiClient = createApiClient({ apiKey: process.env.NUTRIENT_DWS_API_KEY! })
outputDirectory = `test-output-${new Date().toISOString().replace(/[:.]/g, '-')}`
})
@@ -44,6 +48,7 @@ describe('performSignCall with signing-api-examples', () => {
const result = await performSignCall(
filePath,
`${outputDirectory}/${outputPath}`,
+ apiClient,
signatureOptions,
watermarkImagePath,
graphicImagePath,
diff --git a/tests/unit.test.ts b/tests/unit.test.ts
index b924728..fc4c2a5 100644
--- a/tests/unit.test.ts
+++ b/tests/unit.test.ts
@@ -9,12 +9,12 @@ import { performSignCall } from '../src/dws/sign.js'
import { performAiRedactCall } from '../src/dws/ai-redact.js'
import { performDirectoryTreeCall } from '../src/fs/directoryTree.js'
import * as sandbox from '../src/fs/sandbox.js'
-import * as api from '../src/dws/api.js'
-import axios, { InternalAxiosRequestConfig } from 'axios'
+import axios from 'axios'
import path from 'path'
import { FileHandle } from 'fs/promises'
import { parseSandboxPath } from '../src/utils/sandbox.js'
import { CallToolResult, TextContent } from '@modelcontextprotocol/sdk/types.js'
+import { DwsApiClient } from '../src/dws/client.js'
dotenvConfig()
@@ -29,7 +29,23 @@ function getTextContent(result: CallToolResult, index: number = 0): string {
vi.mock('axios')
vi.mock('node:fs', { spy: true })
-vi.mock('../src/dws/api.js')
+
+function createMockApiClient(mockResponse?: { data: Readable; status?: number }): DwsApiClient {
+ const defaultResponse = {
+ data: createMockStream('default mock response'),
+ status: 200,
+ statusText: 'OK',
+ headers: {},
+ config: {},
+ }
+
+ const response = mockResponse ? { ...defaultResponse, data: mockResponse.data, status: mockResponse.status ?? 200 } : defaultResponse
+
+ return {
+ post: vi.fn().mockResolvedValue(response),
+ get: vi.fn().mockResolvedValue(response),
+ } as unknown as DwsApiClient
+}
function createMockStream(content: string | Buffer): Readable {
const readable = new Readable()
@@ -65,17 +81,6 @@ describe('API Functions', () => {
vi.spyOn(fs.promises, 'mkdir').mockReturnValue(Promise.resolve(undefined))
vi.spyOn(fs.promises, 'unlink').mockImplementation(async () => {})
vi.spyOn(fs.promises, 'rm').mockImplementation(async () => {})
-
- vi.mocked(api.callNutrientApi).mockImplementation(async () => {
- const mockStream = createMockStream('default mock response')
- return {
- data: mockStream,
- status: 200,
- statusText: 'OK',
- headers: {},
- config: {} as InternalAxiosRequestConfig,
- }
- })
})
afterEach(() => {
@@ -85,27 +90,28 @@ describe('API Functions', () => {
describe('performBuildCall', () => {
it('should throw an error if file does not exist', async () => {
const resolvedPath = path.resolve('/test.pdf')
+ const mockClient = createMockApiClient()
vi.spyOn(fs.promises, 'access').mockImplementation(async () => {
throw new Error(`Path not found: ${resolvedPath}`)
})
- const buildCall = performBuildCall({ parts: [{ file: '/test.pdf' }] }, '/test_processed.pdf')
+ const buildCall = performBuildCall({ parts: [{ file: '/test.pdf' }] }, '/test_processed.pdf', mockClient)
await expect(buildCall).rejects.toThrowError(
`Error with referenced file /test.pdf: Path not found: ${resolvedPath}`,
)
})
- it('should throw an error if API key is not set', async () => {
- // Mock callNutrientApi to throw an error
- vi.mocked(api.callNutrientApi).mockRejectedValue(
+ it('should return an error when the API client rejects', async () => {
+ const mockClient = createMockApiClient()
+ vi.mocked(mockClient.post).mockRejectedValue(
new Error(
'Error: NUTRIENT_DWS_API_KEY environment variable is required. Please visit https://www.nutrient.io/api/ to get your free API key.',
),
)
- const result = await performBuildCall({ parts: [{ file: '/test.pdf' }] }, '/test_processed.pdf')
+ const result = await performBuildCall({ parts: [{ file: '/test.pdf' }] }, '/test_processed.pdf', mockClient)
expect(result.isError).toBe(true)
expect(getTextContent(result)).toContain('NUTRIENT_DWS_API_KEY environment variable is required')
@@ -113,52 +119,31 @@ describe('API Functions', () => {
})
it('should use application/json when all inputs are URLs', async () => {
- const mockStream = createMockStream('processed content')
- vi.mocked(api.callNutrientApi).mockResolvedValueOnce({
- data: mockStream,
- status: 200,
- statusText: 'OK',
- headers: {},
- config: {} as InternalAxiosRequestConfig,
- })
+ const mockClient = createMockApiClient({ data: createMockStream('processed content') })
const instructions = {
parts: [{ file: 'https://example.com/test.pdf' }],
}
- await performBuildCall(instructions, '/test_processed.pdf')
+ await performBuildCall(instructions, '/test_processed.pdf', mockClient)
- expect(api.callNutrientApi).toHaveBeenCalledWith('build', instructions)
+ expect(mockClient.post).toHaveBeenCalledWith('build', instructions)
})
it('should use multipart/form-data when local files are included', async () => {
- const mockStream = createMockStream('processed content')
- vi.mocked(api.callNutrientApi).mockResolvedValueOnce({
- data: mockStream,
- status: 200,
- statusText: 'OK',
- headers: {},
- config: {} as InternalAxiosRequestConfig,
- })
+ const mockClient = createMockApiClient({ data: createMockStream('processed content') })
const instructions = {
parts: [{ file: '/test.pdf' }],
}
- await performBuildCall(instructions, '/test_processed.pdf')
+ await performBuildCall(instructions, '/test_processed.pdf', mockClient)
- expect(api.callNutrientApi).toHaveBeenCalledWith('build', expect.any(Object))
+ expect(mockClient.post).toHaveBeenCalledWith('build', expect.any(Object))
})
it('should handle json-content output type', async () => {
- const mockStream = createMockStream('{"result": "success"}')
- vi.mocked(api.callNutrientApi).mockResolvedValueOnce({
- data: mockStream,
- status: 200,
- statusText: 'OK',
- headers: {},
- config: {} as InternalAxiosRequestConfig,
- })
+ const mockClient = createMockApiClient({ data: createMockStream('{"result": "success"}') })
const instructions: Instructions = {
parts: [{ file: 'https://example.com/test.pdf' }],
@@ -170,7 +155,7 @@ describe('API Functions', () => {
},
}
- const result = await performBuildCall(instructions, '/test_processed.pdf')
+ const result = await performBuildCall(instructions, '/test_processed.pdf', mockClient)
expect(result.isError).toBe(false)
expect(result.content[0].type).toBe('text')
@@ -178,35 +163,30 @@ describe('API Functions', () => {
})
it('should handle file output and save to disk', async () => {
- const mockStream = createMockStream('processed content')
- vi.mocked(api.callNutrientApi).mockResolvedValueOnce({
- data: mockStream,
- status: 200,
- statusText: 'OK',
- headers: {},
- config: {} as InternalAxiosRequestConfig,
- })
+ const mockClient = createMockApiClient({ data: createMockStream('processed content') })
- await performBuildCall({ parts: [{ file: '/test.pdf' }] }, '/test_processed.pdf')
+ await performBuildCall({ parts: [{ file: '/test.pdf' }] }, '/test_processed.pdf', mockClient)
expect(fs.promises.writeFile).toHaveBeenCalledWith(expect.stringContaining('_processed.pdf'), expect.any(Buffer))
})
it('should handle errors from the API', async () => {
+ const mockClient = createMockApiClient()
const mockError = {
response: {
data: createMockStream('Error message from API'),
},
}
vi.mocked(axios.isAxiosError).mockImplementation(() => true)
- vi.mocked(api.callNutrientApi).mockRejectedValueOnce(mockError)
- const result = await performBuildCall({ parts: [{ file: '/test.pdf' }] }, '/test_processed.pdf')
+ vi.mocked(mockClient.post).mockRejectedValueOnce(mockError)
+ const result = await performBuildCall({ parts: [{ file: '/test.pdf' }] }, '/test_processed.pdf', mockClient)
expect(result.isError).toBe(true)
expect(getTextContent(result)).toContain('Error processing API response: Error message from API')
})
it('should handle HostedErrorResponse format from the API', async () => {
+ const mockClient = createMockApiClient()
const hostedErrorResponse = {
details: 'The request is malformed',
status: 400,
@@ -224,9 +204,9 @@ describe('API Functions', () => {
},
}
vi.mocked(axios.isAxiosError).mockImplementation(() => true)
- vi.mocked(api.callNutrientApi).mockRejectedValueOnce(mockError)
+ vi.mocked(mockClient.post).mockRejectedValueOnce(mockError)
- const result = await performBuildCall({ parts: [{ file: '/test.pdf' }] }, '/test_processed.pdf')
+ const result = await performBuildCall({ parts: [{ file: '/test.pdf' }] }, '/test_processed.pdf', mockClient)
expect(result.isError).toBe(true)
@@ -243,26 +223,28 @@ describe('API Functions', () => {
describe('performSignCall', () => {
it('should throw an error if file does not exist', async () => {
const resolvedPath = path.resolve('/test.pdf')
+ const mockClient = createMockApiClient()
vi.spyOn(fs.promises, 'access').mockImplementation(async () => {
throw new Error(`Error with referenced file /test.pdf: Path not found: ${resolvedPath}`)
})
- const buildCall = performBuildCall({ parts: [{ file: '/test.pdf' }] }, '/test_processed.pdf')
+ const buildCall = performBuildCall({ parts: [{ file: '/test.pdf' }] }, '/test_processed.pdf', mockClient)
await expect(buildCall).rejects.toThrowError(
`Error with referenced file /test.pdf: Path not found: ${resolvedPath}`,
)
})
- it('should throw an error if API key is not set', async () => {
- vi.mocked(api.callNutrientApi).mockRejectedValueOnce(
+ it('should return an error when the API client rejects', async () => {
+ const mockClient = createMockApiClient()
+ vi.mocked(mockClient.post).mockRejectedValueOnce(
new Error(
'Error: NUTRIENT_DWS_API_KEY environment variable is required. Please visit https://www.nutrient.io/api/ to get your free API key.',
),
)
- const result = await performSignCall('/test.pdf', '/test_processed.pdf')
+ const result = await performSignCall('/test.pdf', '/test_processed.pdf', mockClient)
expect(result.isError).toBe(true)
expect(getTextContent(result)).toContain('NUTRIENT_DWS_API_KEY environment variable is required')
@@ -270,14 +252,7 @@ describe('API Functions', () => {
})
it('should send the file and signature options to the API', async () => {
- const mockStream = createMockStream('signed content')
- vi.mocked(api.callNutrientApi).mockResolvedValueOnce({
- data: mockStream,
- status: 200,
- statusText: 'OK',
- headers: {},
- config: {} as InternalAxiosRequestConfig,
- })
+ const mockClient = createMockApiClient({ data: createMockStream('signed content') })
const signatureOptions: SignatureOptions = {
signatureType: 'cms',
@@ -289,24 +264,18 @@ describe('API Functions', () => {
},
}
- await performSignCall('/test.pdf', '/test_processed.pdf', signatureOptions)
+ await performSignCall('/test.pdf', '/test_processed.pdf', mockClient, signatureOptions)
- expect(api.callNutrientApi).toHaveBeenCalledWith('sign', expect.any(Object))
+ expect(mockClient.post).toHaveBeenCalledWith('sign', expect.any(Object))
})
it('should include watermark image if provided', async () => {
- const mockStream = createMockStream('signed content')
- vi.mocked(api.callNutrientApi).mockResolvedValueOnce({
- data: mockStream,
- status: 200,
- statusText: 'OK',
- headers: {},
- config: {} as InternalAxiosRequestConfig,
- })
+ const mockClient = createMockApiClient({ data: createMockStream('signed content') })
await performSignCall(
'/test.pdf',
'/test_processed.pdf',
+ mockClient,
{ signatureType: 'cms', flatten: false },
'/watermark.png',
)
@@ -315,18 +284,12 @@ describe('API Functions', () => {
})
it('should include graphic image if provided', async () => {
- const mockStream = createMockStream('signed content')
- vi.mocked(api.callNutrientApi).mockResolvedValueOnce({
- data: mockStream,
- status: 200,
- statusText: 'OK',
- headers: {},
- config: {} as InternalAxiosRequestConfig,
- })
+ const mockClient = createMockApiClient({ data: createMockStream('signed content') })
await performSignCall(
'/test.pdf',
'/test_processed.pdf',
+ mockClient,
{ signatureType: 'cms', flatten: false },
undefined,
'/graphic.png',
@@ -336,30 +299,24 @@ describe('API Functions', () => {
})
it('should save the result to disk', async () => {
- const mockStream = createMockStream('signed content')
- vi.mocked(api.callNutrientApi).mockResolvedValueOnce({
- data: mockStream,
- status: 200,
- statusText: 'OK',
- headers: {},
- config: {} as InternalAxiosRequestConfig,
- })
+ const mockClient = createMockApiClient({ data: createMockStream('signed content') })
- await performSignCall('/test.pdf', '/test_signed.pdf')
+ await performSignCall('/test.pdf', '/test_signed.pdf', mockClient)
expect(fs.promises.writeFile).toHaveBeenCalledWith(expect.stringContaining('_signed.pdf'), expect.any(Buffer))
})
it('should handle errors from the API', async () => {
+ const mockClient = createMockApiClient()
const mockError = {
response: {
data: createMockStream('Error message from API'),
},
}
vi.mocked(axios.isAxiosError).mockImplementation(() => true)
- vi.mocked(api.callNutrientApi).mockRejectedValueOnce(mockError)
+ vi.mocked(mockClient.post).mockRejectedValueOnce(mockError)
- const result = await performSignCall('/test.pdf', '/test_processed.pdf')
+ const result = await performSignCall('/test.pdf', '/test_processed.pdf', mockClient)
expect(result.isError).toBe(true)
expect(getTextContent(result)).toContain('Error processing API response: Error message from API')
@@ -372,29 +329,39 @@ describe('API Functions', () => {
})
it('should return an error if file does not exist', async () => {
+ const mockClient = createMockApiClient()
vi.spyOn(sandbox, 'resolveReadFilePath').mockRejectedValueOnce(new Error('Path not found: /missing.pdf'))
- const result = await performAiRedactCall('/missing.pdf', 'All personally identifiable information', '/out.pdf')
+ const result = await performAiRedactCall('/missing.pdf', 'All personally identifiable information', '/out.pdf', mockClient)
expect(result.isError).toBe(true)
expect(getTextContent(result)).toContain('Error: Path not found: /missing.pdf')
})
it('should return an error when stage and apply are both true', async () => {
+ const mockClient = createMockApiClient()
vi.spyOn(sandbox, 'resolveReadFilePath').mockResolvedValueOnce('/input.pdf')
vi.spyOn(sandbox, 'resolveWriteFilePath').mockResolvedValueOnce('/output.pdf')
- const result = await performAiRedactCall('/input.pdf', 'All personally identifiable information', '/output.pdf', true, true)
+ const result = await performAiRedactCall(
+ '/input.pdf',
+ 'All personally identifiable information',
+ '/output.pdf',
+ mockClient,
+ true,
+ true,
+ )
expect(result.isError).toBe(true)
expect(getTextContent(result)).toBe('Error: stage and apply cannot both be true. Choose one mode.')
})
it('should return an error when output path equals input path', async () => {
+ const mockClient = createMockApiClient()
vi.spyOn(sandbox, 'resolveReadFilePath').mockResolvedValueOnce('/same.pdf')
vi.spyOn(sandbox, 'resolveWriteFilePath').mockResolvedValueOnce('/same.pdf')
- const result = await performAiRedactCall('/same.pdf', 'All personally identifiable information', '/same.pdf')
+ const result = await performAiRedactCall('/same.pdf', 'All personally identifiable information', '/same.pdf', mockClient)
expect(result.isError).toBe(true)
expect(getTextContent(result)).toContain(
@@ -403,31 +370,20 @@ describe('API Functions', () => {
})
it('should call the API and save the result to disk', async () => {
+ const mockClient = createMockApiClient({ data: createMockStream('redacted content') })
vi.spyOn(sandbox, 'resolveReadFilePath').mockResolvedValueOnce('/input.pdf')
vi.spyOn(sandbox, 'resolveWriteFilePath').mockResolvedValueOnce('/redacted.pdf')
- const mockStream = createMockStream('redacted content')
- vi.mocked(api.callNutrientApi).mockResolvedValueOnce({
- data: mockStream,
- status: 200,
- statusText: 'OK',
- headers: {},
- config: {} as InternalAxiosRequestConfig,
- })
-
- const result = await performAiRedactCall(
- '/input.pdf',
- 'All personally identifiable information',
- '/redacted.pdf',
- )
+ const result = await performAiRedactCall('/input.pdf', 'All personally identifiable information', '/redacted.pdf', mockClient)
expect(result.isError).toBe(false)
expect(getTextContent(result)).toContain('AI redaction completed successfully')
expect(fs.promises.writeFile).toHaveBeenCalledWith('/redacted.pdf', expect.any(Buffer))
- expect(api.callNutrientApi).toHaveBeenCalledWith('ai/redact', expect.any(Object))
+ expect(mockClient.post).toHaveBeenCalledWith('ai/redact', expect.any(Object))
})
it('should handle errors from the API', async () => {
+ const mockClient = createMockApiClient()
vi.spyOn(sandbox, 'resolveReadFilePath').mockResolvedValueOnce('/input.pdf')
vi.spyOn(sandbox, 'resolveWriteFilePath').mockResolvedValueOnce('/redacted.pdf')
@@ -437,9 +393,9 @@ describe('API Functions', () => {
},
}
vi.mocked(axios.isAxiosError).mockImplementation(() => true)
- vi.mocked(api.callNutrientApi).mockRejectedValueOnce(mockError)
+ vi.mocked(mockClient.post).mockRejectedValueOnce(mockError)
- const result = await performAiRedactCall('/input.pdf', 'All personally identifiable information', '/redacted.pdf')
+ const result = await performAiRedactCall('/input.pdf', 'All personally identifiable information', '/redacted.pdf', mockClient)
expect(result.isError).toBe(true)
expect(getTextContent(result)).toContain('Error processing API response: Error message from API')
@@ -888,12 +844,9 @@ describe('API Functions', () => {
usage: { totalCredits: 100, usedCredits: 42 },
}
- vi.stubEnv('NUTRIENT_DWS_API_KEY', 'test-key')
- vi.spyOn(axios, 'get').mockResolvedValue({
- data: Readable.from([JSON.stringify(apiResponse)]),
- })
+ const mockClient = createMockApiClient({ data: Readable.from([JSON.stringify(apiResponse)]) })
- const result = await performCheckCreditsCall()
+ const result = await performCheckCreditsCall(mockClient)
expect(result.isError).toBe(false)
const text = (result.content[0] as TextContent).text
@@ -904,31 +857,15 @@ describe('API Functions', () => {
expect(parsed.remainingCredits).toBe(58)
// Must not contain the API key
expect(text).not.toContain('sk_live_secret')
-
- vi.restoreAllMocks()
})
it('should handle non-JSON API response', async () => {
- vi.stubEnv('NUTRIENT_DWS_API_KEY', 'test-key')
- vi.spyOn(axios, 'get').mockResolvedValue({
- data: Readable.from(['not json']),
- })
+ const mockClient = createMockApiClient({ data: Readable.from(['not json']) })
- const result = await performCheckCreditsCall()
+ const result = await performCheckCreditsCall(mockClient)
expect(result.isError).toBe(true)
expect((result.content[0] as TextContent).text).toContain('Unexpected non-JSON response')
-
- vi.restoreAllMocks()
- })
-
- it('should error when API key is not set', async () => {
- vi.stubEnv('NUTRIENT_DWS_API_KEY', '')
- delete process.env.NUTRIENT_DWS_API_KEY
-
- await expect(performCheckCreditsCall()).rejects.toThrow('NUTRIENT_DWS_API_KEY not set')
-
- vi.restoreAllMocks()
})
})
})