Skip to content
Merged
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
6 changes: 4 additions & 2 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2.2.0

- name: Install dependencies
run: bun install

- name: Release new version
run: bunx semantic-release --package @semantic-release/changelog@6.0.3 --package @semantic-release/git@10.0.1
run: bunx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
34 changes: 34 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules

# output
out
dist
*.tgz

# code coverage
coverage
*.lcov

# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json

# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local

# caches
.eslintcache
.cache
*.tsbuildinfo

# IntelliJ based IDEs
.idea

# Finder (MacOS) folder config
.DS_Store
3 changes: 3 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"recommendations": ["oxc.oxc-vscode"]
}
5 changes: 5 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"oxc.fmt.configPath": "oxfmt.config.ts",
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
}
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# 1.0.0-beta.1 (2026-06-25)


### Features

* add initial MCP implementation ([7ebc7c0](https://github.com/ceski23/openapi-mcp/commit/7ebc7c0e5c89b4cbb5d5f22999a675f13025d18f))
1,422 changes: 1,422 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions oxfmt.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { defineConfig } from 'oxfmt'

export default defineConfig({
singleQuote: true,
semi: false,
trailingComma: 'all',
printWidth: 100,
tabWidth: 4,
useTabs: false,
insertFinalNewline: true,
ignorePatterns: ['**/node_modules/**', 'bun.lock'],
})
15 changes: 15 additions & 0 deletions oxlint.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { defineConfig } from 'oxlint'

export default defineConfig({
plugins: ['typescript', 'unicorn'],
categories: {
correctness: 'error',
suspicious: 'warn',
pedantic: 'warn',
},
ignorePatterns: ['**/dist/**', '**/node_modules/**', 'bun.lock'],
rules: {
'eslint/max-lines-per-function': 'off',
'import/max-dependencies': 'off',
},
})
50 changes: 50 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"name": "@ceski23/openapi-mcp",
"version": "1.0.0-beta.1",
"license": "MIT",
"author": "Cezary Bober",
"repository": {
"type": "git",
"url": "git+https://github.com/ceski23/openapi-mcp.git"
},
"bin": {
"openapi-mcp": "./dist/index.js"
},
"files": [
"dist/"
],
"type": "module",
"publishConfig": {
"access": "public"
},
"scripts": {
"lint": "oxlint",
"format": "oxfmt",
"check": "bun run lint && bun run format --check",
"typecheck": "bun run tsc --noEmit",
"build": "bun build src/index.ts --target=node --outdir=dist --packages=external",
"prepublishOnly": "bun run build",
"test": "bun test",
"check:config": "publint"
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.29.0",
"lru-cache": "11.5.1",
"oas": "36.0.3",
"oas-normalize": "16.0.5",
"zod": "4.4.3"
},
"devDependencies": {
"@semantic-release/changelog": "6.0.3",
"@semantic-release/git": "10.0.1",
"@types/bun": "latest",
"oxfmt": "0.56.0",
"oxlint": "1.71.0",
"publint": "0.3.21",
"typescript": "6.0.3"
},
"engines": {
"bun": ">=1.0.0",
"node": ">=22"
}
}
19 changes: 19 additions & 0 deletions src/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { LRUCache } from 'lru-cache'
import type Oas from 'oas'

export type CachedSpec = {
oas: Oas | null
}

const specCache = new LRUCache<string, CachedSpec>({
max: 10,
ttl: 1000 * 60 * 60,
})

export function setSpec(id: string, spec: CachedSpec): void {
specCache.set(id, spec)
}

export function getSpec(id: string): CachedSpec | undefined {
return specCache.get(id)
}
30 changes: 30 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env node
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { loadSpec } from './tools/load-spec'
import { findOperations } from './tools/find-operations'
import { getOperation } from './tools/get-operation'
import { getPath } from './tools/get-path'
import { findSchemas } from './tools/find-schemas'
import { getSchema } from './tools/get-schema'
import { searchContract } from './tools/search-contract'

const server = new McpServer({
name: 'openapi-mcp',
version: '0.1.0',
})

for (const { name, description, inputSchema, execute } of [
loadSpec,
findOperations,
getOperation,
getPath,
findSchemas,
getSchema,
searchContract,
]) {
server.registerTool(name, { description, inputSchema }, execute)
}

const transport = new StdioServerTransport()
await server.connect(transport)
35 changes: 35 additions & 0 deletions src/tools/find-operations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { z } from 'zod'
import { getSpec } from '../cache'
import { MISSING_SPEC_RESPONSE, defineTool, iterateOperations, matchesQuery } from '../utils'

export const findOperations = defineTool({
name: 'find_operations',
description:
'Search for API operations in the loaded spec by matching against operationId, path, summary, tags, and descriptions. Use when you know approximately what you need but not the exact endpoint.',
inputSchema: z.object({
specId: z.string().describe('The spec ID returned by load_spec'),
query: z
.string()
.describe('Search query to match against operation names, paths, summaries, and tags'),
}),
execute: ({ specId, query }) => {
const cached = getSpec(specId)
const spec = cached?.oas?.getDefinition()
if (!spec) return MISSING_SPEC_RESPONSE

const lowercaseQuery = query.toLowerCase()
const results = iterateOperations(spec)
.filter(({ path, operation }) => matchesQuery(operation, path, lowercaseQuery))
.map(({ path, method, operation }) => ({
operationId: operation.operationId ?? '',
method,
path,
...(operation.summary ? { summary: operation.summary } : {}),
}))
.toArray()

return {
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }],
}
},
})
30 changes: 30 additions & 0 deletions src/tools/find-schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { z } from 'zod'
import { getSpec } from '../cache'
import { MISSING_SPEC_RESPONSE, defineTool } from '../utils'

