@edgegap/mcp 0.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/LICENSE +201 -0
- package/README.md +178 -0
- package/dist/auth.js +228 -0
- package/dist/client.js +115 -0
- package/dist/config.js +63 -0
- package/dist/index.js +70 -0
- package/dist/tools.js +485 -0
- package/package.json +52 -0
package/dist/tools.js
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ten golden-path tools.
|
|
3
|
+
*
|
|
4
|
+
* Tool descriptions are written for a coding agent, not a human reading docs.
|
|
5
|
+
* Each one says when to reach for it and what to call next, because the main
|
|
6
|
+
* failure mode in agent flows is not a bad call, it is a call made out of order.
|
|
7
|
+
*/
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { EdgegapApiError } from './client.js';
|
|
10
|
+
import { assertAppAllowed, redact } from './config.js';
|
|
11
|
+
import { TokenUnavailableError } from './auth.js';
|
|
12
|
+
/** 1x1 transparent PNG. The create-app endpoint requires an image and agents
|
|
13
|
+
* have no sensible one to supply; a placeholder beats a blocked flow. */
|
|
14
|
+
const PLACEHOLDER_IMAGE = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
|
|
15
|
+
const TERMINAL_OK = ['READY', 'STATUS_READY'];
|
|
16
|
+
const TERMINAL_BAD = ['ERROR', 'STATUS_ERROR', 'TERMINATED', 'STATUS_TERMINATED'];
|
|
17
|
+
function ok(payload, notice) {
|
|
18
|
+
const body = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
|
|
19
|
+
return {
|
|
20
|
+
content: [{ type: 'text', text: notice ? `${body}\n\nNOTE: ${notice}` : body }],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function fail(message) {
|
|
24
|
+
return { content: [{ type: 'text', text: message }], isError: true };
|
|
25
|
+
}
|
|
26
|
+
/** Wraps a handler so API errors come back as readable, self-correctable text
|
|
27
|
+
* rather than a protocol-level exception the agent cannot act on. */
|
|
28
|
+
function guard(auth, fn) {
|
|
29
|
+
const handled = fn().catch((err) => {
|
|
30
|
+
if (err instanceof TokenUnavailableError) {
|
|
31
|
+
// Not a failure to correct — a decision the human made. Say so plainly
|
|
32
|
+
// so the agent stops rather than looping on the same tool.
|
|
33
|
+
return fail(err.message);
|
|
34
|
+
}
|
|
35
|
+
if (err instanceof EdgegapApiError) {
|
|
36
|
+
const hint = errorHint(err);
|
|
37
|
+
return fail(`${err.message}${hint ? `\n\nLikely cause: ${hint}` : ''}`);
|
|
38
|
+
}
|
|
39
|
+
return fail(redact(err.message ?? String(err), auth.current));
|
|
40
|
+
});
|
|
41
|
+
// The org-wide-scope reminder is attached to whichever call first used a
|
|
42
|
+
// freshly supplied token, success or failure. A developer who just handed
|
|
43
|
+
// over a credential should hear about its blast radius even if the call
|
|
44
|
+
// they were making went on to fail.
|
|
45
|
+
return handled.then((result) => {
|
|
46
|
+
const notice = auth.current ? auth.consumeFirstUseNotice() : undefined;
|
|
47
|
+
if (!notice)
|
|
48
|
+
return result;
|
|
49
|
+
return {
|
|
50
|
+
...result,
|
|
51
|
+
content: [...result.content, { type: 'text', text: `NOTE: ${notice}` }],
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function errorHint(err) {
|
|
56
|
+
switch (err.status) {
|
|
57
|
+
case 401:
|
|
58
|
+
return 'the token was rejected. It has been discarded; the next call will ask for a new one.';
|
|
59
|
+
case 404:
|
|
60
|
+
return 'the application or version name does not exist. Call edgegap_list_apps first.';
|
|
61
|
+
case 409:
|
|
62
|
+
return 'a resource with that name already exists. Pick a different name or reuse the existing one.';
|
|
63
|
+
case 422:
|
|
64
|
+
return 'Edgegap could not allocate a server for the requested location or resources. Try different user coordinates, or lower req_cpu/req_memory.';
|
|
65
|
+
case 424:
|
|
66
|
+
return 'Edgegap could not pull the container image. Check docker_repository, docker_image, docker_tag, and registry credentials.';
|
|
67
|
+
default:
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Builds the count fields for a paginated list.
|
|
73
|
+
*
|
|
74
|
+
* The API's total_count is the total across all pages, not the number of rows
|
|
75
|
+
* returned. Reporting it alone makes a truncated page look complete, and an
|
|
76
|
+
* agent that believes it has the full list will confidently tell a developer
|
|
77
|
+
* their application does not exist. When the page is short, say so and say
|
|
78
|
+
* what to do about it.
|
|
79
|
+
*/
|
|
80
|
+
function pageInfo(totalCount, returned, page) {
|
|
81
|
+
const total = totalCount ?? returned;
|
|
82
|
+
if (total <= returned)
|
|
83
|
+
return { total };
|
|
84
|
+
return {
|
|
85
|
+
total,
|
|
86
|
+
showing: returned,
|
|
87
|
+
truncated: true,
|
|
88
|
+
next_step: `Only page ${page} is shown. Call again with page: ${page + 1} ` +
|
|
89
|
+
`(or raise limit) to see the rest before concluding something is missing.`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/** Trims a deployment status down to what an agent needs to act. */
|
|
93
|
+
function compactDeployment(d) {
|
|
94
|
+
const ports = Object.entries(d.ports ?? {}).map(([key, p]) => ({
|
|
95
|
+
name: p.name ?? key,
|
|
96
|
+
connect: p.link,
|
|
97
|
+
external: p.external,
|
|
98
|
+
internal: p.internal,
|
|
99
|
+
protocol: p.protocol,
|
|
100
|
+
}));
|
|
101
|
+
return {
|
|
102
|
+
request_id: d.request_id,
|
|
103
|
+
status: d.current_status,
|
|
104
|
+
running: d.running,
|
|
105
|
+
error: d.error || undefined,
|
|
106
|
+
error_detail: d.error_detail || undefined,
|
|
107
|
+
fqdn: d.fqdn,
|
|
108
|
+
public_ip: d.public_ip,
|
|
109
|
+
application: d.app_name,
|
|
110
|
+
version: d.app_version,
|
|
111
|
+
location: d.location
|
|
112
|
+
? [d.location.city, d.location.country].filter(Boolean).join(', ')
|
|
113
|
+
: undefined,
|
|
114
|
+
elapsed_seconds: d.elapsed_time,
|
|
115
|
+
ports,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const userSchema = z
|
|
119
|
+
.object({
|
|
120
|
+
ip_addresses: z
|
|
121
|
+
.array(z.string())
|
|
122
|
+
.optional()
|
|
123
|
+
.describe('Public IPv4/IPv6 addresses of the players. Edgegap places the server near them.'),
|
|
124
|
+
geo_coordinates: z
|
|
125
|
+
.array(z.object({ latitude: z.number(), longitude: z.number() }))
|
|
126
|
+
.optional()
|
|
127
|
+
.describe('Latitude/longitude pairs, as an alternative to IP addresses.'),
|
|
128
|
+
})
|
|
129
|
+
.describe('Where the players are. Exactly one of these two is required.');
|
|
130
|
+
function buildUsers(input) {
|
|
131
|
+
const users = [];
|
|
132
|
+
for (const ip of input.ip_addresses ?? []) {
|
|
133
|
+
users.push({ user_type: 'ip_address', user_data: { ip_address: ip } });
|
|
134
|
+
}
|
|
135
|
+
for (const geo of input.geo_coordinates ?? []) {
|
|
136
|
+
users.push({
|
|
137
|
+
user_type: 'geo_coordinates',
|
|
138
|
+
user_data: { latitude: geo.latitude, longitude: geo.longitude },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return users;
|
|
142
|
+
}
|
|
143
|
+
export function registerTools(server, client, config, auth) {
|
|
144
|
+
const mutating = !config.readOnly;
|
|
145
|
+
// ---------------------------------------------------------------- 1 ----
|
|
146
|
+
server.registerTool('edgegap_list_apps', {
|
|
147
|
+
title: 'List Edgegap applications',
|
|
148
|
+
description: 'List the applications in the Edgegap organization. Start here before creating or ' +
|
|
149
|
+
'deploying anything, so you reuse an existing application instead of making a duplicate. ' +
|
|
150
|
+
'An "application" groups versions of one game server.',
|
|
151
|
+
inputSchema: {
|
|
152
|
+
limit: z.number().int().min(1).max(100).optional().describe('Results per page. Default 50.'),
|
|
153
|
+
page: z.number().int().min(1).optional(),
|
|
154
|
+
},
|
|
155
|
+
}, async ({ limit, page }) => guard(auth, async () => {
|
|
156
|
+
const res = await client.listApps({ limit: limit ?? 50, page });
|
|
157
|
+
const apps = (res.applications ?? []).map((a) => ({
|
|
158
|
+
name: a.name,
|
|
159
|
+
active: a.is_active,
|
|
160
|
+
last_updated: a.last_updated,
|
|
161
|
+
}));
|
|
162
|
+
return ok({ ...pageInfo(res.total_count, apps.length, page ?? 1), applications: apps });
|
|
163
|
+
}));
|
|
164
|
+
// ---------------------------------------------------------------- 2 ----
|
|
165
|
+
if (mutating) {
|
|
166
|
+
server.registerTool('edgegap_create_app', {
|
|
167
|
+
title: 'Create an Edgegap application',
|
|
168
|
+
description: 'Create a new application to hold game server versions. Only call this after ' +
|
|
169
|
+
'edgegap_list_apps confirms no suitable application exists. Creating an application ' +
|
|
170
|
+
'does not deploy anything — follow with edgegap_create_app_version.',
|
|
171
|
+
inputSchema: {
|
|
172
|
+
name: z
|
|
173
|
+
.string()
|
|
174
|
+
.min(3)
|
|
175
|
+
.max(64)
|
|
176
|
+
.describe('Application name, 3-64 chars. Usually the game or project name.'),
|
|
177
|
+
is_active: z.boolean().optional().describe('Whether deployments are allowed. Default true.'),
|
|
178
|
+
},
|
|
179
|
+
}, async ({ name, is_active }) => guard(auth, async () => {
|
|
180
|
+
assertAppAllowed(config, name);
|
|
181
|
+
const res = await client.createApp({
|
|
182
|
+
name,
|
|
183
|
+
is_active: is_active ?? true,
|
|
184
|
+
image: PLACEHOLDER_IMAGE,
|
|
185
|
+
});
|
|
186
|
+
return ok({
|
|
187
|
+
created: res.name,
|
|
188
|
+
active: res.is_active,
|
|
189
|
+
next_step: 'Call edgegap_create_app_version to attach a container image.',
|
|
190
|
+
});
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
// ---------------------------------------------------------------- 3 ----
|
|
194
|
+
server.registerTool('edgegap_list_app_versions', {
|
|
195
|
+
title: 'List versions of an application',
|
|
196
|
+
description: 'List the versions under an application, with their container image and resource ' +
|
|
197
|
+
'settings. Use this to find the version name to deploy, or to copy settings from a ' +
|
|
198
|
+
'working version when creating a new one.',
|
|
199
|
+
inputSchema: {
|
|
200
|
+
application: z.string().describe('Application name, as returned by edgegap_list_apps.'),
|
|
201
|
+
},
|
|
202
|
+
}, async ({ application }) => guard(auth, async () => {
|
|
203
|
+
assertAppAllowed(config, application);
|
|
204
|
+
const res = await client.listAppVersions(application);
|
|
205
|
+
const versions = (res.versions ?? []).map((v) => ({
|
|
206
|
+
name: v.name,
|
|
207
|
+
active: v.is_active,
|
|
208
|
+
image: [v.docker_repository, v.docker_image, v.docker_tag].filter(Boolean).join('/'),
|
|
209
|
+
cpu_units: v.req_cpu,
|
|
210
|
+
memory_mb: v.req_memory,
|
|
211
|
+
max_duration_minutes: v.max_duration,
|
|
212
|
+
ports: v.ports?.map((p) => `${p.name ?? 'port'}:${p.port}/${p.protocol}`),
|
|
213
|
+
}));
|
|
214
|
+
return ok({ application, ...pageInfo(res.total_count, versions.length, 1), versions });
|
|
215
|
+
}));
|
|
216
|
+
// ---------------------------------------------------------------- 4 ----
|
|
217
|
+
if (mutating) {
|
|
218
|
+
server.registerTool('edgegap_create_app_version', {
|
|
219
|
+
title: 'Create an application version',
|
|
220
|
+
description: 'Register a container image as a deployable version of an application. The image must ' +
|
|
221
|
+
'already be pushed to a registry that Edgegap can pull from. Resource units: 1024 cpu ' +
|
|
222
|
+
'units = 1 vCPU; memory_mb must be at least 256 and at most double the cpu units. ' +
|
|
223
|
+
'Set verify_image true on the first version so a bad image fails here rather than at ' +
|
|
224
|
+
'deploy time. Avoid the "latest" docker tag — use a build ID so deployments are reproducible.',
|
|
225
|
+
inputSchema: {
|
|
226
|
+
application: z.string().describe('Existing application name.'),
|
|
227
|
+
name: z
|
|
228
|
+
.string()
|
|
229
|
+
.min(1)
|
|
230
|
+
.max(64)
|
|
231
|
+
.describe('Version identifier, typically a build ID or timestamp.'),
|
|
232
|
+
docker_repository: z
|
|
233
|
+
.string()
|
|
234
|
+
.describe('Registry host, e.g. "docker.io" or "registry.edgegap.com".'),
|
|
235
|
+
docker_image: z.string().describe('Namespaced image, e.g. "mystudio/game-server".'),
|
|
236
|
+
docker_tag: z.string().describe('Image tag. Use a build ID, not "latest".'),
|
|
237
|
+
cpu_units: z.number().int().min(256).describe('vCPU units. 1024 = 1 vCPU.'),
|
|
238
|
+
memory_mb: z.number().int().min(256).describe('Memory in MB. At most 2x cpu_units.'),
|
|
239
|
+
ports: z
|
|
240
|
+
.array(z.object({
|
|
241
|
+
port: z.number().int().min(1).max(59999).describe('Port the server listens on.'),
|
|
242
|
+
protocol: z
|
|
243
|
+
.string()
|
|
244
|
+
.describe('UDP, TCP, WS, or HTTP. Most game servers use UDP.'),
|
|
245
|
+
name: z.string().optional().describe('Label, e.g. "gameport".'),
|
|
246
|
+
to_check: z
|
|
247
|
+
.boolean()
|
|
248
|
+
.optional()
|
|
249
|
+
.describe('Readiness check on this port. Default true.'),
|
|
250
|
+
}))
|
|
251
|
+
.min(1)
|
|
252
|
+
.describe('Ports to expose. At least one is required for players to connect.'),
|
|
253
|
+
registry_username: z.string().optional().describe('Registry username, for private images.'),
|
|
254
|
+
registry_token: z.string().optional().describe('Registry password or token.'),
|
|
255
|
+
max_duration_minutes: z
|
|
256
|
+
.number()
|
|
257
|
+
.int()
|
|
258
|
+
.optional()
|
|
259
|
+
.describe('Auto-stop after this many minutes. Keeps test deployments from running up cost.'),
|
|
260
|
+
verify_image: z
|
|
261
|
+
.boolean()
|
|
262
|
+
.optional()
|
|
263
|
+
.describe('Verify Edgegap can pull the image before accepting the version.'),
|
|
264
|
+
env: z
|
|
265
|
+
.array(z.object({ key: z.string(), value: z.string() }))
|
|
266
|
+
.optional()
|
|
267
|
+
.describe('Environment variables injected into the container.'),
|
|
268
|
+
},
|
|
269
|
+
}, async (args) => guard(auth, async () => {
|
|
270
|
+
assertAppAllowed(config, args.application);
|
|
271
|
+
if (args.memory_mb > args.cpu_units * 2) {
|
|
272
|
+
return fail(`memory_mb (${args.memory_mb}) exceeds twice cpu_units (${args.cpu_units}). ` +
|
|
273
|
+
`Edgegap rejects this. Either lower memory_mb to ${args.cpu_units * 2} or raise cpu_units.`);
|
|
274
|
+
}
|
|
275
|
+
const requested = args.max_duration_minutes ?? config.maxDurationCeiling;
|
|
276
|
+
const capped = Math.min(requested, config.maxDurationCeiling);
|
|
277
|
+
const res = await client.createAppVersion(args.application, {
|
|
278
|
+
name: args.name,
|
|
279
|
+
is_active: true,
|
|
280
|
+
req_cpu: args.cpu_units,
|
|
281
|
+
req_memory: args.memory_mb,
|
|
282
|
+
docker_repository: args.docker_repository,
|
|
283
|
+
docker_image: args.docker_image,
|
|
284
|
+
docker_tag: args.docker_tag,
|
|
285
|
+
private_username: args.registry_username,
|
|
286
|
+
private_token: args.registry_token,
|
|
287
|
+
verify_image: args.verify_image ?? false,
|
|
288
|
+
max_duration: capped,
|
|
289
|
+
ports: args.ports.map((p) => ({
|
|
290
|
+
port: p.port,
|
|
291
|
+
protocol: p.protocol,
|
|
292
|
+
name: p.name ?? 'gameport',
|
|
293
|
+
to_check: p.to_check ?? true,
|
|
294
|
+
})),
|
|
295
|
+
envs: args.env?.map((e) => ({ key: e.key, value: e.value, is_hidden: false })),
|
|
296
|
+
});
|
|
297
|
+
return ok({
|
|
298
|
+
created: `${args.application}/${res.version?.name ?? args.name}`,
|
|
299
|
+
max_duration_minutes: capped,
|
|
300
|
+
capped_by_server: capped < requested ? config.maxDurationCeiling : undefined,
|
|
301
|
+
next_step: 'Call edgegap_deploy to start an instance.',
|
|
302
|
+
});
|
|
303
|
+
}));
|
|
304
|
+
}
|
|
305
|
+
// ---------------------------------------------------------------- 5 ----
|
|
306
|
+
if (mutating) {
|
|
307
|
+
server.registerTool('edgegap_deploy', {
|
|
308
|
+
title: 'Deploy a game server',
|
|
309
|
+
description: 'Start one containerized instance of an application version, placed near the players ' +
|
|
310
|
+
'you specify. Returns immediately with a request_id; the server is still starting and ' +
|
|
311
|
+
'has no connection details yet. Follow this call with edgegap_wait_for_deployment to ' +
|
|
312
|
+
'get the address players connect to. Always stop deployments you started for testing.',
|
|
313
|
+
inputSchema: {
|
|
314
|
+
application: z.string().describe('Application name.'),
|
|
315
|
+
version: z.string().describe('Version name within the application.'),
|
|
316
|
+
users: userSchema,
|
|
317
|
+
env: z
|
|
318
|
+
.array(z.object({ key: z.string(), value: z.string() }))
|
|
319
|
+
.optional()
|
|
320
|
+
.describe('Environment variables for this deployment only.'),
|
|
321
|
+
tags: z
|
|
322
|
+
.array(z.string())
|
|
323
|
+
.optional()
|
|
324
|
+
.describe('Tags for filtering later, e.g. ["agent-test"]. Recommended.'),
|
|
325
|
+
cpu_units: z.number().int().min(256).optional().describe('Override the version CPU.'),
|
|
326
|
+
memory_mb: z.number().int().min(256).optional().describe('Override the version memory.'),
|
|
327
|
+
},
|
|
328
|
+
}, async (args) => guard(auth, async () => {
|
|
329
|
+
assertAppAllowed(config, args.application);
|
|
330
|
+
const users = buildUsers(args.users);
|
|
331
|
+
if (users.length === 0) {
|
|
332
|
+
return fail('No player locations given. Supply users.ip_addresses (e.g. the developer\'s own ' +
|
|
333
|
+
'public IP) or users.geo_coordinates. Edgegap needs at least one to choose a region.');
|
|
334
|
+
}
|
|
335
|
+
const body = {
|
|
336
|
+
application: args.application,
|
|
337
|
+
version: args.version,
|
|
338
|
+
users,
|
|
339
|
+
tags: args.tags ?? ['mcp'],
|
|
340
|
+
};
|
|
341
|
+
if (args.env) {
|
|
342
|
+
body.environment_variables = args.env.map((e) => ({
|
|
343
|
+
key: e.key,
|
|
344
|
+
value: e.value,
|
|
345
|
+
is_hidden: false,
|
|
346
|
+
}));
|
|
347
|
+
}
|
|
348
|
+
if (args.cpu_units && args.memory_mb) {
|
|
349
|
+
body.resources = { cpu_units: args.cpu_units, memory_mib: args.memory_mb };
|
|
350
|
+
}
|
|
351
|
+
const res = await client.deploy(body);
|
|
352
|
+
return ok({
|
|
353
|
+
request_id: res.request_id,
|
|
354
|
+
status: 'starting',
|
|
355
|
+
next_step: `Call edgegap_wait_for_deployment with request_id ${res.request_id}.`,
|
|
356
|
+
});
|
|
357
|
+
}));
|
|
358
|
+
}
|
|
359
|
+
// ---------------------------------------------------------------- 6 ----
|
|
360
|
+
server.registerTool('edgegap_get_deployment', {
|
|
361
|
+
title: 'Get deployment status',
|
|
362
|
+
description: 'Read the current status of one deployment, including connection address and ports once ' +
|
|
363
|
+
'it is ready. For a deployment you just created, prefer edgegap_wait_for_deployment — it ' +
|
|
364
|
+
'polls for you instead of making you call this in a loop.',
|
|
365
|
+
inputSchema: {
|
|
366
|
+
request_id: z.string().describe('The request_id returned by edgegap_deploy.'),
|
|
367
|
+
},
|
|
368
|
+
}, async ({ request_id }) => guard(auth, async () => {
|
|
369
|
+
const d = await client.getDeployment(request_id);
|
|
370
|
+
return ok(compactDeployment(d));
|
|
371
|
+
}));
|
|
372
|
+
// ---------------------------------------------------------------- 7 ----
|
|
373
|
+
server.registerTool('edgegap_wait_for_deployment', {
|
|
374
|
+
title: 'Wait for a deployment to become ready',
|
|
375
|
+
description: 'Poll a deployment until it is ready, errors, or the timeout expires, then return the ' +
|
|
376
|
+
'connection details. This is the tool to call right after edgegap_deploy. Do not build ' +
|
|
377
|
+
'your own polling loop — this handles backoff and reports the container error detail if ' +
|
|
378
|
+
'the server fails to start.',
|
|
379
|
+
inputSchema: {
|
|
380
|
+
request_id: z.string().describe('The request_id returned by edgegap_deploy.'),
|
|
381
|
+
timeout_seconds: z
|
|
382
|
+
.number()
|
|
383
|
+
.int()
|
|
384
|
+
.min(5)
|
|
385
|
+
.max(600)
|
|
386
|
+
.optional()
|
|
387
|
+
.describe('How long to wait before giving up. Default 180.'),
|
|
388
|
+
},
|
|
389
|
+
}, async ({ request_id, timeout_seconds }) => guard(auth, async () => {
|
|
390
|
+
const budgetMs = (timeout_seconds ?? 180) * 1000;
|
|
391
|
+
const startedAt = Date.now();
|
|
392
|
+
let intervalMs = 2000;
|
|
393
|
+
let last;
|
|
394
|
+
while (Date.now() - startedAt < budgetMs) {
|
|
395
|
+
last = await client.getDeployment(request_id);
|
|
396
|
+
const status = (last.current_status ?? '').toUpperCase();
|
|
397
|
+
if (last.error || TERMINAL_BAD.some((s) => status.includes(s))) {
|
|
398
|
+
return fail(`Deployment ${request_id} failed with status ${last.current_status}.\n` +
|
|
399
|
+
`${last.error_detail ?? 'No error detail returned.'}\n\n` +
|
|
400
|
+
`Call edgegap_get_deployment_logs for container output, then fix the image or ` +
|
|
401
|
+
`port configuration before redeploying.`);
|
|
402
|
+
}
|
|
403
|
+
if (last.running || TERMINAL_OK.some((s) => status.includes(s))) {
|
|
404
|
+
return ok({
|
|
405
|
+
...compactDeployment(last),
|
|
406
|
+
waited_seconds: Math.round((Date.now() - startedAt) / 1000),
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
410
|
+
intervalMs = Math.min(intervalMs * 1.5, 10_000);
|
|
411
|
+
}
|
|
412
|
+
return fail(`Deployment ${request_id} did not become ready within ${timeout_seconds ?? 180}s. ` +
|
|
413
|
+
`Last status: ${last?.current_status ?? 'unknown'}. It may still be starting — ` +
|
|
414
|
+
`call edgegap_get_deployment to check again, or edgegap_stop_deployment to clean up.`);
|
|
415
|
+
}));
|
|
416
|
+
// ---------------------------------------------------------------- 8 ----
|
|
417
|
+
server.registerTool('edgegap_list_deployments', {
|
|
418
|
+
title: 'List running deployments',
|
|
419
|
+
description: 'List active deployments, optionally filtered. Use this to find deployments left running ' +
|
|
420
|
+
'from earlier sessions before starting new ones — orphaned servers cost money.',
|
|
421
|
+
inputSchema: {
|
|
422
|
+
filter: z
|
|
423
|
+
.string()
|
|
424
|
+
.optional()
|
|
425
|
+
.describe('Edgegap filter expression, e.g. by tag. Omit for all deployments.'),
|
|
426
|
+
limit: z.number().int().min(1).max(100).optional().describe('Default 50.'),
|
|
427
|
+
},
|
|
428
|
+
}, async ({ filter, limit }) => guard(auth, async () => {
|
|
429
|
+
const res = await client.listDeployments({ query: filter, limit: limit ?? 50 });
|
|
430
|
+
const rows = (res.data ?? []).map((d) => ({
|
|
431
|
+
request_id: d.request_id,
|
|
432
|
+
ready: d.ready,
|
|
433
|
+
fqdn: d.fqdn,
|
|
434
|
+
started: d.start_time,
|
|
435
|
+
tags: d.tags,
|
|
436
|
+
}));
|
|
437
|
+
return ok({ ...pageInfo(res.total_count, rows.length, 1), deployments: rows });
|
|
438
|
+
}));
|
|
439
|
+
// ---------------------------------------------------------------- 9 ----
|
|
440
|
+
if (mutating) {
|
|
441
|
+
server.registerTool('edgegap_stop_deployment', {
|
|
442
|
+
title: 'Stop a deployment',
|
|
443
|
+
description: 'Gracefully stop one deployment by request_id, sending SIGTERM to the container. ' +
|
|
444
|
+
'Stop every deployment you started for testing before ending your task. This tool ' +
|
|
445
|
+
'stops exactly one deployment; bulk stop is deliberately not exposed.',
|
|
446
|
+
inputSchema: {
|
|
447
|
+
request_id: z.string().describe('The request_id of the deployment to stop.'),
|
|
448
|
+
},
|
|
449
|
+
}, async ({ request_id }) => guard(auth, async () => {
|
|
450
|
+
const res = await client.stopDeployment(request_id);
|
|
451
|
+
return ok({ request_id, result: res.message ?? 'stop requested' });
|
|
452
|
+
}));
|
|
453
|
+
}
|
|
454
|
+
// --------------------------------------------------------------- 10 ----
|
|
455
|
+
server.registerTool('edgegap_get_deployment_logs', {
|
|
456
|
+
title: 'Get container logs for a deployment',
|
|
457
|
+
description: 'Retrieve stdout/stderr and crash output for a deployment. Call this whenever a ' +
|
|
458
|
+
'deployment errors or a server exits unexpectedly — the crash exit code usually ' +
|
|
459
|
+
'identifies the problem faster than redeploying does. Logs for stopped deployments are ' +
|
|
460
|
+
'only retained if Endpoint Storage was configured on the version beforehand.',
|
|
461
|
+
inputSchema: {
|
|
462
|
+
request_id: z.string().describe('The request_id of the deployment.'),
|
|
463
|
+
max_characters: z
|
|
464
|
+
.number()
|
|
465
|
+
.int()
|
|
466
|
+
.min(500)
|
|
467
|
+
.max(50_000)
|
|
468
|
+
.optional()
|
|
469
|
+
.describe('Truncate logs to this length, keeping the tail. Default 8000.'),
|
|
470
|
+
},
|
|
471
|
+
}, async ({ request_id, max_characters }) => guard(auth, async () => {
|
|
472
|
+
const res = await client.getDeploymentLogs(request_id);
|
|
473
|
+
const cap = max_characters ?? 8000;
|
|
474
|
+
const tail = (s) => !s ? undefined : s.length > cap ? `...[truncated]...\n${s.slice(-cap)}` : s;
|
|
475
|
+
return ok({
|
|
476
|
+
request_id,
|
|
477
|
+
logs: tail(res.logs) ?? '(no logs returned)',
|
|
478
|
+
crash_logs: tail(res.crash_logs),
|
|
479
|
+
exit_code: res.crash_data?.exit_code,
|
|
480
|
+
crash_message: res.crash_data?.message,
|
|
481
|
+
restart_count: res.crash_data?.restart_count,
|
|
482
|
+
storage_link: res.logs_link ?? undefined,
|
|
483
|
+
});
|
|
484
|
+
}));
|
|
485
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@edgegap/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Deploy game servers on Edgegap from your coding agent. Runs locally; your API token never leaves your machine.",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"prebuild": "rm -rf dist",
|
|
7
|
+
"build": "tsc && chmod +x dist/index.js",
|
|
8
|
+
"typecheck": "tsc --noEmit",
|
|
9
|
+
"typecheck:worker": "tsc -p worker/tsconfig.json --noEmit",
|
|
10
|
+
"test": "node smoke.mjs && node guards.mjs && node elicit.mjs",
|
|
11
|
+
"worker:dev": "wrangler dev --config worker/wrangler.jsonc --local --port 8787",
|
|
12
|
+
"worker:check": "wrangler deploy --config worker/wrangler.jsonc --dry-run --outdir=.wrangler/dryrun",
|
|
13
|
+
"worker:deploy": "wrangler deploy --config worker/wrangler.jsonc",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mcp",
|
|
18
|
+
"edgegap",
|
|
19
|
+
"game-server",
|
|
20
|
+
"multiplayer",
|
|
21
|
+
"unity",
|
|
22
|
+
"unreal"
|
|
23
|
+
],
|
|
24
|
+
"author": "Mathieu Duperre",
|
|
25
|
+
"license": "Apache-2.0",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
28
|
+
"@modelcontextprotocol/server": "^2.0.0",
|
|
29
|
+
"agents": "^0.23.0",
|
|
30
|
+
"zod": "^4.6.2"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@cloudflare/workers-types": "^5.20260911.1",
|
|
34
|
+
"@types/node": "^22.20.2",
|
|
35
|
+
"typescript": "^7.0.2",
|
|
36
|
+
"wrangler": "^4.131.1"
|
|
37
|
+
},
|
|
38
|
+
"type": "module",
|
|
39
|
+
"bin": {
|
|
40
|
+
"edgegap-mcp": "dist/index.js"
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"dist",
|
|
44
|
+
"README.md"
|
|
45
|
+
],
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=18"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
}
|
|
52
|
+
}
|