Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
165 changes: 165 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -37,6 +39,8 @@ interface EndpointArgs {
body?: any;
headers?: Record<string, string>;
host?: string;
// Free-form per-request options. Passed through to AUTH_TOKEN_MODULE as ctx.options.
options?: Record<string, any>;
}

interface ValidationResult {
Expand Down Expand Up @@ -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<string>;
private tokenFn?: (ctx: {
axios: AxiosInstance;
env: NodeJS.ProcessEnv;
options?: EndpointArgs['options'];
}) => Promise<string> | string;
private tokenFnInflight?: Promise<
(ctx: {
axios: AxiosInstance;
env: NodeJS.ProcessEnv;
options?: EndpointArgs['options'];
}) => Promise<string> | string
>;

constructor(private axiosInstance: AxiosInstance) {}

invalidate() {
this.token = undefined;
this.tokenExpiresAt = undefined;
}

async getToken(
forceRefresh: boolean = false,
options?: EndpointArgs['options']
): Promise<string> {
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<string> {
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> | 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<string, string> => {
Expand All @@ -179,6 +325,7 @@ const getCustomHeaders = (): Record<string, string> => {
class RestTester {
private server!: Server;
private axiosInstance!: AxiosInstance;
private tokenProvider?: TokenProvider;

constructor() {
this.setupServer();
Expand Down Expand Up @@ -207,6 +354,10 @@ class RestTester {
})
});

if (hasDynamicBearerAuth()) {
this.tokenProvider = new TokenProvider(this.axiosInstance);
}

this.setupToolHandlers();
this.setupResourceHandlers();

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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 {
Expand All @@ -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 = {
Expand Down
63 changes: 62 additions & 1 deletion src/resources/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down