import postmanCollection from 'postman-collection';
import type {
Collection as CollectionType,
Item as ItemType,
ItemGroup as ItemGroupType,
Header as HeaderType,
CollectionDefinition,
} from 'postman-collection';
// Handle CommonJS module interop
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */
const postmanLib = postmanCollection as any;
const Collection = postmanLib.Collection as typeof CollectionType;
const Item = postmanLib.Item as typeof ItemType & { isItem(obj: unknown): obj is ItemType };
const ItemGroup = postmanLib.ItemGroup as typeof ItemGroupType & { isItemGroup(obj: unknown): obj is ItemGroupType<ItemType> };
/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */
import { readFile } from 'node:fs/promises';
import type {
PostmanCollectionDefinition,
PostmanUrlDefinition,
PostmanBodyDefinition,
PostmanQueryParamDefinition,
PostmanHeaderDefinition,
ParsedPostmanItem,
PostmanAuthDefinition,
} from '../types/postman.js';
import type { NormalizedOperation, NormalizedParameter, NormalizedRequestBody, HttpMethod } from '../types/index.js';
import type { JSONSchema } from '../types/mcp-tool.js';
import { log } from '../utils/logger.js';
/**
* Valid HTTP methods
*/
const VALID_METHODS = new Set(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']);
/**
* Parse Postman collection from URL
*/
export async function parsePostmanFromUrl(url: string): Promise<NormalizedOperation[]> {
log.info('Fetching Postman collection from URL', { url });
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch Postman collection: ${response.status} ${response.statusText}`);
}
const content = await response.text();
return parsePostmanContent(content);
}
/**
* Parse Postman collection from file
*/
export async function parsePostmanFromFile(filePath: string): Promise<NormalizedOperation[]> {
log.info('Loading Postman collection from file', { file: filePath });
const content = await readFile(filePath, 'utf-8');
return parsePostmanContent(content);
}
/**
* Parse Postman collection from inline content
*/
export function parsePostmanContent(content: string): NormalizedOperation[] {
let parsed: PostmanCollectionDefinition;
try {
parsed = JSON.parse(content) as PostmanCollectionDefinition;
} catch {
throw new Error('Failed to parse Postman collection as JSON');
}
// Validate it's a Postman collection (support both v2.0 and v2.1 schema formats)
const schemaField = parsed.info?.schema || (parsed.info as { _postman_schema?: string })?._postman_schema;
if (!schemaField || (!schemaField.includes('postman') && !schemaField.includes('getpostman'))) {
throw new Error('Invalid Postman collection: missing or invalid schema');
}
// Use postman-collection SDK to parse
// Cast to CollectionDefinition to satisfy SDK types
const collection = new Collection(parsed as unknown as CollectionDefinition);
// Extract all items recursively
const parsedItems = extractItems(collection.items, []);
// Convert to normalized operations
const operations = parsedItems.map(itemToOperation);
log.info('Extracted operations from Postman collection', { count: operations.length });
return operations;
}
/**
* Parse and extract operations from Postman collection
*/
export async function parsePostman(
source: { url?: string; file?: string; inline?: string }
): Promise<NormalizedOperation[]> {
if (source.url) {
return parsePostmanFromUrl(source.url);
} else if (source.file) {
return parsePostmanFromFile(source.file);
} else if (source.inline) {
return parsePostmanContent(source.inline);
} else {
throw new Error('No Postman collection source provided');
}
}
/**
* Recursively extract items from collection
*/
function extractItems(
items: CollectionType['items'],
parentTags: string[]
): ParsedPostmanItem[] {
const result: ParsedPostmanItem[] = [];
items.each((item) => {
if (ItemGroup.isItemGroup(item)) {
// This is a folder - recurse with folder name as tag
const folderName = item.name || 'Unnamed';
const itemGroup = item as ItemGroupType<ItemType>;
const folderItems = extractItems(itemGroup.items, [...parentTags, folderName]);
result.push(...folderItems);
} else if (Item.isItem(item)) {
const requestItem = item as ItemType;
if (requestItem.request) {
// This is a request item
const parsed = parseItem(requestItem, parentTags);
if (parsed) {
result.push(parsed);
}
}
}
});
return result;
}
/**
* Parse a single Postman item into our intermediate format
*/
function parseItem(item: ItemType, tags: string[]): ParsedPostmanItem | null {
const request = item.request;
if (!request) return null;
const method = (request.method || 'GET').toUpperCase();
if (!VALID_METHODS.has(method)) {
log.warn('Skipping item with invalid method', { name: item.name, method });
return null;
}
// Extract URL components
const { path, pathVariables, queryParams } = extractUrl(request.url);
// Extract headers
const headers: PostmanHeaderDefinition[] = [];
if (request.headers) {
request.headers.each((header: HeaderType) => {
if (!header.disabled) {
headers.push({
key: header.key,
value: header.value,
description: typeof header.description === 'string' ? header.description : undefined,
});
}
});
}
// Extract body
const body = extractBody(request.body);
// Get description - handle both string and object with content property
const getDescriptionString = (desc: unknown): string | undefined => {
if (typeof desc === 'string') return desc;
if (desc && typeof desc === 'object') {
// Postman description can be { content: string, type: string }
const descObj = desc as { content?: string };
if (typeof descObj.content === 'string') return descObj.content;
}
return undefined;
};
const description = getDescriptionString(request.description) || getDescriptionString(item.description);
// Extract auth - safely handle the toJSON result
let auth: PostmanAuthDefinition | undefined;
if (request.auth) {
const authJson = request.auth.toJSON();
if (authJson && typeof authJson.type === 'string') {
auth = authJson as PostmanAuthDefinition;
}
}
return {
name: item.name || 'Unnamed Request',
description,
method,
path,
pathVariables,
queryParams,
headers,
body,
tags,
auth,
};
}
/**
* Extract URL path and variables from Postman URL
*/
function extractUrl(url: unknown): {
path: string;
pathVariables: string[];
queryParams: PostmanQueryParamDefinition[];
} {
// Handle string URL
if (typeof url === 'string') {
return parseUrlString(url);
}
// Handle URL object (from SDK or raw definition)
// First check if it has a getPath method (SDK Url object)
if (url && typeof url === 'object' && 'getPath' in url) {
const urlObj = url as {
getPath: (options?: { unresolved?: boolean }) => string;
path?: { all: () => Array<{ value: string }> };
query?: { all: () => Array<{ key: string; value?: string; description?: { toString: () => string }; disabled?: boolean }> };
variables?: { all: () => Array<{ key: string }> }
};
// Use unresolved path to get :param format instead of substituted values
const pathStr = urlObj.getPath({ unresolved: true });
const pathVariables: string[] = [];
// Normalize path variables from :param to {param}
const normalizedPath = pathStr.split('/').map((segment: string) => {
if (segment.startsWith(':')) {
const varName = segment.slice(1);
pathVariables.push(varName);
return `{${varName}}`;
}
return segment;
}).join('/');
// Get path variables from SDK
if (urlObj.variables) {
urlObj.variables.all().forEach((v: { key: string }) => {
if (!pathVariables.includes(v.key)) {
pathVariables.push(v.key);
}
});
}
// Extract query params
const queryParams: PostmanQueryParamDefinition[] = [];
if (urlObj.query) {
urlObj.query.all().forEach((q: { key: string; value?: string; description?: { toString: () => string }; disabled?: boolean }) => {
if (!q.disabled) {
queryParams.push({
key: q.key,
value: q.value,
description: q.description?.toString(),
});
}
});
}
return { path: normalizedPath || '/', pathVariables, queryParams };
}
// Handle raw URL definition object
const urlDef = url as PostmanUrlDefinition;
// Get path segments
let pathSegments: string[] = [];
if (Array.isArray(urlDef.path)) {
pathSegments = urlDef.path;
} else if (typeof urlDef.path === 'string') {
pathSegments = urlDef.path.split('/').filter(Boolean);
}
// Convert :param to {param} format
const pathVariables: string[] = [];
const normalizedPath = '/' + pathSegments.map((segment) => {
// Handle :param format
if (segment.startsWith(':')) {
const varName = segment.slice(1);
pathVariables.push(varName);
return `{${varName}}`;
}
return segment;
}).join('/');
// Also check urlDef.variable for path variables
if (urlDef.variable) {
for (const v of urlDef.variable) {
if (!pathVariables.includes(v.key)) {
pathVariables.push(v.key);
}
}
}
// Extract query params
const queryParams: PostmanQueryParamDefinition[] = [];
if (urlDef.query) {
for (const q of urlDef.query) {
if (!q.disabled) {
queryParams.push({
key: q.key,
value: q.value,
description: q.description,
});
}
}
}
return { path: normalizedPath || '/', pathVariables, queryParams };
}
/**
* Parse URL string to extract path, variables, and query params
*/
function parseUrlString(url: string): {
path: string;
pathVariables: string[];
queryParams: PostmanQueryParamDefinition[];
} {
// Remove protocol and host if present
let path = url;
try {
const parsed = new URL(url);
path = parsed.pathname + parsed.search;
} catch {
// URL is relative or invalid, use as-is
// Remove host-like prefix if present
const hostMatch = path.match(/^https?:\/\/[^/]+/);
if (hostMatch) {
path = path.slice(hostMatch[0].length);
}
}
// Split path and query string
const [pathPart, queryPart] = path.split('?');
// Parse path variables (:param format)
const pathVariables: string[] = [];
const normalizedPath = pathPart.split('/').map((segment) => {
if (segment.startsWith(':')) {
const varName = segment.slice(1);
pathVariables.push(varName);
return `{${varName}}`;
}
// Also handle {{variable}} Postman variable format
const match = segment.match(/^\{\{(\w+)\}\}$/);
if (match) {
pathVariables.push(match[1]);
return `{${match[1]}}`;
}
return segment;
}).join('/');
// Parse query params
const queryParams: PostmanQueryParamDefinition[] = [];
if (queryPart) {
const searchParams = new URLSearchParams(queryPart);
searchParams.forEach((value, key) => {
queryParams.push({ key, value });
});
}
return {
path: normalizedPath || '/',
pathVariables,
queryParams,
};
}
/**
* Extract body definition from Postman request body
*/
function extractBody(body: unknown): PostmanBodyDefinition | undefined {
if (!body) return undefined;
const bodyObj = body as { mode?: string; raw?: string; formdata?: unknown[]; urlencoded?: unknown[]; toJSON?: () => PostmanBodyDefinition };
// Use toJSON if available for clean export
if (typeof bodyObj.toJSON === 'function') {
return bodyObj.toJSON();
}
return {
mode: bodyObj.mode as PostmanBodyDefinition['mode'],
raw: bodyObj.raw,
};
}
/**
* Generate operationId from item name
* For Postman collections, the name usually already describes the action,
* so we don't prefix with HTTP method to avoid duplication like "get_get_users"
*/
function generateOperationId(name: string, _method: string): string {
// Convert name to snake_case operation ID
const cleanName = name
.toLowerCase()
.replace(/[^a-z0-9\s]/g, '') // Remove special chars
.replace(/\s+/g, '_') // Spaces to underscores
.replace(/_+/g, '_') // Collapse underscores
.replace(/^_|_$/g, ''); // Trim underscores
return cleanName;
}
/**
* Convert parsed Postman item to NormalizedOperation
*/
function itemToOperation(item: ParsedPostmanItem): NormalizedOperation {
const parameters: NormalizedParameter[] = [];
// Add path parameters
for (const varName of item.pathVariables) {
parameters.push({
name: varName,
in: 'path',
required: true,
description: `Path parameter: ${varName}`,
schema: { type: 'string' },
});
}
// Add query parameters
for (const query of item.queryParams) {
parameters.push({
name: query.key,
in: 'query',
required: false,
description: query.description || `Query parameter: ${query.key}`,
schema: { type: 'string' },
example: query.value,
});
}
// Add non-standard headers (exclude common headers)
const excludedHeaders = new Set([
'content-type', 'accept', 'authorization', 'user-agent',
'host', 'connection', 'cache-control', 'accept-encoding',
]);
for (const header of item.headers) {
if (!excludedHeaders.has(header.key.toLowerCase())) {
parameters.push({
name: header.key,
in: 'header',
required: false,
description: header.description || `Header: ${header.key}`,
schema: { type: 'string' },
example: header.value,
});
}
}
// Extract request body
const requestBody = extractRequestBody(item.body);
return {
operationId: generateOperationId(item.name, item.method),
method: item.method as HttpMethod,
path: item.path,
summary: item.name,
description: item.description,
parameters,
requestBody,
tags: item.tags,
deprecated: false,
security: [],
};
}
/**
* Extract request body from Postman body definition
*/
function extractRequestBody(body: PostmanBodyDefinition | undefined): NormalizedRequestBody | undefined {
if (!body || !body.mode) return undefined;
let contentType = 'application/json';
let schema: JSONSchema = { type: 'object' };
switch (body.mode) {
case 'raw':
// Try to parse raw body as JSON for schema hints
if (body.raw) {
try {
const parsed: unknown = JSON.parse(body.raw);
schema = inferSchemaFromValue(parsed);
} catch {
// Not JSON, use generic string
schema = { type: 'string' };
contentType = 'text/plain';
}
}
// Check language option for content type
if (body.options?.raw?.language === 'xml') {
contentType = 'application/xml';
}
break;
case 'formdata':
contentType = 'multipart/form-data';
schema = buildFormSchema(body.formdata || []);
break;
case 'urlencoded':
contentType = 'application/x-www-form-urlencoded';
schema = buildFormSchema(body.urlencoded || []);
break;
case 'file':
contentType = 'application/octet-stream';
schema = { type: 'string', format: 'binary' };
break;
default:
return undefined;
}
return {
required: true,
description: 'Request body',
contentType,
schema,
};
}
/**
* Build JSON schema from form parameters
*/
function buildFormSchema(params: Array<{ key: string; value?: string; description?: string; type?: string }>): JSONSchema {
const properties: Record<string, JSONSchema> = {};
for (const param of params) {
properties[param.key] = {
type: 'string',
description: param.description,
format: param.type === 'file' ? 'binary' : undefined,
};
}
return {
type: 'object',
properties,
};
}
/**
* Infer JSON schema from a sample value
*/
function inferSchemaFromValue(value: unknown): JSONSchema {
if (value === null) {
return { type: 'null' };
}
if (Array.isArray(value)) {
if (value.length > 0) {
return {
type: 'array',
items: inferSchemaFromValue(value[0]),
};
}
return { type: 'array' };
}
switch (typeof value) {
case 'string':
return { type: 'string' };
case 'number':
return Number.isInteger(value) ? { type: 'integer' } : { type: 'number' };
case 'boolean':
return { type: 'boolean' };
case 'object': {
const properties: Record<string, JSONSchema> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
properties[key] = inferSchemaFromValue(val);
}
return {
type: 'object',
properties,
};
}
default:
return { type: 'object' };
}
}