gameye-mcp 0.3.2 → 0.5.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 +55 -27
- package/dist/dockerfile-check.d.ts +84 -0
- package/dist/dockerfile-check.js +278 -0
- package/dist/env.d.ts +23 -2
- package/dist/env.js +61 -9
- package/dist/generated/skills.d.ts +39 -0
- package/dist/generated/skills.js +220 -0
- package/dist/index.js +9 -3
- package/dist/limits.d.ts +40 -0
- package/dist/limits.js +131 -0
- package/dist/server.d.ts +16 -2
- package/dist/server.js +34 -3
- package/dist/tokenless.d.ts +36 -0
- package/dist/tokenless.js +97 -0
- package/dist/tools/bundled-skills.d.ts +7 -0
- package/dist/tools/bundled-skills.js +76 -0
- package/dist/tools/common.d.ts +14 -2
- package/dist/tools/common.js +9 -2
- package/dist/tools/context.js +15 -1
- package/dist/tools/dockerfile-check.d.ts +3 -0
- package/dist/tools/dockerfile-check.js +27 -0
- package/dist/tools/register.js +6 -0
- package/dist/tools/sessions.js +31 -7
- package/dist/tools/skills.js +8 -2
- package/dist/tools/templates.d.ts +3 -0
- package/dist/tools/templates.js +40 -0
- package/dist/types.d.ts +1 -0
- package/package.json +3 -1
package/dist/index.js
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { GameyeClient } from "./client.js";
|
|
4
|
-
import { loadConfigOrExit } from "./env.js";
|
|
4
|
+
import { loadConfigOrExit, NO_TOKEN_NOTICE } from "./env.js";
|
|
5
5
|
import { registerAllTools } from "./tools/register.js";
|
|
6
6
|
import { createServer } from "./server.js";
|
|
7
7
|
import { BETA_NOTICE } from "./beta.js";
|
|
8
8
|
import { VERSION as version } from "./version.js";
|
|
9
9
|
const config = loadConfigOrExit(process.env);
|
|
10
|
-
|
|
10
|
+
// With no token the client gets an empty one so registrars keep their shape;
|
|
11
|
+
// the per-tool token gate stops any request from being built without a token.
|
|
12
|
+
const client = new GameyeClient({ token: config.token ?? "", baseUrl: config.baseUrl, timeoutMs: config.timeoutMs });
|
|
11
13
|
const ctx = {
|
|
12
14
|
environment: config.environment,
|
|
13
15
|
host: config.host,
|
|
14
16
|
baseUrl: config.baseUrl,
|
|
15
17
|
version,
|
|
18
|
+
hasToken: config.hasToken,
|
|
19
|
+
limits: config.limits,
|
|
16
20
|
mask: (text) => client.maskToken(text),
|
|
17
21
|
};
|
|
18
|
-
const server = createServer(version);
|
|
22
|
+
const server = createServer(version, { hasToken: config.hasToken });
|
|
19
23
|
registerAllTools(server, client, ctx);
|
|
20
24
|
// Attribute API traffic to the MCP client (name only) once it has introduced itself.
|
|
21
25
|
server.server.oninitialized = () => client.setClientName(server.server.getClientVersion()?.name);
|
|
@@ -23,3 +27,5 @@ const transport = new StdioServerTransport();
|
|
|
23
27
|
await server.connect(transport);
|
|
24
28
|
console.error(`Gameye MCP server ${version} running on stdio [${ctx.environment} · ${ctx.host}]`);
|
|
25
29
|
console.error(BETA_NOTICE);
|
|
30
|
+
if (!config.hasToken)
|
|
31
|
+
console.error(NO_TOKEN_NOTICE);
|
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/server.d.ts
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
-
/**
|
|
3
|
-
export
|
|
2
|
+
/** What the server knows about its configuration when it builds the instructions. */
|
|
3
|
+
export interface ServerMode {
|
|
4
|
+
hasToken: boolean;
|
|
5
|
+
}
|
|
6
|
+
/** The engine skills an agent reads after the `gameye` skill, one per supported engine. */
|
|
7
|
+
export declare const ENGINE_SKILLS: readonly string[];
|
|
8
|
+
/** The line, in both modes, naming the tools that read the bundled skills. */
|
|
9
|
+
export declare const BUNDLED_SKILLS_LINE = "This server bundles the Gameye agent skills: list them with gameye_list_skills and read one with gameye_get_skill, starting with the `gameye` skill.";
|
|
10
|
+
/**
|
|
11
|
+
* The instructions sent at initialize. With no token they lead with what works
|
|
12
|
+
* now: the first sentence says no token is configured and names the token-free
|
|
13
|
+
* tools and the first skill to read (R8).
|
|
14
|
+
*/
|
|
15
|
+
export declare function serverInstructions(mode: ServerMode): string;
|
|
16
|
+
/** The MCP server, with instructions for its token mode and the beta notice. */
|
|
17
|
+
export declare function createServer(version: string, mode: ServerMode): McpServer;
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { BETA_NOTICE } from "./beta.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import { NO_PASTE_RULE, RESTART_RULE, TOKEN_FREE_TOOLS, TOKEN_SIGNUP_STEPS } from "./tokenless.js";
|
|
4
|
+
/** The engine skills an agent reads after the `gameye` skill, one per supported engine. */
|
|
5
|
+
export const ENGINE_SKILLS = [
|
|
6
|
+
"gameye-unity-server",
|
|
7
|
+
"gameye-unreal-server",
|
|
8
|
+
"gameye-godot-server",
|
|
9
|
+
"gameye-web-server",
|
|
10
|
+
];
|
|
11
|
+
function orList(items, word) {
|
|
12
|
+
return items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} ${word} ${items[items.length - 1]}`;
|
|
13
|
+
}
|
|
14
|
+
/** The line, in both modes, naming the tools that read the bundled skills. */
|
|
15
|
+
export const BUNDLED_SKILLS_LINE = "This server bundles the Gameye agent skills: list them with gameye_list_skills and read one with gameye_get_skill, starting with the `gameye` skill.";
|
|
16
|
+
/**
|
|
17
|
+
* The instructions sent at initialize. With no token they lead with what works
|
|
18
|
+
* now: the first sentence says no token is configured and names the token-free
|
|
19
|
+
* tools and the first skill to read (R8).
|
|
20
|
+
*/
|
|
21
|
+
export function serverInstructions(mode) {
|
|
22
|
+
if (mode.hasToken)
|
|
23
|
+
return `${BETA_NOTICE}\n${BUNDLED_SKILLS_LINE}`;
|
|
24
|
+
return [
|
|
25
|
+
`No Gameye API token is configured, so only the token-free tools work: ${orList(TOKEN_FREE_TOOLS, "and")}; ` +
|
|
26
|
+
`read the \`gameye\` skill first with gameye_get_skill, then the skill for the game's engine (${orList(ENGINE_SKILLS, "or")}).`,
|
|
27
|
+
"Every other tool needs a token: without one it returns setup steps and sends nothing to the Gameye API, dry runs included.",
|
|
28
|
+
TOKEN_SIGNUP_STEPS,
|
|
29
|
+
RESTART_RULE,
|
|
30
|
+
NO_PASTE_RULE,
|
|
31
|
+
BETA_NOTICE,
|
|
32
|
+
].join("\n");
|
|
33
|
+
}
|
|
34
|
+
/** The MCP server, with instructions for its token mode and the beta notice. */
|
|
35
|
+
export function createServer(version, mode) {
|
|
36
|
+
return new McpServer({ name: "gameye", version }, { instructions: serverInstructions(mode) });
|
|
6
37
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** The first line of every no-token refusal. Stable: agents and tests match on it. */
|
|
2
|
+
export declare const TOKEN_REQUIRED_MARKER = "Token required: no GAMEYE_API_TOKEN is configured for this server.";
|
|
3
|
+
/**
|
|
4
|
+
* Tools that work without a token. Every other tool needs one: `defineTool`
|
|
5
|
+
* gates any tool that does not set `requiresToken: false`.
|
|
6
|
+
*/
|
|
7
|
+
export declare const TOKEN_FREE_TOOLS: readonly string[];
|
|
8
|
+
/** Every token-free tool description opens with this sentence. */
|
|
9
|
+
export declare const TOKEN_FREE_LEAD = "Works without a Gameye account or token.";
|
|
10
|
+
/** Every account tool description ends with this sentence. */
|
|
11
|
+
export declare const ACCOUNT_TOOL_TAIL = "Needs GAMEYE_API_TOKEN; without one, returns setup steps and sends nothing.";
|
|
12
|
+
/** A tool description with the sentence that says whether it needs a token. */
|
|
13
|
+
export declare function describeTokenNeed(description: string, requiresToken: boolean): string;
|
|
14
|
+
export type ScopeRecipeName = "Observer" | "Operator" | "Deployer" | "Token admin";
|
|
15
|
+
export interface ScopeRecipe {
|
|
16
|
+
name: ScopeRecipeName;
|
|
17
|
+
/** Every scope the recipe grants, including the recipes it builds on. */
|
|
18
|
+
scopes: readonly string[];
|
|
19
|
+
/** What a token with this recipe lets the agent do. */
|
|
20
|
+
summary: string;
|
|
21
|
+
}
|
|
22
|
+
/** The README "Token scopes" recipes, narrowest first. */
|
|
23
|
+
export declare const SCOPE_RECIPES: readonly ScopeRecipe[];
|
|
24
|
+
/** The narrowest recipe for each account tool, from the scope each API operation requires. */
|
|
25
|
+
export declare const TOOL_SCOPE_RECIPE: Readonly<Record<string, ScopeRecipeName>>;
|
|
26
|
+
/** Where a self-serve developer gets a token (sandbox host, this server's default). */
|
|
27
|
+
export declare const TOKEN_SIGNUP_STEPS: string;
|
|
28
|
+
export declare const RESTART_RULE = "The server reads GAMEYE_API_TOKEN once when it starts: after setting or changing it, restart or reconnect the MCP server.";
|
|
29
|
+
export declare const NO_PASTE_RULE = "Do not paste the token into this chat. The user sets it in the server configuration themselves.";
|
|
30
|
+
export declare const TOKEN_FREE_LINE: string;
|
|
31
|
+
/**
|
|
32
|
+
* The refusal an account tool returns when no token is configured. Order per
|
|
33
|
+
* R6: marker; nothing sent; where to get a token; scopes; how to set it per
|
|
34
|
+
* install path; restart; do not paste; what works now.
|
|
35
|
+
*/
|
|
36
|
+
export declare function tokenRequiredMessage(tool: string): string;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// No-token mode: which tools work without GAMEYE_API_TOKEN, the scope recipe
|
|
2
|
+
// each account tool needs, and the refusal an account tool returns when no
|
|
3
|
+
// token is configured. The refusal never asks for the token: credentials stay
|
|
4
|
+
// in the server's environment, read once at start.
|
|
5
|
+
/** The first line of every no-token refusal. Stable: agents and tests match on it. */
|
|
6
|
+
export const TOKEN_REQUIRED_MARKER = "Token required: no GAMEYE_API_TOKEN is configured for this server.";
|
|
7
|
+
/**
|
|
8
|
+
* Tools that work without a token. Every other tool needs one: `defineTool`
|
|
9
|
+
* gates any tool that does not set `requiresToken: false`.
|
|
10
|
+
*/
|
|
11
|
+
export const TOKEN_FREE_TOOLS = [
|
|
12
|
+
"gameye_context",
|
|
13
|
+
"gameye_update_skills",
|
|
14
|
+
"gameye_list_skills",
|
|
15
|
+
"gameye_get_skill",
|
|
16
|
+
"gameye_get_template",
|
|
17
|
+
"gameye_check_dockerfile",
|
|
18
|
+
];
|
|
19
|
+
/** Every token-free tool description opens with this sentence. */
|
|
20
|
+
export const TOKEN_FREE_LEAD = "Works without a Gameye account or token.";
|
|
21
|
+
/** Every account tool description ends with this sentence. */
|
|
22
|
+
export const ACCOUNT_TOOL_TAIL = "Needs GAMEYE_API_TOKEN; without one, returns setup steps and sends nothing.";
|
|
23
|
+
/** A tool description with the sentence that says whether it needs a token. */
|
|
24
|
+
export function describeTokenNeed(description, requiresToken) {
|
|
25
|
+
return requiresToken ? `${description} ${ACCOUNT_TOOL_TAIL}` : `${TOKEN_FREE_LEAD} ${description}`;
|
|
26
|
+
}
|
|
27
|
+
const OBSERVER = ["session:read", "logs:read", "artifact:read", "regions:read", "region:read", "application:read"];
|
|
28
|
+
const OPERATOR = [...OBSERVER, "session:start", "session:stop", "session:write"];
|
|
29
|
+
const DEPLOYER = [...OPERATOR, "application:write"];
|
|
30
|
+
const TOKEN_ADMIN = [...DEPLOYER, "token:write"];
|
|
31
|
+
/** The README "Token scopes" recipes, narrowest first. */
|
|
32
|
+
export const SCOPE_RECIPES = [
|
|
33
|
+
{ name: "Observer", scopes: OBSERVER, summary: "Inspect sessions, logs, artifacts, regions and applications. No changes." },
|
|
34
|
+
{ name: "Operator", scopes: OPERATOR, summary: "Also launch and stop sessions and update session state." },
|
|
35
|
+
{ name: "Deployer", scopes: DEPLOYER, summary: "Also create and edit applications, so the agent can set up a deployment end to end." },
|
|
36
|
+
{ name: "Token admin", scopes: TOKEN_ADMIN, summary: "Also create API tokens." },
|
|
37
|
+
];
|
|
38
|
+
/** The narrowest recipe for each account tool, from the scope each API operation requires. */
|
|
39
|
+
export const TOOL_SCOPE_RECIPE = {
|
|
40
|
+
gameye_list_sessions: "Observer",
|
|
41
|
+
gameye_describe_session: "Observer",
|
|
42
|
+
gameye_get_logs: "Observer",
|
|
43
|
+
gameye_get_artifacts: "Observer",
|
|
44
|
+
gameye_list_regions: "Observer",
|
|
45
|
+
gameye_get_locations: "Observer",
|
|
46
|
+
gameye_check_tag: "Observer",
|
|
47
|
+
gameye_list_applications: "Observer",
|
|
48
|
+
gameye_list_registry_tags: "Observer",
|
|
49
|
+
gameye_launch_session: "Operator",
|
|
50
|
+
gameye_stop_session: "Operator",
|
|
51
|
+
gameye_player_join: "Operator",
|
|
52
|
+
gameye_player_leave: "Operator",
|
|
53
|
+
gameye_enable_tag: "Deployer",
|
|
54
|
+
gameye_create_application: "Deployer",
|
|
55
|
+
gameye_edit_application: "Deployer",
|
|
56
|
+
gameye_create_token: "Token admin",
|
|
57
|
+
};
|
|
58
|
+
/** Where a self-serve developer gets a token (sandbox host, this server's default). */
|
|
59
|
+
export const TOKEN_SIGNUP_STEPS = "Get a token: sign up at https://trial.gameye.com, then in the Gameye Admin Panel open Users → API tokens and create one. " +
|
|
60
|
+
"Self-serve accounts use the sandbox host, which is this server's default (GAMEYE_ENV=sandbox).";
|
|
61
|
+
export const RESTART_RULE = "The server reads GAMEYE_API_TOKEN once when it starts: after setting or changing it, restart or reconnect the MCP server.";
|
|
62
|
+
export const NO_PASTE_RULE = "Do not paste the token into this chat. The user sets it in the server configuration themselves.";
|
|
63
|
+
export const TOKEN_FREE_LINE = `Works now without a token: ${TOKEN_FREE_TOOLS.join(", ")}.`;
|
|
64
|
+
function scopeLine(tool) {
|
|
65
|
+
const name = TOOL_SCOPE_RECIPE[tool];
|
|
66
|
+
const recipe = SCOPE_RECIPES.find((r) => r.name === name);
|
|
67
|
+
if (!recipe) {
|
|
68
|
+
return `Narrowest token scopes for ${tool}: see the Token scopes section of the gameye-mcp README.`;
|
|
69
|
+
}
|
|
70
|
+
return `Narrowest token scopes for ${tool}: ${recipe.name} (${recipe.scopes.join(" ")}). ${recipe.summary}`;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The refusal an account tool returns when no token is configured. Order per
|
|
74
|
+
* R6: marker; nothing sent; where to get a token; scopes; how to set it per
|
|
75
|
+
* install path; restart; do not paste; what works now.
|
|
76
|
+
*/
|
|
77
|
+
export function tokenRequiredMessage(tool) {
|
|
78
|
+
return [
|
|
79
|
+
TOKEN_REQUIRED_MARKER,
|
|
80
|
+
"Nothing was sent to the Gameye API, and retrying this call will not help until a token is configured.",
|
|
81
|
+
"",
|
|
82
|
+
TOKEN_SIGNUP_STEPS,
|
|
83
|
+
"",
|
|
84
|
+
scopeLine(tool),
|
|
85
|
+
"",
|
|
86
|
+
"Set the token where this server is configured:",
|
|
87
|
+
"• Claude Code plugin: enter it in the gameye plugin's API token setting.",
|
|
88
|
+
"• Claude Code CLI: re-add the server in your own terminal, passing the token with the -e GAMEYE_API_TOKEN option of `claude mcp add`.",
|
|
89
|
+
"• Other MCP clients: add GAMEYE_API_TOKEN to the env block of the server's mcpServers entry.",
|
|
90
|
+
"• Docker: keep `-e GAMEYE_API_TOKEN` (no value) in the docker run arguments and set the value in the client's env block, so it never appears on the command line.",
|
|
91
|
+
"",
|
|
92
|
+
RESTART_RULE,
|
|
93
|
+
NO_PASTE_RULE,
|
|
94
|
+
"",
|
|
95
|
+
TOKEN_FREE_LINE,
|
|
96
|
+
].join("\n");
|
|
97
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { type ServerContext } from "./common.js";
|
|
3
|
+
/** The first line of every bundled-skill result. */
|
|
4
|
+
export declare const BUNDLED_PACK_LINE: string;
|
|
5
|
+
export declare const INSTALLED_WINS_LINE = "If an installed copy of the Gameye skills is newer, it wins.";
|
|
6
|
+
export declare const BUNDLED_HEADER: string;
|
|
7
|
+
export declare function registerBundledSkillsTools(server: McpServer, ctx: ServerContext): void;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineTool } from "./common.js";
|
|
3
|
+
import { BUNDLED_SKILLS, BUNDLED_SKILLS_COMMIT, BUNDLED_SKILLS_PACK_VERSION, BUNDLED_SKILLS_TAG, } from "../generated/skills.js";
|
|
4
|
+
/** The first line of every bundled-skill result. */
|
|
5
|
+
export const BUNDLED_PACK_LINE = `Bundled Gameye skills pack: ${BUNDLED_SKILLS_PACK_VERSION} (gameye-skills ${BUNDLED_SKILLS_TAG}, commit ${BUNDLED_SKILLS_COMMIT.slice(0, 7)}).`;
|
|
6
|
+
export const INSTALLED_WINS_LINE = "If an installed copy of the Gameye skills is newer, it wins.";
|
|
7
|
+
export const BUNDLED_HEADER = `${BUNDLED_PACK_LINE}\n${INSTALLED_WINS_LINE}`;
|
|
8
|
+
const SKILL_FILE = "SKILL.md";
|
|
9
|
+
/** Looks like a path escape, an absolute path or a URL rather than a file inside one skill. */
|
|
10
|
+
function unsafeReason(value) {
|
|
11
|
+
if (/^[a-z]:/i.test(value) || (value.startsWith("/") && !value.startsWith("//")) || value.startsWith("\\"))
|
|
12
|
+
return "an absolute path";
|
|
13
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith("//"))
|
|
14
|
+
return "a URL";
|
|
15
|
+
if (value.split(/[\\/]/).includes(".."))
|
|
16
|
+
return "a parent-directory path";
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
function skillNames() {
|
|
20
|
+
return BUNDLED_SKILLS.map((s) => s.name).join(", ");
|
|
21
|
+
}
|
|
22
|
+
function refuse(text) {
|
|
23
|
+
return { text: `${BUNDLED_HEADER}\n\n${text}`, isError: true };
|
|
24
|
+
}
|
|
25
|
+
function filesOf(skill) {
|
|
26
|
+
return Object.keys(skill.files);
|
|
27
|
+
}
|
|
28
|
+
export function registerBundledSkillsTools(server, ctx) {
|
|
29
|
+
defineTool(server, ctx, "gameye_list_skills", {
|
|
30
|
+
title: "List the bundled Gameye skills",
|
|
31
|
+
description: "List the Gameye agent skills bundled with this server: each skill's name, description, version and files, and the bundled pack version. " +
|
|
32
|
+
"Works offline. Read one with gameye_get_skill. If an installed copy of the Gameye skills is newer, it wins.",
|
|
33
|
+
inputSchema: {},
|
|
34
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
35
|
+
requiresToken: false,
|
|
36
|
+
}, async () => {
|
|
37
|
+
const lines = [BUNDLED_HEADER, ""];
|
|
38
|
+
for (const skill of BUNDLED_SKILLS) {
|
|
39
|
+
lines.push(`• ${skill.name} (skill ${skill.version}): ${skill.description}`);
|
|
40
|
+
lines.push(` files: ${filesOf(skill).join(", ")}`);
|
|
41
|
+
}
|
|
42
|
+
return lines.join("\n");
|
|
43
|
+
});
|
|
44
|
+
defineTool(server, ctx, "gameye_get_skill", {
|
|
45
|
+
title: "Read a bundled Gameye skill",
|
|
46
|
+
description: "Read a Gameye agent skill bundled with this server: its SKILL.md, or one of its reference files. " +
|
|
47
|
+
"Works offline and serves only the bundled copy (list names and files with gameye_list_skills). " +
|
|
48
|
+
"If an installed copy of the Gameye skills is newer, it wins.",
|
|
49
|
+
inputSchema: {
|
|
50
|
+
name: z.string().describe("Skill name, e.g. gameye or gameye-containerize"),
|
|
51
|
+
file: z
|
|
52
|
+
.string()
|
|
53
|
+
.optional()
|
|
54
|
+
.describe("File inside the skill, e.g. references/routing.md. Defaults to SKILL.md"),
|
|
55
|
+
},
|
|
56
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
57
|
+
requiresToken: false,
|
|
58
|
+
}, async ({ name, file }) => {
|
|
59
|
+
const nameProblem = unsafeReason(name) ?? (/[\\/]/.test(name) ? "a path" : undefined);
|
|
60
|
+
if (nameProblem)
|
|
61
|
+
return refuse(`Refused: the skill name is ${nameProblem}. Use one of: ${skillNames()}.`);
|
|
62
|
+
const skill = BUNDLED_SKILLS.find((s) => s.name === name);
|
|
63
|
+
// Never echo the supplied name or file: it is user text and may be a pasted token.
|
|
64
|
+
if (!skill)
|
|
65
|
+
return refuse(`No bundled skill has that name. Bundled skills: ${skillNames()}.`);
|
|
66
|
+
const rel = file ?? SKILL_FILE;
|
|
67
|
+
const fileProblem = unsafeReason(rel);
|
|
68
|
+
if (fileProblem) {
|
|
69
|
+
return refuse(`Refused: the file is ${fileProblem}. Files in ${skill.name}: ${filesOf(skill).join(", ")}.`);
|
|
70
|
+
}
|
|
71
|
+
if (!Object.hasOwn(skill.files, rel)) {
|
|
72
|
+
return refuse(`The bundled ${skill.name} skill has no such file. Files: ${filesOf(skill).join(", ")}.`);
|
|
73
|
+
}
|
|
74
|
+
return [BUNDLED_HEADER, `Skill: ${skill.name} ${skill.version} · ${rel}`, "", skill.files[rel]].join("\n");
|
|
75
|
+
});
|
|
76
|
+
}
|
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,10 @@ export interface ServerContext {
|
|
|
11
11
|
host: string;
|
|
12
12
|
baseUrl: string;
|
|
13
13
|
version: string;
|
|
14
|
+
/** Whether an API token is configured. The context never holds the token itself. */
|
|
15
|
+
hasToken: boolean;
|
|
16
|
+
/** Local launch and stop limits, fixed when the server starts. */
|
|
17
|
+
limits: SessionLimits;
|
|
14
18
|
/** Replace the API token with `***`. Applied to every tool result, success or error. */
|
|
15
19
|
mask: (text: string) => string;
|
|
16
20
|
}
|
|
@@ -26,13 +30,21 @@ export interface ToolDefinition<Shape extends z.ZodRawShape> {
|
|
|
26
30
|
annotations: ToolAnnotations;
|
|
27
31
|
/** Extra hint appended to the error text for a GameyeError, e.g. for a 404. */
|
|
28
32
|
errorHint?: (err: GameyeError) => string | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* Whether the tool needs GAMEYE_API_TOKEN. Defaults to true, so a new tool
|
|
35
|
+
* fails closed: with no token it refuses before its handler runs.
|
|
36
|
+
*/
|
|
37
|
+
requiresToken?: boolean;
|
|
29
38
|
}
|
|
30
39
|
/**
|
|
31
40
|
* Register a tool so that every result starts with the environment and host,
|
|
32
41
|
* has the API token masked (log output, labels and error messages can echo
|
|
33
42
|
* it), and every failure comes back as `isError: true` with the server's details
|
|
34
43
|
* and trace ID instead of being thrown. A GuardError (a refusal before any
|
|
35
|
-
* request) comes back as `isError: true` with its message.
|
|
44
|
+
* request) comes back as `isError: true` with its message. With no token
|
|
45
|
+
* configured, a tool that requires one is refused before its handler runs, so
|
|
46
|
+
* no request (and no dry run or limit check) happens. The description gains
|
|
47
|
+
* the sentence that says whether the tool works without a token.
|
|
36
48
|
*/
|
|
37
49
|
export declare function defineTool<Shape extends z.ZodRawShape>(server: McpServer, ctx: ServerContext, name: string, def: ToolDefinition<Shape>, handler: (args: z.infer<z.ZodObject<Shape>>) => Promise<ToolOutput>): void;
|
|
38
50
|
/** Format labels for display, never showing the value of the `env` label. */
|
package/dist/tools/common.js
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { GameyeError, GameyeTimeoutError, redactLabels } from "../client.js";
|
|
3
3
|
import { resultPrefix } from "../env.js";
|
|
4
4
|
import { GuardError } from "../guards.js";
|
|
5
|
+
import { describeTokenNeed, tokenRequiredMessage } from "../tokenless.js";
|
|
5
6
|
/** Shared `dryRun` input for every tool that sends a write. */
|
|
6
7
|
export const dryRunInput = z
|
|
7
8
|
.boolean()
|
|
@@ -12,9 +13,13 @@ export const dryRunInput = z
|
|
|
12
13
|
* has the API token masked (log output, labels and error messages can echo
|
|
13
14
|
* it), and every failure comes back as `isError: true` with the server's details
|
|
14
15
|
* and trace ID instead of being thrown. A GuardError (a refusal before any
|
|
15
|
-
* request) comes back as `isError: true` with its message.
|
|
16
|
+
* request) comes back as `isError: true` with its message. With no token
|
|
17
|
+
* configured, a tool that requires one is refused before its handler runs, so
|
|
18
|
+
* no request (and no dry run or limit check) happens. The description gains
|
|
19
|
+
* the sentence that says whether the tool works without a token.
|
|
16
20
|
*/
|
|
17
21
|
export function defineTool(server, ctx, name, def, handler) {
|
|
22
|
+
const requiresToken = def.requiresToken !== false;
|
|
18
23
|
const prefix = resultPrefix(ctx);
|
|
19
24
|
const wrap = (text, isError) => ({
|
|
20
25
|
content: [{ type: "text", text: ctx.mask(`${prefix}\n${text}`) }],
|
|
@@ -22,6 +27,8 @@ export function defineTool(server, ctx, name, def, handler) {
|
|
|
22
27
|
});
|
|
23
28
|
const callback = async (args) => {
|
|
24
29
|
try {
|
|
30
|
+
if (requiresToken && !ctx.hasToken)
|
|
31
|
+
throw new GuardError(tokenRequiredMessage(name));
|
|
25
32
|
const out = await handler(args);
|
|
26
33
|
return typeof out === "string" ? wrap(out) : wrap(out.text, out.isError);
|
|
27
34
|
}
|
|
@@ -38,7 +45,7 @@ export function defineTool(server, ctx, name, def, handler) {
|
|
|
38
45
|
};
|
|
39
46
|
server.registerTool(name, {
|
|
40
47
|
title: def.title,
|
|
41
|
-
description: def.description,
|
|
48
|
+
description: describeTokenNeed(def.description, requiresToken),
|
|
42
49
|
inputSchema: def.inputSchema,
|
|
43
50
|
annotations: def.annotations,
|
|
44
51
|
}, callback);
|
package/dist/tools/context.js
CHANGED
|
@@ -1,25 +1,39 @@
|
|
|
1
1
|
import { AGENT_LABEL_KEY, AGENT_LABEL_VALUE, DEFAULT_SESSION_TTL, DEFAULT_TOKEN_EXPIRY } from "../guards.js";
|
|
2
2
|
import { defineTool } from "./common.js";
|
|
3
3
|
import { BETA_NOTICE } from "../beta.js";
|
|
4
|
+
import { RESTART_RULE, TOKEN_FREE_LINE, TOKEN_SIGNUP_STEPS } from "../tokenless.js";
|
|
4
5
|
export function registerContextTool(server, ctx) {
|
|
5
6
|
defineTool(server, ctx, "gameye_context", {
|
|
6
7
|
title: "Server context",
|
|
7
8
|
description: "Show which Gameye environment and host this server targets, the server version and the guardrail defaults. The environment is fixed when the server starts (GAMEYE_ENV / GAMEYE_API_URL) and cannot be changed by a tool call.",
|
|
8
9
|
inputSchema: {},
|
|
9
10
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
11
|
+
requiresToken: false,
|
|
10
12
|
}, async () => [
|
|
11
13
|
BETA_NOTICE,
|
|
12
14
|
`Environment: ${ctx.environment}`,
|
|
13
15
|
`Host: ${ctx.host}`,
|
|
14
16
|
`Base URL: ${ctx.baseUrl}`,
|
|
15
17
|
`Server version: gameye-mcp ${ctx.version}`,
|
|
18
|
+
`Token: ${ctx.hasToken ? "configured" : "not configured"}`,
|
|
16
19
|
"",
|
|
17
20
|
"Guardrail defaults:",
|
|
18
21
|
` Session TTL: ${DEFAULT_SESSION_TTL} when a launch gives no ttl`,
|
|
19
22
|
` Session label: ${AGENT_LABEL_KEY}=${AGENT_LABEL_VALUE} on every launch`,
|
|
20
23
|
` Token expiry: ${DEFAULT_TOKEN_EXPIRY} when gameye_create_token gives no expires_after`,
|
|
21
24
|
"",
|
|
22
|
-
"
|
|
25
|
+
"Session limits (set when the server starts):",
|
|
26
|
+
` Running at once: ${ctx.limits.maxSessions} sessions started by this server (GAMEYE_MAX_SESSIONS)`,
|
|
27
|
+
` Longest TTL: ${ctx.limits.maxTtl} (GAMEYE_MAX_TTL)`,
|
|
28
|
+
` Launches: ${ctx.limits.maxLaunches} per server run (GAMEYE_MAX_LAUNCHES)`,
|
|
29
|
+
` Stop: ${ctx.limits.stopOwnOnly ? "only sessions this server launched (GAMEYE_STOP_OWN_ONLY=1)" : "any session the token can see, by exact ID"}`,
|
|
30
|
+
"",
|
|
31
|
+
...(ctx.hasToken
|
|
32
|
+
? [
|
|
33
|
+
"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.",
|
|
34
|
+
]
|
|
35
|
+
: ["No API token: account tools refuse and send nothing until a token is set.", TOKEN_FREE_LINE, TOKEN_SIGNUP_STEPS]),
|
|
36
|
+
RESTART_RULE,
|
|
23
37
|
ctx.environment === "custom"
|
|
24
38
|
? "This server uses a non-standard GAMEYE_API_URL."
|
|
25
39
|
: "To target the other environment, configure a second server entry with a different GAMEYE_ENV.",
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { checkDockerfile, formatDockerfileReport } from "../dockerfile-check.js";
|
|
3
|
+
import { defineTool } from "./common.js";
|
|
4
|
+
const MAX_DOCKERFILE_CHARS = 200_000;
|
|
5
|
+
export function registerDockerfileCheckTool(server, ctx) {
|
|
6
|
+
defineTool(server, ctx, "gameye_check_dockerfile", {
|
|
7
|
+
title: "Check Dockerfile",
|
|
8
|
+
description: "Check game server Dockerfile text against the gameye-containerize container contract, offline: linux/amd64 base, exposed ports and protocols, exec-form launch without npm/yarn start, no loopback bind, non-root user, and no API token or credential in ENV/ARG. Returns findings with severity, rule code, line, message and fix, plus what the check cannot know. Pass the Dockerfile text itself; this tool reads no files and sends nothing.",
|
|
9
|
+
inputSchema: {
|
|
10
|
+
dockerfile: z
|
|
11
|
+
.string()
|
|
12
|
+
.min(1)
|
|
13
|
+
.max(MAX_DOCKERFILE_CHARS)
|
|
14
|
+
.describe("The full Dockerfile text (not a path)"),
|
|
15
|
+
ports: z
|
|
16
|
+
.array(z.object({
|
|
17
|
+
port: z.number().int().min(1).max(65535).describe("Container port the server binds"),
|
|
18
|
+
protocol: z.enum(["tcp", "udp"]).describe("Protocol the server binds on that port"),
|
|
19
|
+
}))
|
|
20
|
+
.max(64)
|
|
21
|
+
.optional()
|
|
22
|
+
.describe("Optional: the ports and protocols the server binds, to check against EXPOSE"),
|
|
23
|
+
},
|
|
24
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
25
|
+
requiresToken: false,
|
|
26
|
+
}, async ({ dockerfile, ports }) => formatDockerfileReport(checkDockerfile(dockerfile, { ports })));
|
|
27
|
+
}
|