From e38a2c7206a029bde7cb3631813a96398f27d77b Mon Sep 17 00:00:00 2001 From: Stepan Prokipchyn Date: Wed, 7 Jan 2026 01:56:36 +0200 Subject: [PATCH 1/2] Add support for dynamic Bearer token via custom JS module Motivation: In real life bearer token is temporary and generated via API call. Setting it each time in MCP configuration is inconvenient. The aim of this new feature is to allow to provide a function in MCP configuration that the server will use to acquire a new token when it is needed. This function can implement custom authentication mechanism. --- README.md | 12 +++- src/index.ts | 125 ++++++++++++++++++++++++++++++++++++++++ src/resources/config.md | 38 +++++++++++- 3 files changed, 172 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ec33a81..9a3982d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -[![MseeP.ai Security Assessment Badge](https://mseep.net/pr/dkmaker-mcp-rest-api-badge.png)](https://mseep.ai/app/dkmaker-mcp-rest-api) - +[![MseeP.ai Security Assessment Badge](https://mseep.net/pr/dkmaker-mcp-rest-api-badge.png)](https://mseep.ai/app/dkmaker-mcp-rest-api) + # MCP REST API Tester [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![NPM Package](https://img.shields.io/npm/v/dkmaker-mcp-rest-api.svg)](https://www.npmjs.com/package/dkmaker-mcp-rest-api) @@ -150,6 +150,13 @@ Note: Replace the environment variables with your actual values. Only configure 1. Basic Authentication (username/password) 2. Bearer Token (if Basic Auth is not configured) 3. API Key (if neither Basic Auth nor Bearer Token is configured) +4. Dynamic Bearer Token (if none of the above are configured) + +Dynamic Bearer Token (module): +- `AUTH_TOKEN_MODULE`: path to a local JS module that default-exports an async function + - The server calls it with `{ axios, env }` + - The function must return the token string + - If the returned token is a JWT with an `exp` claim, it is refreshed automatically shortly before it expires ## Features @@ -176,6 +183,7 @@ Note: Replace the environment variables with your actual values. Only configure - Basic Authentication (username/password) - Bearer Token Authentication - API Key Authentication (custom header) + - Dynamic Bearer Token Authentication (module) ## Usage Examples diff --git a/src/index.ts b/src/index.ts index 2ec5777..d152f6c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,8 @@ const AUTH_BASIC_PASSWORD = process.env.AUTH_BASIC_PASSWORD; const AUTH_BEARER = process.env.AUTH_BEARER; const AUTH_APIKEY_HEADER_NAME = process.env.AUTH_APIKEY_HEADER_NAME; const AUTH_APIKEY_VALUE = process.env.AUTH_APIKEY_VALUE; +// Dynamic bearer token acquisition via local JS module (optional) +const AUTH_TOKEN_MODULE = process.env.AUTH_TOKEN_MODULE; const REST_ENABLE_SSL_VERIFY = process.env.REST_ENABLE_SSL_VERIFY !== 'false'; interface EndpointArgs { @@ -159,6 +161,115 @@ const isValidEndpointArgs = (args: any): args is EndpointArgs => { const hasBasicAuth = () => AUTH_BASIC_USERNAME && AUTH_BASIC_PASSWORD; const hasBearerAuth = () => !!AUTH_BEARER; const hasApiKeyAuth = () => AUTH_APIKEY_HEADER_NAME && AUTH_APIKEY_VALUE; +const hasDynamicBearerAuth = () => !!AUTH_TOKEN_MODULE; + +const decodeBase64UrlToString = (input: string): string => { + const normalized = input.replace(/-/g, '+').replace(/_/g, '/'); + const pad = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4)); + return Buffer.from(normalized + pad, 'base64').toString('utf8'); +}; + +const getJwtExpMs = (token: string): number | undefined => { + const parts = token.split('.'); + if (parts.length < 2) return undefined; + try { + const payloadJson = decodeBase64UrlToString(parts[1]); + const payload = JSON.parse(payloadJson); + const exp = payload?.exp; + if (typeof exp !== 'number' || !Number.isFinite(exp)) return undefined; + return exp * 1000; + } catch { + return undefined; + } +}; + +class TokenProvider { + private token?: string; + private tokenExpiresAt?: number; + private inflight?: Promise; + private tokenFn?: (ctx: { axios: AxiosInstance; env: NodeJS.ProcessEnv }) => Promise | string; + private tokenFnInflight?: Promise<(ctx: { axios: AxiosInstance; env: NodeJS.ProcessEnv }) => Promise | string>; + + constructor(private axiosInstance: AxiosInstance) {} + + invalidate() { + this.token = undefined; + this.tokenExpiresAt = undefined; + } + + async getToken(forceRefresh: boolean = false): Promise { + const now = Date.now(); + + // If we know the token's expiry, refresh slightly before it actually expires. + // This avoids edge cases where token expires mid-request. + const refreshSkewMs = 60_000; + const isValid = + this.token && + (this.tokenExpiresAt === undefined || now < this.tokenExpiresAt - refreshSkewMs); + + if (!forceRefresh && isValid) { + return this.token as string; + } + + if (!forceRefresh && this.inflight) { + return await this.inflight; + } + + this.inflight = this.acquireToken().finally(() => { + this.inflight = undefined; + }); + + const token = await this.inflight; + this.token = token; + + const expMs = getJwtExpMs(token); + this.tokenExpiresAt = expMs; + return token; + } + + private async acquireToken(): Promise { + if (!AUTH_TOKEN_MODULE) { + throw new Error('Dynamic auth is enabled but AUTH_TOKEN_MODULE is not set'); + } + + const tokenFn = await this.getTokenFn(); + const token = await tokenFn({ axios: this.axiosInstance, env: process.env }); + const trimmed = String(token).trim(); + if (!trimmed) { + throw new Error('AUTH_TOKEN_MODULE function returned empty token'); + } + return trimmed; + } + + private async getTokenFn(): Promise< + (ctx: { axios: AxiosInstance; env: NodeJS.ProcessEnv }) => Promise | string + > { + if (this.tokenFn) return this.tokenFn; + if (this.tokenFnInflight) return await this.tokenFnInflight; + + this.tokenFnInflight = (async () => { + const path = await import('node:path'); + const url = await import('node:url'); + + const modulePath = path.isAbsolute(AUTH_TOKEN_MODULE!) + ? AUTH_TOKEN_MODULE! + : path.resolve(process.cwd(), AUTH_TOKEN_MODULE!); + + const moduleUrl = url.pathToFileURL(modulePath).href; + const mod: any = await import(moduleUrl); + const fn = mod?.default; + if (typeof fn !== 'function') { + throw new Error('AUTH_TOKEN_MODULE must default-export a function'); + } + this.tokenFn = fn; + return fn; + })().finally(() => { + this.tokenFnInflight = undefined; + }); + + return await this.tokenFnInflight; + } +} // Collect custom headers from environment variables const getCustomHeaders = (): Record => { @@ -179,6 +290,7 @@ const getCustomHeaders = (): Record => { class RestTester { private server!: Server; private axiosInstance!: AxiosInstance; + private tokenProvider?: TokenProvider; constructor() { this.setupServer(); @@ -207,6 +319,10 @@ class RestTester { }) }); + if (hasDynamicBearerAuth()) { + this.tokenProvider = new TokenProvider(this.axiosInstance); + } + this.setupToolHandlers(); this.setupResourceHandlers(); @@ -294,6 +410,8 @@ class RestTester { 'Bearer token authentication configured' : hasApiKeyAuth() ? `API Key using header: ${AUTH_APIKEY_HEADER_NAME}` : + hasDynamicBearerAuth() ? + 'Dynamic Bearer token authentication configured (module)' : 'No authentication configured' } | ${(() => { const customHeaders = getCustomHeaders(); @@ -412,6 +530,12 @@ class RestTester { ...config.headers, [AUTH_APIKEY_HEADER_NAME as string]: AUTH_APIKEY_VALUE }; + } else if (this.tokenProvider) { + const token = await this.tokenProvider.getToken(false); + config.headers = { + ...config.headers, + 'Authorization': `Bearer ${token}` + }; } try { @@ -424,6 +548,7 @@ class RestTester { if (hasBasicAuth()) authMethod = 'basic'; else if (hasBearerAuth()) authMethod = 'bearer'; else if (hasApiKeyAuth()) authMethod = 'apikey'; + else if (this.tokenProvider) authMethod = 'dynamic_bearer'; // Prepare response object const responseObj: ResponseObject = { diff --git a/src/resources/config.md b/src/resources/config.md index 8191d0e..2da0378 100644 --- a/src/resources/config.md +++ b/src/resources/config.md @@ -37,7 +37,7 @@ This document describes all available configuration options for the REST API tes ## Authentication Configuration -The tool supports three authentication methods. Configure one based on your API's requirements. +The tool supports four authentication methods. Configure one based on your API's requirements. ### Basic Authentication - REST_BASIC_USERNAME: Username for Basic Auth @@ -58,6 +58,42 @@ The tool supports three authentication methods. Configure one based on your API' ``` - Usage: When both are set, requests will include the specified header with the API key +### Dynamic Bearer Token (Module) + +If your API requires a custom flow to obtain a token, you can configure a local JavaScript module that exports an async function. + +- AUTH_TOKEN_MODULE: Path to a local JS module file (`.mjs`/`.js`) that default-exports a function. + - The server will `import()` this module at runtime. + - The exported function is called with `{ axios, env }`: + - `axios` is the **preconfigured Axios instance** used by the server (includes `baseURL` and SSL settings) + - `env` is `process.env` + - The function must return a **string token**. + +Token refresh behavior: +- If the token looks like a JWT and includes an `exp` claim, the server will refresh it automatically shortly before it expires. +- If no `exp` is present, the token is cached for the lifetime of the server process. + +Example module (`./get-token.mjs`): + +```js +export default async function getToken({ axios, env }) { + const res = await axios.post('/api/user/v1/auth/signin', { + email: env.AUTH_EMAIL, + password: env.AUTH_PASSWORD, + }); + return res.data.token; +} +``` + +Example configuration: + +```bash +REST_BASE_URL=http://localhost:8080 +AUTH_TOKEN_MODULE=./get-token.mjs +AUTH_EMAIL=xxx@yyy.com +AUTH_PASSWORD=password@ +``` + ## Configuration Examples ### Local Development From cac3b4356390cdce355082b1c19d409392d4bcd4 Mon Sep 17 00:00:00 2001 From: Stepan Prokipchyn Date: Fri, 9 Jan 2026 10:52:01 +0200 Subject: [PATCH 2/2] Extend dynamic Bearer token support with per-request options for custom authorization contexts. Problem: Many APIs and applications require multiple parallel sessions (e.g., multiple users, multiple roles). Current dynamic approach hardcodes single credentials pair for all requests. Solution: Allow LLM to provide MCP with override credentials. This allows having multiple users (in additional to default ones). --- README.md | 2 +- src/index.ts | 70 ++++++++++++++++++++++++++++++++--------- src/resources/config.md | 33 ++++++++++++++++--- 3 files changed, 85 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 9a3982d..2471dc1 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ Note: Replace the environment variables with your actual values. Only configure Dynamic Bearer Token (module): - `AUTH_TOKEN_MODULE`: path to a local JS module that default-exports an async function - - The server calls it with `{ axios, env }` +- The server calls it with `{ axios, env, options }` - The function must return the token string - If the returned token is a JWT with an `exp` claim, it is refreshed automatically shortly before it expires diff --git a/src/index.ts b/src/index.ts index d152f6c..0fb2105 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,8 @@ interface EndpointArgs { body?: any; headers?: Record; host?: string; + // Free-form per-request options. Passed through to AUTH_TOKEN_MODULE as ctx.options. + options?: Record; } interface ValidationResult { @@ -187,8 +189,18 @@ class TokenProvider { private token?: string; private tokenExpiresAt?: number; private inflight?: Promise; - private tokenFn?: (ctx: { axios: AxiosInstance; env: NodeJS.ProcessEnv }) => Promise | string; - private tokenFnInflight?: Promise<(ctx: { axios: AxiosInstance; env: NodeJS.ProcessEnv }) => Promise | string>; + private tokenFn?: (ctx: { + axios: AxiosInstance; + env: NodeJS.ProcessEnv; + options?: EndpointArgs['options']; + }) => Promise | string; + private tokenFnInflight?: Promise< + (ctx: { + axios: AxiosInstance; + env: NodeJS.ProcessEnv; + options?: EndpointArgs['options']; + }) => Promise | string + >; constructor(private axiosInstance: AxiosInstance) {} @@ -197,9 +209,19 @@ class TokenProvider { this.tokenExpiresAt = undefined; } - async getToken(forceRefresh: boolean = false): Promise { + async getToken( + forceRefresh: boolean = false, + options?: EndpointArgs['options'] + ): Promise { const now = Date.now(); + // If per-request options are provided, treat them as a unique auth context. + // To avoid cross-request/cross-user leakage, do not reuse cached/inflight tokens. + const hasRequestOptions = + !!options && + typeof options === 'object' && + Object.keys(options).length > 0; + // If we know the token's expiry, refresh slightly before it actually expires. // This avoids edge cases where token expires mid-request. const refreshSkewMs = 60_000; @@ -207,33 +229,42 @@ class TokenProvider { this.token && (this.tokenExpiresAt === undefined || now < this.tokenExpiresAt - refreshSkewMs); - if (!forceRefresh && isValid) { + // If per-request options are provided, never return a cached token. + if (!forceRefresh && !hasRequestOptions && isValid) { return this.token as string; } - if (!forceRefresh && this.inflight) { + // Avoid sharing inflight token acquisition across different per-request option contexts. + if (!forceRefresh && !hasRequestOptions && this.inflight) { return await this.inflight; } - this.inflight = this.acquireToken().finally(() => { - this.inflight = undefined; - }); + const tokenPromise = this.acquireToken(options); + if (!hasRequestOptions) { + this.inflight = tokenPromise.finally(() => { + this.inflight = undefined; + }); + } - const token = await this.inflight; - this.token = token; + const token = await tokenPromise; + if (!hasRequestOptions) { + this.token = token; + } const expMs = getJwtExpMs(token); - this.tokenExpiresAt = expMs; + if (!hasRequestOptions) { + this.tokenExpiresAt = expMs; + } return token; } - private async acquireToken(): Promise { + private async acquireToken(options?: EndpointArgs['options']): Promise { if (!AUTH_TOKEN_MODULE) { throw new Error('Dynamic auth is enabled but AUTH_TOKEN_MODULE is not set'); } const tokenFn = await this.getTokenFn(); - const token = await tokenFn({ axios: this.axiosInstance, env: process.env }); + const token = await tokenFn({ axios: this.axiosInstance, env: process.env, options }); const trimmed = String(token).trim(); if (!trimmed) { throw new Error('AUTH_TOKEN_MODULE function returned empty token'); @@ -242,7 +273,11 @@ class TokenProvider { } private async getTokenFn(): Promise< - (ctx: { axios: AxiosInstance; env: NodeJS.ProcessEnv }) => Promise | string + (ctx: { + axios: AxiosInstance; + env: NodeJS.ProcessEnv; + options?: EndpointArgs['options']; + }) => Promise | string > { if (this.tokenFn) return this.tokenFn; if (this.tokenFnInflight) return await this.tokenFnInflight; @@ -464,6 +499,11 @@ class RestTester { additionalProperties: { type: 'string' } + }, + options: { + type: 'object', + description: 'Optional per-request options passed through to the dynamic bearer token module (AUTH_TOKEN_MODULE) as ctx.options. This object is free-form; its meaning is defined by your token module.', + additionalProperties: true } }, required: ['method', 'endpoint'], @@ -531,7 +571,7 @@ class RestTester { [AUTH_APIKEY_HEADER_NAME as string]: AUTH_APIKEY_VALUE }; } else if (this.tokenProvider) { - const token = await this.tokenProvider.getToken(false); + const token = await this.tokenProvider.getToken(false, request.params.arguments.options); config.headers = { ...config.headers, 'Authorization': `Bearer ${token}` diff --git a/src/resources/config.md b/src/resources/config.md index 2da0378..16dd957 100644 --- a/src/resources/config.md +++ b/src/resources/config.md @@ -64,9 +64,10 @@ If your API requires a custom flow to obtain a token, you can configure a local - AUTH_TOKEN_MODULE: Path to a local JS module file (`.mjs`/`.js`) that default-exports a function. - The server will `import()` this module at runtime. - - The exported function is called with `{ axios, env }`: +- The exported function is called with `{ axios, env, options }`: - `axios` is the **preconfigured Axios instance** used by the server (includes `baseURL` and SSL settings) - `env` is `process.env` + - `options` is the per-request `options` object from `test_request` (optional) - The function must return a **string token**. Token refresh behavior: @@ -76,15 +77,39 @@ Token refresh behavior: Example module (`./get-token.mjs`): ```js -export default async function getToken({ axios, env }) { +export default async function getToken({ axios, env, options }) { const res = await axios.post('/api/user/v1/auth/signin', { - email: env.AUTH_EMAIL, - password: env.AUTH_PASSWORD, + email: options?.emailOverride ?? env.AUTH_EMAIL, + password: options?.passwordOverride ?? env.AUTH_PASSWORD, }); return res.data.token; } ``` +Per-request options (dynamic token module only): + +- `test_request.options` is a **free-form object**. +- It is passed only to the token module as `ctx.options` and is not added to the upstream request headers. +- When `options` is provided (non-empty), the server does not reuse cached/inflight tokens (to avoid mixing tokens across different option contexts). + +Example convention (implemented in `./get-token.mjs` below): + +- `options.emailOverride` +- `options.passwordOverride` + +Example tool call: + +```typescript +use_mcp_tool('rest-api', 'test_request', { + method: 'GET', + endpoint: '/users', + options: { + emailOverride: 'user1@example.com', + passwordOverride: 'secret' + } +}); +``` + Example configuration: ```bash