VibeGame / src /lib /server /mcp-client.ts
dylanebert's picture
improved prompting/UX
db9635c
import { DynamicStructuredTool } from "@langchain/core/tools";
import { z } from "zod";
import type { WebSocket } from "ws";
import { consoleBuffer } from "./console-buffer";
import { virtualFileSystem } from "../services/virtual-fs";
interface EditorWebSocketConnection {
send: (message: {
type: string;
payload: Record<string, unknown>;
timestamp: number;
}) => void;
}
let wsConnection: EditorWebSocketConnection | null = null;
export function setMCPWebSocketConnection(ws: WebSocket) {
wsConnection = {
send: (message: {
type: string;
payload: Record<string, unknown>;
timestamp: number;
}) => {
if (ws && ws.readyState === ws.OPEN) {
ws.send(JSON.stringify(message));
}
},
};
}
/**
* Standardized tool response builder
*/
function buildToolResponse({
success,
action,
error,
gameState,
consoleOutput,
}: {
success: boolean;
action: string;
error?: string;
gameState?: ReturnType<typeof consoleBuffer.getGameStateFromMessages>;
consoleOutput?: string[];
}): string {
const sections: string[] = [];
// Status section
if (success) {
sections.push(`✅ ${action} completed successfully.`);
} else {
sections.push(`❌ ${action} failed.`);
}
// Error section
if (error) {
sections.push(`\nError: ${error}`);
}
// Game state section
if (gameState) {
if (gameState.isReady) {
sections.push("\n🎮 Game Status: Running");
} else if (gameState.hasError) {
sections.push(`\n🎮 Game Status: Error\n ${gameState.lastError}`);
} else if (gameState.isLoading) {
sections.push("\n🎮 Game Status: Loading...");
} else {
sections.push("\n🎮 Game Status: Unknown");
}
}
// Console output section
if (consoleOutput && consoleOutput.length > 0) {
sections.push("\n📝 Console Output:");
sections.push(consoleOutput.join("\n"));
}
return sections.join("\n");
}
/**
* Wait for game state with improved console capture
*/
async function waitForGameState(
toolName: string,
maxWaitTime: number = 5000,
): Promise<{
gameState: ReturnType<typeof consoleBuffer.getGameStateFromMessages>;
consoleOutput: string[];
}> {
// Set execution context for console messages
consoleBuffer.setExecutionContext(toolName);
consoleBuffer.clearToolMessages();
const startTime = Date.now();
// Initial wait for game to process changes (increased to match GameCanvas reload delay)
await new Promise((resolve) => setTimeout(resolve, 2000));
while (Date.now() - startTime < maxWaitTime) {
const gameState = consoleBuffer.getGameStateFromMessages();
// Check if we have a definitive state (not reloading, and either ready or error)
if (!gameState.isReloading && (gameState.isReady || gameState.hasError)) {
const messages = consoleBuffer.getMessagesSinceLastTool();
const consoleOutput = messages
.slice(-30)
.map((m) => `[${m.type}] ${m.message}`);
// Clear context
consoleBuffer.setExecutionContext(null);
consoleBuffer.markAsRead();
return { gameState, consoleOutput };
}
// If still reloading, wait longer
if (gameState.isReloading) {
await new Promise((resolve) => setTimeout(resolve, 200));
} else {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
// Timeout - return current state
const gameState = consoleBuffer.getGameStateFromMessages();
const messages = consoleBuffer.getMessagesSinceLastTool();
const consoleOutput = messages
.slice(-30)
.map((m) => `[${m.type}] ${m.message}`);
// Clear context
consoleBuffer.setExecutionContext(null);
consoleBuffer.markAsRead();
return { gameState, consoleOutput };
}
/**
* MCPClientManager provides MCP-style tools for editor operations
* Currently uses local implementation, can be extended to use actual MCP servers
*/
export class MCPClientManager {
private tools: DynamicStructuredTool[] = [];
private initialized = false;
async initialize(): Promise<void> {
if (this.initialized) {
return;
}
this.tools = this.createEditorTools();
this.initialized = true;
}
private createEditorTools(): DynamicStructuredTool[] {
const tools: DynamicStructuredTool[] = [];
tools.push(
new DynamicStructuredTool({
name: "read_editor",
description:
"Read the complete editor content - use for initial exploration or when search returns no results",
schema: z.object({}),
func: async () => {
const file = virtualFileSystem.getGameFile();
return `Current editor content (html):\n${file.content}`;
},
}),
);
tools.push(
new DynamicStructuredTool({
name: "read_editor_lines",
description:
"Read specific lines from the editor - use AFTER search_editor to examine found code sections in detail",
schema: z.object({
startLine: z
.number()
.min(1)
.describe("The starting line number (1-indexed)"),
endLine: z
.number()
.min(1)
.optional()
.describe(
"The ending line number (inclusive). If not provided, only the start line is returned",
),
}),
func: async (input: { startLine: number; endLine?: number }) => {
const result = virtualFileSystem.getLines(
input.startLine,
input.endLine,
);
if (result.error) {
return `Error: ${result.error}`;
}
return result.content;
},
}),
);
tools.push(
new DynamicStructuredTool({
name: "search_editor",
description:
"Search for code elements and get line numbers - use FIRST to locate specific functions, classes, or components before reading or editing",
schema: z.object({
query: z.string().describe("Text or regex pattern to search for"),
mode: z
.enum(["text", "regex"])
.optional()
.describe(
"Search mode: 'text' for literal text search, 'regex' for pattern matching (default: text)",
),
contextLines: z
.number()
.min(0)
.max(5)
.optional()
.describe(
"Number of context lines before/after match (default: 2, max: 5)",
),
}),
func: async (input: {
query: string;
mode?: "text" | "regex";
contextLines?: number;
}) => {
const mode = input.mode || "text";
const results = virtualFileSystem.searchContent(input.query, mode);
if (results.length === 0) {
return `No matches found for "${input.query}" in editor content`;
}
const totalMatches = results.length;
const displayMatches = results.slice(0, 10);
let output = `Found ${totalMatches} match${totalMatches > 1 ? "es" : ""} for "${input.query}":\n\n`;
displayMatches.forEach((match, index) => {
if (index > 0) output += "\n---\n\n";
output += match.context.join("\n");
});
if (totalMatches > 10) {
output += `\n\n(Showing first 10 of ${totalMatches} matches. Use more specific search terms to narrow results)`;
}
return output;
},
}),
);
tools.push(
new DynamicStructuredTool({
name: "edit_editor",
description:
"Replace specific text in the editor - use for SMALL, targeted changes (max ~20 lines). For large changes, use multiple edit_editor calls with plan_tasks",
schema: z.object({
oldText: z
.string()
.describe(
"The exact text to find and replace (keep small - max ~20 lines)",
),
newText: z.string().describe("The text to replace it with"),
}),
func: async (input: { oldText: string; newText: string }) => {
const currentContent = virtualFileSystem.getGameFile().content;
if (!currentContent.includes(input.oldText)) {
const shortPreview =
input.oldText.substring(0, 50) +
(input.oldText.length > 50 ? "..." : "");
return buildToolResponse({
success: false,
action: "Text replacement",
error: `Text not found: "${shortPreview}". This might be due to a previous edit already modifying this text. Please verify the current content with read_editor or search_editor.`,
});
}
const result = virtualFileSystem.editContent(
input.oldText,
input.newText,
);
if (!result.success) {
return buildToolResponse({
success: false,
action: "Text replacement",
error: result.error,
});
}
const file = virtualFileSystem.getGameFile();
this.syncEditorContent(file.content);
// Wait for game to reload and capture console
const { gameState, consoleOutput } =
await waitForGameState("edit_editor");
return buildToolResponse({
success: true,
action: "Text replacement",
gameState,
consoleOutput,
});
},
}),
);
tools.push(
new DynamicStructuredTool({
name: "write_editor",
description:
"Replace entire editor content - use ONLY for creating new files or complete rewrites. For modifications, use edit_editor with plan_tasks instead",
schema: z.object({
content: z
.string()
.describe("The complete code content to write to the editor"),
}),
func: async (input: { content: string }) => {
virtualFileSystem.updateGameContent(input.content);
this.syncEditorContent(input.content);
// Wait for game to reload and capture console
const { gameState, consoleOutput } =
await waitForGameState("write_editor");
return buildToolResponse({
success: true,
action: "Editor content update",
gameState,
consoleOutput,
});
},
}),
);
tools.push(...this.createContext7Tools());
return tools;
}
private createContext7Tools(): DynamicStructuredTool[] {
const tools: DynamicStructuredTool[] = [];
const apiKey = process.env.CONTEXT7_API_KEY;
if (!apiKey) {
console.warn(
"CONTEXT7_API_KEY not set, Context7 tools will not be available",
);
return tools;
}
tools.push(
new DynamicStructuredTool({
name: "resolve_library_id",
description: "Resolve a library name to Context7-compatible library ID",
schema: z.object({
libraryName: z
.string()
.describe("The name of the library to resolve"),
}),
func: async (input: { libraryName: string }) => {
try {
const response = await globalThis.fetch(
"https://mcp.context7.com/mcp",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
CONTEXT7_API_KEY: apiKey,
},
body: JSON.stringify({
jsonrpc: "2.0",
id: Math.floor(Math.random() * 10000),
method: "tools/call",
params: {
name: "resolve-library-id",
arguments: input,
},
}),
},
);
if (!response.ok) {
throw new Error(
`HTTP ${response.status}: ${response.statusText}`,
);
}
const text = await response.text();
const lines = text.split("\n");
let jsonData = null;
for (const line of lines) {
if (line.startsWith("data: ")) {
try {
jsonData = JSON.parse(line.substring(6));
break;
} catch {
// Continue looking for valid JSON
}
}
}
if (!jsonData) {
throw new Error("No valid JSON data found in response");
}
if (jsonData.error) {
throw new Error(
jsonData.error.message || JSON.stringify(jsonData.error),
);
}
return JSON.stringify(jsonData.result, null, 2);
} catch (error) {
return `Error resolving library ID for "${input.libraryName}": ${error instanceof Error ? error.message : String(error)}`;
}
},
}),
);
tools.push(
new DynamicStructuredTool({
name: "get_library_docs",
description:
"Fetch up-to-date documentation for a Context7-compatible library",
schema: z.object({
context7CompatibleLibraryID: z
.string()
.describe("The Context7 library ID (e.g., '/greensock/gsap')"),
tokens: z
.number()
.optional()
.describe("Maximum tokens to retrieve (default: 5000)"),
topic: z
.string()
.optional()
.describe("Specific topic to focus on (e.g., 'animations')"),
}),
func: async (input: {
context7CompatibleLibraryID: string;
tokens?: number;
topic?: string;
}) => {
try {
const response = await globalThis.fetch(
"https://mcp.context7.com/mcp",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
CONTEXT7_API_KEY: apiKey,
},
body: JSON.stringify({
jsonrpc: "2.0",
id: Math.floor(Math.random() * 10000),
method: "tools/call",
params: {
name: "get-library-docs",
arguments: {
context7CompatibleLibraryID:
input.context7CompatibleLibraryID,
tokens: input.tokens || 5000,
topic: input.topic,
},
},
}),
},
);
if (!response.ok) {
throw new Error(
`HTTP ${response.status}: ${response.statusText}`,
);
}
const text = await response.text();
const lines = text.split("\n");
let jsonData = null;
for (const line of lines) {
if (line.startsWith("data: ")) {
try {
jsonData = JSON.parse(line.substring(6));
break;
} catch {
// Continue looking for valid JSON
}
}
}
if (!jsonData) {
throw new Error("No valid JSON data found in response");
}
if (jsonData.error) {
throw new Error(
jsonData.error.message || JSON.stringify(jsonData.error),
);
}
return JSON.stringify(jsonData.result, null, 2);
} catch (error) {
return `Error fetching docs for "${input.context7CompatibleLibraryID}": ${error instanceof Error ? error.message : String(error)}`;
}
},
}),
);
return tools;
}
private syncEditorContent(content: string): void {
if (wsConnection) {
wsConnection.send({
type: "editor_update",
payload: { content },
timestamp: Date.now(),
});
}
}
getTools(): DynamicStructuredTool[] {
return this.tools;
}
async cleanup(): Promise<void> {
this.initialized = false;
}
}
export const mcpClientManager = new MCPClientManager();