import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import axios from "axios";
import {
Collection,
Request as PostmanRequest,
Item,
ItemGroup,
} from "postman-collection";
import { Request, Response } from "express";
import { AuthConfig, ToolInput } from "./types.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
let transport: SSEServerTransport | null = null;
export class SimplePostmanMcpServer {
private mcpServer: McpServer;
private collection: Collection | null = null;
private environment: Record<string, any> = {};
private defaultAuth: AuthConfig | undefined;
private requests: Array<{
id: string;
name: string;
method: string;
url: string;
description: string;
folder: string;
request: PostmanRequest;
}> = [];
constructor(defaultAuth?: AuthConfig) {
if (process.env.NODE_ENV !== "production") {
console.debug("SimplePostmanMcpServer constructor", defaultAuth);
}
this.defaultAuth = defaultAuth;
this.mcpServer = new McpServer({
name: "Simple Postman Collection MCP Server",
version: "1.0.0",
});
}
private getAuthHeaders(auth?: AuthConfig): Record<string, string> {
const authConfig = auth || this.defaultAuth;
if (!authConfig) return {};
switch (authConfig.type) {
case "basic":
if (authConfig.username && authConfig.password) {
const credentials = Buffer.from(
`${authConfig.username}:${authConfig.password}`
).toString("base64");
return { Authorization: `Basic ${credentials}` };
}
break;
case "bearer":
if (authConfig.token) {
return { Authorization: `Bearer ${authConfig.token}` };
}
break;
case "apiKey":
if (
authConfig.apiKey &&
authConfig.apiKeyName &&
authConfig.apiKeyIn === "header"
) {
return { [authConfig.apiKeyName]: authConfig.apiKey };
}
break;
case "oauth2":
if (authConfig.token) {
return { Authorization: `Bearer ${authConfig.token}` };
}
break;
}
return {};
}
private getAuthQueryParams(auth?: AuthConfig): Record<string, string> {
const authConfig = auth || this.defaultAuth;
if (!authConfig) return {};
if (
authConfig.type === "apiKey" &&
authConfig.apiKey &&
authConfig.apiKeyName &&
authConfig.apiKeyIn === "query"
) {
return { [authConfig.apiKeyName]: authConfig.apiKey };
}
return {};
}
private createAuthSchema(): z.ZodType<any> {
return z
.object({
type: z
.enum(["none", "basic", "bearer", "apiKey", "oauth2"])
.default("none"),
username: z.string().optional(),
password: z.string().optional(),
token: z.string().optional(),
apiKey: z.string().optional(),
apiKeyName: z.string().optional(),
apiKeyIn: z.enum(["header", "query"]).optional(),
})
.describe("Authentication configuration for the request");
}
async loadCollection(collectionUrlOrFile: string) {
if (process.env.NODE_ENV !== "production") {
console.debug("Loading Postman collection from:", collectionUrlOrFile);
}
try {
let collectionData: any;
if (collectionUrlOrFile.startsWith("http")) {
const response = await axios.get(collectionUrlOrFile);
collectionData = response.data;
} else {
const fs = await import("fs/promises");
const fileContent = await fs.readFile(collectionUrlOrFile, "utf-8");
collectionData = JSON.parse(fileContent);
}
this.collection = new Collection(collectionData);
// Get collection info safely
const info = {
name:
(this.collection as any).name ||
collectionData.info?.name ||
"Postman Collection",
description:
(this.collection as any).description ||
collectionData.info?.description ||
"",
version:
(this.collection as any).version ||
collectionData.info?.version ||
"1.0.0",
};
if (process.env.NODE_ENV !== "production") {
console.debug("Loaded Postman collection:", {
name: info.name,
description:
typeof info.description === "string"
? info.description.substring(0, 100) + "..."
: "",
});
}
// Update server name with collection info
this.mcpServer = new McpServer({
name:
`${info.name} - Simple Explorer` ||
"Simple Postman Collection Server",
version: info.version || "1.0.0",
description: `Simplified explorer for ${info.name}` || undefined,
});
// Parse all requests for the strategic tools
this.parseAllRequests();
await this.registerStrategicTools();
} catch (error) {
console.error("Failed to load Postman collection:", error);
throw error;
}
}
async loadEnvironment(environmentUrlOrFile: string) {
if (process.env.NODE_ENV !== "production") {
console.debug("Loading Postman environment from:", environmentUrlOrFile);
}
try {
let environmentData: any;
if (environmentUrlOrFile.startsWith("http")) {
const response = await axios.get(environmentUrlOrFile);
environmentData = response.data;
} else {
const fs = await import("fs/promises");
const fileContent = await fs.readFile(environmentUrlOrFile, "utf-8");
environmentData = JSON.parse(fileContent);
}
// Parse environment variables
if (environmentData.values) {
for (const variable of environmentData.values) {
this.environment[variable.key] = variable.value;
}
}
if (process.env.NODE_ENV !== "production") {
console.debug(
"Loaded environment variables:",
Object.keys(this.environment)
);
}
} catch (error) {
console.error("Failed to load Postman environment:", error);
throw error;
}
}
private parseAllRequests() {
if (!this.collection) return;
this.requests = [];
const parseItem = (
item: Item | ItemGroup<Item>,
folderPath: string = ""
) => {
if (item instanceof Item && item.request) {
const request = item.request;
// Handle description safely
let description = "";
if (item.request.description) {
if (typeof item.request.description === "string") {
description = item.request.description;
} else if (
typeof item.request.description === "object" &&
"content" in item.request.description
) {
description = (item.request.description as any).content;
}
}
this.requests.push({
id: `${folderPath}${item.name}`
.replace(/[^a-zA-Z0-9_]/g, "_")
.toLowerCase(),
name: item.name || "Unnamed Request",
method: request.method || "GET",
url: request.url?.toString() || "",
description,
folder: folderPath,
request,
});
} else if (item instanceof ItemGroup) {
// Handle folders recursively
const newFolderPath = folderPath
? `${folderPath}/${item.name}`
: item.name;
item.items.each((subItem: Item | ItemGroup<Item>) => {
parseItem(subItem, newFolderPath);
});
}
};
// Parse all items
this.collection.items.each((item: Item | ItemGroup<Item>) => {
parseItem(item);
});
console.log(
`✅ Parsed ${this.requests.length} requests from Postman collection`
);
}
private resolveVariables(text: string): string {
if (!text) return text;
// Replace {{variableName}} with actual values
return text.replace(/\{\{(\w+)\}\}/g, (match, variableName) => {
return this.environment[variableName] || match;
});
}
private async registerStrategicTools() {
// Tool 1: List all requests
this.mcpServer.tool(
"list_requests",
"List all available requests in the Postman collection with basic information",
{
input: z.object({
method: z
.string()
.optional()
.describe("Filter by HTTP method (GET, POST, PUT, DELETE, etc.)"),
folder: z.string().optional().describe("Filter by folder/path"),
limit: z
.number()
.optional()
.default(50)
.describe("Maximum number of requests to return"),
}),
},
async ({ input }) => {
let filteredRequests = this.requests;
// Apply filters
if (input.method) {
filteredRequests = filteredRequests.filter(
(req) => req.method.toLowerCase() === input.method!.toLowerCase()
);
}
if (input.folder) {
filteredRequests = filteredRequests.filter((req) =>
req.folder.toLowerCase().includes(input.folder!.toLowerCase())
);
}
// Limit results
const limitedRequests = filteredRequests.slice(0, input.limit);
return {
content: [
{
type: "text",
text: JSON.stringify(
{
total: limitedRequests.length,
requests: limitedRequests.map((req) => ({
id: req.id,
name: req.name,
method: req.method,
url: req.url,
folder: req.folder,
description:
req.description.substring(0, 100) +
(req.description.length > 100 ? "..." : ""),
})),
},
null,
2
),
},
],
};
}
);
// Tool 2: Get detailed information about a specific request
this.mcpServer.tool(
"get_request_details",
"Get detailed information about a specific request including parameters, headers, and body structure",
{
input: z.object({
requestId: z.string().describe("The ID of the request"),
name: z
.string()
.optional()
.describe("The name of the request (alternative to ID)"),
}),
},
async ({ input }) => {
let targetRequest = null;
// Find the request by ID or name
for (const req of this.requests) {
if (
req.id === input.requestId ||
req.name.toLowerCase() === input.name?.toLowerCase()
) {
targetRequest = req;
break;
}
}
if (!targetRequest) {
return {
content: [
{
type: "text",
text: `Request not found. Use list_requests to see available requests.`,
},
],
};
}
const request = targetRequest.request;
// Extract parameters information
const parameters = {
query: [] as any[],
path: [] as any[],
headers: [] as any[],
};
// Query parameters
if (request.url && request.url.query) {
request.url.query.each((param: any) => {
if (param.key && !param.disabled) {
parameters.query.push({
name: param.key,
value: param.value || "",
description: param.description || "",
});
}
});
}
// Path variables
if (request.url && request.url.variables) {
request.url.variables.each((variable: any) => {
if (variable.key) {
parameters.path.push({
name: variable.key,
value: variable.value || "",
description: variable.description || "",
});
}
});
}
// Headers
if (request.headers) {
request.headers.each((header: any) => {
if (header.key && !header.disabled) {
parameters.headers.push({
name: header.key,
value: header.value || "",
description: header.description || "",
});
}
});
}
// Request body info
let bodyInfo: any = null;
if (
request.body &&
["POST", "PUT", "PATCH"].includes(request.method || "")
) {
bodyInfo = {
mode: request.body.mode,
description: "Request body based on the collection definition",
} as any;
if (request.body.mode === "raw") {
bodyInfo.example = request.body.raw || "";
} else if (request.body.mode === "formdata") {
bodyInfo.formFields = [];
if (request.body.formdata) {
request.body.formdata.each((field: any) => {
if (field.key && !field.disabled) {
bodyInfo.formFields.push({
name: field.key,
type: field.type || "text",
value: field.value || "",
description: field.description || "",
});
}
});
}
}
}
return {
content: [
{
type: "text",
text: JSON.stringify(
{
id: targetRequest.id,
name: targetRequest.name,
method: targetRequest.method,
url: targetRequest.url,
folder: targetRequest.folder,
description: targetRequest.description,
parameters,
body: bodyInfo,
auth: "Use the auth parameter in make_request to provide authentication",
},
null,
2
),
},
],
};
}
);
// Tool 3: Search requests by keyword
this.mcpServer.tool(
"search_requests",
"Search requests by keyword in name, description, URL, or folder",
{
input: z.object({
query: z
.string()
.describe(
"Search term to look for in request names, descriptions, URLs, or folders"
),
limit: z
.number()
.optional()
.default(20)
.describe("Maximum number of results to return"),
}),
},
async ({ input }) => {
const query = input.query.toLowerCase();
const results = [];
for (const req of this.requests) {
const searchText = [
req.name,
req.description,
req.url,
req.folder,
req.method,
]
.join(" ")
.toLowerCase();
if (searchText.includes(query)) {
results.push({
id: req.id,
name: req.name,
method: req.method,
url: req.url,
folder: req.folder,
description:
req.description.substring(0, 100) +
(req.description.length > 100 ? "..." : ""),
relevance: this.calculateRelevance(query, searchText),
});
if (results.length >= input.limit) break;
}
}
// Sort by relevance
results.sort((a, b) => b.relevance - a.relevance);
return {
content: [
{
type: "text",
text: JSON.stringify(
{
query: input.query,
total: results.length,
results: results.map((r) => ({ ...r, relevance: undefined })), // Remove relevance from output
},
null,
2
),
},
],
};
}
);
// Tool 4: Make request
this.mcpServer.tool(
"make_request",
"Execute any request from the Postman collection with the specified parameters and authentication",
{
input: z.object({
requestId: z.string().optional().describe("The ID of the request"),
name: z
.string()
.optional()
.describe("The name of the request (alternative to ID)"),
parameters: z
.record(z.any())
.optional()
.describe(
"Query parameters, path parameters, headers, or form data"
),
body: z
.any()
.optional()
.describe("Request body (for POST, PUT, PATCH requests)"),
auth: this.createAuthSchema()
.optional()
.describe("Authentication configuration"),
}),
},
async ({ input }) => {
// Find the request
let targetRequest = null;
for (const req of this.requests) {
if (
req.id === input.requestId ||
req.name.toLowerCase() === input.name?.toLowerCase()
) {
targetRequest = req;
break;
}
}
if (!targetRequest) {
return {
content: [
{
type: "text",
text: `Request not found. Use list_requests to see available requests.`,
},
],
};
}
try {
const result = await this.executeRequest(
targetRequest.request,
input.parameters || {},
input.body,
input.auth
);
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error: ${
error instanceof Error ? error.message : String(error)
}`,
},
],
};
}
}
);
console.log(
"✅ Successfully registered 4 strategic tools for Postman collection exploration"
);
}
private calculateRelevance(query: string, text: string): number {
const queryWords = query.split(" ");
let score = 0;
queryWords.forEach((word) => {
if (text.includes(word)) {
score += 1;
// Bonus for exact word matches
if (
text.includes(` ${word} `) ||
text.startsWith(word) ||
text.endsWith(word)
) {
score += 0.5;
}
}
});
return score;
}
private async executeRequest(
request: PostmanRequest,
parameters: Record<string, any>,
body?: any,
auth?: AuthConfig
): Promise<any> {
// Build URL
let url = request.url?.toString() || "";
url = this.resolveVariables(url);
// Replace path variables
Object.keys(parameters).forEach((key) => {
const value = parameters[key];
if (value !== undefined) {
// Try different variable formats
url = url.replace(`:${key}`, encodeURIComponent(String(value)));
url = url.replace(`{{${key}}}`, encodeURIComponent(String(value)));
url = url.replace(`{${key}}`, encodeURIComponent(String(value)));
}
});
// Build query parameters
const queryParams = this.getAuthQueryParams(auth);
Object.keys(parameters).forEach((key) => {
const value = parameters[key];
if (
value !== undefined &&
!url.includes(`:${key}`) &&
!url.includes(`{{${key}}}`)
) {
queryParams[key] = value;
}
});
// Build headers
const headers = this.getAuthHeaders(auth);
// Add any headers from parameters
Object.keys(parameters).forEach((key) => {
const value = parameters[key];
if (value !== undefined && key.toLowerCase().includes("header")) {
headers[key] = value;
}
});
// Add default content type for requests with body
if (body && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json";
}
// Prepare request configuration
const config: any = {
method: request.method || "GET",
url,
headers,
params: queryParams,
};
// Add body if present
if (body) {
if (typeof body === "string") {
config.data = body;
} else {
config.data = JSON.stringify(body);
}
}
if (process.env.NODE_ENV !== "production") {
console.debug("Executing request:", {
method: config.method,
url: config.url,
headers: Object.keys(config.headers),
hasBody: !!config.data,
});
}
try {
const response = await axios(config);
return {
status: response.status,
statusText: response.statusText,
headers: response.headers,
data: response.data,
};
} catch (error: any) {
if (error.response) {
return {
status: error.response.status,
statusText: error.response.statusText,
headers: error.response.headers,
data: error.response.data,
error: true,
};
}
throw error;
}
}
getServer() {
return this.mcpServer;
}
handleSSE(res: Response) {
if (!transport) {
transport = new SSEServerTransport("/messages", res);
}
this.mcpServer.connect(transport);
}
handleMessage(req: Request, res: Response) {
this.mcpServer.connect(transport!);
}
}