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/oauth.js
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
const EXPIRY_BUFFER_MS = 60_000;
|
|
7
|
+
const CALLBACK_PATH = "/callback";
|
|
8
|
+
const CALLBACK_TIMEOUT_MS = 300_000; // 5 minutes
|
|
9
|
+
const TOKEN_DIR = process.platform === "win32"
|
|
10
|
+
? join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "anyapi-mcp", "tokens")
|
|
11
|
+
: join(homedir(), ".cache", "anyapi-mcp", "tokens");
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// PKCE helpers
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
function generateCodeVerifier() {
|
|
16
|
+
return randomBytes(32).toString("base64url");
|
|
17
|
+
}
|
|
18
|
+
function generateCodeChallenge(verifier) {
|
|
19
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
20
|
+
}
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Module state
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
let pendingPkce = null;
|
|
25
|
+
let currentTokens = null;
|
|
26
|
+
let persistName = null;
|
|
27
|
+
let refreshPromise = null;
|
|
28
|
+
// Localhost callback server state
|
|
29
|
+
let callbackServer = null;
|
|
30
|
+
let callbackCodePromise = null;
|
|
31
|
+
let callbackRedirectUri = "";
|
|
32
|
+
let callbackTimeoutHandle = null;
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Token persistence
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
export function initTokenStorage(name) {
|
|
37
|
+
persistName = name;
|
|
38
|
+
const tokenPath = join(TOKEN_DIR, `${name}.json`);
|
|
39
|
+
if (existsSync(tokenPath)) {
|
|
40
|
+
try {
|
|
41
|
+
const raw = readFileSync(tokenPath, "utf-8");
|
|
42
|
+
const loaded = JSON.parse(raw);
|
|
43
|
+
if (loaded.accessToken && typeof loaded.expiresAt === "number") {
|
|
44
|
+
currentTokens = loaded;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Corrupt file — will re-auth
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function persistTokens(tokens) {
|
|
53
|
+
if (!persistName)
|
|
54
|
+
return;
|
|
55
|
+
try {
|
|
56
|
+
mkdirSync(TOKEN_DIR, { recursive: true });
|
|
57
|
+
writeFileSync(join(TOKEN_DIR, `${persistName}.json`), JSON.stringify(tokens, null, 2), "utf-8");
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
console.error(`[oauth] Failed to persist tokens: ${err}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Token accessors
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
export function storeTokens(tokens) {
|
|
67
|
+
currentTokens = tokens;
|
|
68
|
+
persistTokens(tokens);
|
|
69
|
+
}
|
|
70
|
+
export function getTokens() {
|
|
71
|
+
return currentTokens;
|
|
72
|
+
}
|
|
73
|
+
export function clearTokens() {
|
|
74
|
+
currentTokens = null;
|
|
75
|
+
}
|
|
76
|
+
export function isTokenExpired() {
|
|
77
|
+
if (!currentTokens)
|
|
78
|
+
return true;
|
|
79
|
+
return Date.now() >= currentTokens.expiresAt - EXPIRY_BUFFER_MS;
|
|
80
|
+
}
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Localhost callback server
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
function cleanupCallbackServer() {
|
|
85
|
+
if (callbackTimeoutHandle) {
|
|
86
|
+
clearTimeout(callbackTimeoutHandle);
|
|
87
|
+
callbackTimeoutHandle = null;
|
|
88
|
+
}
|
|
89
|
+
if (callbackServer) {
|
|
90
|
+
callbackServer.close();
|
|
91
|
+
callbackServer = null;
|
|
92
|
+
}
|
|
93
|
+
callbackCodePromise = null;
|
|
94
|
+
callbackRedirectUri = "";
|
|
95
|
+
}
|
|
96
|
+
function startCallbackServer() {
|
|
97
|
+
return new Promise((resolveSetup, rejectSetup) => {
|
|
98
|
+
let resolveCode;
|
|
99
|
+
let rejectCode;
|
|
100
|
+
const codePromise = new Promise((resolve, reject) => {
|
|
101
|
+
resolveCode = resolve;
|
|
102
|
+
rejectCode = reject;
|
|
103
|
+
});
|
|
104
|
+
// Mark promise as handled to prevent Node.js unhandled rejection warning.
|
|
105
|
+
// The rejection will still propagate through awaitCallback/exchangeCode.
|
|
106
|
+
codePromise.catch(() => { });
|
|
107
|
+
const server = createServer((req, res) => {
|
|
108
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1`);
|
|
109
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
110
|
+
res.writeHead(404);
|
|
111
|
+
res.end("Not found");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const code = url.searchParams.get("code");
|
|
115
|
+
const error = url.searchParams.get("error");
|
|
116
|
+
const errorDesc = url.searchParams.get("error_description");
|
|
117
|
+
if (error) {
|
|
118
|
+
const msg = errorDesc ? `${error}: ${errorDesc}` : error;
|
|
119
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
120
|
+
res.end("<html><body style=\"font-family:system-ui;text-align:center;padding:40px\">" +
|
|
121
|
+
`<h1>Authentication Failed</h1><p>${msg}</p>` +
|
|
122
|
+
"<p style=\"color:#666\">You can close this window.</p></body></html>");
|
|
123
|
+
rejectCode(new Error(`OAuth authorization failed: ${msg}`));
|
|
124
|
+
}
|
|
125
|
+
else if (code) {
|
|
126
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
127
|
+
res.end("<html><body style=\"font-family:system-ui;text-align:center;padding:40px\">" +
|
|
128
|
+
"<h1>Authentication Successful</h1>" +
|
|
129
|
+
"<p>You can close this window and return to Claude.</p></body></html>");
|
|
130
|
+
resolveCode(code);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
134
|
+
res.end("<html><body style=\"font-family:system-ui;text-align:center;padding:40px\">" +
|
|
135
|
+
"<h1>Error</h1><p>No authorization code received.</p></body></html>");
|
|
136
|
+
}
|
|
137
|
+
// Close the HTTP server (no longer needed) but preserve
|
|
138
|
+
// callbackCodePromise and callbackRedirectUri for awaitCallback/exchangeCode.
|
|
139
|
+
if (callbackTimeoutHandle) {
|
|
140
|
+
clearTimeout(callbackTimeoutHandle);
|
|
141
|
+
callbackTimeoutHandle = null;
|
|
142
|
+
}
|
|
143
|
+
setTimeout(() => {
|
|
144
|
+
if (callbackServer) {
|
|
145
|
+
callbackServer.close();
|
|
146
|
+
callbackServer = null;
|
|
147
|
+
}
|
|
148
|
+
}, 500);
|
|
149
|
+
});
|
|
150
|
+
server.on("error", rejectSetup);
|
|
151
|
+
server.listen(0, "127.0.0.1", () => {
|
|
152
|
+
const addr = server.address();
|
|
153
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
154
|
+
callbackServer = server;
|
|
155
|
+
// Timeout: reject code promise and clean up after 5 minutes
|
|
156
|
+
callbackTimeoutHandle = setTimeout(() => {
|
|
157
|
+
rejectCode(new Error("OAuth callback timed out after 5 minutes. Please try auth start again."));
|
|
158
|
+
cleanupCallbackServer();
|
|
159
|
+
}, CALLBACK_TIMEOUT_MS);
|
|
160
|
+
resolveSetup({ port, codePromise });
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Token exchange helpers
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
async function fetchTokens(tokenUrl, body) {
|
|
168
|
+
const res = await fetch(tokenUrl, {
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
171
|
+
body: body.toString(),
|
|
172
|
+
});
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
const errorBody = await res.text();
|
|
175
|
+
throw new Error(`OAuth token request failed (${res.status} ${res.statusText}): ${errorBody}`);
|
|
176
|
+
}
|
|
177
|
+
const data = (await res.json());
|
|
178
|
+
if (typeof data.access_token !== "string") {
|
|
179
|
+
throw new Error("OAuth token response missing access_token");
|
|
180
|
+
}
|
|
181
|
+
const expiresIn = typeof data.expires_in === "number" ? data.expires_in : 3600;
|
|
182
|
+
return {
|
|
183
|
+
accessToken: data.access_token,
|
|
184
|
+
refreshToken: typeof data.refresh_token === "string" ? data.refresh_token : undefined,
|
|
185
|
+
expiresAt: Date.now() + expiresIn * 1000,
|
|
186
|
+
tokenType: typeof data.token_type === "string" ? data.token_type : "Bearer",
|
|
187
|
+
scope: typeof data.scope === "string" ? data.scope : undefined,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
async function exchangeClientCredentials(config) {
|
|
191
|
+
const body = new URLSearchParams({
|
|
192
|
+
grant_type: "client_credentials",
|
|
193
|
+
client_id: config.clientId,
|
|
194
|
+
client_secret: config.clientSecret,
|
|
195
|
+
});
|
|
196
|
+
if (config.scopes.length > 0) {
|
|
197
|
+
body.set("scope", config.scopes.join(" "));
|
|
198
|
+
}
|
|
199
|
+
return fetchTokens(config.tokenUrl, body);
|
|
200
|
+
}
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Public API — auth flow
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
export async function startAuth(config) {
|
|
205
|
+
if (config.flow === "client_credentials") {
|
|
206
|
+
const tokens = await exchangeClientCredentials(config);
|
|
207
|
+
return { tokens };
|
|
208
|
+
}
|
|
209
|
+
if (!config.authUrl) {
|
|
210
|
+
throw new Error("OAuth authorization URL is required for authorization_code flow. " +
|
|
211
|
+
"Provide --oauth-auth-url or ensure the OpenAPI spec has securitySchemes with an authorizationUrl.");
|
|
212
|
+
}
|
|
213
|
+
// Clean up any previous callback server
|
|
214
|
+
cleanupCallbackServer();
|
|
215
|
+
const verifier = generateCodeVerifier();
|
|
216
|
+
const challenge = generateCodeChallenge(verifier);
|
|
217
|
+
const state = randomBytes(16).toString("hex");
|
|
218
|
+
pendingPkce = { verifier, state };
|
|
219
|
+
// Start localhost callback server on a random available port
|
|
220
|
+
const { port, codePromise } = await startCallbackServer();
|
|
221
|
+
callbackRedirectUri = `http://127.0.0.1:${port}${CALLBACK_PATH}`;
|
|
222
|
+
callbackCodePromise = codePromise;
|
|
223
|
+
const params = new URLSearchParams({
|
|
224
|
+
response_type: "code",
|
|
225
|
+
client_id: config.clientId,
|
|
226
|
+
redirect_uri: callbackRedirectUri,
|
|
227
|
+
code_challenge: challenge,
|
|
228
|
+
code_challenge_method: "S256",
|
|
229
|
+
state,
|
|
230
|
+
...config.extraParams,
|
|
231
|
+
});
|
|
232
|
+
if (config.scopes.length > 0) {
|
|
233
|
+
params.set("scope", config.scopes.join(" "));
|
|
234
|
+
}
|
|
235
|
+
const url = `${config.authUrl}?${params.toString()}`;
|
|
236
|
+
return { url };
|
|
237
|
+
}
|
|
238
|
+
export async function exchangeCode(config, code) {
|
|
239
|
+
if (!pendingPkce) {
|
|
240
|
+
throw new Error("No pending authorization flow. Call auth with action 'start' first.");
|
|
241
|
+
}
|
|
242
|
+
const { verifier } = pendingPkce;
|
|
243
|
+
pendingPkce = null;
|
|
244
|
+
const redirectUri = callbackRedirectUri;
|
|
245
|
+
const body = new URLSearchParams({
|
|
246
|
+
grant_type: "authorization_code",
|
|
247
|
+
code,
|
|
248
|
+
client_id: config.clientId,
|
|
249
|
+
client_secret: config.clientSecret,
|
|
250
|
+
code_verifier: verifier,
|
|
251
|
+
redirect_uri: redirectUri,
|
|
252
|
+
});
|
|
253
|
+
cleanupCallbackServer();
|
|
254
|
+
return fetchTokens(config.tokenUrl, body);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Wait for the localhost callback server to receive the authorization code,
|
|
258
|
+
* then exchange it for tokens automatically.
|
|
259
|
+
*/
|
|
260
|
+
export async function awaitCallback(config) {
|
|
261
|
+
if (!callbackCodePromise) {
|
|
262
|
+
throw new Error("No pending authorization flow. Call auth with action 'start' first.");
|
|
263
|
+
}
|
|
264
|
+
if (!pendingPkce) {
|
|
265
|
+
throw new Error("No pending PKCE state. Call auth with action 'start' first.");
|
|
266
|
+
}
|
|
267
|
+
const code = await callbackCodePromise;
|
|
268
|
+
const { verifier } = pendingPkce;
|
|
269
|
+
pendingPkce = null;
|
|
270
|
+
const redirectUri = callbackRedirectUri;
|
|
271
|
+
const body = new URLSearchParams({
|
|
272
|
+
grant_type: "authorization_code",
|
|
273
|
+
code,
|
|
274
|
+
client_id: config.clientId,
|
|
275
|
+
client_secret: config.clientSecret,
|
|
276
|
+
code_verifier: verifier,
|
|
277
|
+
redirect_uri: redirectUri,
|
|
278
|
+
});
|
|
279
|
+
cleanupCallbackServer();
|
|
280
|
+
return fetchTokens(config.tokenUrl, body);
|
|
281
|
+
}
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// Token refresh
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
export async function refreshTokens(config) {
|
|
286
|
+
if (!currentTokens?.refreshToken) {
|
|
287
|
+
throw new Error("Cannot refresh: no refresh token available. " +
|
|
288
|
+
"Re-authenticate using the auth tool with action 'start'.");
|
|
289
|
+
}
|
|
290
|
+
const body = new URLSearchParams({
|
|
291
|
+
grant_type: "refresh_token",
|
|
292
|
+
refresh_token: currentTokens.refreshToken,
|
|
293
|
+
client_id: config.clientId,
|
|
294
|
+
client_secret: config.clientSecret,
|
|
295
|
+
});
|
|
296
|
+
const tokens = await fetchTokens(config.tokenUrl, body);
|
|
297
|
+
// Preserve refresh token if server didn't issue a new one
|
|
298
|
+
if (!tokens.refreshToken && currentTokens.refreshToken) {
|
|
299
|
+
tokens.refreshToken = currentTokens.refreshToken;
|
|
300
|
+
}
|
|
301
|
+
storeTokens(tokens);
|
|
302
|
+
return tokens;
|
|
303
|
+
}
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// Auth middleware — called by api-client before each request
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
export async function getValidAccessToken(config) {
|
|
308
|
+
if (!config || !currentTokens)
|
|
309
|
+
return undefined;
|
|
310
|
+
if (isTokenExpired()) {
|
|
311
|
+
if (!refreshPromise) {
|
|
312
|
+
refreshPromise = (async () => {
|
|
313
|
+
try {
|
|
314
|
+
if (config.flow === "client_credentials") {
|
|
315
|
+
const tokens = await exchangeClientCredentials(config);
|
|
316
|
+
storeTokens(tokens);
|
|
317
|
+
return tokens;
|
|
318
|
+
}
|
|
319
|
+
return await refreshTokens(config);
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
refreshPromise = null;
|
|
323
|
+
}
|
|
324
|
+
})();
|
|
325
|
+
}
|
|
326
|
+
const tokens = await refreshPromise;
|
|
327
|
+
return tokens.accessToken;
|
|
328
|
+
}
|
|
329
|
+
return currentTokens.accessToken;
|
|
330
|
+
}
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// Test helpers — reset module state
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
export function _resetForTests() {
|
|
335
|
+
pendingPkce = null;
|
|
336
|
+
currentTokens = null;
|
|
337
|
+
persistName = null;
|
|
338
|
+
refreshPromise = null;
|
|
339
|
+
cleanupCallbackServer();
|
|
340
|
+
}
|
package/build/response-parser.js
CHANGED
|
@@ -7,7 +7,12 @@ const xmlParser = new XMLParser({
|
|
|
7
7
|
export function parseResponse(contentType, body) {
|
|
8
8
|
const ct = (contentType ?? "").toLowerCase();
|
|
9
9
|
if (ct.includes("application/json") || ct.includes("+json")) {
|
|
10
|
-
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(body);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// Content-Type claims JSON but body isn't — fall through to detection
|
|
15
|
+
}
|
|
11
16
|
}
|
|
12
17
|
if (ct.includes("xml") || ct.includes("+xml")) {
|
|
13
18
|
return xmlParser.parse(body);
|
|
@@ -15,17 +20,57 @@ export function parseResponse(contentType, body) {
|
|
|
15
20
|
if (ct.includes("text/csv") || ct.includes("application/csv")) {
|
|
16
21
|
return parseCsv(body);
|
|
17
22
|
}
|
|
23
|
+
if (ct.includes("application/x-www-form-urlencoded")) {
|
|
24
|
+
return parseFormUrlEncoded(body);
|
|
25
|
+
}
|
|
18
26
|
// Try JSON parse for responses without content-type
|
|
19
27
|
if (!ct || ct.includes("text/plain")) {
|
|
20
28
|
try {
|
|
21
29
|
return JSON.parse(body);
|
|
22
30
|
}
|
|
23
31
|
catch {
|
|
24
|
-
// Not JSON,
|
|
32
|
+
// Not JSON, continue
|
|
25
33
|
}
|
|
26
34
|
}
|
|
35
|
+
// Try form-urlencoded detection (e.g. key=value&key2=value2)
|
|
36
|
+
if (looksLikeFormUrlEncoded(body)) {
|
|
37
|
+
return parseFormUrlEncoded(body);
|
|
38
|
+
}
|
|
27
39
|
return { _type: "text", content: body };
|
|
28
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Returns true if parsed response data is non-JSON (text, form-encoded, etc.)
|
|
43
|
+
* and should skip the GraphQL schema inference layer.
|
|
44
|
+
*/
|
|
45
|
+
export function isNonJsonResult(data) {
|
|
46
|
+
if (data === null || data === undefined)
|
|
47
|
+
return true;
|
|
48
|
+
if (typeof data !== "object")
|
|
49
|
+
return true;
|
|
50
|
+
if (typeof data === "object" &&
|
|
51
|
+
data !== null &&
|
|
52
|
+
"_type" in data &&
|
|
53
|
+
data._type === "text") {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
function parseFormUrlEncoded(body) {
|
|
59
|
+
const params = new URLSearchParams(body);
|
|
60
|
+
const result = {};
|
|
61
|
+
for (const [key, value] of params) {
|
|
62
|
+
result[key] = value;
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
function looksLikeFormUrlEncoded(body) {
|
|
67
|
+
const trimmed = body.trim();
|
|
68
|
+
if (!trimmed || trimmed.startsWith("{") || trimmed.startsWith("[") || trimmed.startsWith("<")) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
// Must have at least one key=value pair
|
|
72
|
+
return /^[^=&]+=[^&]*(&[^=&]+=[^&]*)*$/.test(trimmed.split("\n")[0]);
|
|
73
|
+
}
|
|
29
74
|
function parseCsv(csv) {
|
|
30
75
|
const lines = csv.split("\n").filter((line) => line.trim().length > 0);
|
|
31
76
|
if (lines.length === 0)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anyapi-mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"description": "A universal MCP server that connects any REST API (via OpenAPI spec) to AI assistants, with GraphQL-style field selection and automatic schema inference.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|