anyapi-mcp-server 1.8.1 → 2.0.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.
@@ -0,0 +1,213 @@
1
+ import { z } from "zod";
2
+ import { formatToolError, attachRateLimit } from "./shared.js";
3
+ import { callApi } from "../api-client.js";
4
+ import { getOrBuildSchema, schemaToSDL, collectJsonFields, computeFieldCosts, collectArrayLengths, } from "../graphql-schema.js";
5
+ import { generateSuggestions } from "../query-suggestions.js";
6
+ import { isNonJsonResult } from "../response-parser.js";
7
+ import { detectPagination, PAGINATION_PARAM_NAMES } from "../pagination.js";
8
+ import { storeResponse } from "../data-cache.js";
9
+ export function registerInspectApi({ server, config, apiIndex }) {
10
+ server.tool("inspect_api", `Understand a ${config.name} API endpoint before using it. ` +
11
+ "For GET endpoints: makes a real request and returns the inferred GraphQL schema (SDL), " +
12
+ "suggested queries, per-field token costs, and a dataKey for cached re-queries. " +
13
+ "For write endpoints (POST/PUT/PATCH/DELETE): returns spec documentation only — " +
14
+ "parameters, request body schema, response codes — WITHOUT making any HTTP request. " +
15
+ "Always safe to call. Use list_api first to discover endpoints.", {
16
+ method: z
17
+ .enum(["GET", "POST", "PUT", "DELETE", "PATCH"])
18
+ .describe("HTTP method"),
19
+ path: z
20
+ .string()
21
+ .describe("API path template (e.g. '/api/card/{id}'). Use list_api to discover paths."),
22
+ params: z
23
+ .record(z.unknown())
24
+ .optional()
25
+ .describe("Path and query parameters (GET only). " +
26
+ "Path params like {id} are interpolated; remaining become query string."),
27
+ headers: z
28
+ .record(z.string())
29
+ .optional()
30
+ .describe("Additional HTTP headers (GET only). " +
31
+ "Overrides default --header values."),
32
+ }, async ({ method, path, params, headers }) => {
33
+ try {
34
+ // Non-GET: return spec documentation only (no HTTP request)
35
+ if (method !== "GET") {
36
+ return handleSpecDocs(apiIndex, method, path);
37
+ }
38
+ // GET: make request and infer schema
39
+ const { data, responseHeaders: respHeaders } = await callApi(config, method, path, params, undefined, headers);
40
+ const dataKey = storeResponse(method, path, data, respHeaders);
41
+ // Non-JSON response — skip GraphQL layer
42
+ if (isNonJsonResult(data)) {
43
+ return {
44
+ content: [
45
+ { type: "text", text: JSON.stringify({
46
+ rawResponse: data,
47
+ responseHeaders: respHeaders,
48
+ dataKey,
49
+ hint: "This endpoint returned a non-JSON response. The raw parsed content is shown above. " +
50
+ "GraphQL schema inference is not available for non-JSON responses — use the data directly.",
51
+ }, null, 2) },
52
+ ],
53
+ };
54
+ }
55
+ const endpoint = apiIndex.getEndpoint(method, path);
56
+ const { schema, shapeHash } = getOrBuildSchema(data, method, path, endpoint?.requestBodySchema);
57
+ const sdl = schemaToSDL(schema);
58
+ const result = { graphqlSchema: sdl, shapeHash, responseHeaders: respHeaders };
59
+ if (dataKey)
60
+ result.dataKey = dataKey;
61
+ attachRateLimit(result, respHeaders);
62
+ if (endpoint && endpoint.parameters.length > 0) {
63
+ result.parameters = endpoint.parameters.map((p) => ({
64
+ name: p.name,
65
+ in: p.in,
66
+ required: p.required,
67
+ ...(p.description ? { description: p.description } : {}),
68
+ ...(p.in === "query" && PAGINATION_PARAM_NAMES.has(p.name) ? { pagination: true } : {}),
69
+ }));
70
+ const paginationParams = endpoint.parameters
71
+ .filter((p) => p.in === "query" && PAGINATION_PARAM_NAMES.has(p.name))
72
+ .map((p) => p.name);
73
+ if (paginationParams.length > 0) {
74
+ result.paginationParams = paginationParams;
75
+ }
76
+ }
77
+ const suggestions = generateSuggestions(schema);
78
+ if (suggestions.length > 0) {
79
+ result.suggestedQueries = suggestions;
80
+ }
81
+ const fieldCosts = computeFieldCosts(data);
82
+ result.fieldTokenCosts = fieldCosts;
83
+ const arrayLengths = collectArrayLengths(data);
84
+ if (Object.keys(arrayLengths).length > 0) {
85
+ result.fieldArrayLengths = arrayLengths;
86
+ }
87
+ const allFieldsCost = fieldCosts._total;
88
+ if (Array.isArray(data) && data.length > 0 && fieldCosts._perItem) {
89
+ const perItem = fieldCosts._perItem;
90
+ result.budgetExamples = [
91
+ `All fields: ~${perItem} tokens/item, ~${allFieldsCost} tokens total`,
92
+ ];
93
+ }
94
+ else if (typeof data === "object" && data !== null) {
95
+ result.budgetExamples = [
96
+ `All fields: ~${allFieldsCost} tokens total`,
97
+ ];
98
+ }
99
+ const jsonFields = collectJsonFields(schema);
100
+ if (jsonFields.length > 0) {
101
+ result.jsonFields = jsonFields;
102
+ result.jsonFieldsHint =
103
+ "These fields contain heterogeneous or deeply nested data that cannot be queried " +
104
+ "with GraphQL field selection. Query them as-is and parse the returned JSON directly.";
105
+ }
106
+ const pagination = detectPagination(data, endpoint?.parameters);
107
+ if (pagination) {
108
+ result._pagination = pagination;
109
+ }
110
+ const paginationParamsList = result.paginationParams ?? [];
111
+ const paginationSuffix = paginationParamsList.length > 0
112
+ ? ` This API supports pagination via: ${paginationParamsList.join(", ")}. Pass these inside params.`
113
+ : "";
114
+ if (Array.isArray(data)) {
115
+ result.totalItems = data.length;
116
+ result.hint =
117
+ "Use query_api with field names from the schema above. " +
118
+ "For raw arrays: '{ items { ... } _count }'. " +
119
+ "For paginated APIs, pass limit/offset inside params (as query string parameters to the API), " +
120
+ "NOT as top-level tool parameters. " +
121
+ "Use fieldTokenCosts to understand per-field token costs and select fields wisely. " +
122
+ "Responses over ~10k tokens require maxTokens (to truncate) or unlimited: true (for full data)." +
123
+ paginationSuffix;
124
+ }
125
+ else {
126
+ result.hint =
127
+ "Use query_api with the exact root field names from the schema above (e.g. if schema shows " +
128
+ "'products: [Product]', query as '{ products { id name } }' — do NOT use '{ items { ... } }'). " +
129
+ "For paginated APIs, pass limit/offset inside params (as query string parameters to the API), " +
130
+ "NOT as top-level tool parameters. " +
131
+ "Use fieldTokenCosts to understand per-field token costs and select fields wisely. " +
132
+ "Responses over ~10k tokens require maxTokens (to truncate) or unlimited: true (for full data)." +
133
+ paginationSuffix;
134
+ }
135
+ return {
136
+ content: [
137
+ { type: "text", text: JSON.stringify(result, null, 2) },
138
+ ],
139
+ };
140
+ }
141
+ catch (error) {
142
+ return formatToolError(error, apiIndex, method, path);
143
+ }
144
+ });
145
+ }
146
+ /**
147
+ * Return spec documentation for non-GET endpoints (no HTTP request).
148
+ */
149
+ function handleSpecDocs(apiIndex, method, path) {
150
+ const endpoint = apiIndex.getEndpoint(method, path);
151
+ if (!endpoint) {
152
+ return {
153
+ content: [
154
+ {
155
+ type: "text",
156
+ text: JSON.stringify({
157
+ error: `Endpoint not found: ${method} ${path}`,
158
+ hint: "Use list_api to discover available endpoints.",
159
+ }),
160
+ },
161
+ ],
162
+ isError: true,
163
+ };
164
+ }
165
+ const result = {
166
+ method: endpoint.method,
167
+ path: endpoint.path,
168
+ summary: endpoint.summary,
169
+ };
170
+ if (endpoint.description) {
171
+ result.description = endpoint.description;
172
+ }
173
+ if (endpoint.operationId) {
174
+ result.operationId = endpoint.operationId;
175
+ }
176
+ if (endpoint.deprecated) {
177
+ result.deprecated = true;
178
+ }
179
+ result.tag = endpoint.tag;
180
+ if (endpoint.parameters.length > 0) {
181
+ result.parameters = endpoint.parameters.map((p) => ({
182
+ name: p.name,
183
+ in: p.in,
184
+ required: p.required,
185
+ ...(p.description ? { description: p.description } : {}),
186
+ }));
187
+ }
188
+ if (endpoint.hasRequestBody) {
189
+ const bodyInfo = {};
190
+ if (endpoint.requestBodyDescription) {
191
+ bodyInfo.description = endpoint.requestBodyDescription;
192
+ }
193
+ if (endpoint.requestBodySchema) {
194
+ bodyInfo.contentType = endpoint.requestBodySchema.contentType;
195
+ bodyInfo.properties = endpoint.requestBodySchema.properties;
196
+ }
197
+ result.requestBody = bodyInfo;
198
+ }
199
+ if (endpoint.responses && endpoint.responses.length > 0) {
200
+ result.responses = endpoint.responses;
201
+ }
202
+ if (endpoint.externalDocs) {
203
+ result.externalDocs = endpoint.externalDocs;
204
+ }
205
+ result.hint = "Use mutate_api to execute this endpoint. " +
206
+ "Provide the request body via 'body' (inline), 'bodyFile' (file path), " +
207
+ "or 'patch' (JSON Patch operations for targeted changes to existing resources).";
208
+ return {
209
+ content: [
210
+ { type: "text", text: JSON.stringify(result, null, 2) },
211
+ ],
212
+ };
213
+ }
@@ -0,0 +1,82 @@
1
+ import { z } from "zod";
2
+ import { getOrBuildSchema, truncateIfArray, executeQuery, } from "../graphql-schema.js";
3
+ export function registerListApi({ server, config, apiIndex }) {
4
+ server.tool("list_api", `List available ${config.name} API endpoints. ` +
5
+ "Call with no arguments to see all endpoints. " +
6
+ "Provide 'category' to filter by tag. " +
7
+ "Provide 'search' to search across paths and summaries (supports regex). " +
8
+ "Results are paginated with limit (default 20) and offset.", {
9
+ category: z
10
+ .string()
11
+ .optional()
12
+ .describe("Tag/category to filter by. Case-insensitive."),
13
+ search: z
14
+ .string()
15
+ .optional()
16
+ .describe("Search keyword or regex pattern across endpoint paths and summaries"),
17
+ query: z
18
+ .string()
19
+ .optional()
20
+ .describe("GraphQL selection query. Default: '{ items { method path summary } _count }'. " +
21
+ "Available fields: method, path, summary, tag, parameters { name in required description }"),
22
+ limit: z
23
+ .number()
24
+ .int()
25
+ .min(1)
26
+ .optional()
27
+ .describe("Max items to return (default: 20)"),
28
+ offset: z
29
+ .number()
30
+ .int()
31
+ .min(0)
32
+ .optional()
33
+ .describe("Items to skip (default: 0)"),
34
+ }, async ({ category, search, query, limit, offset }) => {
35
+ try {
36
+ let data;
37
+ if (search) {
38
+ data = apiIndex.searchAll(search);
39
+ }
40
+ else if (category) {
41
+ data = apiIndex.listAllByCategory(category);
42
+ }
43
+ else {
44
+ data = apiIndex.listAll();
45
+ }
46
+ if (data.length === 0) {
47
+ return {
48
+ content: [
49
+ { type: "text", text: JSON.stringify({ items: [], _count: 0 }, null, 2) },
50
+ ],
51
+ };
52
+ }
53
+ const defaultQuery = "{ items { method path summary } _count }";
54
+ const effectiveQuery = query ?? defaultQuery;
55
+ const { schema } = getOrBuildSchema(data, "LIST", category ?? search ?? "_all");
56
+ const { data: sliced, truncated, total } = truncateIfArray(data, limit ?? 20, offset);
57
+ const queryResult = await executeQuery(schema, sliced, effectiveQuery);
58
+ if (truncated && typeof queryResult === "object" && queryResult !== null) {
59
+ queryResult._meta = {
60
+ total,
61
+ offset: offset ?? 0,
62
+ limit: limit ?? 20,
63
+ hasMore: true,
64
+ };
65
+ }
66
+ return {
67
+ content: [
68
+ { type: "text", text: JSON.stringify(queryResult, null, 2) },
69
+ ],
70
+ };
71
+ }
72
+ catch (error) {
73
+ const message = error instanceof Error ? error.message : String(error);
74
+ return {
75
+ content: [
76
+ { type: "text", text: JSON.stringify({ error: message }) },
77
+ ],
78
+ isError: true,
79
+ };
80
+ }
81
+ });
82
+ }
@@ -0,0 +1,222 @@
1
+ import { z } from "zod";
2
+ import { formatToolError, attachRateLimit, shrinkageError, placeholderError, } from "./shared.js";
3
+ import { callApi } from "../api-client.js";
4
+ import { getOrBuildSchema, schemaToSDL, executeQuery, computeShapeHash, } from "../graphql-schema.js";
5
+ import { generateSuggestions } from "../query-suggestions.js";
6
+ import { isNonJsonResult } from "../response-parser.js";
7
+ import { storeResponse, loadResponse } from "../data-cache.js";
8
+ import { resolveBody } from "../body-file.js";
9
+ import { detectPlaceholders } from "../body-validation.js";
10
+ import { createBackup } from "../pre-write-backup.js";
11
+ import { extractPathParamNames } from "../pre-write-backup.js";
12
+ import { detectArrayShrinkage } from "../write-safety.js";
13
+ import { applyPatch } from "../json-patch.js";
14
+ export function registerMutateApi({ server, config, apiIndex }) {
15
+ server.tool("mutate_api", `Write data to a ${config.name} API endpoint (POST/PUT/PATCH/DELETE). ` +
16
+ "Two modes:\n" +
17
+ "1. Direct: provide 'body' or 'bodyFile' with the full request payload.\n" +
18
+ "2. Patch: provide 'patch' with JSON Patch operations (add/remove/replace). " +
19
+ "The tool automatically fetches the current resource state, applies your patches, " +
20
+ "and sends the complete result — you never need to hold the full data.\n" +
21
+ "Use inspect_api first to understand the endpoint's parameters and body schema. " +
22
+ "Optionally pass 'query' to select specific fields from the response via GraphQL.", {
23
+ method: z
24
+ .enum(["POST", "PUT", "DELETE", "PATCH"])
25
+ .describe("HTTP method"),
26
+ path: z
27
+ .string()
28
+ .describe("API path template (e.g. '/api/card/{id}')"),
29
+ params: z
30
+ .record(z.unknown())
31
+ .optional()
32
+ .describe("Path and query parameters. Path params like {id} are interpolated; " +
33
+ "remaining become query string."),
34
+ body: z
35
+ .record(z.unknown())
36
+ .optional()
37
+ .describe("Request body (direct mode). Mutually exclusive with 'patch' and 'bodyFile'."),
38
+ bodyFile: z
39
+ .string()
40
+ .optional()
41
+ .describe("Absolute path to a JSON file for the request body (direct mode). " +
42
+ "Mutually exclusive with 'body' and 'patch'."),
43
+ patch: z
44
+ .array(z.object({
45
+ op: z.enum(["add", "remove", "replace"]),
46
+ path: z.string().describe("JSON Pointer path (e.g. '/title', '/panels/3/title', '/tags/-' for append)"),
47
+ value: z.unknown().optional().describe("Value for add/replace operations"),
48
+ }))
49
+ .optional()
50
+ .describe("JSON Patch operations (RFC 6902 subset). The tool GETs the current resource, " +
51
+ "applies these patches, and sends the result. " +
52
+ "Mutually exclusive with 'body' and 'bodyFile'."),
53
+ headers: z
54
+ .record(z.string())
55
+ .optional()
56
+ .describe("Additional HTTP headers. Overrides default --header values."),
57
+ query: z
58
+ .string()
59
+ .optional()
60
+ .describe("Optional GraphQL selection query on the response " +
61
+ "(e.g. '{ id name status }' to select specific fields)."),
62
+ skipBackup: z
63
+ .boolean()
64
+ .optional()
65
+ .describe("Skip the automatic pre-write backup for PUT/PATCH (direct mode only). " +
66
+ "Default: false."),
67
+ }, async ({ method, path, params, body, bodyFile, patch, headers, query, skipBackup }) => {
68
+ try {
69
+ // Validate mutual exclusivity
70
+ const modeCount = [body, bodyFile, patch].filter((x) => x !== undefined).length;
71
+ if (modeCount > 1) {
72
+ return formatToolError(new Error("Only one of 'body', 'bodyFile', or 'patch' can be provided."), apiIndex);
73
+ }
74
+ // --- Patch mode ---
75
+ if (patch !== undefined) {
76
+ return await handlePatchMode({ config, apiIndex }, method, path, params, patch, headers, query);
77
+ }
78
+ // --- Direct mode ---
79
+ let resolvedBody;
80
+ try {
81
+ resolvedBody = resolveBody(body, bodyFile);
82
+ }
83
+ catch (err) {
84
+ return formatToolError(err, apiIndex);
85
+ }
86
+ // Placeholder detection (except DELETE)
87
+ if (resolvedBody && method !== "DELETE") {
88
+ const endpoint = apiIndex.getEndpoint(method, path);
89
+ const warnings = detectPlaceholders(resolvedBody, endpoint?.requestBodySchema);
90
+ if (warnings.length > 0)
91
+ return placeholderError(warnings);
92
+ }
93
+ // Pre-write backup for PUT/PATCH
94
+ let backupDataKey;
95
+ if ((method === "PATCH" || method === "PUT") && !skipBackup) {
96
+ backupDataKey = await createBackup(config, method, path, params, headers);
97
+ }
98
+ // Array shrinkage detection against backup
99
+ if (backupDataKey && resolvedBody) {
100
+ const backupEntry = loadResponse(backupDataKey);
101
+ if (backupEntry) {
102
+ const shrinkWarnings = detectArrayShrinkage(resolvedBody, backupEntry.data);
103
+ if (shrinkWarnings.length > 0)
104
+ return shrinkageError(shrinkWarnings, { backupDataKey });
105
+ }
106
+ }
107
+ // Execute the request
108
+ const { data: rawData, responseHeaders: respHeaders } = await callApi(config, method, path, params, resolvedBody, headers);
109
+ return await buildMutateResponse({ apiIndex }, method, path, rawData, respHeaders, resolvedBody, query, backupDataKey);
110
+ }
111
+ catch (error) {
112
+ return formatToolError(error, apiIndex, method, path);
113
+ }
114
+ });
115
+ }
116
+ /**
117
+ * Patch mode: GET current state, apply patches, send the result.
118
+ */
119
+ async function handlePatchMode({ config, apiIndex }, method, path, params, operations, headers, query) {
120
+ if (operations.length === 0) {
121
+ return formatToolError(new Error("Patch operations array is empty."), apiIndex);
122
+ }
123
+ if (method === "DELETE") {
124
+ return formatToolError(new Error("Patch mode is not supported for DELETE — use direct mode (no body) instead."), apiIndex);
125
+ }
126
+ // Fetch current state (path params only, same as createBackup)
127
+ const pathParamNames = extractPathParamNames(path);
128
+ const pathOnlyParams = params
129
+ ? Object.fromEntries(Object.entries(params).filter(([k]) => pathParamNames.has(k)))
130
+ : undefined;
131
+ let currentData;
132
+ let currentHeaders;
133
+ try {
134
+ const result = await callApi(config, "GET", path, pathOnlyParams && Object.keys(pathOnlyParams).length > 0 ? pathOnlyParams : undefined, undefined, headers);
135
+ currentData = result.data;
136
+ currentHeaders = result.responseHeaders;
137
+ }
138
+ catch (err) {
139
+ return formatToolError(new Error(`Patch mode: failed to GET current state of ${path}: ` +
140
+ (err instanceof Error ? err.message : String(err))), apiIndex);
141
+ }
142
+ // Validate current data is a plain object
143
+ if (typeof currentData !== "object" || currentData === null || Array.isArray(currentData)) {
144
+ return formatToolError(new Error(`Patch mode requires the GET response to be a JSON object, ` +
145
+ `but got ${Array.isArray(currentData) ? "array" : typeof currentData}. ` +
146
+ `Use direct mode (body/bodyFile) instead.`), apiIndex);
147
+ }
148
+ // Store pre-patch state as backup
149
+ const backupDataKey = storeResponse("GET", path, currentData, currentHeaders);
150
+ // Apply patches
151
+ let patchedBody;
152
+ try {
153
+ patchedBody = applyPatch(currentData, operations);
154
+ }
155
+ catch (err) {
156
+ return formatToolError(new Error(`Patch failed: ${err instanceof Error ? err.message : String(err)}`), apiIndex);
157
+ }
158
+ // Placeholder detection on patched result
159
+ const endpoint = apiIndex.getEndpoint(method, path);
160
+ const warnings = detectPlaceholders(patchedBody, endpoint?.requestBodySchema);
161
+ if (warnings.length > 0)
162
+ return placeholderError(warnings);
163
+ // Execute the write with the complete patched body
164
+ const { data: rawData, responseHeaders: respHeaders } = await callApi(config, method, path, params, patchedBody, headers);
165
+ return await buildMutateResponse({ apiIndex }, method, path, rawData, respHeaders, patchedBody, query, backupDataKey, operations.length);
166
+ }
167
+ /**
168
+ * Build the response for mutate_api (shared between direct and patch modes).
169
+ */
170
+ async function buildMutateResponse({ apiIndex }, method, path, rawData, respHeaders, resolvedBody, query, backupDataKey, patchCount) {
171
+ const newDataKey = storeResponse(method, path, rawData, respHeaders);
172
+ // Non-JSON response
173
+ if (isNonJsonResult(rawData)) {
174
+ return {
175
+ content: [
176
+ { type: "text", text: JSON.stringify({
177
+ rawResponse: rawData,
178
+ responseHeaders: respHeaders,
179
+ ...(newDataKey ? { _dataKey: newDataKey } : {}),
180
+ hint: "This endpoint returned a non-JSON response. The raw content is shown above.",
181
+ }, null, 2) },
182
+ ],
183
+ };
184
+ }
185
+ const endpoint = apiIndex.getEndpoint(method, path);
186
+ const bodyHash = resolvedBody ? computeShapeHash(resolvedBody) : undefined;
187
+ const { schema, fromCache } = getOrBuildSchema(rawData, method, path, endpoint?.requestBodySchema, bodyHash);
188
+ // If query provided, apply GraphQL field selection
189
+ let resultData = rawData;
190
+ if (query) {
191
+ resultData = await executeQuery(schema, rawData, query);
192
+ }
193
+ const output = { _status: "COMPLETE" };
194
+ if (query && typeof resultData === "object" && resultData !== null && !Array.isArray(resultData)) {
195
+ Object.assign(output, resultData);
196
+ }
197
+ else {
198
+ output.data = resultData;
199
+ }
200
+ attachRateLimit(output, respHeaders);
201
+ if (!fromCache) {
202
+ output._schema = schemaToSDL(schema);
203
+ const suggestions = generateSuggestions(schema);
204
+ if (suggestions.length > 0) {
205
+ output._suggestedQueries = suggestions;
206
+ }
207
+ }
208
+ if (newDataKey)
209
+ output._dataKey = newDataKey;
210
+ if (backupDataKey) {
211
+ output._backupDataKey = backupDataKey;
212
+ output._backupHint = "Pre-write snapshot stored. Use query_api with this dataKey to retrieve original data if needed.";
213
+ }
214
+ if (patchCount !== undefined) {
215
+ output._patchApplied = `${patchCount} operation(s) applied successfully`;
216
+ }
217
+ return {
218
+ content: [
219
+ { type: "text", text: JSON.stringify(output, null, 2) },
220
+ ],
221
+ };
222
+ }