export const findSchemas = defineTool({
name: 'find_schemas',
description:
'Search component/definition schema names in the loaded spec by matching against schema names. Use when you know approximately what a schema is called but not the exact name.',
inputSchema: z.object({
specId: z.string().describe('The spec ID returned by load_spec'),
query: z.string().describe('Search query to match against schema/definition names'),
}),
execute: ({ specId, query }) => {
const cached = getSpec(specId)
const spec = cached?.oas?.getDefinition()
if (!spec) return MISSING_SPEC_RESPONSE

const schemas = spec.components?.schemas
const keys = Object.keys(schemas ?? {})

const lowercaseQuery = query.toLowerCase()
const results = keys
.filter((name) => name.toLowerCase().includes(lowercaseQuery))
.toSorted()

return {
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }],
}
},
})
49 changes: 49 additions & 0 deletions src/tools/get-operation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { z } from 'zod'
import { getSpec } from '../cache'
import { MISSING_SPEC_RESPONSE, defineTool } from '../utils'

export const getOperation = defineTool({
name: 'get_operation',
description:
'Retrieve full details for a single API operation by operationId, including method, path, parameters, requestBody, and responses.',
inputSchema: z.object({
specId: z.string().describe('The spec ID returned by load_spec'),
operationId: z.string().describe('The operationId of the endpoint to retrieve'),
}),
execute: ({ specId, operationId }) => {
const cached = getSpec(specId)
const oas = cached?.oas
if (!oas) return MISSING_SPEC_RESPONSE

const operation = oas.getOperationById(operationId)

if (!operation) {
return {
content: [
{
type: 'text' as const,
text: `No operation found with operationId "${operationId}".`,
},
],
isError: true,
}
}

return {
content: [
{
type: 'text' as const,
text: JSON.stringify(
{
method: operation.method.toUpperCase(),
path: operation.path,
...operation.schema,
},
null,
2,
),
},
],
}
},
})
32 changes: 32 additions & 0 deletions src/tools/get-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { z } from 'zod'
import { getSpec } from '../cache'
import { MISSING_SPEC_RESPONSE, defineTool } from '../utils'

export const getPath = defineTool({
name: 'get_path',
description:
'Retrieve all operations (GET, POST, PATCH, DELETE, etc.) for a specific path. Use when you know the exact path and want the full contract for all methods on it.',
inputSchema: z.object({
specId: z.string().describe('The spec ID returned by load_spec'),
path: z.string().describe('The exact path from the spec (e.g. /customers/{id})'),
}),
execute: ({ specId, path }) => {
const cached = getSpec(specId)
const spec = cached?.oas?.getDefinition()
if (!spec) return MISSING_SPEC_RESPONSE

const pathItem = spec.paths?.[path]
if (!pathItem) {
return {
content: [
{ type: 'text' as const, text: `No operations found for path "${path}".` },
],
isError: true,
}
}

return {
content: [{ type: 'text' as const, text: JSON.stringify(pathItem, null, 2) }],
}
},
})
31 changes: 31 additions & 0 deletions src/tools/get-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { z } from 'zod'
import { getSpec } from '../cache'
import { MISSING_SPEC_RESPONSE, defineTool } from '../utils'

export const getSchema = defineTool({
name: 'get_schema',
description:
'Retrieve a fully dereferenced component/definition schema by name. Use when you need the full structure of a specific schema.',
inputSchema: z.object({
specId: z.string().describe('The spec ID returned by load_spec'),
name: z.string().describe('The schema/definition name (case-sensitive, e.g. Invoice)'),
}),
execute: ({ specId, name }) => {
const cached = getSpec(specId)
const spec = cached?.oas?.getDefinition()
if (!spec) return MISSING_SPEC_RESPONSE

const schema = spec.components?.schemas?.[name]

if (!schema) {
return {
content: [{ type: 'text' as const, text: `No schema found with name "${name}".` }],
isError: true,
}
}

return {
content: [{ type: 'text' as const, text: JSON.stringify(schema, null, 2) }],
}
},
})
Loading