gameye-mcp 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -63,7 +63,24 @@ Most clients (Claude Desktop, Cursor, Windsurf and others) accept an `mcpServers
63
63
 
64
64
  Restart the client after you change its configuration.
65
65
 
66
- `npx -y gameye-mcp` runs the latest release. To stay on one version, name it: `npx -y gameye-mcp@0.2.0` (or `"args": ["-y", "gameye-mcp@0.2.0"]`).
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"]`).
67
+
68
+ ### Windows
69
+
70
+ The server runs on Windows with Node.js 22 or newer. We've tested it on Windows 11, through Claude Code and over plain stdio. If something breaks on Windows, please [open an issue](https://github.com/Gameye/gameye-mcp/issues).
71
+
72
+ - Many Windows MCP clients can't start `npx` directly, because it's a `.cmd` script. Start it through `cmd /c` instead:
73
+
74
+ ```json
75
+ "command": "cmd",
76
+ "args": ["/c", "npx", "-y", "gameye-mcp"]
77
+ ```
78
+
79
+ With Claude Code: `claude mcp add gameye --env GAMEYE_API_TOKEN=<your-token> -- cmd /c npx -y gameye-mcp`. Claude Code 2.1.296 and later also start plain `npx`.
80
+ - In PowerShell, type `npx.cmd` instead of `npx` when you run the server by hand. A new Windows install blocks the `npx` PowerShell script with "running scripts is disabled on this system".
81
+ - The first start downloads the package and can take 10 to 40 seconds. Later starts take about a second.
82
+ - `gameye_update_skills` reports outdated skills on Windows but doesn't run the update. It prints the commands for you to run.
83
+ - `gameye_create_token` writes the token file with owner-only permissions on macOS and Linux. On Windows those permissions don't apply, and the file gets the access rules of its folder. Write it inside your user profile.
67
84
 
68
85
  ### Docker
69
86
 
@@ -88,7 +105,7 @@ With Claude Code: `claude mcp add gameye --env GAMEYE_API_TOKEN=<your-token> --
88
105
 
89
106
  - 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.
90
107
  - Keep `-i` and never add `-t`: a terminal mangles the JSON-RPC messages on stdout.
91
- - To stay on one version, use a version tag such as `ghcr.io/gameye/gameye-mcp:0.2.0`.
108
+ - To stay on one version, use a version tag such as `ghcr.io/gameye/gameye-mcp:0.3.1`.
92
109
  - Files the server writes stay inside the container, and `--rm` deletes them:
93
110
  - `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.
94
111
  - `gameye_create_token` writes to the `outputPath` you give it. Mount a directory the same way and pass an `outputPath` under `/out`.
