openapi-explorer-mcp 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ import { classifyDanger } from './risk.js';
2
+ const METHODS = ['get', 'post', 'put', 'patch', 'delete'];
3
+ const NAMESPACE_SEGMENT = /^(admin|internal|api|v\d+)$/i;
4
+ /**
5
+ * Browsing group: the first path segment, or the first two when the first is a namespace like admin or v1.
6
+ */
7
+ function groupOf(path) {
8
+ const parts = path.split('/').filter(Boolean);
9
+ if (parts.length === 0)
10
+ return '(root)';
11
+ return NAMESPACE_SEGMENT.test(parts[0]) && parts[1] ? `${parts[0]}/${parts[1]}` : parts[0];
12
+ }
13
+ /**
14
+ * Documented success response: 200, then 201, then any 2xx, then default.
15
+ */
16
+ function successResponse(responses = {}) {
17
+ const statuses = ['200', '201', ...Object.keys(responses).filter((s) => /^2\d\d$/.test(s) && s !== '200' && s !== '201'), 'default'];
18
+ for (const status of statuses) {
19
+ const schema = responses[status]?.content?.['application/json']?.schema;
20
+ if (schema)
21
+ return { status, schema };
22
+ }
23
+ return null;
24
+ }
25
+ /**
26
+ * Builds the searchable index of a spec.
27
+ */
28
+ export function buildIndex(spec, rules) {
29
+ const operations = [];
30
+ const byKey = new Map();
31
+ const byOperationId = new Map();
32
+ const schemaUsedBy = new Map();
33
+ const globalSecurity = spec.security;
34
+ for (const [path, item] of Object.entries(spec.paths ?? {})) {
35
+ for (const lower of METHODS) {
36
+ const op = item[lower];
37
+ if (!op)
38
+ continue;
39
+ const method = lower.toUpperCase();
40
+ const key = `${method} ${path}`;
41
+ const params = { path: [], query: [], header: [], cookie: [] };
42
+ for (const param of [...(item.parameters ?? []), ...(op.parameters ?? [])]) {
43
+ (params[param.in] ??= []).push(param);
44
+ }
45
+ // Operation-level security overrides the global one; an empty requirement object means anonymous access.
46
+ const security = (op.security ?? globalSecurity ?? []).map((requirement) => Object.keys(requirement));
47
+ const response = successResponse(op.responses);
48
+ const verdict = classifyDanger(rules, method, path);
49
+ const record = {
50
+ key,
51
+ method,
52
+ path,
53
+ operationId: op.operationId,
54
+ summary: op.summary ?? '',
55
+ tags: op.tags ?? [],
56
+ group: groupOf(path),
57
+ admin: path.startsWith('/admin'),
58
+ danger: verdict.danger,
59
+ dangerReason: verdict.reason,
60
+ security,
61
+ authSchemes: [...new Set(security.flat())],
62
+ params,
63
+ request: op.requestBody?.content?.['application/json']?.schema,
64
+ responseStatus: response?.status ?? null,
65
+ response: response?.schema ?? null,
66
+ searchText: [method, path, op.operationId, op.summary, (op.tags ?? []).join(' '), Object.values(params).flat().map((p) => p.name).join(' ')]
67
+ .filter(Boolean)
68
+ .join(' ')
69
+ .toLowerCase(),
70
+ };
71
+ operations.push(record);
72
+ byKey.set(key, record);
73
+ if (op.operationId) {
74
+ const keys = byOperationId.get(op.operationId) ?? [];
75
+ keys.push(key);
76
+ byOperationId.set(op.operationId, keys);
77
+ }
78
+ for (const name of new Set([...JSON.stringify(op).matchAll(/#\/components\/schemas\/([A-Za-z0-9_.-]+)/g)].map((m) => m[1]))) {
79
+ const keys = schemaUsedBy.get(name) ?? [];
80
+ keys.push(key);
81
+ schemaUsedBy.set(name, keys);
82
+ }
83
+ }
84
+ }
85
+ return {
86
+ version: spec.info?.version,
87
+ title: spec.info?.title,
88
+ operations,
89
+ byKey,
90
+ byOperationId,
91
+ schemaUsedBy,
92
+ schemas: spec.components?.schemas ?? {},
93
+ securitySchemes: spec.components?.securitySchemes ?? {},
94
+ servers: (spec.servers ?? []).map((s) => s.url).filter((url) => typeof url === 'string'),
95
+ counts: {
96
+ paths: Object.keys(spec.paths ?? {}).length,
97
+ operations: operations.length,
98
+ schemas: Object.keys(spec.components?.schemas ?? {}).length,
99
+ },
100
+ };
101
+ }
@@ -0,0 +1,82 @@
1
+ import type { ExplorerConfig } from './config.js';
2
+ import type { DangerRules } from './risk.js';
3
+ import { type SpecIndex } from './spec-index.js';
4
+ /**
5
+ * Operations added and removed between two spec versions.
6
+ */
7
+ export interface KeyDiff {
8
+ /** Operation keys that appeared. */
9
+ added: string[];
10
+ /** Operation keys that disappeared. */
11
+ removed: string[];
12
+ }
13
+ /**
14
+ * What is known about the loaded spec.
15
+ */
16
+ export interface SpecMeta {
17
+ /** Spec URL or file path. */
18
+ source: string;
19
+ /** ETag of the last fetched copy. */
20
+ etag: string | null;
21
+ /** When the current content was fetched (URL) or modified (file), epoch ms. */
22
+ fetchedAt: number;
23
+ /** When the source was last checked, epoch ms. */
24
+ checkedAt: number;
25
+ /** info.version. */
26
+ version?: string;
27
+ /** Size of the spec text in characters. */
28
+ size: number;
29
+ /** Operations changed since the previous version. */
30
+ changed?: KeyDiff;
31
+ }
32
+ /**
33
+ * The loaded spec with its index.
34
+ */
35
+ export interface SpecState {
36
+ /** Searchable index. */
37
+ index: SpecIndex;
38
+ /** Metadata. */
39
+ meta: SpecMeta;
40
+ /** Set when the source is unreachable and a cached copy is served. */
41
+ offline: {
42
+ since: number;
43
+ reason: string;
44
+ } | null;
45
+ /** Path of a local copy of the spec, used for type generation. */
46
+ specPath: string;
47
+ }
48
+ /**
49
+ * Loads the spec lazily: a local file is re-read when modified, a URL is cached on disk and revalidated with ETag.
50
+ */
51
+ export declare class SpecStore {
52
+ private readonly config;
53
+ private readonly rules;
54
+ private state;
55
+ private readonly cachedSpecPath;
56
+ private readonly metaPath;
57
+ constructor(config: ExplorerConfig, rules: DangerRules);
58
+ /**
59
+ * Returns the current spec, revalidating it when due.
60
+ */
61
+ load(force?: boolean): Promise<SpecState>;
62
+ /**
63
+ * Revalidates the spec now.
64
+ */
65
+ forceRevalidate(): Promise<SpecState>;
66
+ /**
67
+ * Loads a local spec file, re-reading it only when its modification time changes.
68
+ */
69
+ private loadFile;
70
+ /**
71
+ * Loads a URL spec: memory, then disk cache, then a conditional fetch; serves the cache when the source is down.
72
+ */
73
+ private loadUrl;
74
+ /**
75
+ * Reads the cached copy; ignores a cache that belongs to another spec source.
76
+ */
77
+ private readDisk;
78
+ /**
79
+ * Writes the fetched spec and its metadata; a failed cache write never fails the call.
80
+ */
81
+ private writeDisk;
82
+ }
@@ -0,0 +1,147 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { buildIndex } from './spec-index.js';
4
+ /**
5
+ * Parses spec text and checks that it is OpenAPI 3.
6
+ */
7
+ function parseSpec(text, source) {
8
+ let spec;
9
+ try {
10
+ spec = JSON.parse(text);
11
+ }
12
+ catch {
13
+ throw new Error(`the spec at ${source} is not valid JSON (only JSON specs are supported)`);
14
+ }
15
+ if (typeof spec.openapi !== 'string' || !spec.openapi.startsWith('3.')) {
16
+ throw new Error(`the spec at ${source} is not OpenAPI 3.x (openapi: ${String(spec.openapi ?? spec.swagger ?? 'missing')})`);
17
+ }
18
+ return spec;
19
+ }
20
+ /**
21
+ * Lists operations added and removed between two indexes.
22
+ */
23
+ function diffKeys(before, after) {
24
+ const old = new Set(before.operations.map((o) => o.key));
25
+ const next = new Set(after.operations.map((o) => o.key));
26
+ return { added: [...next].filter((k) => !old.has(k)), removed: [...old].filter((k) => !next.has(k)) };
27
+ }
28
+ /**
29
+ * Loads the spec lazily: a local file is re-read when modified, a URL is cached on disk and revalidated with ETag.
30
+ */
31
+ export class SpecStore {
32
+ config;
33
+ rules;
34
+ state = null;
35
+ cachedSpecPath;
36
+ metaPath;
37
+ constructor(config, rules) {
38
+ this.config = config;
39
+ this.rules = rules;
40
+ this.cachedSpecPath = path.join(config.cacheDir, 'spec.json');
41
+ this.metaPath = path.join(config.cacheDir, 'meta.json');
42
+ }
43
+ /**
44
+ * Returns the current spec, revalidating it when due.
45
+ */
46
+ async load(force = false) {
47
+ return this.config.specIsUrl ? this.loadUrl(force) : this.loadFile(force);
48
+ }
49
+ /**
50
+ * Revalidates the spec now.
51
+ */
52
+ forceRevalidate() {
53
+ return this.load(true);
54
+ }
55
+ /**
56
+ * Loads a local spec file, re-reading it only when its modification time changes.
57
+ */
58
+ loadFile(force) {
59
+ const { mtimeMs } = statSync(this.config.specSource);
60
+ if (!force && this.state && this.state.meta.fetchedAt === mtimeMs)
61
+ return this.state;
62
+ const text = readFileSync(this.config.specSource, 'utf8');
63
+ const index = buildIndex(parseSpec(text, this.config.specSource), this.rules);
64
+ const meta = { source: this.config.specSource, etag: null, fetchedAt: mtimeMs, checkedAt: Date.now(), version: index.version, size: text.length };
65
+ if (this.state?.meta.version && this.state.meta.version !== meta.version)
66
+ meta.changed = diffKeys(this.state.index, index);
67
+ this.state = { index, meta, offline: null, specPath: this.config.specSource };
68
+ return this.state;
69
+ }
70
+ /**
71
+ * Loads a URL spec: memory, then disk cache, then a conditional fetch; serves the cache when the source is down.
72
+ */
73
+ async loadUrl(force) {
74
+ const now = Date.now();
75
+ if (!force && this.state && !this.state.offline && now - this.state.meta.checkedAt < this.config.specTtlMs)
76
+ return this.state;
77
+ this.state ??= this.readDisk();
78
+ try {
79
+ const response = await fetch(this.config.specSource, {
80
+ headers: this.state?.meta.etag ? { 'If-None-Match': this.state.meta.etag } : {},
81
+ signal: AbortSignal.timeout(this.config.timeoutMs),
82
+ });
83
+ if (response.status === 304 && this.state) {
84
+ this.state.meta.checkedAt = now;
85
+ this.state.offline = null;
86
+ }
87
+ else if (response.ok) {
88
+ const text = await response.text();
89
+ const index = buildIndex(parseSpec(text, this.config.specSource), this.rules);
90
+ const meta = {
91
+ source: this.config.specSource,
92
+ etag: response.headers.get('etag'),
93
+ fetchedAt: now,
94
+ checkedAt: now,
95
+ version: index.version,
96
+ size: text.length,
97
+ };
98
+ if (this.state?.meta.version && this.state.meta.version !== meta.version)
99
+ meta.changed = diffKeys(this.state.index, index);
100
+ this.writeDisk(text, meta);
101
+ this.state = { index, meta, offline: null, specPath: this.cachedSpecPath };
102
+ }
103
+ else {
104
+ throw new Error(`HTTP ${response.status}`);
105
+ }
106
+ }
107
+ catch (error) {
108
+ const reason = error instanceof Error ? error.message : String(error);
109
+ if (!this.state)
110
+ throw new Error(`the spec is unreachable and there is no cached copy: ${reason}. Check that ${this.config.specSource} is reachable`);
111
+ this.state.offline = { since: this.state.offline?.since ?? now, reason };
112
+ }
113
+ return this.state;
114
+ }
115
+ /**
116
+ * Reads the cached copy; ignores a cache that belongs to another spec source.
117
+ */
118
+ readDisk() {
119
+ if (!existsSync(this.cachedSpecPath) || !existsSync(this.metaPath))
120
+ return null;
121
+ try {
122
+ const meta = JSON.parse(readFileSync(this.metaPath, 'utf8'));
123
+ if ((meta.source ?? meta.url) !== this.config.specSource)
124
+ return null;
125
+ const text = readFileSync(this.cachedSpecPath, 'utf8');
126
+ const index = buildIndex(parseSpec(text, this.cachedSpecPath), this.rules);
127
+ return { index, meta: { ...meta, source: this.config.specSource, etag: meta.etag ?? null }, offline: null, specPath: this.cachedSpecPath };
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ }
133
+ /**
134
+ * Writes the fetched spec and its metadata; a failed cache write never fails the call.
135
+ */
136
+ writeDisk(text, meta) {
137
+ try {
138
+ mkdirSync(this.config.cacheDir, { recursive: true });
139
+ writeFileSync(`${this.cachedSpecPath}.tmp`, text);
140
+ renameSync(`${this.cachedSpecPath}.tmp`, this.cachedSpecPath);
141
+ writeFileSync(this.metaPath, JSON.stringify(meta, null, 2));
142
+ }
143
+ catch (error) {
144
+ process.stderr.write(`openapi-explorer-mcp: could not write the spec cache — ${error.message}\n`);
145
+ }
146
+ }
147
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Splits types.gen.ts into declarations by name, keeping the JSDoc right above each one.
3
+ */
4
+ export declare function parseTypes(source: string): Map<string, string>;
5
+ /**
6
+ * Returns generated declarations for a spec, regenerating only when the cache key changes.
7
+ */
8
+ export declare function getTypeMap(specPath: string, outDir: string, cacheKey: string): Promise<Map<string, string>>;
9
+ /**
10
+ * Renames the declared name in `export type Old = ...`.
11
+ */
12
+ export declare function renameDeclaration(declaration: string, from: string, to: string): string;
@@ -0,0 +1,54 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ const cache = { key: null, map: null };
4
+ /**
5
+ * Splits types.gen.ts into declarations by name, keeping the JSDoc right above each one.
6
+ */
7
+ export function parseTypes(source) {
8
+ const map = new Map();
9
+ const re = /export (?:type|interface) (\w+)/g;
10
+ let match;
11
+ while ((match = re.exec(source))) {
12
+ let start = match.index;
13
+ const before = source.slice(0, start).trimEnd();
14
+ if (before.endsWith('*/'))
15
+ start = before.lastIndexOf('/**');
16
+ let depth = 0;
17
+ let i = re.lastIndex;
18
+ for (; i < source.length; i += 1) {
19
+ const ch = source[i];
20
+ if (ch === '{' || ch === '(' || ch === '[')
21
+ depth += 1;
22
+ else if (ch === '}' || ch === ')' || ch === ']')
23
+ depth -= 1;
24
+ else if (ch === ';' && depth === 0)
25
+ break;
26
+ }
27
+ map.set(match[1], source.slice(start, i + 1).trim());
28
+ }
29
+ return map;
30
+ }
31
+ /**
32
+ * Returns generated declarations for a spec, regenerating only when the cache key changes.
33
+ */
34
+ export async function getTypeMap(specPath, outDir, cacheKey) {
35
+ if (cache.key === cacheKey && cache.map)
36
+ return cache.map;
37
+ // Loaded lazily: the generator is heavy and only this tool needs it.
38
+ const { createClient } = await import('@hey-api/openapi-ts');
39
+ await createClient({
40
+ input: specPath,
41
+ output: { path: outDir, postProcess: [] },
42
+ plugins: ['@hey-api/typescript'],
43
+ logs: { level: 'silent' },
44
+ });
45
+ cache.map = parseTypes(readFileSync(path.join(outDir, 'types.gen.ts'), 'utf8'));
46
+ cache.key = cacheKey;
47
+ return cache.map;
48
+ }
49
+ /**
50
+ * Renames the declared name in `export type Old = ...`.
51
+ */
52
+ export function renameDeclaration(declaration, from, to) {
53
+ return from === to ? declaration : declaration.replace(new RegExp(`(export (?:type|interface) )${from}\\b`), `$1${to}`);
54
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "openapi-explorer-mcp",
3
+ "version": "0.0.1",
4
+ "description": "MCP server for any OpenAPI 3 spec: search endpoints, inspect request and response shapes, generate TypeScript types, and call endpoints with credentials mapped to the spec's security schemes.",
5
+ "license": "MIT",
6
+ "author": "Eugene Trofimov",
7
+ "type": "module",
8
+ "bin": {
9
+ "openapi-explorer-mcp": "dist/index.js"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/auth.d.ts",
14
+ "default": "./dist/auth.js"
15
+ },
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "engines": {
24
+ "node": ">=20.19"
25
+ },
26
+ "scripts": {
27
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
28
+ "typecheck": "tsc --noEmit",
29
+ "smoke": "node scripts/smoke.mjs",
30
+ "check": "npm run typecheck && npm run build && npm run smoke",
31
+ "prepack": "npm run build"
32
+ },
33
+ "keywords": [
34
+ "openapi",
35
+ "swagger",
36
+ "mcp",
37
+ "model-context-protocol",
38
+ "api-explorer",
39
+ "typescript-types",
40
+ "claude-code"
41
+ ],
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/deadrime/openapi-explorer-mcp.git"
45
+ },
46
+ "homepage": "https://github.com/deadrime/openapi-explorer-mcp#readme",
47
+ "bugs": {
48
+ "url": "https://github.com/deadrime/openapi-explorer-mcp/issues"
49
+ },
50
+ "dependencies": {
51
+ "@hey-api/openapi-ts": "^0.95.0",
52
+ "@modelcontextprotocol/sdk": "^1.30.0",
53
+ "zod": "^4.6.5"
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^20.19.43",
57
+ "typescript": "^5.9.3"
58
+ }
59
+ }