gameye-mcp 0.4.0 → 0.5.1

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 CHANGED
@@ -12,8 +12,23 @@ A [Model Context Protocol](https://modelcontextprotocol.io) server for the [Game
12
12
  GAMEYE_API_TOKEN=<your-token> npx -y gameye-mcp
13
13
  ```
14
14
 
15
+ No Gameye account yet? Start it without a token, `npx -y gameye-mcp`, and your agent can still read the bundled Gameye skills and check a Dockerfile. See [Without a token](#without-a-token).
16
+
15
17
  It needs Node.js 22 or newer. The server speaks MCP over stdio. Normally your MCP client starts it for you (see [Install](#install)), so you rarely need to run it by hand.
16
18
 
19
+ ## Without a token
20
+
21
+ You can connect the server before you have a Gameye account. Leave `GAMEYE_API_TOKEN` out of any install below, and the server starts in no-token mode and prints one line on stderr saying so.
22
+
23
+ - These tools work, and send nothing to the Gameye API: `gameye_context`, `gameye_list_skills` and `gameye_get_skill` (the Gameye skills bundled with the server), `gameye_get_template`, and `gameye_check_dockerfile`. `gameye_update_skills` works too, and reaches GitHub only when called.
24
+ - Every other tool is an account tool. Without a token it sends nothing and returns an error that starts with `Token required:`, then gives the setup steps and the narrowest [token scopes](#token-scopes) for that tool. A `dryRun` call is refused the same way.
25
+ - Every tool result shows `· no token` after the host, for example `[sandbox · api.sandbox-gameye.gameye.net · no token]`, and `gameye_context` reports `Token: not configured`.
26
+ - The instructions the server sends to a connecting agent name the tools that work and tell it to read the `gameye` skill first, then the skill for the game's engine.
27
+
28
+ To get a token, sign up at [trial.gameye.com](https://trial.gameye.com/?utm_source=github&utm_medium=readme&utm_campaign=gameye-mcp), then in the Gameye Admin Panel open **Users → API tokens** and create one. Self-serve accounts use the sandbox host, which is the server's default.
29
+
30
+ Set the token where the server is configured: the `env` block of your MCP client's server entry, or `--env` when you add it with `claude mcp add`. The server reads `GAMEYE_API_TOKEN` once when it starts, so restart or reconnect it after you set or change the token. Never paste the token into the chat with your agent.
31
+
17
32
  ## Environments
18
33
 
19
34
  Gameye has two public API hosts. The server targets **sandbox** by default.
@@ -29,7 +44,7 @@ The environment is a property of the server process. No tool takes an environmen
29
44
 
30
45
  | Variable | Required | Default | Description |
31
46
  |---|---|---|---|
32
- | `GAMEYE_API_TOKEN` | **Yes** | — | Gameye API token. Use the least-privileged scopes you need (see below). |
47
+ | `GAMEYE_API_TOKEN` | No | — | Gameye API token. Use the least-privileged scopes you need (see [Token scopes](#token-scopes)). Without it the server starts in no-token mode: see [Without a token](#without-a-token). |
33
48
  | `GAMEYE_ENV` | No | `sandbox` | `sandbox` or `production`. Selects the API host. |
34
49
  | `GAMEYE_API_URL` | No | — | Overrides the host, for testing only. Must be `https`. If it equals one of the two known hosts, it must match `GAMEYE_ENV` or the server refuses to start. Any other URL is labelled `custom` in every tool result. |
35
50
  | `GAMEYE_ENABLE_SKILLS_UPDATE` | No | — | Set to `1` to let `gameye_update_skills` run the skills updaters with `apply: true`. Without it the tool only checks and lists the commands. |
@@ -133,29 +148,35 @@ Give the server a token with only the scopes it needs.
133
148
 
134
149
  ## Available tools
135
150
 
136
- Every tool result starts with the environment and host it used, for example `[sandbox · api.sandbox-gameye.gameye.net]`.
151
+ Every tool result starts with the environment and host it used, for example `[sandbox · api.sandbox-gameye.gameye.net]`. With no token configured it ends with `· no token`: `[sandbox · api.sandbox-gameye.gameye.net · no token]`.
137
152
 
138
- | Tool | Kind | Description |
139
- |---|---|---|
140
- | `gameye_context` | read | Show the environment, host and server version this server targets, and the guardrail defaults (session TTL, `launched-by` label, token expiry) |
141
- | `gameye_launch_session` | write, `dryRun` | Start a containerized game server (billable). Default TTL and agent label applied; see [Guardrails](#guardrails) |
142
- | `gameye_list_sessions` | read | List running sessions (filterable; `all=true` for every status) |
143
- | `gameye_describe_session` | read | Get session details, ports and players |
144
- | `gameye_stop_session` | destructive, `dryRun` | Stop one session by its exact ID |
145
- | `gameye_player_join` | write | Register one or more players as joined |
146
- | `gameye_player_leave` | write | Remove one or more players from a session |
147
- | `gameye_get_locations` | read | List locations that can host an image right now |
148
- | `gameye_list_regions` | read | List the regions available to your organization |
149
- | `gameye_check_tag` | read | Check whether an image tag is available in a region |
150
- | `gameye_list_applications` | read | List your applications with registry, resources, regions and ports |
151
- | `gameye_list_registry_tags` | read | List an application's tags in its upstream registry (paged) |
152
- | `gameye_enable_tag` | write, `dryRun` | Enable an image tag for an application so Gameye preloads it |
153
- | `gameye_get_logs` | read | Fetch session logs |
154
- | `gameye_get_artifacts` | read | Download a file or directory from a session container as a tar archive |
155
- | `gameye_create_application` | write, `dryRun` | Create an application |
156
- | `gameye_edit_application` | destructive, `dryRun` | Change an application; omitted fields stay unchanged |
157
- | `gameye_create_token` | write, `dryRun` | Create an API token and write it to a file |
158
- | `gameye_update_skills` | read, or write with `apply` | Compare the installed [Gameye skills](https://github.com/gameye/gameye-skills) with the latest pack, and update them with `apply: true`. See [Keeping the skills current](#keeping-the-skills-current) |
153
+ The Token column names the narrowest [token scope recipe](#token-scopes) each tool needs. Tools marked `none` work without a token.
154
+
155
+ | Tool | Kind | Token | Description |
156
+ |---|---|---|---|
157
+ | `gameye_context` | read | none | Show the environment, host and server version this server targets, and the guardrail defaults (session TTL, `launched-by` label, token expiry) |
158
+ | `gameye_launch_session` | write, `dryRun` | Operator | Start a containerized game server (billable). Default TTL and agent label applied; see [Guardrails](#guardrails) |
159
+ | `gameye_list_sessions` | read | Observer | List running sessions (filterable; `all=true` for every status) |
160
+ | `gameye_describe_session` | read | Observer | Get session details, ports and players |
161
+ | `gameye_stop_session` | destructive, `dryRun` | Operator | Stop one session by its exact ID |
162
+ | `gameye_player_join` | write | Operator | Register one or more players as joined |
163
+ | `gameye_player_leave` | write | Operator | Remove one or more players from a session |
164
+ | `gameye_get_locations` | read | Observer | List locations that can host an image right now |
165
+ | `gameye_list_regions` | read | Observer | List the regions available to your organization |
166
+ | `gameye_check_tag` | read | Observer | Check whether an image tag is available in a region |
167
+ | `gameye_list_applications` | read | Observer | List your applications with registry, resources, regions and ports |
168
+ | `gameye_list_registry_tags` | read | Observer | List an application's tags in its upstream registry (paged) |
169
+ | `gameye_enable_tag` | write, `dryRun` | Deployer | Enable an image tag for an application so Gameye preloads it |
170
+ | `gameye_get_logs` | read | Observer | Fetch session logs |
171
+ | `gameye_get_artifacts` | read | Observer | Download a file or directory from a session container as a tar archive |
172
+ | `gameye_create_application` | write, `dryRun` | Deployer | Create an application |
173
+ | `gameye_edit_application` | destructive, `dryRun` | Deployer | Change an application; omitted fields stay unchanged |
174
+ | `gameye_create_token` | write, `dryRun` | Token admin | Create an API token and write it to a file |
175
+ | `gameye_update_skills` | read, or write with `apply` | none | Compare the installed [Gameye skills](https://github.com/gameye/gameye-skills) with the latest pack, and update them with `apply: true`. See [Keeping the skills current](#keeping-the-skills-current) |
176
+ | `gameye_list_skills` | read | none | List the Gameye skills bundled with this server, with each skill's version and files |
177
+ | `gameye_get_skill` | read | none | Read a bundled skill's `SKILL.md` or one of its reference files |
178
+ | `gameye_get_template` | read | none | Get the Dockerfile or shutdown-hook template for an Unreal, Unity, Godot or web server, exactly as published in the bundled skill |
179
+ | `gameye_check_dockerfile` | read | none | Check Dockerfile text against the [gameye-containerize](https://github.com/gameye/gameye-skills) container contract, offline: findings with severity, rule, line and fix, plus what the check cannot know |
159
180
 
160
181
  Every tool carries MCP annotations: read tools set `readOnlyHint`, `gameye_stop_session` and `gameye_edit_application` set `destructiveHint`, and every tool that calls the Gameye API sets `openWorldHint`.
161
182
 
@@ -180,7 +201,7 @@ The [Gameye skills](https://github.com/gameye/gameye-skills) change as the Gamey
180
201
  - **skills CLI, this project:** the same, in `skills-lock.json` in the server's working directory.
181
202
  - **Claude Code plugin:** `gameye@gameye` in `~/.claude/plugins/installed_plugins.json`.
182
203
 
183
- Without `apply` it only reports. With `apply: true`, and only when the server was started with `GAMEYE_ENABLE_SKILLS_UPDATE=1`, it runs each outdated install's own updater: `npx -y skills@1.7.4 update <the Gameye skills> -g -y` (or `-p -y`), or `claude plugin marketplace update gameye` then `claude plugin update gameye@gameye`. Otherwise it lists those commands for you to run. The updaters get a minimal environment: paths, home, temp, locale, proxy and CA settings and npm and Claude config, never `GAMEYE_*` variables or anything named like a token, secret, password or key. It only updates skills whose lock-file source is `gameye/gameye-skills`, and it never downloads or follows skill text itself. Each command runs with stdin closed and a 90-second limit. On Windows it prints the commands instead of running them. The running agent session keeps the skills it loaded; the next session uses the update. The `gameye` skill tells agents to run the check once per conversation and ask before applying.
204
+ Without `apply` it only reports. With `apply: true`, and only when the server was started with `GAMEYE_ENABLE_SKILLS_UPDATE=1`, it runs each outdated install's own updater: `npx -y skills@1.7.4 update <the Gameye skills> -g -y` (or `-p -y`), or `claude plugin marketplace update gameye` then `claude plugin update gameye@gameye`. Otherwise it lists those commands for you to run. The updaters get a minimal environment: paths, home, temp, locale, proxy and CA settings and npm and Claude config, never `GAMEYE_*` variables or anything named like a token, secret, password or key. It only updates skills whose lock-file source is `gameye/gameye-skills`, and it ships a pinned copy of the skills reviewed at build time: the skill and template tools never fetch skill text at runtime, and only `gameye_update_skills` reaches GitHub, when called. Each command runs with stdin closed and a 90-second limit. On Windows it prints the commands instead of running them. The running agent session keeps the skills it loaded; the next session uses the update. The `gameye` skill tells agents to run the check once per conversation and ask before applying.
184
205
 
185
206
  ## Telemetry/attribution
186
207
 
@@ -208,6 +229,8 @@ A port with `tls: true` has TLS terminated by Gameye on the host. Set it with `g
208
229
 
209
230
  To teach your agent how to integrate Gameye (session lifecycle, matchmaking, webhooks and more), install the sister project [gameye-skills](https://github.com/gameye/gameye-skills). The skills use these MCP tools when they are available, and fall back to plain HTTP when they aren't.
210
231
 
232
+ The server also bundles a pinned copy of the skills, which an agent reads with `gameye_list_skills` and `gameye_get_skill`, with or without a token. If an installed copy is newer, it wins.
233
+
211
234
  ## Development
212
235
 
213
236
  See [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -0,0 +1,84 @@
1
+ export type Severity = "error" | "warning";
2
+ export type Protocol = "tcp" | "udp";
3
+ export interface DeclaredPort {
4
+ port: number;
5
+ protocol: Protocol;
6
+ }
7
+ export interface DockerfileFinding {
8
+ severity: Severity;
9
+ code: RuleCode;
10
+ /** 1-based line where the offending instruction starts. */
11
+ line: number;
12
+ message: string;
13
+ fix: string;
14
+ }
15
+ export interface DockerfileCheckResult {
16
+ findings: DockerfileFinding[];
17
+ }
18
+ export interface DockerfileRule {
19
+ code: string;
20
+ /** Number of the item in the gameye-containerize "container contract" list. */
21
+ contractNumber: number;
22
+ /** Text that must appear in that contract item. */
23
+ keyword: string;
24
+ severity: Severity;
25
+ }
26
+ export declare const DOCKERFILE_RULES: readonly [{
27
+ readonly code: "GC1-arm64-platform";
28
+ readonly contractNumber: 1;
29
+ readonly keyword: "linux/amd64";
30
+ readonly severity: "error";
31
+ }, {
32
+ readonly code: "GC1-windows-base";
33
+ readonly contractNumber: 1;
34
+ readonly keyword: "Windows containers";
35
+ readonly severity: "error";
36
+ }, {
37
+ readonly code: "GC2-expose-missing";
38
+ readonly contractNumber: 2;
39
+ readonly keyword: "EXPOSE";
40
+ readonly severity: "error";
41
+ }, {
42
+ readonly code: "GC2-port-mismatch";
43
+ readonly contractNumber: 2;
44
+ readonly keyword: "EXPOSE";
45
+ readonly severity: "error";
46
+ }, {
47
+ readonly code: "GC2-loopback-bind";
48
+ readonly contractNumber: 2;
49
+ readonly keyword: "0.0.0.0";
50
+ readonly severity: "error";
51
+ }, {
52
+ readonly code: "GC4-exec-form";
53
+ readonly contractNumber: 4;
54
+ readonly keyword: "SIGTERM";
55
+ readonly severity: "error";
56
+ }, {
57
+ readonly code: "GC4-npm-start";
58
+ readonly contractNumber: 4;
59
+ readonly keyword: "SIGTERM";
60
+ readonly severity: "error";
61
+ }, {
62
+ readonly code: "GC7-non-root";
63
+ readonly contractNumber: 7;
64
+ readonly keyword: "non-root";
65
+ readonly severity: "warning";
66
+ }, {
67
+ readonly code: "GC9-token-in-image";
68
+ readonly contractNumber: 9;
69
+ readonly keyword: "API token";
70
+ readonly severity: "error";
71
+ }];
72
+ export type RuleCode = (typeof DOCKERFILE_RULES)[number]["code"];
73
+ export declare const CANNOT_KNOW: string;
74
+ export interface Instruction {
75
+ keyword: string;
76
+ args: string;
77
+ line: number;
78
+ }
79
+ /** Split Dockerfile text into instructions, joining continuations and skipping comments and heredoc bodies. */
80
+ export declare function parseDockerfile(text: string): Instruction[];
81
+ export declare function checkDockerfile(text: string, opts?: {
82
+ ports?: DeclaredPort[];
83
+ }): DockerfileCheckResult;
84
+ export declare function formatDockerfileReport(result: DockerfileCheckResult): string;
@@ -0,0 +1,278 @@
1
+ // Offline Dockerfile check against the gameye-containerize container contract.
2
+ //
3
+ // A pure function over Dockerfile text: it reads no files, spawns nothing and
4
+ // makes no request. Each rule cites the numbered contract item it enforces, so
5
+ // a later test can check the numbers against the bundled skill. Any value the
6
+ // check flags as a credential is redacted to `***` here, by the check itself;
7
+ // the server's own token masking does not know about values in user input.
8
+ import { MASK } from "./client.js";
9
+ import { GuardError } from "./guards.js";
10
+ export const DOCKERFILE_RULES = [
11
+ { code: "GC1-arm64-platform", contractNumber: 1, keyword: "linux/amd64", severity: "error" },
12
+ { code: "GC1-windows-base", contractNumber: 1, keyword: "Windows containers", severity: "error" },
13
+ { code: "GC2-expose-missing", contractNumber: 2, keyword: "EXPOSE", severity: "error" },
14
+ { code: "GC2-port-mismatch", contractNumber: 2, keyword: "EXPOSE", severity: "error" },
15
+ { code: "GC2-loopback-bind", contractNumber: 2, keyword: "0.0.0.0", severity: "error" },
16
+ { code: "GC4-exec-form", contractNumber: 4, keyword: "SIGTERM", severity: "error" },
17
+ { code: "GC4-npm-start", contractNumber: 4, keyword: "SIGTERM", severity: "error" },
18
+ { code: "GC7-non-root", contractNumber: 7, keyword: "non-root", severity: "warning" },
19
+ { code: "GC9-token-in-image", contractNumber: 9, keyword: "API token", severity: "error" },
20
+ ];
21
+ export const CANNOT_KNOW = "What this check cannot know: whether the image is pushed to a registry Gameye can pull from, " +
22
+ "whether your application's port bindings in the Gameye Admin Panel match these ports and protocols, " +
23
+ "and whether the server binary starts, binds its ports and exits on SIGTERM. It reads only the Dockerfile " +
24
+ "text, not entrypoint scripts, build output or the base image's defaults. A clean result means no contract " +
25
+ "break was found in this text, nothing more; test the image locally before launching it.";
26
+ const SKILL = "gameye-containerize";
27
+ /** Split Dockerfile text into instructions, joining continuations and skipping comments and heredoc bodies. */
28
+ export function parseDockerfile(text) {
29
+ const lines = text.split(/\r?\n/);
30
+ const escapeMatch = /^#\s*escape\s*=\s*([`\\])\s*$/i.exec(lines[0] ?? "");
31
+ const escape = escapeMatch ? escapeMatch[1] : "\\";
32
+ const out = [];
33
+ let i = 0;
34
+ while (i < lines.length) {
35
+ const raw = lines[i];
36
+ const trimmed = raw.trim();
37
+ if (trimmed === "" || trimmed.startsWith("#")) {
38
+ i++;
39
+ continue;
40
+ }
41
+ const start = i + 1;
42
+ let joined = "";
43
+ let current = raw;
44
+ for (;;) {
45
+ const t = current.trimEnd();
46
+ if (t.endsWith(escape)) {
47
+ joined += t.slice(0, -1) + " ";
48
+ i++;
49
+ // Comment and empty lines inside a continuation are dropped.
50
+ while (i < lines.length && (lines[i].trim() === "" || lines[i].trim().startsWith("#")))
51
+ i++;
52
+ if (i >= lines.length)
53
+ break;
54
+ current = lines[i];
55
+ }
56
+ else {
57
+ joined += t;
58
+ i++;
59
+ break;
60
+ }
61
+ }
62
+ const m = /^\s*(\S+)\s*(.*)$/s.exec(joined);
63
+ if (!m)
64
+ continue;
65
+ const keyword = m[1].toUpperCase();
66
+ const args = m[2].trim();
67
+ out.push({ keyword, args, line: start });
68
+ // Skip heredoc bodies (RUN <<EOT ... EOT) so their lines are not read as instructions.
69
+ for (const h of args.matchAll(/<<(-?)\s*["']?([A-Za-z_][A-Za-z0-9_]*)["']?/g)) {
70
+ const [, dash, delim] = h;
71
+ while (i < lines.length) {
72
+ const l = dash ? lines[i].replace(/^\t+/, "") : lines[i];
73
+ i++;
74
+ if (l.trim() === delim)
75
+ break;
76
+ }
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+ function splitStages(instructions) {
82
+ const stages = [];
83
+ for (const ins of instructions) {
84
+ if (ins.keyword === "FROM") {
85
+ const words = ins.args.split(/\s+/).filter(Boolean);
86
+ let platform;
87
+ const rest = [];
88
+ for (const w of words) {
89
+ const p = /^--platform=(.*)$/i.exec(w);
90
+ if (p)
91
+ platform = p[1];
92
+ else if (!w.startsWith("--"))
93
+ rest.push(w);
94
+ }
95
+ const asIdx = rest.findIndex((w) => w.toUpperCase() === "AS");
96
+ stages.push({
97
+ from: ins,
98
+ image: rest[0] ?? "",
99
+ platform,
100
+ name: asIdx >= 0 ? rest[asIdx + 1]?.toLowerCase() : undefined,
101
+ body: [],
102
+ });
103
+ }
104
+ else if (stages.length > 0) {
105
+ stages[stages.length - 1].body.push(ins);
106
+ }
107
+ }
108
+ return stages;
109
+ }
110
+ /** The final stage plus any earlier stages it is built FROM, root first. */
111
+ function finalChain(stages) {
112
+ const chain = [];
113
+ let idx = stages.length - 1;
114
+ while (idx >= 0) {
115
+ const stage = stages[idx];
116
+ chain.unshift(stage);
117
+ const parent = stage.image.toLowerCase();
118
+ idx = stages.findIndex((s, j) => j < idx && s.name === parent);
119
+ }
120
+ return chain;
121
+ }
122
+ // ── Helpers ─────────────────────────────────────────────────────────────────
123
+ function parseExecForm(args) {
124
+ if (!args.startsWith("["))
125
+ return undefined;
126
+ try {
127
+ const parsed = JSON.parse(args);
128
+ return Array.isArray(parsed) && parsed.every((x) => typeof x === "string") ? parsed : undefined;
129
+ }
130
+ catch {
131
+ return undefined;
132
+ }
133
+ }
134
+ /** Parse `KEY=value KEY2="v w"` (or legacy `KEY value` for ENV) into pairs. */
135
+ function parseAssignments(keyword, args) {
136
+ const legacy = /^([A-Za-z_][A-Za-z0-9_]*)\s+(.*)$/s.exec(args);
137
+ if (keyword === "ENV" && legacy && !args.split(/\s+/)[0].includes("=")) {
138
+ return [{ name: legacy[1], value: unquote(legacy[2].trim()) }];
139
+ }
140
+ const pairs = [];
141
+ const re = /([A-Za-z_][A-Za-z0-9_]*)(?:=("(?:[^"\\]|\\.)*"|'[^']*'|\S*))?/g;
142
+ for (const m of args.matchAll(re))
143
+ pairs.push({ name: m[1], value: m[2] === undefined ? undefined : unquote(m[2]) });
144
+ return pairs;
145
+ }
146
+ function unquote(v) {
147
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))
148
+ return v.slice(1, -1);
149
+ return v;
150
+ }
151
+ const SECRET_NAME = /(TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|PRIVATE_KEY|CREDENTIAL)/i;
152
+ const SECRET_VALUE = /^(AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_\w{20,}|sk-[A-Za-z0-9_-]{20,}|xox[abpr]-[A-Za-z0-9-]{10,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+)$/;
153
+ function isLiteral(value) {
154
+ return value !== undefined && value !== "" && !/^\$\{?[A-Za-z_][A-Za-z0-9_]*(:-[^}]*)?\}?$/.test(value);
155
+ }
156
+ function parseExpose(args) {
157
+ const out = [];
158
+ for (const word of args.split(/\s+/).filter(Boolean)) {
159
+ const m = /^(\d+)(?:-(\d+))?(?:\/(tcp|udp))?$/i.exec(word);
160
+ if (!m)
161
+ continue; // variables and other forms can't be resolved statically
162
+ out.push({ from: Number(m[1]), to: Number(m[2] ?? m[1]), protocol: (m[3]?.toLowerCase() ?? "tcp") });
163
+ }
164
+ return out;
165
+ }
166
+ function rule(code) {
167
+ return DOCKERFILE_RULES.find((r) => r.code === code);
168
+ }
169
+ // ── Check ───────────────────────────────────────────────────────────────────
170
+ export function checkDockerfile(text, opts = {}) {
171
+ const instructions = parseDockerfile(text);
172
+ const stages = splitStages(instructions);
173
+ if (instructions.length === 0 || stages.length === 0) {
174
+ throw new GuardError("No Dockerfile instructions found: pass the full Dockerfile text, starting with a FROM line.");
175
+ }
176
+ const findings = [];
177
+ const add = (code, line, message, fix) => findings.push({ severity: rule(code).severity, code, line, message, fix });
178
+ const chain = finalChain(stages);
179
+ const root = chain[0];
180
+ const finalFrom = chain[chain.length - 1].from;
181
+ const runtime = chain.flatMap((s) => s.body);
182
+ // GC1: linux/amd64 only.
183
+ for (const stage of chain) {
184
+ if (stage.platform && /arm64|aarch64|linux\/arm\b|arm\/v/i.test(stage.platform)) {
185
+ add("GC1-arm64-platform", stage.from.line, `The runtime image is pinned to ${stage.platform}; the Gameye fleet runs linux/amd64 only.`, `Remove the arm64 --platform pin (or use --platform=linux/amd64) and build with docker buildx build --platform=linux/amd64. See ${SKILL}.`);
186
+ }
187
+ }
188
+ if (/^mcr\.microsoft\.com\/windows|nanoserver|servercore/i.test(root.image) || /^windows/i.test(root.platform ?? "")) {
189
+ add("GC1-windows-base", root.from.line, "The runtime image uses a Windows base image; Gameye runs Linux containers only.", `Build a Linux dedicated server and use a Linux base image such as debian:bookworm-slim. See ${SKILL}.`);
190
+ }
191
+ // GC2: EXPOSE present and consistent with declared ports.
192
+ const exposes = runtime.filter((i) => i.keyword === "EXPOSE");
193
+ if (exposes.length === 0) {
194
+ add("GC2-expose-missing", finalFrom.line, "The runtime stage has no EXPOSE; the exposed ports must match what the server binds and the application's port bindings.", `Add EXPOSE <port>/<protocol> for every port the server binds, e.g. EXPOSE 7777/udp. See ${SKILL}.`);
195
+ }
196
+ else if (opts.ports && opts.ports.length > 0) {
197
+ const exposed = exposes.flatMap((i) => parseExpose(i.args).map((p) => ({ ...p, line: i.line })));
198
+ const lastExpose = exposes[exposes.length - 1].line;
199
+ const isExposed = (d) => exposed.some((e) => e.protocol === d.protocol && d.port >= e.from && d.port <= e.to);
200
+ for (const d of opts.ports) {
201
+ if (isExposed(d))
202
+ continue;
203
+ const other = exposed.find((e) => d.port >= e.from && d.port <= e.to);
204
+ add("GC2-port-mismatch", other?.line ?? lastExpose, other
205
+ ? `Port ${d.port} is declared as ${d.protocol} but exposed as ${other.protocol}.`
206
+ : `Declared port ${d.port}/${d.protocol} is not exposed.`, `Make EXPOSE list exactly the ports and protocols the server binds (${d.port}/${d.protocol}), and match them in the application's port bindings. See ${SKILL}.`);
207
+ }
208
+ for (const e of exposed) {
209
+ const declared = opts.ports.some((d) => d.port >= e.from && d.port <= e.to);
210
+ if (declared)
211
+ continue;
212
+ const range = e.from === e.to ? `${e.from}` : `${e.from}-${e.to}`;
213
+ add("GC2-port-mismatch", e.line, `Port ${range}/${e.protocol} is exposed but not among the declared ports.`, `Remove the EXPOSE for ports the server does not bind, or declare them. See ${SKILL}.`);
214
+ }
215
+ }
216
+ // GC2 / GC4: launch command.
217
+ const lastOf = (kw) => [...runtime].reverse().find((i) => i.keyword === kw);
218
+ const launch = [lastOf("ENTRYPOINT"), lastOf("CMD")].filter((i) => i !== undefined).sort((a, b) => a.line - b.line);
219
+ for (const ins of launch) {
220
+ const exec = parseExecForm(ins.args);
221
+ if (!exec) {
222
+ add("GC4-exec-form", ins.line, `${ins.keyword} uses shell form, so /bin/sh is PID 1 and swallows SIGTERM; the server is hard-killed with no drain.`, `Use exec form, e.g. ${ins.keyword} ["/server/game-server", "--port", "7777"], and exec the binary from any entrypoint script. See ${SKILL}.`);
223
+ }
224
+ const words = exec ?? ins.args.split(/\s+/);
225
+ const cmd = words.join(" ");
226
+ if (/(^|[\s/])(npm|yarn)(\s+run)?\s+start\b/.test(cmd)) {
227
+ add("GC4-npm-start", ins.line, `${ins.keyword} launches through npm/yarn start, which sits between PID 1 and the server and swallows SIGTERM.`, `Run the server directly, e.g. ${ins.keyword} ["node", "server/server.js"]. See ${SKILL}.`);
228
+ }
229
+ if (/(^|[^\d.])127\.\d{1,3}\.\d{1,3}\.\d{1,3}(?![\d.])|\blocalhost\b/i.test(cmd)) {
230
+ add("GC2-loopback-bind", ins.line, `${ins.keyword} binds the server to a loopback address, which is unreachable from outside the container.`, `Bind on 0.0.0.0 (all interfaces) instead of 127.0.0.1 or localhost. See ${SKILL}.`);
231
+ }
232
+ }
233
+ // GC7: non-root user.
234
+ const user = lastOf("USER");
235
+ const userName = user?.args.split(/\s+/)[0].split(":")[0];
236
+ if (!user || userName === "root" || userName === "0") {
237
+ add("GC7-non-root", user?.line ?? finalFrom.line, user ? "The runtime stage switches to root." : "The runtime stage sets no USER, so the server runs as root unless the base image says otherwise.", `Create a system user and switch to it before ENTRYPOINT, e.g. USER game. See ${SKILL}.`);
238
+ }
239
+ // GC9: no Gameye API token or credential in ENV/ARG (any stage; the Dockerfile itself is shared).
240
+ const secrets = [];
241
+ for (const ins of instructions) {
242
+ if (ins.keyword !== "ENV" && ins.keyword !== "ARG")
243
+ continue;
244
+ for (const { name, value } of parseAssignments(ins.keyword, ins.args)) {
245
+ const gameye = name.toUpperCase() === "GAMEYE_API_TOKEN";
246
+ const literal = isLiteral(value);
247
+ const credential = gameye || (literal && (SECRET_NAME.test(name) || SECRET_VALUE.test(value)));
248
+ if (!credential)
249
+ continue;
250
+ if (literal)
251
+ secrets.push(value);
252
+ add("GC9-token-in-image", ins.line,
253
+ // Only the fixed GAMEYE_API_TOKEN name is echoed: any other name is user text and may itself hold a token.
254
+ `${ins.keyword} ${gameye ? `GAMEYE_API_TOKEN${value === undefined ? "" : `=${MASK}`}` : "on this line"} puts ${gameye ? "a Gameye API token" : "a credential"} in the image or its build history.`, `Remove it from the Dockerfile. The Gameye API token belongs to your backend only; pass only short-lived per-match values in the session env. Rotate the token if this was ever pushed. See ${SKILL}.`);
255
+ }
256
+ }
257
+ findings.sort((a, b) => a.line - b.line);
258
+ return { findings: redact(findings, secrets) };
259
+ }
260
+ /** Replace every flagged credential value with `***`, wherever it appears. */
261
+ function redact(findings, secrets) {
262
+ const values = [...new Set(secrets)].filter((s) => s.length >= 4).sort((a, b) => b.length - a.length);
263
+ const clean = (t) => values.reduce((acc, v) => acc.split(v).join(MASK), t);
264
+ return findings.map((f) => ({ ...f, message: clean(f.message), fix: clean(f.fix) }));
265
+ }
266
+ export function formatDockerfileReport(result) {
267
+ const errors = result.findings.filter((f) => f.severity === "error").length;
268
+ const warnings = result.findings.length - errors;
269
+ const lines = [`Dockerfile check against the ${SKILL} container contract: ${errors} error(s), ${warnings} warning(s).`, ""];
270
+ if (result.findings.length === 0) {
271
+ lines.push("No contract issues found in the Dockerfile text.", "");
272
+ }
273
+ for (const f of result.findings) {
274
+ lines.push(`${f.severity.toUpperCase()} ${f.code} (line ${f.line}): ${f.message}`, ` Fix: ${f.fix}`, "");
275
+ }
276
+ lines.push(CANNOT_KNOW);
277
+ return lines.join("\n");
278
+ }
package/dist/env.d.ts CHANGED
@@ -12,8 +12,10 @@ export interface ResolvedConfig {
12
12
  baseUrl: string;
13
13
  /** Hostname only, for display. */
14
14
  host: string;
15
- /** API token. Never print or return this. */
16
- token: string;
15
+ /** API token, absent in no-token mode. Never print or return this. */
16
+ token?: string;
17
+ /** True when a usable token is configured. Derived from `token`; safe to show. */
18
+ hasToken: boolean;
17
19
  /** Default per-request timeout in milliseconds (GAMEYE_TIMEOUT_MS, default 30s). */
18
20
  timeoutMs: number;
19
21
  /** Local launch and stop limits (GAMEYE_MAX_SESSIONS, GAMEYE_MAX_TTL, GAMEYE_MAX_LAUNCHES, GAMEYE_STOP_OWN_ONLY). */
@@ -37,9 +39,12 @@ export declare const DEFAULT_TIMEOUT_MS = 30000;
37
39
  export declare class ConfigError extends Error {
38
40
  constructor(message: string);
39
41
  }
42
+ /** The one stderr line printed at start in no-token mode. Never mentions a token value. */
43
+ export declare const NO_TOKEN_NOTICE = "No GAMEYE_API_TOKEN set: account tools will refuse until a token is set and the server is restarted.";
40
44
  /**
41
45
  * Resolve the server configuration from an environment object.
42
46
  * Pure: throws ConfigError on any invalid combination and never touches process state.
47
+ * A missing token is not an error: it resolves with `hasToken: false`.
43
48
  */
44
49
  export declare function resolveConfig(env: Record<string, string | undefined>): ResolvedConfig;
45
50
  /** Resolve from process.env, printing a clear message and exiting 1 on any error. */
@@ -48,4 +53,5 @@ export declare function loadConfigOrExit(env?: Record<string, string | undefined
48
53
  export declare function resultPrefix(config: {
49
54
  environment: EnvironmentLabel;
50
55
  host: string;
56
+ hasToken?: boolean;
51
57
  }): string;
package/dist/env.js CHANGED
@@ -30,6 +30,20 @@ function nonEmpty(value) {
30
30
  const trimmed = value?.trim();
31
31
  return trimmed ? trimmed : undefined;
32
32
  }
33
+ /** A whole value like `${user_config.api_token}`: a host placeholder that was never filled in. */
34
+ const UNEXPANDED_PLACEHOLDER = /^\$\{[^}]*\}$/;
35
+ /**
36
+ * The configured token, or undefined when it is missing, blank or an unexpanded
37
+ * host placeholder. Any of those starts the server in no-token mode.
38
+ */
39
+ function resolveToken(raw) {
40
+ const value = nonEmpty(raw);
41
+ if (value === undefined || UNEXPANDED_PLACEHOLDER.test(value))
42
+ return undefined;
43
+ return value;
44
+ }
45
+ /** The one stderr line printed at start in no-token mode. Never mentions a token value. */
46
+ export const NO_TOKEN_NOTICE = "No GAMEYE_API_TOKEN set: account tools will refuse until a token is set and the server is restarted.";
33
47
  function parseTimeoutMs(raw) {
34
48
  const value = nonEmpty(raw);
35
49
  if (value === undefined)
@@ -87,13 +101,11 @@ function matchKnownTier(url) {
87
101
  /**
88
102
  * Resolve the server configuration from an environment object.
89
103
  * Pure: throws ConfigError on any invalid combination and never touches process state.
104
+ * A missing token is not an error: it resolves with `hasToken: false`.
90
105
  */
91
106
  export function resolveConfig(env) {
92
- const token = nonEmpty(env.GAMEYE_API_TOKEN);
93
- if (!token) {
94
- throw new ConfigError("GAMEYE_API_TOKEN is required. Set it in your MCP server configuration:\n" +
95
- ' "env": { "GAMEYE_API_TOKEN": "<your token>" }');
96
- }
107
+ const token = resolveToken(env.GAMEYE_API_TOKEN);
108
+ const auth = token === undefined ? { hasToken: false } : { token, hasToken: true };
97
109
  if (nonEmpty(env.GAMEYE_API_ENDPOINT)) {
98
110
  throw new ConfigError("GAMEYE_API_ENDPOINT is no longer supported. Use GAMEYE_ENV=sandbox|production " +
99
111
  "(default sandbox), or GAMEYE_API_URL for a non-standard https host.");
@@ -111,7 +123,7 @@ export function resolveConfig(env) {
111
123
  const rawUrl = nonEmpty(env.GAMEYE_API_URL);
112
124
  if (rawUrl === undefined) {
113
125
  const baseUrl = KNOWN_HOSTS[tier];
114
- return { environment: tier, baseUrl, host: new URL(baseUrl).host, token, timeoutMs, limits };
126
+ return { environment: tier, baseUrl, host: new URL(baseUrl).host, ...auth, timeoutMs, limits };
115
127
  }
116
128
  let url;
117
129
  try {
@@ -130,10 +142,10 @@ export function resolveConfig(env) {
130
142
  `Set GAMEYE_ENV=${knownTier} to use ${knownTier}, or remove GAMEYE_API_URL.`);
131
143
  }
132
144
  const baseUrl = KNOWN_HOSTS[knownTier];
133
- return { environment: knownTier, baseUrl, host: new URL(baseUrl).host, token, timeoutMs, limits };
145
+ return { environment: knownTier, baseUrl, host: new URL(baseUrl).host, ...auth, timeoutMs, limits };
134
146
  }
135
147
  const baseUrl = `${url.origin}${url.pathname}`.replace(/\/+$/, "");
136
- return { environment: "custom", baseUrl, host: url.host, token, timeoutMs, limits };
148
+ return { environment: "custom", baseUrl, host: url.host, ...auth, timeoutMs, limits };
137
149
  }
138
150
  /** Resolve from process.env, printing a clear message and exiting 1 on any error. */
139
151
  export function loadConfigOrExit(env = process.env) {
@@ -150,5 +162,5 @@ export function loadConfigOrExit(env = process.env) {
150
162
  }
151
163
  /** The line every tool result starts with, e.g. `[sandbox · api.sandbox-gameye.gameye.net]`. */
152
164
  export function resultPrefix(config) {
153
- return `[${config.environment} · ${config.host}]`;
165
+ return `[${config.environment} · ${config.host}${config.hasToken === false ? " · no token" : ""}]`;
154
166
  }
@@ -0,0 +1,39 @@
1
+ export interface BundledSkill {
2
+ /** Directory and frontmatter name, e.g. "gameye-containerize". */
3
+ name: string;
4
+ description: string;
5
+ /** The skill's own metadata.version. */
6
+ version: string;
7
+ /** "SKILL.md" and "references/<file>.md", keyed by path relative to the skill. */
8
+ files: Readonly<Record<string, string>>;
9
+ }
10
+ export interface ContainerContractItem {
11
+ /** The item's number in gameye-containerize "The container contract". */
12
+ number: number;
13
+ text: string;
14
+ }
15
+ export declare const TEMPLATE_ENGINES: readonly ["unreal", "unity", "godot", "web"];
16
+ export declare const TEMPLATE_KINDS: readonly ["dockerfile", "shutdown-hook"];
17
+ export type TemplateEngine = (typeof TEMPLATE_ENGINES)[number];
18
+ export type TemplateKind = (typeof TEMPLATE_KINDS)[number];
19
+ export interface BundledTemplate {
20
+ engine: TemplateEngine;
21
+ kind: TemplateKind;
22
+ /** The engine skill that publishes it, e.g. "gameye-godot-server". */
23
+ skill: string;
24
+ /** The heading of the section the markers sit in. */
25
+ section: string;
26
+ /** The fence's info string, e.g. "dockerfile". */
27
+ language: string;
28
+ /** The fenced block's content exactly as published, without the fence lines. */
29
+ code: string;
30
+ /** Prose inside the marker pair beside the fence, e.g. the engine version it was tested on. */
31
+ note?: string;
32
+ }
33
+ export declare const BUNDLED_SKILLS_PACK_VERSION = "1.7.1";
34
+ export declare const BUNDLED_SKILLS_TAG = "v1.7.1";
35
+ export declare const BUNDLED_SKILLS_COMMIT = "63635b5fc737ec3f7f7e232ce6e19eb1d44944e8";
36
+ export declare const BUNDLED_SKILLS: readonly BundledSkill[];
37
+ export declare const CONTAINER_CONTRACT: readonly ContainerContractItem[];
38
+ /** Template blocks marked <!-- gameye-template: engine/kind --> in the pinned engine skills. */
39
+ export declare const BUNDLED_TEMPLATES: readonly BundledTemplate[];