@@ -131,6 +148,7 @@ Every tool result starts with the environment and host it used, for example `[sa
131
148
  | `gameye_create_application` | write, `dryRun` | Create an application |
132
149
  | `gameye_edit_application` | destructive, `dryRun` | Change an application; omitted fields stay unchanged |
133
150
  | `gameye_create_token` | write, `dryRun` | Create an API token and write it to a file |
151
+ | `gameye_update_skills` | read, or write with `apply` | Compare the installed [Gameye skills](https://github.com/gameye/gameye-skills) with the latest pack, and update them with `apply: true`. See [Keeping the skills current](#keeping-the-skills-current) |
134
152
 
135
153
  Every tool carries MCP annotations: read tools set `readOnlyHint`, `gameye_stop_session` and `gameye_edit_application` set `destructiveHint`, and every tool that calls the Gameye API sets `openWorldHint`.
136
154
 
@@ -146,6 +164,16 @@ These behave the same on sandbox and production, because sandbox runs live match
146
164
  - **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.
147
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.
148
166
 
167
+ ## Keeping the skills current
168
+
169
+ The [Gameye skills](https://github.com/gameye/gameye-skills) change as the Gameye API does. `gameye_update_skills` reads the latest pack version from the `gameye` skill on GitHub (`Pack version:` in `skills/gameye/SKILL.md` on `main`) and compares it with what is installed:
170
+
171
+ - **skills CLI, user-wide:** skills recorded in `~/.agents/.skill-lock.json` with source `gameye/gameye-skills`.
172
+ - **skills CLI, this project:** the same, in `skills-lock.json` in the server's working directory.
173
+ - **Claude Code plugin:** `gameye@gameye` in `~/.claude/plugins/installed_plugins.json`.
174
+
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.
176
+
149
177
  ## Telemetry/attribution
150
178
 
151
179
  Every request to the Gameye API sends `User-Agent: gameye-mcp/<version>`, plus the MCP client's name once it has connected, e.g. `gameye-mcp/0.1.0 (claude-code)`, so Gameye can count MCP usage. Only this server's version and the client name are sent, nothing else: no token, env values, paths or other data. There is no other telemetry.
@@ -0,0 +1,32 @@
1
+ /** Where the `gameye` skill's `Pack version:` line is published. */
2
+ export declare const LATEST_PACK_URL = "https://raw.githubusercontent.com/Gameye/gameye-skills/main/skills/gameye/SKILL.md";
3
+ /** How the Gameye skills were installed. Each kind updates differently. */
4
+ export type InstallKind = "skills-cli-global" | "skills-cli-project" | "claude-plugin";
5
+ export interface SkillsInstall {
6
+ kind: InstallKind;
7
+ /** Pack version read from the installed `gameye` skill, if it could be read. */
8
+ version?: string;
9
+ /** Skill names installed from gameye-skills (skills CLI installs only). */
10
+ skills: string[];
11
+ }
12
+ /** Everything the check touches outside this module, so tests can replace it. */
13
+ export interface SkillsEnv {
14
+ home: string;
15
+ cwd: string;
16
+ platform: NodeJS.Platform;
17
+ readText: (path: string) => Promise<string | undefined>;
18
+ fetchText: (url: string) => Promise<string>;
19
+ run: (cmd: string, args: string[], cwd: string) => Promise<string>;
20
+ }
21
+ export declare function defaultSkillsEnv(): SkillsEnv;
22
+ export declare function stripAnsi(text: string): string;
23
+ export declare function parsePackVersion(skillText: string | undefined): string | undefined;
24
+ /** -1, 0 or 1 for two X.Y.Z versions. */
25
+ export declare function compareVersions(a: string, b: string): number;
26
+ /** Find every install of the Gameye skills this server can see: user-wide, this project, and the Claude Code plugin. */
27
+ export declare function findSkillsInstalls(env: SkillsEnv): Promise<SkillsInstall[]>;
28
+ export declare function fetchLatestPackVersion(env: SkillsEnv): Promise<string>;
29
+ export declare function isOutdated(install: SkillsInstall, latest: string): boolean;
30
+ /** The commands that update an install, as argv lists. The skills CLI updates only the named Gameye skills. */
31
+ export declare function updateCommands(install: SkillsInstall): string[][];
32
+ export declare function describeInstall(install: SkillsInstall): string;
package/dist/skills.js ADDED
@@ -0,0 +1,140 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ /** Where the `gameye` skill's `Pack version:` line is published. */
6
+ export const LATEST_PACK_URL = "https://raw.githubusercontent.com/Gameye/gameye-skills/main/skills/gameye/SKILL.md";
7
+ const PACK_VERSION_RE = /^Pack version: (\d+\.\d+\.\d+)[ \t]*$/m;
8
+ const SKILLS_SOURCE_RE = /^(https:\/\/github\.com\/|git@github\.com:|github:)?gameye\/gameye-skills(\.git)?\/?$/i;
9
+ // Lock files can come from a cloned project, so a name must look like a skill name, never a CLI option.
10
+ const SKILL_NAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
11
+ export function defaultSkillsEnv() {
12
+ return {
13
+ home: homedir(),
14
+ cwd: process.cwd(),
15
+ platform: process.platform,
16
+ readText: (path) => readFile(path, "utf8").catch(() => undefined),
17
+ fetchText: async (url) => {
18
+ const res = await fetch(url, { signal: AbortSignal.timeout(5_000) });
19
+ if (!res.ok)
20
+ throw new Error(`GET ${url} returned ${res.status}`);
21
+ return res.text();
22
+ },
23
+ 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 };
26
+ const child = execFile(cmd, args, options, (err, stdout, stderr) => {
27
+ const out = stripAnsi(`${stdout}${stderr}`).trim();
28
+ if (err)
29
+ reject(new Error(`${cmd} ${args.join(" ")} failed: ${err.message}\n${out}`));
30
+ else
31
+ resolve(out);
32
+ });
33
+ // Closed stdin makes a prompting CLI fail at once instead of waiting for the timeout.
34
+ child.stdin?.end();
35
+ }),
36
+ };
37
+ }
38
+ // Terminal colour and cursor codes and carriage returns, which some CLIs print even when asked not to.
39
+ const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]|\r/g;
40
+ export function stripAnsi(text) {
41
+ return text.replace(ANSI_RE, "");
42
+ }
43
+ export function parsePackVersion(skillText) {
44
+ return skillText ? PACK_VERSION_RE.exec(skillText)?.[1] : undefined;
45
+ }
46
+ /** -1, 0 or 1 for two X.Y.Z versions. */
47
+ export function compareVersions(a, b) {
48
+ const pa = a.split(".").map(Number);
49
+ const pb = b.split(".").map(Number);
50
+ for (let i = 0; i < 3; i++) {
51
+ if (pa[i] !== pb[i])
52
+ return pa[i] < pb[i] ? -1 : 1;
53
+ }
54
+ return 0;
55
+ }
56
+ function parseJson(text) {
57
+ if (!text)
58
+ return undefined;
59
+ try {
60
+ return JSON.parse(text);
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
65
+ }
66
+ /** Skill names a skills CLI lock file records as coming from gameye-skills. */
67
+ function gameyeSkillsInLock(lockText) {
68
+ const lock = parseJson(lockText);
69
+ return Object.entries(lock?.skills ?? {})
70
+ .filter(([name, s]) => SKILL_NAME_RE.test(name) && (SKILLS_SOURCE_RE.test(s.source ?? "") || SKILLS_SOURCE_RE.test(s.sourceUrl ?? "")))
71
+ .map(([name]) => name)
72
+ .sort();
73
+ }
74
+ // The skills CLI keeps one copy in .agents/skills when it installs for several agents,
75
+ // and copies straight into the agent's own folder when it installs for one.
76
+ const SKILL_DIRS = [".agents/skills", ".claude/skills", ".codex/skills", ".cursor/skills", ".config/opencode/skills"];
77
+ /** The oldest pack version among the copies found, so one stale copy still counts as outdated. */
78
+ async function readInstalledPackVersion(env, root) {
79
+ const versions = [];
80
+ for (const dir of SKILL_DIRS) {
81
+ const version = parsePackVersion(await env.readText(join(root, dir, "gameye", "SKILL.md")));
82
+ if (version)
83
+ versions.push(version);
84
+ }
85
+ return versions.sort(compareVersions)[0];
86
+ }
87
+ async function skillsCliInstall(env, kind, lockPath, root) {
88
+ const skills = gameyeSkillsInLock(await env.readText(lockPath));
89
+ if (skills.length === 0)
90
+ return undefined;
91
+ return { kind, version: await readInstalledPackVersion(env, root), skills };
92
+ }
93
+ async function claudePluginInstall(env) {
94
+ const registry = parseJson(await env.readText(join(env.home, ".claude", "plugins", "installed_plugins.json")));
95
+ const entry = Object.entries(registry?.plugins ?? {}).find(([id]) => id === "gameye@gameye")?.[1]?.[0];
96
+ if (!entry?.installPath)
97
+ return undefined;
98
+ const version = parsePackVersion(await env.readText(join(entry.installPath, "skills", "gameye", "SKILL.md")));
99
+ return { kind: "claude-plugin", version, skills: [] };
100
+ }
101
+ /** Find every install of the Gameye skills this server can see: user-wide, this project, and the Claude Code plugin. */
102
+ export async function findSkillsInstalls(env) {
103
+ const found = await Promise.all([
104
+ skillsCliInstall(env, "skills-cli-global", join(env.home, ".agents", ".skill-lock.json"), env.home),
105
+ skillsCliInstall(env, "skills-cli-project", join(env.cwd, "skills-lock.json"), env.cwd),
106
+ claudePluginInstall(env),
107
+ ]);
108
+ return found.filter((i) => i !== undefined);
109
+ }
110
+ export async function fetchLatestPackVersion(env) {
111
+ const version = parsePackVersion(await env.fetchText(LATEST_PACK_URL));
112
+ if (!version)
113
+ throw new Error(`No "Pack version: X.Y.Z" line in ${LATEST_PACK_URL}`);
114
+ return version;
115
+ }
116
+ export function isOutdated(install, latest) {
117
+ return !install.version || compareVersions(install.version, latest) < 0;
118
+ }
119
+ /** The commands that update an install, as argv lists. The skills CLI updates only the named Gameye skills. */
120
+ export function updateCommands(install) {
121
+ switch (install.kind) {
122
+ case "skills-cli-global":
123
+ return [["npx", "-y", "skills", "update", ...install.skills, "-g", "-y"]];
124
+ case "skills-cli-project":
125
+ return [["npx", "-y", "skills", "update", ...install.skills, "-p", "-y"]];
126
+ case "claude-plugin":
127
+ return [
128
+ ["claude", "plugin", "marketplace", "update", "gameye"],
129
+ ["claude", "plugin", "update", "gameye@gameye"],
130
+ ];
131
+ }
132
+ }
133
+ export function describeInstall(install) {
134
+ const where = {
135
+ "skills-cli-global": "skills CLI, user-wide",
136
+ "skills-cli-project": "skills CLI, this project",
137
+ "claude-plugin": "Claude Code plugin gameye@gameye",
138
+ }[install.kind];
139
+ return `${where}: pack ${install.version ?? "version unknown"}`;
140
+ }
@@ -8,6 +8,7 @@ import { registerApplicationTools } from "./applications.js";
8
8
  import { registerTokenTools } from "./tokens.js";
9
9
  import { registerLogTools } from "./logs.js";
10
10
  import { registerArtifactTools } from "./artifacts.js";
11
+ import { registerSkillsTools } from "./skills.js";
11
12
  /** Register every tool. */
12
13
  export function registerAllTools(server, client, ctx) {
13
14
  registerContextTool(server, ctx);
@@ -20,4 +21,5 @@ export function registerAllTools(server, client, ctx) {
20
21
  registerLogTools(server, client, ctx);
21
22
  registerArtifactTools(server, client, ctx);
22
23
  registerTokenTools(server, client, ctx);
24
+ registerSkillsTools(server, ctx);
23
25
  }
@@ -0,0 +1,4 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { type ServerContext } from "./common.js";
3
+ import { type SkillsEnv } from "../skills.js";
4
+ export declare function registerSkillsTools(server: McpServer, ctx: ServerContext, env?: SkillsEnv): void;
@@ -0,0 +1,96 @@
1
+ import { z } from "zod";
2
+ import { defineTool } from "./common.js";
3
+ import { compareVersions, defaultSkillsEnv, describeInstall, fetchLatestPackVersion, findSkillsInstalls, isOutdated, updateCommands, } from "../skills.js";
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
+ export function registerSkillsTools(server, ctx, env = defaultSkillsEnv()) {
6
+ defineTool(server, ctx, "gameye_update_skills", {
7
+ title: "Check and update the Gameye skills",
8
+ description: "Compare the installed Gameye agent skills with the latest pack on GitHub, and update them. " +
9
+ "Finds installs made with the skills CLI (user-wide or in this project) and the Claude Code plugin gameye@gameye. " +
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. " +
12
+ "Updates come only through those package managers; this tool never downloads or follows skill text itself.",
13
+ inputSchema: {
14
+ apply: z
15
+ .boolean()
16
+ .optional()
17
+ .describe("Run the updates for outdated installs. Omit or false to only check."),
18
+ installedVersion: z
19
+ .string()
20
+ .regex(/^\d+\.\d+\.\d+$/)
21
+ .optional()
22
+ .describe("The `Pack version:` from the gameye skill you have loaded, used when no install is found on disk"),
23
+ },
24
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
25
+ }, async ({ apply, installedVersion }) => {
26
+ const installs = await findSkillsInstalls(env);
27
+ let latest;
28
+ try {
29
+ latest = await fetchLatestPackVersion(env);
30
+ }
31
+ catch (err) {
32
+ const lines = [
33
+ `Could not read the latest Gameye skills pack version from GitHub: ${err instanceof Error ? err.message : String(err)}`,
34
+ "Nothing was updated.",
35
+ ];
36
+ for (const install of installs)
37
+ lines.push(`• ${describeInstall(install)}`);
38
+ return { text: lines.join("\n"), isError: true };
39
+ }
40
+ const lines = [`Latest Gameye skills pack: ${latest}`];
41
+ if (installs.length === 0) {
42
+ lines.push("No skills CLI or Claude Code plugin install of the Gameye skills was found.");
43
+ if (installedVersion) {
44
+ lines.push(`The loaded gameye skill reports pack ${installedVersion}.`);
45
+ if (compareVersions(installedVersion, latest) < 0) {
46
+ lines.push("To update, reinstall the way they were installed: `npx skills add gameye/gameye-skills --skill \"*\"` (add -g for user-wide), " +
47
+ "`/plugin marketplace update gameye` for the Claude Code plugin, or copy the skills directories again.");
48
+ }
49
+ }
50
+ return lines.join("\n");
51
+ }
52
+ const outdated = installs.filter((i) => isOutdated(i, latest));
53
+ for (const install of installs) {
54
+ lines.push(`• ${describeInstall(install)}${outdated.includes(install) ? " (update available)" : " (up to date)"}`);
55
+ }
56
+ if (outdated.length === 0)
57
+ return lines.join("\n");
58
+ 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:");
61
+ for (const cmd of commands)
62
+ lines.push(` ${cmd.join(" ")}`);
63
+ return lines.join("\n");
64
+ }
65
+ let failed = false;
66
+ lines.push("");
67
+ for (const install of outdated) {
68
+ for (const cmd of updateCommands(install)) {
69
+ try {
70
+ const out = await env.run(cmd[0], cmd.slice(1), env.cwd);
71
+ lines.push(`Ran: ${cmd.join(" ")}`, ...(out ? [indentTail(out)] : []));
72
+ }
73
+ catch (err) {
74
+ failed = true;
75
+ lines.push(`Failed: ${err instanceof Error ? err.message : String(err)}`);
76
+ break;
77
+ }
78
+ }
79
+ }
80
+ const after = await findSkillsInstalls(env);
81
+ lines.push("", "After updating:");
82
+ for (const install of after) {
83
+ const behind = isOutdated(install, latest);
84
+ failed ||= behind;
85
+ lines.push(`• ${describeInstall(install)}${behind ? " (still behind)" : ""}`);
86
+ }
87
+ lines.push("", RELOAD_NOTE);
88
+ return { text: lines.join("\n"), isError: failed };
89
+ });
90
+ }
91
+ /** The last lines of a command's output, indented, so a long install log doesn't flood the result. */
92
+ function indentTail(out, max = 15) {
93
+ const all = out.split("\n");
94
+ const tail = all.slice(-max).map((l) => ` ${l}`);
95
+ return (all.length > max ? [" …", ...tail] : tail).join("\n");
96
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gameye-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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",