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.
- package/README.md +3 -2
- package/build/data-cache.js +5 -1
- package/build/error-context.js +1 -1
- package/build/graphql-schema.js +61 -6
- package/build/index.js +13 -699
- package/build/json-patch.js +137 -0
- package/build/oauth.js +0 -3
- package/build/pagination.js +1 -1
- package/build/pre-write-backup.js +19 -1
- package/build/token-budget.js +88 -45
- package/build/tools/auth.js +117 -0
- package/build/tools/call-api.js +204 -0
- package/build/tools/explain-api.js +86 -0
- package/build/tools/inspect-api.js +213 -0
- package/build/tools/list-api.js +82 -0
- package/build/tools/mutate-api.js +222 -0
- package/build/tools/query-api.js +183 -0
- package/build/tools/shared.js +65 -0
- package/build/write-safety.js +56 -0
- package/package.json +1 -1
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { WRITE_METHODS, formatToolError, attachRateLimit, shrinkageError, placeholderError, } from "./shared.js";
|
|
3
|
+
import { callApi } from "../api-client.js";
|
|
4
|
+
import { getOrBuildSchema, schemaToSDL, computeShapeHash, 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, loadResponse } from "../data-cache.js";
|
|
9
|
+
import { resolveBody } from "../body-file.js";
|
|
10
|
+
import { detectPlaceholders } from "../body-validation.js";
|
|
11
|
+
import { createBackup } from "../pre-write-backup.js";
|
|
12
|
+
import { detectArrayShrinkage } from "../write-safety.js";
|
|
13
|
+
export function registerCallApi({ server, config, apiIndex }) {
|
|
14
|
+
server.tool("call_api", `Inspect a ${config.name} API endpoint. Makes a real request and returns ONLY the ` +
|
|
15
|
+
"inferred GraphQL schema (SDL) showing all available fields and their types. " +
|
|
16
|
+
"No response data is returned — use query_api to fetch actual data. " +
|
|
17
|
+
"IMPORTANT: Read the returned schema carefully. The root field names in the schema " +
|
|
18
|
+
"are what you must use in query_api — do NOT assume generic names like 'items'. " +
|
|
19
|
+
"For example, if the schema shows 'products: [Product]', query as '{ products { id name } }', not '{ items { id name } }'. " +
|
|
20
|
+
"Also returns accepted parameters (name, location, required) from the API spec, " +
|
|
21
|
+
"and suggestedQueries with ready-to-use GraphQL queries. " +
|
|
22
|
+
"Returns a dataKey — pass it to query_api to reuse the cached response (zero HTTP calls). " +
|
|
23
|
+
"Use list_api first to discover endpoints.", {
|
|
24
|
+
method: z
|
|
25
|
+
.enum(["GET", "POST", "PUT", "DELETE", "PATCH"])
|
|
26
|
+
.describe("HTTP method"),
|
|
27
|
+
path: z
|
|
28
|
+
.string()
|
|
29
|
+
.describe("API path template (e.g. '/api/card/{id}'). Use list_api to discover paths."),
|
|
30
|
+
params: z
|
|
31
|
+
.record(z.unknown())
|
|
32
|
+
.optional()
|
|
33
|
+
.describe("Path and query parameters as key-value pairs. " +
|
|
34
|
+
"Path params like {id} are interpolated; remaining become query string for GET."),
|
|
35
|
+
body: z
|
|
36
|
+
.record(z.unknown())
|
|
37
|
+
.optional()
|
|
38
|
+
.describe("Request body for POST/PUT/PATCH"),
|
|
39
|
+
bodyFile: z
|
|
40
|
+
.string()
|
|
41
|
+
.optional()
|
|
42
|
+
.describe("Absolute path to a JSON file to use as request body. " +
|
|
43
|
+
"Mutually exclusive with 'body'. Use for large payloads that cannot be inlined."),
|
|
44
|
+
headers: z
|
|
45
|
+
.record(z.string())
|
|
46
|
+
.optional()
|
|
47
|
+
.describe("Additional HTTP headers for this request (e.g. { \"Authorization\": \"Bearer <token>\" }). " +
|
|
48
|
+
"Overrides default --header values."),
|
|
49
|
+
skipBackup: z
|
|
50
|
+
.boolean()
|
|
51
|
+
.optional()
|
|
52
|
+
.describe("Skip the automatic pre-write backup for PUT/PATCH requests. " +
|
|
53
|
+
"Default: false (backup is created automatically)."),
|
|
54
|
+
}, async ({ method, path, params, body, bodyFile, headers, skipBackup }) => {
|
|
55
|
+
try {
|
|
56
|
+
// Resolve body from inline or file
|
|
57
|
+
let resolvedBody;
|
|
58
|
+
try {
|
|
59
|
+
resolvedBody = resolveBody(body, bodyFile);
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
return formatToolError(err, apiIndex);
|
|
63
|
+
}
|
|
64
|
+
// Placeholder detection for write methods (except DELETE)
|
|
65
|
+
if (resolvedBody && WRITE_METHODS.has(method) && method !== "DELETE") {
|
|
66
|
+
const endpoint = apiIndex.getEndpoint(method, path);
|
|
67
|
+
const warnings = detectPlaceholders(resolvedBody, endpoint?.requestBodySchema);
|
|
68
|
+
if (warnings.length > 0)
|
|
69
|
+
return placeholderError(warnings);
|
|
70
|
+
}
|
|
71
|
+
// Pre-write backup for PUT/PATCH
|
|
72
|
+
let backupDataKey;
|
|
73
|
+
if ((method === "PATCH" || method === "PUT") && !skipBackup) {
|
|
74
|
+
backupDataKey = await createBackup(config, method, path, params, headers);
|
|
75
|
+
}
|
|
76
|
+
// Array shrinkage detection: compare body against backup
|
|
77
|
+
if (backupDataKey && resolvedBody) {
|
|
78
|
+
const backupEntry = loadResponse(backupDataKey);
|
|
79
|
+
if (backupEntry) {
|
|
80
|
+
const shrinkWarnings = detectArrayShrinkage(resolvedBody, backupEntry.data);
|
|
81
|
+
if (shrinkWarnings.length > 0)
|
|
82
|
+
return shrinkageError(shrinkWarnings, { backupDataKey });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const { data, responseHeaders: respHeaders } = await callApi(config, method, path, params, resolvedBody, headers);
|
|
86
|
+
const dataKey = storeResponse(method, path, data, respHeaders);
|
|
87
|
+
// Non-JSON response — skip GraphQL layer, return raw parsed data
|
|
88
|
+
if (isNonJsonResult(data)) {
|
|
89
|
+
return {
|
|
90
|
+
content: [
|
|
91
|
+
{ type: "text", text: JSON.stringify({
|
|
92
|
+
rawResponse: data,
|
|
93
|
+
responseHeaders: respHeaders,
|
|
94
|
+
dataKey,
|
|
95
|
+
hint: "This endpoint returned a non-JSON response. The raw parsed content is shown above. " +
|
|
96
|
+
"GraphQL schema inference is not available for non-JSON responses — use the data directly.",
|
|
97
|
+
}, null, 2) },
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const endpoint = apiIndex.getEndpoint(method, path);
|
|
102
|
+
const bodyHash = WRITE_METHODS.has(method) && resolvedBody ? computeShapeHash(resolvedBody) : undefined;
|
|
103
|
+
const { schema, shapeHash } = getOrBuildSchema(data, method, path, endpoint?.requestBodySchema, bodyHash);
|
|
104
|
+
const sdl = schemaToSDL(schema);
|
|
105
|
+
const result = { graphqlSchema: sdl, shapeHash, responseHeaders: respHeaders };
|
|
106
|
+
if (dataKey)
|
|
107
|
+
result.dataKey = dataKey;
|
|
108
|
+
if (bodyHash)
|
|
109
|
+
result.bodyHash = bodyHash;
|
|
110
|
+
if (backupDataKey) {
|
|
111
|
+
result.backupDataKey = backupDataKey;
|
|
112
|
+
result.backupHint = "Pre-write snapshot stored. Use query_api with this dataKey to retrieve original data if needed.";
|
|
113
|
+
}
|
|
114
|
+
attachRateLimit(result, respHeaders);
|
|
115
|
+
if (endpoint && endpoint.parameters.length > 0) {
|
|
116
|
+
result.parameters = endpoint.parameters.map((p) => ({
|
|
117
|
+
name: p.name,
|
|
118
|
+
in: p.in,
|
|
119
|
+
required: p.required,
|
|
120
|
+
...(p.description ? { description: p.description } : {}),
|
|
121
|
+
...(p.in === "query" && PAGINATION_PARAM_NAMES.has(p.name) ? { pagination: true } : {}),
|
|
122
|
+
}));
|
|
123
|
+
const paginationParams = endpoint.parameters
|
|
124
|
+
.filter((p) => p.in === "query" && PAGINATION_PARAM_NAMES.has(p.name))
|
|
125
|
+
.map((p) => p.name);
|
|
126
|
+
if (paginationParams.length > 0) {
|
|
127
|
+
result.paginationParams = paginationParams;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Smart query suggestions
|
|
131
|
+
const suggestions = generateSuggestions(schema);
|
|
132
|
+
if (suggestions.length > 0) {
|
|
133
|
+
result.suggestedQueries = suggestions;
|
|
134
|
+
}
|
|
135
|
+
// Per-field token costs
|
|
136
|
+
const fieldCosts = computeFieldCosts(data);
|
|
137
|
+
result.fieldTokenCosts = fieldCosts;
|
|
138
|
+
// Actual array lengths for truncation awareness
|
|
139
|
+
const arrayLengths = collectArrayLengths(data);
|
|
140
|
+
if (Object.keys(arrayLengths).length > 0) {
|
|
141
|
+
result.fieldArrayLengths = arrayLengths;
|
|
142
|
+
}
|
|
143
|
+
// Budget examples
|
|
144
|
+
const allFieldsCost = fieldCosts._total;
|
|
145
|
+
if (Array.isArray(data) && data.length > 0 && fieldCosts._perItem) {
|
|
146
|
+
const perItem = fieldCosts._perItem;
|
|
147
|
+
result.budgetExamples = [
|
|
148
|
+
`All fields: ~${perItem} tokens/item, ~${allFieldsCost} tokens total`,
|
|
149
|
+
];
|
|
150
|
+
}
|
|
151
|
+
else if (typeof data === "object" && data !== null) {
|
|
152
|
+
result.budgetExamples = [
|
|
153
|
+
`All fields: ~${allFieldsCost} tokens total`,
|
|
154
|
+
];
|
|
155
|
+
}
|
|
156
|
+
// Flag JSON scalar fields so the AI knows which fields are opaque
|
|
157
|
+
const jsonFields = collectJsonFields(schema);
|
|
158
|
+
if (jsonFields.length > 0) {
|
|
159
|
+
result.jsonFields = jsonFields;
|
|
160
|
+
result.jsonFieldsHint =
|
|
161
|
+
"These fields contain heterogeneous or deeply nested data that cannot be queried " +
|
|
162
|
+
"with GraphQL field selection. Query them as-is and parse the returned JSON directly.";
|
|
163
|
+
}
|
|
164
|
+
// Pagination detection from response data
|
|
165
|
+
const pagination = detectPagination(data, endpoint?.parameters);
|
|
166
|
+
if (pagination) {
|
|
167
|
+
result._pagination = pagination;
|
|
168
|
+
}
|
|
169
|
+
const paginationParamsList = result.paginationParams ?? [];
|
|
170
|
+
const paginationSuffix = paginationParamsList.length > 0
|
|
171
|
+
? ` This API supports pagination via: ${paginationParamsList.join(", ")}. Pass these inside params.`
|
|
172
|
+
: "";
|
|
173
|
+
if (Array.isArray(data)) {
|
|
174
|
+
result.totalItems = data.length;
|
|
175
|
+
result.hint =
|
|
176
|
+
"Use query_api with field names from the schema above. " +
|
|
177
|
+
"For raw arrays: '{ items { ... } _count }'. " +
|
|
178
|
+
"For paginated APIs, pass limit/offset inside params (as query string parameters to the API), " +
|
|
179
|
+
"NOT as top-level tool parameters. " +
|
|
180
|
+
"Use fieldTokenCosts to understand per-field token costs and select fields wisely. " +
|
|
181
|
+
"Responses over ~10k tokens require maxTokens (to truncate) or unlimited: true (for full data)." +
|
|
182
|
+
paginationSuffix;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
result.hint =
|
|
186
|
+
"Use query_api with the exact root field names from the schema above (e.g. if schema shows " +
|
|
187
|
+
"'products: [Product]', query as '{ products { id name } }' — do NOT use '{ items { ... } }'). " +
|
|
188
|
+
"For paginated APIs, pass limit/offset inside params (as query string parameters to the API), " +
|
|
189
|
+
"NOT as top-level tool parameters. " +
|
|
190
|
+
"Use fieldTokenCosts to understand per-field token costs and select fields wisely. " +
|
|
191
|
+
"Responses over ~10k tokens require maxTokens (to truncate) or unlimited: true (for full data)." +
|
|
192
|
+
paginationSuffix;
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
content: [
|
|
196
|
+
{ type: "text", text: JSON.stringify(result, null, 2) },
|
|
197
|
+
],
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
return formatToolError(error, apiIndex, method, path);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export function registerExplainApi({ server, config, apiIndex }) {
|
|
3
|
+
server.tool("explain_api", `Get detailed documentation for a ${config.name} API endpoint from the spec. ` +
|
|
4
|
+
"Returns all available spec information — summary, description, parameters, " +
|
|
5
|
+
"request body schema, response codes, deprecation status — without making any HTTP request. " +
|
|
6
|
+
"Use list_api first to discover endpoints, then explain_api to understand them before calling.", {
|
|
7
|
+
method: z
|
|
8
|
+
.enum(["GET", "POST", "PUT", "DELETE", "PATCH"])
|
|
9
|
+
.describe("HTTP method"),
|
|
10
|
+
path: z
|
|
11
|
+
.string()
|
|
12
|
+
.describe("API path template (e.g. '/api/card/{id}')"),
|
|
13
|
+
}, async ({ method, path }) => {
|
|
14
|
+
try {
|
|
15
|
+
const endpoint = apiIndex.getEndpoint(method, path);
|
|
16
|
+
if (!endpoint) {
|
|
17
|
+
return {
|
|
18
|
+
content: [
|
|
19
|
+
{
|
|
20
|
+
type: "text",
|
|
21
|
+
text: JSON.stringify({
|
|
22
|
+
error: `Endpoint not found: ${method} ${path}`,
|
|
23
|
+
hint: "Use list_api to discover available endpoints.",
|
|
24
|
+
}),
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
isError: true,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const result = {
|
|
31
|
+
method: endpoint.method,
|
|
32
|
+
path: endpoint.path,
|
|
33
|
+
summary: endpoint.summary,
|
|
34
|
+
};
|
|
35
|
+
if (endpoint.description) {
|
|
36
|
+
result.description = endpoint.description;
|
|
37
|
+
}
|
|
38
|
+
if (endpoint.operationId) {
|
|
39
|
+
result.operationId = endpoint.operationId;
|
|
40
|
+
}
|
|
41
|
+
if (endpoint.deprecated) {
|
|
42
|
+
result.deprecated = true;
|
|
43
|
+
}
|
|
44
|
+
result.tag = endpoint.tag;
|
|
45
|
+
if (endpoint.parameters.length > 0) {
|
|
46
|
+
result.parameters = endpoint.parameters.map((p) => ({
|
|
47
|
+
name: p.name,
|
|
48
|
+
in: p.in,
|
|
49
|
+
required: p.required,
|
|
50
|
+
...(p.description ? { description: p.description } : {}),
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
if (endpoint.hasRequestBody) {
|
|
54
|
+
const bodyInfo = {};
|
|
55
|
+
if (endpoint.requestBodyDescription) {
|
|
56
|
+
bodyInfo.description = endpoint.requestBodyDescription;
|
|
57
|
+
}
|
|
58
|
+
if (endpoint.requestBodySchema) {
|
|
59
|
+
bodyInfo.contentType = endpoint.requestBodySchema.contentType;
|
|
60
|
+
bodyInfo.properties = endpoint.requestBodySchema.properties;
|
|
61
|
+
}
|
|
62
|
+
result.requestBody = bodyInfo;
|
|
63
|
+
}
|
|
64
|
+
if (endpoint.responses && endpoint.responses.length > 0) {
|
|
65
|
+
result.responses = endpoint.responses;
|
|
66
|
+
}
|
|
67
|
+
if (endpoint.externalDocs) {
|
|
68
|
+
result.externalDocs = endpoint.externalDocs;
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
content: [
|
|
72
|
+
{ type: "text", text: JSON.stringify(result, null, 2) },
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
78
|
+
return {
|
|
79
|
+
content: [
|
|
80
|
+
{ type: "text", text: JSON.stringify({ error: message }) },
|
|
81
|
+
],
|
|
82
|
+
isError: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -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
|
+
}
|