hostinger-api-mcp 1.63.4 → 2.1.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/CHANGELOG.md +433 -0
- package/README.md +433 -425
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/headless/SKILL.md +5 -5
- package/skills/headless/references/DATABASE.md +15 -15
- package/skills/headless/references/DEPLOYMENT.md +5 -5
- package/skills/headless/references/SETUP.md +10 -10
- package/skills/headless/references/STORE.md +5 -5
- package/skills/headless/references/WORDPRESS.md +6 -6
- package/src/core/catalog.js +183 -0
- package/src/core/catalog.ts +207 -0
- package/src/core/instructions.js +1 -1
- package/src/core/instructions.ts +1 -1
- package/src/core/runtime.js +70 -94
- package/src/core/runtime.ts +61 -96
- package/src/core/tools/agency-hosting.js +44 -44
- package/src/core/tools/agency-hosting.ts +44 -44
- package/src/core/tools/all.js +405 -405
- package/src/core/tools/all.ts +405 -405
- package/src/core/tools/billing.js +9 -9
- package/src/core/tools/billing.ts +9 -9
- package/src/core/tools/dns.js +8 -8
- package/src/core/tools/dns.ts +8 -8
- package/src/core/tools/domains.js +41 -41
- package/src/core/tools/domains.ts +41 -41
- package/src/core/tools/ecommerce.js +29 -29
- package/src/core/tools/ecommerce.ts +29 -29
- package/src/core/tools/horizons.js +6 -6
- package/src/core/tools/horizons.ts +6 -6
- package/src/core/tools/hosting.js +76 -76
- package/src/core/tools/hosting.ts +76 -76
- package/src/core/tools/mail.js +38 -38
- package/src/core/tools/mail.ts +38 -38
- package/src/core/tools/reach.js +52 -52
- package/src/core/tools/reach.ts +52 -52
- package/src/core/tools/vps.js +64 -64
- package/src/core/tools/vps.ts +64 -64
- package/src/core/tools/wordpress.js +38 -38
- package/src/core/tools/wordpress.ts +38 -38
- package/src/servers/agency-hosting.js +1 -1
- package/src/servers/agency-hosting.ts +1 -1
- package/src/servers/all.js +1 -1
- package/src/servers/all.ts +1 -1
- package/src/servers/billing.js +1 -1
- package/src/servers/billing.ts +1 -1
- package/src/servers/dns.js +1 -1
- package/src/servers/dns.ts +1 -1
- package/src/servers/domains.js +1 -1
- package/src/servers/domains.ts +1 -1
- package/src/servers/ecommerce.js +1 -1
- package/src/servers/ecommerce.ts +1 -1
- package/src/servers/horizons.js +1 -1
- package/src/servers/horizons.ts +1 -1
- package/src/servers/hosting.js +1 -1
- package/src/servers/hosting.ts +1 -1
- package/src/servers/mail.js +1 -1
- package/src/servers/mail.ts +1 -1
- package/src/servers/reach.js +1 -1
- package/src/servers/reach.ts +1 -1
- package/src/servers/vps.js +1 -1
- package/src/servers/vps.ts +1 -1
- package/src/servers/wordpress.js +1 -1
- package/src/servers/wordpress.ts +1 -1
- package/types.d.ts +405 -405
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Typed mirror of catalog.js. Keep both files, and api-remote-mcp's PHP
|
|
2
|
+
// implementation, behaviourally identical.
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_SEARCH_LIMIT = 5;
|
|
5
|
+
export const MAX_SEARCH_LIMIT = 20;
|
|
6
|
+
export const MAX_STEPS = 20;
|
|
7
|
+
|
|
8
|
+
const STEP_REF = /^\$steps\.(\d+)((?:\.[^.]+)*)$/;
|
|
9
|
+
|
|
10
|
+
export interface CatalogOperation {
|
|
11
|
+
name: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
group?: string;
|
|
15
|
+
annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean; [key: string]: unknown };
|
|
16
|
+
inputSchema: { type: 'object'; properties?: Record<string, unknown>; required?: string[]; [key: string]: unknown };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface OperationSummary {
|
|
20
|
+
operation: string;
|
|
21
|
+
title: string;
|
|
22
|
+
description: string;
|
|
23
|
+
group: string | null;
|
|
24
|
+
readOnly: boolean;
|
|
25
|
+
destructive: boolean;
|
|
26
|
+
inputSchema: CatalogOperation['inputSchema'];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface StepResult { step: number; operation: string; result: unknown }
|
|
30
|
+
export interface BatchResult { results: StepResult[]; error?: { step: number; message: string } }
|
|
31
|
+
|
|
32
|
+
export class ToolInputError extends Error {
|
|
33
|
+
constructor(message: string) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = 'ToolInputError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const meta = (name: string, title: string, description: string, inputSchema: CatalogOperation['inputSchema'], readOnly: boolean) => ({
|
|
40
|
+
name,
|
|
41
|
+
title,
|
|
42
|
+
description,
|
|
43
|
+
inputSchema,
|
|
44
|
+
annotations: { title, readOnlyHint: readOnly, destructiveHint: !readOnly },
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const META_TOOLS = [
|
|
48
|
+
meta('search', 'Search API operations',
|
|
49
|
+
"Find API operations by keyword. Returns the best matches, each with its operation name, hints and inputSchema. Pass a result's operation and params matching its inputSchema to execute or multi-execute.",
|
|
50
|
+
{
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
query: { type: 'string', description: 'Keywords, e.g. "list vps" or "dns records".' },
|
|
54
|
+
limit: { type: 'integer', minimum: 1, maximum: MAX_SEARCH_LIMIT, default: DEFAULT_SEARCH_LIMIT, description: 'Maximum number of results.' },
|
|
55
|
+
},
|
|
56
|
+
required: ['query'],
|
|
57
|
+
},
|
|
58
|
+
true),
|
|
59
|
+
meta('execute', 'Execute an API operation',
|
|
60
|
+
"Run one API operation found with search. params is a flat object holding the operation's path, query and body parameters as described by its inputSchema.",
|
|
61
|
+
{
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: {
|
|
64
|
+
operation: { type: 'string', description: 'Operation name returned by search.' },
|
|
65
|
+
params: { type: 'object', description: 'Parameters matching the operation inputSchema.' },
|
|
66
|
+
},
|
|
67
|
+
required: ['operation'],
|
|
68
|
+
},
|
|
69
|
+
false),
|
|
70
|
+
meta('multi-execute', 'Execute API operations in sequence',
|
|
71
|
+
'Run up to 20 API operations in order, stopping at the first failure. A params value that is exactly "$steps.<i>.<path>" is replaced by that value from the result of earlier step <i> (dot-separated keys; numbers index arrays), e.g. "$steps.0.0.id".',
|
|
72
|
+
{
|
|
73
|
+
type: 'object',
|
|
74
|
+
properties: {
|
|
75
|
+
steps: {
|
|
76
|
+
type: 'array',
|
|
77
|
+
minItems: 1,
|
|
78
|
+
maxItems: MAX_STEPS,
|
|
79
|
+
items: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
properties: {
|
|
82
|
+
operation: { type: 'string', description: 'Operation name returned by search.' },
|
|
83
|
+
params: { type: 'object', description: 'Parameters; may contain "$steps.<i>.<path>" references.' },
|
|
84
|
+
},
|
|
85
|
+
required: ['operation'],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
required: ['steps'],
|
|
90
|
+
},
|
|
91
|
+
false),
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
const byteOrder = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
95
|
+
|
|
96
|
+
function toSummary(op: CatalogOperation): OperationSummary {
|
|
97
|
+
return {
|
|
98
|
+
operation: op.name,
|
|
99
|
+
title: op.title ?? op.name,
|
|
100
|
+
description: op.description ?? '',
|
|
101
|
+
group: op.group ?? null,
|
|
102
|
+
readOnly: op.annotations?.readOnlyHint === true,
|
|
103
|
+
destructive: op.annotations?.destructiveHint === true,
|
|
104
|
+
inputSchema: op.inputSchema,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function searchOperations(operations: CatalogOperation[], query: unknown, limit: unknown = DEFAULT_SEARCH_LIMIT): OperationSummary[] {
|
|
109
|
+
if (typeof query !== 'string') throw new ToolInputError('query must be a string');
|
|
110
|
+
const tokens = query.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
111
|
+
if (tokens.length === 0) throw new ToolInputError('query must contain at least one letter or digit');
|
|
112
|
+
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > MAX_SEARCH_LIMIT) {
|
|
113
|
+
throw new ToolInputError(`limit must be an integer from 1 to ${MAX_SEARCH_LIMIT}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return operations
|
|
117
|
+
.map(op => {
|
|
118
|
+
const name = op.name.toLowerCase();
|
|
119
|
+
const group = (op.group ?? '').toLowerCase();
|
|
120
|
+
const title = (op.title ?? '').toLowerCase();
|
|
121
|
+
const description = (op.description ?? '').toLowerCase();
|
|
122
|
+
let score = 0;
|
|
123
|
+
for (const t of tokens) {
|
|
124
|
+
if (name.includes(t)) score += 3;
|
|
125
|
+
if (group.includes(t)) score += 2;
|
|
126
|
+
if (title.includes(t)) score += 2;
|
|
127
|
+
if (description.includes(t)) score += 1;
|
|
128
|
+
}
|
|
129
|
+
return { op, score };
|
|
130
|
+
})
|
|
131
|
+
.filter(s => s.score > 0)
|
|
132
|
+
.sort((a, b) => b.score - a.score || byteOrder(a.op.name, b.op.name))
|
|
133
|
+
.slice(0, limit)
|
|
134
|
+
.map(s => toSummary(s.op));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function renderSearch(query: string, results: OperationSummary[]): string {
|
|
138
|
+
return results.length > 0
|
|
139
|
+
? JSON.stringify(results)
|
|
140
|
+
: `No operations matched "${query}". Try broader or different terms.`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function findOperation<T extends CatalogOperation>(operations: Map<string, T>, name: unknown, params: unknown): T {
|
|
144
|
+
const op = typeof name === 'string' ? operations.get(name) : undefined;
|
|
145
|
+
if (!op) {
|
|
146
|
+
throw new ToolInputError(`Unknown operation "${String(name)}". Use search to find operations.`);
|
|
147
|
+
}
|
|
148
|
+
if (params === null || typeof params !== 'object' || Array.isArray(params)) {
|
|
149
|
+
throw new ToolInputError('params must be an object');
|
|
150
|
+
}
|
|
151
|
+
const given = params as Record<string, unknown>;
|
|
152
|
+
const missing = (op.inputSchema?.required ?? []).filter(k => given[k] === undefined || given[k] === null);
|
|
153
|
+
if (missing.length > 0) {
|
|
154
|
+
throw new ToolInputError(`Missing required parameter(s) for ${op.name}: ${missing.join(', ')}`);
|
|
155
|
+
}
|
|
156
|
+
return op;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function resolveRefs(value: unknown, results: unknown[], stepIndex: number): unknown {
|
|
160
|
+
if (typeof value === 'string') {
|
|
161
|
+
const m = STEP_REF.exec(value);
|
|
162
|
+
if (!m) return value;
|
|
163
|
+
const i = Number(m[1]);
|
|
164
|
+
if (i >= stepIndex) {
|
|
165
|
+
throw new ToolInputError(`${value}: references must point to an earlier step`);
|
|
166
|
+
}
|
|
167
|
+
let current: unknown = results[i];
|
|
168
|
+
for (const segment of m[2].split('.').slice(1)) {
|
|
169
|
+
if (current === null || typeof current !== 'object' || !Object.hasOwn(current, segment)) {
|
|
170
|
+
throw new ToolInputError(`${value}: path not found in the result of step ${i}`);
|
|
171
|
+
}
|
|
172
|
+
current = (current as Record<string, unknown>)[segment];
|
|
173
|
+
}
|
|
174
|
+
return current;
|
|
175
|
+
}
|
|
176
|
+
if (Array.isArray(value)) return value.map(v => resolveRefs(v, results, stepIndex));
|
|
177
|
+
if (value !== null && typeof value === 'object') {
|
|
178
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, resolveRefs(v, results, stepIndex)]));
|
|
179
|
+
}
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function runSteps(
|
|
184
|
+
steps: unknown,
|
|
185
|
+
runOne: (operation: string, params: Record<string, unknown>) => Promise<unknown>,
|
|
186
|
+
formatError: (error: unknown) => string,
|
|
187
|
+
): Promise<BatchResult> {
|
|
188
|
+
if (!Array.isArray(steps) || steps.length === 0 || steps.length > MAX_STEPS) {
|
|
189
|
+
throw new ToolInputError(`steps must be an array of 1 to ${MAX_STEPS} items`);
|
|
190
|
+
}
|
|
191
|
+
const raw: unknown[] = [];
|
|
192
|
+
const results: StepResult[] = [];
|
|
193
|
+
for (const [i, step] of steps.entries()) {
|
|
194
|
+
try {
|
|
195
|
+
if (step === null || typeof step !== 'object' || typeof step.operation !== 'string') {
|
|
196
|
+
throw new ToolInputError(`step ${i}: operation must be a string`);
|
|
197
|
+
}
|
|
198
|
+
const params = resolveRefs(step.params ?? {}, raw, i) as Record<string, unknown>;
|
|
199
|
+
const result = await runOne(step.operation, params);
|
|
200
|
+
raw.push(result);
|
|
201
|
+
results.push({ step: i, operation: step.operation, result });
|
|
202
|
+
} catch (error) {
|
|
203
|
+
return { results, error: { step: i, message: formatError(error) } };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { results };
|
|
207
|
+
}
|
package/src/core/instructions.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated server instructions
|
|
2
|
-
export default "Hostinger API notes for agents.
|
|
2
|
+
export default "Hostinger API notes for agents. Find operations with search; the inputSchema it returns is authoritative for request shapes. Run one with execute, or chain several with multi-execute.\n\nDatabases (web hosting, hosting_* operations)\n- MySQL runs on the same server as the website. From the deployed app connect to host 127.0.0.1, port 3306. PHP may also use localhost (Unix socket). Node.js should use 127.0.0.1: localhost can resolve to IPv6 (::1) and not every database user has that grant.\n- The `host` from hosting_databases_list (srvNNNN.hstgr.io) is only for connections from outside Hostinger, after hosting_databases_create-remote-connection. Never use it from the app.\n- Agency Plan (agency-hosting_* operations) database users are created for localhost; the PHP app connects there.\n\nAsynchronous operations\n- A success response often means \"queued\", not \"done\". Poll the matching read operation with backoff (seconds, then minutes). Never re-send the write.\n- hosting_websites_create -> poll hosting_websites_list until the domain appears (a few minutes).\n- hosting_nodejs_start-build -> hosting_nodejs_build; logs via hosting_nodejs_build-logs; on failure hosting_nodejs_analyse-failed-build.\n- wordpress_installations_install and WordPress plugin/theme/core jobs -> poll wordpress_installations_list.\n- agency-hosting_website-setups_create -> poll agency-hosting_website-setups_status with setup_uuid; other Agency Plan jobs -> agency-hosting_websites_list-processes.\n- Website deletion, database repair and domain contact changes finish in the background.\n\nCredentials\n- Never write database passwords or API tokens into source code, client-side JavaScript or committed .env files.\n- Node.js apps read them from environment variables: set the full set with hosting_nodejs_replace-environment-variables (full replace; values are masked on read). Build-time variables (Next.js) need a new build afterwards.\n- PHP apps read them from a config file on the server, outside the repository.\n- Generate strong passwords and do not echo them back once stored.\n\nFor end-to-end site builds install the headless skill: npx skills add hostinger/api-mcp-server";
|
package/src/core/instructions.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Auto-generated server instructions
|
|
2
|
-
const instructions: string = "Hostinger API notes for agents.
|
|
2
|
+
const instructions: string = "Hostinger API notes for agents. Find operations with search; the inputSchema it returns is authoritative for request shapes. Run one with execute, or chain several with multi-execute.\n\nDatabases (web hosting, hosting_* operations)\n- MySQL runs on the same server as the website. From the deployed app connect to host 127.0.0.1, port 3306. PHP may also use localhost (Unix socket). Node.js should use 127.0.0.1: localhost can resolve to IPv6 (::1) and not every database user has that grant.\n- The `host` from hosting_databases_list (srvNNNN.hstgr.io) is only for connections from outside Hostinger, after hosting_databases_create-remote-connection. Never use it from the app.\n- Agency Plan (agency-hosting_* operations) database users are created for localhost; the PHP app connects there.\n\nAsynchronous operations\n- A success response often means \"queued\", not \"done\". Poll the matching read operation with backoff (seconds, then minutes). Never re-send the write.\n- hosting_websites_create -> poll hosting_websites_list until the domain appears (a few minutes).\n- hosting_nodejs_start-build -> hosting_nodejs_build; logs via hosting_nodejs_build-logs; on failure hosting_nodejs_analyse-failed-build.\n- wordpress_installations_install and WordPress plugin/theme/core jobs -> poll wordpress_installations_list.\n- agency-hosting_website-setups_create -> poll agency-hosting_website-setups_status with setup_uuid; other Agency Plan jobs -> agency-hosting_websites_list-processes.\n- Website deletion, database repair and domain contact changes finish in the background.\n\nCredentials\n- Never write database passwords or API tokens into source code, client-side JavaScript or committed .env files.\n- Node.js apps read them from environment variables: set the full set with hosting_nodejs_replace-environment-variables (full replace; values are masked on read). Build-time variables (Next.js) need a new build afterwards.\n- PHP apps read them from a config file on the server, outside the repository.\n- Generate strong passwords and do not echo them back once stored.\n\nFor end-to-end site builds install the headless skill: npx skills add hostinger/api-mcp-server";
|
|
3
3
|
export default instructions;
|
package/src/core/runtime.js
CHANGED
|
@@ -17,6 +17,7 @@ import { OAuthProvider, getEnvToken } from "./oauth.js";
|
|
|
17
17
|
import * as tus from "tus-js-client";
|
|
18
18
|
import fs from "fs";
|
|
19
19
|
import path from "path";
|
|
20
|
+
import { META_TOOLS, searchOperations, renderSearch, findOperation, runSteps } from "./catalog.js";
|
|
20
21
|
|
|
21
22
|
// Load environment variables
|
|
22
23
|
dotenvConfig({ quiet: true });
|
|
@@ -54,7 +55,7 @@ class MCPServer {
|
|
|
54
55
|
this.toolList = tools;
|
|
55
56
|
this.instructions = instructions;
|
|
56
57
|
this.server = null;
|
|
57
|
-
this.
|
|
58
|
+
this.operations = new Map();
|
|
58
59
|
this.debug = process.env.DEBUG === "true";
|
|
59
60
|
this.baseUrl = process.env.API_BASE_URL || "https://developers.hostinger.com";
|
|
60
61
|
this.headers = this.parseHeaders(process.env.API_HEADERS || "");
|
|
@@ -115,20 +116,13 @@ class MCPServer {
|
|
|
115
116
|
* This runs before the server is connected, so don't log here
|
|
116
117
|
*/
|
|
117
118
|
initializeTools() {
|
|
118
|
-
//
|
|
119
|
+
// The operations are the catalog behind search/execute; they are not listed as tools.
|
|
119
120
|
for (const tool of this.toolList) {
|
|
120
|
-
this.
|
|
121
|
-
name: tool.name,
|
|
122
|
-
title: tool.title,
|
|
123
|
-
description: tool.description,
|
|
124
|
-
inputSchema: tool.inputSchema,
|
|
125
|
-
annotations: tool.annotations,
|
|
126
|
-
// Don't include security at the tool level
|
|
127
|
-
});
|
|
121
|
+
this.operations.set(tool.name, tool);
|
|
128
122
|
}
|
|
129
123
|
|
|
130
124
|
// Don't log here, we're not connected yet
|
|
131
|
-
console.error(`
|
|
125
|
+
console.error(`Loaded ${this.operations.size} operations`);
|
|
132
126
|
}
|
|
133
127
|
|
|
134
128
|
/**
|
|
@@ -149,116 +143,98 @@ class MCPServer {
|
|
|
149
143
|
// Handle tool listing requests
|
|
150
144
|
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
151
145
|
this.log('debug', "Handling ListTools request");
|
|
152
|
-
|
|
153
|
-
return {
|
|
154
|
-
tools: Array.from(this.tools.entries()).map(([id, tool]) => ({
|
|
155
|
-
id,
|
|
156
|
-
...tool,
|
|
157
|
-
})),
|
|
158
|
-
};
|
|
146
|
+
return { tools: META_TOOLS };
|
|
159
147
|
});
|
|
160
148
|
|
|
161
|
-
// Handle tool execution requests
|
|
162
149
|
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
163
|
-
const { name
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
let toolName;
|
|
167
|
-
let toolDetails;
|
|
168
|
-
|
|
169
|
-
// Find the requested tool
|
|
170
|
-
for (const [tid, tool] of this.tools.entries()) {
|
|
171
|
-
if (tool.name === name) {
|
|
172
|
-
toolName = name;
|
|
173
|
-
break;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
150
|
+
const { name } = request.params;
|
|
151
|
+
const args = request.params.arguments ?? {};
|
|
152
|
+
this.log('debug', "Handling CallTool request", { name, args });
|
|
176
153
|
|
|
177
154
|
// An unknown tool is a protocol error, not a tool execution error: the
|
|
178
155
|
// model cannot self-correct its way out of a name the server does not
|
|
179
|
-
// have. -32602 is what the spec's example and the SDK's own McpServer
|
|
180
|
-
|
|
181
|
-
if (!toolName) {
|
|
156
|
+
// have. -32602 is what the spec's example and the SDK's own McpServer use.
|
|
157
|
+
if (!META_TOOLS.some(t => t.name === name)) {
|
|
182
158
|
throw new McpError(ErrorCode.InvalidParams, `Tool not found: ${name}`);
|
|
183
159
|
}
|
|
184
160
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
throw new McpError(ErrorCode.InternalError, `Tool details not found for ID: ${toolName}`);
|
|
188
|
-
}
|
|
189
|
-
|
|
161
|
+
// Everything below is a tool execution: failures (bad input, unknown
|
|
162
|
+
// operation, API errors) come back inside the result with isError: true.
|
|
190
163
|
try {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (toolDetails.custom) {
|
|
196
|
-
result = await this.executeCustomTool(toolDetails, params || {});
|
|
197
|
-
} else {
|
|
198
|
-
result = await this.executeApiCall(toolDetails, params || {});
|
|
164
|
+
if (name === 'search') {
|
|
165
|
+
const results = searchOperations(this.toolList, args.query, args.limit ?? undefined);
|
|
166
|
+
return { content: [{ type: "text", text: renderSearch(args.query, results) }] };
|
|
199
167
|
}
|
|
200
168
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
content: [
|
|
204
|
-
{
|
|
205
|
-
type: "text",
|
|
206
|
-
text: JSON.stringify(result)
|
|
207
|
-
}
|
|
208
|
-
]
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
} catch (error) {
|
|
212
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
213
|
-
this.log('error', `Error executing tool ${name}: ${errorMessage}`);
|
|
214
|
-
|
|
215
|
-
// MCP reports tool execution failures (API errors, bad input, business
|
|
216
|
-
// logic) inside the result with isError: true. Throwing here would turn
|
|
217
|
-
// them into JSON-RPC protocol errors instead, which the spec reserves
|
|
218
|
-
// for unknown tools and malformed requests — those are still thrown,
|
|
219
|
-
// above, before this try. The status stays in the text so the model can
|
|
220
|
-
// tell a retryable 429/503 from a 403 it should not retry.
|
|
221
|
-
let errorText = errorMessage;
|
|
222
|
-
if (error instanceof ApiExecutionError) {
|
|
223
|
-
const payload = error.responseData;
|
|
224
|
-
const body = payload === null || payload === undefined || payload === ''
|
|
225
|
-
? errorMessage
|
|
226
|
-
: typeof payload === 'object' ? JSON.stringify(payload) : String(payload);
|
|
227
|
-
errorText = error.status ? `HTTP ${error.status}: ${body}` : body;
|
|
169
|
+
if (name === 'execute') {
|
|
170
|
+
const result = await this.executeOperation(args.operation, args.params ?? {});
|
|
171
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
228
172
|
}
|
|
229
173
|
|
|
174
|
+
const batch = await runSteps(
|
|
175
|
+
args.steps,
|
|
176
|
+
(operation, params) => this.executeOperation(operation, params),
|
|
177
|
+
(error) => this.formatToolError(error),
|
|
178
|
+
);
|
|
230
179
|
return {
|
|
231
|
-
isError: true,
|
|
232
|
-
content: [
|
|
233
|
-
{
|
|
234
|
-
type: "text",
|
|
235
|
-
text: errorText
|
|
236
|
-
}
|
|
237
|
-
]
|
|
180
|
+
...(batch.error ? { isError: true } : {}),
|
|
181
|
+
content: [{ type: "text", text: JSON.stringify(batch) }],
|
|
238
182
|
};
|
|
183
|
+
} catch (error) {
|
|
184
|
+
const errorText = this.formatToolError(error);
|
|
185
|
+
this.log('error', `Error executing ${name}: ${errorText}`);
|
|
186
|
+
return { isError: true, content: [{ type: "text", text: errorText }] };
|
|
239
187
|
}
|
|
240
188
|
});
|
|
241
189
|
}
|
|
242
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Look an operation up in the catalog, check its required params, and run it
|
|
193
|
+
* as either a custom tool or a plain API call.
|
|
194
|
+
*/
|
|
195
|
+
async executeOperation(operation, params) {
|
|
196
|
+
const tool = findOperation(this.operations, operation, params);
|
|
197
|
+
this.log('info', `Executing operation: ${tool.name}`);
|
|
198
|
+
return tool.custom
|
|
199
|
+
? this.executeCustomTool(tool, params)
|
|
200
|
+
: this.executeApiCall(tool, params);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Text for an isError result. The status stays in the text so the model can
|
|
205
|
+
* tell a retryable 429/503 from a 403 it should not retry.
|
|
206
|
+
*/
|
|
207
|
+
formatToolError(error) {
|
|
208
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
209
|
+
if (!(error instanceof ApiExecutionError)) {
|
|
210
|
+
return errorMessage;
|
|
211
|
+
}
|
|
212
|
+
const payload = error.responseData;
|
|
213
|
+
const body = payload === null || payload === undefined || payload === ''
|
|
214
|
+
? errorMessage
|
|
215
|
+
: typeof payload === 'object' ? JSON.stringify(payload) : String(payload);
|
|
216
|
+
return error.status ? `HTTP ${error.status}: ${body}` : body;
|
|
217
|
+
}
|
|
218
|
+
|
|
243
219
|
async executeCustomTool(tool, params) {
|
|
244
220
|
switch (tool.name) {
|
|
245
|
-
case 'agency-
|
|
221
|
+
case 'agency-hosting_deploy-node-static-website':
|
|
246
222
|
return await this.handleNodeStaticDeploy(params);
|
|
247
|
-
case 'agency-
|
|
223
|
+
case 'agency-hosting_deploy-php-application':
|
|
248
224
|
return await this.handlePhpAppDeploy(params);
|
|
249
|
-
case '
|
|
225
|
+
case 'hosting_import-wordpress-website':
|
|
250
226
|
return await this.handleWordpressWebsiteImport(params);
|
|
251
|
-
case '
|
|
227
|
+
case 'hosting_deploy-wordpress-plugin':
|
|
252
228
|
return await this.handleWordpressPluginDeploy(params);
|
|
253
|
-
case '
|
|
229
|
+
case 'hosting_deploy-wordpress-theme':
|
|
254
230
|
return await this.handleWordpressThemeDeploy(params);
|
|
255
|
-
case '
|
|
231
|
+
case 'hosting_deploy-js-application':
|
|
256
232
|
return await this.handleJavascriptApplicationDeploy(params);
|
|
257
|
-
case '
|
|
233
|
+
case 'hosting_deploy-static-website':
|
|
258
234
|
return await this.handleStaticWebsiteDeploy(params);
|
|
259
|
-
case '
|
|
235
|
+
case 'hosting_list-js-deployments':
|
|
260
236
|
return await this.handleListJavascriptDeployments(params);
|
|
261
|
-
case '
|
|
237
|
+
case 'hosting_show-js-deployment-logs':
|
|
262
238
|
return await this.handleShowJsDeploymentLogs(params);
|
|
263
239
|
default:
|
|
264
240
|
throw new Error(`Unknown custom tool: ${tool.name}`);
|
|
@@ -2625,7 +2601,7 @@ class MCPServer {
|
|
|
2625
2601
|
|
|
2626
2602
|
// Start the server
|
|
2627
2603
|
const server = app.listen(port, host, () => {
|
|
2628
|
-
this.log('info', `MCP Server with HTTP streaming transport started successfully with ${
|
|
2604
|
+
this.log('info', `MCP Server with HTTP streaming transport started successfully with ${META_TOOLS.length} tools`);
|
|
2629
2605
|
this.log('info', `Listening on http://${host}:${port}`);
|
|
2630
2606
|
});
|
|
2631
2607
|
|
|
@@ -2651,8 +2627,8 @@ class MCPServer {
|
|
|
2651
2627
|
await this.server.connect(transport);
|
|
2652
2628
|
|
|
2653
2629
|
// Now we can safely log via MCP
|
|
2654
|
-
console.error(`Registered ${
|
|
2655
|
-
this.log('info', `MCP Server with stdio transport started successfully with ${
|
|
2630
|
+
console.error(`Registered ${META_TOOLS.length} tools`);
|
|
2631
|
+
this.log('info', `MCP Server with stdio transport started successfully with ${META_TOOLS.length} tools`);
|
|
2656
2632
|
} catch (error) {
|
|
2657
2633
|
console.error("Failed to start MCP server:", error);
|
|
2658
2634
|
process.exit(1);
|