@edgegap/mcp 0.1.2 → 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 +183 -58
- package/dist/auth.js +14 -8
- 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 +388 -4
- package/package.json +3 -3
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==';
|
|
@@ -55,7 +59,13 @@ function guard(auth, fn) {
|
|
|
55
59
|
function errorHint(err) {
|
|
56
60
|
switch (err.status) {
|
|
57
61
|
case 401:
|
|
58
|
-
|
|
62
|
+
// Deliberately does not promise a re-prompt: the local server asks again
|
|
63
|
+
// on the next call, the hosted relay cannot, and a hint that lies about
|
|
64
|
+
// what happens next sends the developer looking in the wrong place.
|
|
65
|
+
return ('Edgegap rejected the token. It may be expired, revoked, or from a ' +
|
|
66
|
+
'different organization. Check it at ' +
|
|
67
|
+
'https://app.edgegap.com/user-settings?tab=tokens. The token has been ' +
|
|
68
|
+
'discarded from this session.');
|
|
59
69
|
case 404:
|
|
60
70
|
return 'the application or version name does not exist. Call edgegap_list_apps first.';
|
|
61
71
|
case 409:
|
|
@@ -221,7 +231,8 @@ export function registerTools(server, client, config, auth) {
|
|
|
221
231
|
server.registerTool('edgegap_create_app_version', {
|
|
222
232
|
title: 'Create an application version',
|
|
223
233
|
description: 'Register a container image as a deployable version of an application. The image must ' +
|
|
224
|
-
'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 ' +
|
|
225
236
|
'units = 1 vCPU; memory_mb must be at least 256 and at most double the cpu units. ' +
|
|
226
237
|
'Set verify_image true on the first version so a bad image fails here rather than at ' +
|
|
227
238
|
'deploy time. Avoid the "latest" docker tag — use a build ID so deployments are reproducible.',
|
|
@@ -244,7 +255,7 @@ export function registerTools(server, client, config, auth) {
|
|
|
244
255
|
port: z.number().int().min(1).max(59999).describe('Port the server listens on.'),
|
|
245
256
|
protocol: z
|
|
246
257
|
.string()
|
|
247
|
-
.describe(
|
|
258
|
+
.describe(`One of ${PROTOCOLS.join(', ')}. Most game servers use UDP.`),
|
|
248
259
|
name: z.string().optional().describe('Label, e.g. "gameport".'),
|
|
249
260
|
to_check: z
|
|
250
261
|
.boolean()
|
|
@@ -492,4 +503,377 @@ export function registerTools(server, client, config, auth) {
|
|
|
492
503
|
storage_link: res.logs_link ?? undefined,
|
|
493
504
|
});
|
|
494
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
|
+
};
|
|
495
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",
|