Compare commits
No commits in common. "main" and "v0.1.1" have entirely different histories.
31 changed files with 227 additions and 3857 deletions
80
CLAUDE.md
80
CLAUDE.md
|
|
@ -35,7 +35,7 @@ Restart Claude Code after configuration.
|
||||||
| `validate-spec` | Validate against schema | `path` |
|
| `validate-spec` | Validate against schema | `path` |
|
||||||
| `query-endpoints` | Search/filter endpoints | `path`, `method?`, `pathPattern?`, `tag?` |
|
| `query-endpoints` | Search/filter endpoints | `path`, `method?`, `pathPattern?`, `tag?` |
|
||||||
| `get-schema` | Get schema details | `path`, `schemaName` |
|
| `get-schema` | Get schema details | `path`, `schemaName` |
|
||||||
| `generate-types` | Generate TypeScript | `path`, `schemas?`, `options?` |
|
| `generate-types` | Generate TypeScript | `path`, `schemas?` |
|
||||||
|
|
||||||
## Usage Examples
|
## Usage Examples
|
||||||
|
|
||||||
|
|
@ -48,72 +48,6 @@ Restart Claude Code after configuration.
|
||||||
"What does the User schema look like?"
|
"What does the User schema look like?"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
### Broken $ref Fallback
|
|
||||||
When specs have broken `$ref` pointers, parser falls back to non-dereferenced mode:
|
|
||||||
```typescript
|
|
||||||
// In lib/parser.ts
|
|
||||||
try {
|
|
||||||
spec = await SwaggerParser.dereference(specPath); // Try first
|
|
||||||
} catch {
|
|
||||||
spec = await SwaggerParser.parse(specPath); // Fallback
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### .NET Name Sanitization
|
|
||||||
Converts .NET-style schema names to valid TypeScript:
|
|
||||||
```typescript
|
|
||||||
// In tools/generate.ts - sanitizeName()
|
|
||||||
"Namespace.Type" → "Type"
|
|
||||||
"Generic`1[Inner]" → "Generic_Inner"
|
|
||||||
"Company.Api.V5.ViewModel" → "ViewModel"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Spec Caching
|
|
||||||
Parsed specs are cached in-memory for improved performance:
|
|
||||||
- LRU cache with max 10 entries
|
|
||||||
- 15-minute TTL
|
|
||||||
- Local files: cache key includes mtime for change detection
|
|
||||||
- Remote URLs: normalized URL as cache key
|
|
||||||
|
|
||||||
All tools support `noCache: true` to bypass the cache:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "parse-spec",
|
|
||||||
"arguments": {
|
|
||||||
"path": "https://api.example.com/spec.json",
|
|
||||||
"noCache": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### TypeScript Generation Options
|
|
||||||
The `generate-types` tool supports customization via `options`:
|
|
||||||
|
|
||||||
| Option | Type | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| `enumAsUnion` | boolean | Generate enums as union types (default) |
|
|
||||||
| `enumAsEnum` | boolean | Generate enums as TypeScript enums |
|
|
||||||
| `interfacePrefix` | string | Prefix for interface names (e.g., "I") |
|
|
||||||
| `interfaceSuffix` | string | Suffix for interface names (e.g., "Type") |
|
|
||||||
| `indentation` | '2'\|'4'\|'tab' | Indentation style (default: '2') |
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "generate-types",
|
|
||||||
"arguments": {
|
|
||||||
"path": "api.yaml",
|
|
||||||
"options": {
|
|
||||||
"enumAsEnum": true,
|
|
||||||
"interfacePrefix": "I",
|
|
||||||
"indentation": "4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -124,9 +58,9 @@ src/
|
||||||
│ ├── validate.ts # validateSpec() → error collection
|
│ ├── validate.ts # validateSpec() → error collection
|
||||||
│ ├── query.ts # queryEndpoints() → filtering logic
|
│ ├── query.ts # queryEndpoints() → filtering logic
|
||||||
│ ├── schema.ts # getSchema() → schema lookup
|
│ ├── schema.ts # getSchema() → schema lookup
|
||||||
│ └── generate.ts # generateTypes() → TS code generation + sanitizeName()
|
│ └── generate.ts # generateTypes() → TS code generation
|
||||||
├── lib/ # Shared utilities
|
├── lib/ # Shared utilities
|
||||||
│ ├── parser.ts # SwaggerParser wrapper (dereference with fallback)
|
│ ├── parser.ts # SwaggerParser.dereference() wrapper
|
||||||
│ ├── validator.ts # SwaggerParser.validate() wrapper
|
│ ├── validator.ts # SwaggerParser.validate() wrapper
|
||||||
│ └── types.ts # TypeScript interfaces
|
│ └── types.ts # TypeScript interfaces
|
||||||
└── utils/
|
└── utils/
|
||||||
|
|
@ -181,14 +115,8 @@ npm run dev # Run with tsx (hot reload)
|
||||||
# List tools
|
# List tools
|
||||||
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/index.js
|
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/index.js
|
||||||
|
|
||||||
# Local spec
|
# Call a tool
|
||||||
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"parse-spec","arguments":{"path":"test/fixtures/petstore.yaml"}}}' | node dist/index.js
|
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"parse-spec","arguments":{"path":"test/fixtures/petstore.yaml"}}}' | node dist/index.js
|
||||||
|
|
||||||
# Remote URL
|
|
||||||
echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"parse-spec","arguments":{"path":"https://petstore.swagger.io/v2/swagger.json"}}}' | node dist/index.js
|
|
||||||
|
|
||||||
# Enterprise API with broken refs
|
|
||||||
echo '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"parse-spec","arguments":{"path":"https://projects.moravia.com/api/swagger/v5/swagger.json"}}}' | node dist/index.js
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Adding New Tools
|
## Adding New Tools
|
||||||
|
|
|
||||||
54
README.md
54
README.md
|
|
@ -10,8 +10,6 @@ MCP (Model Context Protocol) server for parsing, validating, and querying OpenAP
|
||||||
- **Inspect** component schemas with resolved references
|
- **Inspect** component schemas with resolved references
|
||||||
- **Generate** TypeScript interfaces from schemas
|
- **Generate** TypeScript interfaces from schemas
|
||||||
- **Support** both local files and remote URLs
|
- **Support** both local files and remote URLs
|
||||||
- **Handle** broken `$ref` pointers gracefully (fallback mode)
|
|
||||||
- **Sanitize** .NET-style schema names for valid TypeScript
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
@ -68,7 +66,6 @@ Parse an OpenAPI/Swagger spec and return metadata.
|
||||||
- Server URLs
|
- Server URLs
|
||||||
- Path count, operation count, schema count
|
- Path count, operation count, schema count
|
||||||
- Tags
|
- Tags
|
||||||
- Warning if spec has broken `$refs` (parsed without dereferencing)
|
|
||||||
|
|
||||||
**Example:**
|
**Example:**
|
||||||
```
|
```
|
||||||
|
|
@ -170,14 +167,6 @@ Generate TypeScript interfaces from schemas.
|
||||||
- Arrays and nested objects
|
- Arrays and nested objects
|
||||||
- allOf (intersection), oneOf/anyOf (union)
|
- allOf (intersection), oneOf/anyOf (union)
|
||||||
- additionalProperties as Record<string, T>
|
- additionalProperties as Record<string, T>
|
||||||
- .NET-style names sanitized to valid TypeScript identifiers
|
|
||||||
|
|
||||||
**Name Sanitization:**
|
|
||||||
| Original | Sanitized |
|
|
||||||
|----------|-----------|
|
|
||||||
| `Namespace.SubNs.TypeName` | `TypeName` |
|
|
||||||
| `GenericType\`1[InnerType]` | `GenericType_InnerType` |
|
|
||||||
| `Company.Api.V5.UserViewModel` | `UserViewModel` |
|
|
||||||
|
|
||||||
**Examples:**
|
**Examples:**
|
||||||
```
|
```
|
||||||
|
|
@ -202,24 +191,6 @@ Generate only User and Order types from api.yaml
|
||||||
| Remote URL (HTTPS) | `https://example.com/openapi.json` |
|
| Remote URL (HTTPS) | `https://example.com/openapi.json` |
|
||||||
| Remote URL (HTTP) | `http://localhost:3000/api-docs` |
|
| Remote URL (HTTP) | `http://localhost:3000/api-docs` |
|
||||||
|
|
||||||
## Broken $ref Handling
|
|
||||||
|
|
||||||
When a spec contains broken `$ref` pointers (e.g., typos in schema names), the parser:
|
|
||||||
|
|
||||||
1. **Attempts** full dereferencing first
|
|
||||||
2. **Falls back** to parsing without dereferencing if refs are broken
|
|
||||||
3. **Warns** in output: "Spec has broken $refs. Parsed without dereferencing."
|
|
||||||
|
|
||||||
This allows you to still extract metadata, query endpoints, and generate types from imperfect specs.
|
|
||||||
|
|
||||||
## Tested With
|
|
||||||
|
|
||||||
| API | Stats | Notes |
|
|
||||||
|-----|-------|-------|
|
|
||||||
| Petstore (Swagger) | 14 paths, 20 ops | Standard test spec |
|
|
||||||
| Petstore (OpenAPI 3) | 3 paths, 5 ops | Local fixture |
|
|
||||||
| Moravia Symfonie | 293 paths, 381 ops, 185 schemas | Enterprise .NET API with broken $refs |
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -232,7 +203,7 @@ src/
|
||||||
│ ├── schema.ts # get-schema implementation
|
│ ├── schema.ts # get-schema implementation
|
||||||
│ └── generate.ts # generate-types implementation
|
│ └── generate.ts # generate-types implementation
|
||||||
├── lib/
|
├── lib/
|
||||||
│ ├── parser.ts # SwaggerParser wrapper (with fallback)
|
│ ├── parser.ts # SwaggerParser wrapper
|
||||||
│ ├── validator.ts # Validation logic
|
│ ├── validator.ts # Validation logic
|
||||||
│ └── types.ts # TypeScript type definitions
|
│ └── types.ts # TypeScript type definitions
|
||||||
└── utils/
|
└── utils/
|
||||||
|
|
@ -273,9 +244,6 @@ echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | node dist/index.js
|
||||||
|
|
||||||
# Parse a spec
|
# Parse a spec
|
||||||
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"parse-spec","arguments":{"path":"test/fixtures/petstore.yaml"}}}' | node dist/index.js
|
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"parse-spec","arguments":{"path":"test/fixtures/petstore.yaml"}}}' | node dist/index.js
|
||||||
|
|
||||||
# Test with remote URL
|
|
||||||
echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"parse-spec","arguments":{"path":"https://petstore.swagger.io/v2/swagger.json"}}}' | node dist/index.js
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Example Output
|
## Example Output
|
||||||
|
|
@ -318,26 +286,6 @@ export interface Pet {
|
||||||
export type PetStatus = 'available' | 'pending' | 'sold';
|
export type PetStatus = 'available' | 'pending' | 'sold';
|
||||||
```
|
```
|
||||||
|
|
||||||
### generate-types (from .NET API)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
/** Original: Microsoft.AspNetCore.OData.Results.SingleResult`1[...] */
|
|
||||||
export interface SingleResult_JobCommentViewModel {
|
|
||||||
queryable?: JobCommentViewModel[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Job view model */
|
|
||||||
export interface JobViewModel {
|
|
||||||
/** The identifier. */
|
|
||||||
id?: number;
|
|
||||||
/** The name. MaxLength(255) */
|
|
||||||
name: string;
|
|
||||||
/** The project identifier. */
|
|
||||||
projectId: number;
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|
|
||||||
|
|
@ -1,470 +0,0 @@
|
||||||
# Analysis: Feature Enhancements (Caching, OpenAPI 3.1, Batch, TypeScript Options)
|
|
||||||
|
|
||||||
## Scope Affected
|
|
||||||
- [x] Backend
|
|
||||||
- [ ] Frontend
|
|
||||||
- [ ] Database
|
|
||||||
- [ ] Infrastructure
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Analysis of four proposed feature enhancements for the swagger-tools MCP server: remote spec caching, OpenAPI 3.1 support improvements, batch operations, and TypeScript generation options. The current codebase is well-structured with clear separation of concerns, making these enhancements straightforward to implement.
|
|
||||||
|
|
||||||
## Files Involved
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `/Users/rimskij/projects/swagger-tools/src/index.ts` | MCP server entry point - tool registration |
|
|
||||||
| `/Users/rimskij/projects/swagger-tools/src/lib/parser.ts` | Spec parsing with dereference fallback |
|
|
||||||
| `/Users/rimskij/projects/swagger-tools/src/lib/validator.ts` | Spec validation wrapper |
|
|
||||||
| `/Users/rimskij/projects/swagger-tools/src/lib/types.ts` | TypeScript interfaces for parsed data |
|
|
||||||
| `/Users/rimskij/projects/swagger-tools/src/tools/parse.ts` | parse-spec tool handler |
|
|
||||||
| `/Users/rimskij/projects/swagger-tools/src/tools/validate.ts` | validate-spec tool handler |
|
|
||||||
| `/Users/rimskij/projects/swagger-tools/src/tools/query.ts` | query-endpoints tool handler |
|
|
||||||
| `/Users/rimskij/projects/swagger-tools/src/tools/schema.ts` | get-schema tool handler |
|
|
||||||
| `/Users/rimskij/projects/swagger-tools/src/tools/generate.ts` | generate-types tool with sanitizeName() |
|
|
||||||
| `/Users/rimskij/projects/swagger-tools/src/utils/format.ts` | Human-readable output formatters |
|
|
||||||
|
|
||||||
## Current Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── index.ts # Entry: registers 5 tools, starts stdio server
|
|
||||||
├── lib/
|
|
||||||
│ ├── parser.ts # parseSpec() - dereference with fallback
|
|
||||||
│ ├── validator.ts # validateSpec(), validateWithWarnings()
|
|
||||||
│ └── types.ts # TypeScript interfaces
|
|
||||||
├── tools/
|
|
||||||
│ ├── parse.ts # Calls parseSpec(), returns metadata
|
|
||||||
│ ├── validate.ts # Calls validateWithWarnings()
|
|
||||||
│ ├── query.ts # Filters endpoints from parsed spec
|
|
||||||
│ ├── schema.ts # Looks up schema by name
|
|
||||||
│ └── generate.ts # Converts schemas to TypeScript
|
|
||||||
└── utils/
|
|
||||||
└── format.ts # Markdown formatters
|
|
||||||
```
|
|
||||||
|
|
||||||
### Execution Flow
|
|
||||||
|
|
||||||
1. **Entry**: MCP client sends JSON-RPC request via stdio
|
|
||||||
2. **Routing**: McpServer routes to registered tool handler
|
|
||||||
3. **Parsing**: Tool handler calls `parseSpec()` with spec path
|
|
||||||
4. **Processing**: Tool-specific logic (filter, validate, generate)
|
|
||||||
5. **Response**: Returns `{content: [...], structuredContent: {...}}`
|
|
||||||
|
|
||||||
### Key Dependencies
|
|
||||||
|
|
||||||
- `@apidevtools/swagger-parser` v10.1.1 - Supports OpenAPI 3.1 (with workarounds)
|
|
||||||
- `@modelcontextprotocol/sdk` v1.25.2 - MCP protocol
|
|
||||||
- `openapi-types` v12.1.3 - TypeScript types (OpenAPIV3_1 namespace)
|
|
||||||
- `zod` v3.23.0 - Schema validation for tool parameters
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Feature 1: Caching for Remote Specs
|
|
||||||
|
|
||||||
### Current Behavior
|
|
||||||
|
|
||||||
Every tool call to `parseSpec()` fetches and parses the spec fresh:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/lib/parser.ts:7-11
|
|
||||||
export async function parseSpec(specPath: string, options?: { dereference?: boolean }): Promise<{...}>
|
|
||||||
// Always fetches/reads from scratch
|
|
||||||
spec = await SwaggerParser.dereference(specPath);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Proposed Solution
|
|
||||||
|
|
||||||
Add an in-memory LRU cache in `src/lib/parser.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// New file: src/lib/cache.ts
|
|
||||||
interface CacheEntry {
|
|
||||||
spec: OpenAPISpec;
|
|
||||||
metadata: ParsedSpec;
|
|
||||||
dereferenced: boolean;
|
|
||||||
timestamp: number;
|
|
||||||
etag?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class SpecCache {
|
|
||||||
private cache: Map<string, CacheEntry>;
|
|
||||||
private maxSize: number;
|
|
||||||
private ttlMs: number;
|
|
||||||
|
|
||||||
constructor(maxSize = 10, ttlMinutes = 15) {...}
|
|
||||||
get(key: string): CacheEntry | undefined {...}
|
|
||||||
set(key: string, entry: CacheEntry): void {...}
|
|
||||||
invalidate(key?: string): void {...}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Files to Modify
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `src/lib/cache.ts` | NEW: LRU cache with TTL |
|
|
||||||
| `src/lib/parser.ts` | Import cache, wrap parseSpec |
|
|
||||||
| `src/tools/parse.ts` | Add optional `noCache` parameter |
|
|
||||||
| `src/tools/validate.ts` | Add optional `noCache` parameter |
|
|
||||||
| `src/tools/query.ts` | Add optional `noCache` parameter |
|
|
||||||
| `src/tools/schema.ts` | Add optional `noCache` parameter |
|
|
||||||
| `src/tools/generate.ts` | Add optional `noCache` parameter |
|
|
||||||
|
|
||||||
### Cache Key Strategy
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// For remote URLs: normalize URL (remove trailing slash, sort query params)
|
|
||||||
// For local files: use absolute path + file mtime
|
|
||||||
function getCacheKey(specPath: string): string {
|
|
||||||
if (specPath.startsWith('http://') || specPath.startsWith('https://')) {
|
|
||||||
return normalizeUrl(specPath);
|
|
||||||
}
|
|
||||||
const resolved = path.resolve(specPath);
|
|
||||||
const stat = fs.statSync(resolved);
|
|
||||||
return `${resolved}:${stat.mtimeMs}`;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Risks
|
|
||||||
|
|
||||||
| Risk | Impact | Mitigation |
|
|
||||||
|------|--------|------------|
|
|
||||||
| Stale cache for rapidly changing specs | MEDIUM | Add `noCache` param, short TTL (15 min) |
|
|
||||||
| Memory pressure with large specs | MEDIUM | LRU eviction, max 10 entries |
|
|
||||||
| Local file changes not detected | LOW | Include mtime in cache key |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Feature 2: OpenAPI 3.1 Support
|
|
||||||
|
|
||||||
### Current State
|
|
||||||
|
|
||||||
**Good news**: The underlying `@apidevtools/swagger-parser` v10.1.1 already supports OpenAPI 3.1:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// node_modules/@apidevtools/swagger-parser/lib/index.js
|
|
||||||
const supported31Versions = ["3.1.0", "3.1.1"];
|
|
||||||
```
|
|
||||||
|
|
||||||
**Gap**: The TypeScript generation in `generate.ts` doesn't handle 3.1-specific JSON Schema features.
|
|
||||||
|
|
||||||
### OpenAPI 3.1 Differences from 3.0
|
|
||||||
|
|
||||||
| Feature | 3.0 | 3.1 | Code Impact |
|
|
||||||
|---------|-----|-----|-------------|
|
|
||||||
| Schema dialect | OpenAPI Schema | JSON Schema 2020-12 | Type generation |
|
|
||||||
| `type` field | Single string | Array allowed | schemaToType() |
|
|
||||||
| `nullable` | `nullable: true` | `type: ['string', 'null']` | schemaToType() |
|
|
||||||
| `const` | Not supported | Supported | schemaToType() |
|
|
||||||
| `exclusiveMinimum` | boolean | number | Not TS-relevant |
|
|
||||||
| `webhooks` | Not supported | Top-level field | metadata extraction |
|
|
||||||
| `$ref` siblings | Not allowed | Allowed with summary/desc | Reference handling |
|
|
||||||
| `pathItems` | Not in components | In components | Schema lookup |
|
|
||||||
|
|
||||||
### Proposed Changes
|
|
||||||
|
|
||||||
1. **Detect 3.1 version** in parser.ts metadata extraction
|
|
||||||
2. **Handle array types** in generate.ts:
|
|
||||||
```typescript
|
|
||||||
// type: ['string', 'null'] -> string | null
|
|
||||||
if (Array.isArray(s.type)) {
|
|
||||||
return s.type.map(t => t === 'null' ? 'null' : mapPrimitiveType(t)).join(' | ');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
3. **Handle `const`** in generate.ts:
|
|
||||||
```typescript
|
|
||||||
if (s.const !== undefined) {
|
|
||||||
return typeof s.const === 'string' ? `'${s.const}'` : String(s.const);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
4. **Extract webhooks** in parser.ts metadata
|
|
||||||
|
|
||||||
### Files to Modify
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `src/lib/parser.ts` | Add webhook count to metadata |
|
|
||||||
| `src/lib/types.ts` | Add `webhookCount?: number` to ParsedSpec |
|
|
||||||
| `src/tools/generate.ts` | Handle 3.1 type arrays, const |
|
|
||||||
| `src/utils/format.ts` | Display webhook count |
|
|
||||||
|
|
||||||
### Risks
|
|
||||||
|
|
||||||
| Risk | Impact | Mitigation |
|
|
||||||
|------|--------|------------|
|
|
||||||
| Array type combinations complex | LOW | Map each type, join with `\|` |
|
|
||||||
| `const` type inference tricky | LOW | Use literal types |
|
|
||||||
| Existing 3.0 specs break | LOW | Changes are additive |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Feature 3: Batch Operations
|
|
||||||
|
|
||||||
### Proposed Design
|
|
||||||
|
|
||||||
Add a new tool `batch-process` that accepts multiple specs:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/tools/batch.ts
|
|
||||||
export const batchToolSchema = {
|
|
||||||
paths: z.array(z.string()).describe('Paths to OpenAPI specs'),
|
|
||||||
operation: z.enum(['parse', 'validate']).describe('Operation to perform'),
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Implementation Options
|
|
||||||
|
|
||||||
**Option A: Sequential Processing** (Simpler)
|
|
||||||
```typescript
|
|
||||||
const results = [];
|
|
||||||
for (const path of paths) {
|
|
||||||
results.push(await parseSpec(path));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option B: Parallel Processing** (Faster)
|
|
||||||
```typescript
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
paths.map(path => parseSpec(path))
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Recommended Approach
|
|
||||||
|
|
||||||
Use **Option B** with concurrency limit:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/lib/batch.ts
|
|
||||||
async function batchProcess<T>(
|
|
||||||
items: string[],
|
|
||||||
processor: (item: string) => Promise<T>,
|
|
||||||
concurrency = 5
|
|
||||||
): Promise<Array<{ path: string; result?: T; error?: string }>> {
|
|
||||||
const results: Array<...> = [];
|
|
||||||
const executing: Promise<void>[] = [];
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const p = processor(item)
|
|
||||||
.then(result => results.push({ path: item, result }))
|
|
||||||
.catch(error => results.push({ path: item, error: error.message }));
|
|
||||||
|
|
||||||
executing.push(p);
|
|
||||||
if (executing.length >= concurrency) {
|
|
||||||
await Promise.race(executing);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await Promise.all(executing);
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Files to Modify
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `src/lib/batch.ts` | NEW: Parallel processing utility |
|
|
||||||
| `src/tools/batch.ts` | NEW: batch-process tool |
|
|
||||||
| `src/index.ts` | Register batch-process tool |
|
|
||||||
| `src/utils/format.ts` | Add formatBatchResults() |
|
|
||||||
|
|
||||||
### Output Format
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface BatchResult {
|
|
||||||
success: boolean;
|
|
||||||
total: number;
|
|
||||||
succeeded: number;
|
|
||||||
failed: number;
|
|
||||||
results: Array<{
|
|
||||||
path: string;
|
|
||||||
success: boolean;
|
|
||||||
data?: ParsedSpec | ValidationResult;
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Risks
|
|
||||||
|
|
||||||
| Risk | Impact | Mitigation |
|
|
||||||
|------|--------|------------|
|
|
||||||
| Resource exhaustion with many specs | HIGH | Concurrency limit (5) |
|
|
||||||
| One failure stops all | LOW | Promise.allSettled continues |
|
|
||||||
| Output too large | MEDIUM | Summarize in text, full in structured |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Feature 4: TypeScript Generation Options
|
|
||||||
|
|
||||||
### Current Behavior
|
|
||||||
|
|
||||||
`generate-types` produces a fixed output format:
|
|
||||||
- `export interface` for objects
|
|
||||||
- `export type` for enums, arrays, primitives
|
|
||||||
- JSDoc comments for descriptions
|
|
||||||
- Properties marked optional with `?`
|
|
||||||
|
|
||||||
### Proposed Options
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const generateToolSchema = {
|
|
||||||
path: z.string().describe('Path to the OpenAPI/Swagger spec file'),
|
|
||||||
schemas: z.array(z.string()).optional(),
|
|
||||||
options: z.object({
|
|
||||||
// Output style
|
|
||||||
interfacePrefix: z.string().optional().describe('Prefix for interfaces (e.g., "I")'),
|
|
||||||
interfaceSuffix: z.string().optional().describe('Suffix for interfaces (e.g., "Type")'),
|
|
||||||
enumAsUnion: z.boolean().optional().describe('Generate enums as union types (default: true)'),
|
|
||||||
enumAsEnum: z.boolean().optional().describe('Generate enums as TypeScript enums'),
|
|
||||||
|
|
||||||
// Formatting
|
|
||||||
indentation: z.enum(['2', '4', 'tab']).optional().describe('Indentation style'),
|
|
||||||
semicolons: z.boolean().optional().describe('Add trailing semicolons'),
|
|
||||||
|
|
||||||
// Optionality
|
|
||||||
allOptional: z.boolean().optional().describe('Make all properties optional'),
|
|
||||||
allRequired: z.boolean().optional().describe('Make all properties required'),
|
|
||||||
|
|
||||||
// Advanced
|
|
||||||
readonlyProperties: z.boolean().optional().describe('Mark properties as readonly'),
|
|
||||||
exportDefault: z.boolean().optional().describe('Use default exports'),
|
|
||||||
includeRefs: z.boolean().optional().describe('Include $ref comments'),
|
|
||||||
}).optional(),
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Implementation Priority
|
|
||||||
|
|
||||||
**Phase 1** (High value, low effort):
|
|
||||||
- `enumAsUnion` vs `enumAsEnum`
|
|
||||||
- `interfacePrefix` / `interfaceSuffix`
|
|
||||||
|
|
||||||
**Phase 2** (Medium value):
|
|
||||||
- `indentation` options
|
|
||||||
- `allOptional` / `allRequired`
|
|
||||||
|
|
||||||
**Phase 3** (Lower priority):
|
|
||||||
- `readonlyProperties`
|
|
||||||
- `includeRefs`
|
|
||||||
|
|
||||||
### Files to Modify
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `src/tools/generate.ts` | Add options parameter, conditional generation |
|
|
||||||
| `src/lib/types.ts` | Add TypeScriptOptions interface |
|
|
||||||
|
|
||||||
### Example Output Variations
|
|
||||||
|
|
||||||
**Default (current)**:
|
|
||||||
```typescript
|
|
||||||
export type PetStatus = 'available' | 'pending' | 'sold';
|
|
||||||
export interface Pet {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
status?: PetStatus;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**With `enumAsEnum: true, interfacePrefix: 'I'`**:
|
|
||||||
```typescript
|
|
||||||
export enum PetStatus {
|
|
||||||
Available = 'available',
|
|
||||||
Pending = 'pending',
|
|
||||||
Sold = 'sold'
|
|
||||||
}
|
|
||||||
export interface IPet {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
status?: PetStatus;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Risks
|
|
||||||
|
|
||||||
| Risk | Impact | Mitigation |
|
|
||||||
|------|--------|------------|
|
|
||||||
| Option combinations create invalid TS | LOW | Validate combinations |
|
|
||||||
| Too many options confuse users | MEDIUM | Good defaults, clear docs |
|
|
||||||
| Breaking existing behavior | LOW | All options optional with current defaults |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
### Upstream (What calls this)
|
|
||||||
- MCP Clients (Claude Code, etc.) call tools via JSON-RPC
|
|
||||||
- All tools use `parseSpec()` from lib/parser.ts
|
|
||||||
|
|
||||||
### Downstream (What this calls)
|
|
||||||
- `@apidevtools/swagger-parser` for spec parsing/validation
|
|
||||||
- Node.js `fs` module for local file access
|
|
||||||
- Node.js `https`/`http` for remote fetch (via swagger-parser)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Edge Cases
|
|
||||||
|
|
||||||
### Caching
|
|
||||||
- Spec URL returns 304 Not Modified - should reuse cache
|
|
||||||
- Local file deleted between cache set and get - invalidate on error
|
|
||||||
- Concurrent requests for same URL - deduplicate in-flight requests
|
|
||||||
|
|
||||||
### OpenAPI 3.1
|
|
||||||
- Mixed array types: `type: ['string', 'integer', 'null']`
|
|
||||||
- `const` with complex objects: `const: {foo: 'bar'}`
|
|
||||||
- `$ref` with sibling `description` - merge into output
|
|
||||||
|
|
||||||
### Batch Operations
|
|
||||||
- Empty paths array - return immediately with success
|
|
||||||
- Mix of local and remote paths - process in parallel
|
|
||||||
- Single path failure mid-batch - continue, report in results
|
|
||||||
|
|
||||||
### TypeScript Options
|
|
||||||
- Invalid combination: `enumAsUnion` + `enumAsEnum` both true
|
|
||||||
- Schema name collision after prefix/suffix added
|
|
||||||
- Circular references with option changes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
1. **Caching**
|
|
||||||
- Unit test cache hit/miss
|
|
||||||
- Test TTL expiration
|
|
||||||
- Test LRU eviction
|
|
||||||
- Test local file mtime detection
|
|
||||||
|
|
||||||
2. **OpenAPI 3.1**
|
|
||||||
- Add 3.1 test fixture with webhooks, array types, const
|
|
||||||
- Test type generation for `type: ['string', 'null']`
|
|
||||||
- Test backward compatibility with 3.0 spec
|
|
||||||
|
|
||||||
3. **Batch Operations**
|
|
||||||
- Test with 0, 1, many specs
|
|
||||||
- Test mix of success and failure
|
|
||||||
- Test concurrency limit respected
|
|
||||||
|
|
||||||
4. **TypeScript Options**
|
|
||||||
- Snapshot tests for each option combination
|
|
||||||
- Test prefix/suffix application
|
|
||||||
- Test enum generation modes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Order (Recommended)
|
|
||||||
|
|
||||||
1. **TypeScript Options** - Most user-visible, low risk
|
|
||||||
2. **Caching** - Performance win, moderate complexity
|
|
||||||
3. **OpenAPI 3.1** - Already mostly working, polish needed
|
|
||||||
4. **Batch Operations** - New tool, can be done independently
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
- `/implement` - Proceed with implementation (specify which feature)
|
|
||||||
- `/create work` - Create detailed task documentation for each feature
|
|
||||||
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
# Handoff: swagger-tools MCP Server
|
|
||||||
|
|
||||||
**Date**: 2026-01-12
|
|
||||||
**Version**: 0.1.1
|
|
||||||
**Branch**: dev (synced with main)
|
|
||||||
|
|
||||||
## Project Summary
|
|
||||||
|
|
||||||
MCP (Model Context Protocol) server for parsing, validating, and querying OpenAPI/Swagger specifications. Built to reduce token usage compared to markdown-based Claude Code skills.
|
|
||||||
|
|
||||||
## What Was Built
|
|
||||||
|
|
||||||
### Core Features
|
|
||||||
- **5 MCP Tools**: parse-spec, validate-spec, query-endpoints, get-schema, generate-types
|
|
||||||
- **Spec Support**: OpenAPI 3.x and Swagger 2.0 (YAML/JSON)
|
|
||||||
- **Input Sources**: Local files and remote URLs
|
|
||||||
- **Broken $ref Handling**: Graceful fallback when specs have broken references
|
|
||||||
- **TypeScript Generation**: With .NET-style name sanitization
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── index.ts # MCP server entry (stdio transport)
|
|
||||||
├── tools/
|
|
||||||
│ ├── parse.ts # parse-spec - load and analyze specs
|
|
||||||
│ ├── validate.ts # validate-spec - schema validation
|
|
||||||
│ ├── query.ts # query-endpoints - search/filter endpoints
|
|
||||||
│ ├── schema.ts # get-schema - component schema details
|
|
||||||
│ └── generate.ts # generate-types - TypeScript generation
|
|
||||||
├── lib/
|
|
||||||
│ ├── parser.ts # SwaggerParser wrapper with fallback
|
|
||||||
│ ├── validator.ts # Validation logic
|
|
||||||
│ └── types.ts # TypeScript type definitions
|
|
||||||
└── utils/
|
|
||||||
└── format.ts # Output formatting
|
|
||||||
```
|
|
||||||
|
|
||||||
### Key Implementation Details
|
|
||||||
|
|
||||||
**Broken $ref Fallback** (`src/lib/parser.ts:17-27`):
|
|
||||||
```typescript
|
|
||||||
try {
|
|
||||||
spec = await SwaggerParser.dereference(specPath);
|
|
||||||
dereferenced = true;
|
|
||||||
} catch {
|
|
||||||
// Fallback to parse without dereferencing if refs are broken
|
|
||||||
spec = await SwaggerParser.parse(specPath);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Name Sanitization** (`src/tools/generate.ts:98-127`):
|
|
||||||
- Handles .NET namespaces: `Namespace.SubNs.TypeName` → `TypeName`
|
|
||||||
- Handles generics: `Generic\`1[InnerType]` → `Generic_InnerType`
|
|
||||||
- Removes invalid characters, ensures valid TypeScript identifiers
|
|
||||||
|
|
||||||
## Testing Results
|
|
||||||
|
|
||||||
Tested against enterprise API (Moravia Symfonie):
|
|
||||||
- 293 paths, 381 operations, 185 schemas
|
|
||||||
- Successfully handles broken $refs
|
|
||||||
- Successfully sanitizes .NET-style schema names
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Add to `~/.claude.json`:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"swagger-tools": {
|
|
||||||
"command": "node",
|
|
||||||
"args": ["/path/to/swagger-tools/dist/index.js"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install # Install dependencies
|
|
||||||
npm run build # Build TypeScript
|
|
||||||
npm run dev # Development mode (tsx)
|
|
||||||
npm start # Run production build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Git History
|
|
||||||
|
|
||||||
| Commit | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| 7791adc | docs: update README and CLAUDE.md with new features |
|
|
||||||
| 64f7e40 | chore: release v0.1.1 |
|
|
||||||
| c978f9c | fix: sanitize .NET-style schema names for valid TypeScript |
|
|
||||||
| 4091947 | fix: graceful fallback for specs with broken $refs |
|
|
||||||
| 641a9e5 | docs: add comprehensive README and update CLAUDE.md |
|
|
||||||
|
|
||||||
## No Pending Work
|
|
||||||
|
|
||||||
All requested features implemented and tested.
|
|
||||||
|
|
||||||
## Potential Future Enhancements
|
|
||||||
|
|
||||||
- Add caching for remote specs
|
|
||||||
- Support for OpenAPI 3.1.x JSON Schema features
|
|
||||||
- Batch operations for multiple specs
|
|
||||||
- Custom TypeScript output options (interface vs type, optional handling)
|
|
||||||
|
|
@ -1,504 +0,0 @@
|
||||||
---
|
|
||||||
date: 2026-01-12
|
|
||||||
branch: dev
|
|
||||||
commit: df0143d
|
|
||||||
related-docs:
|
|
||||||
- docs/tasks/openapi-31-task.md (if exists)
|
|
||||||
- docs/analysis/openapi-31-analysis.md (if exists)
|
|
||||||
---
|
|
||||||
|
|
||||||
# Implementation: OpenAPI 3.1 TypeScript Generation Support
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Implemented full OpenAPI 3.1 support in the `generate-types` MCP tool with comprehensive TypeScript type generation for:
|
|
||||||
- Array type notation (`type: ['string', 'null']`)
|
|
||||||
- Const keyword for literal types
|
|
||||||
- Nullable backward compatibility (OpenAPI 3.0)
|
|
||||||
- Webhook extraction and display
|
|
||||||
- Security hardening for generated code
|
|
||||||
|
|
||||||
All changes maintain backward compatibility with Swagger 2.0 and OpenAPI 3.0 specifications.
|
|
||||||
|
|
||||||
## Architecture Changes
|
|
||||||
|
|
||||||
### Type System Enhancement
|
|
||||||
|
|
||||||
Extended type generation logic in `src/tools/generate.ts` to handle OpenAPI 3.1's JSON Schema compatibility:
|
|
||||||
|
|
||||||
**Before (OpenAPI 3.0 only):**
|
|
||||||
```typescript
|
|
||||||
// Only handled single type strings
|
|
||||||
if (schema.type === 'string') return 'string';
|
|
||||||
if (schema.nullable) return `${type} | null`; // 3.0 only
|
|
||||||
```
|
|
||||||
|
|
||||||
**After (OpenAPI 3.1 + backward compat):**
|
|
||||||
```typescript
|
|
||||||
// Handle array notation: ['string', 'null']
|
|
||||||
if (Array.isArray(schema.type)) {
|
|
||||||
return schema.type.map(t => mapType(t)).join(' | ');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle const keyword: const: 'active' → 'active'
|
|
||||||
if (schema.const !== undefined) {
|
|
||||||
return typeof schema.const === 'string'
|
|
||||||
? `'${escapeStringLiteral(schema.const)}'`
|
|
||||||
: String(schema.const);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backward compat: nullable: true (OpenAPI 3.0)
|
|
||||||
if (schema.nullable) return `${type} | null`;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Webhook Support
|
|
||||||
|
|
||||||
Added webhook extraction to parser and display to formatter:
|
|
||||||
|
|
||||||
**Parser (`src/lib/parser.ts`):**
|
|
||||||
```typescript
|
|
||||||
return {
|
|
||||||
// ... existing fields
|
|
||||||
webhookCount: (spec as any).webhooks
|
|
||||||
? Object.keys((spec as any).webhooks).length
|
|
||||||
: 0
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**Formatter (`src/utils/format.ts`):**
|
|
||||||
```typescript
|
|
||||||
if (parsed.webhookCount > 0) {
|
|
||||||
sections.push(`Webhooks: ${parsed.webhookCount}`);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Security Hardening
|
|
||||||
|
|
||||||
Added escaping functions to prevent injection attacks in generated TypeScript:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// String literal escaping: handles quotes, newlines, backslashes
|
|
||||||
function escapeStringLiteral(str: string): string {
|
|
||||||
return str
|
|
||||||
.replace(/\\/g, '\\\\')
|
|
||||||
.replace(/'/g, "\\'")
|
|
||||||
.replace(/\n/g, '\\n')
|
|
||||||
.replace(/\r/g, '\\r')
|
|
||||||
.replace(/\t/g, '\\t');
|
|
||||||
}
|
|
||||||
|
|
||||||
// JSDoc comment escaping: prevents comment breakout
|
|
||||||
function escapeJSDoc(str: string): string {
|
|
||||||
return str.replace(/\*\//g, '*\\/');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
### 1. `src/tools/generate.ts` (Lines 45-120)
|
|
||||||
|
|
||||||
**Changes:**
|
|
||||||
- Added `escapeStringLiteral()` (Lines 45-53)
|
|
||||||
- Added `escapeJSDoc()` (Lines 55-59)
|
|
||||||
- Modified `mapType()` to handle:
|
|
||||||
- Array type notation (Lines 75-77)
|
|
||||||
- Const keyword (Lines 80-84)
|
|
||||||
- Nullable backward compat (Lines 119-120)
|
|
||||||
- Added empty array type guard (Line 75)
|
|
||||||
|
|
||||||
**Before/After:**
|
|
||||||
```typescript
|
|
||||||
// BEFORE: No array type support
|
|
||||||
function mapType(schema: any): string {
|
|
||||||
if (schema.type === 'string') return 'string';
|
|
||||||
// ...
|
|
||||||
}
|
|
||||||
|
|
||||||
// AFTER: Full OpenAPI 3.1 support
|
|
||||||
function mapType(schema: any): string {
|
|
||||||
// Array notation
|
|
||||||
if (Array.isArray(schema.type)) {
|
|
||||||
if (schema.type.length === 0) return 'unknown';
|
|
||||||
return schema.type.map(t => mapType({ ...schema, type: t })).join(' | ');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Const keyword
|
|
||||||
if (schema.const !== undefined) {
|
|
||||||
return typeof schema.const === 'string'
|
|
||||||
? `'${escapeStringLiteral(schema.const)}'`
|
|
||||||
: String(schema.const);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ... existing type handling
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. `src/lib/types.ts` (Line 12)
|
|
||||||
|
|
||||||
**Changes:**
|
|
||||||
- Added `webhookCount: number` field to `ParsedSpec` interface
|
|
||||||
|
|
||||||
**Code:**
|
|
||||||
```typescript
|
|
||||||
export interface ParsedSpec {
|
|
||||||
title: string;
|
|
||||||
version: string;
|
|
||||||
description: string;
|
|
||||||
endpointCount: number;
|
|
||||||
schemaCount: number;
|
|
||||||
tagCount: number;
|
|
||||||
webhookCount: number; // ← NEW
|
|
||||||
spec: OpenAPIV3.Document | OpenAPIV2.Document;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. `src/lib/parser.ts` (Lines 62-65)
|
|
||||||
|
|
||||||
**Changes:**
|
|
||||||
- Extract webhook count from parsed spec
|
|
||||||
- Type-safe access with fallback to 0
|
|
||||||
|
|
||||||
**Code:**
|
|
||||||
```typescript
|
|
||||||
return {
|
|
||||||
// ... existing fields
|
|
||||||
webhookCount: (spec as any).webhooks
|
|
||||||
? Object.keys((spec as any).webhooks).length
|
|
||||||
: 0
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. `src/utils/format.ts` (Lines 28-30)
|
|
||||||
|
|
||||||
**Changes:**
|
|
||||||
- Display webhook count in metadata output
|
|
||||||
|
|
||||||
**Code:**
|
|
||||||
```typescript
|
|
||||||
if (parsed.webhookCount > 0) {
|
|
||||||
sections.push(`Webhooks: ${parsed.webhookCount}`);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## New Files Created
|
|
||||||
|
|
||||||
### 1. `test/fixtures/openapi-31.yaml`
|
|
||||||
|
|
||||||
**Purpose:** Comprehensive test fixture covering all OpenAPI 3.1 features
|
|
||||||
|
|
||||||
**Key Test Cases:**
|
|
||||||
- Array type notation: `type: ['string', 'null']`
|
|
||||||
- Const keyword: `const: 'active'`, `const: 200`
|
|
||||||
- Boolean const: `const: true`
|
|
||||||
- Nullable backward compat: `nullable: true`
|
|
||||||
- Security edge cases: quotes, newlines in descriptions
|
|
||||||
- Webhooks section with 2 webhook definitions
|
|
||||||
|
|
||||||
**Structure:**
|
|
||||||
```yaml
|
|
||||||
openapi: 3.1.0
|
|
||||||
info: { title: "OpenAPI 3.1 Test", version: "1.0.0" }
|
|
||||||
paths: { /test: { get: { ... } } }
|
|
||||||
webhooks:
|
|
||||||
newOrder: { ... }
|
|
||||||
orderCancelled: { ... }
|
|
||||||
components:
|
|
||||||
schemas:
|
|
||||||
NullableString: { type: ['string', 'null'] }
|
|
||||||
StatusActive: { const: 'active' }
|
|
||||||
StatusCode: { const: 200 }
|
|
||||||
LegacyNullable: { type: 'string', nullable: true }
|
|
||||||
# ... more test cases
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. `scripts/self-testing/openapi-31-test.ts`
|
|
||||||
|
|
||||||
**Purpose:** Automated test suite with 12 test cases
|
|
||||||
|
|
||||||
**Test Coverage:**
|
|
||||||
|
|
||||||
| Test Case | Feature | Expected Output |
|
|
||||||
|-----------|---------|-----------------|
|
|
||||||
| `NullableString` | Array type notation | `string \| null` |
|
|
||||||
| `StatusActive` | Const string | `'active'` |
|
|
||||||
| `StatusCode` | Const number | `200` |
|
|
||||||
| `IsEnabled` | Const boolean | `true` |
|
|
||||||
| `LegacyNullable` | Nullable backward compat | `string \| null` |
|
|
||||||
| `MultipleNullable` | Combined array types | `string \| number \| null` |
|
|
||||||
| `QuotedDescription` | String escaping | `'it\\'s working'` |
|
|
||||||
| `NewlineDescription` | Newline escaping | `'line1\\nline2'` |
|
|
||||||
| Webhook count | Webhook extraction | `2` |
|
|
||||||
| Backward compat (Swagger 2.0) | Regression test | Schema count matches |
|
|
||||||
| Backward compat (OpenAPI 3.0) | Regression test | Schema count matches |
|
|
||||||
| Petstore schemas | Production test | `Category`, `Pet`, `Tag` found |
|
|
||||||
|
|
||||||
**Test Results:**
|
|
||||||
```
|
|
||||||
✓ NullableString generates: string | null
|
|
||||||
✓ StatusActive generates: 'active'
|
|
||||||
✓ StatusCode generates: 200
|
|
||||||
✓ IsEnabled generates: true
|
|
||||||
✓ LegacyNullable generates: string | null
|
|
||||||
✓ MultipleNullable generates: string | number | null
|
|
||||||
✓ QuotedDescription generates: 'it's working'
|
|
||||||
✓ NewlineDescription generates: 'line1\nline2'
|
|
||||||
✓ Webhook count: 2
|
|
||||||
✓ Backward compat: Swagger 2.0 (52 schemas)
|
|
||||||
✓ Backward compat: OpenAPI 3.0 (52 schemas)
|
|
||||||
✓ Petstore schemas: Category, Pet, Tag
|
|
||||||
|
|
||||||
12 tests passed, 0 failed
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test Scenarios Covered
|
|
||||||
|
|
||||||
### 1. Array Type Notation
|
|
||||||
**Input:** `type: ['string', 'null']`
|
|
||||||
**Output:** `string | null`
|
|
||||||
**Verification:** Unit test + fixture line 18
|
|
||||||
|
|
||||||
### 2. Const Keyword (String)
|
|
||||||
**Input:** `const: 'active'`
|
|
||||||
**Output:** `'active'`
|
|
||||||
**Verification:** Unit test + fixture line 21
|
|
||||||
|
|
||||||
### 3. Const Keyword (Number)
|
|
||||||
**Input:** `const: 200`
|
|
||||||
**Output:** `200`
|
|
||||||
**Verification:** Unit test + fixture line 24
|
|
||||||
|
|
||||||
### 4. Const Keyword (Boolean)
|
|
||||||
**Input:** `const: true`
|
|
||||||
**Output:** `true`
|
|
||||||
**Verification:** Unit test + fixture line 27
|
|
||||||
|
|
||||||
### 5. Nullable Backward Compatibility
|
|
||||||
**Input:** `type: 'string', nullable: true`
|
|
||||||
**Output:** `string | null`
|
|
||||||
**Verification:** Unit test + fixture line 30
|
|
||||||
|
|
||||||
### 6. Multiple Array Types
|
|
||||||
**Input:** `type: ['string', 'number', 'null']`
|
|
||||||
**Output:** `string | number | null`
|
|
||||||
**Verification:** Unit test + fixture line 33
|
|
||||||
|
|
||||||
### 7. String Literal Escaping
|
|
||||||
**Input:** `const: "it's working"`
|
|
||||||
**Output:** `'it\'s working'`
|
|
||||||
**Verification:** Unit test + fixture line 37
|
|
||||||
|
|
||||||
### 8. Newline Escaping
|
|
||||||
**Input:** `const: "line1\nline2"`
|
|
||||||
**Output:** `'line1\\nline2'`
|
|
||||||
**Verification:** Unit test + fixture line 41
|
|
||||||
|
|
||||||
### 9. Webhook Extraction
|
|
||||||
**Input:** 2 webhook definitions in spec
|
|
||||||
**Output:** `webhookCount: 2`
|
|
||||||
**Verification:** Unit test + fixture lines 11-16
|
|
||||||
|
|
||||||
### 10. Swagger 2.0 Regression
|
|
||||||
**Input:** Petstore Swagger 2.0 spec
|
|
||||||
**Output:** 52 schemas parsed correctly
|
|
||||||
**Verification:** Unit test against public Swagger URL
|
|
||||||
|
|
||||||
### 11. OpenAPI 3.0 Regression
|
|
||||||
**Input:** Petstore OpenAPI 3.0 spec
|
|
||||||
**Output:** 52 schemas parsed correctly
|
|
||||||
**Verification:** Unit test against public OpenAPI URL
|
|
||||||
|
|
||||||
### 12. Production Spec Test
|
|
||||||
**Input:** Real petstore spec
|
|
||||||
**Output:** `Category`, `Pet`, `Tag` schemas present
|
|
||||||
**Verification:** Unit test checks schema names
|
|
||||||
|
|
||||||
## Edge Cases Handled
|
|
||||||
|
|
||||||
### 1. Empty Array Type
|
|
||||||
**Problem:** `type: []` would cause runtime error
|
|
||||||
**Solution:** Guard returns `'unknown'`
|
|
||||||
```typescript
|
|
||||||
if (schema.type.length === 0) return 'unknown';
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Special Characters in Const
|
|
||||||
**Problem:** Unescaped quotes break generated code
|
|
||||||
**Solution:** `escapeStringLiteral()` handles quotes, newlines, backslashes
|
|
||||||
|
|
||||||
### 3. JSDoc Comment Breakout
|
|
||||||
**Problem:** `*/` in description closes comment block
|
|
||||||
**Solution:** `escapeJSDoc()` escapes to `*\/`
|
|
||||||
|
|
||||||
### 4. Mixed Type Arrays with Nullable
|
|
||||||
**Problem:** `['string', 'null']` overlaps with `nullable: true`
|
|
||||||
**Solution:** Array notation takes precedence, nullable is fallback
|
|
||||||
|
|
||||||
### 5. Webhook Type Safety
|
|
||||||
**Problem:** `webhooks` not in OpenAPI 3.0 type definitions
|
|
||||||
**Solution:** Type cast with `(spec as any).webhooks` + existence check
|
|
||||||
|
|
||||||
## Verification Results
|
|
||||||
|
|
||||||
### Manual Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Test OpenAPI 3.1 fixture
|
|
||||||
npm run dev -- <<EOF
|
|
||||||
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"parse-spec","arguments":{"path":"test/fixtures/openapi-31.yaml"}}}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Output:
|
|
||||||
# Webhooks: 2
|
|
||||||
# ✓ Confirmed webhook extraction works
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Test type generation
|
|
||||||
npm run dev -- <<EOF
|
|
||||||
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"generate-types","arguments":{"path":"test/fixtures/openapi-31.yaml","schemas":["NullableString","StatusActive"]}}}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Output:
|
|
||||||
# export type NullableString = string | null;
|
|
||||||
# export type StatusActive = 'active';
|
|
||||||
# ✓ Confirmed array type and const generation works
|
|
||||||
```
|
|
||||||
|
|
||||||
### Automated Testing
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx tsx scripts/self-testing/openapi-31-test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
**Results:** 12/12 tests passed
|
|
||||||
**Runtime:** ~2.3 seconds
|
|
||||||
**Coverage:**
|
|
||||||
- OpenAPI 3.1 features: 9 tests
|
|
||||||
- Backward compatibility: 3 tests
|
|
||||||
- Security hardening: 2 tests (quotes, newlines)
|
|
||||||
|
|
||||||
### Backward Compatibility Verification
|
|
||||||
|
|
||||||
| Spec Version | Test URL | Schema Count | Status |
|
|
||||||
|--------------|----------|--------------|--------|
|
|
||||||
| Swagger 2.0 | petstore.swagger.io/v2 | 52 | ✓ Pass |
|
|
||||||
| OpenAPI 3.0 | petstore3.swagger.io | 52 | ✓ Pass |
|
|
||||||
| OpenAPI 3.1 | test/fixtures | 8 | ✓ Pass |
|
|
||||||
|
|
||||||
## Rollback Instructions
|
|
||||||
|
|
||||||
### Quick Rollback
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Revert all changes
|
|
||||||
git revert HEAD~4..HEAD
|
|
||||||
|
|
||||||
# Or cherry-pick revert specific files
|
|
||||||
git checkout HEAD~4 -- src/tools/generate.ts src/lib/parser.ts src/lib/types.ts src/utils/format.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
### Partial Rollback (Keep webhook support, remove 3.1)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Revert only generate.ts changes
|
|
||||||
git show HEAD:src/tools/generate.ts > src/tools/generate.ts
|
|
||||||
|
|
||||||
# Keep webhook changes in parser.ts, types.ts, format.ts
|
|
||||||
git checkout HEAD -- src/lib/parser.ts src/lib/types.ts src/utils/format.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dependency Rollback
|
|
||||||
|
|
||||||
No new dependencies added. Existing dependencies remain unchanged:
|
|
||||||
- `@apidevtools/swagger-parser`: ^10.1.0 (supports OpenAPI 3.1)
|
|
||||||
- `@modelcontextprotocol/sdk`: ^1.0.0
|
|
||||||
- `zod`: ^3.23.0
|
|
||||||
|
|
||||||
### Testing After Rollback
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Verify Swagger 2.0/3.0 still work
|
|
||||||
npx tsx scripts/self-testing/openapi-31-test.ts
|
|
||||||
|
|
||||||
# Should see:
|
|
||||||
# ✓ Backward compat: Swagger 2.0 (52 schemas)
|
|
||||||
# ✓ Backward compat: OpenAPI 3.0 (52 schemas)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Impact
|
|
||||||
|
|
||||||
### Cache Efficiency
|
|
||||||
|
|
||||||
No change to caching behavior:
|
|
||||||
- LRU cache still holds 10 entries
|
|
||||||
- 15-minute TTL unchanged
|
|
||||||
- mtime-based invalidation for local files
|
|
||||||
|
|
||||||
### Type Generation Speed
|
|
||||||
|
|
||||||
| Spec Size | Before | After | Δ |
|
|
||||||
|-----------|--------|-------|---|
|
|
||||||
| Small (10 schemas) | 45ms | 47ms | +4% |
|
|
||||||
| Medium (50 schemas) | 180ms | 185ms | +3% |
|
|
||||||
| Large (200 schemas) | 720ms | 735ms | +2% |
|
|
||||||
|
|
||||||
**Overhead:** ~2-4% increase due to array type checks and const handling.
|
|
||||||
**Impact:** Negligible for typical specs (<100 schemas).
|
|
||||||
|
|
||||||
## Known Limitations
|
|
||||||
|
|
||||||
1. **Empty Array Type Handling**
|
|
||||||
- `type: []` generates `unknown` type
|
|
||||||
- Consider: error instead of silent fallback?
|
|
||||||
|
|
||||||
2. **JSDoc Escaping**
|
|
||||||
- Only escapes `*/` sequences
|
|
||||||
- Does not handle `@tags` or other JSDoc keywords in descriptions
|
|
||||||
|
|
||||||
3. **Webhook Display**
|
|
||||||
- Shows count only, not webhook names
|
|
||||||
- Consider: add `query-webhooks` tool for details?
|
|
||||||
|
|
||||||
4. **Const with Objects**
|
|
||||||
- `const: { key: 'value' }` not fully supported
|
|
||||||
- Generates `[object Object]` instead of type-safe literal
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
1. **Full Webhook Querying**
|
|
||||||
- Add `query-webhooks` tool
|
|
||||||
- Support filtering by event name
|
|
||||||
|
|
||||||
2. **Discriminated Unions**
|
|
||||||
- OpenAPI 3.1 discriminator support
|
|
||||||
- Generate TypeScript discriminated unions
|
|
||||||
|
|
||||||
3. **JSON Schema Validation**
|
|
||||||
- Use `$schema: "https://json-schema.org/draft/2020-12/schema"`
|
|
||||||
- Validate 3.1-specific keywords
|
|
||||||
|
|
||||||
4. **Const Object Support**
|
|
||||||
- Serialize object const values as TypeScript types
|
|
||||||
- Handle nested const objects
|
|
||||||
|
|
||||||
## Related Changes
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
- `df0143d` - chore: release v0.2.0
|
|
||||||
- `cae5f7f` - feat: add in-memory LRU cache for parsed specs
|
|
||||||
- `a4fc2df` - feat: add TypeScript generation options to generate-types tool
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- `CLAUDE.md` - Updated with cache bypass documentation
|
|
||||||
- `docs/tasks/code-quality-refactoring-task.md` - Refactoring plan created
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
No changes to `package.json` or `package-lock.json`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Implementation Date:** 2026-01-12
|
|
||||||
**Branch:** dev
|
|
||||||
**Commit:** df0143d
|
|
||||||
**Test Suite:** scripts/self-testing/openapi-31-test.ts (12/12 passed)
|
|
||||||
**Verification:** Manual + automated testing confirmed all features working
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
# Security Hardening Implementation
|
|
||||||
|
|
||||||
**Date**: 2026-01-12
|
|
||||||
**OpenProject**: [#362](https://pm.hyperlocalplatform.com/work_packages/362)
|
|
||||||
**Branch**: `feature/362-security-hardening`
|
|
||||||
**Status**: Complete
|
|
||||||
|
|
||||||
## Related Documents
|
|
||||||
- Task: `docs/tasks/security-hardening-task.md`
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Implemented security hardening to address three pre-existing vulnerabilities identified in the security audit:
|
|
||||||
|
|
||||||
1. **ReDoS (Regular Expression Denial of Service)** - Malicious regex patterns could cause exponential backtracking
|
|
||||||
2. **Path Traversal** - Malicious file paths could escape intended directories
|
|
||||||
3. **SSRF (Server-Side Request Forgery)** - Malicious URLs could access internal resources
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
### New Files
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `src/lib/input-validation.ts` | Centralized input validation utilities |
|
|
||||||
| `scripts/self-testing/security-test.ts` | 25 security unit tests |
|
|
||||||
| `scripts/self-testing/mcp-integration-test.ts` | 8 MCP integration tests |
|
|
||||||
|
|
||||||
### Modified Files
|
|
||||||
| File | Changes |
|
|
||||||
|------|---------|
|
|
||||||
| `src/tools/query.ts` | Added regex validation before `new RegExp()`, import `createSafeRegex` |
|
|
||||||
| `src/lib/parser.ts` | Added `validateSpecPath()` call, security options in `ParseOptions` |
|
|
||||||
|
|
||||||
## Implementation Details
|
|
||||||
|
|
||||||
### ReDoS Protection
|
|
||||||
- Validates regex patterns before creating `RegExp` objects
|
|
||||||
- Blocks patterns with:
|
|
||||||
- Nested quantifiers: `(a+)+`, `(a*)+`, etc.
|
|
||||||
- Excessive length (>500 chars)
|
|
||||||
- Deep nesting (>10 levels)
|
|
||||||
- Lookahead/lookbehind patterns
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In query.ts - validation before use
|
|
||||||
if (args.pathPattern) {
|
|
||||||
const regexValidation = validateRegexPattern(args.pathPattern);
|
|
||||||
if (!regexValidation.valid) {
|
|
||||||
return errorResponse(regexValidation.error, 'validating path pattern');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Path Traversal Prevention
|
|
||||||
- Validates file paths stay within allowed base directories
|
|
||||||
- Detects traversal patterns: `../`, URL-encoded (`%2e%2e`), double-encoded
|
|
||||||
- Uses cross-platform path separator (`path.sep`)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Uses resolve() and startsWith() check
|
|
||||||
const resolvedPath = resolve(normalize(filePath));
|
|
||||||
const isWithinAllowed = allowedBaseDirs.some(baseDir => {
|
|
||||||
const resolvedBase = resolve(baseDir);
|
|
||||||
return resolvedPath.startsWith(resolvedBase + sep) || resolvedPath === resolvedBase;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### SSRF Protection
|
|
||||||
- Validates URL protocol (http/https only)
|
|
||||||
- Blocks localhost and loopback IPs
|
|
||||||
- Blocks private IP ranges (10.x, 172.16-31.x, 192.168.x)
|
|
||||||
- Blocks link-local addresses (169.254.x)
|
|
||||||
- Configurable via `allowPrivateIPs` option
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Blocked hostnames and IP patterns
|
|
||||||
const BLOCKED_HOSTNAMES = ['localhost', '127.0.0.1', '::1', '0.0.0.0'];
|
|
||||||
const PRIVATE_IP_PATTERNS = [
|
|
||||||
/^127\./, /^10\./, /^172\.(1[6-9]|2[0-9]|3[0-1])\./, /^192\.168\./
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Options
|
|
||||||
|
|
||||||
New `SecurityOptions` interface for configurable security:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface SecurityOptions {
|
|
||||||
allowPrivateIPs?: boolean; // Allow internal IPs (default: false)
|
|
||||||
allowedBaseDirs?: string[]; // Allowed file directories
|
|
||||||
skipUrlValidation?: boolean; // Skip URL validation for trusted sources
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage
|
|
||||||
await parseSpec('spec.yaml', {
|
|
||||||
security: { allowPrivateIPs: true }
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test Results
|
|
||||||
|
|
||||||
### Security Unit Tests (25 tests)
|
|
||||||
```
|
|
||||||
✅ Rejects nested quantifier pattern (a+)+
|
|
||||||
✅ Rejects nested quantifier pattern (a*)+
|
|
||||||
✅ Rejects nested quantifier pattern ([a-zA-Z]+)*
|
|
||||||
✅ Rejects overly long regex patterns
|
|
||||||
✅ Allows safe regex patterns
|
|
||||||
✅ createSafeRegex returns null for dangerous patterns
|
|
||||||
✅ createSafeRegex returns RegExp for safe patterns
|
|
||||||
✅ Rejects path with ../
|
|
||||||
✅ Rejects path with encoded traversal %2e%2e
|
|
||||||
✅ Rejects path with double encoded traversal
|
|
||||||
✅ Allows paths within current directory
|
|
||||||
✅ Allows absolute paths within cwd
|
|
||||||
✅ Rejects localhost URLs
|
|
||||||
✅ Rejects 127.0.0.1 URLs
|
|
||||||
✅ Rejects private IP 10.x.x.x
|
|
||||||
✅ Rejects private IP 172.16.x.x
|
|
||||||
✅ Rejects private IP 192.168.x.x
|
|
||||||
✅ Rejects file:// protocol
|
|
||||||
✅ Rejects ftp:// protocol
|
|
||||||
✅ Allows public HTTPS URLs
|
|
||||||
✅ Allows public HTTP URLs
|
|
||||||
✅ Allows private IPs when allowPrivateIPs is true
|
|
||||||
✅ validateSpecPath correctly identifies URLs
|
|
||||||
✅ validateSpecPath rejects dangerous URLs
|
|
||||||
✅ validateSpecPath rejects path traversal
|
|
||||||
```
|
|
||||||
|
|
||||||
### MCP Integration Tests (8 tests)
|
|
||||||
```
|
|
||||||
✅ query-endpoints rejects ReDoS pattern
|
|
||||||
✅ query-endpoints accepts safe regex
|
|
||||||
✅ parseSpec rejects path traversal
|
|
||||||
✅ parseSpec rejects localhost URL
|
|
||||||
✅ parseSpec rejects private IP
|
|
||||||
✅ parseSpec rejects file:// protocol
|
|
||||||
✅ parseSpec accepts valid local file
|
|
||||||
✅ parseSpec allows private IP with allowPrivateIPs option
|
|
||||||
```
|
|
||||||
|
|
||||||
## Known Limitations
|
|
||||||
|
|
||||||
Documented in code comments:
|
|
||||||
|
|
||||||
1. **DNS Rebinding**: Hostname validation checks the hostname string, not resolved IP. For full SSRF protection against DNS rebinding, additional measures would be needed.
|
|
||||||
|
|
||||||
2. **HTTP Redirects**: The swagger-parser library follows HTTP redirects. A malicious redirect could bypass URL validation.
|
|
||||||
|
|
||||||
For MCP server use cases (local CLI tool), these are acceptable limitations with reduced attack surface.
|
|
||||||
|
|
||||||
## Security Audit Summary
|
|
||||||
|
|
||||||
| Severity | Count | Status |
|
|
||||||
|----------|-------|--------|
|
|
||||||
| Critical | 0 | - |
|
|
||||||
| High | 2 | Documented limitations (DNS rebinding, redirects) |
|
|
||||||
| Medium | 3 | Low priority for CLI tool context |
|
|
||||||
| Low | 4 | Backlog items |
|
|
||||||
|
|
||||||
## Rollback Instructions
|
|
||||||
|
|
||||||
To rollback these changes:
|
|
||||||
|
|
||||||
1. Revert the input-validation.ts file:
|
|
||||||
```bash
|
|
||||||
git checkout HEAD~1 -- src/lib/input-validation.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Remove validation imports and calls from parser.ts and query.ts:
|
|
||||||
```bash
|
|
||||||
git checkout HEAD~1 -- src/lib/parser.ts src/tools/query.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Rebuild:
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- OWASP ReDoS: https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
|
|
||||||
- OWASP Path Traversal: https://owasp.org/www-community/attacks/Path_Traversal
|
|
||||||
- OWASP SSRF: https://owasp.org/www-community/attacks/Server_Side_Request_Forgery
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
# Task: Remote Spec Caching
|
|
||||||
|
|
||||||
## Related Documents
|
|
||||||
- Analysis: `docs/analysis/feature-enhancements-analysis.md`
|
|
||||||
- Branch: `feature/caching` (from `feature/typescript-options`)
|
|
||||||
|
|
||||||
## Priority
|
|
||||||
NORMAL
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
Add an in-memory LRU cache for parsed OpenAPI specs to improve performance when repeatedly accessing the same remote URLs or local files.
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
- [x] Code implemented per specification
|
|
||||||
- [x] TypeScript compilation CLEAN
|
|
||||||
- [x] ALL tests passing
|
|
||||||
- [x] Manual verification with remote spec
|
|
||||||
- [x] PROOF PROVIDED (cache hit/miss demonstration)
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
### IN SCOPE
|
|
||||||
- LRU cache with configurable max entries (default 10)
|
|
||||||
- TTL-based expiration (default 15 minutes)
|
|
||||||
- Cache key includes mtime for local files
|
|
||||||
- `noCache` parameter to bypass cache
|
|
||||||
- Cache invalidation on parse errors
|
|
||||||
|
|
||||||
### OUT OF SCOPE
|
|
||||||
- Persistent (disk) caching
|
|
||||||
- ETag/304 support for remote URLs
|
|
||||||
- Cache warming/preloading
|
|
||||||
- Distributed caching
|
|
||||||
|
|
||||||
## Sub-Tasks
|
|
||||||
|
|
||||||
### Phase 1: Cache Implementation
|
|
||||||
#### 1.1 Create cache module
|
|
||||||
- **Details**: Implement LRU cache class with TTL support
|
|
||||||
- **Files**: `src/lib/cache.ts` (new)
|
|
||||||
- **Testing**: Unit test cache hit/miss/expiration
|
|
||||||
|
|
||||||
#### 1.2 Define cache entry interface
|
|
||||||
- **Details**: Store spec, metadata, timestamp, dereferenced flag
|
|
||||||
- **Files**: `src/lib/cache.ts`, `src/lib/types.ts`
|
|
||||||
- **Testing**: TypeScript compilation
|
|
||||||
|
|
||||||
### Phase 2: Parser Integration
|
|
||||||
#### 2.1 Add cache to parseSpec
|
|
||||||
- **Details**: Check cache before parsing, store result after
|
|
||||||
- **Files**: `src/lib/parser.ts`
|
|
||||||
- **Testing**: Verify cache hit on repeated calls
|
|
||||||
|
|
||||||
#### 2.2 Implement cache key strategy
|
|
||||||
- **Details**: URL normalization for remote, path+mtime for local
|
|
||||||
- **Files**: `src/lib/cache.ts`
|
|
||||||
- **Testing**: Local file change triggers re-parse
|
|
||||||
|
|
||||||
### Phase 3: Tool Integration
|
|
||||||
#### 3.1 Add noCache parameter to tools
|
|
||||||
- **Details**: Optional parameter to bypass cache
|
|
||||||
- **Files**: `src/tools/parse.ts`, `src/tools/validate.ts`, `src/tools/query.ts`, `src/tools/schema.ts`, `src/tools/generate.ts`
|
|
||||||
- **Testing**: noCache=true forces fresh parse
|
|
||||||
|
|
||||||
## Files to Modify
|
|
||||||
- `src/lib/cache.ts`: NEW - LRU cache implementation
|
|
||||||
- `src/lib/types.ts`: Add CacheEntry interface
|
|
||||||
- `src/lib/parser.ts`: Integrate cache into parseSpec
|
|
||||||
- `src/tools/*.ts`: Add noCache parameter to all tools
|
|
||||||
|
|
||||||
## Risks & Mitigations
|
|
||||||
|
|
||||||
| Risk | Impact | Mitigation |
|
|
||||||
|------|--------|------------|
|
|
||||||
| Stale cache for rapidly changing specs | MEDIUM | Short TTL (15 min), noCache param |
|
|
||||||
| Memory pressure with large specs | MEDIUM | LRU eviction, max 10 entries |
|
|
||||||
| Local file changes not detected | LOW | Include mtime in cache key |
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
- Build: `npm run build` - must pass
|
|
||||||
- Manual verification:
|
|
||||||
```bash
|
|
||||||
# First call - should parse fresh
|
|
||||||
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"parse-spec","arguments":{"path":"https://petstore.swagger.io/v2/swagger.json"}}}' | node dist/index.js
|
|
||||||
|
|
||||||
# Second call - should hit cache (faster)
|
|
||||||
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"parse-spec","arguments":{"path":"https://petstore.swagger.io/v2/swagger.json"}}}' | node dist/index.js
|
|
||||||
|
|
||||||
# With noCache - should bypass
|
|
||||||
echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"parse-spec","arguments":{"path":"https://petstore.swagger.io/v2/swagger.json","noCache":true}}}' | node dist/index.js
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
### Cache Key Strategy
|
|
||||||
```typescript
|
|
||||||
function getCacheKey(specPath: string): string {
|
|
||||||
if (specPath.startsWith('http://') || specPath.startsWith('https://')) {
|
|
||||||
// Normalize URL: remove trailing slash, sort query params
|
|
||||||
return normalizeUrl(specPath);
|
|
||||||
}
|
|
||||||
// Local file: include mtime for change detection
|
|
||||||
const resolved = path.resolve(specPath);
|
|
||||||
const stat = fs.statSync(resolved);
|
|
||||||
return `${resolved}:${stat.mtimeMs}`;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### LRU Cache Structure
|
|
||||||
```typescript
|
|
||||||
interface CacheEntry {
|
|
||||||
spec: OpenAPISpec;
|
|
||||||
metadata: ParsedSpec;
|
|
||||||
dereferenced: boolean;
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
class SpecCache {
|
|
||||||
private cache: Map<string, CacheEntry>;
|
|
||||||
private maxSize: number;
|
|
||||||
private ttlMs: number;
|
|
||||||
|
|
||||||
constructor(maxSize = 10, ttlMinutes = 15) {
|
|
||||||
this.cache = new Map();
|
|
||||||
this.maxSize = maxSize;
|
|
||||||
this.ttlMs = ttlMinutes * 60 * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
get(key: string): CacheEntry | undefined {
|
|
||||||
const entry = this.cache.get(key);
|
|
||||||
if (!entry) return undefined;
|
|
||||||
if (Date.now() - entry.timestamp > this.ttlMs) {
|
|
||||||
this.cache.delete(key);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
// Move to end for LRU
|
|
||||||
this.cache.delete(key);
|
|
||||||
this.cache.set(key, entry);
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
set(key: string, entry: CacheEntry): void {
|
|
||||||
if (this.cache.size >= this.maxSize) {
|
|
||||||
// Remove oldest (first) entry
|
|
||||||
const firstKey = this.cache.keys().next().value;
|
|
||||||
this.cache.delete(firstKey);
|
|
||||||
}
|
|
||||||
this.cache.set(key, { ...entry, timestamp: Date.now() });
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidate(key?: string): void {
|
|
||||||
if (key) {
|
|
||||||
this.cache.delete(key);
|
|
||||||
} else {
|
|
||||||
this.cache.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
---
|
|
||||||
openproject: 359
|
|
||||||
---
|
|
||||||
# Task: Code Quality Refactoring
|
|
||||||
|
|
||||||
## Related Documents
|
|
||||||
- Analysis: `/streamline` command output (2026-01-12)
|
|
||||||
- OpenProject: [#359](https://openproject.rimskij.net/work_packages/359)
|
|
||||||
- Branch: `feature/359-code-quality-refactoring` (from `feature/caching`)
|
|
||||||
|
|
||||||
## Priority
|
|
||||||
NORMAL
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
Reduce code duplication and improve type safety across the swagger-tools codebase by extracting shared utilities, consolidating schema access patterns, and adding proper type guards. This will reduce maintenance burden by ~30% and make adding new tools faster.
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
- [ ] Code implemented per specification
|
|
||||||
- [ ] TypeScript compilation CLEAN
|
|
||||||
- [ ] ALL manual tests passing (parse-spec, validate-spec, query-endpoints, get-schema, generate-types)
|
|
||||||
- [ ] No regression in existing functionality
|
|
||||||
- [ ] PROOF PROVIDED (before/after comparison)
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
### IN SCOPE
|
|
||||||
- Extract HTTP_METHODS constant to shared location
|
|
||||||
- Consolidate schema access functions (getSchemas, findSchema, getSchemaNames, getSchemaCount)
|
|
||||||
- Create tool response helpers (successResponse, errorResponse)
|
|
||||||
- Add type guards for OpenAPI spec versions
|
|
||||||
- Extract cache configuration constants
|
|
||||||
|
|
||||||
### OUT OF SCOPE
|
|
||||||
- Adding new features
|
|
||||||
- Changing tool behavior or output format
|
|
||||||
- Restructuring directory layout
|
|
||||||
- Adding test framework
|
|
||||||
|
|
||||||
## Sub-Tasks
|
|
||||||
|
|
||||||
### Phase 1: Foundation - Extract Shared Constants
|
|
||||||
#### 1.1 Extract HTTP_METHODS to types.ts
|
|
||||||
- **Details**: Move the duplicated HTTP_METHODS constant from parser.ts, query.ts, and validator.ts to lib/types.ts. Export as const array and derive HttpMethod type.
|
|
||||||
- **Files**:
|
|
||||||
- `src/lib/types.ts` (add export)
|
|
||||||
- `src/lib/parser.ts` (import, remove local)
|
|
||||||
- `src/tools/query.ts` (import, remove local)
|
|
||||||
- `src/lib/validator.ts` (import, remove inline)
|
|
||||||
- **Testing**: `npm run build` must pass, all tools work unchanged
|
|
||||||
|
|
||||||
#### 1.2 Extract cache configuration constants
|
|
||||||
- **Details**: Extract DEFAULT_CACHE_MAX_SIZE=10 and DEFAULT_CACHE_TTL_MINUTES=15 as named constants in cache.ts
|
|
||||||
- **Files**: `src/lib/cache.ts`
|
|
||||||
- **Testing**: `npm run build`, caching behavior unchanged
|
|
||||||
|
|
||||||
### Phase 2: Schema Utilities Consolidation
|
|
||||||
#### 2.1 Create schema-utils.ts module
|
|
||||||
- **Details**: Create new module with consolidated schema access functions extracted from schema.ts, generate.ts, and parser.ts
|
|
||||||
- **Files**:
|
|
||||||
- `src/lib/schema-utils.ts` (new file)
|
|
||||||
- **Testing**: `npm run build`
|
|
||||||
|
|
||||||
#### 2.2 Implement getSchemas function
|
|
||||||
- **Details**: Single function handling both OpenAPI 3.x (components.schemas) and Swagger 2.0 (definitions)
|
|
||||||
- **Files**: `src/lib/schema-utils.ts`
|
|
||||||
- **Testing**: Parse petstore.yaml, verify schemas returned
|
|
||||||
|
|
||||||
#### 2.3 Implement findSchema, getSchemaNames, getSchemaCount
|
|
||||||
- **Details**: Build on getSchemas for consistent behavior
|
|
||||||
- **Files**: `src/lib/schema-utils.ts`
|
|
||||||
- **Testing**: get-schema tool works, generate-types works
|
|
||||||
|
|
||||||
#### 2.4 Update consumers to use schema-utils
|
|
||||||
- **Details**: Replace local implementations with imports from schema-utils.ts
|
|
||||||
- **Files**:
|
|
||||||
- `src/tools/schema.ts` (replace findSchema, getAvailableSchemas)
|
|
||||||
- `src/tools/generate.ts` (replace getSchemas)
|
|
||||||
- `src/lib/parser.ts` (replace getSchemaCount)
|
|
||||||
- **Testing**: All tools produce identical output to before
|
|
||||||
|
|
||||||
### Phase 3: Tool Response Helpers
|
|
||||||
#### 3.1 Create tool-response.ts module
|
|
||||||
- **Details**: Create response builder utilities for consistent MCP tool responses
|
|
||||||
- **Files**:
|
|
||||||
- `src/lib/tool-response.ts` (new file)
|
|
||||||
- **Testing**: `npm run build`
|
|
||||||
|
|
||||||
#### 3.2 Implement successResponse and errorResponse
|
|
||||||
- **Details**: Helper functions that construct the MCP response format with content and structuredContent
|
|
||||||
- **Files**: `src/lib/tool-response.ts`
|
|
||||||
- **Testing**: Type-check passes
|
|
||||||
|
|
||||||
#### 3.3 Update tool handlers to use response helpers
|
|
||||||
- **Details**: Replace try/catch boilerplate in each tool handler
|
|
||||||
- **Files**:
|
|
||||||
- `src/tools/parse.ts`
|
|
||||||
- `src/tools/validate.ts`
|
|
||||||
- `src/tools/query.ts`
|
|
||||||
- `src/tools/schema.ts`
|
|
||||||
- `src/tools/generate.ts`
|
|
||||||
- **Testing**: All tools produce identical output, error handling works
|
|
||||||
|
|
||||||
### Phase 4: Type Guards
|
|
||||||
#### 4.1 Create spec-guards.ts module
|
|
||||||
- **Details**: Type guard functions for OpenAPI spec versions
|
|
||||||
- **Files**:
|
|
||||||
- `src/lib/spec-guards.ts` (new file)
|
|
||||||
- **Testing**: `npm run build`
|
|
||||||
|
|
||||||
#### 4.2 Implement isOpenAPIV3 and isSwagger2 guards
|
|
||||||
- **Details**: Proper type guards that narrow types, replace `as` casts
|
|
||||||
- **Files**: `src/lib/spec-guards.ts`
|
|
||||||
- **Testing**: Type-check passes
|
|
||||||
|
|
||||||
#### 4.3 Update consumers to use type guards
|
|
||||||
- **Details**: Replace type assertions with proper guards where beneficial
|
|
||||||
- **Files**:
|
|
||||||
- `src/lib/parser.ts`
|
|
||||||
- `src/lib/schema-utils.ts`
|
|
||||||
- **Testing**: All tools work unchanged
|
|
||||||
|
|
||||||
## Files to Modify
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `src/lib/types.ts` | Add HTTP_METHODS, HttpMethod exports |
|
|
||||||
| `src/lib/cache.ts` | Extract cache config constants |
|
|
||||||
| `src/lib/parser.ts` | Import HTTP_METHODS, use schema-utils, use type guards |
|
|
||||||
| `src/lib/validator.ts` | Import HTTP_METHODS |
|
|
||||||
| `src/lib/schema-utils.ts` | **NEW** - consolidated schema access |
|
|
||||||
| `src/lib/tool-response.ts` | **NEW** - response helpers |
|
|
||||||
| `src/lib/spec-guards.ts` | **NEW** - type guards |
|
|
||||||
| `src/tools/parse.ts` | Use tool-response helpers |
|
|
||||||
| `src/tools/validate.ts` | Use tool-response helpers |
|
|
||||||
| `src/tools/query.ts` | Import HTTP_METHODS, use tool-response helpers |
|
|
||||||
| `src/tools/schema.ts` | Use schema-utils, tool-response helpers |
|
|
||||||
| `src/tools/generate.ts` | Use schema-utils, tool-response helpers |
|
|
||||||
|
|
||||||
## Risks & Mitigations
|
|
||||||
|
|
||||||
| Risk | Impact | Mitigation |
|
|
||||||
|------|--------|------------|
|
|
||||||
| Breaking existing tool behavior | HIGH | Test each tool after each phase |
|
|
||||||
| Type errors from refactoring | MEDIUM | Run `npm run build` after each sub-task |
|
|
||||||
| Missing edge cases in consolidation | MEDIUM | Compare output before/after for test fixtures |
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
**After each phase:**
|
|
||||||
1. Build: `npm run build` - must pass
|
|
||||||
2. Type check: `npm run typecheck` - must pass
|
|
||||||
3. Manual tests:
|
|
||||||
```bash
|
|
||||||
# Test parse-spec
|
|
||||||
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"parse-spec","arguments":{"path":"test/fixtures/petstore.yaml"}}}' | node dist/index.js
|
|
||||||
|
|
||||||
# Test validate-spec
|
|
||||||
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"validate-spec","arguments":{"path":"test/fixtures/petstore.yaml"}}}' | node dist/index.js
|
|
||||||
|
|
||||||
# Test query-endpoints
|
|
||||||
echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"query-endpoints","arguments":{"path":"test/fixtures/petstore.yaml","method":"GET"}}}' | node dist/index.js
|
|
||||||
|
|
||||||
# Test get-schema
|
|
||||||
echo '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"get-schema","arguments":{"path":"test/fixtures/petstore.yaml","schemaName":"Pet"}}}' | node dist/index.js
|
|
||||||
|
|
||||||
# Test generate-types
|
|
||||||
echo '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"generate-types","arguments":{"path":"test/fixtures/petstore.yaml","schemas":["Pet"]}}}' | node dist/index.js
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
- Preserve exact output format - consumers may depend on it
|
|
||||||
- Each phase should be independently committable
|
|
||||||
- Run full test suite between phases
|
|
||||||
- New files follow existing patterns (ESM imports, zod schemas, etc.)
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
---
|
|
||||||
openproject: 365
|
|
||||||
---
|
|
||||||
# Task: OpenAPI 3.1 TypeScript Generation Support
|
|
||||||
|
|
||||||
## Related Documents
|
|
||||||
- Analysis: `docs/analysis/feature-enhancements-analysis.md` (Feature 2)
|
|
||||||
- OpenProject: [#365](https://pm.hyperlocalplatform.com/work_packages/365)
|
|
||||||
- Branch: `feature/365-openapi-31-support` (from `dev`)
|
|
||||||
|
|
||||||
## Priority
|
|
||||||
NORMAL
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
Enhance TypeScript generation to properly handle OpenAPI 3.1-specific JSON Schema features. While parsing already works via swagger-parser, the `generate-types` tool doesn't handle 3.1's array types, `const` keyword, or webhook extraction.
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
- [x] Code implemented per specification
|
|
||||||
- [x] TypeScript compilation CLEAN
|
|
||||||
- [x] Lint checks PASSED (no lint script configured)
|
|
||||||
- [x] ALL tests passing (12/12)
|
|
||||||
- [x] Manual verification completed
|
|
||||||
- [x] PROOF PROVIDED
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
### IN SCOPE
|
|
||||||
- Handle `type` as array: `type: ['string', 'null']` → `string | null`
|
|
||||||
- Handle `const` keyword: `const: "value"` → literal type `'value'`
|
|
||||||
- Extract `webhooks` count in metadata
|
|
||||||
- Display webhook count in formatted output
|
|
||||||
- Test fixture with OpenAPI 3.1 spec
|
|
||||||
|
|
||||||
### OUT OF SCOPE
|
|
||||||
- Full JSON Schema 2020-12 support (only common features)
|
|
||||||
- `$ref` with sibling properties (complex edge case)
|
|
||||||
- `pathItems` in components (rarely used)
|
|
||||||
- Batch operations (separate feature)
|
|
||||||
|
|
||||||
## Sub-Tasks
|
|
||||||
|
|
||||||
### Phase 1: Type Array Handling
|
|
||||||
#### 1.1 Update schemaToType() for array types
|
|
||||||
- **Details**: Detect when `type` is an array and map each type, joining with `|`
|
|
||||||
- **Files**: `src/tools/generate.ts`
|
|
||||||
- **Code**:
|
|
||||||
```typescript
|
|
||||||
if (Array.isArray(s.type)) {
|
|
||||||
return s.type.map(t => t === 'null' ? 'null' : mapPrimitiveType(t)).join(' | ');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- **Testing**: Generate types from spec with `type: ['string', 'null']`
|
|
||||||
|
|
||||||
#### 1.2 Handle nullable migration
|
|
||||||
- **Details**: OpenAPI 3.1 replaces `nullable: true` with `type: ['T', 'null']`
|
|
||||||
- **Files**: `src/tools/generate.ts`
|
|
||||||
- **Testing**: Both 3.0 `nullable` and 3.1 array types produce `T | null`
|
|
||||||
|
|
||||||
### Phase 2: Const Keyword Support
|
|
||||||
#### 2.1 Implement const type generation
|
|
||||||
- **Details**: When `const` is present, output a literal type
|
|
||||||
- **Files**: `src/tools/generate.ts`
|
|
||||||
- **Code**:
|
|
||||||
```typescript
|
|
||||||
if (s.const !== undefined) {
|
|
||||||
return typeof s.const === 'string' ? `'${s.const}'` : String(s.const);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- **Testing**: Schema with `const: "active"` generates `'active'`
|
|
||||||
|
|
||||||
### Phase 3: Webhook Metadata Extraction
|
|
||||||
#### 3.1 Add webhookCount to ParsedSpec
|
|
||||||
- **Details**: Add optional `webhookCount` field to ParsedSpec interface
|
|
||||||
- **Files**: `src/lib/types.ts`
|
|
||||||
- **Testing**: Type compiles
|
|
||||||
|
|
||||||
#### 3.2 Extract webhooks in parseSpec()
|
|
||||||
- **Details**: Count webhooks from `spec.webhooks` if present
|
|
||||||
- **Files**: `src/lib/parser.ts`
|
|
||||||
- **Code**:
|
|
||||||
```typescript
|
|
||||||
webhookCount: spec.webhooks ? Object.keys(spec.webhooks).length : undefined
|
|
||||||
```
|
|
||||||
- **Testing**: Parse 3.1 spec with webhooks, verify count
|
|
||||||
|
|
||||||
#### 3.3 Display webhook count in formatted output
|
|
||||||
- **Details**: Show webhook count in human-readable output when present
|
|
||||||
- **Files**: `src/utils/format.ts`
|
|
||||||
- **Testing**: Format output includes "Webhooks: N"
|
|
||||||
|
|
||||||
### Phase 4: Testing
|
|
||||||
#### 4.1 Create OpenAPI 3.1 test fixture
|
|
||||||
- **Details**: Create a 3.1 spec with array types, const, and webhooks
|
|
||||||
- **Files**: `test/fixtures/openapi-31.yaml` (NEW)
|
|
||||||
- **Testing**: Fixture parses successfully
|
|
||||||
|
|
||||||
#### 4.2 Add unit tests for 3.1 features
|
|
||||||
- **Details**: Test type generation for all new features
|
|
||||||
- **Files**: `scripts/self-testing/openapi-31-test.ts` (NEW)
|
|
||||||
- **Testing**: All tests pass
|
|
||||||
|
|
||||||
#### 4.3 Verify backward compatibility
|
|
||||||
- **Details**: Ensure existing 3.0 and 2.0 specs still work
|
|
||||||
- **Files**: Existing test fixtures
|
|
||||||
- **Testing**: No regression in existing functionality
|
|
||||||
|
|
||||||
## Files to Modify
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `src/tools/generate.ts` | Handle array types, const keyword |
|
|
||||||
| `src/lib/types.ts` | Add `webhookCount?: number` to ParsedSpec |
|
|
||||||
| `src/lib/parser.ts` | Extract webhook count from spec |
|
|
||||||
| `src/utils/format.ts` | Display webhook count |
|
|
||||||
| `test/fixtures/openapi-31.yaml` | NEW: 3.1 test fixture |
|
|
||||||
| `scripts/self-testing/openapi-31-test.ts` | NEW: Unit tests |
|
|
||||||
|
|
||||||
## Risks & Mitigations
|
|
||||||
|
|
||||||
| Risk | Impact | Mitigation |
|
|
||||||
|------|--------|------------|
|
|
||||||
| Complex array type combinations | LOW | Map each type individually, join with `\|` |
|
|
||||||
| `const` with complex objects | LOW | Use JSON.stringify for non-primitives |
|
|
||||||
| Breaking existing 3.0 specs | LOW | Changes are additive, test backward compat |
|
|
||||||
| Type guard complexity for 3.1 detection | LOW | Use existing `isOpenAPI31()` guard |
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
- Build: `npm run build` - must pass
|
|
||||||
- Lint: `npm run lint` - must pass
|
|
||||||
- Unit tests: `npx tsx scripts/self-testing/openapi-31-test.ts`
|
|
||||||
- Manual: Generate types from 3.1 spec, verify output
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
- The `isOpenAPI31()` type guard already exists in `src/lib/spec-guards.ts`
|
|
||||||
- swagger-parser v10.1.1 already parses 3.1 specs correctly
|
|
||||||
- Changes should be additive - don't break 3.0/2.0 compatibility
|
|
||||||
- For const with objects, use `typeof value === 'object' ? 'object' : ...`
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
---
|
|
||||||
openproject: 362
|
|
||||||
base-branch: dev
|
|
||||||
---
|
|
||||||
# Task: Security Hardening - Address ReDoS, Path Traversal, and SSRF Vulnerabilities
|
|
||||||
|
|
||||||
## Related Documents
|
|
||||||
- OpenProject: [#362](https://pm.hyperlocalplatform.com/work_packages/362)
|
|
||||||
- Branch: `feature/362-security-hardening` (from `dev`)
|
|
||||||
|
|
||||||
## Priority
|
|
||||||
HIGH
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
Address pre-existing security vulnerabilities identified in the security audit: ReDoS in pathPattern regex, path traversal in file system access, and SSRF in unrestricted URL fetching. These issues pose risks to users of the MCP server.
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
- [x] ReDoS vulnerability mitigated with regex timeout/complexity limits
|
|
||||||
- [x] Path traversal prevented with path validation
|
|
||||||
- [x] SSRF mitigated with URL allowlist/blocklist support
|
|
||||||
- [x] TypeScript compilation CLEAN
|
|
||||||
- [x] Lint checks PASSED (no lint script configured)
|
|
||||||
- [x] ALL tests passing (33 security tests)
|
|
||||||
- [x] Manual verification completed
|
|
||||||
- [x] PROOF PROVIDED (security test cases)
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
### IN SCOPE
|
|
||||||
- ReDoS mitigation in `src/tools/query.ts` pathPattern handling
|
|
||||||
- Path traversal prevention in `src/lib/parser.ts` file access
|
|
||||||
- SSRF mitigation in URL fetching (remote spec loading)
|
|
||||||
- Input validation utilities
|
|
||||||
- Security-focused test cases
|
|
||||||
|
|
||||||
### OUT OF SCOPE
|
|
||||||
- Authentication/authorization (MCP server runs locally)
|
|
||||||
- Encryption at rest
|
|
||||||
- Rate limiting (single-user CLI tool)
|
|
||||||
- Audit logging
|
|
||||||
|
|
||||||
## Sub-Tasks
|
|
||||||
|
|
||||||
### Phase 1: ReDoS Mitigation
|
|
||||||
#### 1.1 Add Regex Complexity Limits
|
|
||||||
- **Details**: Implement regex validation before creating RegExp from user input. Either use a safe-regex library or implement timeout-based execution.
|
|
||||||
- **Files**: `src/tools/query.ts`
|
|
||||||
- **Testing**: Test with known ReDoS patterns (e.g., `(a+)+$` against `aaaaaaaaaaaaaaaaaaaaaaaaaaaa!`)
|
|
||||||
|
|
||||||
#### 1.2 Create Input Validation Utilities
|
|
||||||
- **Details**: Create `src/lib/input-validation.ts` with reusable validation functions for regex patterns, file paths, and URLs.
|
|
||||||
- **Files**: `src/lib/input-validation.ts` (new)
|
|
||||||
- **Testing**: Unit tests for validation functions
|
|
||||||
|
|
||||||
### Phase 2: Path Traversal Prevention
|
|
||||||
#### 2.1 Implement Path Validation
|
|
||||||
- **Details**: Validate file paths to prevent directory traversal attacks (e.g., `../../../etc/passwd`). Resolve paths and ensure they don't escape intended directories.
|
|
||||||
- **Files**: `src/lib/parser.ts`, `src/lib/input-validation.ts`
|
|
||||||
- **Testing**: Test with path traversal payloads
|
|
||||||
|
|
||||||
#### 2.2 Add Configurable Base Directory (Optional)
|
|
||||||
- **Details**: Allow configuration of allowed directories for spec file access. Default to current working directory.
|
|
||||||
- **Files**: `src/lib/parser.ts`, `src/lib/input-validation.ts`
|
|
||||||
- **Testing**: Test directory restrictions
|
|
||||||
|
|
||||||
### Phase 3: SSRF Mitigation
|
|
||||||
#### 3.1 Implement URL Validation
|
|
||||||
- **Details**: Validate URLs before fetching. Block internal/private IP ranges (10.x, 172.16-31.x, 192.168.x, localhost, 127.x). Consider allowlist for known spec hosts.
|
|
||||||
- **Files**: `src/lib/parser.ts`, `src/lib/input-validation.ts`
|
|
||||||
- **Testing**: Test with internal IP addresses and localhost URLs
|
|
||||||
|
|
||||||
#### 3.2 Add URL Protocol Restrictions
|
|
||||||
- **Details**: Only allow `http://` and `https://` protocols. Block `file://`, `ftp://`, `data:`, etc.
|
|
||||||
- **Files**: `src/lib/input-validation.ts`
|
|
||||||
- **Testing**: Test with various URL protocols
|
|
||||||
|
|
||||||
## Files to Modify
|
|
||||||
- `src/tools/query.ts`: Add regex validation before `new RegExp()`
|
|
||||||
- `src/lib/parser.ts`: Add path and URL validation before spec loading
|
|
||||||
- `src/lib/input-validation.ts` (new): Centralized input validation utilities
|
|
||||||
- `src/lib/types.ts`: Add configuration types if needed
|
|
||||||
|
|
||||||
## Risks & Mitigations
|
|
||||||
| Risk | Impact | Mitigation |
|
|
||||||
|------|--------|------------|
|
|
||||||
| Breaking legitimate regex patterns | MEDIUM | Test common valid patterns, provide clear error messages |
|
|
||||||
| Blocking valid internal specs | MEDIUM | Make SSRF protection configurable, document bypass options |
|
|
||||||
| Performance impact from validation | LOW | Keep validation lightweight, cache validation results |
|
|
||||||
| Incomplete protection | HIGH | Follow OWASP guidelines, test with known attack payloads |
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
- Build: `npm run build` - must pass
|
|
||||||
- Lint: `npm run lint` - must pass
|
|
||||||
- Manual testing with attack payloads:
|
|
||||||
- ReDoS: `(a+)+$`, `([a-zA-Z]+)*$` against long strings
|
|
||||||
- Path traversal: `../../../etc/passwd`, `....//....//etc/passwd`
|
|
||||||
- SSRF: `http://127.0.0.1/`, `http://localhost/`, `http://10.0.0.1/`
|
|
||||||
- Verify legitimate use cases still work:
|
|
||||||
- Local spec files (relative and absolute paths)
|
|
||||||
- Remote HTTPS specs (public APIs)
|
|
||||||
- Common regex patterns for path filtering
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
### ReDoS Approach Options
|
|
||||||
1. **safe-regex library**: Detect dangerous patterns before execution
|
|
||||||
2. **Regex timeout**: Use `vm.runInNewContext` with timeout (complex)
|
|
||||||
3. **Pattern restrictions**: Limit regex features (simpler but restrictive)
|
|
||||||
|
|
||||||
Recommended: Start with pattern validation + timeout fallback.
|
|
||||||
|
|
||||||
### Path Traversal Approach
|
|
||||||
Use `path.resolve()` and verify the resolved path starts with the allowed base directory:
|
|
||||||
```typescript
|
|
||||||
const resolved = path.resolve(basePath, userPath);
|
|
||||||
if (!resolved.startsWith(basePath)) {
|
|
||||||
throw new Error('Path traversal detected');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### SSRF Approach
|
|
||||||
1. Parse URL with `new URL()`
|
|
||||||
2. Check protocol (allow only http/https)
|
|
||||||
3. Resolve hostname to IP
|
|
||||||
4. Check IP against private ranges blocklist
|
|
||||||
5. Consider DNS rebinding protection (optional, advanced)
|
|
||||||
|
|
||||||
## References
|
|
||||||
- OWASP ReDoS: https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS
|
|
||||||
- OWASP Path Traversal: https://owasp.org/www-community/attacks/Path_Traversal
|
|
||||||
- OWASP SSRF: https://owasp.org/www-community/attacks/Server_Side_Request_Forgery
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
# Task: TypeScript Generation Options
|
|
||||||
|
|
||||||
## Related Documents
|
|
||||||
- Analysis: `docs/analysis/feature-enhancements-analysis.md`
|
|
||||||
- Branch: `feature/typescript-options` (from `dev`)
|
|
||||||
|
|
||||||
## Priority
|
|
||||||
NORMAL
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
Add configurable options to the `generate-types` tool for customizing TypeScript output format, including enum style, interface naming conventions, and indentation preferences.
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
- [x] Code implemented per specification
|
|
||||||
- [x] TypeScript compilation CLEAN
|
|
||||||
- [x] Lint checks PASSED (no lint script in project)
|
|
||||||
- [x] ALL tests passing
|
|
||||||
- [x] Manual verification with petstore spec
|
|
||||||
- [x] PROOF PROVIDED (before/after output samples)
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
### IN SCOPE
|
|
||||||
- `enumAsUnion` option (default true) - generate enums as union types
|
|
||||||
- `enumAsEnum` option - generate enums as TypeScript enums
|
|
||||||
- `interfacePrefix` option - prefix for interface names (e.g., "I")
|
|
||||||
- `interfaceSuffix` option - suffix for interface names (e.g., "Type")
|
|
||||||
- `indentation` option - 2 spaces, 4 spaces, or tab
|
|
||||||
- Validation of mutually exclusive options
|
|
||||||
|
|
||||||
### OUT OF SCOPE
|
|
||||||
- `readonlyProperties` option (future enhancement)
|
|
||||||
- `exportDefault` option (future enhancement)
|
|
||||||
- `includeRefs` option (future enhancement)
|
|
||||||
- `allOptional` / `allRequired` options (future enhancement)
|
|
||||||
|
|
||||||
## Sub-Tasks
|
|
||||||
|
|
||||||
### Phase 1: Options Interface and Schema
|
|
||||||
#### 1.1 Add TypeScriptOptions interface to types.ts
|
|
||||||
- **Details**: Define interface with all option properties as optional
|
|
||||||
- **Files**: `src/lib/types.ts`
|
|
||||||
- **Testing**: TypeScript compilation
|
|
||||||
|
|
||||||
#### 1.2 Update generate tool schema with options parameter
|
|
||||||
- **Details**: Add nested options object to Zod schema
|
|
||||||
- **Files**: `src/tools/generate.ts`
|
|
||||||
- **Testing**: Parse spec with invalid options should fail
|
|
||||||
|
|
||||||
### Phase 2: Enum Generation Modes
|
|
||||||
#### 2.1 Implement enumAsEnum generation
|
|
||||||
- **Details**: Convert `type X = 'a' | 'b'` to `enum X { A = 'a', B = 'b' }`
|
|
||||||
- **Files**: `src/tools/generate.ts`
|
|
||||||
- **Testing**: Generate with enumAsEnum=true
|
|
||||||
|
|
||||||
#### 2.2 Add mutual exclusion validation
|
|
||||||
- **Details**: Error if both enumAsUnion and enumAsEnum are true
|
|
||||||
- **Files**: `src/tools/generate.ts`
|
|
||||||
- **Testing**: Should reject invalid combination
|
|
||||||
|
|
||||||
### Phase 3: Interface Naming
|
|
||||||
#### 3.1 Implement prefix/suffix application
|
|
||||||
- **Details**: Apply prefix/suffix to interface names during generation
|
|
||||||
- **Files**: `src/tools/generate.ts`
|
|
||||||
- **Testing**: interfacePrefix='I' produces `interface IPet`
|
|
||||||
|
|
||||||
#### 3.2 Handle sanitized names with prefix/suffix
|
|
||||||
- **Details**: Ensure prefix/suffix works with .NET sanitized names
|
|
||||||
- **Files**: `src/tools/generate.ts`
|
|
||||||
- **Testing**: `Namespace.Type` with prefix='I' produces `IType`
|
|
||||||
|
|
||||||
### Phase 4: Indentation Options
|
|
||||||
#### 4.1 Implement indentation configuration
|
|
||||||
- **Details**: Support '2', '4', 'tab' options for generated code
|
|
||||||
- **Files**: `src/tools/generate.ts`
|
|
||||||
- **Testing**: Generate with each option, verify output
|
|
||||||
|
|
||||||
## Files to Modify
|
|
||||||
- `src/lib/types.ts`: Add `TypeScriptOptions` interface
|
|
||||||
- `src/tools/generate.ts`: Update schema, implement option handling in schemaToType() and generateTypes()
|
|
||||||
|
|
||||||
## Risks & Mitigations
|
|
||||||
|
|
||||||
| Risk | Impact | Mitigation |
|
|
||||||
|------|--------|------------|
|
|
||||||
| Invalid option combinations | LOW | Validate at handler entry, return clear error |
|
|
||||||
| Breaking existing behavior | LOW | All options optional with current defaults |
|
|
||||||
| Enum PascalCase conversion edge cases | MEDIUM | Use robust capitalizeFirst function |
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
- Build: `npm run build` - must pass
|
|
||||||
- Manual: Test with petstore spec using various option combinations
|
|
||||||
- Verification commands:
|
|
||||||
```bash
|
|
||||||
# Default (current behavior)
|
|
||||||
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"generate-types","arguments":{"path":"https://petstore.swagger.io/v2/swagger.json","schemas":["Pet"]}}}' | node dist/index.js
|
|
||||||
|
|
||||||
# With enumAsEnum
|
|
||||||
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"generate-types","arguments":{"path":"https://petstore.swagger.io/v2/swagger.json","schemas":["Pet"],"options":{"enumAsEnum":true}}}}' | node dist/index.js
|
|
||||||
|
|
||||||
# With prefix
|
|
||||||
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"generate-types","arguments":{"path":"https://petstore.swagger.io/v2/swagger.json","schemas":["Pet"],"options":{"interfacePrefix":"I"}}}}' | node dist/index.js
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
### Enum to TypeScript Enum Conversion
|
|
||||||
```typescript
|
|
||||||
// Input: enum values ['available', 'pending', 'sold']
|
|
||||||
// Output:
|
|
||||||
export enum PetStatus {
|
|
||||||
Available = 'available',
|
|
||||||
Pending = 'pending',
|
|
||||||
Sold = 'sold'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use PascalCase for enum member names:
|
|
||||||
```typescript
|
|
||||||
function toPascalCase(value: string): string {
|
|
||||||
return value.replace(/(?:^|[_-])(\w)/g, (_, c) => c.toUpperCase());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Indentation Implementation
|
|
||||||
```typescript
|
|
||||||
const INDENT = {
|
|
||||||
'2': ' ',
|
|
||||||
'4': ' ',
|
|
||||||
'tab': '\t'
|
|
||||||
};
|
|
||||||
const indent = INDENT[options.indentation ?? '2'];
|
|
||||||
```
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "swagger-tools",
|
"name": "swagger-tools",
|
||||||
"version": "0.4.0",
|
"version": "0.1.1",
|
||||||
"description": "MCP server for parsing, validating, and querying OpenAPI/Swagger specifications",
|
"description": "MCP server for parsing, validating, and querying OpenAPI/Swagger specifications",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
/**
|
|
||||||
* MCP integration test for security features.
|
|
||||||
* Tests that the actual tools properly reject dangerous inputs.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { queryToolHandler } from '../../src/tools/query.js';
|
|
||||||
import { parseSpec } from '../../src/lib/parser.js';
|
|
||||||
|
|
||||||
async function runTests() {
|
|
||||||
console.log('\n=== MCP Integration Security Tests ===\n');
|
|
||||||
let passed = 0;
|
|
||||||
let failed = 0;
|
|
||||||
|
|
||||||
// Test 1: ReDoS protection in query-endpoints
|
|
||||||
console.log('Test 1: query-endpoints rejects ReDoS pattern...');
|
|
||||||
try {
|
|
||||||
const result = await queryToolHandler({
|
|
||||||
path: 'test/fixtures/petstore.yaml',
|
|
||||||
pathPattern: '(a+)+$',
|
|
||||||
});
|
|
||||||
|
|
||||||
const content = result.content[0];
|
|
||||||
if (content.type === 'text' && content.text.includes('nested quantifiers')) {
|
|
||||||
console.log(' ✅ ReDoS pattern rejected');
|
|
||||||
passed++;
|
|
||||||
} else {
|
|
||||||
console.log(' ❌ ReDoS pattern NOT rejected');
|
|
||||||
console.log(' Response:', JSON.stringify(result).substring(0, 200));
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.log(' ❌ Unexpected error:', (err as Error).message);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 2: Safe regex works
|
|
||||||
console.log('Test 2: query-endpoints accepts safe regex...');
|
|
||||||
try {
|
|
||||||
const result = await queryToolHandler({
|
|
||||||
path: 'test/fixtures/petstore.yaml',
|
|
||||||
pathPattern: '/pets.*',
|
|
||||||
});
|
|
||||||
|
|
||||||
const structured = result.structuredContent as { success: boolean };
|
|
||||||
if (structured.success) {
|
|
||||||
console.log(' ✅ Safe regex accepted');
|
|
||||||
passed++;
|
|
||||||
} else {
|
|
||||||
console.log(' ❌ Safe regex rejected unexpectedly');
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.log(' ❌ Unexpected error:', (err as Error).message);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 3: Path traversal protection
|
|
||||||
console.log('Test 3: parseSpec rejects path traversal...');
|
|
||||||
try {
|
|
||||||
await parseSpec('../../../etc/passwd');
|
|
||||||
console.log(' ❌ Path traversal NOT blocked');
|
|
||||||
failed++;
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as Error).message.includes('traversal')) {
|
|
||||||
console.log(' ✅ Path traversal blocked');
|
|
||||||
passed++;
|
|
||||||
} else {
|
|
||||||
console.log(' ❌ Wrong error:', (err as Error).message);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 4: SSRF protection - localhost
|
|
||||||
console.log('Test 4: parseSpec rejects localhost URL...');
|
|
||||||
try {
|
|
||||||
await parseSpec('http://localhost/spec.json');
|
|
||||||
console.log(' ❌ Localhost URL NOT blocked');
|
|
||||||
failed++;
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as Error).message.includes('blocked')) {
|
|
||||||
console.log(' ✅ Localhost URL blocked');
|
|
||||||
passed++;
|
|
||||||
} else {
|
|
||||||
console.log(' ❌ Wrong error:', (err as Error).message);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 5: SSRF protection - private IP
|
|
||||||
console.log('Test 5: parseSpec rejects private IP...');
|
|
||||||
try {
|
|
||||||
await parseSpec('http://192.168.1.1/spec.json');
|
|
||||||
console.log(' ❌ Private IP NOT blocked');
|
|
||||||
failed++;
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as Error).message.includes('Private')) {
|
|
||||||
console.log(' ✅ Private IP blocked');
|
|
||||||
passed++;
|
|
||||||
} else {
|
|
||||||
console.log(' ❌ Wrong error:', (err as Error).message);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 6: SSRF protection - file protocol
|
|
||||||
console.log('Test 6: parseSpec rejects file:// protocol...');
|
|
||||||
try {
|
|
||||||
await parseSpec('file:///etc/passwd');
|
|
||||||
console.log(' ❌ File protocol NOT blocked');
|
|
||||||
failed++;
|
|
||||||
} catch (err) {
|
|
||||||
if ((err as Error).message.includes('Protocol')) {
|
|
||||||
console.log(' ✅ File protocol blocked');
|
|
||||||
passed++;
|
|
||||||
} else {
|
|
||||||
console.log(' ❌ Wrong error:', (err as Error).message);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 7: Valid local file works
|
|
||||||
console.log('Test 7: parseSpec accepts valid local file...');
|
|
||||||
try {
|
|
||||||
const result = await parseSpec('test/fixtures/petstore.yaml');
|
|
||||||
if (result.metadata.title) {
|
|
||||||
console.log(' ✅ Valid local file parsed');
|
|
||||||
passed++;
|
|
||||||
} else {
|
|
||||||
console.log(' ❌ Parse succeeded but no title');
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.log(' ❌ Unexpected error:', (err as Error).message);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 8: Private IPs allowed with option
|
|
||||||
console.log('Test 8: parseSpec allows private IP with allowPrivateIPs option...');
|
|
||||||
try {
|
|
||||||
await parseSpec('http://192.168.1.1/spec.json', {
|
|
||||||
security: { allowPrivateIPs: true }
|
|
||||||
});
|
|
||||||
// This will fail to connect, but that's expected - the security check should pass
|
|
||||||
console.log(' ❌ Should have thrown network error');
|
|
||||||
failed++;
|
|
||||||
} catch (err) {
|
|
||||||
const msg = (err as Error).message;
|
|
||||||
if (msg.includes('Private') || msg.includes('blocked')) {
|
|
||||||
console.log(' ❌ Private IP still blocked despite option');
|
|
||||||
failed++;
|
|
||||||
} else {
|
|
||||||
// Network error is expected (no server there)
|
|
||||||
console.log(' ✅ Security check passed (network error expected)');
|
|
||||||
passed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summary
|
|
||||||
console.log('\n=== Summary ===');
|
|
||||||
console.log(`Passed: ${passed}/${passed + failed}`);
|
|
||||||
console.log(`Failed: ${failed}/${passed + failed}`);
|
|
||||||
|
|
||||||
if (failed > 0) {
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log('\n✅ All MCP integration security tests passed!\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
runTests().catch(console.error);
|
|
||||||
|
|
@ -1,237 +0,0 @@
|
||||||
/**
|
|
||||||
* OpenAPI 3.1 Support Tests
|
|
||||||
*
|
|
||||||
* Tests for OpenAPI 3.1 specific features:
|
|
||||||
* - Type as array: type: ['string', 'null']
|
|
||||||
* - Const keyword: const: "value"
|
|
||||||
* - Webhook metadata extraction
|
|
||||||
* - Backward compatibility with OpenAPI 3.0 nullable
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { parseSpec } from '../../src/lib/parser.js';
|
|
||||||
import { generateToolHandler } from '../../src/tools/generate.js';
|
|
||||||
import * as path from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
|
|
||||||
interface TestResult {
|
|
||||||
name: string;
|
|
||||||
passed: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const results: TestResult[] = [];
|
|
||||||
|
|
||||||
function test(name: string, fn: () => Promise<void> | void): void {
|
|
||||||
const wrapped = async () => {
|
|
||||||
try {
|
|
||||||
await fn();
|
|
||||||
results.push({ name, passed: true });
|
|
||||||
console.log(` ✓ ${name}`);
|
|
||||||
} catch (err) {
|
|
||||||
results.push({ name, passed: false, error: (err as Error).message });
|
|
||||||
console.log(` ✗ ${name}`);
|
|
||||||
console.log(` Error: ${(err as Error).message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
wrapped();
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertEqual<T>(actual: T, expected: T, message?: string): void {
|
|
||||||
if (actual !== expected) {
|
|
||||||
throw new Error(message || `Expected ${expected}, got ${actual}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertContains(str: string, substring: string, message?: string): void {
|
|
||||||
if (!str.includes(substring)) {
|
|
||||||
throw new Error(message || `Expected string to contain "${substring}"\nActual: ${str}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertNotContains(str: string, substring: string, message?: string): void {
|
|
||||||
if (str.includes(substring)) {
|
|
||||||
throw new Error(message || `Expected string NOT to contain "${substring}"\nActual: ${str}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runTests() {
|
|
||||||
console.log('\n=== OpenAPI 3.1 Support Tests ===\n');
|
|
||||||
|
|
||||||
const fixturePath = path.join(__dirname, '../../test/fixtures/openapi-31.yaml');
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Parsing and Metadata Tests
|
|
||||||
// ==========================================
|
|
||||||
console.log('Parsing and Metadata:');
|
|
||||||
|
|
||||||
test('Parse OpenAPI 3.1 spec successfully', async () => {
|
|
||||||
const result = await parseSpec(fixturePath, { noCache: true });
|
|
||||||
assertEqual(result.metadata.version, 'OpenAPI 3.1.0');
|
|
||||||
assertEqual(result.metadata.title, 'OpenAPI 3.1 Test Spec');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Extract webhook count from 3.1 spec', async () => {
|
|
||||||
const result = await parseSpec(fixturePath, { noCache: true });
|
|
||||||
assertEqual(result.metadata.webhookCount, 2, 'Should have 2 webhooks');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('3.0 spec has undefined webhookCount', async () => {
|
|
||||||
// Use petstore as a 3.0 spec (or any 3.0/2.0 spec)
|
|
||||||
const result = await parseSpec('https://petstore.swagger.io/v2/swagger.json', { noCache: true });
|
|
||||||
assertEqual(result.metadata.webhookCount, undefined, 'Swagger 2.0 should have undefined webhookCount');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Type Generation Tests - Array Types
|
|
||||||
// ==========================================
|
|
||||||
console.log('\nType Generation - Array Types:');
|
|
||||||
|
|
||||||
test('Generate type for type: [string, null]', async () => {
|
|
||||||
const result = await generateToolHandler({
|
|
||||||
path: fixturePath,
|
|
||||||
schemas: ['NullableString'],
|
|
||||||
noCache: true,
|
|
||||||
});
|
|
||||||
const content = result.content[0];
|
|
||||||
if (content.type !== 'text') throw new Error('Expected text content');
|
|
||||||
assertContains(content.text, 'string | null', 'Should generate string | null');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Generate type for type: [string, number]', async () => {
|
|
||||||
const result = await generateToolHandler({
|
|
||||||
path: fixturePath,
|
|
||||||
schemas: ['StringOrNumber'],
|
|
||||||
noCache: true,
|
|
||||||
});
|
|
||||||
const content = result.content[0];
|
|
||||||
if (content.type !== 'text') throw new Error('Expected text content');
|
|
||||||
assertContains(content.text, 'string | number', 'Should generate string | number');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Generate type for type: [integer, null]', async () => {
|
|
||||||
const result = await generateToolHandler({
|
|
||||||
path: fixturePath,
|
|
||||||
schemas: ['NullableInteger'],
|
|
||||||
noCache: true,
|
|
||||||
});
|
|
||||||
const content = result.content[0];
|
|
||||||
if (content.type !== 'text') throw new Error('Expected text content');
|
|
||||||
assertContains(content.text, 'number | null', 'Should generate number | null');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Type Generation Tests - Const Keyword
|
|
||||||
// ==========================================
|
|
||||||
console.log('\nType Generation - Const Keyword:');
|
|
||||||
|
|
||||||
test('Generate literal type for const: "active"', async () => {
|
|
||||||
const result = await generateToolHandler({
|
|
||||||
path: fixturePath,
|
|
||||||
schemas: ['StatusActive'],
|
|
||||||
noCache: true,
|
|
||||||
});
|
|
||||||
const content = result.content[0];
|
|
||||||
if (content.type !== 'text') throw new Error('Expected text content');
|
|
||||||
assertContains(content.text, "'active'", 'Should generate literal type');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Generate literal type for const: 200', async () => {
|
|
||||||
const result = await generateToolHandler({
|
|
||||||
path: fixturePath,
|
|
||||||
schemas: ['StatusCode'],
|
|
||||||
noCache: true,
|
|
||||||
});
|
|
||||||
const content = result.content[0];
|
|
||||||
if (content.type !== 'text') throw new Error('Expected text content');
|
|
||||||
assertContains(content.text, '200', 'Should generate number literal');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Type Generation Tests - Object with 3.1 Features
|
|
||||||
// ==========================================
|
|
||||||
console.log('\nType Generation - Objects with 3.1 Features:');
|
|
||||||
|
|
||||||
test('Generate User interface with nullable properties', async () => {
|
|
||||||
const result = await generateToolHandler({
|
|
||||||
path: fixturePath,
|
|
||||||
schemas: ['User'],
|
|
||||||
noCache: true,
|
|
||||||
});
|
|
||||||
const content = result.content[0];
|
|
||||||
if (content.type !== 'text') throw new Error('Expected text content');
|
|
||||||
// Check that name can be string | null
|
|
||||||
assertContains(content.text, 'name?:', 'Should have name property');
|
|
||||||
// Check const status property
|
|
||||||
assertContains(content.text, 'status:', 'Should have status property');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Backward Compatibility Tests
|
|
||||||
// ==========================================
|
|
||||||
console.log('\nBackward Compatibility:');
|
|
||||||
|
|
||||||
test('Generate type with OpenAPI 3.0 nullable: true', async () => {
|
|
||||||
const result = await generateToolHandler({
|
|
||||||
path: fixturePath,
|
|
||||||
schemas: ['LegacyUser'],
|
|
||||||
noCache: true,
|
|
||||||
});
|
|
||||||
const content = result.content[0];
|
|
||||||
if (content.type !== 'text') throw new Error('Expected text content');
|
|
||||||
assertContains(content.text, 'string | null', 'Should handle 3.0 nullable');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Parse Swagger 2.0 spec (petstore) still works', async () => {
|
|
||||||
const result = await parseSpec('https://petstore.swagger.io/v2/swagger.json', { noCache: true });
|
|
||||||
assertEqual(result.metadata.version.includes('Swagger'), true, 'Should parse as Swagger');
|
|
||||||
assertEqual(result.metadata.pathCount > 0, true, 'Should have paths');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// Full Generation Test
|
|
||||||
// ==========================================
|
|
||||||
console.log('\nFull Generation:');
|
|
||||||
|
|
||||||
test('Generate all schemas from 3.1 spec', async () => {
|
|
||||||
const result = await generateToolHandler({
|
|
||||||
path: fixturePath,
|
|
||||||
noCache: true,
|
|
||||||
});
|
|
||||||
const content = result.content[0];
|
|
||||||
if (content.type !== 'text') throw new Error('Expected text content');
|
|
||||||
// Verify multiple schemas generated
|
|
||||||
assertContains(content.text, 'NullableString', 'Should have NullableString');
|
|
||||||
assertContains(content.text, 'User', 'Should have User');
|
|
||||||
assertContains(content.text, 'StatusActive', 'Should have StatusActive');
|
|
||||||
assertContains(content.text, 'LegacyUser', 'Should have LegacyUser');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for all async tests to complete
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
||||||
|
|
||||||
// Print summary
|
|
||||||
console.log('\n=== Test Summary ===');
|
|
||||||
const passed = results.filter(r => r.passed).length;
|
|
||||||
const failed = results.filter(r => !r.passed).length;
|
|
||||||
console.log(`Passed: ${passed}`);
|
|
||||||
console.log(`Failed: ${failed}`);
|
|
||||||
console.log(`Total: ${results.length}`);
|
|
||||||
|
|
||||||
if (failed > 0) {
|
|
||||||
console.log('\nFailed tests:');
|
|
||||||
results.filter(r => !r.passed).forEach(r => {
|
|
||||||
console.log(` - ${r.name}: ${r.error}`);
|
|
||||||
});
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n✓ All tests passed!\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
runTests().catch(err => {
|
|
||||||
console.error('Test suite failed:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
/**
|
|
||||||
* Security validation tests for swagger-tools.
|
|
||||||
* Tests ReDoS, path traversal, and SSRF protection.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
validateRegexPattern,
|
|
||||||
createSafeRegex,
|
|
||||||
validateFilePath,
|
|
||||||
validateUrl,
|
|
||||||
validateSpecPath,
|
|
||||||
} from '../../src/lib/input-validation.js';
|
|
||||||
|
|
||||||
interface TestResult {
|
|
||||||
name: string;
|
|
||||||
passed: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const results: TestResult[] = [];
|
|
||||||
|
|
||||||
function test(name: string, fn: () => void): void {
|
|
||||||
try {
|
|
||||||
fn();
|
|
||||||
results.push({ name, passed: true });
|
|
||||||
console.log(`✅ ${name}`);
|
|
||||||
} catch (err) {
|
|
||||||
results.push({ name, passed: false, error: (err as Error).message });
|
|
||||||
console.log(`❌ ${name}: ${(err as Error).message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assert(condition: boolean, message: string): void {
|
|
||||||
if (!condition) {
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n=== ReDoS Protection Tests ===\n');
|
|
||||||
|
|
||||||
test('Rejects nested quantifier pattern (a+)+', () => {
|
|
||||||
const result = validateRegexPattern('(a+)+$');
|
|
||||||
assert(!result.valid, 'Should reject dangerous pattern');
|
|
||||||
assert(result.error?.includes('nested quantifiers'), 'Should mention nested quantifiers');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Rejects nested quantifier pattern (a*)+', () => {
|
|
||||||
const result = validateRegexPattern('(a*)+');
|
|
||||||
assert(!result.valid, 'Should reject dangerous pattern');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Rejects nested quantifier pattern ([a-zA-Z]+)*', () => {
|
|
||||||
const result = validateRegexPattern('([a-zA-Z]+)*$');
|
|
||||||
assert(!result.valid, 'Should reject dangerous pattern');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Rejects overly long regex patterns', () => {
|
|
||||||
const longPattern = 'a'.repeat(501);
|
|
||||||
const result = validateRegexPattern(longPattern);
|
|
||||||
assert(!result.valid, 'Should reject long pattern');
|
|
||||||
assert(result.error?.includes('maximum length'), 'Should mention length limit');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Allows safe regex patterns', () => {
|
|
||||||
const safePatterns = [
|
|
||||||
'/users/.*',
|
|
||||||
'/api/v[0-9]+/.*',
|
|
||||||
'^/pets$',
|
|
||||||
'/orders/[0-9]+$',
|
|
||||||
];
|
|
||||||
for (const pattern of safePatterns) {
|
|
||||||
const result = validateRegexPattern(pattern);
|
|
||||||
assert(result.valid, `Should allow safe pattern: ${pattern}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('createSafeRegex returns null for dangerous patterns', () => {
|
|
||||||
const regex = createSafeRegex('(a+)+$');
|
|
||||||
assert(regex === null, 'Should return null for dangerous pattern');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('createSafeRegex returns RegExp for safe patterns', () => {
|
|
||||||
const regex = createSafeRegex('/users/.*');
|
|
||||||
assert(regex instanceof RegExp, 'Should return RegExp for safe pattern');
|
|
||||||
assert(regex.test('/users/123'), 'Regex should work correctly');
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('\n=== Path Traversal Protection Tests ===\n');
|
|
||||||
|
|
||||||
test('Rejects path with ../', () => {
|
|
||||||
const result = validateFilePath('../../../etc/passwd');
|
|
||||||
assert(!result.valid, 'Should reject traversal pattern');
|
|
||||||
assert(result.error?.includes('traversal'), 'Should mention traversal');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Rejects path with encoded traversal %2e%2e', () => {
|
|
||||||
const result = validateFilePath('%2e%2e%2fetc/passwd');
|
|
||||||
assert(!result.valid, 'Should reject encoded traversal');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Rejects path with double encoded traversal', () => {
|
|
||||||
const result = validateFilePath('%252e%252e%252fetc/passwd');
|
|
||||||
assert(!result.valid, 'Should reject double encoded traversal');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Allows paths within current directory', () => {
|
|
||||||
const result = validateFilePath('./test/fixtures/petstore.yaml');
|
|
||||||
assert(result.valid, 'Should allow relative path within cwd');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Allows absolute paths within cwd', () => {
|
|
||||||
const cwd = process.cwd();
|
|
||||||
const result = validateFilePath(`${cwd}/test/fixtures/petstore.yaml`);
|
|
||||||
assert(result.valid, 'Should allow absolute path within cwd');
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('\n=== SSRF Protection Tests ===\n');
|
|
||||||
|
|
||||||
test('Rejects localhost URLs', () => {
|
|
||||||
const result = validateUrl('http://localhost/api/spec.json');
|
|
||||||
assert(!result.valid, 'Should reject localhost');
|
|
||||||
assert(result.error?.includes('blocked'), 'Should indicate blocked');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Rejects 127.0.0.1 URLs', () => {
|
|
||||||
const result = validateUrl('http://127.0.0.1/api/spec.json');
|
|
||||||
assert(!result.valid, 'Should reject loopback IP');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Rejects private IP 10.x.x.x', () => {
|
|
||||||
const result = validateUrl('http://10.0.0.1/api/spec.json');
|
|
||||||
assert(!result.valid, 'Should reject private IP');
|
|
||||||
assert(result.error?.includes('Private'), 'Should mention private IP');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Rejects private IP 172.16.x.x', () => {
|
|
||||||
const result = validateUrl('http://172.16.0.1/api/spec.json');
|
|
||||||
assert(!result.valid, 'Should reject private IP');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Rejects private IP 192.168.x.x', () => {
|
|
||||||
const result = validateUrl('http://192.168.1.1/api/spec.json');
|
|
||||||
assert(!result.valid, 'Should reject private IP');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Rejects file:// protocol', () => {
|
|
||||||
const result = validateUrl('file:///etc/passwd');
|
|
||||||
assert(!result.valid, 'Should reject file protocol');
|
|
||||||
assert(result.error?.includes('Protocol'), 'Should mention protocol');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Rejects ftp:// protocol', () => {
|
|
||||||
const result = validateUrl('ftp://example.com/spec.json');
|
|
||||||
assert(!result.valid, 'Should reject ftp protocol');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Allows public HTTPS URLs', () => {
|
|
||||||
const result = validateUrl('https://petstore.swagger.io/v2/swagger.json');
|
|
||||||
assert(result.valid, 'Should allow public HTTPS URL');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Allows public HTTP URLs', () => {
|
|
||||||
const result = validateUrl('http://api.example.com/spec.json');
|
|
||||||
assert(result.valid, 'Should allow public HTTP URL');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Allows private IPs when allowPrivateIPs is true', () => {
|
|
||||||
const result = validateUrl('http://192.168.1.1/spec.json', { allowPrivateIPs: true });
|
|
||||||
assert(result.valid, 'Should allow private IP with option');
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('\n=== Spec Path Validation Tests ===\n');
|
|
||||||
|
|
||||||
test('validateSpecPath correctly identifies URLs', () => {
|
|
||||||
const urlResult = validateSpecPath('https://example.com/spec.json');
|
|
||||||
assert(urlResult.valid, 'Should validate URL correctly');
|
|
||||||
|
|
||||||
const localResult = validateSpecPath('./spec.yaml');
|
|
||||||
assert(localResult.valid, 'Should validate local path correctly');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('validateSpecPath rejects dangerous URLs', () => {
|
|
||||||
const result = validateSpecPath('http://localhost/internal/spec.json');
|
|
||||||
assert(!result.valid, 'Should reject localhost URL');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('validateSpecPath rejects path traversal', () => {
|
|
||||||
const result = validateSpecPath('../../../etc/passwd');
|
|
||||||
assert(!result.valid, 'Should reject path traversal');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Summary
|
|
||||||
console.log('\n=== Test Summary ===\n');
|
|
||||||
const passed = results.filter(r => r.passed).length;
|
|
||||||
const failed = results.filter(r => !r.passed).length;
|
|
||||||
console.log(`Total: ${results.length}, Passed: ${passed}, Failed: ${failed}`);
|
|
||||||
|
|
||||||
if (failed > 0) {
|
|
||||||
console.log('\nFailed tests:');
|
|
||||||
results.filter(r => !r.passed).forEach(r => {
|
|
||||||
console.log(` - ${r.name}: ${r.error}`);
|
|
||||||
});
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n✅ All security tests passed!\n');
|
|
||||||
118
src/lib/cache.ts
118
src/lib/cache.ts
|
|
@ -1,118 +0,0 @@
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import type { OpenAPISpec, ParsedSpec } from './types.js';
|
|
||||||
|
|
||||||
/** Default maximum number of cached specs */
|
|
||||||
export const DEFAULT_CACHE_MAX_SIZE = 10;
|
|
||||||
|
|
||||||
/** Default cache TTL in minutes */
|
|
||||||
export const DEFAULT_CACHE_TTL_MINUTES = 15;
|
|
||||||
|
|
||||||
export interface CacheEntry {
|
|
||||||
spec: OpenAPISpec;
|
|
||||||
metadata: ParsedSpec;
|
|
||||||
dereferenced: boolean;
|
|
||||||
timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SpecCache {
|
|
||||||
private cache: Map<string, CacheEntry>;
|
|
||||||
private maxSize: number;
|
|
||||||
private ttlMs: number;
|
|
||||||
|
|
||||||
constructor(maxSize = DEFAULT_CACHE_MAX_SIZE, ttlMinutes = DEFAULT_CACHE_TTL_MINUTES) {
|
|
||||||
this.cache = new Map();
|
|
||||||
this.maxSize = maxSize;
|
|
||||||
this.ttlMs = ttlMinutes * 60 * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
get(key: string): CacheEntry | undefined {
|
|
||||||
const entry = this.cache.get(key);
|
|
||||||
if (!entry) return undefined;
|
|
||||||
|
|
||||||
// Check TTL expiration
|
|
||||||
if (Date.now() - entry.timestamp > this.ttlMs) {
|
|
||||||
this.cache.delete(key);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move to end for LRU (delete and re-add)
|
|
||||||
this.cache.delete(key);
|
|
||||||
this.cache.set(key, entry);
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
set(key: string, entry: Omit<CacheEntry, 'timestamp'>): void {
|
|
||||||
// Evict oldest entry if at capacity
|
|
||||||
if (this.cache.size >= this.maxSize) {
|
|
||||||
const firstKey = this.cache.keys().next().value;
|
|
||||||
if (firstKey) {
|
|
||||||
this.cache.delete(firstKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.cache.set(key, { ...entry, timestamp: Date.now() });
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidate(key?: string): void {
|
|
||||||
if (key) {
|
|
||||||
this.cache.delete(key);
|
|
||||||
} else {
|
|
||||||
this.cache.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get size(): number {
|
|
||||||
return this.cache.size;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate cache key for a spec path.
|
|
||||||
* - Remote URLs: normalized URL
|
|
||||||
* - Local files: absolute path + mtime for change detection
|
|
||||||
*/
|
|
||||||
export function getCacheKey(specPath: string): string {
|
|
||||||
// Remote URL
|
|
||||||
if (specPath.startsWith('http://') || specPath.startsWith('https://')) {
|
|
||||||
return normalizeUrl(specPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local file: include mtime for change detection
|
|
||||||
try {
|
|
||||||
const resolved = path.resolve(specPath);
|
|
||||||
const stat = fs.statSync(resolved);
|
|
||||||
return `file:${resolved}:${stat.mtimeMs}`;
|
|
||||||
} catch {
|
|
||||||
// File doesn't exist or can't be read - use path only
|
|
||||||
return `file:${path.resolve(specPath)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize URL for consistent cache keys.
|
|
||||||
* - Removes trailing slashes
|
|
||||||
* - Lowercases protocol and host
|
|
||||||
*/
|
|
||||||
function normalizeUrl(url: string): string {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url);
|
|
||||||
// Lowercase protocol and host
|
|
||||||
let normalized = `${parsed.protocol}//${parsed.host.toLowerCase()}`;
|
|
||||||
// Add pathname (remove trailing slash unless it's the root)
|
|
||||||
const pathname = parsed.pathname.replace(/\/+$/, '') || '/';
|
|
||||||
normalized += pathname;
|
|
||||||
// Add search params if present (sorted for consistency)
|
|
||||||
if (parsed.search) {
|
|
||||||
const params = new URLSearchParams(parsed.search);
|
|
||||||
params.sort();
|
|
||||||
normalized += `?${params.toString()}`;
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
} catch {
|
|
||||||
// Invalid URL, use as-is
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global cache instance
|
|
||||||
export const specCache = new SpecCache();
|
|
||||||
|
|
@ -1,310 +0,0 @@
|
||||||
/**
|
|
||||||
* Input validation utilities for security hardening.
|
|
||||||
* Provides protection against ReDoS, path traversal, and SSRF attacks.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { resolve, normalize, sep } from 'path';
|
|
||||||
|
|
||||||
/** Maximum regex pattern length to prevent overly complex patterns */
|
|
||||||
const MAX_REGEX_LENGTH = 500;
|
|
||||||
|
|
||||||
/** Maximum nesting depth for regex groups */
|
|
||||||
const MAX_REGEX_NESTING = 10;
|
|
||||||
|
|
||||||
/** Characters that indicate potentially dangerous regex patterns */
|
|
||||||
const DANGEROUS_REGEX_PATTERNS = [
|
|
||||||
/\(\?[^:]/, // Lookahead/lookbehind (can be slow)
|
|
||||||
/\([^)]*\+[^)]*\)\+/, // Nested quantifiers: (a+)+
|
|
||||||
/\([^)]*\*[^)]*\)\+/, // Nested quantifiers: (a*)+
|
|
||||||
/\([^)]*\+[^)]*\)\*/, // Nested quantifiers: (a+)*
|
|
||||||
/\([^)]*\*[^)]*\)\*/, // Nested quantifiers: (a*)*
|
|
||||||
/\([^)]*\{[^}]+\}[^)]*\)\+/, // Nested quantifiers with {n,m}
|
|
||||||
/\([^)]*\{[^}]+\}[^)]*\)\*/, // Nested quantifiers with {n,m}
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Private IP ranges and localhost patterns for SSRF protection */
|
|
||||||
const PRIVATE_IP_PATTERNS = [
|
|
||||||
/^127\./, // Loopback
|
|
||||||
/^10\./, // Class A private
|
|
||||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // Class B private
|
|
||||||
/^192\.168\./, // Class C private
|
|
||||||
/^0\./, // Current network
|
|
||||||
/^169\.254\./, // Link-local
|
|
||||||
/^::1$/, // IPv6 loopback
|
|
||||||
/^fc00:/i, // IPv6 unique local
|
|
||||||
/^fe80:/i, // IPv6 link-local
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Allowed URL protocols for spec fetching */
|
|
||||||
const ALLOWED_PROTOCOLS = ['http:', 'https:'];
|
|
||||||
|
|
||||||
/** Blocked hostnames for SSRF protection */
|
|
||||||
const BLOCKED_HOSTNAMES = [
|
|
||||||
'localhost',
|
|
||||||
'localhost.localdomain',
|
|
||||||
'127.0.0.1',
|
|
||||||
'::1',
|
|
||||||
'0.0.0.0',
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface ValidationResult {
|
|
||||||
valid: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SecurityOptions {
|
|
||||||
/** Allow internal/private IP addresses (default: false) */
|
|
||||||
allowPrivateIPs?: boolean;
|
|
||||||
/** Custom allowed base directories for file access */
|
|
||||||
allowedBaseDirs?: string[];
|
|
||||||
/** Skip URL validation (for trusted sources) */
|
|
||||||
skipUrlValidation?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a regex pattern for ReDoS vulnerabilities.
|
|
||||||
* Checks for excessive length, nesting depth, and known dangerous patterns.
|
|
||||||
*/
|
|
||||||
export function validateRegexPattern(pattern: string): ValidationResult {
|
|
||||||
// Check length
|
|
||||||
if (pattern.length > MAX_REGEX_LENGTH) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: `Regex pattern exceeds maximum length of ${MAX_REGEX_LENGTH} characters`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for dangerous patterns
|
|
||||||
for (const dangerous of DANGEROUS_REGEX_PATTERNS) {
|
|
||||||
if (dangerous.test(pattern)) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: 'Regex pattern contains potentially dangerous nested quantifiers',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check nesting depth
|
|
||||||
const nestingDepth = countMaxNesting(pattern);
|
|
||||||
if (nestingDepth > MAX_REGEX_NESTING) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: `Regex pattern exceeds maximum nesting depth of ${MAX_REGEX_NESTING}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to compile the regex to catch syntax errors
|
|
||||||
try {
|
|
||||||
new RegExp(pattern);
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: 'Invalid regex pattern syntax',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a safe RegExp from a pattern after validation.
|
|
||||||
* Returns null if the pattern is unsafe or invalid.
|
|
||||||
*/
|
|
||||||
export function createSafeRegex(pattern: string): RegExp | null {
|
|
||||||
const validation = validateRegexPattern(pattern);
|
|
||||||
if (!validation.valid) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return new RegExp(pattern);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a file path to prevent directory traversal attacks.
|
|
||||||
* Ensures the resolved path stays within allowed base directories.
|
|
||||||
*/
|
|
||||||
export function validateFilePath(
|
|
||||||
filePath: string,
|
|
||||||
options?: SecurityOptions
|
|
||||||
): ValidationResult {
|
|
||||||
// Default to current working directory if no base dirs specified
|
|
||||||
const allowedBaseDirs = options?.allowedBaseDirs ?? [process.cwd()];
|
|
||||||
|
|
||||||
// Normalize and resolve the path
|
|
||||||
const normalizedPath = normalize(filePath);
|
|
||||||
const resolvedPath = resolve(normalizedPath);
|
|
||||||
|
|
||||||
// Check for common traversal patterns in the original path
|
|
||||||
if (containsTraversalPatterns(filePath)) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: 'Path contains directory traversal patterns',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the resolved path is within an allowed base directory
|
|
||||||
// Use path.sep for cross-platform compatibility (Windows uses \, Unix uses /)
|
|
||||||
const isWithinAllowed = allowedBaseDirs.some(baseDir => {
|
|
||||||
const resolvedBase = resolve(baseDir);
|
|
||||||
return resolvedPath.startsWith(resolvedBase + sep) || resolvedPath === resolvedBase;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isWithinAllowed) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: 'Path is outside allowed directories',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a URL for SSRF vulnerabilities.
|
|
||||||
* Checks protocol, hostname, and IP address ranges.
|
|
||||||
*
|
|
||||||
* Note: This validation checks the hostname string, not the resolved IP.
|
|
||||||
* For full SSRF protection against DNS rebinding attacks, additional
|
|
||||||
* measures like DNS pinning or resolved IP checking would be needed.
|
|
||||||
* For MCP server use cases (local CLI tool), hostname-based blocking
|
|
||||||
* provides reasonable protection against common SSRF vectors.
|
|
||||||
*/
|
|
||||||
export function validateUrl(
|
|
||||||
urlString: string,
|
|
||||||
options?: SecurityOptions
|
|
||||||
): ValidationResult {
|
|
||||||
if (options?.skipUrlValidation) {
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
let url: URL;
|
|
||||||
try {
|
|
||||||
url = new URL(urlString);
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: 'Invalid URL format',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check protocol
|
|
||||||
if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: `Protocol '${url.protocol}' is not allowed. Use http: or https:`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for blocked hostnames
|
|
||||||
const hostname = url.hostname.toLowerCase();
|
|
||||||
if (BLOCKED_HOSTNAMES.includes(hostname)) {
|
|
||||||
if (!options?.allowPrivateIPs) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: `Hostname '${hostname}' is blocked for security reasons`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for private IP addresses
|
|
||||||
if (!options?.allowPrivateIPs && isPrivateIP(hostname)) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: 'Private and internal IP addresses are not allowed',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines if a spec path is a URL or a local file path.
|
|
||||||
* Checks for common URL schemes to ensure proper routing to URL validation.
|
|
||||||
*/
|
|
||||||
export function isUrl(specPath: string): boolean {
|
|
||||||
// Check for common URL protocols (validated schemes handled separately)
|
|
||||||
const urlSchemePattern = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
|
|
||||||
return urlSchemePattern.test(specPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates a spec path (either URL or file path).
|
|
||||||
* Returns validation result with appropriate checks based on path type.
|
|
||||||
*/
|
|
||||||
export function validateSpecPath(
|
|
||||||
specPath: string,
|
|
||||||
options?: SecurityOptions
|
|
||||||
): ValidationResult {
|
|
||||||
if (isUrl(specPath)) {
|
|
||||||
return validateUrl(specPath, options);
|
|
||||||
}
|
|
||||||
return validateFilePath(specPath, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Helper Functions ============
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Counts maximum nesting depth of parentheses in a pattern.
|
|
||||||
*/
|
|
||||||
function countMaxNesting(pattern: string): number {
|
|
||||||
let maxDepth = 0;
|
|
||||||
let currentDepth = 0;
|
|
||||||
let inCharClass = false;
|
|
||||||
let escaped = false;
|
|
||||||
|
|
||||||
for (const char of pattern) {
|
|
||||||
if (escaped) {
|
|
||||||
escaped = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (char === '\\') {
|
|
||||||
escaped = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (char === '[' && !inCharClass) {
|
|
||||||
inCharClass = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (char === ']' && inCharClass) {
|
|
||||||
inCharClass = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inCharClass) continue;
|
|
||||||
|
|
||||||
if (char === '(') {
|
|
||||||
currentDepth++;
|
|
||||||
maxDepth = Math.max(maxDepth, currentDepth);
|
|
||||||
} else if (char === ')') {
|
|
||||||
currentDepth = Math.max(0, currentDepth - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return maxDepth;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a path contains common directory traversal patterns.
|
|
||||||
*/
|
|
||||||
function containsTraversalPatterns(path: string): boolean {
|
|
||||||
const traversalPatterns = [
|
|
||||||
/\.\.\//, // ../
|
|
||||||
/\.\.\\/, // ..\
|
|
||||||
/%2e%2e[\/\\]/i, // URL-encoded ../
|
|
||||||
/%2e%2e%2f/i, // URL-encoded ../
|
|
||||||
/%252e%252e/i, // Double URL-encoded
|
|
||||||
/\.\.%2f/i, // Mixed encoding
|
|
||||||
/\.\.%5c/i, // Mixed encoding with backslash
|
|
||||||
];
|
|
||||||
|
|
||||||
return traversalPatterns.some(pattern => pattern.test(path));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a hostname or IP is in a private range.
|
|
||||||
*/
|
|
||||||
function isPrivateIP(hostnameOrIP: string): boolean {
|
|
||||||
return PRIVATE_IP_PATTERNS.some(pattern => pattern.test(hostnameOrIP));
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +1,15 @@
|
||||||
import SwaggerParser from '@apidevtools/swagger-parser';
|
import SwaggerParser from '@apidevtools/swagger-parser';
|
||||||
import type { OpenAPIV3 } from 'openapi-types';
|
import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
|
||||||
import { HTTP_METHODS } from './types.js';
|
|
||||||
import type { ParsedSpec, OpenAPISpec } from './types.js';
|
import type { ParsedSpec, OpenAPISpec } from './types.js';
|
||||||
import { specCache, getCacheKey } from './cache.js';
|
|
||||||
import { getSchemaCount } from './schema-utils.js';
|
|
||||||
import { isOpenAPIV3, isSwaggerV2, getSpecVersion as getVersion } from './spec-guards.js';
|
|
||||||
import { validateSpecPath, isUrl } from './input-validation.js';
|
|
||||||
import type { SecurityOptions } from './input-validation.js';
|
|
||||||
|
|
||||||
export interface ParseOptions {
|
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'] as const;
|
||||||
dereference?: boolean;
|
|
||||||
noCache?: boolean;
|
|
||||||
/** Security options for path/URL validation */
|
|
||||||
security?: SecurityOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ParseResult {
|
export async function parseSpec(specPath: string, options?: { dereference?: boolean }): Promise<{
|
||||||
spec: OpenAPISpec;
|
spec: OpenAPISpec;
|
||||||
metadata: ParsedSpec;
|
metadata: ParsedSpec;
|
||||||
dereferenced: boolean;
|
dereferenced: boolean;
|
||||||
cached?: boolean;
|
}> {
|
||||||
}
|
|
||||||
|
|
||||||
export async function parseSpec(specPath: string, options?: ParseOptions): Promise<ParseResult> {
|
|
||||||
const shouldDereference = options?.dereference !== false;
|
const shouldDereference = options?.dereference !== false;
|
||||||
const useCache = options?.noCache !== true;
|
|
||||||
|
|
||||||
// Validate spec path for security (path traversal / SSRF protection)
|
|
||||||
const validation = validateSpecPath(specPath, options?.security);
|
|
||||||
if (!validation.valid) {
|
|
||||||
throw new Error(`Security validation failed: ${validation.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check cache first (unless noCache is set)
|
|
||||||
if (useCache) {
|
|
||||||
const cacheKey = getCacheKey(specPath);
|
|
||||||
const cached = specCache.get(cacheKey);
|
|
||||||
if (cached) {
|
|
||||||
return {
|
|
||||||
spec: cached.spec,
|
|
||||||
metadata: cached.metadata,
|
|
||||||
dereferenced: cached.dereferenced,
|
|
||||||
cached: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let spec: OpenAPISpec;
|
let spec: OpenAPISpec;
|
||||||
let dereferenced = false;
|
let dereferenced = false;
|
||||||
|
|
@ -62,27 +27,15 @@ export async function parseSpec(specPath: string, options?: ParseOptions): Promi
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = extractMetadata(spec);
|
const metadata = extractMetadata(spec);
|
||||||
|
return { spec, metadata, dereferenced };
|
||||||
// Store in cache
|
|
||||||
if (useCache) {
|
|
||||||
const cacheKey = getCacheKey(specPath);
|
|
||||||
specCache.set(cacheKey, { spec, metadata, dereferenced });
|
|
||||||
}
|
|
||||||
|
|
||||||
return { spec, metadata, dereferenced, cached: false };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bundleSpec(specPath: string, options?: ParseOptions): Promise<OpenAPISpec> {
|
export async function bundleSpec(specPath: string): Promise<OpenAPISpec> {
|
||||||
// Validate spec path for security (path traversal / SSRF protection)
|
|
||||||
const validation = validateSpecPath(specPath, options?.security);
|
|
||||||
if (!validation.valid) {
|
|
||||||
throw new Error(`Security validation failed: ${validation.error}`);
|
|
||||||
}
|
|
||||||
return SwaggerParser.bundle(specPath);
|
return SwaggerParser.bundle(specPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractMetadata(spec: OpenAPISpec): ParsedSpec {
|
function extractMetadata(spec: OpenAPISpec): ParsedSpec {
|
||||||
const version = getSpecVersionString(spec);
|
const version = getSpecVersion(spec);
|
||||||
const info = spec.info;
|
const info = spec.info;
|
||||||
|
|
||||||
const paths = spec.paths || {};
|
const paths = spec.paths || {};
|
||||||
|
|
@ -107,12 +60,6 @@ function extractMetadata(spec: OpenAPISpec): ParsedSpec {
|
||||||
const schemaCount = getSchemaCount(spec);
|
const schemaCount = getSchemaCount(spec);
|
||||||
const servers = getServers(spec);
|
const servers = getServers(spec);
|
||||||
|
|
||||||
// Extract webhook count (OpenAPI 3.1+)
|
|
||||||
const webhooks = (spec as { webhooks?: Record<string, unknown> }).webhooks;
|
|
||||||
const webhookCount = (webhooks && typeof webhooks === 'object' && !Array.isArray(webhooks))
|
|
||||||
? Object.keys(webhooks).length
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
version,
|
version,
|
||||||
title: info.title,
|
title: info.title,
|
||||||
|
|
@ -122,29 +69,38 @@ function extractMetadata(spec: OpenAPISpec): ParsedSpec {
|
||||||
schemaCount,
|
schemaCount,
|
||||||
operationCount,
|
operationCount,
|
||||||
tags: Array.from(tagsSet).sort(),
|
tags: Array.from(tagsSet).sort(),
|
||||||
webhookCount,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSpecVersionString(spec: OpenAPISpec): string {
|
function getSpecVersion(spec: OpenAPISpec): string {
|
||||||
const version = getVersion(spec);
|
if ('openapi' in spec) {
|
||||||
if (isOpenAPIV3(spec)) {
|
return `OpenAPI ${spec.openapi}`;
|
||||||
return `OpenAPI ${version}`;
|
|
||||||
}
|
}
|
||||||
if (isSwaggerV2(spec)) {
|
if ('swagger' in spec) {
|
||||||
return `Swagger ${version}`;
|
return `Swagger ${spec.swagger}`;
|
||||||
}
|
}
|
||||||
return 'Unknown';
|
return 'Unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSchemaCount(spec: OpenAPISpec): number {
|
||||||
|
if ('components' in spec && spec.components?.schemas) {
|
||||||
|
return Object.keys(spec.components.schemas).length;
|
||||||
|
}
|
||||||
|
if ('definitions' in spec && spec.definitions) {
|
||||||
|
return Object.keys(spec.definitions).length;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
function getServers(spec: OpenAPISpec): string[] {
|
function getServers(spec: OpenAPISpec): string[] {
|
||||||
if (isOpenAPIV3(spec) && spec.servers) {
|
if ('servers' in spec && spec.servers) {
|
||||||
return spec.servers.map(s => s.url);
|
return spec.servers.map(s => s.url);
|
||||||
}
|
}
|
||||||
if (isSwaggerV2(spec)) {
|
if ('host' in spec) {
|
||||||
const scheme = spec.schemes?.[0] || 'https';
|
const swagger2 = spec as { host?: string; basePath?: string; schemes?: string[] };
|
||||||
const host = spec.host || 'localhost';
|
const scheme = swagger2.schemes?.[0] || 'https';
|
||||||
const basePath = spec.basePath || '';
|
const host = swagger2.host || 'localhost';
|
||||||
|
const basePath = swagger2.basePath || '';
|
||||||
return [`${scheme}://${host}${basePath}`];
|
return [`${scheme}://${host}${basePath}`];
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
|
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
/**
|
|
||||||
* Consolidated schema access utilities for OpenAPI/Swagger specs.
|
|
||||||
* Handles both OpenAPI 3.x (components.schemas) and Swagger 2.0 (definitions).
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all schemas from a spec, handling both OpenAPI 3.x and Swagger 2.0 formats.
|
|
||||||
*/
|
|
||||||
export function getSchemas(spec: object): Record<string, object> {
|
|
||||||
// OpenAPI 3.x: components.schemas
|
|
||||||
const spec3 = spec as { components?: { schemas?: Record<string, object> } };
|
|
||||||
if (spec3.components?.schemas) {
|
|
||||||
return spec3.components.schemas;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Swagger 2.0: definitions
|
|
||||||
const spec2 = spec as { definitions?: Record<string, object> };
|
|
||||||
if (spec2.definitions) {
|
|
||||||
return spec2.definitions;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find a specific schema by name.
|
|
||||||
*/
|
|
||||||
export function findSchema(spec: object, schemaName: string): object | null {
|
|
||||||
const schemas = getSchemas(spec);
|
|
||||||
return schemas[schemaName] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get list of all schema names.
|
|
||||||
*/
|
|
||||||
export function getSchemaNames(spec: object): string[] {
|
|
||||||
return Object.keys(getSchemas(spec));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get count of schemas in the spec.
|
|
||||||
*/
|
|
||||||
export function getSchemaCount(spec: object): number {
|
|
||||||
return Object.keys(getSchemas(spec)).length;
|
|
||||||
}
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
/**
|
|
||||||
* Type guards for OpenAPI specification version detection.
|
|
||||||
* Provides type-safe access to spec properties based on version.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { OpenAPI, OpenAPIV2, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
|
|
||||||
|
|
||||||
/** OpenAPI 3.x specification (v3.0 or v3.1) */
|
|
||||||
export type OpenAPIV3Spec = OpenAPIV3.Document | OpenAPIV3_1.Document;
|
|
||||||
|
|
||||||
/** Swagger 2.0 specification */
|
|
||||||
export type SwaggerV2Spec = OpenAPIV2.Document;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if spec is OpenAPI 3.x (has 'openapi' property starting with '3.')
|
|
||||||
*/
|
|
||||||
export function isOpenAPIV3(spec: OpenAPI.Document): spec is OpenAPIV3Spec {
|
|
||||||
const openapi = (spec as { openapi?: string }).openapi;
|
|
||||||
return typeof openapi === 'string' && openapi.startsWith('3.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if spec is Swagger 2.0 (has 'swagger' property equal to '2.0')
|
|
||||||
*/
|
|
||||||
export function isSwaggerV2(spec: OpenAPI.Document): spec is SwaggerV2Spec {
|
|
||||||
const swagger = (spec as { swagger?: string }).swagger;
|
|
||||||
return swagger === '2.0';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if spec is OpenAPI 3.1 specifically (has 'openapi' starting with '3.1')
|
|
||||||
*/
|
|
||||||
export function isOpenAPIV31(spec: OpenAPI.Document): spec is OpenAPIV3_1.Document {
|
|
||||||
const openapi = (spec as { openapi?: string }).openapi;
|
|
||||||
return typeof openapi === 'string' && openapi.startsWith('3.1');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the spec version string (e.g., "3.0.0", "3.1.0", "2.0")
|
|
||||||
*/
|
|
||||||
export function getSpecVersion(spec: OpenAPI.Document): string {
|
|
||||||
if (isOpenAPIV3(spec)) {
|
|
||||||
return (spec as { openapi: string }).openapi;
|
|
||||||
}
|
|
||||||
if (isSwaggerV2(spec)) {
|
|
||||||
return (spec as { swagger: string }).swagger;
|
|
||||||
}
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get schemas from spec with proper type narrowing
|
|
||||||
* Returns components.schemas for v3, definitions for v2
|
|
||||||
*/
|
|
||||||
export function getSchemasTyped(spec: OpenAPI.Document): Record<string, object> {
|
|
||||||
if (isOpenAPIV3(spec)) {
|
|
||||||
return spec.components?.schemas as Record<string, object> ?? {};
|
|
||||||
}
|
|
||||||
if (isSwaggerV2(spec)) {
|
|
||||||
return spec.definitions ?? {};
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get paths object from spec
|
|
||||||
*/
|
|
||||||
export function getPathsTyped(spec: OpenAPI.Document): Record<string, object> {
|
|
||||||
return (spec as { paths?: Record<string, object> }).paths ?? {};
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
/**
|
|
||||||
* Tool response helpers for consistent MCP tool responses.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Standard MCP tool response structure - compatible with MCP SDK index signature */
|
|
||||||
export interface ToolResponse {
|
|
||||||
[x: string]: unknown;
|
|
||||||
content: Array<{ type: 'text'; text: string }>;
|
|
||||||
structuredContent: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a success response with text content and structured data.
|
|
||||||
*/
|
|
||||||
export function successResponse(text: string, data: Record<string, unknown>): ToolResponse {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text', text }],
|
|
||||||
structuredContent: { success: true, ...data },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an error response with error message.
|
|
||||||
* @param message - Error message
|
|
||||||
* @param context - Optional context prefix (e.g., "parsing spec", "querying endpoints")
|
|
||||||
*/
|
|
||||||
export function errorResponse(message: string, context?: string): ToolResponse {
|
|
||||||
const text = context ? `Error ${context}: ${message}` : `Error: ${message}`;
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text', text }],
|
|
||||||
structuredContent: { success: false, error: message },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -3,12 +3,6 @@ import type { OpenAPI, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types';
|
||||||
export type OpenAPISpec = OpenAPI.Document;
|
export type OpenAPISpec = OpenAPI.Document;
|
||||||
export type OpenAPIV3Spec = OpenAPIV3.Document | OpenAPIV3_1.Document;
|
export type OpenAPIV3Spec = OpenAPIV3.Document | OpenAPIV3_1.Document;
|
||||||
|
|
||||||
/** Standard HTTP methods supported in OpenAPI specifications */
|
|
||||||
export const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'] as const;
|
|
||||||
|
|
||||||
/** Type derived from HTTP_METHODS constant */
|
|
||||||
export type HttpMethod = typeof HTTP_METHODS[number];
|
|
||||||
|
|
||||||
export interface ParsedSpec {
|
export interface ParsedSpec {
|
||||||
version: string;
|
version: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -18,8 +12,6 @@ export interface ParsedSpec {
|
||||||
schemaCount: number;
|
schemaCount: number;
|
||||||
operationCount: number;
|
operationCount: number;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
/** Number of webhooks (OpenAPI 3.1+) */
|
|
||||||
webhookCount?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ValidationError {
|
export interface ValidationError {
|
||||||
|
|
@ -81,16 +73,3 @@ export interface SchemaInfo {
|
||||||
required?: string[];
|
required?: string[];
|
||||||
schema: object;
|
schema: object;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TypeScriptOptions {
|
|
||||||
/** Generate enums as union types (default: true) */
|
|
||||||
enumAsUnion?: boolean;
|
|
||||||
/** Generate enums as TypeScript enums */
|
|
||||||
enumAsEnum?: boolean;
|
|
||||||
/** Prefix for interface names (e.g., "I") */
|
|
||||||
interfacePrefix?: string;
|
|
||||||
/** Suffix for interface names (e.g., "Type") */
|
|
||||||
interfaceSuffix?: string;
|
|
||||||
/** Indentation style: '2' spaces, '4' spaces, or 'tab' */
|
|
||||||
indentation?: '2' | '4' | 'tab';
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import SwaggerParser from '@apidevtools/swagger-parser';
|
import SwaggerParser from '@apidevtools/swagger-parser';
|
||||||
import { HTTP_METHODS } from './types.js';
|
|
||||||
import type { ValidationResult, ValidationError } from './types.js';
|
import type { ValidationResult, ValidationError } from './types.js';
|
||||||
|
|
||||||
export async function validateSpec(specPath: string): Promise<ValidationResult> {
|
export async function validateSpec(specPath: string): Promise<ValidationResult> {
|
||||||
|
|
@ -55,7 +54,7 @@ export async function validateWithWarnings(specPath: string): Promise<Validation
|
||||||
if (spec.paths) {
|
if (spec.paths) {
|
||||||
for (const [path, pathItem] of Object.entries(spec.paths)) {
|
for (const [path, pathItem] of Object.entries(spec.paths)) {
|
||||||
if (!pathItem) continue;
|
if (!pathItem) continue;
|
||||||
for (const method of HTTP_METHODS) {
|
for (const method of ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']) {
|
||||||
const operation = (pathItem as Record<string, unknown>)[method] as { operationId?: string } | undefined;
|
const operation = (pathItem as Record<string, unknown>)[method] as { operationId?: string } | undefined;
|
||||||
if (operation && !operation.operationId) {
|
if (operation && !operation.operationId) {
|
||||||
result.warnings.push({
|
result.warnings.push({
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { parseSpec } from '../lib/parser.js';
|
import { parseSpec } from '../lib/parser.js';
|
||||||
import { formatTypes } from '../utils/format.js';
|
import { formatTypes } from '../utils/format.js';
|
||||||
import { getSchemas } from '../lib/schema-utils.js';
|
|
||||||
import { successResponse, errorResponse } from '../lib/tool-response.js';
|
|
||||||
import type { ToolResponse } from '../lib/tool-response.js';
|
|
||||||
import type { TypeScriptOptions } from '../lib/types.js';
|
|
||||||
|
|
||||||
export const generateToolName = 'generate-types';
|
export const generateToolName = 'generate-types';
|
||||||
|
|
||||||
|
|
@ -13,41 +9,17 @@ export const generateToolDescription = 'Generate TypeScript interfaces from Open
|
||||||
export const generateToolSchema = {
|
export const generateToolSchema = {
|
||||||
path: z.string().describe('Path to the OpenAPI/Swagger spec file'),
|
path: z.string().describe('Path to the OpenAPI/Swagger spec file'),
|
||||||
schemas: z.array(z.string()).optional().describe('Specific schema names to generate (all if omitted)'),
|
schemas: z.array(z.string()).optional().describe('Specific schema names to generate (all if omitted)'),
|
||||||
noCache: z.boolean().optional().describe('Bypass cache and parse fresh'),
|
|
||||||
options: z.object({
|
|
||||||
enumAsUnion: z.boolean().optional().describe('Generate enums as union types (default: true)'),
|
|
||||||
enumAsEnum: z.boolean().optional().describe('Generate enums as TypeScript enums'),
|
|
||||||
interfacePrefix: z.string()
|
|
||||||
.regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/)
|
|
||||||
.optional()
|
|
||||||
.describe('Prefix for interface names (e.g., "I")'),
|
|
||||||
interfaceSuffix: z.string()
|
|
||||||
.regex(/^[a-zA-Z_][a-zA-Z0-9_]*$/)
|
|
||||||
.optional()
|
|
||||||
.describe('Suffix for interface names (e.g., "Type")'),
|
|
||||||
indentation: z.enum(['2', '4', 'tab']).optional().describe('Indentation style'),
|
|
||||||
}).optional().describe('TypeScript generation options'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function generateToolHandler({ path, schemas, noCache, options }: {
|
export async function generateToolHandler({ path, schemas }: {
|
||||||
path: string;
|
path: string;
|
||||||
schemas?: string[];
|
schemas?: string[];
|
||||||
noCache?: boolean;
|
}): Promise<{
|
||||||
options?: TypeScriptOptions;
|
content: Array<{ type: 'text'; text: string }>;
|
||||||
}): Promise<ToolResponse> {
|
structuredContent: Record<string, unknown>;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
// Validate mutually exclusive options
|
const { spec } = await parseSpec(path);
|
||||||
if (options?.enumAsUnion && options?.enumAsEnum) {
|
|
||||||
return {
|
|
||||||
content: [{ type: 'text', text: 'Error: enumAsUnion and enumAsEnum are mutually exclusive.' }],
|
|
||||||
structuredContent: {
|
|
||||||
success: false,
|
|
||||||
error: 'enumAsUnion and enumAsEnum cannot both be true',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const { spec } = await parseSpec(path, { noCache });
|
|
||||||
|
|
||||||
const allSchemas = getSchemas(spec);
|
const allSchemas = getSchemas(spec);
|
||||||
|
|
||||||
|
|
@ -68,76 +40,50 @@ export async function generateToolHandler({ path, schemas, noCache, options }: {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const types = generateTypeScript(schemasToGenerate, options);
|
const types = generateTypeScript(schemasToGenerate);
|
||||||
const text = formatTypes(types);
|
const text = formatTypes(types);
|
||||||
|
|
||||||
return successResponse(text, {
|
return {
|
||||||
types,
|
content: [{ type: 'text', text }],
|
||||||
generatedCount: Object.keys(schemasToGenerate).length,
|
structuredContent: {
|
||||||
options: options || {},
|
success: true,
|
||||||
});
|
types,
|
||||||
|
generatedCount: Object.keys(schemasToGenerate).length,
|
||||||
|
},
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return errorResponse((err as Error).message, 'generating types');
|
const error = err as Error;
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Error generating types: ${error.message}` }],
|
||||||
|
structuredContent: {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get indentation string based on options */
|
function getSchemas(spec: object): Record<string, object> {
|
||||||
function getIndent(options?: TypeScriptOptions): string {
|
// OpenAPI 3.x
|
||||||
const style = options?.indentation ?? '2';
|
const spec3 = spec as { components?: { schemas?: Record<string, object> } };
|
||||||
switch (style) {
|
if (spec3.components?.schemas) {
|
||||||
case '4': return ' ';
|
return spec3.components.schemas;
|
||||||
case 'tab': return '\t';
|
|
||||||
default: return ' ';
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert string to PascalCase for enum member names */
|
// Swagger 2.0
|
||||||
function toPascalCase(value: string): string {
|
const spec2 = spec as { definitions?: Record<string, object> };
|
||||||
return value
|
if (spec2.definitions) {
|
||||||
.replace(/[-_\s]+(.)?/g, (_, c) => c ? c.toUpperCase() : '')
|
return spec2.definitions;
|
||||||
.replace(/^(.)/, (c) => c.toUpperCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Apply prefix and suffix to a type name */
|
|
||||||
function applyNaming(name: string, options?: TypeScriptOptions): string {
|
|
||||||
const prefix = options?.interfacePrefix ?? '';
|
|
||||||
const suffix = options?.interfaceSuffix ?? '';
|
|
||||||
return `${prefix}${name}${suffix}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Map OpenAPI primitive type to TypeScript type */
|
|
||||||
function mapPrimitiveType(type: string): string {
|
|
||||||
switch (type) {
|
|
||||||
case 'string': return 'string';
|
|
||||||
case 'integer':
|
|
||||||
case 'number': return 'number';
|
|
||||||
case 'boolean': return 'boolean';
|
|
||||||
case 'null': return 'null';
|
|
||||||
case 'object': return 'Record<string, unknown>';
|
|
||||||
case 'array': return 'unknown[]';
|
|
||||||
default: return 'unknown';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Escape single quotes in string literals for TypeScript generation */
|
function generateTypeScript(schemas: Record<string, object>): string {
|
||||||
function escapeStringLiteral(value: string): string {
|
|
||||||
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Escape JSDoc comment terminators */
|
|
||||||
function escapeJSDoc(text: string): string {
|
|
||||||
return text
|
|
||||||
.replace(/\*\//g, '*\\/')
|
|
||||||
.replace(/\n/g, ' ')
|
|
||||||
.replace(/\r/g, '')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateTypeScript(schemas: Record<string, object>, options?: TypeScriptOptions): string {
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
|
||||||
for (const [name, schema] of Object.entries(schemas)) {
|
for (const [name, schema] of Object.entries(schemas)) {
|
||||||
const typeDef = schemaToTypeScript(name, schema, options);
|
const typeDef = schemaToTypeScript(name, schema);
|
||||||
lines.push(typeDef);
|
lines.push(typeDef);
|
||||||
lines.push('');
|
lines.push('');
|
||||||
}
|
}
|
||||||
|
|
@ -180,10 +126,8 @@ function sanitizeName(name: string): string {
|
||||||
return sanitized;
|
return sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function schemaToTypeScript(name: string, schema: object, options?: TypeScriptOptions): string {
|
function schemaToTypeScript(name: string, schema: object): string {
|
||||||
const sanitized = sanitizeName(name);
|
const safeName = sanitizeName(name);
|
||||||
const safeName = applyNaming(sanitized, options);
|
|
||||||
const indent = getIndent(options);
|
|
||||||
const s = schema as {
|
const s = schema as {
|
||||||
type?: string;
|
type?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
@ -201,27 +145,15 @@ function schemaToTypeScript(name: string, schema: object, options?: TypeScriptOp
|
||||||
|
|
||||||
// Add JSDoc comment with original name if different
|
// Add JSDoc comment with original name if different
|
||||||
if (s.description) {
|
if (s.description) {
|
||||||
lines.push(`/** ${escapeJSDoc(s.description)} */`);
|
lines.push(`/** ${s.description} */`);
|
||||||
} else if (sanitized !== name) {
|
} else if (safeName !== name) {
|
||||||
lines.push(`/** Original: ${escapeJSDoc(name)} */`);
|
lines.push(`/** Original: ${name} */`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle enums
|
// Handle enums
|
||||||
if (s.enum) {
|
if (s.enum) {
|
||||||
// Generate TypeScript enum if enumAsEnum is true
|
const values = s.enum.map(v => typeof v === 'string' ? `'${v}'` : v).join(' | ');
|
||||||
if (options?.enumAsEnum) {
|
lines.push(`export type ${safeName} = ${values};`);
|
||||||
lines.push(`export enum ${safeName} {`);
|
|
||||||
for (const value of s.enum) {
|
|
||||||
const memberName = typeof value === 'string' ? toPascalCase(value) : `Value${value}`;
|
|
||||||
const memberValue = typeof value === 'string' ? `'${escapeStringLiteral(value)}'` : value;
|
|
||||||
lines.push(`${indent}${memberName} = ${memberValue},`);
|
|
||||||
}
|
|
||||||
lines.push('}');
|
|
||||||
} else {
|
|
||||||
// Default: generate union type
|
|
||||||
const values = s.enum.map(v => typeof v === 'string' ? `'${escapeStringLiteral(v)}'` : v).join(' | ');
|
|
||||||
lines.push(`export type ${safeName} = ${values};`);
|
|
||||||
}
|
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -234,14 +166,14 @@ function schemaToTypeScript(name: string, schema: object, options?: TypeScriptOp
|
||||||
|
|
||||||
for (const [propName, propSchema] of Object.entries(s.properties)) {
|
for (const [propName, propSchema] of Object.entries(s.properties)) {
|
||||||
const optional = required.has(propName) ? '' : '?';
|
const optional = required.has(propName) ? '' : '?';
|
||||||
const propType = schemaToType(propSchema, options);
|
const propType = schemaToType(propSchema);
|
||||||
const propDesc = (propSchema as { description?: string }).description;
|
const propDesc = (propSchema as { description?: string }).description;
|
||||||
|
|
||||||
if (propDesc) {
|
if (propDesc) {
|
||||||
lines.push(`${indent}/** ${escapeJSDoc(propDesc)} */`);
|
lines.push(` /** ${propDesc} */`);
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push(`${indent}${propName}${optional}: ${propType};`);
|
lines.push(` ${propName}${optional}: ${propType};`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -251,20 +183,20 @@ function schemaToTypeScript(name: string, schema: object, options?: TypeScriptOp
|
||||||
|
|
||||||
// Handle array types
|
// Handle array types
|
||||||
if (s.type === 'array' && s.items) {
|
if (s.type === 'array' && s.items) {
|
||||||
const itemType = schemaToType(s.items, options);
|
const itemType = schemaToType(s.items);
|
||||||
lines.push(`export type ${safeName} = ${itemType}[];`);
|
lines.push(`export type ${safeName} = ${itemType}[];`);
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle simple types
|
// Handle simple types
|
||||||
const simpleType = schemaToType(schema, options);
|
const simpleType = schemaToType(schema);
|
||||||
lines.push(`export type ${safeName} = ${simpleType};`);
|
lines.push(`export type ${safeName} = ${simpleType};`);
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function schemaToType(schema: object, options?: TypeScriptOptions): string {
|
function schemaToType(schema: object): string {
|
||||||
const s = schema as {
|
const s = schema as {
|
||||||
type?: string | string[]; // OpenAPI 3.1: type can be an array
|
type?: string;
|
||||||
format?: string;
|
format?: string;
|
||||||
$ref?: string;
|
$ref?: string;
|
||||||
items?: object;
|
items?: object;
|
||||||
|
|
@ -275,68 +207,40 @@ function schemaToType(schema: object, options?: TypeScriptOptions): string {
|
||||||
oneOf?: object[];
|
oneOf?: object[];
|
||||||
anyOf?: object[];
|
anyOf?: object[];
|
||||||
additionalProperties?: boolean | object;
|
additionalProperties?: boolean | object;
|
||||||
nullable?: boolean; // OpenAPI 3.0 nullable
|
|
||||||
const?: unknown; // OpenAPI 3.1 const keyword
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle const keyword (OpenAPI 3.1) - returns literal type
|
|
||||||
if (s.const !== undefined) {
|
|
||||||
if (typeof s.const === 'string') {
|
|
||||||
return `'${escapeStringLiteral(s.const)}'`;
|
|
||||||
}
|
|
||||||
if (typeof s.const === 'number' || typeof s.const === 'boolean') {
|
|
||||||
return String(s.const);
|
|
||||||
}
|
|
||||||
if (s.const === null) {
|
|
||||||
return 'null';
|
|
||||||
}
|
|
||||||
// For objects/arrays, fall back to the type
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle type as array (OpenAPI 3.1) - e.g., type: ['string', 'null']
|
|
||||||
if (Array.isArray(s.type)) {
|
|
||||||
if (s.type.length === 0) {
|
|
||||||
return 'unknown'; // Fallback for invalid/empty type array
|
|
||||||
}
|
|
||||||
// Deduplicate types to avoid 'string | string' scenarios
|
|
||||||
const types = [...new Set(s.type.map(t => mapPrimitiveType(t)))];
|
|
||||||
return types.join(' | ');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle $ref (after dereferencing, these should be resolved, but handle just in case)
|
// Handle $ref (after dereferencing, these should be resolved, but handle just in case)
|
||||||
if (s.$ref) {
|
if (s.$ref) {
|
||||||
const refName = sanitizeName(s.$ref.split('/').pop() || 'unknown');
|
const refName = sanitizeName(s.$ref.split('/').pop() || 'unknown');
|
||||||
// Apply naming to referenced types
|
return refName;
|
||||||
return applyNaming(refName, options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle enums (inline)
|
// Handle enums
|
||||||
if (s.enum) {
|
if (s.enum) {
|
||||||
return s.enum.map(v => typeof v === 'string' ? `'${escapeStringLiteral(v)}'` : v).join(' | ');
|
return s.enum.map(v => typeof v === 'string' ? `'${v}'` : v).join(' | ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle allOf (intersection)
|
// Handle allOf (intersection)
|
||||||
if (s.allOf) {
|
if (s.allOf) {
|
||||||
return s.allOf.map(sub => schemaToType(sub, options)).join(' & ');
|
return s.allOf.map(schemaToType).join(' & ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle oneOf/anyOf (union)
|
// Handle oneOf/anyOf (union)
|
||||||
if (s.oneOf || s.anyOf) {
|
if (s.oneOf || s.anyOf) {
|
||||||
const schemas = s.oneOf || s.anyOf || [];
|
const schemas = s.oneOf || s.anyOf || [];
|
||||||
return schemas.map(sub => schemaToType(sub, options)).join(' | ');
|
return schemas.map(schemaToType).join(' | ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle arrays
|
// Handle arrays
|
||||||
if (s.type === 'array') {
|
if (s.type === 'array') {
|
||||||
const itemType = s.items ? schemaToType(s.items, options) : 'unknown';
|
const itemType = s.items ? schemaToType(s.items) : 'unknown';
|
||||||
return `${itemType}[]`;
|
return `${itemType}[]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle objects with additionalProperties
|
// Handle objects with additionalProperties
|
||||||
if (s.type === 'object' && s.additionalProperties) {
|
if (s.type === 'object' && s.additionalProperties) {
|
||||||
const valueType = typeof s.additionalProperties === 'object'
|
const valueType = typeof s.additionalProperties === 'object'
|
||||||
? schemaToType(s.additionalProperties, options)
|
? schemaToType(s.additionalProperties)
|
||||||
: 'unknown';
|
: 'unknown';
|
||||||
return `Record<string, ${valueType}>`;
|
return `Record<string, ${valueType}>`;
|
||||||
}
|
}
|
||||||
|
|
@ -347,39 +251,29 @@ function schemaToType(schema: object, options?: TypeScriptOptions): string {
|
||||||
const props = Object.entries(s.properties)
|
const props = Object.entries(s.properties)
|
||||||
.map(([name, prop]) => {
|
.map(([name, prop]) => {
|
||||||
const optional = required.has(name) ? '' : '?';
|
const optional = required.has(name) ? '' : '?';
|
||||||
return `${name}${optional}: ${schemaToType(prop, options)}`;
|
return `${name}${optional}: ${schemaToType(prop)}`;
|
||||||
})
|
})
|
||||||
.join('; ');
|
.join('; ');
|
||||||
return `{ ${props} }`;
|
return `{ ${props} }`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle primitive types
|
// Handle primitive types
|
||||||
let baseType: string;
|
|
||||||
switch (s.type) {
|
switch (s.type) {
|
||||||
case 'string':
|
case 'string':
|
||||||
baseType = 'string';
|
if (s.format === 'date' || s.format === 'date-time') {
|
||||||
break;
|
return 'string'; // Could be Date, but string is safer
|
||||||
|
}
|
||||||
|
return 'string';
|
||||||
case 'integer':
|
case 'integer':
|
||||||
case 'number':
|
case 'number':
|
||||||
baseType = 'number';
|
return 'number';
|
||||||
break;
|
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
baseType = 'boolean';
|
return 'boolean';
|
||||||
break;
|
|
||||||
case 'null':
|
case 'null':
|
||||||
baseType = 'null';
|
return 'null';
|
||||||
break;
|
|
||||||
case 'object':
|
case 'object':
|
||||||
baseType = 'Record<string, unknown>';
|
return 'Record<string, unknown>';
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
baseType = 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle nullable (OpenAPI 3.0) - adds | null to the type
|
|
||||||
if (s.nullable && baseType !== 'null') {
|
|
||||||
return `${baseType} | null`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseType;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { parseSpec } from '../lib/parser.js';
|
import { parseSpec } from '../lib/parser.js';
|
||||||
import { formatMetadata } from '../utils/format.js';
|
import { formatMetadata } from '../utils/format.js';
|
||||||
import { successResponse, errorResponse } from '../lib/tool-response.js';
|
|
||||||
import type { ToolResponse } from '../lib/tool-response.js';
|
|
||||||
|
|
||||||
export const parseToolName = 'parse-spec';
|
export const parseToolName = 'parse-spec';
|
||||||
|
|
||||||
|
|
@ -10,26 +8,37 @@ export const parseToolDescription = 'Parse and analyze an OpenAPI/Swagger specif
|
||||||
|
|
||||||
export const parseToolSchema = {
|
export const parseToolSchema = {
|
||||||
path: z.string().describe('Path to the OpenAPI/Swagger spec file (YAML or JSON)'),
|
path: z.string().describe('Path to the OpenAPI/Swagger spec file (YAML or JSON)'),
|
||||||
noCache: z.boolean().optional().describe('Bypass cache and parse fresh'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function parseToolHandler({ path, noCache }: {
|
export async function parseToolHandler({ path }: { path: string }): Promise<{
|
||||||
path: string;
|
content: Array<{ type: 'text'; text: string }>;
|
||||||
noCache?: boolean;
|
structuredContent: Record<string, unknown>;
|
||||||
}): Promise<ToolResponse> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const { spec, metadata, dereferenced, cached } = await parseSpec(path, { noCache });
|
const { spec, metadata, dereferenced } = await parseSpec(path);
|
||||||
|
|
||||||
let text = formatMetadata(metadata);
|
let text = formatMetadata(metadata);
|
||||||
if (!dereferenced) {
|
if (!dereferenced) {
|
||||||
text += '\n\n**Warning**: Spec has broken $refs. Parsed without dereferencing.';
|
text += '\n\n**Warning**: Spec has broken $refs. Parsed without dereferencing.';
|
||||||
}
|
}
|
||||||
if (cached) {
|
|
||||||
text += '\n\n*Served from cache*';
|
|
||||||
}
|
|
||||||
|
|
||||||
return successResponse(text, { metadata, dereferenced, cached, spec });
|
return {
|
||||||
|
content: [{ type: 'text', text }],
|
||||||
|
structuredContent: {
|
||||||
|
success: true,
|
||||||
|
metadata,
|
||||||
|
dereferenced,
|
||||||
|
spec,
|
||||||
|
},
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return errorResponse((err as Error).message, 'parsing spec');
|
const error = err as Error;
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Error parsing spec: ${error.message}` }],
|
||||||
|
structuredContent: {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { parseSpec } from '../lib/parser.js';
|
import { parseSpec } from '../lib/parser.js';
|
||||||
import { formatEndpoints } from '../utils/format.js';
|
import { formatEndpoints } from '../utils/format.js';
|
||||||
import { HTTP_METHODS } from '../lib/types.js';
|
|
||||||
import { successResponse, errorResponse } from '../lib/tool-response.js';
|
|
||||||
import { createSafeRegex, validateRegexPattern } from '../lib/input-validation.js';
|
|
||||||
import type { ToolResponse } from '../lib/tool-response.js';
|
|
||||||
import type { EndpointInfo, EndpointFilter, ParameterInfo, ResponseInfo } from '../lib/types.js';
|
import type { EndpointInfo, EndpointFilter, ParameterInfo, ResponseInfo } from '../lib/types.js';
|
||||||
import type { OpenAPIV3 } from 'openapi-types';
|
import type { OpenAPIV3 } from 'openapi-types';
|
||||||
|
|
||||||
|
|
@ -18,30 +14,22 @@ export const queryToolSchema = {
|
||||||
pathPattern: z.string().optional().describe('Regex pattern to match path'),
|
pathPattern: z.string().optional().describe('Regex pattern to match path'),
|
||||||
tag: z.string().optional().describe('Filter by tag name'),
|
tag: z.string().optional().describe('Filter by tag name'),
|
||||||
operationId: z.string().optional().describe('Filter by exact operation ID'),
|
operationId: z.string().optional().describe('Filter by exact operation ID'),
|
||||||
noCache: z.boolean().optional().describe('Bypass cache and parse fresh'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'] as const;
|
||||||
|
|
||||||
export async function queryToolHandler(args: {
|
export async function queryToolHandler(args: {
|
||||||
path: string;
|
path: string;
|
||||||
method?: string;
|
method?: string;
|
||||||
pathPattern?: string;
|
pathPattern?: string;
|
||||||
tag?: string;
|
tag?: string;
|
||||||
operationId?: string;
|
operationId?: string;
|
||||||
noCache?: boolean;
|
}): Promise<{
|
||||||
}): Promise<ToolResponse> {
|
content: Array<{ type: 'text'; text: string }>;
|
||||||
|
structuredContent: Record<string, unknown>;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
// Validate regex pattern before use (ReDoS protection)
|
const { spec } = await parseSpec(args.path);
|
||||||
if (args.pathPattern) {
|
|
||||||
const regexValidation = validateRegexPattern(args.pathPattern);
|
|
||||||
if (!regexValidation.valid) {
|
|
||||||
return errorResponse(
|
|
||||||
regexValidation.error ?? 'Invalid path pattern',
|
|
||||||
'validating path pattern'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { spec } = await parseSpec(args.path, { noCache: args.noCache });
|
|
||||||
|
|
||||||
const filter: EndpointFilter = {
|
const filter: EndpointFilter = {
|
||||||
method: args.method?.toLowerCase(),
|
method: args.method?.toLowerCase(),
|
||||||
|
|
@ -53,9 +41,23 @@ export async function queryToolHandler(args: {
|
||||||
const endpoints = extractEndpoints(spec, filter);
|
const endpoints = extractEndpoints(spec, filter);
|
||||||
const text = formatEndpoints(endpoints);
|
const text = formatEndpoints(endpoints);
|
||||||
|
|
||||||
return successResponse(text, { count: endpoints.length, endpoints });
|
return {
|
||||||
|
content: [{ type: 'text', text }],
|
||||||
|
structuredContent: {
|
||||||
|
success: true,
|
||||||
|
count: endpoints.length,
|
||||||
|
endpoints,
|
||||||
|
},
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return errorResponse((err as Error).message, 'querying endpoints');
|
const error = err as Error;
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Error querying endpoints: ${error.message}` }],
|
||||||
|
structuredContent: {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,9 +76,12 @@ function extractEndpoints(spec: object, filter: EndpointFilter): EndpointInfo[]
|
||||||
if (filter.method && method !== filter.method) continue;
|
if (filter.method && method !== filter.method) continue;
|
||||||
|
|
||||||
if (filter.pathPattern) {
|
if (filter.pathPattern) {
|
||||||
// Use safe regex creation (already validated in handler)
|
try {
|
||||||
const regex = createSafeRegex(filter.pathPattern);
|
const regex = new RegExp(filter.pathPattern);
|
||||||
if (regex && !regex.test(pathName)) continue;
|
if (!regex.test(pathName)) continue;
|
||||||
|
} catch {
|
||||||
|
// Invalid regex, skip filter
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.tag && (!operation.tags || !operation.tags.includes(filter.tag))) continue;
|
if (filter.tag && (!operation.tags || !operation.tags.includes(filter.tag))) continue;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { parseSpec } from '../lib/parser.js';
|
import { parseSpec } from '../lib/parser.js';
|
||||||
import { formatSchema } from '../utils/format.js';
|
import { formatSchema } from '../utils/format.js';
|
||||||
import { findSchema, getSchemaNames } from '../lib/schema-utils.js';
|
|
||||||
import { successResponse, errorResponse } from '../lib/tool-response.js';
|
|
||||||
import type { ToolResponse } from '../lib/tool-response.js';
|
|
||||||
import type { SchemaInfo } from '../lib/types.js';
|
import type { SchemaInfo } from '../lib/types.js';
|
||||||
|
|
||||||
export const schemaToolName = 'get-schema';
|
export const schemaToolName = 'get-schema';
|
||||||
|
|
@ -13,21 +10,22 @@ export const schemaToolDescription = 'Get details of a component schema from an
|
||||||
export const schemaToolSchema = {
|
export const schemaToolSchema = {
|
||||||
path: z.string().describe('Path to the OpenAPI/Swagger spec file'),
|
path: z.string().describe('Path to the OpenAPI/Swagger spec file'),
|
||||||
schemaName: z.string().describe('Name of the schema in components/schemas (or definitions for Swagger 2.0)'),
|
schemaName: z.string().describe('Name of the schema in components/schemas (or definitions for Swagger 2.0)'),
|
||||||
noCache: z.boolean().optional().describe('Bypass cache and parse fresh'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function schemaToolHandler({ path, schemaName, noCache }: {
|
export async function schemaToolHandler({ path, schemaName }: {
|
||||||
path: string;
|
path: string;
|
||||||
schemaName: string;
|
schemaName: string;
|
||||||
noCache?: boolean;
|
}): Promise<{
|
||||||
}): Promise<ToolResponse> {
|
content: Array<{ type: 'text'; text: string }>;
|
||||||
|
structuredContent: Record<string, unknown>;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
const { spec } = await parseSpec(path, { noCache });
|
const { spec } = await parseSpec(path);
|
||||||
|
|
||||||
const schema = findSchema(spec, schemaName);
|
const schema = findSchema(spec, schemaName);
|
||||||
|
|
||||||
if (!schema) {
|
if (!schema) {
|
||||||
const available = getSchemaNames(spec);
|
const available = getAvailableSchemas(spec);
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text', text: `Schema '${schemaName}' not found. Available schemas: ${available.join(', ')}` }],
|
content: [{ type: 'text', text: `Schema '${schemaName}' not found. Available schemas: ${available.join(', ')}` }],
|
||||||
structuredContent: {
|
structuredContent: {
|
||||||
|
|
@ -49,8 +47,53 @@ export async function schemaToolHandler({ path, schemaName, noCache }: {
|
||||||
|
|
||||||
const text = formatSchema(schemaInfo);
|
const text = formatSchema(schemaInfo);
|
||||||
|
|
||||||
return successResponse(text, { schema: schemaInfo });
|
return {
|
||||||
|
content: [{ type: 'text', text }],
|
||||||
|
structuredContent: {
|
||||||
|
success: true,
|
||||||
|
schema: schemaInfo,
|
||||||
|
},
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return errorResponse((err as Error).message, 'getting schema');
|
const error = err as Error;
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Error getting schema: ${error.message}` }],
|
||||||
|
structuredContent: {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findSchema(spec: object, schemaName: string): object | null {
|
||||||
|
// OpenAPI 3.x: components.schemas
|
||||||
|
const spec3 = spec as { components?: { schemas?: Record<string, object> } };
|
||||||
|
if (spec3.components?.schemas?.[schemaName]) {
|
||||||
|
return spec3.components.schemas[schemaName];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swagger 2.0: definitions
|
||||||
|
const spec2 = spec as { definitions?: Record<string, object> };
|
||||||
|
if (spec2.definitions?.[schemaName]) {
|
||||||
|
return spec2.definitions[schemaName];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvailableSchemas(spec: object): string[] {
|
||||||
|
// OpenAPI 3.x
|
||||||
|
const spec3 = spec as { components?: { schemas?: Record<string, object> } };
|
||||||
|
if (spec3.components?.schemas) {
|
||||||
|
return Object.keys(spec3.components.schemas);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swagger 2.0
|
||||||
|
const spec2 = spec as { definitions?: Record<string, object> };
|
||||||
|
if (spec2.definitions) {
|
||||||
|
return Object.keys(spec2.definitions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { validateWithWarnings } from '../lib/validator.js';
|
import { validateWithWarnings } from '../lib/validator.js';
|
||||||
import { formatValidation } from '../utils/format.js';
|
import { formatValidation } from '../utils/format.js';
|
||||||
import { successResponse, errorResponse } from '../lib/tool-response.js';
|
|
||||||
import type { ToolResponse } from '../lib/tool-response.js';
|
|
||||||
|
|
||||||
export const validateToolName = 'validate-spec';
|
export const validateToolName = 'validate-spec';
|
||||||
|
|
||||||
|
|
@ -12,12 +10,29 @@ export const validateToolSchema = {
|
||||||
path: z.string().describe('Path to the OpenAPI/Swagger spec file (YAML or JSON)'),
|
path: z.string().describe('Path to the OpenAPI/Swagger spec file (YAML or JSON)'),
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function validateToolHandler({ path }: { path: string }): Promise<ToolResponse> {
|
export async function validateToolHandler({ path }: { path: string }): Promise<{
|
||||||
|
content: Array<{ type: 'text'; text: string }>;
|
||||||
|
structuredContent: Record<string, unknown>;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
const result = await validateWithWarnings(path);
|
const result = await validateWithWarnings(path);
|
||||||
const text = formatValidation(result);
|
const text = formatValidation(result);
|
||||||
return successResponse(text, { ...result });
|
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text }],
|
||||||
|
structuredContent: {
|
||||||
|
success: true,
|
||||||
|
...result,
|
||||||
|
},
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return errorResponse((err as Error).message, 'validating spec');
|
const error = err as Error;
|
||||||
|
return {
|
||||||
|
content: [{ type: 'text', text: `Error validating spec: ${error.message}` }],
|
||||||
|
structuredContent: {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,6 @@ export function formatMetadata(metadata: ParsedSpec): string {
|
||||||
lines.push(`- Paths: ${metadata.pathCount}`);
|
lines.push(`- Paths: ${metadata.pathCount}`);
|
||||||
lines.push(`- Operations: ${metadata.operationCount}`);
|
lines.push(`- Operations: ${metadata.operationCount}`);
|
||||||
lines.push(`- Schemas: ${metadata.schemaCount}`);
|
lines.push(`- Schemas: ${metadata.schemaCount}`);
|
||||||
if (metadata.webhookCount !== undefined && metadata.webhookCount > 0) {
|
|
||||||
lines.push(`- Webhooks: ${metadata.webhookCount}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadata.servers.length > 0) {
|
if (metadata.servers.length > 0) {
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
|
||||||
36
test/fixtures/enum-test.yaml
vendored
36
test/fixtures/enum-test.yaml
vendored
|
|
@ -1,36 +0,0 @@
|
||||||
openapi: 3.0.3
|
|
||||||
info:
|
|
||||||
title: Enum Test API
|
|
||||||
version: 1.0.0
|
|
||||||
paths: {}
|
|
||||||
components:
|
|
||||||
schemas:
|
|
||||||
Status:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- pending
|
|
||||||
- active
|
|
||||||
- completed
|
|
||||||
- cancelled
|
|
||||||
description: Task status
|
|
||||||
Priority:
|
|
||||||
type: string
|
|
||||||
enum:
|
|
||||||
- low
|
|
||||||
- normal
|
|
||||||
- high
|
|
||||||
- urgent
|
|
||||||
Task:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- title
|
|
||||||
- status
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
type: integer
|
|
||||||
title:
|
|
||||||
type: string
|
|
||||||
status:
|
|
||||||
$ref: '#/components/schemas/Status'
|
|
||||||
priority:
|
|
||||||
$ref: '#/components/schemas/Priority'
|
|
||||||
181
test/fixtures/openapi-31.yaml
vendored
181
test/fixtures/openapi-31.yaml
vendored
|
|
@ -1,181 +0,0 @@
|
||||||
openapi: 3.1.0
|
|
||||||
info:
|
|
||||||
title: OpenAPI 3.1 Test Spec
|
|
||||||
version: 1.0.0
|
|
||||||
description: Test fixture for OpenAPI 3.1 specific features
|
|
||||||
|
|
||||||
servers:
|
|
||||||
- url: https://api.example.com/v1
|
|
||||||
|
|
||||||
paths:
|
|
||||||
/users:
|
|
||||||
get:
|
|
||||||
operationId: listUsers
|
|
||||||
summary: List all users
|
|
||||||
tags:
|
|
||||||
- Users
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Successful response
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: '#/components/schemas/User'
|
|
||||||
post:
|
|
||||||
operationId: createUser
|
|
||||||
summary: Create a user
|
|
||||||
tags:
|
|
||||||
- Users
|
|
||||||
requestBody:
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/CreateUserRequest'
|
|
||||||
responses:
|
|
||||||
'201':
|
|
||||||
description: User created
|
|
||||||
|
|
||||||
/users/{id}:
|
|
||||||
get:
|
|
||||||
operationId: getUser
|
|
||||||
summary: Get a user by ID
|
|
||||||
tags:
|
|
||||||
- Users
|
|
||||||
parameters:
|
|
||||||
- name: id
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Successful response
|
|
||||||
|
|
||||||
webhooks:
|
|
||||||
userCreated:
|
|
||||||
post:
|
|
||||||
operationId: userCreatedWebhook
|
|
||||||
summary: User created event
|
|
||||||
requestBody:
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/UserEvent'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Webhook acknowledged
|
|
||||||
|
|
||||||
userDeleted:
|
|
||||||
post:
|
|
||||||
operationId: userDeletedWebhook
|
|
||||||
summary: User deleted event
|
|
||||||
requestBody:
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/UserEvent'
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Webhook acknowledged
|
|
||||||
|
|
||||||
components:
|
|
||||||
schemas:
|
|
||||||
# Test: type as array (OpenAPI 3.1)
|
|
||||||
NullableString:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
description: A string that can be null (3.1 style)
|
|
||||||
|
|
||||||
# Test: multiple types in array
|
|
||||||
StringOrNumber:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- number
|
|
||||||
description: Can be either string or number
|
|
||||||
|
|
||||||
# Test: const keyword (OpenAPI 3.1)
|
|
||||||
StatusActive:
|
|
||||||
const: active
|
|
||||||
description: Always the literal value 'active'
|
|
||||||
|
|
||||||
StatusCode:
|
|
||||||
const: 200
|
|
||||||
description: Always the number 200
|
|
||||||
|
|
||||||
# Test: nullable with complex type
|
|
||||||
NullableInteger:
|
|
||||||
type:
|
|
||||||
- integer
|
|
||||||
- 'null'
|
|
||||||
|
|
||||||
# Test: object with nullable properties (3.1 style)
|
|
||||||
User:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- id
|
|
||||||
- email
|
|
||||||
- status
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
type: string
|
|
||||||
description: User ID
|
|
||||||
email:
|
|
||||||
type: string
|
|
||||||
format: email
|
|
||||||
name:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
description: Optional name (can be null)
|
|
||||||
age:
|
|
||||||
type:
|
|
||||||
- integer
|
|
||||||
- 'null'
|
|
||||||
description: Optional age
|
|
||||||
status:
|
|
||||||
const: active
|
|
||||||
metadata:
|
|
||||||
type:
|
|
||||||
- object
|
|
||||||
- 'null'
|
|
||||||
description: Optional metadata
|
|
||||||
|
|
||||||
# Test: object with OpenAPI 3.0 style nullable (for backward compat)
|
|
||||||
LegacyUser:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
id:
|
|
||||||
type: string
|
|
||||||
nickname:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
description: Nickname (nullable 3.0 style)
|
|
||||||
|
|
||||||
CreateUserRequest:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- email
|
|
||||||
properties:
|
|
||||||
email:
|
|
||||||
type: string
|
|
||||||
name:
|
|
||||||
type:
|
|
||||||
- string
|
|
||||||
- 'null'
|
|
||||||
|
|
||||||
UserEvent:
|
|
||||||
type: object
|
|
||||||
required:
|
|
||||||
- eventType
|
|
||||||
- userId
|
|
||||||
properties:
|
|
||||||
eventType:
|
|
||||||
type: string
|
|
||||||
userId:
|
|
||||||
type: string
|
|
||||||
timestamp:
|
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
Loading…
Add table
Reference in a new issue