Addresses the root cause of issue #631 (infinite Docker agent restart loop) and prevents similar issues with host-agent and sensor-proxy. Changes: - Set dockeragent.Version default to "dev" instead of hardcoded version - Add version embedding to server build in Dockerfile - Add version embedding to host-agent builds (all platforms) - Add version embedding to sensor-proxy builds (all platforms) This ensures: 1. Server's /api/agent/version endpoint returns correct v4.26.0 2. Downloaded agent binaries have matching embedded versions 3. Dev builds skip auto-update (Version="dev") 4. No version mismatch triggers infinite restart loops Related to #631
150 lines
5.1 KiB
JavaScript
150 lines
5.1 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
import { z } from "zod";
|
|
import { exec } from "child_process";
|
|
import { promisify } from "util";
|
|
import { writeFile, readFile } from "fs/promises";
|
|
import { tmpdir } from "os";
|
|
import { join } from "path";
|
|
import { randomBytes } from "crypto";
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
// Session management
|
|
const sessions = new Map<string, string>(); // Map conversation ID to codex session ID
|
|
|
|
// Create the MCP server
|
|
const server = new McpServer({
|
|
name: "pulse-codex",
|
|
version: "1.0.0",
|
|
});
|
|
|
|
// Register the codex tool for initial questions
|
|
server.registerTool(
|
|
"codex",
|
|
{
|
|
title: "Codex AI Assistant",
|
|
description: "Ask a question to OpenAI Codex AI assistant. Returns a thoughtful response. Use this for architectural decisions, code review, and technical consultation.",
|
|
inputSchema: {
|
|
question: z.string().describe("The question to ask Codex"),
|
|
conversationId: z.string().optional().describe("Optional conversation ID to maintain context across calls"),
|
|
},
|
|
},
|
|
async ({ question, conversationId }) => {
|
|
const convId = conversationId || `conv-${Date.now()}-${process.pid}`;
|
|
const uniqueId = `${Date.now()}-${process.pid}-${randomBytes(4).toString('hex')}`;
|
|
const tempFile = join(tmpdir(), `codex-${uniqueId}.txt`);
|
|
|
|
try {
|
|
// Check if we have an existing codex session for this conversation
|
|
const existingSessionId = sessions.get(convId);
|
|
|
|
// Write question to a temp file to avoid shell escaping issues
|
|
const questionFile = join(tmpdir(), `codex-q-${uniqueId}.txt`);
|
|
await writeFile(questionFile, question, "utf-8");
|
|
|
|
let command: string;
|
|
if (existingSessionId) {
|
|
// Resume existing session
|
|
command = `codex exec --yolo -o "${tempFile}" resume "${existingSessionId}" < "${questionFile}"`;
|
|
} else {
|
|
// Start new session
|
|
command = `codex exec --yolo -o "${tempFile}" < "${questionFile}"`;
|
|
}
|
|
|
|
// Execute codex and capture stderr for session ID only
|
|
// Note: stderr contains all reasoning output, but we only need the session ID
|
|
const { stdout, stderr } = await execAsync(command, {
|
|
timeout: 1800000, // 30 minute timeout (codex can take a very long time for complex queries)
|
|
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
|
|
});
|
|
|
|
// Extract only the session ID from stderr
|
|
const sessionIdMatch = stderr.match(/session id:\s*([a-f0-9-]+)/);
|
|
const sessionId = sessionIdMatch ? sessionIdMatch[1] : "";
|
|
|
|
// Store session for this conversation
|
|
if (sessionId && !existingSessionId) {
|
|
sessions.set(convId, sessionId);
|
|
}
|
|
|
|
// Read the response from the output file
|
|
const response = await readFile(tempFile, "utf-8");
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: `${response.trim()}\n\n---\nSession ID: ${sessionId}\nConversation ID: ${convId}`,
|
|
},
|
|
],
|
|
};
|
|
} catch (error) {
|
|
// Log full error to stderr for debugging
|
|
console.error("Codex execution error:", error);
|
|
|
|
// Extract only the relevant error info, not the full stderr with reasoning
|
|
let errorMessage = "Unknown error";
|
|
if (error instanceof Error) {
|
|
// For exec errors, the message contains both stdout and stderr
|
|
// We want to extract just the actual error, not all the reasoning output
|
|
const lines = error.message.split('\n');
|
|
// Look for actual error messages (usually start with "Error:" or contain "failed")
|
|
const relevantLines = lines.filter(line =>
|
|
line.includes('Error:') ||
|
|
line.includes('failed') ||
|
|
line.includes('not found') ||
|
|
line.includes('permission denied') ||
|
|
line.includes('exit code')
|
|
);
|
|
errorMessage = relevantLines.length > 0 ? relevantLines.join('\n') : error.message;
|
|
}
|
|
throw new Error(`Failed to execute codex: ${errorMessage}`);
|
|
}
|
|
}
|
|
);
|
|
|
|
// Register a tool to clear session history
|
|
server.registerTool(
|
|
"codex_clear_session",
|
|
{
|
|
title: "Clear Codex Session",
|
|
description: "Clear the stored codex session for a conversation, starting fresh",
|
|
inputSchema: {
|
|
conversationId: z.string().describe("The conversation ID to clear"),
|
|
},
|
|
},
|
|
async ({ conversationId }) => {
|
|
const existed = sessions.has(conversationId);
|
|
sessions.delete(conversationId);
|
|
|
|
const message = existed
|
|
? `Session cleared for conversation ${conversationId}`
|
|
: `No session found for conversation ${conversationId}`;
|
|
|
|
return {
|
|
content: [
|
|
{
|
|
type: "text",
|
|
text: message,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
);
|
|
|
|
// Connect to stdio transport
|
|
async function main() {
|
|
const transport = new StdioServerTransport();
|
|
await server.connect(transport);
|
|
|
|
// Log to stderr so it doesn't interfere with MCP protocol
|
|
console.error("Pulse Codex MCP Server running on stdio");
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error("Fatal error:", error);
|
|
process.exit(1);
|
|
});
|