anyapi-mcp-server 1.8.1 → 2.0.0

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.
@@ -0,0 +1,137 @@
1
+ /**
2
+ * RFC 6902 JSON Patch subset: add, remove, replace operations.
3
+ * Used by mutate_api to apply targeted changes to large resources
4
+ * without requiring the LLM to hold the full state in context.
5
+ */
6
+ /**
7
+ * Parse a JSON Pointer (RFC 6901) into path segments.
8
+ * Unescapes ~1 → / and ~0 → ~ per spec.
9
+ */
10
+ function parsePointer(path) {
11
+ if (path === "")
12
+ return [];
13
+ if (!path.startsWith("/")) {
14
+ throw new Error(`Invalid JSON Pointer: must start with '/' (got '${path}')`);
15
+ }
16
+ return path
17
+ .slice(1)
18
+ .split("/")
19
+ .map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~"));
20
+ }
21
+ /**
22
+ * Walk to the parent of the target path segment.
23
+ * Returns [parent, lastSegment] for the operation to act on.
24
+ */
25
+ function walkToParent(root, segments) {
26
+ let current = root;
27
+ for (let i = 0; i < segments.length - 1; i++) {
28
+ const seg = segments[i];
29
+ if (Array.isArray(current)) {
30
+ const idx = parseInt(seg, 10);
31
+ if (isNaN(idx) || idx < 0 || idx >= current.length) {
32
+ throw new Error(`Array index out of bounds: '${seg}' at /${segments.slice(0, i + 1).join("/")}`);
33
+ }
34
+ current = current[idx];
35
+ }
36
+ else if (typeof current === "object" && current !== null) {
37
+ const rec = current;
38
+ if (!(seg in rec)) {
39
+ throw new Error(`Path not found: '${seg}' at /${segments.slice(0, i + 1).join("/")}`);
40
+ }
41
+ current = rec[seg];
42
+ }
43
+ else {
44
+ throw new Error(`Cannot traverse into ${typeof current} at /${segments.slice(0, i + 1).join("/")}`);
45
+ }
46
+ }
47
+ if (!Array.isArray(current) && (typeof current !== "object" || current === null)) {
48
+ throw new Error(`Cannot apply operation: parent at /${segments.slice(0, -1).join("/")} is ${typeof current}`);
49
+ }
50
+ return [current, segments[segments.length - 1]];
51
+ }
52
+ function applyAdd(root, segments, value) {
53
+ const [parent, key] = walkToParent(root, segments);
54
+ if (Array.isArray(parent)) {
55
+ if (key === "-") {
56
+ parent.push(value);
57
+ }
58
+ else {
59
+ const idx = parseInt(key, 10);
60
+ if (isNaN(idx) || idx < 0 || idx > parent.length) {
61
+ throw new Error(`Array index out of bounds for add: '${key}'`);
62
+ }
63
+ parent.splice(idx, 0, value);
64
+ }
65
+ }
66
+ else {
67
+ parent[key] = value;
68
+ }
69
+ }
70
+ function applyRemove(root, segments) {
71
+ const [parent, key] = walkToParent(root, segments);
72
+ if (Array.isArray(parent)) {
73
+ const idx = parseInt(key, 10);
74
+ if (isNaN(idx) || idx < 0 || idx >= parent.length) {
75
+ throw new Error(`Array index out of bounds for remove: '${key}'`);
76
+ }
77
+ parent.splice(idx, 1);
78
+ }
79
+ else {
80
+ const rec = parent;
81
+ if (!(key in rec)) {
82
+ throw new Error(`Cannot remove non-existent key: '${key}'`);
83
+ }
84
+ delete rec[key];
85
+ }
86
+ }
87
+ function applyReplace(root, segments, value) {
88
+ const [parent, key] = walkToParent(root, segments);
89
+ if (Array.isArray(parent)) {
90
+ const idx = parseInt(key, 10);
91
+ if (isNaN(idx) || idx < 0 || idx >= parent.length) {
92
+ throw new Error(`Array index out of bounds for replace: '${key}'`);
93
+ }
94
+ parent[idx] = value;
95
+ }
96
+ else {
97
+ const rec = parent;
98
+ if (!(key in rec)) {
99
+ throw new Error(`Cannot replace non-existent key: '${key}'`);
100
+ }
101
+ rec[key] = value;
102
+ }
103
+ }
104
+ /**
105
+ * Apply a sequence of JSON Patch operations to a deep-cloned copy of the target.
106
+ * Returns the patched object. Does not mutate the original.
107
+ */
108
+ export function applyPatch(target, operations) {
109
+ const cloned = JSON.parse(JSON.stringify(target));
110
+ for (let i = 0; i < operations.length; i++) {
111
+ const op = operations[i];
112
+ const segments = parsePointer(op.path);
113
+ if (segments.length === 0) {
114
+ throw new Error(`Operation ${i} (${op.op}): cannot target root`);
115
+ }
116
+ switch (op.op) {
117
+ case "add":
118
+ if (op.value === undefined) {
119
+ throw new Error(`Operation ${i} (add): 'value' is required`);
120
+ }
121
+ applyAdd(cloned, segments, op.value);
122
+ break;
123
+ case "remove":
124
+ applyRemove(cloned, segments);
125
+ break;
126
+ case "replace":
127
+ if (op.value === undefined) {
128
+ throw new Error(`Operation ${i} (replace): 'value' is required`);
129
+ }
130
+ applyReplace(cloned, segments, op.value);
131
+ break;
132
+ default:
133
+ throw new Error(`Operation ${i}: unsupported op '${op.op}'`);
134
+ }
135
+ }
136
+ return cloned;
137
+ }
package/build/oauth.js CHANGED
@@ -70,9 +70,6 @@ export function storeTokens(tokens) {
70
70
  export function getTokens() {
71
71
  return currentTokens;
72
72
  }
73
- export function clearTokens() {
74
- currentTokens = null;
75
- }
76
73
  export function isTokenExpired() {
77
74
  if (!currentTokens)
78
75
  return true;
@@ -28,7 +28,7 @@ function walkPath(data, path) {
28
28
  return current;
29
29
  }
30
30
  /**
31
- * Known pagination-related query parameter names (used to flag params in call_api).
31
+ * Known pagination-related query parameter names (used to flag params in inspect_api).
32
32
  */
33
33
  export const PAGINATION_PARAM_NAMES = new Set([
34
34
  "page", "cursor", "after", "before", "limit", "offset", "per_page",
@@ -1,7 +1,20 @@
1
1
  import { callApi } from "./api-client.js";
2
2
  import { storeResponse } from "./data-cache.js";
3
+ /**
4
+ * Extract parameter names from a path template (e.g., "/items/{id}" → Set(["id"])).
5
+ */
6
+ export function extractPathParamNames(pathTemplate) {
7
+ const names = new Set();
8
+ pathTemplate.replace(/\{([^}]+)\}/g, (_, name) => {
9
+ names.add(name);
10
+ return "";
11
+ });
12
+ return names;
13
+ }
3
14
  /**
4
15
  * Create a pre-write backup by fetching the current state of a resource via GET.
16
+ * Only forwards path parameters — query params are stripped to avoid fetching
17
+ * a filtered/paginated subset of the resource.
5
18
  * Returns a dataKey for the cached snapshot, or undefined on failure.
6
19
  * Failure is non-fatal — errors are logged to stderr.
7
20
  */
@@ -9,7 +22,12 @@ export async function createBackup(config, method, path, params, headers) {
9
22
  if (method !== "PATCH" && method !== "PUT")
10
23
  return undefined;
11
24
  try {
12
- const { data, responseHeaders } = await callApi(config, "GET", path, params, undefined, headers);
25
+ // Only forward path parameters to avoid fetching a filtered/paginated response
26
+ const pathParamNames = extractPathParamNames(path);
27
+ const pathOnlyParams = params
28
+ ? Object.fromEntries(Object.entries(params).filter(([k]) => pathParamNames.has(k)))
29
+ : undefined;
30
+ const { data, responseHeaders } = await callApi(config, "GET", path, pathOnlyParams && Object.keys(pathOnlyParams).length > 0 ? pathOnlyParams : undefined, undefined, headers);
13
31
  return storeResponse("GET", path, data, responseHeaders);
14
32
  }
15
33
  catch (err) {
@@ -3,13 +3,12 @@
3
3
  * Truncates array results to fit within a token budget,
4
4
  * leveraging GraphQL field selection for size control.
5
5
  */
6
- const DEFAULT_BUDGET = 4000;
7
6
  /**
8
7
  * Find the primary array in a query result.
9
8
  * Checks `items` first (raw array responses), then falls back to
10
9
  * the first non-`_`-prefixed array field.
11
10
  */
12
- export function findPrimaryArray(obj) {
11
+ function findPrimaryArray(obj) {
13
12
  if (typeof obj !== "object" || obj === null || Array.isArray(obj))
14
13
  return null;
15
14
  const rec = obj;
@@ -44,40 +43,78 @@ function estimateTokens(value) {
44
43
  }
45
44
  }
46
45
  /**
47
- * Truncate the primary array in an object to fit within a token budget.
48
- * Uses binary search to find the max number of items that fit.
49
- * Returns the truncated object and the original/truncated counts.
46
+ * Public re-export of estimateTokens for use in index.ts.
50
47
  */
51
- export function truncateToTokenBudget(obj, budget) {
52
- const arr = findPrimaryArray(obj);
53
- if (!arr || arr.length === 0) {
48
+ export function estimateResultTokens(value) {
49
+ return estimateTokens(value);
50
+ }
51
+ /**
52
+ * BFS walk of the result tree to find the deepest, largest array by token cost.
53
+ * Skips `_`-prefixed keys. Returns null if no arrays found.
54
+ */
55
+ export function findDeepestLargestArray(obj) {
56
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj))
57
+ return null;
58
+ let best = null;
59
+ // BFS queue: each entry is [currentObject, pathSoFar]
60
+ const queue = [
61
+ [obj, []],
62
+ ];
63
+ while (queue.length > 0) {
64
+ const [current, currentPath] = queue.shift();
65
+ for (const [key, value] of Object.entries(current)) {
66
+ if (key.startsWith("_"))
67
+ continue;
68
+ if (Array.isArray(value)) {
69
+ const cost = estimateTokens(value);
70
+ if (!best ||
71
+ currentPath.length + 1 > best.path.length ||
72
+ (currentPath.length + 1 === best.path.length && cost > best.tokenCost)) {
73
+ best = { path: [...currentPath, key], array: value, tokenCost: cost };
74
+ }
75
+ }
76
+ else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
77
+ queue.push([value, [...currentPath, key]]);
78
+ }
79
+ }
80
+ }
81
+ return best;
82
+ }
83
+ /**
84
+ * Truncate the deepest largest array in an object to fit within a token budget.
85
+ * Clones the spine of the path to avoid mutating the original.
86
+ * Returns the truncated object and original/kept counts.
87
+ */
88
+ export function truncateDeepArray(obj, budget) {
89
+ if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
54
90
  return { result: obj, originalCount: 0, keptCount: 0 };
55
91
  }
56
- const originalCount = arr.length;
57
- // Compute overhead tokens (non-array fields)
58
- const overhead = {};
59
- const arrayKey = findPrimaryArrayKey(obj);
60
- if (!arrayKey)
92
+ const target = findDeepestLargestArray(obj);
93
+ if (!target || target.array.length === 0) {
94
+ return { result: obj, originalCount: 0, keptCount: 0 };
95
+ }
96
+ const originalCount = target.array.length;
97
+ // Check if everything fits as-is
98
+ const totalTokens = estimateTokens(obj);
99
+ if (totalTokens <= budget) {
61
100
  return { result: obj, originalCount, keptCount: originalCount };
62
- for (const [key, value] of Object.entries(obj)) {
63
- if (key !== arrayKey) {
64
- overhead[key] = value;
65
- }
66
101
  }
67
- const overheadTokens = estimateTokens(overhead);
102
+ // Compute overhead: tokens of everything except the target array
103
+ // Build a copy with the target array emptied to measure overhead
104
+ const emptyClone = cloneSpine(obj, target.path, []);
105
+ const overheadTokens = estimateTokens(emptyClone);
68
106
  const arrayBudget = Math.max(1, budget - overheadTokens);
69
- // Check if full array fits
70
- const fullTokens = estimateTokens(arr);
71
- if (fullTokens <= arrayBudget) {
107
+ // Check if full array fits within array budget
108
+ if (target.tokenCost <= arrayBudget) {
72
109
  return { result: obj, originalCount, keptCount: originalCount };
73
110
  }
74
111
  // Binary search for max items that fit
75
112
  let lo = 1;
76
113
  let hi = originalCount;
77
- let bestCount = 1; // Always keep at least 1
114
+ let bestCount = 1;
78
115
  while (lo <= hi) {
79
116
  const mid = Math.floor((lo + hi) / 2);
80
- const sliceTokens = estimateTokens(arr.slice(0, mid));
117
+ const sliceTokens = estimateTokens(target.array.slice(0, mid));
81
118
  if (sliceTokens <= arrayBudget) {
82
119
  bestCount = mid;
83
120
  lo = mid + 1;
@@ -86,50 +123,56 @@ export function truncateToTokenBudget(obj, budget) {
86
123
  hi = mid - 1;
87
124
  }
88
125
  }
89
- const truncated = { ...obj, [arrayKey]: arr.slice(0, bestCount) };
90
- return { result: truncated, originalCount, keptCount: bestCount };
126
+ const truncatedResult = cloneSpine(obj, target.path, target.array.slice(0, bestCount));
127
+ return { result: truncatedResult, originalCount, keptCount: bestCount };
91
128
  }
92
129
  /**
93
- * Find the key name of the primary array in an object.
130
+ * Clone the spine of an object along a path, replacing the leaf with a new value.
94
131
  */
95
- function findPrimaryArrayKey(obj) {
96
- if (typeof obj !== "object" || obj === null || Array.isArray(obj))
97
- return null;
98
- const rec = obj;
99
- if (Array.isArray(rec.items))
100
- return "items";
101
- for (const [key, value] of Object.entries(rec)) {
102
- if (!key.startsWith("_") && Array.isArray(value)) {
103
- return key;
104
- }
132
+ function cloneSpine(obj, path, leafValue) {
133
+ if (path.length === 0)
134
+ return obj;
135
+ if (path.length === 1) {
136
+ return { ...obj, [path[0]]: leafValue };
105
137
  }
106
- return null;
138
+ const [head, ...rest] = path;
139
+ return {
140
+ ...obj,
141
+ [head]: cloneSpine(obj[head], rest, leafValue),
142
+ };
107
143
  }
108
144
  /**
109
145
  * Build a status message for the response.
110
- * If estimated tokens exceed the budget, truncates and returns TRUNCATED status.
111
- * Otherwise returns COMPLETE status.
146
+ * When maxTokens is provided, uses truncateDeepArray for deep truncation.
147
+ * When maxTokens is omitted, returns COMPLETE with no truncation.
112
148
  */
113
- export function buildStatusMessage(queryResult, budget = DEFAULT_BUDGET) {
149
+ export function buildStatusMessage(queryResult, maxTokens) {
114
150
  if (typeof queryResult !== "object" || queryResult === null || Array.isArray(queryResult)) {
115
151
  return { status: "COMPLETE", result: queryResult };
116
152
  }
117
153
  const qr = queryResult;
154
+ // No budget specified — return complete, no truncation
155
+ if (maxTokens === undefined) {
156
+ const arrayLen = findPrimaryArrayLength(qr);
157
+ const status = arrayLen !== null ? `COMPLETE (${arrayLen} items)` : "COMPLETE";
158
+ return { status, result: qr };
159
+ }
160
+ // Budget specified — check if truncation needed
118
161
  const totalTokens = estimateTokens(qr);
119
- if (totalTokens <= budget) {
162
+ if (totalTokens <= maxTokens) {
120
163
  const arrayLen = findPrimaryArrayLength(qr);
121
164
  const status = arrayLen !== null ? `COMPLETE (${arrayLen} items)` : "COMPLETE";
122
165
  return { status, result: qr };
123
166
  }
124
- // Need truncation
125
- const { result, originalCount, keptCount } = truncateToTokenBudget(qr, budget);
167
+ // Need truncation — use deep array truncation
168
+ const { result, originalCount, keptCount } = truncateDeepArray(qr, maxTokens);
126
169
  if (originalCount === 0) {
127
170
  // No array found but response is over budget
128
171
  return {
129
- status: `COMPLETE (response exceeds token budget ${budget} — select fewer fields)`,
172
+ status: `COMPLETE (response exceeds token budget ${maxTokens} — select fewer fields)`,
130
173
  result: qr,
131
174
  };
132
175
  }
133
- const status = `TRUNCATED — ${keptCount} of ${originalCount} items (token budget ${budget}). Select fewer fields to fit more items.`;
176
+ const status = `TRUNCATED — ${keptCount} of ${originalCount} items (token budget ${maxTokens}). Select fewer fields to fit more items. Other array fields may also be truncated by default limits — check *_count fields for actual totals.`;
134
177
  return { status, result };
135
178
  }
@@ -0,0 +1,117 @@
1
+ import { z } from "zod";
2
+ import { startAuth, exchangeCode, awaitCallback, storeTokens, getTokens, isTokenExpired, } from "../oauth.js";
3
+ export function registerAuth({ server, config }) {
4
+ if (!config.oauth)
5
+ return;
6
+ server.tool("auth", `Manage OAuth 2.0 authentication for ${config.name}. ` +
7
+ "Use action 'start' to begin the OAuth flow (returns an authorization URL for " +
8
+ "authorization_code flow, or completes token exchange for client_credentials). " +
9
+ "Use action 'exchange' to complete the flow — the callback is captured automatically " +
10
+ "via a localhost server, or you can provide a 'code' manually. " +
11
+ "Use action 'status' to check the current token status.", {
12
+ action: z
13
+ .enum(["start", "exchange", "status"])
14
+ .describe("'start' begins auth flow, 'exchange' completes code exchange, 'status' shows token info"),
15
+ code: z
16
+ .string()
17
+ .optional()
18
+ .describe("Authorization code from the OAuth provider (optional for 'exchange' — " +
19
+ "if omitted, waits for the localhost callback automatically)"),
20
+ }, async ({ action, code }) => {
21
+ try {
22
+ if (action === "start") {
23
+ const result = await startAuth(config.oauth);
24
+ if ("url" in result) {
25
+ return {
26
+ content: [
27
+ {
28
+ type: "text",
29
+ text: JSON.stringify({
30
+ message: "Open this URL to authorize. A local callback server is listening. " +
31
+ "After you approve, call auth with action 'exchange' to complete authentication.",
32
+ authorizationUrl: result.url,
33
+ flow: config.oauth.flow,
34
+ }, null, 2),
35
+ },
36
+ ],
37
+ };
38
+ }
39
+ // client_credentials: tokens obtained directly
40
+ storeTokens(result.tokens);
41
+ return {
42
+ content: [
43
+ {
44
+ type: "text",
45
+ text: JSON.stringify({
46
+ message: "Authentication successful (client_credentials flow).",
47
+ tokenType: result.tokens.tokenType,
48
+ expiresIn: Math.round((result.tokens.expiresAt - Date.now()) / 1000),
49
+ scope: result.tokens.scope ?? null,
50
+ }, null, 2),
51
+ },
52
+ ],
53
+ };
54
+ }
55
+ if (action === "exchange") {
56
+ const tokens = code
57
+ ? await exchangeCode(config.oauth, code)
58
+ : await awaitCallback(config.oauth);
59
+ storeTokens(tokens);
60
+ return {
61
+ content: [
62
+ {
63
+ type: "text",
64
+ text: JSON.stringify({
65
+ message: "Authentication successful.",
66
+ tokenType: tokens.tokenType,
67
+ expiresIn: Math.round((tokens.expiresAt - Date.now()) / 1000),
68
+ hasRefreshToken: !!tokens.refreshToken,
69
+ scope: tokens.scope ?? null,
70
+ }, null, 2),
71
+ },
72
+ ],
73
+ };
74
+ }
75
+ // action === "status"
76
+ const tokens = getTokens();
77
+ if (!tokens) {
78
+ return {
79
+ content: [
80
+ {
81
+ type: "text",
82
+ text: JSON.stringify({
83
+ authenticated: false,
84
+ message: "No tokens stored. Use auth with action 'start' to authenticate.",
85
+ }, null, 2),
86
+ },
87
+ ],
88
+ };
89
+ }
90
+ const expired = isTokenExpired();
91
+ return {
92
+ content: [
93
+ {
94
+ type: "text",
95
+ text: JSON.stringify({
96
+ authenticated: true,
97
+ tokenType: tokens.tokenType,
98
+ expired,
99
+ expiresIn: Math.round((tokens.expiresAt - Date.now()) / 1000),
100
+ hasRefreshToken: !!tokens.refreshToken,
101
+ scope: tokens.scope ?? null,
102
+ }, null, 2),
103
+ },
104
+ ],
105
+ };
106
+ }
107
+ catch (error) {
108
+ const message = error instanceof Error ? error.message : String(error);
109
+ return {
110
+ content: [
111
+ { type: "text", text: JSON.stringify({ error: message }) },
112
+ ],
113
+ isError: true,
114
+ };
115
+ }
116
+ });
117
+ }