@edgegap/mcp 0.1.4 → 0.2.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 +92 -19
- package/dist/client.js +31 -0
- package/dist/index.js +24 -15
- package/dist/matchmaker.js +101 -0
- package/dist/serverconfig.js +299 -0
- package/dist/tools.js +381 -3
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# edgegap-mcp
|
|
2
2
|
|
|
3
3
|
An MCP server for Edgegap that lets a coding agent take a developer from "I
|
|
4
|
-
have a
|
|
5
|
-
developer reading the API reference
|
|
4
|
+
have a headless server build" to "players are connected to it" without the
|
|
5
|
+
developer reading the API reference: checking the Dockerfile and ports, pushing
|
|
6
|
+
to Edgegap's registry, deploying, and — for peer-to-peer games — opening a relay
|
|
7
|
+
instead.
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
[
|
|
9
|
+
Eighteen tools, hand-picked. Not generated from the OpenAPI spec — see
|
|
10
|
+
[Design decisions](#design-decisions) for why.
|
|
9
11
|
|
|
10
12
|
## Install
|
|
11
13
|
|
|
@@ -48,7 +50,7 @@ config, nothing to clone, nothing to build.
|
|
|
48
50
|
```
|
|
49
51
|
|
|
50
52
|
Works in Claude Code, Cursor, Codex, and VS Code. Pin a version in production
|
|
51
|
-
(`@edgegap/mcp@0.1.
|
|
53
|
+
(`@edgegap/mcp@0.1.5`) rather than floating on latest.
|
|
52
54
|
|
|
53
55
|
Registered in the official MCP registry as `dev.edgegap/mcp`.
|
|
54
56
|
|
|
@@ -112,7 +114,7 @@ Recommended setup, in decreasing order of caution:
|
|
|
112
114
|
| Situation | Setup |
|
|
113
115
|
| --- | --- |
|
|
114
116
|
| Unattended or autonomous agent | Local mode. Separate non-production organization, plus `EDGEGAP_READ_ONLY=1` |
|
|
115
|
-
| Supervised agent, live game in the org | Local mode. `EDGEGAP_APP_ALLOWLIST` scoped to the app being worked on, plus `EDGEGAP_MAX_DURATION_MINUTES
|
|
117
|
+
| Supervised agent, live game in the org | Local mode. `EDGEGAP_APP_ALLOWLIST` scoped to the app being worked on, plus `EDGEGAP_MAX_DURATION_MINUTES`. Read [Scope of the allowlist](#scope-of-the-allowlist) first — deployments that are already running are not covered |
|
|
116
118
|
| Solo developer, no production workload | Either mode. Defaults are fine; revoke the token when finished |
|
|
117
119
|
|
|
118
120
|
The allowlist and read-only flag are enforced in the local server, which means
|
|
@@ -120,6 +122,33 @@ they protect against an agent that makes a mistake, not against one that has
|
|
|
120
122
|
been compromised into calling the API directly. They narrow the blast radius;
|
|
121
123
|
they do not remove it.
|
|
122
124
|
|
|
125
|
+
### Scope of the allowlist
|
|
126
|
+
|
|
127
|
+
`EDGEGAP_APP_ALLOWLIST` is enforced by the five tools that take an application
|
|
128
|
+
name: `edgegap_create_app`, `edgegap_list_app_versions`,
|
|
129
|
+
`edgegap_create_app_version`, `edgegap_deploy`, and
|
|
130
|
+
`edgegap_build_matchmaker_config`.
|
|
131
|
+
|
|
132
|
+
Relay sessions and the container registry belong to the organization, not to an
|
|
133
|
+
application, so the relay and registry tools are not covered by it either.
|
|
134
|
+
|
|
135
|
+
It is **not** enforced by the five tools keyed on `request_id`:
|
|
136
|
+
`edgegap_get_deployment`, `edgegap_wait_for_deployment`,
|
|
137
|
+
`edgegap_list_deployments`, `edgegap_stop_deployment`, and
|
|
138
|
+
`edgegap_get_deployment_logs`. An agent running with an allowlist set can list
|
|
139
|
+
every deployment in the organization and then inspect, read the logs of, or stop
|
|
140
|
+
any of them — including deployments belonging to applications outside the list.
|
|
141
|
+
|
|
142
|
+
So the allowlist scopes what an agent can **create and deploy into**, not what it
|
|
143
|
+
can **touch once running**. That is narrower than earlier versions of this
|
|
144
|
+
document implied.
|
|
145
|
+
|
|
146
|
+
For a stronger guarantee today, use `EDGEGAP_READ_ONLY=1`, which never registers
|
|
147
|
+
the mutating tools at all, or point the agent at a separate non-production
|
|
148
|
+
organization. Both are unaffected by this gap.
|
|
149
|
+
|
|
150
|
+
Reported by Syed Anas Mohiuddin, September 2026.
|
|
151
|
+
|
|
123
152
|
### Environment variables
|
|
124
153
|
|
|
125
154
|
These configure the local server. On the remote endpoint they are set by
|
|
@@ -129,15 +158,26 @@ locally.
|
|
|
129
158
|
| Variable | Default | Purpose |
|
|
130
159
|
| --- | --- | --- |
|
|
131
160
|
| `EDGEGAP_API_TOKEN` | *(prompted)* | API token. Optional — omit it and the developer is asked at first use. The `token ` prefix is added for you. |
|
|
132
|
-
| `EDGEGAP_READ_ONLY` | `0` | Set to `1` and the
|
|
133
|
-
| `EDGEGAP_APP_ALLOWLIST` | *(empty)* | Comma-separated application names. When set,
|
|
161
|
+
| `EDGEGAP_READ_ONLY` | `0` | Set to `1` and the eight mutating tools (●, below) are never registered. The agent cannot see them, so it cannot be talked into calling them. |
|
|
162
|
+
| `EDGEGAP_APP_ALLOWLIST` | *(empty)* | Comma-separated application names. When set, the five application-keyed tools refuse to touch anything else. Does **not** scope the five `request_id`-keyed tools — see [Scope of the allowlist](#scope-of-the-allowlist). |
|
|
134
163
|
| `EDGEGAP_MAX_DURATION_MINUTES` | `60` | Ceiling on `max_duration` the agent may set on a version. Caps runaway cost from an unattended agent. |
|
|
135
164
|
| `EDGEGAP_TIMEOUT_MS` | `30000` | Per-request HTTP timeout. |
|
|
136
165
|
|
|
137
166
|
## Tools
|
|
138
167
|
|
|
139
|
-
|
|
140
|
-
|
|
168
|
+
Eighteen tools, grouped by where they fall on the path. The same set in both
|
|
169
|
+
modes; `EDGEGAP_READ_ONLY=1` hides the ● ones.
|
|
170
|
+
|
|
171
|
+
**Before the first deploy** — getting a headless build into a correct image and
|
|
172
|
+
into a registry, which is where agent-driven onboarding actually stalls.
|
|
173
|
+
|
|
174
|
+
| Tool | Mutating | What it's for |
|
|
175
|
+
| --- | --- | --- |
|
|
176
|
+
| `edgegap_validate_server_config` | | Static check of a Dockerfile, ports, resources and tag against Edgegap's requirements: linux/amd64, Unreal not running as root, Unity `-batchmode -nographics`, loopback binds, EXPOSE vs. version ports, protocol vs. netcode transport, `latest` tags. Returns Edgegap's reference Unity/Unreal Dockerfile when there is none yet or it fails. No API call. |
|
|
177
|
+
| `edgegap_get_registry_credentials` | ● | Push credentials for the org's private `registry.edgegap.com` project, plus the exact login/build/push commands and the values to pass to `edgegap_create_app_version`. Provisions the project on first use. |
|
|
178
|
+
| `edgegap_list_registry_tags` | | Confirm a pushed tag landed before registering it. |
|
|
179
|
+
|
|
180
|
+
**Dedicated servers** — the original golden path.
|
|
141
181
|
|
|
142
182
|
| Tool | Mutating | What it's for |
|
|
143
183
|
| --- | --- | --- |
|
|
@@ -152,12 +192,29 @@ both modes.
|
|
|
152
192
|
| `edgegap_stop_deployment` | ● | Graceful SIGTERM, one deployment at a time. |
|
|
153
193
|
| `edgegap_get_deployment_logs` | | Container output and crash exit code after a failure. |
|
|
154
194
|
|
|
195
|
+
**Peer-to-peer relays** — for co-op and host-client games, which need no server
|
|
196
|
+
image at all.
|
|
197
|
+
|
|
198
|
+
| Tool | Mutating | What it's for |
|
|
199
|
+
| --- | --- | --- |
|
|
200
|
+
| `edgegap_create_relay_session` | ● | Open a relay session for a set of player IPs, wait until it is ready, and return the relay address, ports, and per-player authorization tokens. |
|
|
201
|
+
| `edgegap_get_relay_session` | | Re-read a session. |
|
|
202
|
+
| `edgegap_authorize_relay_user` | ● | Add a player who joins after the session was created. |
|
|
203
|
+
| `edgegap_delete_relay_session` | ● | Close a session. |
|
|
204
|
+
|
|
205
|
+
**Matchmaking**
|
|
206
|
+
|
|
207
|
+
| Tool | Mutating | What it's for |
|
|
208
|
+
| --- | --- | --- |
|
|
209
|
+
| `edgegap_build_matchmaker_config` | | Generate a basic matchmaker configuration (teams, team size, optional latency rule and expansions) checked against the application version it deploys. Edgegap has no API for creating a matchmaker, so the developer uploads the result in the dashboard. |
|
|
210
|
+
|
|
155
211
|
## Design decisions
|
|
156
212
|
|
|
157
213
|
**Curated, not generated.** The Edgegap API has roughly sixty operations.
|
|
158
214
|
Auto-generating one tool per operation puts all sixty descriptions into the
|
|
159
|
-
agent's context on every turn and measurably degrades tool selection. These
|
|
160
|
-
cover the path that converts a new developer
|
|
215
|
+
agent's context on every turn and measurably degrades tool selection. These
|
|
216
|
+
cover the path that converts a new developer — including the steps before the
|
|
217
|
+
first deploy, which are where that path used to end.
|
|
161
218
|
|
|
162
219
|
**`wait_for_deployment` is a tool, not a loop.** Left to itself an agent will
|
|
163
220
|
call a status endpoint in a tight loop, burn turns, and give up early. Folding
|
|
@@ -171,6 +228,19 @@ round trip to the human.
|
|
|
171
228
|
|
|
172
229
|
**Local validation before the wire.** The memory-to-CPU ratio and the missing
|
|
173
230
|
player location are caught here rather than surfacing as an opaque 400.
|
|
231
|
+
`edgegap_validate_server_config` extends this to the image itself, before a
|
|
232
|
+
build and push are spent discovering a problem.
|
|
233
|
+
|
|
234
|
+
**The registry token is handed to the agent; the API token never is.** The agent
|
|
235
|
+
has to run `docker login`, so the registry token is returned in the tool result.
|
|
236
|
+
It is scoped to the org's registry project, and the returned command reads it
|
|
237
|
+
from an environment variable over `--password-stdin` so it stays off command
|
|
238
|
+
lines and out of shell history. The tool is hidden in read-only mode.
|
|
239
|
+
|
|
240
|
+
**The registry credentials endpoint is not in the public spec.** It is
|
|
241
|
+
`GET /v1/wizard/registry-credentials`, the same call the Unity plugin makes,
|
|
242
|
+
preceded by `POST /v1/wizard/init-quick-start` when the project is not yet
|
|
243
|
+
provisioned.
|
|
174
244
|
|
|
175
245
|
**Bulk operations are deliberately absent.** `stop` takes one `request_id`.
|
|
176
246
|
There is no bulk-stop tool, because an agent with a filter expression and a bug
|
|
@@ -185,13 +255,14 @@ longer version.
|
|
|
185
255
|
|
|
186
256
|
## Scope
|
|
187
257
|
|
|
188
|
-
Not exposed, on purpose:
|
|
189
|
-
|
|
190
|
-
|
|
258
|
+
Not exposed, on purpose: private fleets, smart fleets, endpoint storage,
|
|
259
|
+
ACL/whitelist entries, deployment tags, metrics, registry tag deletion, DNS
|
|
260
|
+
configuration, and matchmaker lifecycle (start, stop, delete).
|
|
191
261
|
|
|
192
|
-
These
|
|
193
|
-
|
|
194
|
-
|
|
262
|
+
These belong to studios already operating on the platform, not to a developer
|
|
263
|
+
getting a first game online. Relays, registry push, and a basic matchmaker
|
|
264
|
+
config were moved in scope because agents hit them before the first deploy,
|
|
265
|
+
not after.
|
|
195
266
|
|
|
196
267
|
## Known limitation: asking for the token at all
|
|
197
268
|
|
|
@@ -222,9 +293,11 @@ npm run typecheck
|
|
|
222
293
|
node smoke.mjs # handshake, tool registration, read-only mode
|
|
223
294
|
node guards.mjs # local validation and allowlist enforcement
|
|
224
295
|
node elicit.mjs # token prompt: accept, refuse acknowledgement, decline, no support
|
|
296
|
+
node newtools.mjs # validator, registry, relay, matchmaker tools against a local mock API
|
|
225
297
|
```
|
|
226
298
|
|
|
227
|
-
None of these
|
|
299
|
+
None of these reach Edgegap. `newtools.mjs` points `EDGEGAP_BASE_URL` at a
|
|
300
|
+
mock server on localhost. `elicit.mjs` asserts that the prompt states
|
|
228
301
|
the org-wide scope, that the acknowledgement is required, that the token never
|
|
229
302
|
appears in tool output, and that declining produces a stop-and-report message
|
|
230
303
|
rather than a retry loop.
|
package/dist/client.js
CHANGED
|
@@ -112,4 +112,35 @@ export class EdgegapClient {
|
|
|
112
112
|
getDeploymentLogs(requestId, format = 'text') {
|
|
113
113
|
return this.request('GET', 'v1', `/v1/deployment/${encodeURIComponent(requestId)}/container-logs`, { query: { format } });
|
|
114
114
|
}
|
|
115
|
+
// --- Container registry -------------------------------------------------
|
|
116
|
+
/**
|
|
117
|
+
* Push credentials for the organization's project on registry.edgegap.com.
|
|
118
|
+
* Not in the published OpenAPI spec: this is the endpoint the Unity plugin
|
|
119
|
+
* uses, and it can fail until init-quick-start has provisioned the project.
|
|
120
|
+
*/
|
|
121
|
+
getRegistryCredentials() {
|
|
122
|
+
return this.request('GET', 'v1', '/v1/wizard/registry-credentials');
|
|
123
|
+
}
|
|
124
|
+
/** Provisions the registry project if needed. Idempotent; returns 204. */
|
|
125
|
+
initQuickStart(source) {
|
|
126
|
+
return this.request('POST', 'v1', '/v1/wizard/init-quick-start', { body: { source } });
|
|
127
|
+
}
|
|
128
|
+
/** imageName is "<project>/<image>"; the slash is part of the route. */
|
|
129
|
+
listRegistryTags(imageName, query = {}) {
|
|
130
|
+
const path = imageName.split('/').map(encodeURIComponent).join('/');
|
|
131
|
+
return this.request('GET', 'v1', `/v1/container-registry/images/${path}/tags`, { query });
|
|
132
|
+
}
|
|
133
|
+
// --- Relays -------------------------------------------------------------
|
|
134
|
+
createRelaySession(body) {
|
|
135
|
+
return this.request('POST', 'v1', '/v1/relays/sessions', { body });
|
|
136
|
+
}
|
|
137
|
+
getRelaySession(sessionId) {
|
|
138
|
+
return this.request('GET', 'v1', `/v1/relays/sessions/${encodeURIComponent(sessionId)}`);
|
|
139
|
+
}
|
|
140
|
+
authorizeRelayUser(body) {
|
|
141
|
+
return this.request('POST', 'v1', '/v1/relays/sessions:authorize-user', { body });
|
|
142
|
+
}
|
|
143
|
+
deleteRelaySession(sessionId) {
|
|
144
|
+
return this.request('DELETE', 'v1', `/v1/relays/sessions/${encodeURIComponent(sessionId)}`);
|
|
145
|
+
}
|
|
115
146
|
}
|
package/dist/index.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Edgegap MCP server.
|
|
4
4
|
*
|
|
5
|
-
* Exposes
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Exposes curated tools covering the path from "I have a headless server
|
|
6
|
+
* build" to "players are connected to it" — Dockerfile checks, registry push,
|
|
7
|
+
* deploy, relays for peer-to-peer games, and a matchmaker config — so a coding
|
|
8
|
+
* agent can take a developer through it without reading the API reference.
|
|
8
9
|
*
|
|
9
10
|
* Transport is stdio, which is what Claude Code, Cursor, Codex and VS Code use
|
|
10
11
|
* for locally configured servers.
|
|
@@ -28,18 +29,26 @@ async function main() {
|
|
|
28
29
|
}
|
|
29
30
|
throw err;
|
|
30
31
|
}
|
|
31
|
-
const server = new McpServer({ name: 'edgegap', version: '0.
|
|
32
|
-
instructions: '
|
|
33
|
-
'
|
|
34
|
-
|
|
35
|
-
'
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
'
|
|
39
|
-
'
|
|
40
|
-
'
|
|
41
|
-
'
|
|
42
|
-
'
|
|
32
|
+
const server = new McpServer({ name: 'edgegap', version: '0.2.0' }, {
|
|
33
|
+
instructions: 'Host multiplayer games on Edgegap: dedicated servers, or relays for peer-to-peer.\n\n' +
|
|
34
|
+
'Pick the path first. Peer-to-peer and host-client games (common for co-op) need no ' +
|
|
35
|
+
"server image: call edgegap_create_relay_session with the players' public IPs and " +
|
|
36
|
+
'configure the relay transport with what it returns. Dedicated-server games follow ' +
|
|
37
|
+
'the golden path below.\n\n' +
|
|
38
|
+
'Golden path for a first dedicated-server deployment:\n' +
|
|
39
|
+
'1. edgegap_validate_server_config on the Dockerfile and ports, before building\n' +
|
|
40
|
+
'2. edgegap_get_registry_credentials, then docker build --platform linux/amd64 and push\n' +
|
|
41
|
+
'3. edgegap_list_registry_tags to confirm the push landed\n' +
|
|
42
|
+
'4. edgegap_list_apps, then edgegap_create_app if no suitable application exists\n' +
|
|
43
|
+
'5. edgegap_create_app_version to register the image\n' +
|
|
44
|
+
'6. edgegap_deploy to start an instance near the players\n' +
|
|
45
|
+
'7. edgegap_wait_for_deployment to get the connection address\n' +
|
|
46
|
+
'8. edgegap_stop_deployment when finished\n' +
|
|
47
|
+
'To match players into those servers, edgegap_build_matchmaker_config produces the ' +
|
|
48
|
+
'config the developer uploads in the dashboard.\n\n' +
|
|
49
|
+
'Deployments and relay sessions cost money while running. Tag test deployments, and ' +
|
|
50
|
+
'stop deployments and delete relay sessions you created before ending the task. If a ' +
|
|
51
|
+
'deployment errors, read the container logs before redeploying.\n\n' +
|
|
43
52
|
'Credentials: if no token was configured, the first tool call asks the ' +
|
|
44
53
|
'developer for one. That token is org-wide and cannot be scoped by ' +
|
|
45
54
|
'Edgegap, so it authorises far more than any single task needs. Treat it ' +
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds and checks a basic Edgegap matchmaker configuration.
|
|
3
|
+
*
|
|
4
|
+
* Edgegap has no public API for creating a matchmaker: it is created in the
|
|
5
|
+
* dashboard by uploading a JSON configuration. What an agent can usefully do is
|
|
6
|
+
* produce that JSON correctly the first time, pointed at an application version
|
|
7
|
+
* that actually exists, so the developer's only step is the upload.
|
|
8
|
+
*
|
|
9
|
+
* Format reference: https://docs.edgegap.com/learn/matchmaking/matchmaker-in-depth
|
|
10
|
+
*/
|
|
11
|
+
/** Config schema version the docs currently publish. Overridable per call. */
|
|
12
|
+
export const MATCHMAKER_CONFIG_VERSION = '3.3.2';
|
|
13
|
+
export const DASHBOARD_URL = 'https://app.edgegap.com';
|
|
14
|
+
const DURATION = /^\d+(ms|s|m|h)$/;
|
|
15
|
+
function seconds(d) {
|
|
16
|
+
const m = d.match(/^(\d+)(ms|s|m|h)$/);
|
|
17
|
+
if (!m)
|
|
18
|
+
return NaN;
|
|
19
|
+
const n = Number(m[1]);
|
|
20
|
+
return m[2] === 'ms' ? n / 1000 : m[2] === 's' ? n : m[2] === 'm' ? n * 60 : n * 3600;
|
|
21
|
+
}
|
|
22
|
+
export function buildMatchmakerConfig(input) {
|
|
23
|
+
const problems = [];
|
|
24
|
+
const cautions = [];
|
|
25
|
+
if (input.min_team_size > input.max_team_size) {
|
|
26
|
+
problems.push(`min_team_size (${input.min_team_size}) is greater than max_team_size (${input.max_team_size}).`);
|
|
27
|
+
}
|
|
28
|
+
if (!/^[a-z0-9][a-z0-9-_]*$/i.test(input.profile_name)) {
|
|
29
|
+
problems.push(`profile_name "${input.profile_name}" should be letters, digits, "-" or "_". Game clients send it on every ticket.`);
|
|
30
|
+
}
|
|
31
|
+
const expiration = input.ticket_expiration ?? '5m';
|
|
32
|
+
const removal = input.ticket_removal ?? '1m';
|
|
33
|
+
for (const [field, value] of [['ticket_expiration', expiration], ['ticket_removal', removal]]) {
|
|
34
|
+
if (!DURATION.test(value))
|
|
35
|
+
problems.push(`${field} "${value}" is not a duration like "30s", "5m" or "1h".`);
|
|
36
|
+
}
|
|
37
|
+
if (seconds(expiration) < 60) {
|
|
38
|
+
cautions.push(`ticket_expiration ${expiration} is short. Tickets must outlive the queue wait plus server start-up, or players are dropped before the match is ready.`);
|
|
39
|
+
}
|
|
40
|
+
const rules = {
|
|
41
|
+
match_size: {
|
|
42
|
+
type: 'player_count',
|
|
43
|
+
attributes: {
|
|
44
|
+
team_count: input.team_count,
|
|
45
|
+
min_team_size: input.min_team_size,
|
|
46
|
+
max_team_size: input.max_team_size,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
const useLatency = input.max_latency_ms !== undefined || input.latency_difference_ms !== undefined;
|
|
51
|
+
if (useLatency) {
|
|
52
|
+
rules.beacons = {
|
|
53
|
+
type: 'latencies',
|
|
54
|
+
attributes: {
|
|
55
|
+
difference: input.latency_difference_ms ?? 100,
|
|
56
|
+
max_latency: input.max_latency_ms ?? 200,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const expansions = {};
|
|
61
|
+
let lastAfter = 0;
|
|
62
|
+
for (const e of [...(input.expansions ?? [])].sort((a, b) => a.after_seconds - b.after_seconds)) {
|
|
63
|
+
const step = {};
|
|
64
|
+
if (e.min_team_size !== undefined) {
|
|
65
|
+
if (e.min_team_size > input.max_team_size) {
|
|
66
|
+
problems.push(`expansion at ${e.after_seconds}s sets min_team_size ${e.min_team_size} above max_team_size ${input.max_team_size}.`);
|
|
67
|
+
}
|
|
68
|
+
step.match_size = { min_team_size: e.min_team_size };
|
|
69
|
+
}
|
|
70
|
+
if (e.max_latency_ms !== undefined) {
|
|
71
|
+
if (!useLatency) {
|
|
72
|
+
problems.push(`expansion at ${e.after_seconds}s relaxes max_latency_ms, but no latency rule is configured. Set max_latency_ms on the profile too.`);
|
|
73
|
+
}
|
|
74
|
+
step.beacons = { max_latency: e.max_latency_ms };
|
|
75
|
+
}
|
|
76
|
+
if (Object.keys(step).length === 0)
|
|
77
|
+
continue;
|
|
78
|
+
if (e.after_seconds === lastAfter)
|
|
79
|
+
problems.push(`two expansions share after_seconds ${e.after_seconds}.`);
|
|
80
|
+
lastAfter = e.after_seconds;
|
|
81
|
+
expansions[String(e.after_seconds)] = step;
|
|
82
|
+
}
|
|
83
|
+
if (input.team_count * input.min_team_size === 1) {
|
|
84
|
+
cautions.push('A match of one player starts a server per ticket. Fine for testing, costly in production.');
|
|
85
|
+
}
|
|
86
|
+
const config = {
|
|
87
|
+
version: input.config_version ?? MATCHMAKER_CONFIG_VERSION,
|
|
88
|
+
inspect: input.inspect ?? true,
|
|
89
|
+
max_deployment_retry_count: 3,
|
|
90
|
+
profiles: {
|
|
91
|
+
[input.profile_name]: {
|
|
92
|
+
ticket_expiration_period: expiration,
|
|
93
|
+
ticket_removal_period: removal,
|
|
94
|
+
group_inactivity_removal_period: '5m',
|
|
95
|
+
application: { name: input.application, version: input.version },
|
|
96
|
+
rules: { initial: rules, expansions },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
return { config, problems, cautions };
|
|
101
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static checks for a game server Dockerfile and its Edgegap port config.
|
|
3
|
+
*
|
|
4
|
+
* This is the step an agent gets wrong most often and learns about latest:
|
|
5
|
+
* a Dockerfile that builds and runs locally, then fails on Edgegap because the
|
|
6
|
+
* image is arm64, the server runs as root under Unreal, the port the server
|
|
7
|
+
* listens on is not the port the version exposes, or the protocol is TCP where
|
|
8
|
+
* the transport speaks UDP. Every one of those costs a build, a push, a version
|
|
9
|
+
* and a deployment to discover. Checking the text first costs nothing.
|
|
10
|
+
*
|
|
11
|
+
* No network, no Docker daemon. The rules come from the Dockerfiles Edgegap
|
|
12
|
+
* ships in its Unity and Unreal plugins, and from the constraints the API
|
|
13
|
+
* enforces on app versions.
|
|
14
|
+
*/
|
|
15
|
+
export const PROTOCOLS = ['UDP', 'TCP', 'TCP/UDP', 'HTTP', 'HTTPS', 'WS', 'WSS'];
|
|
16
|
+
/** Transport → protocol it needs on the wire. Keys are lowercase. */
|
|
17
|
+
const NETCODE_PROTOCOL = {
|
|
18
|
+
'mirror-kcp': 'UDP',
|
|
19
|
+
kcp: 'UDP',
|
|
20
|
+
'mirror-telepathy': 'TCP',
|
|
21
|
+
telepathy: 'TCP',
|
|
22
|
+
'mirror-simpleweb': 'WS',
|
|
23
|
+
simpleweb: 'WS',
|
|
24
|
+
websocket: 'WS',
|
|
25
|
+
'fishnet-tugboat': 'UDP',
|
|
26
|
+
tugboat: 'UDP',
|
|
27
|
+
'netcode-for-gameobjects': 'UDP',
|
|
28
|
+
ngo: 'UDP',
|
|
29
|
+
'unity-transport': 'UDP',
|
|
30
|
+
utp: 'UDP',
|
|
31
|
+
'photon-fusion': 'UDP',
|
|
32
|
+
litenetlib: 'UDP',
|
|
33
|
+
enet: 'UDP',
|
|
34
|
+
'unreal-netdriver': 'UDP',
|
|
35
|
+
'godot-enet': 'UDP',
|
|
36
|
+
'godot-websocket': 'WS',
|
|
37
|
+
};
|
|
38
|
+
export const NETCODE_NAMES = Object.keys(NETCODE_PROTOCOL);
|
|
39
|
+
/** Edgegap's own plugin Dockerfiles, lightly commented. Returned when the
|
|
40
|
+
* agent has none yet, or has one that fails, so it starts from known-good. */
|
|
41
|
+
export const REFERENCE_DOCKERFILES = {
|
|
42
|
+
unity: [
|
|
43
|
+
'FROM ubuntu:22.04',
|
|
44
|
+
'',
|
|
45
|
+
'ARG DEBIAN_FRONTEND=noninteractive',
|
|
46
|
+
'# Folder containing the Linux Dedicated Server build (ServerBuild, *_Data, UnityPlayer.so).',
|
|
47
|
+
'ARG SERVER_BUILD_PATH=Builds/EdgegapServer',
|
|
48
|
+
'',
|
|
49
|
+
'COPY ${SERVER_BUILD_PATH} /root/build/',
|
|
50
|
+
'WORKDIR /root/',
|
|
51
|
+
'RUN chmod +x /root/build/ServerBuild',
|
|
52
|
+
'',
|
|
53
|
+
'RUN apt-get update && \\',
|
|
54
|
+
' apt-get install -y ca-certificates && \\',
|
|
55
|
+
' apt-get clean && \\',
|
|
56
|
+
' update-ca-certificates',
|
|
57
|
+
'',
|
|
58
|
+
'# Documentation only; the port that matters is the one on the app version.',
|
|
59
|
+
'EXPOSE 7777/udp',
|
|
60
|
+
'',
|
|
61
|
+
'CMD ["/bin/bash", "-c", "env;/root/build/ServerBuild -batchmode -nographics $UNITY_COMMANDLINE_ARGS"]',
|
|
62
|
+
].join('\n'),
|
|
63
|
+
unreal: [
|
|
64
|
+
'FROM ubuntu:22.04',
|
|
65
|
+
'',
|
|
66
|
+
'RUN apt-get update && \\',
|
|
67
|
+
' apt-get install -y sudo jq curl && \\',
|
|
68
|
+
' apt-get clean && \\',
|
|
69
|
+
' rm -rf /var/lib/{apt,dpkg,cache,log}/',
|
|
70
|
+
'',
|
|
71
|
+
'# Unreal refuses to start as root, so run as an unprivileged user.',
|
|
72
|
+
'RUN useradd -rm -d /home/ubuntu -s /bin/bash -g root -G sudo -u 1000 m -o',
|
|
73
|
+
'',
|
|
74
|
+
'WORKDIR /app',
|
|
75
|
+
'# Contents of the packaged LinuxServer folder, plus StartServer.sh.',
|
|
76
|
+
'COPY --chown=m:sudo . /app',
|
|
77
|
+
'',
|
|
78
|
+
'# Scripts written on Windows carry CRLF endings, which break the shebang.',
|
|
79
|
+
"RUN sed -i 's/\\r$//' /app/StartServer.sh && chmod +x /app/StartServer.sh",
|
|
80
|
+
'',
|
|
81
|
+
'USER m',
|
|
82
|
+
'EXPOSE 7777/udp',
|
|
83
|
+
'',
|
|
84
|
+
'CMD ./StartServer.sh',
|
|
85
|
+
].join('\n'),
|
|
86
|
+
};
|
|
87
|
+
/** Joins backslash continuations and drops comments, keeping the start line. */
|
|
88
|
+
function parseDockerfile(text) {
|
|
89
|
+
const out = [];
|
|
90
|
+
const lines = text.replace(/\r\n?/g, '\n').split('\n');
|
|
91
|
+
let buf = '';
|
|
92
|
+
let start = 0;
|
|
93
|
+
for (let i = 0; i < lines.length; i++) {
|
|
94
|
+
const raw = lines[i];
|
|
95
|
+
const trimmed = raw.trim();
|
|
96
|
+
if (!buf && (trimmed === '' || trimmed.startsWith('#')))
|
|
97
|
+
continue;
|
|
98
|
+
if (!buf)
|
|
99
|
+
start = i + 1;
|
|
100
|
+
if (trimmed.endsWith('\\')) {
|
|
101
|
+
buf += trimmed.slice(0, -1) + ' ';
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
buf += trimmed;
|
|
105
|
+
const m = buf.match(/^(\w+)\s*(.*)$/s);
|
|
106
|
+
if (m)
|
|
107
|
+
out.push({ line: start, op: m[1].toUpperCase(), args: m[2].trim() });
|
|
108
|
+
buf = '';
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
function detectEngine(text) {
|
|
113
|
+
if (/-batchmode|-nographics|UNITY_|UnityPlayer|\.x86_64\b/i.test(text))
|
|
114
|
+
return 'unity';
|
|
115
|
+
if (/StartServer\.sh|UnrealServer|Binaries\/Linux|LinuxServer|Server\.sh\b/i.test(text))
|
|
116
|
+
return 'unreal';
|
|
117
|
+
if (/godot|--headless|\.pck\b/i.test(text))
|
|
118
|
+
return 'godot';
|
|
119
|
+
return 'other';
|
|
120
|
+
}
|
|
121
|
+
function parseExpose(args) {
|
|
122
|
+
return args
|
|
123
|
+
.split(/\s+/)
|
|
124
|
+
.filter(Boolean)
|
|
125
|
+
.map((tok) => {
|
|
126
|
+
const [p, proto] = tok.split('/');
|
|
127
|
+
return { port: Number.parseInt(p, 10), protocol: (proto ?? 'tcp').toUpperCase() };
|
|
128
|
+
})
|
|
129
|
+
.filter((e) => Number.isFinite(e.port));
|
|
130
|
+
}
|
|
131
|
+
/** Does an Edgegap port protocol carry this EXPOSE protocol (tcp|udp)? */
|
|
132
|
+
function carries(edgegapProtocol, exposeProtocol) {
|
|
133
|
+
const p = edgegapProtocol.toUpperCase();
|
|
134
|
+
if (p === 'TCP/UDP')
|
|
135
|
+
return true;
|
|
136
|
+
if (exposeProtocol === 'UDP')
|
|
137
|
+
return p === 'UDP';
|
|
138
|
+
return p !== 'UDP'; // TCP, HTTP, HTTPS, WS, WSS all ride on TCP
|
|
139
|
+
}
|
|
140
|
+
export function validateServerConfig(input) {
|
|
141
|
+
const findings = [];
|
|
142
|
+
const err = (code, message, fix, line) => findings.push({ severity: 'error', code, message, fix, line });
|
|
143
|
+
const warn = (code, message, fix, line) => findings.push({ severity: 'warning', code, message, fix, line });
|
|
144
|
+
let engine = input.engine ?? 'other';
|
|
145
|
+
const exposed = [];
|
|
146
|
+
// ------------------------------------------------------------ Dockerfile --
|
|
147
|
+
if (input.dockerfile !== undefined) {
|
|
148
|
+
const text = input.dockerfile;
|
|
149
|
+
const ins = parseDockerfile(text);
|
|
150
|
+
if (!input.engine)
|
|
151
|
+
engine = detectEngine(text);
|
|
152
|
+
const froms = ins.filter((i) => i.op === 'FROM');
|
|
153
|
+
const finalFrom = froms[froms.length - 1];
|
|
154
|
+
if (!finalFrom) {
|
|
155
|
+
err('no-from', 'The Dockerfile has no FROM instruction.', 'Start from a Linux base image, e.g. "FROM ubuntu:22.04".');
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
const platform = finalFrom.args.match(/--platform=(\S+)/)?.[1];
|
|
159
|
+
const image = finalFrom.args.replace(/--\S+/g, '').trim().split(/\s+/)[0] ?? '';
|
|
160
|
+
if (platform && platform.toLowerCase() !== 'linux/amd64' && !platform.includes('$')) {
|
|
161
|
+
err('wrong-platform', `Final stage is pinned to ${platform}. Edgegap runs linux/amd64 only.`, 'Use "FROM --platform=linux/amd64 ..." or drop the flag and build with "docker build --platform linux/amd64".', finalFrom.line);
|
|
162
|
+
}
|
|
163
|
+
if (/(^|\/)(arm64v8|arm32v7|arm32v6)\//i.test(image) || /[-:]arm64\b/i.test(image)) {
|
|
164
|
+
err('arm-base-image', `Base image "${image}" is an ARM image. Edgegap runs linux/amd64 only.`, 'Use the multi-arch or amd64 variant of the image.', finalFrom.line);
|
|
165
|
+
}
|
|
166
|
+
if (/windows|nanoserver|servercore/i.test(image)) {
|
|
167
|
+
err('windows-base-image', `Base image "${image}" is a Windows container. Edgegap runs Linux containers.`, 'Build a Linux dedicated server and use a Linux base such as ubuntu:22.04.', finalFrom.line);
|
|
168
|
+
}
|
|
169
|
+
if (image && image !== 'scratch' && !image.includes('$') && (!/[:@]/.test(image.split('/').pop() ?? '') || image.endsWith(':latest'))) {
|
|
170
|
+
warn('unpinned-base', `Base image "${image}" is unpinned or uses "latest", so rebuilds are not reproducible.`, 'Pin a version, e.g. ubuntu:22.04.', finalFrom.line);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const run = [...ins].reverse().find((i) => i.op === 'CMD' || i.op === 'ENTRYPOINT');
|
|
174
|
+
if (!run) {
|
|
175
|
+
err('no-cmd', 'No CMD or ENTRYPOINT: the container will exit as soon as it starts and the deployment will error.', 'Add a CMD that launches the server binary in the foreground.');
|
|
176
|
+
}
|
|
177
|
+
const launch = ins.filter((i) => i.op === 'CMD' || i.op === 'ENTRYPOINT').map((i) => i.args).join(' ');
|
|
178
|
+
if (/\.exe\b/i.test(ins.filter((i) => ['COPY', 'ADD', 'CMD', 'ENTRYPOINT'].includes(i.op)).map((i) => i.args).join(' '))) {
|
|
179
|
+
err('windows-binary', 'The image copies or launches a .exe. Edgegap runs Linux containers, so a Windows build cannot start.', 'Build the Linux dedicated server target (Unity: Linux Dedicated Server; Unreal: LinuxServer).');
|
|
180
|
+
}
|
|
181
|
+
if (/\b(127\.0\.0\.1|localhost)\b/.test(launch)) {
|
|
182
|
+
warn('loopback-bind', 'The launch command mentions 127.0.0.1/localhost. A server bound to loopback is unreachable from outside the container.', 'Bind to 0.0.0.0 (or omit the address so the server listens on all interfaces).', run?.line);
|
|
183
|
+
}
|
|
184
|
+
for (const e of ins.filter((i) => i.op === 'EXPOSE')) {
|
|
185
|
+
for (const p of parseExpose(e.args)) {
|
|
186
|
+
exposed.push({ ...p, line: e.line });
|
|
187
|
+
if (p.port === 22) {
|
|
188
|
+
warn('ssh-exposed', 'Port 22 (SSH) is exposed. Edgegap advises never exposing SSH in production images.', 'Remove EXPOSE 22 and the sshd start-up from the production image.', e.line);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const users = ins.filter((i) => i.op === 'USER');
|
|
193
|
+
const finalUser = users[users.length - 1]?.args.split(':')[0];
|
|
194
|
+
const runsAsRoot = !finalUser || finalUser === 'root' || finalUser === '0';
|
|
195
|
+
if (engine === 'unity') {
|
|
196
|
+
if (run && !/-batchmode/.test(launch)) {
|
|
197
|
+
warn('unity-no-batchmode', 'Unity server launched without -batchmode.', 'Append "-batchmode -nographics" to the server command.', run.line);
|
|
198
|
+
}
|
|
199
|
+
if (run && !/-nographics/.test(launch)) {
|
|
200
|
+
warn('unity-no-nographics', 'Unity server launched without -nographics; it may try to initialise a GPU the container does not have.', 'Append "-nographics" to the server command.', run.line);
|
|
201
|
+
}
|
|
202
|
+
if (!/chmod\s+(\+x|[0-7]*[157][0-7]{0,2})/.test(text)) {
|
|
203
|
+
warn('no-chmod', 'No "chmod +x" on the server binary. Builds copied from Windows often lose the executable bit, giving "permission denied" at start.', 'Add "RUN chmod +x /path/to/ServerBuild".');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (engine === 'unreal') {
|
|
207
|
+
if (runsAsRoot) {
|
|
208
|
+
err('unreal-root', 'The container runs as root. Unreal Engine servers refuse to start as root and exit immediately.', 'Create a user (useradd ... -u 1000 m) and add "USER m" before CMD.', users[users.length - 1]?.line);
|
|
209
|
+
}
|
|
210
|
+
if (/\.sh\b/.test(launch) && !/sed\s+-i\s+['"]?s\/\\r\$\/\/|dos2unix/.test(text)) {
|
|
211
|
+
warn('crlf-script', 'The start script is not normalised to LF line endings. Scripts saved on Windows fail with "bad interpreter" or "not found".', "Add \"RUN sed -i 's/\\r$//' /app/StartServer.sh\" after the COPY.");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (engine === 'godot' && run && !/--headless/.test(launch)) {
|
|
215
|
+
warn('godot-no-headless', 'Godot server launched without --headless.', 'Add "--headless" to the server command.', run.line);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// ----------------------------------------------------------------- Ports --
|
|
219
|
+
const ports = input.ports;
|
|
220
|
+
if (ports !== undefined) {
|
|
221
|
+
if (ports.length === 0) {
|
|
222
|
+
err('no-ports', 'No ports configured. Players cannot connect to a version with no ports.', 'Add the port the server listens on, e.g. {port: 7777, protocol: "UDP"}.');
|
|
223
|
+
}
|
|
224
|
+
const seenPorts = new Map();
|
|
225
|
+
const seenNames = new Set();
|
|
226
|
+
ports.forEach((p, idx) => {
|
|
227
|
+
const proto = p.protocol.toUpperCase();
|
|
228
|
+
if (!PROTOCOLS.includes(proto)) {
|
|
229
|
+
err('bad-protocol', `ports[${idx}].protocol "${p.protocol}" is not one Edgegap accepts.`, `Use one of: ${PROTOCOLS.join(', ')}.`);
|
|
230
|
+
}
|
|
231
|
+
if (!Number.isInteger(p.port) || p.port < 1 || p.port > 59999) {
|
|
232
|
+
err('port-range', `ports[${idx}].port ${p.port} is outside 1-59999.`, 'Use the internal port the server listens on inside the container.');
|
|
233
|
+
}
|
|
234
|
+
if (seenPorts.has(p.port)) {
|
|
235
|
+
err('duplicate-port', `Port ${p.port} is listed twice.`, 'Use one entry with protocol "TCP/UDP" if the server needs both.');
|
|
236
|
+
}
|
|
237
|
+
seenPorts.set(p.port, idx);
|
|
238
|
+
const name = p.name ?? 'gameport';
|
|
239
|
+
if (seenNames.has(name)) {
|
|
240
|
+
err('duplicate-port-name', `Port name "${name}" is used twice. Deployment ports are keyed by name, so one would hide the other.`, 'Give every port a distinct name, e.g. "gameport" and "webport".');
|
|
241
|
+
}
|
|
242
|
+
seenNames.add(name);
|
|
243
|
+
});
|
|
244
|
+
if (input.netcode) {
|
|
245
|
+
const expected = NETCODE_PROTOCOL[input.netcode.toLowerCase()];
|
|
246
|
+
if (!expected) {
|
|
247
|
+
warn('unknown-netcode', `Netcode "${input.netcode}" is not one this check knows.`, `Known values: ${NETCODE_NAMES.join(', ')}.`);
|
|
248
|
+
}
|
|
249
|
+
else if (ports.length > 0 && !ports.some((p) => {
|
|
250
|
+
const proto = p.protocol.toUpperCase();
|
|
251
|
+
return proto === expected || (proto === 'TCP/UDP' && (expected === 'UDP' || expected === 'TCP')) || (expected === 'WS' && proto === 'WSS');
|
|
252
|
+
})) {
|
|
253
|
+
err('netcode-protocol', `${input.netcode} speaks ${expected}, but no configured port uses ${expected}. Clients will time out connecting.`, `Set the game port's protocol to ${expected}.`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Cross-check EXPOSE against the version ports. EXPOSE is documentation,
|
|
257
|
+
// so a mismatch is not fatal on its own, but it is the best signal we have
|
|
258
|
+
// for "the server listens on a different port than the version exposes".
|
|
259
|
+
if (exposed.length > 0) {
|
|
260
|
+
for (const p of ports) {
|
|
261
|
+
const match = exposed.find((e) => e.port === p.port);
|
|
262
|
+
if (!match) {
|
|
263
|
+
warn('port-not-exposed', `Port ${p.port} is configured on the version but not EXPOSEd in the Dockerfile, which suggests the server listens elsewhere.`, `Confirm the server listens on ${p.port}, and add "EXPOSE ${p.port}/${p.protocol.toUpperCase() === 'UDP' ? 'udp' : 'tcp'}".`);
|
|
264
|
+
}
|
|
265
|
+
else if (!carries(p.protocol, match.protocol)) {
|
|
266
|
+
warn('protocol-mismatch', `Dockerfile exposes ${p.port}/${match.protocol.toLowerCase()} but the version configures it as ${p.protocol}.`, 'Make the two agree; the version setting is the one Edgegap uses.', match.line);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
for (const e of exposed) {
|
|
270
|
+
if (e.port !== 22 && !ports.some((p) => p.port === e.port)) {
|
|
271
|
+
warn('exposed-not-configured', `Dockerfile exposes ${e.port}/${e.protocol.toLowerCase()} but no version port maps it, so players cannot reach it.`, `Add {port: ${e.port}, protocol: "${e.protocol}"} to the version ports if clients need it.`, e.line);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// ------------------------------------------------------------- Resources --
|
|
277
|
+
if (input.cpu_units !== undefined && input.cpu_units < 256) {
|
|
278
|
+
err('cpu-too-low', `cpu_units ${input.cpu_units} is below the 256 minimum.`, 'Use at least 256 (1024 = 1 vCPU).');
|
|
279
|
+
}
|
|
280
|
+
if (input.memory_mb !== undefined && input.memory_mb < 256) {
|
|
281
|
+
err('memory-too-low', `memory_mb ${input.memory_mb} is below the 256 minimum.`, 'Use at least 256.');
|
|
282
|
+
}
|
|
283
|
+
if (input.cpu_units !== undefined && input.memory_mb !== undefined && input.memory_mb > input.cpu_units * 2) {
|
|
284
|
+
err('memory-ratio', `memory_mb (${input.memory_mb}) exceeds twice cpu_units (${input.cpu_units}). Edgegap rejects this.`, `Lower memory_mb to ${input.cpu_units * 2} or raise cpu_units.`);
|
|
285
|
+
}
|
|
286
|
+
// ----------------------------------------------------------------- Image --
|
|
287
|
+
if (input.docker_tag !== undefined && (input.docker_tag === '' || input.docker_tag === 'latest')) {
|
|
288
|
+
err('latest-tag', `docker_tag "${input.docker_tag || '(empty)'}" is not reproducible, and Edgegap caches images by tag, so a re-pushed "latest" can deploy a stale build.`, 'Tag every build uniquely, e.g. a build ID or timestamp.');
|
|
289
|
+
}
|
|
290
|
+
if (input.docker_repository?.includes('registry.edgegap.com') && input.docker_image && !input.docker_image.includes('/')) {
|
|
291
|
+
err('missing-project', `docker_image "${input.docker_image}" has no project prefix. Images on registry.edgegap.com live under your project.`, 'Use "<project>/<image>"; edgegap_get_registry_credentials returns the project name.');
|
|
292
|
+
}
|
|
293
|
+
if (input.docker_repository && /^https?:\/\//.test(input.docker_repository)) {
|
|
294
|
+
err('repository-scheme', `docker_repository "${input.docker_repository}" includes a URL scheme.`, 'Use the bare host, e.g. "registry.edgegap.com".');
|
|
295
|
+
}
|
|
296
|
+
const errors = findings.filter((f) => f.severity === 'error');
|
|
297
|
+
const warnings = findings.filter((f) => f.severity === 'warning');
|
|
298
|
+
return { engine, errors, warnings };
|
|
299
|
+
}
|
package/dist/tools.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The golden-path tools: getting a server image right and into a registry,
|
|
3
|
+
* deploying it, and the two things players need around it — a relay for
|
|
4
|
+
* peer-to-peer games and a matchmaker config for dedicated ones.
|
|
3
5
|
*
|
|
4
6
|
* Tool descriptions are written for a coding agent, not a human reading docs.
|
|
5
7
|
* Each one says when to reach for it and what to call next, because the main
|
|
@@ -9,6 +11,8 @@ import { z } from 'zod';
|
|
|
9
11
|
import { EdgegapApiError } from './client.js';
|
|
10
12
|
import { assertAppAllowed, redact } from './config.js';
|
|
11
13
|
import { TokenUnavailableError } from './auth.js';
|
|
14
|
+
import { validateServerConfig, REFERENCE_DOCKERFILES, PROTOCOLS, NETCODE_NAMES, } from './serverconfig.js';
|
|
15
|
+
import { buildMatchmakerConfig, DASHBOARD_URL } from './matchmaker.js';
|
|
12
16
|
/** 1x1 transparent PNG. The create-app endpoint requires an image and agents
|
|
13
17
|
* have no sensible one to supply; a placeholder beats a blocked flow. */
|
|
14
18
|
const PLACEHOLDER_IMAGE = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
|
|
@@ -227,7 +231,8 @@ export function registerTools(server, client, config, auth) {
|
|
|
227
231
|
server.registerTool('edgegap_create_app_version', {
|
|
228
232
|
title: 'Create an application version',
|
|
229
233
|
description: 'Register a container image as a deployable version of an application. The image must ' +
|
|
230
|
-
'already be pushed to a registry that Edgegap can pull from
|
|
234
|
+
'already be pushed to a registry that Edgegap can pull from (see ' +
|
|
235
|
+
'edgegap_get_registry_credentials and edgegap_list_registry_tags). Resource units: 1024 cpu ' +
|
|
231
236
|
'units = 1 vCPU; memory_mb must be at least 256 and at most double the cpu units. ' +
|
|
232
237
|
'Set verify_image true on the first version so a bad image fails here rather than at ' +
|
|
233
238
|
'deploy time. Avoid the "latest" docker tag — use a build ID so deployments are reproducible.',
|
|
@@ -250,7 +255,7 @@ export function registerTools(server, client, config, auth) {
|
|
|
250
255
|
port: z.number().int().min(1).max(59999).describe('Port the server listens on.'),
|
|
251
256
|
protocol: z
|
|
252
257
|
.string()
|
|
253
|
-
.describe(
|
|
258
|
+
.describe(`One of ${PROTOCOLS.join(', ')}. Most game servers use UDP.`),
|
|
254
259
|
name: z.string().optional().describe('Label, e.g. "gameport".'),
|
|
255
260
|
to_check: z
|
|
256
261
|
.boolean()
|
|
@@ -498,4 +503,377 @@ export function registerTools(server, client, config, auth) {
|
|
|
498
503
|
storage_link: res.logs_link ?? undefined,
|
|
499
504
|
});
|
|
500
505
|
}));
|
|
506
|
+
// ============================================= before the first deploy ====
|
|
507
|
+
// --------------------------------------------------------------- 11 ----
|
|
508
|
+
// Pure text analysis: no token, no network, so it is always registered and
|
|
509
|
+
// never goes through guard().
|
|
510
|
+
server.registerTool('edgegap_validate_server_config', {
|
|
511
|
+
title: 'Validate a game server Dockerfile and port config',
|
|
512
|
+
description: 'Check a game server Dockerfile and the ports/resources you intend to register against ' +
|
|
513
|
+
'what Edgegap requires, BEFORE building and pushing. Catches the failures that otherwise ' +
|
|
514
|
+
'only show up after a build, push, version and deploy: ARM or Windows images (Edgegap ' +
|
|
515
|
+
'runs linux/amd64), Unreal running as root, missing Unity -batchmode -nographics, a ' +
|
|
516
|
+
'server bound to localhost, EXPOSE ports that do not match the version ports, a protocol ' +
|
|
517
|
+
'that does not match the netcode transport, the "latest" tag, and bad CPU/memory ratios. ' +
|
|
518
|
+
'Pass the Dockerfile text (read it from disk first). With no Dockerfile and an engine of ' +
|
|
519
|
+
'unity or unreal, returns Edgegap\'s reference Dockerfile to start from. Makes no API calls.',
|
|
520
|
+
inputSchema: {
|
|
521
|
+
dockerfile: z.string().optional().describe('Full text of the Dockerfile.'),
|
|
522
|
+
engine: z
|
|
523
|
+
.enum(['unity', 'unreal', 'godot', 'other'])
|
|
524
|
+
.optional()
|
|
525
|
+
.describe('Game engine. Detected from the Dockerfile when omitted.'),
|
|
526
|
+
netcode: z
|
|
527
|
+
.string()
|
|
528
|
+
.optional()
|
|
529
|
+
.describe(`Networking transport, to check the port protocol. Known: ${NETCODE_NAMES.join(', ')}.`),
|
|
530
|
+
ports: z
|
|
531
|
+
.array(z.object({
|
|
532
|
+
port: z.number().int(),
|
|
533
|
+
protocol: z.string(),
|
|
534
|
+
name: z.string().optional(),
|
|
535
|
+
}))
|
|
536
|
+
.optional()
|
|
537
|
+
.describe('Ports you plan to pass to edgegap_create_app_version.'),
|
|
538
|
+
cpu_units: z.number().int().optional(),
|
|
539
|
+
memory_mb: z.number().int().optional(),
|
|
540
|
+
docker_repository: z.string().optional(),
|
|
541
|
+
docker_image: z.string().optional(),
|
|
542
|
+
docker_tag: z.string().optional(),
|
|
543
|
+
},
|
|
544
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
|
|
545
|
+
}, async (args) => {
|
|
546
|
+
const { engine, errors, warnings } = validateServerConfig(args);
|
|
547
|
+
const reference = REFERENCE_DOCKERFILES[engine];
|
|
548
|
+
const wantReference = reference && (args.dockerfile === undefined || errors.length > 0);
|
|
549
|
+
const verdict = errors.length > 0 ? 'fail' : warnings.length > 0 ? 'pass_with_warnings' : 'pass';
|
|
550
|
+
return ok({
|
|
551
|
+
verdict,
|
|
552
|
+
engine,
|
|
553
|
+
errors,
|
|
554
|
+
warnings,
|
|
555
|
+
checked: {
|
|
556
|
+
dockerfile: args.dockerfile !== undefined,
|
|
557
|
+
ports: args.ports !== undefined,
|
|
558
|
+
resources: args.cpu_units !== undefined || args.memory_mb !== undefined,
|
|
559
|
+
image: args.docker_tag !== undefined || args.docker_image !== undefined,
|
|
560
|
+
},
|
|
561
|
+
reference_dockerfile: wantReference ? reference : undefined,
|
|
562
|
+
next_step: verdict === 'fail'
|
|
563
|
+
? 'Fix every error and call this again before building. Do not build or push an image that fails here.'
|
|
564
|
+
: 'Build with "docker build --platform linux/amd64 -t <image>:<unique-tag> ." and run it ' +
|
|
565
|
+
'locally with the same port mapping to confirm it starts. Then push it; ' +
|
|
566
|
+
'edgegap_get_registry_credentials gives you a registry and the exact commands.',
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
// --------------------------------------------------------------- 12 ----
|
|
570
|
+
// Mutating-only: it can provision the registry project, and it hands a
|
|
571
|
+
// secret to the agent. A read-only session has no business receiving one.
|
|
572
|
+
if (mutating) {
|
|
573
|
+
server.registerTool('edgegap_get_registry_credentials', {
|
|
574
|
+
title: 'Get Edgegap container registry push credentials',
|
|
575
|
+
description: 'Return the registry URL, project, username and token for this organization\'s private ' +
|
|
576
|
+
'Edgegap container registry (registry.edgegap.com), plus the exact docker login, build ' +
|
|
577
|
+
'and push commands for your image. Use this when the server image is not in a registry ' +
|
|
578
|
+
'yet — no Docker Hub account needed. Provisions the registry project on first use. The ' +
|
|
579
|
+
'token is registry-scoped (push/pull images in this project), not the org API token: ' +
|
|
580
|
+
'pass it to docker login via --password-stdin, never as a command-line argument, and do ' +
|
|
581
|
+
'not write it into files. Call edgegap_validate_server_config before building.',
|
|
582
|
+
inputSchema: {
|
|
583
|
+
image_name: z
|
|
584
|
+
.string()
|
|
585
|
+
.regex(/^[a-z0-9]+([._-][a-z0-9]+)*$/, 'lowercase letters, digits, ".", "_" or "-"')
|
|
586
|
+
.optional()
|
|
587
|
+
.describe('Image name without project or tag, e.g. "my-game-server". Used to build the commands.'),
|
|
588
|
+
tag: z
|
|
589
|
+
.string()
|
|
590
|
+
.optional()
|
|
591
|
+
.describe('Unique build tag for the commands, e.g. a build ID. Never "latest".'),
|
|
592
|
+
},
|
|
593
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
594
|
+
}, async ({ image_name, tag }) => guard(auth, async () => {
|
|
595
|
+
if (tag === 'latest') {
|
|
596
|
+
return fail('Do not use the "latest" tag: Edgegap caches by tag, so a re-pushed "latest" can deploy a stale build. Pass a build ID or timestamp.');
|
|
597
|
+
}
|
|
598
|
+
let creds;
|
|
599
|
+
try {
|
|
600
|
+
creds = await client.getRegistryCredentials();
|
|
601
|
+
}
|
|
602
|
+
catch (err) {
|
|
603
|
+
if (!(err instanceof EdgegapApiError) || err.status === 401)
|
|
604
|
+
throw err;
|
|
605
|
+
// Usually means the registry project has not been provisioned for
|
|
606
|
+
// this org yet. The plugins provision it this way on every login.
|
|
607
|
+
await client.initQuickStart('mcp');
|
|
608
|
+
creds = await client.getRegistryCredentials();
|
|
609
|
+
}
|
|
610
|
+
if (!creds.project || !creds.username || !creds.token) {
|
|
611
|
+
return fail('Edgegap did not return registry credentials for this organization. The developer ' +
|
|
612
|
+
`can request them in the dashboard (${DASHBOARD_URL}, Container Registry page), or ` +
|
|
613
|
+
'push to any other registry Edgegap can pull from (Docker Hub, GHCR, ECR, GCR, GitLab) ' +
|
|
614
|
+
'and pass registry_username/registry_token to edgegap_create_app_version.');
|
|
615
|
+
}
|
|
616
|
+
const host = (creds.registry_url || 'registry.edgegap.com').replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
617
|
+
const image = image_name ?? '<image-name>';
|
|
618
|
+
const buildTag = tag ?? '<unique-build-tag>';
|
|
619
|
+
const ref = `${host}/${creds.project}/${image}:${buildTag}`;
|
|
620
|
+
return ok({
|
|
621
|
+
registry_url: host,
|
|
622
|
+
project: creds.project,
|
|
623
|
+
username: creds.username,
|
|
624
|
+
token: creds.token,
|
|
625
|
+
image_ref: ref,
|
|
626
|
+
commands: {
|
|
627
|
+
login: `printf '%s' "$EDGEGAP_REGISTRY_TOKEN" | docker login ${host} -u '${creds.username}' --password-stdin`,
|
|
628
|
+
build: `docker build --platform linux/amd64 -t ${ref} .`,
|
|
629
|
+
push: `docker push ${ref}`,
|
|
630
|
+
},
|
|
631
|
+
for_create_app_version: {
|
|
632
|
+
docker_repository: host,
|
|
633
|
+
docker_image: `${creds.project}/${image}`,
|
|
634
|
+
docker_tag: buildTag,
|
|
635
|
+
registry_username: creds.username,
|
|
636
|
+
registry_token: '<the token above>',
|
|
637
|
+
},
|
|
638
|
+
next_step: 'Export the token as EDGEGAP_REGISTRY_TOKEN in the shell that runs docker login ' +
|
|
639
|
+
'(it never needs to appear in a command line or file), then build and push. ' +
|
|
640
|
+
`Confirm the push with edgegap_list_registry_tags (image_name "${creds.project}/${image}"), ` +
|
|
641
|
+
'then call edgegap_create_app_version with the values in for_create_app_version.',
|
|
642
|
+
});
|
|
643
|
+
}));
|
|
644
|
+
}
|
|
645
|
+
// --------------------------------------------------------------- 13 ----
|
|
646
|
+
server.registerTool('edgegap_list_registry_tags', {
|
|
647
|
+
title: 'List image tags in the Edgegap registry',
|
|
648
|
+
description: 'List the tags pushed for one image in this organization\'s Edgegap container registry, ' +
|
|
649
|
+
'with push time and size. Call it after docker push to confirm the tag landed before ' +
|
|
650
|
+
'edgegap_create_app_version, which otherwise fails later with an image-pull error.',
|
|
651
|
+
inputSchema: {
|
|
652
|
+
image_name: z
|
|
653
|
+
.string()
|
|
654
|
+
.describe('"<project>/<image>", e.g. "my-org-cv2l3w3vy6fg/my-game-server". Project comes from edgegap_get_registry_credentials.'),
|
|
655
|
+
page: z.number().int().min(1).optional(),
|
|
656
|
+
limit: z.number().int().min(1).max(100).optional().describe('Default 20.'),
|
|
657
|
+
},
|
|
658
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
659
|
+
}, async ({ image_name, page, limit }) => guard(auth, async () => {
|
|
660
|
+
if (!image_name.includes('/')) {
|
|
661
|
+
return fail(`image_name "${image_name}" needs the project prefix: "<project>/${image_name}".`);
|
|
662
|
+
}
|
|
663
|
+
const res = await client.listRegistryTags(image_name, { page, limit: limit ?? 20 });
|
|
664
|
+
const tags = (res.data ?? []).map((t) => ({
|
|
665
|
+
tag: t.tag,
|
|
666
|
+
pushed: t.last_push_at,
|
|
667
|
+
size_mb: t.artifact?.size_mb,
|
|
668
|
+
digest: t.artifact?.image_hash,
|
|
669
|
+
}));
|
|
670
|
+
return ok({ image_name, ...pageInfo(res.total_count, tags.length, page ?? 1), tags });
|
|
671
|
+
}));
|
|
672
|
+
// ================================================== peer-to-peer relays ====
|
|
673
|
+
// --------------------------------------------------------------- 14 ----
|
|
674
|
+
if (mutating) {
|
|
675
|
+
server.registerTool('edgegap_create_relay_session', {
|
|
676
|
+
title: 'Create a relay session for a peer-to-peer game',
|
|
677
|
+
description: 'Create an Edgegap relay session so players in a peer-to-peer or host-client game connect ' +
|
|
678
|
+
'through the nearest relay instead of needing NAT punch-through or a dedicated server. ' +
|
|
679
|
+
'Use this for co-op and P2P games; use edgegap_deploy for dedicated servers. Needs no ' +
|
|
680
|
+
'application, version or image. Pass every player\'s public IP, host first. Waits until ' +
|
|
681
|
+
'the relay is ready and returns its address plus a per-player authorization token for the ' +
|
|
682
|
+
'relay transport. Relay sessions are billed while open: delete test sessions with ' +
|
|
683
|
+
'edgegap_delete_relay_session when finished.',
|
|
684
|
+
inputSchema: {
|
|
685
|
+
user_ips: z
|
|
686
|
+
.array(z.string().min(3))
|
|
687
|
+
.min(1)
|
|
688
|
+
.describe('Public IP of each player, host first. Add late joiners with edgegap_authorize_relay_user.'),
|
|
689
|
+
webhook_url: z.string().url().optional().describe('Called when the session is ready.'),
|
|
690
|
+
wait_until_ready: z.boolean().optional().describe('Poll until the relay is assigned. Default true.'),
|
|
691
|
+
timeout_seconds: z.number().int().min(5).max(120).optional().describe('Default 30.'),
|
|
692
|
+
},
|
|
693
|
+
annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
694
|
+
}, async ({ user_ips, webhook_url, wait_until_ready, timeout_seconds }) => guard(auth, async () => {
|
|
695
|
+
const created = await client.createRelaySession({
|
|
696
|
+
users: user_ips.map((ip) => ({ ip })),
|
|
697
|
+
...(webhook_url ? { webhook_url } : {}),
|
|
698
|
+
});
|
|
699
|
+
if (wait_until_ready === false) {
|
|
700
|
+
return ok({
|
|
701
|
+
...compactRelay(created),
|
|
702
|
+
next_step: `Call edgegap_get_relay_session with session_id ${created.session_id} until ready is true.`,
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
const budgetMs = (timeout_seconds ?? 30) * 1000;
|
|
706
|
+
const startedAt = Date.now();
|
|
707
|
+
let intervalMs = 1000;
|
|
708
|
+
let last = created;
|
|
709
|
+
while (!last.ready && !last.error && Date.now() - startedAt < budgetMs) {
|
|
710
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
711
|
+
intervalMs = Math.min(intervalMs * 1.5, 5000);
|
|
712
|
+
last = await client.getRelaySession(created.session_id);
|
|
713
|
+
}
|
|
714
|
+
if (last.error) {
|
|
715
|
+
return fail(`Relay session ${created.session_id} failed: ${last.error}\n\n` +
|
|
716
|
+
'Check that every IP is a public address (not 127.x, 10.x, 192.168.x), then delete ' +
|
|
717
|
+
'this session with edgegap_delete_relay_session and create a new one.');
|
|
718
|
+
}
|
|
719
|
+
if (!last.ready) {
|
|
720
|
+
return fail(`Relay session ${created.session_id} was not ready after ${timeout_seconds ?? 30}s ` +
|
|
721
|
+
`(status ${last.status ?? 'unknown'}). Call edgegap_get_relay_session to check again.`);
|
|
722
|
+
}
|
|
723
|
+
return ok({ ...compactRelay(last), waited_seconds: Math.round((Date.now() - startedAt) / 1000) });
|
|
724
|
+
}));
|
|
725
|
+
}
|
|
726
|
+
// --------------------------------------------------------------- 15 ----
|
|
727
|
+
server.registerTool('edgegap_get_relay_session', {
|
|
728
|
+
title: 'Get a relay session',
|
|
729
|
+
description: 'Read one relay session: whether it is ready, the relay address and ports, and each ' +
|
|
730
|
+
'authorized player with their authorization token. edgegap_create_relay_session already ' +
|
|
731
|
+
'waits for readiness; use this to re-read a session or one created with wait_until_ready false.',
|
|
732
|
+
inputSchema: { session_id: z.string().describe('Returned by edgegap_create_relay_session.') },
|
|
733
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
734
|
+
}, async ({ session_id }) => guard(auth, async () => ok(compactRelay(await client.getRelaySession(session_id)))));
|
|
735
|
+
if (mutating) {
|
|
736
|
+
// ------------------------------------------------------------- 16 ----
|
|
737
|
+
server.registerTool('edgegap_authorize_relay_user', {
|
|
738
|
+
title: 'Add a player to a relay session',
|
|
739
|
+
description: 'Authorize one more player (by public IP) on an existing relay session, for a player ' +
|
|
740
|
+
'joining after the session was created. Returns that player\'s authorization token.',
|
|
741
|
+
inputSchema: {
|
|
742
|
+
session_id: z.string(),
|
|
743
|
+
user_ip: z.string().min(3).describe('Public IP of the joining player.'),
|
|
744
|
+
},
|
|
745
|
+
annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
746
|
+
}, async ({ session_id, user_ip }) => guard(auth, async () => {
|
|
747
|
+
const res = await client.authorizeRelayUser({ session_id, user_ip });
|
|
748
|
+
return ok({
|
|
749
|
+
session_id: res.session_id,
|
|
750
|
+
user_ip,
|
|
751
|
+
user_authorization_token: res.session_user?.authorization_token,
|
|
752
|
+
session_authorization_token: res.authorization_token,
|
|
753
|
+
});
|
|
754
|
+
}));
|
|
755
|
+
// ------------------------------------------------------------- 17 ----
|
|
756
|
+
server.registerTool('edgegap_delete_relay_session', {
|
|
757
|
+
title: 'Delete a relay session',
|
|
758
|
+
description: 'Close one relay session. Connected players lose their relay connection. Delete every ' +
|
|
759
|
+
'session you created for testing before ending your task.',
|
|
760
|
+
inputSchema: { session_id: z.string() },
|
|
761
|
+
annotations: { destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
762
|
+
}, async ({ session_id }) => guard(auth, async () => {
|
|
763
|
+
await client.deleteRelaySession(session_id);
|
|
764
|
+
return ok({ session_id, result: 'deleted' });
|
|
765
|
+
}));
|
|
766
|
+
}
|
|
767
|
+
// ============================================================ matchmaker ====
|
|
768
|
+
// --------------------------------------------------------------- 18 ----
|
|
769
|
+
// Edgegap has no public API to create a matchmaker, so this does not create
|
|
770
|
+
// one. It produces the configuration the dashboard asks for, checked against
|
|
771
|
+
// the application version it points at.
|
|
772
|
+
server.registerTool('edgegap_build_matchmaker_config', {
|
|
773
|
+
title: 'Build a basic matchmaker configuration',
|
|
774
|
+
description: 'Generate a ready-to-upload Edgegap matchmaker JSON configuration with one profile: team ' +
|
|
775
|
+
'count and size, optional latency rule, and optional expansions that relax the rules the ' +
|
|
776
|
+
'longer a player waits. Checks the referenced application version exists and has ports. ' +
|
|
777
|
+
'Edgegap has no API for creating a matchmaker, so this tool does NOT create one: save the ' +
|
|
778
|
+
'returned config to a file (e.g. matchmaker-config.json) and have the developer upload it ' +
|
|
779
|
+
'on the Matchmaker page of the dashboard. Use it for dedicated-server games; P2P games ' +
|
|
780
|
+
'want edgegap_create_relay_session instead.',
|
|
781
|
+
inputSchema: {
|
|
782
|
+
profile_name: z.string().describe('Profile clients will queue into, e.g. "casual-2v2".'),
|
|
783
|
+
application: z.string().describe('Application the matchmaker deploys.'),
|
|
784
|
+
version: z.string().describe('Version the matchmaker deploys.'),
|
|
785
|
+
team_count: z.number().int().min(1).describe('Teams per match. 1 for free-for-all or co-op.'),
|
|
786
|
+
min_team_size: z.number().int().min(1),
|
|
787
|
+
max_team_size: z.number().int().min(1),
|
|
788
|
+
max_latency_ms: z
|
|
789
|
+
.number()
|
|
790
|
+
.int()
|
|
791
|
+
.min(1)
|
|
792
|
+
.optional()
|
|
793
|
+
.describe('Adds a latency rule: drop players above this ping to the chosen region. Needs beacon pings from the client.'),
|
|
794
|
+
latency_difference_ms: z.number().int().min(0).optional().describe('Max ping spread between matched players. Default 100.'),
|
|
795
|
+
expansions: z
|
|
796
|
+
.array(z.object({
|
|
797
|
+
after_seconds: z.number().int().min(1),
|
|
798
|
+
min_team_size: z.number().int().min(1).optional(),
|
|
799
|
+
max_latency_ms: z.number().int().min(1).optional(),
|
|
800
|
+
}))
|
|
801
|
+
.optional()
|
|
802
|
+
.describe('Rule relaxations after a player has waited this long, e.g. [{after_seconds: 30, min_team_size: 1}].'),
|
|
803
|
+
ticket_expiration: z.string().optional().describe('Default "5m".'),
|
|
804
|
+
inspect: z.boolean().optional().describe('Expose the inspection API for debugging. Default true; turn off for production.'),
|
|
805
|
+
verify_version: z.boolean().optional().describe('Look up the application version first. Default true.'),
|
|
806
|
+
},
|
|
807
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
|
|
808
|
+
}, async (args) => guard(auth, async () => {
|
|
809
|
+
assertAppAllowed(config, args.application);
|
|
810
|
+
const { config: mmConfig, problems, cautions } = buildMatchmakerConfig(args);
|
|
811
|
+
if (args.verify_version !== false) {
|
|
812
|
+
try {
|
|
813
|
+
const res = await client.listAppVersions(args.application);
|
|
814
|
+
const v = (res.versions ?? []).find((x) => x.name === args.version);
|
|
815
|
+
if (!v) {
|
|
816
|
+
problems.push(`Version "${args.version}" was not found in application "${args.application}". ` +
|
|
817
|
+
'Create it with edgegap_create_app_version, or pick one from edgegap_list_app_versions.');
|
|
818
|
+
}
|
|
819
|
+
else {
|
|
820
|
+
if (v.is_active === false)
|
|
821
|
+
problems.push(`Version "${args.version}" is inactive, so the matchmaker cannot deploy it.`);
|
|
822
|
+
if (!v.ports?.length)
|
|
823
|
+
problems.push(`Version "${args.version}" has no ports, so matched players have nothing to connect to.`);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
catch (err) {
|
|
827
|
+
if (err instanceof EdgegapApiError && err.status === 404) {
|
|
828
|
+
problems.push(`Application "${args.application}" does not exist. Call edgegap_list_apps.`);
|
|
829
|
+
}
|
|
830
|
+
else {
|
|
831
|
+
throw err;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
if (problems.length > 0) {
|
|
836
|
+
return fail('The matchmaker configuration has problems; fix them before uploading:\n- ' +
|
|
837
|
+
problems.join('\n- ') +
|
|
838
|
+
`\n\nDraft config:\n${JSON.stringify(mmConfig, null, 2)}`);
|
|
839
|
+
}
|
|
840
|
+
return ok({
|
|
841
|
+
config: mmConfig,
|
|
842
|
+
cautions: cautions.length ? cautions : undefined,
|
|
843
|
+
next_steps: [
|
|
844
|
+
'Write config to matchmaker-config.json in the project.',
|
|
845
|
+
`The developer uploads it in the dashboard (${DASHBOARD_URL}, Matchmaker page, Create Matchmaker) and waits for it to show as ready. The free tier runs on a shared test cluster for up to 3 hours per restart.`,
|
|
846
|
+
'The dashboard then shows the matchmaker API URL and auth token. Game clients call POST {api_url}/tickets ' +
|
|
847
|
+
`with header "Authorization: <auth token>" and profile "${args.profile_name}", then poll GET {api_url}/memberships/{id} until it returns the server address.`,
|
|
848
|
+
'That auth token is safe to ship in game clients: it grants no access to the Edgegap API.',
|
|
849
|
+
],
|
|
850
|
+
});
|
|
851
|
+
}));
|
|
852
|
+
}
|
|
853
|
+
/** Trims a relay session down to what a game client integration needs. */
|
|
854
|
+
function compactRelay(s) {
|
|
855
|
+
return {
|
|
856
|
+
session_id: s.session_id,
|
|
857
|
+
ready: s.ready ?? false,
|
|
858
|
+
status: s.status,
|
|
859
|
+
error: s.error || undefined,
|
|
860
|
+
session_authorization_token: s.authorization_token,
|
|
861
|
+
relay: s.relay
|
|
862
|
+
? {
|
|
863
|
+
host: s.relay.host,
|
|
864
|
+
ip: s.relay.ip,
|
|
865
|
+
server_port: s.relay.ports?.server,
|
|
866
|
+
client_port: s.relay.ports?.client,
|
|
867
|
+
}
|
|
868
|
+
: undefined,
|
|
869
|
+
users: (s.session_users ?? []).map((u) => ({
|
|
870
|
+
ip: u.ip_address,
|
|
871
|
+
authorization_token: u.authorization_token,
|
|
872
|
+
})),
|
|
873
|
+
how_to_connect: s.ready
|
|
874
|
+
? 'Configure the Edgegap relay transport with the relay address, the session authorization ' +
|
|
875
|
+
'token, and each player\'s own authorization token. The host connects on server_port; ' +
|
|
876
|
+
'every other player connects on client_port.'
|
|
877
|
+
: undefined,
|
|
878
|
+
};
|
|
501
879
|
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgegap/mcp",
|
|
3
3
|
"mcpName": "dev.edgegap/mcp",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"description": "
|
|
4
|
+
"version": "0.2.0",
|
|
5
|
+
"description": "Check, push, and deploy game servers on Edgegap from your coding agent, with relays for P2P games. Runs locally; your API token never leaves your machine.",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"prebuild": "rm -rf dist",
|
|
8
8
|
"build": "tsc && chmod +x dist/index.js",
|
|
9
9
|
"typecheck": "tsc --noEmit",
|
|
10
10
|
"typecheck:worker": "tsc -p worker/tsconfig.json --noEmit",
|
|
11
|
-
"test": "node smoke.mjs && node guards.mjs && node elicit.mjs",
|
|
11
|
+
"test": "node smoke.mjs && node guards.mjs && node elicit.mjs && node newtools.mjs",
|
|
12
12
|
"worker:dev": "wrangler dev --config worker/wrangler.jsonc --local --port 8787",
|
|
13
13
|
"worker:check": "wrangler deploy --config worker/wrangler.jsonc --dry-run --outdir=.wrangler/dryrun",
|
|
14
14
|
"worker:deploy": "wrangler deploy --config worker/wrangler.jsonc",
|