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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- SDK guide tools escape titles, descriptions, paths, tags, and lobby metadata as proper string literals in C++, C#, and GDScript, so quotes, Windows backslashes, and line breaks no longer break the generated code. Contributed by @ect2000. (#36)
- SDK guides compile against their bindings and use every input. `steam_createLobby`'s C# uses Steamworks.NET's real enum names (`ELobbyType.k_ELobbyTypePublic`), and its GDScript callback no longer shadows `connect`. `steam_uploadWorkshopItem` now returns the C# (Steamworks.NET) block its description promised, and its GDScript sets the description and tags. The C++ and C# examples register their call results. (#54)
- `steam_getReviews` and `steam_getAppReviewSummary` now call `IUserReviewsService/GetAppReviews`. Valve disabled the Store `/appreviews` endpoint on 2026-10-22, so both tools failed on every call. Both remain keyless. `steam_getReviews` adds a `funny` sort and accepts a comma-separated `language` list. `steam_getAppReviewSummary` now counts all purchase types, matching the store page total. (#27)
- `steam_setAchievement` and `steam_clearAchievement` report success only when Steam returns EResult 1. A response with no `result` object used to read as success. Failures now include the EResult. (#52)
- `steam_grantInventoryItem` sends the documented AddItem parameters: one `itemdefid[n]` per copy (`quantity` capped at 100), `notify` as `1`/`0`, and a `requestid` so Steam executes a grant at most once. The dry run generates a `requestid`; pass it to the live call. New optional `itempropsjson` input. (#28)
Expand Down
47 changes: 47 additions & 0 deletions src/tools/__tests__/sdk-guides.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,50 @@ describe("SDK guides", () => {
expect(lobby.csharp).toContain('SetLobbyData(lobbyId, "empty", "")');
});
});

describe("SDK guide correctness (#54)", () => {
it("lobby guide uses Steamworks.NET enum names, registers call results, and avoids shadowing connect", async () => {
const lobby = await callGuide("steam_createLobby", { type: "friends_only", max_members: 8 });
expect(lobby.csharp).toContain("ELobbyType.k_ELobbyTypeFriendsOnly");
expect(lobby.csharp).not.toMatch(/ELobbyType\.FriendsOnly\b/);
expect(lobby.csharp).toContain("CallResult<LobbyCreated_t>.Create(OnLobbyCreated)");
expect(lobby.csharp).toContain("m_LobbyCreated.Set(call);");
expect(lobby.cpp).toContain("m_LobbyCreated.Set(call, this, &LobbyCreator::OnLobbyCreated);");
expect(lobby.cpp).toContain("CCallResult<LobbyCreator, LobbyCreated_t> m_LobbyCreated;");
expect(lobby.gdscript).toContain("Steam.lobby_created.connect(_on_lobby_created)");
expect(lobby.gdscript).toContain("this_connect: int");
expect(lobby.gdscript).not.toMatch(/\(connect: int/);
});

it("Workshop guide returns every promised language and uses every input", async () => {
const guide = await callGuide("steam_uploadWorkshopItem", {
appid: 480,
title: "Map pack",
description: "Three maps",
tags: ["Maps", "Co-op"],
visibility: "unlisted",
});
for (const language of ["cpp", "csharp", "gdscript"]) {
const code = guide[language] as string;
expect(code, language).toContain("Map pack");
expect(code, language).toContain("Three maps");
expect(code, language).toContain("Co-op");
}
expect(guide.csharp).toContain("EWorkshopFileType.k_EWorkshopFileTypeCommunity");
expect(guide.csharp).toContain(
"ERemoteStoragePublishedFileVisibility.k_ERemoteStoragePublishedFileVisibilityUnlisted",
);
expect(guide.csharp).toContain('new List<string> { "Maps", "Co-op" }');
expect(guide.cpp).toContain("m_ItemCreated.Set(call, this, &WorkshopUploader::OnItemCreated);");
expect(guide.cpp).toContain("m_ItemSubmitted.Set(submit, this, &WorkshopUploader::OnItemSubmitted);");
expect(guide.gdscript).toContain('Steam.setItemTags(handle, ["Maps", "Co-op"])');
expect(guide.gdscript).toContain('Steam.setItemDescription(handle, "Three maps")');
});

it("omits optional calls when inputs are absent", async () => {
const guide = await callGuide("steam_uploadWorkshopItem", { appid: 480, title: "Solo" });
expect(guide.csharp).not.toContain("SetItemTags");
expect(guide.gdscript).not.toContain("setItemTags");
expect(guide.gdscript).toContain('# Steam.setItemDescription(handle, "...")');
});
});
53 changes: 33 additions & 20 deletions src/tools/createLobby.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,39 +67,52 @@ export function register(server: McpServer): void {
max_members,
metadata: metadata ?? {},
cpp: [
"// Include Steam headers",
'#include "steam/steam_api.h"',
"",
`// Create lobby (async — result via CCallResult<LobbyCreated_t>)`,
`SteamAPICall_t call = SteamMatchmaking()->CreateLobby(${enumValue}, ${max_members});`,
"// Register callback for LobbyCreated_t",
"// Call SteamAPI_RunCallbacks() every frame so the call result fires.",
"class LobbyCreator {",
"public:",
" void Create() {",
` SteamAPICall_t call = SteamMatchmaking()->CreateLobby(${enumValue}, ${max_members});`,
" m_LobbyCreated.Set(call, this, &LobbyCreator::OnLobbyCreated);",
" }",
"",
"// In callback handler:",
"void OnLobbyCreated(LobbyCreated_t* pResult, bool bIOFailure) {",
" if (pResult->m_eResult != k_EResultOK) { /* handle error */ }",
" CSteamID lobbyId = pResult->m_ulSteamIDLobby;",
metaCpp,
"}",
"private:",
" void OnLobbyCreated(LobbyCreated_t* pResult, bool bIOFailure) {",
" if (bIOFailure || pResult->m_eResult != k_EResultOK) { /* handle error */ return; }",
" CSteamID lobbyId(pResult->m_ulSteamIDLobby);",
metaCpp.replace(/^ {2}/gm, " "),
" }",
"",
" CCallResult<LobbyCreator, LobbyCreated_t> m_LobbyCreated;",
"};",
].join("\n"),
csharp: [
"// Using Steamworks.NET",
`var call = SteamMatchmaking.CreateLobby(ELobbyType.${enumValue.replace("k_ELobbyType", "")}, ${max_members});`,
"// Use CallResult<LobbyCreated_t> to handle the result",
"// Using Steamworks.NET (keep a reference to the CallResult so it is not collected)",
"using Steamworks;",
"",
"private CallResult<LobbyCreated_t> m_LobbyCreated;",
"",
"void CreateLobby() {",
" m_LobbyCreated = CallResult<LobbyCreated_t>.Create(OnLobbyCreated);",
` SteamAPICall_t call = SteamMatchmaking.CreateLobby(ELobbyType.${enumValue}, ${max_members});`,
" m_LobbyCreated.Set(call);",
"}",
"",
"void OnLobbyCreated(LobbyCreated_t result, bool bIOFailure) {",
" if (result.m_eResult != EResult.k_EResultOK) { /* handle error */ }",
" if (bIOFailure || result.m_eResult != EResult.k_EResultOK) { /* handle error */ return; }",
" var lobbyId = new CSteamID(result.m_ulSteamIDLobby);",
metaCs,
"}",
].join("\n"),
gdscript: [
"# Using GodotSteam",
`var lobby_type = Steam.LOBBY_TYPE_${type.toUpperCase()}`,
`Steam.createLobby(lobby_type, ${max_members})`,
"# Connect to lobby_created signal",
"# Using GodotSteam (Godot 4)",
"func _ready() -> void:",
" Steam.lobby_created.connect(_on_lobby_created)",
` Steam.createLobby(Steam.LOBBY_TYPE_${type.toUpperCase()}, ${max_members})`,
"",
"func _on_lobby_created(connect: int, lobby_id: int) -> void:",
" if connect != 1:",
"func _on_lobby_created(this_connect: int, lobby_id: int) -> void:",
" if this_connect != 1:",
" print(\"Failed to create lobby\")",
" return",
metaGd,
Expand Down
126 changes: 98 additions & 28 deletions src/tools/uploadWorkshopItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export function register(server: McpServer): void {
};
const vis = visibility ?? "public";
const visEnum = visMap[vis];
const tagList = tags ?? [];
const cppContent = escapeStringLiteral(content_path ?? "/path/to/content", "cpp");
const cppPreview = escapeStringLiteral(preview_path ?? "/path/to/preview.jpg", "cpp");
const csContent = escapeStringLiteral(content_path ?? "C:/path/to/content", "csharp");
const csPreview = escapeStringLiteral(preview_path ?? "C:/path/to/preview.jpg", "csharp");

const guide = {
note: "Workshop uploads are a two-step client-side SDK operation: CreateItem then SubmitItemUpdate. There is no HTTP endpoint for file uploads. Use the code below in a game client or upload tool.",
Expand All @@ -71,55 +76,120 @@ export function register(server: McpServer): void {
cpp: [
'#include "steam/steam_api.h"',
"",
`// Step 1: Create the item`,
`SteamAPICall_t call = SteamUGC()->CreateItem(${appid}, k_EWorkshopFileTypeCommunity);`,
"// Handle CreateItemResult_t callback...",
"// Call SteamAPI_RunCallbacks() every frame so the call results fire.",
"class WorkshopUploader {",
"public:",
" void Upload() {",
` SteamAPICall_t call = SteamUGC()->CreateItem(${appid}, k_EWorkshopFileTypeCommunity);`,
" m_ItemCreated.Set(call, this, &WorkshopUploader::OnItemCreated);",
" }",
"",
"// Step 2-4: In the CreateItem callback:",
"void OnItemCreated(CreateItemResult_t* pResult, bool bIOFailure) {",
" if (pResult->m_eResult != k_EResultOK) { /* handle error */ }",
" PublishedFileId_t fileId = pResult->m_nPublishedFileId;",
"private:",
" void OnItemCreated(CreateItemResult_t* pResult, bool bIOFailure) {",
" if (bIOFailure || pResult->m_eResult != k_EResultOK) { /* handle error */ return; }",
" if (pResult->m_bUserNeedsToAcceptWorkshopLegalAgreement) { /* show the Workshop legal agreement */ }",
"",
` UGCUpdateHandle_t handle = SteamUGC()->StartItemUpdate(${appid}, fileId);`,
` SteamUGC()->SetItemTitle(handle, ${escapeStringLiteral(title, "cpp")});`,
` UGCUpdateHandle_t handle = SteamUGC()->StartItemUpdate(${appid}, pResult->m_nPublishedFileId);`,
` SteamUGC()->SetItemTitle(handle, ${escapeStringLiteral(title, "cpp")});`,
description
? ` SteamUGC()->SetItemDescription(handle, ${escapeStringLiteral(description, "cpp")});`
: " // SteamUGC()->SetItemDescription(handle, \"...\");",
` SteamUGC()->SetItemContent(handle, ${escapeStringLiteral(content_path ?? "/path/to/content", "cpp")});`,
` SteamUGC()->SetItemPreview(handle, ${escapeStringLiteral(preview_path ?? "/path/to/preview.jpg", "cpp")});`,
` SteamUGC()->SetItemVisibility(handle, ${visEnum});`,
...(tags && tags.length > 0
? ` SteamUGC()->SetItemDescription(handle, ${escapeStringLiteral(description, "cpp")});`
: " // SteamUGC()->SetItemDescription(handle, \"...\");",
` SteamUGC()->SetItemContent(handle, ${cppContent});`,
` SteamUGC()->SetItemPreview(handle, ${cppPreview});`,
` SteamUGC()->SetItemVisibility(handle, ${visEnum});`,
...(tagList.length > 0
? [
" SteamParamStringArray_t tagArray;",
` const char* tagStrings[] = { ${tags.map((t) => escapeStringLiteral(t, "cpp")).join(", ")} };`,
` tagArray.m_ppStrings = tagStrings;`,
` tagArray.m_nNumStrings = ${tags.length};`,
" SteamUGC()->SetItemTags(handle, &tagArray);",
` const char* tagStrings[] = { ${tagList.map((t) => escapeStringLiteral(t, "cpp")).join(", ")} };`,
" SteamParamStringArray_t tagArray;",
" tagArray.m_ppStrings = tagStrings;",
` tagArray.m_nNumStrings = ${tagList.length};`,
" SteamUGC()->SetItemTags(handle, &tagArray);",
]
: []),
"",
' SteamUGC()->SubmitItemUpdate(handle, "Initial upload");',
" // Handle SubmitItemUpdateResult_t callback...",
' SteamAPICall_t submit = SteamUGC()->SubmitItemUpdate(handle, "Initial upload");',
" m_ItemSubmitted.Set(submit, this, &WorkshopUploader::OnItemSubmitted);",
" }",
"",
" void OnItemSubmitted(SubmitItemUpdateResult_t* pResult, bool bIOFailure) {",
" if (bIOFailure || pResult->m_eResult != k_EResultOK) { /* handle error */ }",
" }",
"",
" CCallResult<WorkshopUploader, CreateItemResult_t> m_ItemCreated;",
" CCallResult<WorkshopUploader, SubmitItemUpdateResult_t> m_ItemSubmitted;",
"};",
].join("\n"),
csharp: [
"// Using Steamworks.NET (keep references to the CallResults so they are not collected)",
"using System.Collections.Generic;",
"using Steamworks;",
"",
"private CallResult<CreateItemResult_t> m_ItemCreated;",
"private CallResult<SubmitItemUpdateResult_t> m_ItemSubmitted;",
"",
"void Upload() {",
" m_ItemCreated = CallResult<CreateItemResult_t>.Create(OnItemCreated);",
" m_ItemSubmitted = CallResult<SubmitItemUpdateResult_t>.Create(OnItemSubmitted);",
` SteamAPICall_t call = SteamUGC.CreateItem(new AppId_t(${appid}), EWorkshopFileType.k_EWorkshopFileTypeCommunity);`,
" m_ItemCreated.Set(call);",
"}",
"",
"void OnItemCreated(CreateItemResult_t result, bool bIOFailure) {",
" if (bIOFailure || result.m_eResult != EResult.k_EResultOK) { /* handle error */ return; }",
" if (result.m_bUserNeedsToAcceptWorkshopLegalAgreement) { /* show the Workshop legal agreement */ }",
"",
` UGCUpdateHandle_t handle = SteamUGC.StartItemUpdate(new AppId_t(${appid}), result.m_nPublishedFileId);`,
` SteamUGC.SetItemTitle(handle, ${escapeStringLiteral(title, "csharp")});`,
description
? ` SteamUGC.SetItemDescription(handle, ${escapeStringLiteral(description, "csharp")});`
: " // SteamUGC.SetItemDescription(handle, \"...\");",
` SteamUGC.SetItemContent(handle, ${csContent});`,
` SteamUGC.SetItemPreview(handle, ${csPreview});`,
` SteamUGC.SetItemVisibility(handle, ERemoteStoragePublishedFileVisibility.${visEnum});`,
...(tagList.length > 0
? [
` SteamUGC.SetItemTags(handle, new List<string> { ${tagList.map((t) => escapeStringLiteral(t, "csharp")).join(", ")} });`,
]
: []),
"",
' m_ItemSubmitted.Set(SteamUGC.SubmitItemUpdate(handle, "Initial upload"));',
"}",
"",
"void OnItemSubmitted(SubmitItemUpdateResult_t result, bool bIOFailure) {",
" if (bIOFailure || result.m_eResult != EResult.k_EResultOK) { /* handle error */ }",
"}",
].join("\n"),
gdscript: [
"# Using GodotSteam",
`var app_id = ${appid}`,
"# Using GodotSteam (Godot 4)",
`var app_id: int = ${appid}`,
"",
"# Step 1: Create item",
"Steam.createItem(app_id, Steam.WORKSHOP_FILE_TYPE_COMMUNITY)",
"# Connect to item_created signal",
"func _ready() -> void:",
" Steam.item_created.connect(_on_item_created)",
" Steam.item_updated.connect(_on_item_updated)",
" Steam.createItem(app_id, Steam.WORKSHOP_FILE_TYPE_COMMUNITY)",
"",
"func _on_item_created(result: int, file_id: int, accept_tos: bool) -> void:",
" if result != Steam.RESULT_OK:",
' print("Failed to create item")',
" return",
" var handle = Steam.startItemUpdate(app_id, file_id)",
" if accept_tos:",
" pass # Show the Workshop legal agreement",
" var handle: int = Steam.startItemUpdate(app_id, file_id)",
` Steam.setItemTitle(handle, ${escapeStringLiteral(title, "gdscript")})`,
description
? ` Steam.setItemDescription(handle, ${escapeStringLiteral(description, "gdscript")})`
: ' # Steam.setItemDescription(handle, "...")',
` Steam.setItemContent(handle, ${escapeStringLiteral(content_path ?? "res://workshop_content", "gdscript")})`,
` Steam.setItemPreview(handle, ${escapeStringLiteral(preview_path ?? "res://preview.png", "gdscript")})`,
` Steam.setItemVisibility(handle, Steam.REMOTE_STORAGE_PUBLISHED_FILE_VISIBILITY_${vis.toUpperCase()})`,
...(tagList.length > 0
? [` Steam.setItemTags(handle, [${tagList.map((t) => escapeStringLiteral(t, "gdscript")).join(", ")}])`]
: []),
' Steam.submitItemUpdate(handle, "Initial upload")',
"",
"func _on_item_updated(result: int, accept_tos: bool) -> void:",
" if result != Steam.RESULT_OK:",
' print("Failed to submit item")',
].join("\n"),
references: [
"https://partner.steamgames.com/doc/api/ISteamUGC#CreateItem",
Expand Down
Loading