Skip to main content
Glama
postman.ts17.3 kB
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' }; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/procoders/openapi-mcp-ts'

If you have feedback or need assistance with the MCP directory API, please join our Discord server