anyapi-mcp-server 1.5.1 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -193
- package/build/api-client.js +92 -53
- package/build/api-index.js +41 -0
- package/build/config.js +55 -1
- package/build/error-context.js +179 -0
- package/build/index.js +197 -21
- package/build/oauth.js +340 -0
- package/build/response-parser.js +47 -2
- package/package.json +1 -1
package/build/config.js
CHANGED
|
@@ -62,7 +62,17 @@ Required:
|
|
|
62
62
|
Optional:
|
|
63
63
|
--header HTTP header as "Key: Value" (repeatable)
|
|
64
64
|
Supports \${ENV_VAR} interpolation in values
|
|
65
|
-
--log Path to request/response log file (NDJSON format)
|
|
65
|
+
--log Path to request/response log file (NDJSON format)
|
|
66
|
+
|
|
67
|
+
OAuth 2.0 (all optional, but client-id/client-secret/token-url are required together):
|
|
68
|
+
--oauth-client-id OAuth client ID
|
|
69
|
+
--oauth-client-secret OAuth client secret
|
|
70
|
+
--oauth-token-url OAuth token endpoint URL
|
|
71
|
+
--oauth-auth-url OAuth authorization endpoint URL (authorization_code flow)
|
|
72
|
+
--oauth-scopes Comma-separated scopes (e.g. "read,write")
|
|
73
|
+
--oauth-flow "authorization_code" (default) or "client_credentials"
|
|
74
|
+
--oauth-param Extra auth URL param as "key=value" (repeatable)
|
|
75
|
+
All OAuth values support \${ENV_VAR} interpolation`;
|
|
66
76
|
export async function loadConfig() {
|
|
67
77
|
const name = getArg("--name");
|
|
68
78
|
const specUrls = getAllArgs("--spec");
|
|
@@ -84,11 +94,55 @@ export async function loadConfig() {
|
|
|
84
94
|
headers[key] = interpolateEnv(value);
|
|
85
95
|
}
|
|
86
96
|
const logPath = getArg("--log");
|
|
97
|
+
// --- OAuth CLI flags ---
|
|
98
|
+
const oauthClientId = getArg("--oauth-client-id");
|
|
99
|
+
const oauthClientSecret = getArg("--oauth-client-secret");
|
|
100
|
+
const oauthTokenUrl = getArg("--oauth-token-url");
|
|
101
|
+
const oauthAuthUrl = getArg("--oauth-auth-url");
|
|
102
|
+
const oauthScopes = getArg("--oauth-scopes");
|
|
103
|
+
const oauthFlow = getArg("--oauth-flow");
|
|
104
|
+
const oauthParams = getAllArgs("--oauth-param");
|
|
105
|
+
const hasAnyOAuth = oauthClientId || oauthClientSecret || oauthTokenUrl;
|
|
106
|
+
if (hasAnyOAuth && !(oauthClientId && oauthClientSecret && oauthTokenUrl)) {
|
|
107
|
+
console.error("ERROR: --oauth-client-id, --oauth-client-secret, and --oauth-token-url must all be provided together.");
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
let oauth;
|
|
111
|
+
if (oauthClientId && oauthClientSecret && oauthTokenUrl) {
|
|
112
|
+
const extraParams = {};
|
|
113
|
+
for (const raw of oauthParams) {
|
|
114
|
+
const eqIdx = raw.indexOf("=");
|
|
115
|
+
if (eqIdx === -1) {
|
|
116
|
+
console.error(`ERROR: Invalid --oauth-param format "${raw}". Expected "key=value"`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
extraParams[raw.slice(0, eqIdx)] = interpolateEnv(raw.slice(eqIdx + 1));
|
|
120
|
+
}
|
|
121
|
+
const flow = (oauthFlow ? interpolateEnv(oauthFlow) : "authorization_code");
|
|
122
|
+
if (flow !== "authorization_code" && flow !== "client_credentials") {
|
|
123
|
+
console.error(`ERROR: Invalid --oauth-flow "${flow}". Must be "authorization_code" or "client_credentials".`);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
oauth = {
|
|
127
|
+
clientId: interpolateEnv(oauthClientId),
|
|
128
|
+
clientSecret: interpolateEnv(oauthClientSecret),
|
|
129
|
+
tokenUrl: interpolateEnv(oauthTokenUrl),
|
|
130
|
+
authUrl: oauthAuthUrl ? interpolateEnv(oauthAuthUrl) : undefined,
|
|
131
|
+
scopes: oauthScopes
|
|
132
|
+
? interpolateEnv(oauthScopes)
|
|
133
|
+
.split(/[,\s]+/)
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
: [],
|
|
136
|
+
flow,
|
|
137
|
+
extraParams,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
87
140
|
return {
|
|
88
141
|
name,
|
|
89
142
|
specs,
|
|
90
143
|
baseUrl: interpolateEnv(baseUrl).replace(/\/+$/, ""),
|
|
91
144
|
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
|
92
145
|
logPath: logPath ? resolve(logPath) : undefined,
|
|
146
|
+
oauth,
|
|
93
147
|
};
|
|
94
148
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rich API error carrying status, raw body, and response headers
|
|
3
|
+
* for structured error reporting.
|
|
4
|
+
*/
|
|
5
|
+
export class ApiError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
statusText;
|
|
8
|
+
bodyText;
|
|
9
|
+
responseHeaders;
|
|
10
|
+
constructor(message, status, statusText, bodyText, responseHeaders = {}) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.statusText = statusText;
|
|
14
|
+
this.bodyText = bodyText;
|
|
15
|
+
this.responseHeaders = responseHeaders;
|
|
16
|
+
this.name = "ApiError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Extract a human-readable error message from common API error response formats:
|
|
21
|
+
* - RFC 7807 Problem Details: { type, title, status, detail }
|
|
22
|
+
* - Stripe/generic: { error: { message, code } }
|
|
23
|
+
* - Simple: { error: "message" } or { message: "..." }
|
|
24
|
+
* - GraphQL-style: { errors: [{ message }] }
|
|
25
|
+
* - SOAP/Apigee: { fault: { faultstring } }
|
|
26
|
+
*/
|
|
27
|
+
export function extractErrorMessage(bodyText) {
|
|
28
|
+
if (!bodyText.trim())
|
|
29
|
+
return undefined;
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = JSON.parse(bodyText);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
38
|
+
return undefined;
|
|
39
|
+
const obj = parsed;
|
|
40
|
+
// RFC 7807 Problem Details
|
|
41
|
+
if (typeof obj.detail === "string") {
|
|
42
|
+
const title = typeof obj.title === "string" ? obj.title : undefined;
|
|
43
|
+
return title ? `${title}: ${obj.detail}` : obj.detail;
|
|
44
|
+
}
|
|
45
|
+
// { error: { message, code? } }
|
|
46
|
+
if (obj.error && typeof obj.error === "object") {
|
|
47
|
+
const errObj = obj.error;
|
|
48
|
+
if (typeof errObj.message === "string") {
|
|
49
|
+
const code = typeof errObj.code === "string" ? ` (${errObj.code})` : "";
|
|
50
|
+
return `${errObj.message}${code}`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// { error: "message" }
|
|
54
|
+
if (typeof obj.error === "string") {
|
|
55
|
+
return obj.error;
|
|
56
|
+
}
|
|
57
|
+
// { message: "..." }
|
|
58
|
+
if (typeof obj.message === "string") {
|
|
59
|
+
return obj.message;
|
|
60
|
+
}
|
|
61
|
+
// { errors: [{ message }] }
|
|
62
|
+
if (Array.isArray(obj.errors)) {
|
|
63
|
+
const messages = obj.errors
|
|
64
|
+
.filter((e) => typeof e === "object" &&
|
|
65
|
+
e !== null &&
|
|
66
|
+
typeof e.message === "string")
|
|
67
|
+
.map((e) => e.message);
|
|
68
|
+
if (messages.length > 0)
|
|
69
|
+
return messages.join("; ");
|
|
70
|
+
}
|
|
71
|
+
// { fault: { faultstring } }
|
|
72
|
+
if (obj.fault && typeof obj.fault === "object") {
|
|
73
|
+
const fault = obj.fault;
|
|
74
|
+
if (typeof fault.faultstring === "string")
|
|
75
|
+
return fault.faultstring;
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
function getSuggestion(status, endpoint) {
|
|
80
|
+
switch (status) {
|
|
81
|
+
case 400: {
|
|
82
|
+
let msg = "Bad request. Check that all required parameters are provided and the request body matches the expected schema.";
|
|
83
|
+
if (endpoint) {
|
|
84
|
+
const required = endpoint.parameters.filter((p) => p.required);
|
|
85
|
+
if (required.length > 0) {
|
|
86
|
+
msg += ` Required parameters: ${required.map((p) => `${p.name} (${p.in})`).join(", ")}.`;
|
|
87
|
+
}
|
|
88
|
+
if (endpoint.hasRequestBody && endpoint.requestBodySchema) {
|
|
89
|
+
const requiredFields = Object.entries(endpoint.requestBodySchema.properties)
|
|
90
|
+
.filter(([, p]) => p.required)
|
|
91
|
+
.map(([name]) => name);
|
|
92
|
+
if (requiredFields.length > 0) {
|
|
93
|
+
msg += ` Required body fields: ${requiredFields.join(", ")}.`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return msg;
|
|
98
|
+
}
|
|
99
|
+
case 401:
|
|
100
|
+
return "Authentication required. Use the auth tool to authenticate via OAuth, or provide credentials via --header (e.g. --header \"Authorization: Bearer <token>\") or per-request headers parameter.";
|
|
101
|
+
case 403:
|
|
102
|
+
return "Forbidden. Your credentials don't have permission for this operation. Verify your API key or token has the required scopes.";
|
|
103
|
+
case 404: {
|
|
104
|
+
let msg = "Resource not found. Verify the path and parameter values are correct.";
|
|
105
|
+
if (endpoint) {
|
|
106
|
+
const pathParams = endpoint.parameters.filter((p) => p.in === "path");
|
|
107
|
+
if (pathParams.length > 0) {
|
|
108
|
+
msg += ` Check path parameters: ${pathParams.map((p) => p.name).join(", ")}.`;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
msg += " Use list_api to confirm available endpoints.";
|
|
112
|
+
return msg;
|
|
113
|
+
}
|
|
114
|
+
case 405:
|
|
115
|
+
return "Method not allowed for this endpoint. Use list_api to check which HTTP methods are supported.";
|
|
116
|
+
case 409:
|
|
117
|
+
return "Conflict. The resource may already exist or be in a state that prevents this operation.";
|
|
118
|
+
case 415:
|
|
119
|
+
return "Unsupported media type. The API may expect a different Content-Type header.";
|
|
120
|
+
case 422: {
|
|
121
|
+
let msg = "Validation failed. Check that request body fields match the expected types and formats.";
|
|
122
|
+
if (endpoint?.requestBodySchema) {
|
|
123
|
+
const props = Object.entries(endpoint.requestBodySchema.properties)
|
|
124
|
+
.map(([name, p]) => `${name}: ${p.type}${p.required ? " (required)" : ""}`)
|
|
125
|
+
.join(", ");
|
|
126
|
+
if (props)
|
|
127
|
+
msg += ` Expected fields: ${props}.`;
|
|
128
|
+
}
|
|
129
|
+
return msg;
|
|
130
|
+
}
|
|
131
|
+
case 429:
|
|
132
|
+
return "Rate limit exceeded (retries exhausted). Wait before trying again or reduce request frequency.";
|
|
133
|
+
default:
|
|
134
|
+
if (status >= 500) {
|
|
135
|
+
return "Server error. This is likely a temporary issue with the API. Try again later.";
|
|
136
|
+
}
|
|
137
|
+
return "Check the API documentation for this endpoint using explain_api.";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const MAX_RAW_BODY = 500;
|
|
141
|
+
/**
|
|
142
|
+
* Build a rich error response object from an API error or exhausted retry error.
|
|
143
|
+
*/
|
|
144
|
+
export function buildErrorContext(error, method, path, endpoint) {
|
|
145
|
+
const isApiError = error instanceof ApiError;
|
|
146
|
+
const status = isApiError ? error.status : error.status;
|
|
147
|
+
const statusText = isApiError ? error.statusText : "";
|
|
148
|
+
const bodyText = isApiError ? error.bodyText : "";
|
|
149
|
+
const extracted = bodyText ? extractErrorMessage(bodyText) : undefined;
|
|
150
|
+
const result = {
|
|
151
|
+
error: extracted ?? error.message,
|
|
152
|
+
status,
|
|
153
|
+
...(statusText ? { statusText } : {}),
|
|
154
|
+
endpoint: `${method} ${path}`,
|
|
155
|
+
suggestion: getSuggestion(status, endpoint),
|
|
156
|
+
};
|
|
157
|
+
// Include raw body snippet when structured extraction failed
|
|
158
|
+
if (!extracted && bodyText.length > 0) {
|
|
159
|
+
result.rawBody =
|
|
160
|
+
bodyText.length > MAX_RAW_BODY
|
|
161
|
+
? bodyText.slice(0, MAX_RAW_BODY) + "..."
|
|
162
|
+
: bodyText;
|
|
163
|
+
}
|
|
164
|
+
// Include spec info for validation-related errors
|
|
165
|
+
if ((status === 400 || status === 422) && endpoint) {
|
|
166
|
+
if (endpoint.parameters.length > 0) {
|
|
167
|
+
result.specParameters = endpoint.parameters.map((p) => ({
|
|
168
|
+
name: p.name,
|
|
169
|
+
in: p.in,
|
|
170
|
+
required: p.required,
|
|
171
|
+
...(p.description ? { description: p.description } : {}),
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
if (endpoint.hasRequestBody && endpoint.requestBodySchema) {
|
|
175
|
+
result.specRequestBody = endpoint.requestBodySchema;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return result;
|
|
179
|
+
}
|
package/build/index.js
CHANGED
|
@@ -8,10 +8,43 @@ import { callApi } from "./api-client.js";
|
|
|
8
8
|
import { initLogger } from "./logger.js";
|
|
9
9
|
import { generateSuggestions } from "./query-suggestions.js";
|
|
10
10
|
import { getOrBuildSchema, executeQuery, schemaToSDL, truncateIfArray, computeShapeHash, } from "./graphql-schema.js";
|
|
11
|
+
import { ApiError, buildErrorContext } from "./error-context.js";
|
|
12
|
+
import { RetryableError } from "./retry.js";
|
|
13
|
+
import { isNonJsonResult } from "./response-parser.js";
|
|
14
|
+
import { startAuth, exchangeCode, awaitCallback, storeTokens, getTokens, isTokenExpired, initTokenStorage, } from "./oauth.js";
|
|
11
15
|
const WRITE_METHODS = new Set(["POST", "PUT", "DELETE", "PATCH"]);
|
|
12
16
|
const config = await loadConfig();
|
|
13
17
|
initLogger(config.logPath ?? null);
|
|
14
18
|
const apiIndex = new ApiIndex(config.specs);
|
|
19
|
+
// --- OAuth: merge spec-derived security info and init token storage ---
|
|
20
|
+
if (config.oauth) {
|
|
21
|
+
const schemes = apiIndex.getOAuthSchemes();
|
|
22
|
+
if (schemes.length > 0) {
|
|
23
|
+
const scheme = schemes[0];
|
|
24
|
+
if (!config.oauth.authUrl && scheme.authorizationUrl) {
|
|
25
|
+
config.oauth.authUrl = scheme.authorizationUrl;
|
|
26
|
+
}
|
|
27
|
+
if (config.oauth.scopes.length === 0 && scheme.scopes.length > 0) {
|
|
28
|
+
config.oauth.scopes = scheme.scopes;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
initTokenStorage(config.name);
|
|
32
|
+
}
|
|
33
|
+
function formatToolError(error, method, path) {
|
|
34
|
+
if ((error instanceof ApiError || error instanceof RetryableError) && method && path) {
|
|
35
|
+
const endpoint = apiIndex.getEndpoint(method, path);
|
|
36
|
+
const context = buildErrorContext(error, method, path, endpoint);
|
|
37
|
+
return {
|
|
38
|
+
content: [{ type: "text", text: JSON.stringify(context, null, 2) }],
|
|
39
|
+
isError: true,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
|
|
45
|
+
isError: true,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
15
48
|
const server = new McpServer({
|
|
16
49
|
name: config.name,
|
|
17
50
|
version: "1.2.1",
|
|
@@ -128,12 +161,25 @@ server.tool("call_api", `Inspect a ${config.name} API endpoint. Makes a real req
|
|
|
128
161
|
"Overrides default --header values."),
|
|
129
162
|
}, async ({ method, path, params, body, headers }) => {
|
|
130
163
|
try {
|
|
131
|
-
const data = await callApi(config, method, path, params, body, headers, "populate");
|
|
164
|
+
const { data, responseHeaders: respHeaders } = await callApi(config, method, path, params, body, headers, "populate");
|
|
165
|
+
// Non-JSON response — skip GraphQL layer, return raw parsed data
|
|
166
|
+
if (isNonJsonResult(data)) {
|
|
167
|
+
return {
|
|
168
|
+
content: [
|
|
169
|
+
{ type: "text", text: JSON.stringify({
|
|
170
|
+
rawResponse: data,
|
|
171
|
+
responseHeaders: respHeaders,
|
|
172
|
+
hint: "This endpoint returned a non-JSON response. The raw parsed content is shown above. " +
|
|
173
|
+
"GraphQL schema inference is not available for non-JSON responses — use the data directly.",
|
|
174
|
+
}, null, 2) },
|
|
175
|
+
],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
132
178
|
const endpoint = apiIndex.getEndpoint(method, path);
|
|
133
179
|
const bodyHash = WRITE_METHODS.has(method) && body ? computeShapeHash(body) : undefined;
|
|
134
180
|
const { schema, shapeHash } = getOrBuildSchema(data, method, path, endpoint?.requestBodySchema, bodyHash);
|
|
135
181
|
const sdl = schemaToSDL(schema);
|
|
136
|
-
const result = { graphqlSchema: sdl, shapeHash };
|
|
182
|
+
const result = { graphqlSchema: sdl, shapeHash, responseHeaders: respHeaders };
|
|
137
183
|
if (bodyHash)
|
|
138
184
|
result.bodyHash = bodyHash;
|
|
139
185
|
if (endpoint && endpoint.parameters.length > 0) {
|
|
@@ -171,13 +217,7 @@ server.tool("call_api", `Inspect a ${config.name} API endpoint. Makes a real req
|
|
|
171
217
|
};
|
|
172
218
|
}
|
|
173
219
|
catch (error) {
|
|
174
|
-
|
|
175
|
-
return {
|
|
176
|
-
content: [
|
|
177
|
-
{ type: "text", text: JSON.stringify({ error: message }) },
|
|
178
|
-
],
|
|
179
|
-
isError: true,
|
|
180
|
-
};
|
|
220
|
+
return formatToolError(error, method, path);
|
|
181
221
|
}
|
|
182
222
|
});
|
|
183
223
|
// --- Tool 3: query_api ---
|
|
@@ -229,7 +269,20 @@ server.tool("query_api", `Fetch data from a ${config.name} API endpoint, returni
|
|
|
229
269
|
.describe("Client-side slice: items to skip in already-fetched response (default: 0). For API pagination, use params instead."),
|
|
230
270
|
}, async ({ method, path, params, body, query, headers, limit, offset }) => {
|
|
231
271
|
try {
|
|
232
|
-
const rawData = await callApi(config, method, path, params, body, headers, "consume");
|
|
272
|
+
const { data: rawData, responseHeaders: respHeaders } = await callApi(config, method, path, params, body, headers, "consume");
|
|
273
|
+
// Non-JSON response — skip GraphQL layer, return raw parsed data
|
|
274
|
+
if (isNonJsonResult(rawData)) {
|
|
275
|
+
return {
|
|
276
|
+
content: [
|
|
277
|
+
{ type: "text", text: JSON.stringify({
|
|
278
|
+
rawResponse: rawData,
|
|
279
|
+
responseHeaders: respHeaders,
|
|
280
|
+
hint: "This endpoint returned a non-JSON response. GraphQL querying is not available. " +
|
|
281
|
+
"The raw parsed content is shown above.",
|
|
282
|
+
}, null, 2) },
|
|
283
|
+
],
|
|
284
|
+
};
|
|
285
|
+
}
|
|
233
286
|
const endpoint = apiIndex.getEndpoint(method, path);
|
|
234
287
|
const bodyHash = WRITE_METHODS.has(method) && body ? computeShapeHash(body) : undefined;
|
|
235
288
|
const { schema, shapeHash } = getOrBuildSchema(rawData, method, path, endpoint?.requestBodySchema, bodyHash);
|
|
@@ -237,6 +290,7 @@ server.tool("query_api", `Fetch data from a ${config.name} API endpoint, returni
|
|
|
237
290
|
const queryResult = await executeQuery(schema, data, query);
|
|
238
291
|
if (typeof queryResult === "object" && queryResult !== null) {
|
|
239
292
|
queryResult._shapeHash = shapeHash;
|
|
293
|
+
queryResult._responseHeaders = respHeaders;
|
|
240
294
|
if (bodyHash)
|
|
241
295
|
queryResult._bodyHash = bodyHash;
|
|
242
296
|
if (truncated) {
|
|
@@ -255,13 +309,7 @@ server.tool("query_api", `Fetch data from a ${config.name} API endpoint, returni
|
|
|
255
309
|
};
|
|
256
310
|
}
|
|
257
311
|
catch (error) {
|
|
258
|
-
|
|
259
|
-
return {
|
|
260
|
-
content: [
|
|
261
|
-
{ type: "text", text: JSON.stringify({ error: message }) },
|
|
262
|
-
],
|
|
263
|
-
isError: true,
|
|
264
|
-
};
|
|
312
|
+
return formatToolError(error, method, path);
|
|
265
313
|
}
|
|
266
314
|
});
|
|
267
315
|
// --- Tool 4: explain_api ---
|
|
@@ -382,7 +430,17 @@ server.tool("batch_query", `Fetch data from multiple ${config.name} API endpoint
|
|
|
382
430
|
}, async ({ requests }) => {
|
|
383
431
|
try {
|
|
384
432
|
const settled = await Promise.allSettled(requests.map(async (req) => {
|
|
385
|
-
const rawData = await callApi(config, req.method, req.path, req.params, req.body, req.headers, "none");
|
|
433
|
+
const { data: rawData, responseHeaders: respHeaders } = await callApi(config, req.method, req.path, req.params, req.body, req.headers, "none");
|
|
434
|
+
// Non-JSON response — skip GraphQL layer
|
|
435
|
+
if (isNonJsonResult(rawData)) {
|
|
436
|
+
return {
|
|
437
|
+
method: req.method,
|
|
438
|
+
path: req.path,
|
|
439
|
+
data: rawData,
|
|
440
|
+
responseHeaders: respHeaders,
|
|
441
|
+
nonJson: true,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
386
444
|
const endpoint = apiIndex.getEndpoint(req.method, req.path);
|
|
387
445
|
const bodyHash = WRITE_METHODS.has(req.method) && req.body
|
|
388
446
|
? computeShapeHash(req.body)
|
|
@@ -393,6 +451,7 @@ server.tool("batch_query", `Fetch data from multiple ${config.name} API endpoint
|
|
|
393
451
|
method: req.method,
|
|
394
452
|
path: req.path,
|
|
395
453
|
data: queryResult,
|
|
454
|
+
responseHeaders: respHeaders,
|
|
396
455
|
shapeHash,
|
|
397
456
|
...(bodyHash ? { bodyHash } : {}),
|
|
398
457
|
};
|
|
@@ -401,9 +460,12 @@ server.tool("batch_query", `Fetch data from multiple ${config.name} API endpoint
|
|
|
401
460
|
if (outcome.status === "fulfilled") {
|
|
402
461
|
return outcome.value;
|
|
403
462
|
}
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
463
|
+
const reason = outcome.reason;
|
|
464
|
+
if (reason instanceof ApiError || reason instanceof RetryableError) {
|
|
465
|
+
const endpoint = apiIndex.getEndpoint(requests[i].method, requests[i].path);
|
|
466
|
+
return buildErrorContext(reason, requests[i].method, requests[i].path, endpoint);
|
|
467
|
+
}
|
|
468
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
407
469
|
return {
|
|
408
470
|
method: requests[i].method,
|
|
409
471
|
path: requests[i].path,
|
|
@@ -426,6 +488,120 @@ server.tool("batch_query", `Fetch data from multiple ${config.name} API endpoint
|
|
|
426
488
|
};
|
|
427
489
|
}
|
|
428
490
|
});
|
|
491
|
+
// --- Tool 6: auth (only when OAuth is configured) ---
|
|
492
|
+
if (config.oauth) {
|
|
493
|
+
server.tool("auth", `Manage OAuth 2.0 authentication for ${config.name}. ` +
|
|
494
|
+
"Use action 'start' to begin the OAuth flow (returns an authorization URL for " +
|
|
495
|
+
"authorization_code flow, or completes token exchange for client_credentials). " +
|
|
496
|
+
"Use action 'exchange' to complete the flow — the callback is captured automatically " +
|
|
497
|
+
"via a localhost server, or you can provide a 'code' manually. " +
|
|
498
|
+
"Use action 'status' to check the current token status.", {
|
|
499
|
+
action: z
|
|
500
|
+
.enum(["start", "exchange", "status"])
|
|
501
|
+
.describe("'start' begins auth flow, 'exchange' completes code exchange, 'status' shows token info"),
|
|
502
|
+
code: z
|
|
503
|
+
.string()
|
|
504
|
+
.optional()
|
|
505
|
+
.describe("Authorization code from the OAuth provider (optional for 'exchange' — " +
|
|
506
|
+
"if omitted, waits for the localhost callback automatically)"),
|
|
507
|
+
}, async ({ action, code }) => {
|
|
508
|
+
try {
|
|
509
|
+
if (action === "start") {
|
|
510
|
+
const result = await startAuth(config.oauth);
|
|
511
|
+
if ("url" in result) {
|
|
512
|
+
return {
|
|
513
|
+
content: [
|
|
514
|
+
{
|
|
515
|
+
type: "text",
|
|
516
|
+
text: JSON.stringify({
|
|
517
|
+
message: "Open this URL to authorize. A local callback server is listening. " +
|
|
518
|
+
"After you approve, call auth with action 'exchange' to complete authentication.",
|
|
519
|
+
authorizationUrl: result.url,
|
|
520
|
+
flow: config.oauth.flow,
|
|
521
|
+
}, null, 2),
|
|
522
|
+
},
|
|
523
|
+
],
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
// client_credentials: tokens obtained directly
|
|
527
|
+
storeTokens(result.tokens);
|
|
528
|
+
return {
|
|
529
|
+
content: [
|
|
530
|
+
{
|
|
531
|
+
type: "text",
|
|
532
|
+
text: JSON.stringify({
|
|
533
|
+
message: "Authentication successful (client_credentials flow).",
|
|
534
|
+
tokenType: result.tokens.tokenType,
|
|
535
|
+
expiresIn: Math.round((result.tokens.expiresAt - Date.now()) / 1000),
|
|
536
|
+
scope: result.tokens.scope ?? null,
|
|
537
|
+
}, null, 2),
|
|
538
|
+
},
|
|
539
|
+
],
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
if (action === "exchange") {
|
|
543
|
+
const tokens = code
|
|
544
|
+
? await exchangeCode(config.oauth, code)
|
|
545
|
+
: await awaitCallback(config.oauth);
|
|
546
|
+
storeTokens(tokens);
|
|
547
|
+
return {
|
|
548
|
+
content: [
|
|
549
|
+
{
|
|
550
|
+
type: "text",
|
|
551
|
+
text: JSON.stringify({
|
|
552
|
+
message: "Authentication successful.",
|
|
553
|
+
tokenType: tokens.tokenType,
|
|
554
|
+
expiresIn: Math.round((tokens.expiresAt - Date.now()) / 1000),
|
|
555
|
+
hasRefreshToken: !!tokens.refreshToken,
|
|
556
|
+
scope: tokens.scope ?? null,
|
|
557
|
+
}, null, 2),
|
|
558
|
+
},
|
|
559
|
+
],
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
// action === "status"
|
|
563
|
+
const tokens = getTokens();
|
|
564
|
+
if (!tokens) {
|
|
565
|
+
return {
|
|
566
|
+
content: [
|
|
567
|
+
{
|
|
568
|
+
type: "text",
|
|
569
|
+
text: JSON.stringify({
|
|
570
|
+
authenticated: false,
|
|
571
|
+
message: "No tokens stored. Use auth with action 'start' to authenticate.",
|
|
572
|
+
}, null, 2),
|
|
573
|
+
},
|
|
574
|
+
],
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
const expired = isTokenExpired();
|
|
578
|
+
return {
|
|
579
|
+
content: [
|
|
580
|
+
{
|
|
581
|
+
type: "text",
|
|
582
|
+
text: JSON.stringify({
|
|
583
|
+
authenticated: true,
|
|
584
|
+
tokenType: tokens.tokenType,
|
|
585
|
+
expired,
|
|
586
|
+
expiresIn: Math.round((tokens.expiresAt - Date.now()) / 1000),
|
|
587
|
+
hasRefreshToken: !!tokens.refreshToken,
|
|
588
|
+
scope: tokens.scope ?? null,
|
|
589
|
+
}, null, 2),
|
|
590
|
+
},
|
|
591
|
+
],
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
catch (error) {
|
|
595
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
596
|
+
return {
|
|
597
|
+
content: [
|
|
598
|
+
{ type: "text", text: JSON.stringify({ error: message }) },
|
|
599
|
+
],
|
|
600
|
+
isError: true,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
}
|
|
429
605
|
async function main() {
|
|
430
606
|
const transport = new StdioServerTransport();
|
|
431
607
|
await server.connect(transport);
|