diff --git a/src/compile-cache.ts b/src/compile-cache.ts index 7ddbdd5..9458801 100644 --- a/src/compile-cache.ts +++ b/src/compile-cache.ts @@ -5,7 +5,7 @@ import { compileOperations } from "./compiler.js"; import { loadOpenApiDocument } from "./openapi.js"; import type { CompileOptions, OperationModel } from "./types.js"; -const CACHE_FORMAT_VERSION = 2; +const CACHE_FORMAT_VERSION = 3; interface CacheEntry { hash: string; diff --git a/src/compiler.ts b/src/compiler.ts index 78910a7..5b79648 100644 --- a/src/compiler.ts +++ b/src/compiler.ts @@ -672,7 +672,8 @@ function getSecuritySchemes(doc: Record): Record, url: URL, cookieParams const selected = authOptions[0]; const envPrefix = resolveEnvPrefix(tags, authScopes); for (const scheme of selected.schemes) { - const keyByName = process.env[`${envPrefix}${scheme.name.toUpperCase()}_TOKEN`]; + const credentialName = scheme.key ?? scheme.name; + const keyByName = process.env[`${envPrefix}${credentialName.toUpperCase()}_TOKEN`]; if (scheme.type === "apiKey") { const token = keyByName ?? process.env[`${envPrefix}API_KEY`]; diff --git a/src/index.ts b/src/index.ts index f94876a..232d16c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -201,7 +201,7 @@ function operationToEndpoint(op: OperationModel): NormalizedEndpoint { ? op.authOptions.map((req) => { const entry: SecurityRequirement = {}; for (const scheme of req.schemes) { - entry[scheme.name] = []; + entry[scheme.key ?? scheme.name] = []; } return entry; }) diff --git a/src/types.ts b/src/types.ts index e55ca1a..3ea586f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,6 +15,7 @@ export interface CompileOptions { } export interface SecurityScheme { + key?: string; name: string; type: string; in?: "query" | "header" | "cookie"; diff --git a/test/compiler.test.ts b/test/compiler.test.ts index e54bbba..c04d4eb 100644 --- a/test/compiler.test.ts +++ b/test/compiler.test.ts @@ -1,6 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { compileOperations } from "../src/compiler.js"; +import { loadOpenApiDocument } from "../src/openapi.js"; test("compileOperations normalizes nullable and combiners", () => { const doc = { @@ -136,3 +137,38 @@ test("compileOperations supports tool name templates with collision handling", ( const names = [...ops.keys()].sort(); assert.deepEqual(names, ["users_get", "users_get_2"]); }); + +test("compileOperations handles Xquik OpenAPI 3.1 auth and schemas", async () => { + const doc = await loadOpenApiDocument("test/fixtures/xquik-openapi.yaml"); + const operations = compileOperations(doc); + const operation = operations.get("searchTweets"); + assert.ok(operation); + + assert.equal(operation.method, "GET"); + assert.equal(operation.pathTemplate, "/api/v1/x/tweets/search"); + assert.equal(operation.servers[0], "https://xquik.com"); + assert.deepEqual(operation.tags, ["Tweets"]); + assert.equal(operation.responseContentType, "application/json"); + assert.equal(operation.annotations?.readOnlyHint, true); + + const authSchemes = operation.authOptions.map((option) => + option.schemes.map((scheme) => [scheme.key, scheme.name, scheme.type, scheme.in, scheme.scheme]) + ); + assert.deepEqual(authSchemes, [ + [["apiKey", "x-api-key", "apiKey", "header", undefined]], + [["oauthBearer", "oauthBearer", "http", undefined, "bearer"]] + ]); + + const inputSchema = operation.inputSchema as Record; + const query = ((inputSchema.properties as Record).query ?? {}) as Record; + const queryProperties = (query.properties ?? {}) as Record; + const queryType = queryProperties.queryType as Record; + const limit = queryProperties.limit as Record; + assert.deepEqual(queryType.enum, ["Latest", "Top"]); + assert.equal(limit.maximum, 200); + + const outputSchema = operation.outputSchema as Record; + const outputProperties = (outputSchema.properties ?? {}) as Record; + const tweets = outputProperties.tweets as Record; + assert.equal(tweets.type, "array"); +}); diff --git a/test/fixtures/xquik-openapi.yaml b/test/fixtures/xquik-openapi.yaml new file mode 100644 index 0000000..dfea7f9 --- /dev/null +++ b/test/fixtures/xquik-openapi.yaml @@ -0,0 +1,136 @@ +openapi: 3.1.0 +info: + title: Xquik API + version: "1.0" + description: >- + Xquik is an independent third-party service. Not affiliated with X Corp. + "Twitter" and "X" are trademarks of X Corp. +externalDocs: + url: https://docs.xquik.com/api-reference/x/search-tweets +servers: + - url: https://xquik.com +security: + - apiKey: [] + - oauthBearer: [] +paths: + /api/v1/x/tweets/search: + get: + operationId: searchTweets + summary: Search tweets by query, Tweet ID, X status URL, or account date window + tags: + - Tweets + security: + - apiKey: [] + - oauthBearer: [] + - {} + parameters: + - name: q + in: query + required: true + description: Search query, post ID, or X status URL. + schema: + type: string + - name: queryType + in: query + required: false + description: Sort order. + schema: + type: string + enum: + - Latest + - Top + default: Latest + - name: cursor + in: query + required: false + description: Cursor from the previous response. + schema: + type: string + - name: limit + in: query + required: false + description: Maximum posts to return. + schema: + type: integer + default: 20 + maximum: 200 + responses: + "200": + description: Search results. + content: + application/json: + schema: + $ref: "#/components/schemas/PaginatedTweets" + "400": + description: Invalid search request. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" +components: + securitySchemes: + apiKey: + type: apiKey + in: header + name: x-api-key + oauthBearer: + type: http + scheme: bearer + schemas: + PaginatedTweets: + type: object + required: + - tweets + - has_next_page + properties: + tweets: + type: array + items: + $ref: "#/components/schemas/Tweet" + has_next_page: + type: boolean + next_cursor: + type: + - string + - "null" + Tweet: + type: object + required: + - id + - text + - author + properties: + id: + type: string + text: + type: string + createdAt: + type: string + format: date-time + likeCount: + type: integer + retweetCount: + type: integer + author: + $ref: "#/components/schemas/TweetAuthor" + TweetAuthor: + type: object + required: + - id + - username + properties: + id: + type: string + username: + type: string + name: + type: string + verified: + type: boolean + Error: + type: object + required: + - error + properties: + error: + type: string diff --git a/test/http.test.ts b/test/http.test.ts index 2442d86..0e470d8 100644 --- a/test/http.test.ts +++ b/test/http.test.ts @@ -234,6 +234,48 @@ test("executeOperation supports apiKey auth in header, query, and cookie", async }); }); +test("executeOperation separates apiKey identity from its wire name", async () => { + await withEnv( + { + MCP_OPENAPI_APIKEY_TOKEN: "component-token", + MCP_OPENAPI_API_KEY: undefined + }, + async () => { + let wireHeader = ""; + let componentHeader = ""; + const server = createServer((req, res) => { + wireHeader = String(req.headers["x-api-key"] ?? ""); + componentHeader = String(req.headers.apikey ?? ""); + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ ok: true })); + }); + + await new Promise((resolve) => server.listen(0, resolve)); + const address = server.address(); + assert.ok(address && typeof address === "object"); + const baseUrl = `http://127.0.0.1:${address.port}`; + + const result = await executeOperation( + createOperation(baseUrl, { + authOptions: [ + { + schemes: [{ key: "apiKey", name: "x-api-key", type: "apiKey", in: "header" }] + } + ] + }), + { query: {} }, + { timeoutMs: 5000, retries: 0, retryDelayMs: 5 } + ); + + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + + assert.equal(result.status, 200); + assert.equal(wireHeader, "component-token"); + assert.equal(componentHeader, ""); + } + ); +}); + test("executeOperation supports bearer and basic auth", async () => { await withEnv( {