@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/client.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin client over the Edgegap REST API.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately not generated from the OpenAPI spec: the generated surface is
|
|
5
|
+
* ~60 operations, which is more than an agent can hold in context without
|
|
6
|
+
* picking the wrong one. This wraps only what the golden path needs.
|
|
7
|
+
*/
|
|
8
|
+
import { redact } from './config.js';
|
|
9
|
+
export class EdgegapApiError extends Error {
|
|
10
|
+
status;
|
|
11
|
+
apiMessage;
|
|
12
|
+
details;
|
|
13
|
+
constructor(status, apiMessage, details) {
|
|
14
|
+
super(`Edgegap API ${status}: ${apiMessage}`);
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.apiMessage = apiMessage;
|
|
17
|
+
this.details = details;
|
|
18
|
+
this.name = 'EdgegapApiError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class EdgegapClient {
|
|
22
|
+
config;
|
|
23
|
+
auth;
|
|
24
|
+
constructor(config, auth) {
|
|
25
|
+
this.config = config;
|
|
26
|
+
this.auth = auth;
|
|
27
|
+
}
|
|
28
|
+
async request(method, version, path, options = {}) {
|
|
29
|
+
const base = version === 'v2' ? this.config.baseUrlV2 : this.config.baseUrlV1;
|
|
30
|
+
const url = new URL(base + path);
|
|
31
|
+
for (const [key, value] of Object.entries(options.query ?? {})) {
|
|
32
|
+
if (value !== undefined && value !== null) {
|
|
33
|
+
url.searchParams.set(key, String(value));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Resolved per request rather than at construction: the developer may not
|
|
37
|
+
// have been asked for a token yet.
|
|
38
|
+
const token = await this.auth.get();
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
const timer = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
|
|
41
|
+
let response;
|
|
42
|
+
try {
|
|
43
|
+
response = await fetch(url.toString(), {
|
|
44
|
+
method,
|
|
45
|
+
headers: {
|
|
46
|
+
// Edgegap expects the literal word "token" before the value.
|
|
47
|
+
Authorization: `token ${token}`,
|
|
48
|
+
'Content-Type': 'application/json',
|
|
49
|
+
Accept: 'application/json',
|
|
50
|
+
'User-Agent': 'edgegap-mcp',
|
|
51
|
+
},
|
|
52
|
+
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
53
|
+
signal: controller.signal,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
58
|
+
throw new Error(`Request to ${method} ${path} timed out after ${this.config.requestTimeoutMs}ms.`);
|
|
59
|
+
}
|
|
60
|
+
throw new Error(redact(`Network error calling ${method} ${path}: ${err.message}`, token));
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
}
|
|
65
|
+
const raw = await response.text();
|
|
66
|
+
let parsed = undefined;
|
|
67
|
+
if (raw) {
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(raw);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
parsed = { message: raw.slice(0, 500) };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
// A rejected token is worth forgetting immediately, so the next call
|
|
77
|
+
// re-prompts instead of failing repeatedly against a revoked credential.
|
|
78
|
+
if (response.status === 401)
|
|
79
|
+
this.auth.invalidate();
|
|
80
|
+
const body = (parsed ?? {});
|
|
81
|
+
throw new EdgegapApiError(response.status, redact(body.message ?? response.statusText, token), body.details);
|
|
82
|
+
}
|
|
83
|
+
return (parsed ?? {});
|
|
84
|
+
}
|
|
85
|
+
// --- Applications and versions (v1) ------------------------------------
|
|
86
|
+
listApps(query = {}) {
|
|
87
|
+
return this.request('GET', 'v1', '/v1/apps', { query });
|
|
88
|
+
}
|
|
89
|
+
createApp(body) {
|
|
90
|
+
return this.request('POST', 'v1', '/v1/app', { body });
|
|
91
|
+
}
|
|
92
|
+
listAppVersions(appName) {
|
|
93
|
+
return this.request('GET', 'v1', `/v1/app/${encodeURIComponent(appName)}/versions`);
|
|
94
|
+
}
|
|
95
|
+
createAppVersion(appName, body) {
|
|
96
|
+
return this.request('POST', 'v1', `/v1/app/${encodeURIComponent(appName)}/version`, { body });
|
|
97
|
+
}
|
|
98
|
+
// --- Deployments --------------------------------------------------------
|
|
99
|
+
/** v2 deploy. Returns only a request_id; the deployment is still starting. */
|
|
100
|
+
deploy(body) {
|
|
101
|
+
return this.request('POST', 'v2', '/deployments', { body });
|
|
102
|
+
}
|
|
103
|
+
getDeployment(requestId) {
|
|
104
|
+
return this.request('GET', 'v1', `/v1/status/${encodeURIComponent(requestId)}`);
|
|
105
|
+
}
|
|
106
|
+
listDeployments(query = {}) {
|
|
107
|
+
return this.request('GET', 'v1', '/v1/deployments', { query });
|
|
108
|
+
}
|
|
109
|
+
stopDeployment(requestId) {
|
|
110
|
+
return this.request('DELETE', 'v1', `/v1/stop/${encodeURIComponent(requestId)}`);
|
|
111
|
+
}
|
|
112
|
+
getDeploymentLogs(requestId, format = 'text') {
|
|
113
|
+
return this.request('GET', 'v1', `/v1/deployment/${encodeURIComponent(requestId)}/container-logs`, { query: { format } });
|
|
114
|
+
}
|
|
115
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration and safety gates.
|
|
3
|
+
*
|
|
4
|
+
* Everything here exists to keep the blast radius of an autonomous agent small.
|
|
5
|
+
* The Edgegap API token is org-wide, so the server layers its own limits on top.
|
|
6
|
+
*/
|
|
7
|
+
export class ConfigError extends Error {
|
|
8
|
+
}
|
|
9
|
+
function parseList(raw) {
|
|
10
|
+
if (!raw)
|
|
11
|
+
return [];
|
|
12
|
+
return raw
|
|
13
|
+
.split(',')
|
|
14
|
+
.map((s) => s.trim())
|
|
15
|
+
.filter(Boolean);
|
|
16
|
+
}
|
|
17
|
+
function parseIntEnv(raw, fallback) {
|
|
18
|
+
if (!raw)
|
|
19
|
+
return fallback;
|
|
20
|
+
const n = Number.parseInt(raw, 10);
|
|
21
|
+
return Number.isFinite(n) ? n : fallback;
|
|
22
|
+
}
|
|
23
|
+
export function loadConfig(env = process.env) {
|
|
24
|
+
// A token in the environment is optional. When it is absent the server asks
|
|
25
|
+
// the developer for one at first use, which removes the "configure before
|
|
26
|
+
// anything works" step that loses people during onboarding.
|
|
27
|
+
const raw = env.EDGEGAP_API_TOKEN?.trim();
|
|
28
|
+
// Tokens are pasted by hand often enough that it is worth catching the
|
|
29
|
+
// common mistake of including the "token " prefix twice.
|
|
30
|
+
const envToken = raw ? raw.replace(/^token\s+/i, '') : undefined;
|
|
31
|
+
return {
|
|
32
|
+
envToken,
|
|
33
|
+
baseUrlV1: env.EDGEGAP_BASE_URL?.trim() || 'https://api.edgegap.com',
|
|
34
|
+
baseUrlV2: (env.EDGEGAP_BASE_URL?.trim() || 'https://api.edgegap.com') + '/v2',
|
|
35
|
+
readOnly: env.EDGEGAP_READ_ONLY === '1' || env.EDGEGAP_READ_ONLY === 'true',
|
|
36
|
+
appAllowlist: parseList(env.EDGEGAP_APP_ALLOWLIST),
|
|
37
|
+
maxDurationCeiling: parseIntEnv(env.EDGEGAP_MAX_DURATION_MINUTES, 60),
|
|
38
|
+
requestTimeoutMs: parseIntEnv(env.EDGEGAP_TIMEOUT_MS, 30_000),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Throws if the agent is reaching for an application it was not scoped to.
|
|
43
|
+
* No allowlist configured means no restriction.
|
|
44
|
+
*/
|
|
45
|
+
export function assertAppAllowed(config, appName) {
|
|
46
|
+
if (config.appAllowlist.length === 0)
|
|
47
|
+
return;
|
|
48
|
+
if (!config.appAllowlist.includes(appName)) {
|
|
49
|
+
throw new Error(`Application "${appName}" is not in EDGEGAP_APP_ALLOWLIST. ` +
|
|
50
|
+
`Allowed: ${config.appAllowlist.join(', ')}. ` +
|
|
51
|
+
`This server is scoped deliberately; ask the human to widen the ` +
|
|
52
|
+
`allowlist rather than trying another application name.`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Strips the live token out of text before it reaches the model.
|
|
57
|
+
* Error strings from HTTP layers sometimes echo request headers back.
|
|
58
|
+
*/
|
|
59
|
+
export function redact(text, token) {
|
|
60
|
+
if (!token)
|
|
61
|
+
return text;
|
|
62
|
+
return text.split(token).join('<redacted-token>');
|
|
63
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Edgegap MCP server.
|
|
4
|
+
*
|
|
5
|
+
* Exposes ten curated tools covering the path from "I have a game server
|
|
6
|
+
* container" to "players are connected to it", so a coding agent can take a
|
|
7
|
+
* developer through it without the developer reading the API reference.
|
|
8
|
+
*
|
|
9
|
+
* Transport is stdio, which is what Claude Code, Cursor, Codex and VS Code use
|
|
10
|
+
* for locally configured servers.
|
|
11
|
+
*/
|
|
12
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
13
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
14
|
+
import { loadConfig, ConfigError } from './config.js';
|
|
15
|
+
import { EdgegapClient } from './client.js';
|
|
16
|
+
import { registerTools } from './tools.js';
|
|
17
|
+
import { TokenProvider, warnIfTokenInArgv } from './auth.js';
|
|
18
|
+
async function main() {
|
|
19
|
+
let config;
|
|
20
|
+
try {
|
|
21
|
+
config = loadConfig();
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
if (err instanceof ConfigError) {
|
|
25
|
+
// stderr only: stdout is the MCP protocol channel and must stay clean.
|
|
26
|
+
process.stderr.write(`[edgegap-mcp] ${err.message}\n`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
const server = new McpServer({ name: 'edgegap', version: '0.1.0' }, {
|
|
32
|
+
instructions: 'Deploy and operate game servers on Edgegap.\n\n' +
|
|
33
|
+
'Golden path for a first deployment:\n' +
|
|
34
|
+
'1. edgegap_list_apps to see what already exists\n' +
|
|
35
|
+
'2. edgegap_create_app if no suitable application is there\n' +
|
|
36
|
+
'3. edgegap_create_app_version to register the container image\n' +
|
|
37
|
+
'4. edgegap_deploy to start an instance near the players\n' +
|
|
38
|
+
'5. edgegap_wait_for_deployment to get the connection address\n' +
|
|
39
|
+
'6. edgegap_stop_deployment when finished\n\n' +
|
|
40
|
+
'Deployments cost money while running. Tag test deployments and stop ' +
|
|
41
|
+
'them before ending the task. If a deployment errors, read the container ' +
|
|
42
|
+
'logs before redeploying.\n\n' +
|
|
43
|
+
'Credentials: if no token was configured, the first tool call asks the ' +
|
|
44
|
+
'developer for one. That token is org-wide and cannot be scoped by ' +
|
|
45
|
+
'Edgegap, so it authorises far more than any single task needs. Treat it ' +
|
|
46
|
+
'as a supervised credential: do not use it for work the developer did ' +
|
|
47
|
+
'not ask for, do not enumerate or modify unrelated applications, and do ' +
|
|
48
|
+
'not repeat operations against it to explore what is possible. If the ' +
|
|
49
|
+
'developer declines to provide a token, stop and report which operation ' +
|
|
50
|
+
'needed it — do not retry.',
|
|
51
|
+
});
|
|
52
|
+
warnIfTokenInArgv();
|
|
53
|
+
const auth = new TokenProvider(config);
|
|
54
|
+
auth.attach(server.server);
|
|
55
|
+
auth.installExitHandlers();
|
|
56
|
+
registerTools(server, new EdgegapClient(config, auth), config, auth);
|
|
57
|
+
if (!config.envToken) {
|
|
58
|
+
process.stderr.write('[edgegap-mcp] no token configured: you will be asked for one at first ' +
|
|
59
|
+
'use. It stays in memory on this machine and is never written to disk ' +
|
|
60
|
+
'or sent to Edgegap.\n');
|
|
61
|
+
}
|
|
62
|
+
if (config.readOnly) {
|
|
63
|
+
process.stderr.write('[edgegap-mcp] read-only mode: mutating tools are not registered\n');
|
|
64
|
+
}
|
|
65
|
+
await server.connect(new StdioServerTransport());
|
|
66
|
+
}
|
|
67
|
+
main().catch((err) => {
|
|
68
|
+
process.stderr.write(`[edgegap-mcp] fatal: ${err.message}\n`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
});
|