diff --git a/README.md b/README.md index ec33a81..2471dc1 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, 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 ## 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..0fb2105 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 { @@ -37,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 { @@ -159,6 +163,148 @@ 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; + options?: EndpointArgs['options']; + }) => Promise | string; + private tokenFnInflight?: Promise< + (ctx: { + axios: AxiosInstance; + env: NodeJS.ProcessEnv; + options?: EndpointArgs['options']; + }) => Promise | string + >; + + constructor(private axiosInstance: AxiosInstance) {} + + invalidate() { + this.token = undefined; + this.tokenExpiresAt = undefined; + } + + 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; + const isValid = + this.token && + (this.tokenExpiresAt === undefined || now < this.tokenExpiresAt - refreshSkewMs); + + // If per-request options are provided, never return a cached token. + if (!forceRefresh && !hasRequestOptions && isValid) { + return this.token as string; + } + + // Avoid sharing inflight token acquisition across different per-request option contexts. + if (!forceRefresh && !hasRequestOptions && this.inflight) { + return await this.inflight; + } + + const tokenPromise = this.acquireToken(options); + if (!hasRequestOptions) { + this.inflight = tokenPromise.finally(() => { + this.inflight = undefined; + }); + } + + const token = await tokenPromise; + if (!hasRequestOptions) { + this.token = token; + } + + const expMs = getJwtExpMs(token); + if (!hasRequestOptions) { + this.tokenExpiresAt = expMs; + } + return token; + } + + 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, options }); + 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; + options?: EndpointArgs['options']; + }) => 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 +325,7 @@ const getCustomHeaders = (): Record => { class RestTester { private server!: Server; private axiosInstance!: AxiosInstance; + private tokenProvider?: TokenProvider; constructor() { this.setupServer(); @@ -207,6 +354,10 @@ class RestTester { }) }); + if (hasDynamicBearerAuth()) { + this.tokenProvider = new TokenProvider(this.axiosInstance); + } + this.setupToolHandlers(); this.setupResourceHandlers(); @@ -294,6 +445,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(); @@ -346,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'], @@ -412,6 +570,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, request.params.arguments.options); + config.headers = { + ...config.headers, + 'Authorization': `Bearer ${token}` + }; } try { @@ -424,6 +588,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..16dd957 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,67 @@ 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, 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: +- 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, options }) { + const res = await axios.post('/api/user/v1/auth/signin', { + 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 +REST_BASE_URL=http://localhost:8080 +AUTH_TOKEN_MODULE=./get-token.mjs +AUTH_EMAIL=xxx@yyy.com +AUTH_PASSWORD=password@ +``` + ## Configuration Examples ### Local Development