Skip to content
Closed
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
84 changes: 84 additions & 0 deletions src/tools/__tests__/sdk-guides.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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<string, unknown>;
} 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", "")');
});
});
7 changes: 4 additions & 3 deletions src/tools/createLobby.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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");

Expand Down
17 changes: 9 additions & 8 deletions src/tools/uploadWorkshopItem.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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);",
Expand All @@ -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"),
Expand Down
19 changes: 19 additions & 0 deletions src/utils/string-literal.ts
Original file line number Diff line number Diff line change
@@ -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}"`;
}
Loading