gameye-mcp 0.3.1 → 0.4.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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # Gameye MCP Server
2
2
 
3
+ [![Discord](https://img.shields.io/badge/Discord-join%20chat-5865F2?logo=discord&logoColor=white)](https://discord.com/invite/QvJ3KH5max)
4
+
3
5
  A [Model Context Protocol](https://modelcontextprotocol.io) server for the [Gameye](https://gameye.com) game server orchestration API. It lets an AI agent (Claude Code, Claude Desktop, Cursor, or any MCP client) launch and inspect game server sessions, check image tags and regions, and fetch logs and artifacts. You talk to it in plain language, and you don't have to leave your editor.
4
6
 
5
7
  > **Status: beta** (`0.x`). Tools, inputs and outputs may change between releases, and there may be bugs. The server says so to every client that connects, on startup, and in `gameye_context`. It is published on npm as [`gameye-mcp`](https://www.npmjs.com/package/gameye-mcp), as a container image at `ghcr.io/gameye/gameye-mcp`, and in the [MCP Registry](https://registry.modelcontextprotocol.io) as `io.github.Gameye/gameye-mcp`; see the [changelog](https://github.com/Gameye/gameye-mcp/blob/main/CHANGELOG.md) for what changed in each release.
@@ -30,6 +32,11 @@ The environment is a property of the server process. No tool takes an environmen
30
32
  | `GAMEYE_API_TOKEN` | **Yes** | — | Gameye API token. Use the least-privileged scopes you need (see below). |
31
33
  | `GAMEYE_ENV` | No | `sandbox` | `sandbox` or `production`. Selects the API host. |
32
34
  | `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
+ | `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. |
36
+ | `GAMEYE_MAX_SESSIONS` | No | `5` | Most sessions launched by this server that may run at once. A launch past it is refused. |
37
+ | `GAMEYE_MAX_TTL` | No | `4h` | Longest `ttl` a launch may ask for, at least `30m`. A longer TTL, or `0`, is refused. |
38
+ | `GAMEYE_MAX_LAUNCHES` | No | `20` | Launches per server run. Restart the server to reset it. |
39
+ | `GAMEYE_STOP_OWN_ONLY` | No | — | Set to `1` so `gameye_stop_session` only stops sessions this server launched. |
33
40
  | `GAMEYE_TIMEOUT_MS` | No | `30000` | Per-request timeout in milliseconds, a positive whole number. Log snapshots get at least 60 s and artifact downloads at least 120 s. A request that runs out of time comes back as a tool error: `Request to <host> timed out after <n>s`. |
34
41
 
35
42
  ## Install
@@ -63,7 +70,7 @@ Most clients (Claude Desktop, Cursor, Windsurf and others) accept an `mcpServers
63
70
 
64
71
  Restart the client after you change its configuration.
65
72
 
66
- `npx -y gameye-mcp` runs the latest release. To stay on one version, name it: `npx -y gameye-mcp@0.3.1` (or `"args": ["-y", "gameye-mcp@0.3.1"]`).
73
+ `npx -y gameye-mcp` runs the latest release. To stay on one version, name it: `npx -y gameye-mcp@0.4.0` (or `"args": ["-y", "gameye-mcp@0.4.0"]`).
67
74
 
68
75
  ### Windows
69
76
 
@@ -105,7 +112,7 @@ With Claude Code: `claude mcp add gameye --env GAMEYE_API_TOKEN=<your-token> --
105
112
 
106
113
  - Pass each variable with `-e NAME` (no value), so Docker copies it from the client's environment and the token never appears in the command line.
107
114
  - Keep `-i` and never add `-t`: a terminal mangles the JSON-RPC messages on stdout.
108
- - To stay on one version, use a version tag such as `ghcr.io/gameye/gameye-mcp:0.3.1`.
115
+ - To stay on one version, use a version tag such as `ghcr.io/gameye/gameye-mcp:0.4.0`.
109
116
  - Files the server writes stay inside the container, and `--rm` deletes them:
110
117
  - `gameye_get_artifacts` saves its download under the temp directory. To keep it, mount a directory and point the temp directory at it: `-v /absolute/dir:/out -e TMPDIR=/out`. The path the tool reports is the container path; the file is in `/absolute/dir` on the host.
111
118
  - `gameye_create_token` writes to the `outputPath` you give it. Mount a directory the same way and pass an `outputPath` under `/out`.
@@ -162,7 +169,8 @@ These behave the same on sandbox and production, because sandbox runs live match
162
169
  - **Bounded sessions.** A launch without `ttl` gets a `30m` TTL, and the result says the default was applied. Every launch sends the label `launched-by: gameye-mcp`, replacing any `launched-by` value the caller passes, so agent-launched sessions can be found with `gameye_list_sessions` and `filter: { "launched-by": "gameye-mcp" }`. When a warm pool serves the image and region, the Gameye API ignores request labels (and env and args), so warm-pool sessions do not carry this label.
163
170
  - **No token in game servers.** A launch is refused, with nothing sent, if any env value or name, arg, or label key or value contains this server's Gameye API token. Env values are never shown in results.
164
171
  - **No token in results.** Every tool result, including errors, has this server's API token replaced with `***`, in case log output, labels or an error message echo it.
165
- - **Explicit stops.** `gameye_stop_session` stops exactly one session by its exact ID. There is no bulk stop, and empty, wildcard or list IDs are refused.
172
+ - **Explicit stops.** `gameye_stop_session` stops exactly one session by its exact ID. There is no bulk stop, and empty, wildcard or list IDs are refused. With `GAMEYE_STOP_OWN_ONLY=1` it also refuses any session this server did not launch.
173
+ - **Session limits.** The Gameye API has no per-token budget, so the server enforces one before it sends a launch. By default an agent can have at most 5 sessions it launched running at once (`GAMEYE_MAX_SESSIONS`), each with a TTL of at most `4h` (`GAMEYE_MAX_TTL`), and at most 20 launches per server run (`GAMEYE_MAX_LAUNCHES`). That caps one run at 20 sessions of 4 hours, 80 session-hours, and 5 at a time. Before each launch the server lists the `launched-by: gameye-mcp` sessions to see which of its own have ended; if that list fails, the launch is refused. Warm-pool sessions carry no label, so they count until their TTL ends. A launch that timed out or got a 5xx may have started, so it counts too. The limits apply to one server process: sessions launched by other agents or tools in your organization don't count, and a restart starts from zero, so set an organization quota in Gameye as well. `gameye_context` shows the limits in force.
166
174
 
167
175
  ## Keeping the skills current
168
176
 
@@ -172,7 +180,7 @@ The [Gameye skills](https://github.com/gameye/gameye-skills) change as the Gamey
172
180
  - **skills CLI, this project:** the same, in `skills-lock.json` in the server's working directory.
173
181
  - **Claude Code plugin:** `gameye@gameye` in `~/.claude/plugins/installed_plugins.json`.
174
182
 
175
- Without `apply` it only reports. With `apply: true` it runs each outdated install's own updater: `npx -y skills update <the Gameye skills> -g -y` (or `-p -y`), or `claude plugin marketplace update gameye` then `claude plugin update gameye@gameye`. 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.
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.
176
184
 
177
185
  ## Telemetry/attribution
178
186
 
package/dist/env.d.ts CHANGED
@@ -16,7 +16,22 @@ export interface ResolvedConfig {
16
16
  token: string;
17
17
  /** Default per-request timeout in milliseconds (GAMEYE_TIMEOUT_MS, default 30s). */
18
18
  timeoutMs: number;
19
+ /** Local launch and stop limits (GAMEYE_MAX_SESSIONS, GAMEYE_MAX_TTL, GAMEYE_MAX_LAUNCHES, GAMEYE_STOP_OWN_ONLY). */
20
+ limits: SessionLimits;
19
21
  }
22
+ /** Limits on what an agent can start through one server process. See limits.ts. */
23
+ export interface SessionLimits {
24
+ /** Sessions started by this process that may run at once. */
25
+ maxSessions: number;
26
+ /** Longest TTL a launch may ask for, as given (e.g. `4h`). */
27
+ maxTtl: string;
28
+ maxTtlMs: number;
29
+ /** Launches per server process. */
30
+ maxLaunches: number;
31
+ /** Stop only sessions this process started. */
32
+ stopOwnOnly: boolean;
33
+ }
34
+ export declare const DEFAULT_LIMITS: SessionLimits;
20
35
  /** Per-request timeout used when GAMEYE_TIMEOUT_MS is not set. */
21
36
  export declare const DEFAULT_TIMEOUT_MS = 30000;
22
37
  export declare class ConfigError extends Error {
package/dist/env.js CHANGED
@@ -3,10 +3,20 @@
3
3
  // The Gameye environment is a property of the server process, never of a
4
4
  // single tool call: an agent cannot switch to production mid-conversation.
5
5
  // Users who need both environments run two named server entries.
6
+ import { parseDurationMs } from "./limits.js";
6
7
  export const KNOWN_HOSTS = {
7
8
  sandbox: "https://api.sandbox-gameye.gameye.net",
8
9
  production: "https://api.production-gameye.gameye.net",
9
10
  };
11
+ export const DEFAULT_LIMITS = {
12
+ maxSessions: 5,
13
+ maxTtl: "4h",
14
+ maxTtlMs: 4 * 3_600_000,
15
+ maxLaunches: 20,
16
+ stopOwnOnly: false,
17
+ };
18
+ /** Shortest GAMEYE_MAX_TTL: the default TTL a launch without one gets (DEFAULT_SESSION_TTL). */
19
+ const MIN_MAX_TTL_MS = 30 * 60_000;
10
20
  /** Per-request timeout used when GAMEYE_TIMEOUT_MS is not set. */
11
21
  export const DEFAULT_TIMEOUT_MS = 30_000;
12
22
  export class ConfigError extends Error {
@@ -30,6 +40,35 @@ function parseTimeoutMs(raw) {
30
40
  }
31
41
  return ms;
32
42
  }
43
+ function parsePositiveInt(name, raw, fallback) {
44
+ const value = nonEmpty(raw);
45
+ if (value === undefined)
46
+ return fallback;
47
+ const n = /^\d+$/.test(value) ? Number(value) : NaN;
48
+ if (!Number.isSafeInteger(n) || n <= 0) {
49
+ throw new ConfigError(`${name} must be a positive whole number (got "${value}"). Leave it unset for ${fallback}.`);
50
+ }
51
+ return n;
52
+ }
53
+ function parseLimits(env) {
54
+ const maxSessions = parsePositiveInt("GAMEYE_MAX_SESSIONS", env.GAMEYE_MAX_SESSIONS, DEFAULT_LIMITS.maxSessions);
55
+ const maxLaunches = parsePositiveInt("GAMEYE_MAX_LAUNCHES", env.GAMEYE_MAX_LAUNCHES, DEFAULT_LIMITS.maxLaunches);
56
+ let { maxTtl, maxTtlMs } = DEFAULT_LIMITS;
57
+ const rawTtl = nonEmpty(env.GAMEYE_MAX_TTL);
58
+ if (rawTtl !== undefined) {
59
+ const ms = parseDurationMs(rawTtl);
60
+ if (ms === undefined || ms < MIN_MAX_TTL_MS) {
61
+ throw new ConfigError(`GAMEYE_MAX_TTL must be a duration of at least 30m, like 2h or 90m (got "${rawTtl}"). Leave it unset for ${DEFAULT_LIMITS.maxTtl}.`);
62
+ }
63
+ maxTtl = rawTtl;
64
+ maxTtlMs = ms;
65
+ }
66
+ const rawOwn = nonEmpty(env.GAMEYE_STOP_OWN_ONLY);
67
+ if (rawOwn !== undefined && rawOwn !== "1" && rawOwn !== "0") {
68
+ throw new ConfigError(`GAMEYE_STOP_OWN_ONLY must be 1 or 0 (got "${rawOwn}"). Leave it unset to allow stopping any session the token can see.`);
69
+ }
70
+ return { maxSessions, maxTtl, maxTtlMs, maxLaunches, stopOwnOnly: rawOwn === "1" };
71
+ }
33
72
  /** Lowercase a hostname and drop a trailing root dot (`example.com.` is the same host as `example.com`). */
34
73
  function normalizeHostname(hostname) {
35
74
  return hostname.toLowerCase().replace(/\.$/, "");
@@ -60,6 +99,7 @@ export function resolveConfig(env) {
60
99
  "(default sandbox), or GAMEYE_API_URL for a non-standard https host.");
61
100
  }
62
101
  const timeoutMs = parseTimeoutMs(env.GAMEYE_TIMEOUT_MS);
102
+ const limits = parseLimits(env);
63
103
  const rawEnv = nonEmpty(env.GAMEYE_ENV);
64
104
  let tier = "sandbox";
65
105
  if (rawEnv !== undefined) {
@@ -71,7 +111,7 @@ export function resolveConfig(env) {
71
111
  const rawUrl = nonEmpty(env.GAMEYE_API_URL);
72
112
  if (rawUrl === undefined) {
73
113
  const baseUrl = KNOWN_HOSTS[tier];
74
- return { environment: tier, baseUrl, host: new URL(baseUrl).host, token, timeoutMs };
114
+ return { environment: tier, baseUrl, host: new URL(baseUrl).host, token, timeoutMs, limits };
75
115
  }
76
116
  let url;
77
117
  try {
@@ -90,10 +130,10 @@ export function resolveConfig(env) {
90
130
  `Set GAMEYE_ENV=${knownTier} to use ${knownTier}, or remove GAMEYE_API_URL.`);
91
131
  }
92
132
  const baseUrl = KNOWN_HOSTS[knownTier];
93
- return { environment: knownTier, baseUrl, host: new URL(baseUrl).host, token, timeoutMs };
133
+ return { environment: knownTier, baseUrl, host: new URL(baseUrl).host, token, timeoutMs, limits };
94
134
  }
95
135
  const baseUrl = `${url.origin}${url.pathname}`.replace(/\/+$/, "");
96
- return { environment: "custom", baseUrl, host: url.host, token, timeoutMs };
136
+ return { environment: "custom", baseUrl, host: url.host, token, timeoutMs, limits };
97
137
  }
98
138
  /** Resolve from process.env, printing a clear message and exiting 1 on any error. */
99
139
  export function loadConfigOrExit(env = process.env) {
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ const ctx = {
13
13
  host: config.host,
14
14
  baseUrl: config.baseUrl,
15
15
  version,
16
+ limits: config.limits,
16
17
  mask: (text) => client.maskToken(text),
17
18
  };
18
19
  const server = createServer(version);
@@ -0,0 +1,40 @@
1
+ import type { SessionLimits } from "./env.js";
2
+ import type { GameyeClient } from "./client.js";
3
+ /**
4
+ * Parse a Gameye TTL (Go duration syntax, e.g. `30m`, `1h`, `2.5h`, `4h50m`) to
5
+ * milliseconds. Returns undefined for anything else. `0` parses to 0.
6
+ */
7
+ export declare function parseDurationMs(text: string): number | undefined;
8
+ /** A slot held while one launch is in flight. Settle it exactly once. */
9
+ export interface LaunchReservation {
10
+ /** The launch succeeded with this session ID. */
11
+ started(id: string): void;
12
+ /** The launch failed. `maybeStarted` when the outcome is unknown (timeout, network error). */
13
+ failed(maybeStarted: boolean): void;
14
+ }
15
+ export declare class SessionLimiter {
16
+ readonly limits: SessionLimits;
17
+ private readonly client;
18
+ private readonly now;
19
+ private inFlight;
20
+ private launches;
21
+ private unknownSeq;
22
+ /** Sessions this process started (or may have started) that may still run: id → expiry time. */
23
+ private readonly live;
24
+ /** Every session ID this process started, for GAMEYE_STOP_OWN_ONLY. */
25
+ private readonly own;
26
+ constructor(limits: SessionLimits, client: Pick<GameyeClient, "listSessions">, now?: () => number);
27
+ /** Refuse a TTL above GAMEYE_MAX_TTL, a zero TTL, or one that can't be parsed. Returns it in ms. */
28
+ checkTtl(ttl: string): number;
29
+ /**
30
+ * Hold a launch slot, or refuse with a GuardError. Counts this process's
31
+ * sessions that are still running, plus launches in flight.
32
+ */
33
+ reserve(ttlMs: number): Promise<LaunchReservation>;
34
+ /** True when this process started the session. */
35
+ isOwn(id: string): boolean;
36
+ /** The session was stopped: it no longer counts toward GAMEYE_MAX_SESSIONS. */
37
+ stopped(id: string): void;
38
+ /** Number of sessions this process started that may still be running. */
39
+ private countRunning;
40
+ }
package/dist/limits.js ADDED
@@ -0,0 +1,131 @@
1
+ // Local limits on what an agent can start through this server process.
2
+ //
3
+ // The Gameye API has no per-token budget, so these are enforced here, before
4
+ // any request. Together they bound the worst case: at most maxSessions running
5
+ // at once, each living at most maxTtl, and at most maxLaunches per process.
6
+ // Every check fails closed: if the server can't tell how many of its sessions
7
+ // are still running, it refuses the launch.
8
+ import { AGENT_LABEL_KEY, AGENT_LABEL_VALUE, GuardError } from "./guards.js";
9
+ const ENDED = new Set(["exited", "dead"]);
10
+ const UNITS_MS = { h: 3_600_000, m: 60_000, s: 1_000 };
11
+ /**
12
+ * Parse a Gameye TTL (Go duration syntax, e.g. `30m`, `1h`, `2.5h`, `4h50m`) to
13
+ * milliseconds. Returns undefined for anything else. `0` parses to 0.
14
+ */
15
+ export function parseDurationMs(text) {
16
+ const value = text.trim();
17
+ if (value === "0")
18
+ return 0;
19
+ if (!/^(\d+(\.\d+)?[hms])+$/.test(value))
20
+ return undefined;
21
+ let ms = 0;
22
+ for (const [, num, unit] of value.matchAll(/(\d+(?:\.\d+)?)([hms])/g)) {
23
+ ms += Number(num) * UNITS_MS[unit];
24
+ }
25
+ return ms;
26
+ }
27
+ export class SessionLimiter {
28
+ limits;
29
+ client;
30
+ now;
31
+ inFlight = 0;
32
+ launches = 0;
33
+ unknownSeq = 0;
34
+ /** Sessions this process started (or may have started) that may still run: id → expiry time. */
35
+ live = new Map();
36
+ /** Every session ID this process started, for GAMEYE_STOP_OWN_ONLY. */
37
+ own = new Set();
38
+ constructor(limits, client, now = Date.now) {
39
+ this.limits = limits;
40
+ this.client = client;
41
+ this.now = now;
42
+ }
43
+ /** Refuse a TTL above GAMEYE_MAX_TTL, a zero TTL, or one that can't be parsed. Returns it in ms. */
44
+ checkTtl(ttl) {
45
+ const ms = parseDurationMs(ttl);
46
+ if (ms === undefined) {
47
+ throw new GuardError(`Launch refused: ttl "${ttl}" is not a duration like 30m, 1h or 2.5h. No session was started.`);
48
+ }
49
+ if (ms === 0 || ms > this.limits.maxTtlMs) {
50
+ throw new GuardError(`Launch refused: ttl "${ttl}" is above this server's limit of ${this.limits.maxTtl} (GAMEYE_MAX_TTL). ` +
51
+ `Pass a ttl of ${this.limits.maxTtl} or less. No session was started.`);
52
+ }
53
+ return ms;
54
+ }
55
+ /**
56
+ * Hold a launch slot, or refuse with a GuardError. Counts this process's
57
+ * sessions that are still running, plus launches in flight.
58
+ */
59
+ async reserve(ttlMs) {
60
+ const { maxLaunches, maxSessions } = this.limits;
61
+ if (this.launches + this.inFlight >= maxLaunches) {
62
+ throw new GuardError(`Launch refused: this server has already started ${this.launches} session(s), its limit per run (GAMEYE_MAX_LAUNCHES=${maxLaunches}). ` +
63
+ "Restart the MCP server to reset it, or ask the user to raise the limit. No session was started.");
64
+ }
65
+ this.inFlight++;
66
+ let running;
67
+ try {
68
+ running = await this.countRunning();
69
+ }
70
+ catch (err) {
71
+ this.inFlight--;
72
+ const reason = err instanceof Error ? err.message : String(err);
73
+ throw new GuardError(`Launch refused: couldn't check how many of this server's sessions are still running (${reason}). ` +
74
+ "The session limit fails closed. Try again in a moment. No session was started.");
75
+ }
76
+ if (running + this.inFlight > maxSessions) {
77
+ this.inFlight--;
78
+ throw new GuardError(`Launch refused: ${running} session(s) started by this server are still running, and its limit is ${maxSessions} at once (GAMEYE_MAX_SESSIONS). ` +
79
+ "Stop one you no longer need with gameye_stop_session, or wait for its TTL. No session was started.");
80
+ }
81
+ let settled = false;
82
+ const settle = () => {
83
+ if (settled)
84
+ throw new Error("launch reservation settled twice");
85
+ settled = true;
86
+ this.inFlight--;
87
+ };
88
+ return {
89
+ started: (id) => {
90
+ settle();
91
+ this.launches++;
92
+ this.own.add(id);
93
+ this.live.set(id, this.now() + ttlMs);
94
+ },
95
+ failed: (maybeStarted) => {
96
+ settle();
97
+ if (!maybeStarted)
98
+ return;
99
+ // The session may exist under an ID we never saw: count it until its TTL ends.
100
+ this.launches++;
101
+ this.live.set(`unknown-${++this.unknownSeq}`, this.now() + ttlMs);
102
+ },
103
+ };
104
+ }
105
+ /** True when this process started the session. */
106
+ isOwn(id) {
107
+ return this.own.has(id);
108
+ }
109
+ /** The session was stopped: it no longer counts toward GAMEYE_MAX_SESSIONS. */
110
+ stopped(id) {
111
+ this.live.delete(id);
112
+ }
113
+ /** Number of sessions this process started that may still be running. */
114
+ async countRunning() {
115
+ const now = this.now();
116
+ for (const [id, expiresAt] of this.live) {
117
+ if (expiresAt <= now)
118
+ this.live.delete(id);
119
+ }
120
+ if (this.live.size === 0)
121
+ return 0;
122
+ // Labelled sessions report their status. Warm-pool sessions carry no label, so
123
+ // they are missing from the list and keep counting until their TTL ends.
124
+ const { items } = await this.client.listSessions({ all: true, filter: { [AGENT_LABEL_KEY]: AGENT_LABEL_VALUE } });
125
+ for (const s of items) {
126
+ if (ENDED.has(s.status))
127
+ this.live.delete(s.id);
128
+ }
129
+ return this.live.size;
130
+ }
131
+ }
package/dist/skills.d.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  /** Where the `gameye` skill's `Pack version:` line is published. */
2
2
  export declare const LATEST_PACK_URL = "https://raw.githubusercontent.com/Gameye/gameye-skills/main/skills/gameye/SKILL.md";
3
+ /** The skills CLI the updater runs, pinned so `npx` never fetches whatever is newest. */
4
+ export declare const SKILLS_CLI = "skills@1.7.4";
5
+ /** Set to 1 to let `gameye_update_skills` run updaters; otherwise apply prints the commands. */
6
+ export declare const ENABLE_UPDATE_ENV = "GAMEYE_ENABLE_SKILLS_UPDATE";
7
+ /** The environment an updater runs with: the allowlist above, no Gameye variables, nothing that looks like a secret. */
8
+ export declare function childEnv(parent: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
3
9
  /** How the Gameye skills were installed. Each kind updates differently. */
4
10
  export type InstallKind = "skills-cli-global" | "skills-cli-project" | "claude-plugin";
5
11
  export interface SkillsInstall {
@@ -14,6 +20,8 @@ export interface SkillsEnv {
14
20
  home: string;
15
21
  cwd: string;
16
22
  platform: NodeJS.Platform;
23
+ /** The operator set GAMEYE_ENABLE_SKILLS_UPDATE=1: apply may run updaters. */
24
+ applyEnabled: boolean;
17
25
  readText: (path: string) => Promise<string | undefined>;
18
26
  fetchText: (url: string) => Promise<string>;
19
27
  run: (cmd: string, args: string[], cwd: string) => Promise<string>;
package/dist/skills.js CHANGED
@@ -8,11 +8,39 @@ const PACK_VERSION_RE = /^Pack version: (\d+\.\d+\.\d+)[ \t]*$/m;
8
8
  const SKILLS_SOURCE_RE = /^(https:\/\/github\.com\/|git@github\.com:|github:)?gameye\/gameye-skills(\.git)?\/?$/i;
9
9
  // Lock files can come from a cloned project, so a name must look like a skill name, never a CLI option.
10
10
  const SKILL_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
11
+ /** The skills CLI the updater runs, pinned so `npx` never fetches whatever is newest. */
12
+ export const SKILLS_CLI = "skills@1.7.4";
13
+ /** Set to 1 to let `gameye_update_skills` run updaters; otherwise apply prints the commands. */
14
+ export const ENABLE_UPDATE_ENV = "GAMEYE_ENABLE_SKILLS_UPDATE";
15
+ // What an updater needs from the parent: where to find programs and home, temp dirs, locale,
16
+ // proxy and CA settings, npm and Claude config. Nothing else is passed, so the Gameye token stays here.
17
+ const CHILD_ENV_NAMES = new Set([
18
+ "PATH", "Path", "PATHEXT", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "HOMEDRIVE", "HOMEPATH",
19
+ "SystemRoot", "SYSTEMROOT", "ComSpec", "COMSPEC", "WINDIR", "TEMP", "TMP", "TMPDIR",
20
+ "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "TERM",
21
+ "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy",
22
+ "NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "SSL_CERT_DIR", "CLAUDE_CONFIG_DIR",
23
+ ]);
24
+ const CHILD_ENV_PREFIXES = ["XDG_", "npm_config_", "NPM_CONFIG_"];
25
+ // Never passed, even under an allowed prefix: an npm registry token is not the updater's business either.
26
+ const SECRET_NAME_RE = /token|secret|password|passwd|auth|key|credential/i;
27
+ /** The environment an updater runs with: the allowlist above, no Gameye variables, nothing that looks like a secret. */
28
+ export function childEnv(parent) {
29
+ const env = {};
30
+ for (const [name, value] of Object.entries(parent)) {
31
+ if (value === undefined || name.toUpperCase().startsWith("GAMEYE_") || SECRET_NAME_RE.test(name))
32
+ continue;
33
+ if (CHILD_ENV_NAMES.has(name) || CHILD_ENV_PREFIXES.some((p) => name.startsWith(p)))
34
+ env[name] = value;
35
+ }
36
+ return { ...env, NO_COLOR: "1", FORCE_COLOR: "0", CI: "1" };
37
+ }
11
38
  export function defaultSkillsEnv() {
12
39
  return {
13
40
  home: homedir(),
14
41
  cwd: process.cwd(),
15
42
  platform: process.platform,
43
+ applyEnabled: process.env[ENABLE_UPDATE_ENV] === "1",
16
44
  readText: (path) => readFile(path, "utf8").catch(() => undefined),
17
45
  fetchText: async (url) => {
18
46
  const res = await fetch(url, { signal: AbortSignal.timeout(5_000) });
@@ -21,8 +49,7 @@ export function defaultSkillsEnv() {
21
49
  return res.text();
22
50
  },
23
51
  run: (cmd, args, cwd) => new Promise((resolve, reject) => {
24
- const childEnv = { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0", CI: "1" };
25
- const options = { cwd, env: childEnv, timeout: 90_000, killSignal: "SIGKILL", maxBuffer: 4 * 1024 * 1024 };
52
+ const options = { cwd, env: childEnv(process.env), timeout: 90_000, killSignal: "SIGKILL", maxBuffer: 4 * 1024 * 1024 };
26
53
  const child = execFile(cmd, args, options, (err, stdout, stderr) => {
27
54
  const out = stripAnsi(`${stdout}${stderr}`).trim();
28
55
  if (err)
@@ -120,9 +147,9 @@ export function isOutdated(install, latest) {
120
147
  export function updateCommands(install) {
121
148
  switch (install.kind) {
122
149
  case "skills-cli-global":
123
- return [["npx", "-y", "skills", "update", ...install.skills, "-g", "-y"]];
150
+ return [["npx", "-y", SKILLS_CLI, "update", ...install.skills, "-g", "-y"]];
124
151
  case "skills-cli-project":
125
- return [["npx", "-y", "skills", "update", ...install.skills, "-p", "-y"]];
152
+ return [["npx", "-y", SKILLS_CLI, "update", ...install.skills, "-p", "-y"]];
126
153
  case "claude-plugin":
127
154
  return [
128
155
  ["claude", "plugin", "marketplace", "update", "gameye"],
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
4
4
  import { GameyeError } from "../client.js";
5
- import { type EnvironmentLabel } from "../env.js";
5
+ import { type EnvironmentLabel, type SessionLimits } from "../env.js";
6
6
  /** Shared `dryRun` input for every tool that sends a write. */
7
7
  export declare const dryRunInput: z.ZodOptional<z.ZodBoolean>;
8
8
  /** What every tool needs to know about the server it runs in. Never holds the token. */
@@ -11,6 +11,8 @@ export interface ServerContext {
11
11
  host: string;
12
12
  baseUrl: string;
13
13
  version: string;
14
+ /** Local launch and stop limits, fixed when the server starts. */
15
+ limits: SessionLimits;
14
16
  /** Replace the API token with `***`. Applied to every tool result, success or error. */
15
17
  mask: (text: string) => string;
16
18
  }
@@ -19,6 +19,12 @@ export function registerContextTool(server, ctx) {
19
19
  ` Session label: ${AGENT_LABEL_KEY}=${AGENT_LABEL_VALUE} on every launch`,
20
20
  ` Token expiry: ${DEFAULT_TOKEN_EXPIRY} when gameye_create_token gives no expires_after`,
21
21
  "",
22
+ "Session limits (set when the server starts):",
23
+ ` Running at once: ${ctx.limits.maxSessions} sessions started by this server (GAMEYE_MAX_SESSIONS)`,
24
+ ` Longest TTL: ${ctx.limits.maxTtl} (GAMEYE_MAX_TTL)`,
25
+ ` Launches: ${ctx.limits.maxLaunches} per server run (GAMEYE_MAX_LAUNCHES)`,
26
+ ` Stop: ${ctx.limits.stopOwnOnly ? "only sessions this server launched (GAMEYE_STOP_OWN_ONLY=1)" : "any session the token can see, by exact ID"}`,
27
+ "",
22
28
  "Token scopes: not introspectable. The Gameye API has no endpoint that reports a token's scopes; a call that needs a missing scope fails with 403 and names the required scope.",
23
29
  ctx.environment === "custom"
24
30
  ? "This server uses a non-standard GAMEYE_API_URL."
@@ -1,18 +1,23 @@
1
1
  import { z } from "zod";
2
- import { GameyeClient } from "../client.js";
3
- import { AGENT_LABEL_KEY, AGENT_LABEL_VALUE, DEFAULT_SESSION_TTL, applyLaunchDefaults, assertExplicitSessionId, assertNoTokenInLaunch, describeTtl, formatDryRun, } from "../guards.js";
2
+ import { GameyeClient, GameyeError } from "../client.js";
3
+ import { AGENT_LABEL_KEY, AGENT_LABEL_VALUE, DEFAULT_SESSION_TTL, applyLaunchDefaults, assertExplicitSessionId, assertNoTokenInLaunch, describeTtl, formatDryRun, GuardError, } from "../guards.js";
4
+ import { SessionLimiter } from "../limits.js";
4
5
  import { defineTool, dryRunInput, formatLabels } from "./common.js";
5
6
  function formatPorts(ports) {
6
7
  return (ports ?? []).map((p) => `${p.host}:${p.container}/${p.type}`).join(", ");
7
8
  }
8
9
  export function registerSessionTools(server, client, ctx) {
10
+ const limiter = new SessionLimiter(ctx.limits, client);
11
+ const { maxSessions, maxTtl, maxLaunches, stopOwnOnly } = ctx.limits;
9
12
  // ─── Launch Session ──────────────────────────────────────────────────────────
10
13
  defineTool(server, ctx, "gameye_launch_session", {
11
14
  title: "Launch session",
12
15
  description: "Start a containerized game server session on the Gameye platform. Returns the session ID, host and ports. Starts a billable session in the environment shown in the result prefix. " +
13
16
  `When no ttl is given, a ${DEFAULT_SESSION_TTL} TTL is applied and reported. The label ${AGENT_LABEL_KEY}=${AGENT_LABEL_VALUE} is always sent and replaces any ${AGENT_LABEL_KEY} value you pass; ` +
14
17
  "if a warm pool serves the image and region, the server ignores request labels (and env and args), so warm-pool sessions do not carry it. " +
15
- "The launch is refused, with nothing sent, if any env value, arg or label contains this server's Gameye API token. Pass dryRun=true to preview the request.",
18
+ "The launch is refused, with nothing sent, if any env value, arg or label contains this server's Gameye API token. " +
19
+ `It is also refused if the ttl is above ${maxTtl}, if ${maxSessions} sessions started by this server are already running, or after ${maxLaunches} launches by this server process; ` +
20
+ "these limits are set by the user when the server starts, and a refusal says which one applied. Pass dryRun=true to preview the request.",
16
21
  inputSchema: {
17
22
  location: z.string().describe("Region/datacenter to host the session (e.g. 'europe', 'us-east')"),
18
23
  image: z.string().describe("Docker image name to run (e.g. 'csgo', 'tf2')"),
@@ -25,18 +30,30 @@ export function registerSessionTools(server, client, ctx) {
25
30
  ttl: z
26
31
  .string()
27
32
  .optional()
28
- .describe(`Session time-to-live, e.g. '1h', '30m'. Session auto-stops after this duration. Defaults to ${DEFAULT_SESSION_TTL}`),
33
+ .describe(`Session time-to-live, e.g. '1h', '30m'. Session auto-stops after this duration. Defaults to ${DEFAULT_SESSION_TTL}; at most ${maxTtl}`),
29
34
  dryRun: dryRunInput,
30
35
  },
31
36
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
32
37
  }, async ({ dryRun, ...input }) => {
33
38
  assertNoTokenInLaunch(input, client);
34
39
  const { request, defaultTtlApplied } = applyLaunchDefaults(input);
35
- const ttlLine = ` TTL: ${describeTtl(request.ttl ?? DEFAULT_SESSION_TTL, defaultTtlApplied)}`;
40
+ const ttl = request.ttl ?? DEFAULT_SESSION_TTL;
41
+ const ttlMs = limiter.checkTtl(ttl);
42
+ const ttlLine = ` TTL: ${describeTtl(ttl, defaultTtlApplied)}`;
36
43
  if (dryRun) {
37
44
  return `${formatDryRun(ctx, GameyeClient.launchSessionRequest(request), client)}\n\n${ttlLine.trim()}`;
38
45
  }
39
- const result = await client.launchSession(request);
46
+ const slot = await limiter.reserve(ttlMs);
47
+ let result;
48
+ try {
49
+ result = await client.launchSession(request);
50
+ }
51
+ catch (err) {
52
+ // A 4xx means nothing started. A 5xx, timeout or network error may have started one.
53
+ slot.failed(!(err instanceof GameyeError && err.statusCode < 500));
54
+ throw err;
55
+ }
56
+ slot.started(result.id);
40
57
  const lines = [`Session launched successfully.`, ` Session ID: ${result.id}`, ttlLine];
41
58
  if (result.host)
42
59
  lines.push(` Host: ${result.host}`);
@@ -123,7 +140,9 @@ export function registerSessionTools(server, client, ctx) {
123
140
  defineTool(server, ctx, "gameye_stop_session", {
124
141
  title: "Stop session",
125
142
  description: "Terminate one Gameye session by its exact ID (sends SIGTERM to the container). Any players connected to it are disconnected. The session transitions to 'draining' then 'shuttingdown' then 'exited'. " +
126
- "There is no bulk or implicit stop: an empty, wildcard or list ID is refused. Pass dryRun=true to preview the request.",
143
+ "There is no bulk or implicit stop: an empty, wildcard or list ID is refused. " +
144
+ (stopOwnOnly ? "This server only stops sessions it launched itself (GAMEYE_STOP_OWN_ONLY=1). " : "") +
145
+ "Pass dryRun=true to preview the request.",
127
146
  inputSchema: {
128
147
  id: z.string().min(1).describe("Exact ID of the one session to stop"),
129
148
  dryRun: dryRunInput,
@@ -131,9 +150,14 @@ export function registerSessionTools(server, client, ctx) {
131
150
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
132
151
  }, async ({ id: rawId, dryRun }) => {
133
152
  const id = assertExplicitSessionId(rawId);
153
+ if (stopOwnOnly && !limiter.isOwn(id)) {
154
+ throw new GuardError(`Stop refused: session ${id} was not launched by this server, and it only stops its own sessions (GAMEYE_STOP_OWN_ONLY=1). ` +
155
+ "Ask the user to stop it in the Admin Panel or from another tool. No request was sent.");
156
+ }
134
157
  if (dryRun)
135
158
  return formatDryRun(ctx, GameyeClient.stopSessionRequest(id), client);
136
159
  await client.stopSession(id);
160
+ limiter.stopped(id);
137
161
  return `Session ${id} stop signal sent. The session is now draining.\n\nUse gameye_describe_session to monitor shutdown progress.`;
138
162
  });
139
163
  }
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { defineTool } from "./common.js";
3
- import { compareVersions, defaultSkillsEnv, describeInstall, fetchLatestPackVersion, findSkillsInstalls, isOutdated, updateCommands, } from "../skills.js";
3
+ import { compareVersions, defaultSkillsEnv, describeInstall, ENABLE_UPDATE_ENV, fetchLatestPackVersion, findSkillsInstalls, isOutdated, updateCommands, } from "../skills.js";
4
4
  const RELOAD_NOTE = "The running session keeps the skills it already loaded. Start a new session (in Claude Code, /reload-plugins also works for the plugin) to use the update.";
5
5
  export function registerSkillsTools(server, ctx, env = defaultSkillsEnv()) {
6
6
  defineTool(server, ctx, "gameye_update_skills", {
@@ -8,7 +8,8 @@ export function registerSkillsTools(server, ctx, env = defaultSkillsEnv()) {
8
8
  description: "Compare the installed Gameye agent skills with the latest pack on GitHub, and update them. " +
9
9
  "Finds installs made with the skills CLI (user-wide or in this project) and the Claude Code plugin gameye@gameye. " +
10
10
  "Without apply it only checks and changes nothing. With apply=true it runs each outdated install's own updater " +
11
- "(npx skills update for the Gameye skills only, or claude plugin update); ask the user before applying. " +
11
+ "(npx skills update for the Gameye skills only, or claude plugin update) when the operator set " +
12
+ `${ENABLE_UPDATE_ENV}=1; otherwise it lists the commands for the user to run. Ask the user before applying. ` +
12
13
  "Updates come only through those package managers; this tool never downloads or follows skill text itself.",
13
14
  inputSchema: {
14
15
  apply: z
@@ -56,8 +57,15 @@ export function registerSkillsTools(server, ctx, env = defaultSkillsEnv()) {
56
57
  if (outdated.length === 0)
57
58
  return lines.join("\n");
58
59
  const commands = outdated.flatMap(updateCommands);
59
- if (!apply || env.platform === "win32") {
60
- lines.push("", apply ? "Updating from this server isn't supported on Windows. Run:" : "Call again with apply=true to run:");
60
+ if (!apply || env.platform === "win32" || !env.applyEnabled) {
61
+ const why = !apply
62
+ ? env.applyEnabled
63
+ ? "Call again with apply=true to run:"
64
+ : "Run:"
65
+ : env.platform === "win32"
66
+ ? "Updating from this server isn't supported on Windows. Run:"
67
+ : `Updating from this server is off (set ${ENABLE_UPDATE_ENV}=1 in the server's environment to allow it). Run:`;
68
+ lines.push("", why);
61
69
  for (const cmd of commands)
62
70
  lines.push(` ${cmd.join(" ")}`);
63
71
  return lines.join("\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gameye-mcp",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "mcpName": "io.github.Gameye/gameye-mcp",
5
5
  "description": "MCP server for the Gameye game server orchestration API: launch and inspect sessions, check image tags, fetch logs and artifacts from any MCP client",
6
6
  "main": "dist/index.js",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "type": "module",
50
50
  "dependencies": {
51
- "@modelcontextprotocol/sdk": "^1.26.0",
51
+ "@modelcontextprotocol/sdk": "^1.32.1",
52
52
  "zod": "^4.3.6"
53
53
  },
54
54
  "devDependencies": {