gameye-mcp 0.3.2 → 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 +8 -3
- package/dist/env.d.ts +15 -0
- package/dist/env.js +43 -3
- package/dist/index.js +1 -0
- package/dist/limits.d.ts +40 -0
- package/dist/limits.js +131 -0
- package/dist/tools/common.d.ts +3 -1
- package/dist/tools/context.js +6 -0
- package/dist/tools/sessions.js +31 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,6 +33,10 @@ The environment is a property of the server process. No tool takes an environmen
|
|
|
33
33
|
| `GAMEYE_ENV` | No | `sandbox` | `sandbox` or `production`. Selects the API host. |
|
|
34
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
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. |
|
|
36
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`. |
|
|
37
41
|
|
|
38
42
|
## Install
|
|
@@ -66,7 +70,7 @@ Most clients (Claude Desktop, Cursor, Windsurf and others) accept an `mcpServers
|
|
|
66
70
|
|
|
67
71
|
Restart the client after you change its configuration.
|
|
68
72
|
|
|
69
|
-
`npx -y gameye-mcp` runs the latest release. To stay on one version, name it: `npx -y gameye-mcp@0.
|
|
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"]`).
|
|
70
74
|
|
|
71
75
|
### Windows
|
|
72
76
|
|
|
@@ -108,7 +112,7 @@ With Claude Code: `claude mcp add gameye --env GAMEYE_API_TOKEN=<your-token> --
|
|
|
108
112
|
|
|
109
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.
|
|
110
114
|
- Keep `-i` and never add `-t`: a terminal mangles the JSON-RPC messages on stdout.
|
|
111
|
-
- To stay on one version, use a version tag such as `ghcr.io/gameye/gameye-mcp:0.
|
|
115
|
+
- To stay on one version, use a version tag such as `ghcr.io/gameye/gameye-mcp:0.4.0`.
|
|
112
116
|
- Files the server writes stay inside the container, and `--rm` deletes them:
|
|
113
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.
|
|
114
118
|
- `gameye_create_token` writes to the `outputPath` you give it. Mount a directory the same way and pass an `outputPath` under `/out`.
|
|
@@ -165,7 +169,8 @@ These behave the same on sandbox and production, because sandbox runs live match
|
|
|
165
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.
|
|
166
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.
|
|
167
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.
|
|
168
|
-
- **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.
|
|
169
174
|
|
|
170
175
|
## Keeping the skills current
|
|
171
176
|
|
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
package/dist/limits.d.ts
ADDED
|
@@ -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/tools/common.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/tools/context.js
CHANGED
|
@@ -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."
|
package/dist/tools/sessions.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gameye-mcp",
|
|
3
|
-
"version": "0.
|
|
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",
|