diff --git a/src/tools/__tests__/sdk-guides.test.ts b/src/tools/__tests__/sdk-guides.test.ts new file mode 100644 index 0000000..939533c --- /dev/null +++ b/src/tools/__tests__/sdk-guides.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { register as registerLobby } from "../createLobby.js"; +import { register as registerWorkshop } from "../uploadWorkshopItem.js"; + +async function callGuide(name: string, args: Record) { + const server = new McpServer({ name: "sdk-guide-test", version: "1.0.0" }); + registerLobby(server); + registerWorkshop(server); + const client = new Client({ name: "sdk-guide-client", version: "1.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + try { + const result = await client.callTool({ name, arguments: args }); + expect(result.isError).not.toBe(true); + const content = result.content as Array<{ type: string; text: string }>; + return JSON.parse(content[0].text) as Record; + } finally { + await client.close(); + await server.close(); + } +} + +describe("SDK guides", () => { + it("escapes Workshop text, Windows paths and tags in every generated language", async () => { + const title = 'A "quoted" title\nnext line'; + const description = 'Line one\r\nLine two\twith "quotes"'; + const content_path = "C:\\mods\\new"; + const preview_path = 'C:\\images\\my "preview".png'; + const tag = 'tag"\\value'; + const guide = await callGuide("steam_uploadWorkshopItem", { + appid: 480, title, description, content_path, preview_path, tags: [tag], + }); + + expect(guide.title).toBe(title); + expect(guide.description).toBe(description); + expect(guide.content_path).toBe(content_path); + expect(guide.preview_path).toBe(preview_path); + expect(guide.tags).toEqual([tag]); + for (const language of ["cpp", "gdscript"]) { + const code = guide[language] as string; + expect(code).toContain('A \\"quoted\\" title'); + expect(code).toContain('C:\\\\mods\\\\new'); + expect(code).toContain('C:\\\\images\\\\my \\"preview\\".png'); + expect(code).not.toContain(title); + } + expect(guide.cpp).toContain('tag\\"\\\\value'); + expect(guide.cpp).toContain('Line one\\015\\012Line two\\011with \\"quotes\\"'); + expect(guide.gdscript).toContain('title\\nnext line'); + }); + + it("escapes lobby metadata keys and values without changing metadata", async () => { + const metadata = { 'map"\\name': 'C:\\maps\\new\n"arena"' }; + const guide = await callGuide("steam_createLobby", { + type: "public", max_members: 4, metadata, + }); + + expect(guide.metadata).toEqual(metadata); + for (const language of ["cpp", "csharp", "gdscript"]) { + const code = guide[language] as string; + expect(code).toContain('"map\\"\\\\name"'); + expect(code).toContain('C:\\\\maps\\\\new'); + expect(code).toContain('\\"arena\\"'); + expect(code).not.toContain(metadata['map"\\name']); + } + expect(guide.cpp).toContain('new\\012\\"arena\\"'); + expect(guide.csharp).toContain('new\\n\\"arena\\"'); + expect(guide.gdscript).toContain('new\\n\\"arena\\"'); + }); + + it("retains ordinary defaults and empty strings in guides", async () => { + const workshop = await callGuide("steam_uploadWorkshopItem", { appid: 480, title: "Example" }); + expect(workshop.cpp).toContain('SetItemTitle(handle, "Example")'); + expect(workshop.cpp).toContain('SetItemContent(handle, "/path/to/content")'); + expect(workshop.gdscript).toContain('"res://workshop_content"'); + const lobby = await callGuide("steam_createLobby", { + type: "private", max_members: 2, metadata: { empty: "" }, + }); + expect(lobby.csharp).toContain('SetLobbyData(lobbyId, "empty", "")'); + }); +}); diff --git a/src/tools/createLobby.ts b/src/tools/createLobby.ts index 36ed08f..052228c 100644 --- a/src/tools/createLobby.ts +++ b/src/tools/createLobby.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { escapeStringLiteral } from "../utils/string-literal.js"; import { LOCAL_ONLY } from "../utils/annotations.js"; const inputSchema = { @@ -43,19 +44,19 @@ export function register(server: McpServer): void { const metaCpp = metaEntries .map( ([k, v]) => - ` SteamMatchmaking()->SetLobbyData(lobbyId, "${k}", "${v}");`, + ` SteamMatchmaking()->SetLobbyData(lobbyId, ${escapeStringLiteral(k, "cpp")}, ${escapeStringLiteral(v, "cpp")});`, ) .join("\n"); const metaCs = metaEntries .map( ([k, v]) => - ` SteamMatchmaking.SetLobbyData(lobbyId, "${k}", "${v}");`, + ` SteamMatchmaking.SetLobbyData(lobbyId, ${escapeStringLiteral(k, "csharp")}, ${escapeStringLiteral(v, "csharp")});`, ) .join("\n"); const metaGd = metaEntries .map( ([k, v]) => - ` Steam.setLobbyData(lobby_id, "${k}", "${v}")`, + ` Steam.setLobbyData(lobby_id, ${escapeStringLiteral(k, "gdscript")}, ${escapeStringLiteral(v, "gdscript")})`, ) .join("\n"); diff --git a/src/tools/uploadWorkshopItem.ts b/src/tools/uploadWorkshopItem.ts index 4a06f1f..c5b0535 100644 --- a/src/tools/uploadWorkshopItem.ts +++ b/src/tools/uploadWorkshopItem.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { escapeStringLiteral } from "../utils/string-literal.js"; import { LOCAL_ONLY } from "../utils/annotations.js"; const inputSchema = { @@ -80,17 +81,17 @@ export function register(server: McpServer): void { " PublishedFileId_t fileId = pResult->m_nPublishedFileId;", "", ` UGCUpdateHandle_t handle = SteamUGC()->StartItemUpdate(${appid}, fileId);`, - ` SteamUGC()->SetItemTitle(handle, "${title}");`, + ` SteamUGC()->SetItemTitle(handle, ${escapeStringLiteral(title, "cpp")});`, description - ? ` SteamUGC()->SetItemDescription(handle, "${description}");` + ? ` SteamUGC()->SetItemDescription(handle, ${escapeStringLiteral(description, "cpp")});` : " // SteamUGC()->SetItemDescription(handle, \"...\");", - ` SteamUGC()->SetItemContent(handle, "${content_path ?? "/path/to/content"}");`, - ` SteamUGC()->SetItemPreview(handle, "${preview_path ?? "/path/to/preview.jpg"}");`, + ` 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 ? [ " SteamParamStringArray_t tagArray;", - ` const char* tagStrings[] = { ${tags.map((t) => `"${t}"`).join(", ")} };`, + ` const char* tagStrings[] = { ${tags.map((t) => escapeStringLiteral(t, "cpp")).join(", ")} };`, ` tagArray.m_ppStrings = tagStrings;`, ` tagArray.m_nNumStrings = ${tags.length};`, " SteamUGC()->SetItemTags(handle, &tagArray);", @@ -114,9 +115,9 @@ export function register(server: McpServer): void { ' print("Failed to create item")', " return", " var handle = Steam.startItemUpdate(app_id, file_id)", - ` Steam.setItemTitle(handle, "${title}")`, - ` Steam.setItemContent(handle, "${content_path ?? "res://workshop_content"}")`, - ` Steam.setItemPreview(handle, "${preview_path ?? "res://preview.png"}")`, + ` Steam.setItemTitle(handle, ${escapeStringLiteral(title, "gdscript")})`, + ` 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()})`, ' Steam.submitItemUpdate(handle, "Initial upload")', ].join("\n"), diff --git a/src/utils/string-literal.ts b/src/utils/string-literal.ts new file mode 100644 index 0000000..2ee6120 --- /dev/null +++ b/src/utils/string-literal.ts @@ -0,0 +1,19 @@ +type GuideLanguage = "cpp" | "csharp" | "gdscript"; + +/** Return a quoted source literal, not a JSON-encoded code fragment. */ +export function escapeStringLiteral(value: string, language: GuideLanguage): string { + if (language !== "cpp") { + return JSON.stringify(value) + .replace(/\u2028/g, "\\u2028") + .replace(/\u2029/g, "\\u2029"); + } + + // Fixed-width octal preserves UTF-8 bytes without consuming a following digit. + const escaped = Array.from(Buffer.from(value, "utf8"), (byte) => { + if (byte === 0x22) return '\\"'; + if (byte === 0x5c) return "\\\\"; + if (byte >= 0x20 && byte <= 0x7e) return String.fromCharCode(byte); + return `\\${byte.toString(8).padStart(3, "0")}`; + }).join(""); + return `"${escaped}"`; +}