Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# Steam Web API Key
# Get yours free at https://steamcommunity.com/dev/apikey
STEAM_API_KEY=

# Partner admin (separate process only: npx tsx src/partner/index.ts).
# Names only. Put real values in a gitignored local .env, never in mcp.json.
# STEAM_PARTNER_ADMIN=1
# STEAM_PARTNER_COOKIES=
# STEAM_PARTNER_PROFILE_DIR=
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,10 @@ jobs:
- run: npm ci
- run: npm run build
- run: npm test

secrets-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Scan for committed Steam secrets
run: python3 scripts/scan-secrets.py
32 changes: 32 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,41 @@ node_modules/
dist/
.env
.env.*
.env.local
.env.partner
!.env.example
*.ssfn
ssfn*
config.vdf
loginusers.vdf
local.vdf

# Cookie jars and Playwright auth
cookies.txt
cookies.json
*.cookies
*cookie-jar*
storageState.json
**/playwright/.auth/

# Chromium / Playwright profiles (never store these in-repo)
**/*user-data-dir*/
**/chromium-profile/
**/partner-profile/
**/.pw-profile/
playwright-report/
blob-report/
*.har

# Generic secrets
secrets.json
*.pem
*.key
*.p12

# Partner-admin dry-run dumps
partner-dry-run*.json
admin-save-payload*.json

# OS
.DS_Store
Expand Down
13 changes: 11 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

## What is this?

