Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/compile-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/compiler.ts
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -672,7 +672,8 @@ function getSecuritySchemes(doc: Record<string, unknown>): Record<string, Securi
continue;
}
out[name] = {
name,
key: name,
name: scheme.type === "apiKey" && typeof scheme.name === "string" ? scheme.name : name,
Comment thread
kriptoburak marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
type: String(scheme.type),
in: isValidIn(scheme.in) ? scheme.in : undefined,
scheme: typeof scheme.scheme === "string" ? scheme.scheme : undefined,
Expand Down
3 changes: 2 additions & 1 deletion src/http.ts
Comment thread
kriptoburak marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,8 @@ async function applyAuth(headers: Record<string, string>, 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`];
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
})
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface CompileOptions {
}

export interface SecurityScheme {
key?: string;
name: string;
type: string;
in?: "query" | "header" | "cookie";
Expand Down
36 changes: 36 additions & 0 deletions test/compiler.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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);
Comment thread
kriptoburak marked this conversation as resolved.
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"]]
]);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

const inputSchema = operation.inputSchema as Record<string, unknown>;
const query = ((inputSchema.properties as Record<string, unknown>).query ?? {}) as Record<string, unknown>;
const queryProperties = (query.properties ?? {}) as Record<string, unknown>;
const queryType = queryProperties.queryType as Record<string, unknown>;
const limit = queryProperties.limit as Record<string, unknown>;
assert.deepEqual(queryType.enum, ["Latest", "Top"]);
assert.equal(limit.maximum, 200);

const outputSchema = operation.outputSchema as Record<string, unknown>;
const outputProperties = (outputSchema.properties ?? {}) as Record<string, unknown>;
const tweets = outputProperties.tweets as Record<string, unknown>;
assert.equal(tweets.type, "array");
});
136 changes: 136 additions & 0 deletions test/fixtures/xquik-openapi.yaml
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions test/http.test.ts
Comment thread
kriptoburak marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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<void>((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(
{
Expand Down