An MCP (Model Context Protocol) server that exposes Steam Web API endpoints as structured tools for AI-powered IDEs. It is the companion server for the [Steam Developer Tools](https://github.com/TMHSDigital/Steam-Cursor-Plugin) Cursor plugin, which provides 30 skills and 9 rules for Steam/Steamworks development. The server provides 25 tools: 18 read-only and 7 write/guidance tools.
An MCP (Model Context Protocol) server that exposes Steam Web API endpoints as structured tools for AI-powered IDEs. It is the companion server for the [Steam Developer Tools](https://github.com/TMHSDigital/Steam-Cursor-Plugin) Cursor plugin, which provides 30 skills and 9 rules for Steam/Steamworks development. The server provides 26 tools: 19 read-only and 7 write/guidance tools.

The plugin's skills reference these MCP tools to fetch live data from Steam - player stats, store info, workshop items, leaderboards, and more.

Expand All @@ -17,7 +17,13 @@ src/
getAppDetails.ts Each file exports a register(server) function
searchApps.ts that adds one tool with its name, description,
getPlayerCount.ts zod input schema, and async handler
validateStoreAsset.ts
...
storeAssets/
slots.ts Valve pixel sizes and unofficial Partner form field names
png.ts PNG/JPEG header parse plus PNG pixel decode
heroHeuristics.ts Library-hero ribbon / seam / wordmark checks
validate.ts Pure validateStoreAsset(path, slot)
utils/
steam-api.ts Shared fetch wrapper, URL builders, API key helper, error formatting
errors.ts Custom error classes (rate limit, missing key, unavailable)
Expand All @@ -29,7 +35,7 @@ src/
- `steam-api.ts` provides `steamFetch()` which handles timeouts (15s via AbortController with `TimeoutError`), HTTP error detection (429 rate limits with up to 2 retries and exponential backoff, 5xx unavailable), and JSON parsing.
- `errorResponse()` formats errors as MCP-compatible `{ isError: true }` responses.
- Tools that need an API key call `requireApiKey()` which reads `STEAM_API_KEY` from env and throws `MissingApiKeyError` with setup instructions if missing.
- No-auth tools (getAppDetails, searchApps, getPlayerCount, getAchievementStats, getWorkshopItem, getReviews, getPriceOverview, getAppReviewSummary, getRegionalPricing, getNewsForApp) work without any configuration.
- No-auth tools (getAppDetails, searchApps, getPlayerCount, getAchievementStats, getWorkshopItem, getReviews, getPriceOverview, getAppReviewSummary, getRegionalPricing, getNewsForApp, validateStoreAsset) work without any configuration.

## How to build and run

Expand Down Expand Up @@ -74,6 +80,9 @@ No-auth tools can be tested without setting `STEAM_API_KEY`.
| Variable | Required | Description |
|----------|----------|-------------|
| `STEAM_API_KEY` | For some tools | Steam Web API key from https://steamcommunity.com/dev/apikey |
| `STEAM_PARTNER_ADMIN` | Partner process only | Must be `1` to start `src/partner/index.ts`. Ignored by the default bin. |
| `STEAM_PARTNER_COOKIES` | Partner process only | Path to a cookie jar. Never a cookie string. Gitignored. |
| `STEAM_PARTNER_PROFILE_DIR` | Partner process only | Chromium user-data dir outside the repo. |

## Relationship to the companion plugin

Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@

<p align="center">
<a href="package.json"><img src="https://img.shields.io/node/v/@tmhs/steam-mcp" alt="node"></a>
<a href="https://github.com/TMHSDigital/Steam-MCP#available-tools-v070"><img src="https://img.shields.io/badge/MCP_tools-25-blue" alt="MCP tools"></a>
<a href="https://github.com/TMHSDigital/Steam-MCP#available-tools-v080"><img src="https://img.shields.io/badge/MCP_tools-26-blue" alt="MCP tools"></a>
<img src="https://img.shields.io/badge/Steam_Web_API-powered-1b2838" alt="Steam Web API">
</p>

---

<p align="center"><strong>25 MCP tools</strong> - 10 no-auth - 8 API key - 7 publisher key</p>
<p align="center"><strong>26 MCP tools</strong> - 11 no-auth - 8 API key - 7 publisher key</p>

Query Steam store data, player statistics, achievements, reviews, pricing, workshop items, leaderboards, inventory, and player profiles - all as structured MCP tools callable from Cursor's AI agent.

Expand Down Expand Up @@ -107,10 +107,10 @@ Add the Steam MCP server to your Cursor MCP settings (`.cursor/mcp.json` in your

Once configured, the tools are available to Cursor's AI agent. Pair with the [Steam Developer Tools](https://github.com/TMHSDigital/Steam-Cursor-Plugin) plugin for the full skill set.

## Available Tools (v0.7.0) - 25 Total
## Available Tools (v0.8.0) - 26 Total

<details>
<summary><strong>Read Tools (No Auth) - 10 tools</strong></summary>
<summary><strong>Read Tools (No Auth) - 11 tools</strong></summary>

These work without an API key:

Expand All @@ -126,6 +126,7 @@ These work without an API key:
| `steam_getAppReviewSummary` | Review score, total counts, and positive percentage (no individual reviews) |
| `steam_getRegionalPricing` | Pricing breakdown across multiple countries/regions |
| `steam_getNewsForApp` | Recent news articles with title, URL, contents, date, and author |
| `steam_validateStoreAsset` | Local PNG/JPEG vs Valve store and library sizes, plus library-hero heuristics |

</details>

Expand Down Expand Up @@ -204,6 +205,8 @@ npm run test:watch # Test watch mode

See [CONTRIBUTING.md](CONTRIBUTING.md) for how to add new tools and submit PRs.

Partner-admin tools (`steam_partnerLogin`, `steam_uploadStoreImage`, `steam_uploadTrailer`) are not registered by this package's default bin and are not in the npm tarball. They live in `src/partner/` for local use only (`STEAM_PARTNER_ADMIN=1` plus a cookie-jar path or Chromium profile dir outside the repo). There is no Publish tool.

</details>

## Related
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@tmhs/steam-mcp",
"version": "0.7.0",
"description": "MCP server for Steam & Steamworks APIs - 25 tools (18 read + 7 write) for store data, player stats, reviews, pricing, achievements, workshop, leaderboards, inventory, and lobbies.",
"version": "0.8.0",
"description": "MCP server for Steam & Steamworks APIs - 26 tools (19 read + 7 write) for store data, player stats, reviews, pricing, achievements, workshop, leaderboards, inventory, and lobbies.",
"type": "module",
"main": "dist/index.js",
"bin": {
Expand Down
111 changes: 111 additions & 0 deletions scripts/scan-secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Fail CI if partner cookies or API keys look committed.

Scans git-tracked files only so a local gitignored .env is allowed.
Excludes documentation and lockfiles so naming the variables is allowed.
"""
from __future__ import annotations

import re
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent

SKIP_NAMES = {
"package-lock.json",
".env.example",
"SECURITY.md",
"scan-secrets.py",
}

SKIP_SUFFIXES = {
".md",
".mdc",
".png",
".jpg",
".jpeg",
".gif",
".webp",
".woff2",
".ico",
}

SKIP_DIR_PREFIXES = (
"rules/",
"node_modules/",
"dist/",
)

PATTERNS: list[tuple[re.Pattern[str], str]] = [
(
re.compile(r"""STEAM_API_KEY\s*=\s*["']?[0-9a-fA-F]{32}"""),
"STEAM_API_KEY assigned a 32-char hex value",
),
(
re.compile(
r"""steamLoginSecure\s*=\s*["']?(?![*\[/^])[^\s"'<>]{8,}"""
),
"steamLoginSecure cookie assignment",
),
(
re.compile(r"""STEAM_PARTNER_COOKIES\s*=\s*["']?[^"'\s#]+"""),
"STEAM_PARTNER_COOKIES assigned a non-empty value",
),
]


def tracked_files() -> list[Path]:
proc = subprocess.run(
["git", "ls-files", "-z"],
cwd=ROOT,
check=True,
capture_output=True,
)
out: list[Path] = []
for rel in proc.stdout.split(b"\0"):
if not rel:
continue
text = rel.decode("utf-8", errors="replace").replace("\\", "/")
if text in SKIP_NAMES or Path(text).name in SKIP_NAMES:
continue
if any(text.startswith(prefix) for prefix in SKIP_DIR_PREFIXES):
continue
path = ROOT / text
if path.suffix.lower() in SKIP_SUFFIXES:
continue
if path.is_file():
out.append(path)
return out


def main() -> int:
files = tracked_files()
hits: list[str] = []
for path in files:
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
print(f"skip unreadable {path}: {exc}", file=sys.stderr)
continue
rel = path.relative_to(ROOT).as_posix()
for pattern, label in PATTERNS:
if pattern.search(text):
hits.append(f"{rel}: {label}")
if hits:
print("Secret-pattern scan failed:", file=sys.stderr)
for hit in hits:
print(f" {hit}", file=sys.stderr)
return 1
print(f"Secret-pattern scan clean ({len(files)} tracked files).")
return 0


if __name__ == "__main__":
raise SystemExit(main())



if __name__ == "__main__":
raise SystemExit(main())
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@ import { register as registerGetSchemaForGame } from "./tools/getSchemaForGame.j
import { register as registerGetNewsForApp } from "./tools/getNewsForApp.js";
import { register as registerGetLeaderboardsForGame } from "./tools/getLeaderboardsForGame.js";
import { register as registerGetPlayerAchievements } from "./tools/getPlayerAchievements.js";
import { register as registerValidateStoreAsset } from "./tools/validateStoreAsset.js";

const server = new McpServer({
name: "steam-mcp",
version: "0.7.0",
version: "0.8.0",
});

registerGetAppDetails(server);
Expand Down Expand Up @@ -59,6 +60,7 @@ registerGetSchemaForGame(server);
registerGetNewsForApp(server);
registerGetLeaderboardsForGame(server);
registerGetPlayerAchievements(server);
registerValidateStoreAsset(server);

async function main(): Promise<void> {
const transport = new StdioServerTransport();
Expand Down
25 changes: 25 additions & 0 deletions src/partner/__tests__/partner-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, it, expect } from "vitest";
import { refuseIfUnconfirmed } from "../../partner/tools.js";
import { SLOT_FORM_FIELD } from "../../storeAssets/slots.js";

describe("partner destructive gates", () => {
it("allows dry_run without confirm", () => {
expect(refuseIfUnconfirmed(true, undefined)).toBeNull();
expect(refuseIfUnconfirmed(true, false)).toBeNull();
});

it("rejects live calls without confirm", () => {
expect(refuseIfUnconfirmed(false, undefined)).toMatch(/confirm must be true/);
expect(refuseIfUnconfirmed(false, false)).toMatch(/confirm must be true/);
});

it("allows live calls with confirm", () => {
expect(refuseIfUnconfirmed(false, true)).toBeNull();
});
});

describe("partner form fields", () => {
it("maps libraryHero to library_hero|image", () => {
expect(`${SLOT_FORM_FIELD.libraryHero}|image`).toBe("library_hero|image");
});
});
48 changes: 48 additions & 0 deletions src/partner/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env node
/**
* Gated Partner-admin MCP process. Not the default @tmhs/steam-mcp bin.
* Run locally: STEAM_PARTNER_ADMIN=1 npx tsx src/partner/index.ts
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
registerPartnerLogin,
registerUploadStoreImage,
registerUploadTrailer,
} from "./tools.js";

function fail(message: string): never {
console.error(`[AUTH_MISSING] ${message}`);
process.exit(1);
}

if (process.env.STEAM_PARTNER_ADMIN !== "1") {
fail("Set STEAM_PARTNER_ADMIN=1 to start the Partner-admin process.");
}

const cookies = process.env.STEAM_PARTNER_COOKIES?.trim();
const profile = process.env.STEAM_PARTNER_PROFILE_DIR?.trim();
if (!cookies && !profile) {
fail(
"Set STEAM_PARTNER_COOKIES (cookie-jar path) or STEAM_PARTNER_PROFILE_DIR (Chromium profile outside the repo).",
);
}

const server = new McpServer({
name: "steam-mcp-partner",
version: "0.8.0",
});

registerPartnerLogin(server);
registerUploadStoreImage(server);
registerUploadTrailer(server);

async function main(): Promise<void> {
const transport = new StdioServerTransport();
await server.connect(transport);
}

main().catch((error) => {
console.error("Fatal error:", error instanceof Error ? error.message : String(error));
process.exit(1);
});
Loading
Loading