mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-21 13:01:31 +00:00
chore(api): remove unused backend endpoints after MCP audit
Remove legacy backend routes that no longer have frontend or internal callers, including the old Claude/Codex MCP APIs, unused Cursor and Codex helper endpoints, stale TaskMaster detection/next/initialize routes, and unused command/project helpers. This reduces duplicated MCP behavior now handled by the provider-based MCP API, shrinks the exposed backend surface, and removes probe/service code that only existed for deleted endpoints. Add an MCP settings API audit document to capture the route-usage analysis and explain why the legacy MCP endpoints were considered safe to remove.
This commit is contained in:
@@ -44,10 +44,9 @@ import cors from 'cors';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import { spawn } from 'child_process';
|
||||
import pty from 'node-pty';
|
||||
import fetch from 'node-fetch';
|
||||
import mime from 'mime-types';
|
||||
|
||||
import { getProjects, getSessions, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js';
|
||||
import { getProjects, getSessions, renameProject, deleteSession, deleteProject, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js';
|
||||
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js';
|
||||
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
|
||||
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
|
||||
@@ -55,7 +54,6 @@ import { spawnGemini, abortGeminiSession, isGeminiSessionActive, getActiveGemini
|
||||
import sessionManager from './sessionManager.js';
|
||||
import gitRoutes from './routes/git.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import mcpRoutes from './routes/mcp.js';
|
||||
import cursorRoutes from './routes/cursor.js';
|
||||
import taskmasterRoutes from './routes/taskmaster.js';
|
||||
import mcpUtilsRoutes from './routes/mcp-utils.js';
|
||||
@@ -367,9 +365,6 @@ app.use('/api/projects', authenticateToken, projectsRoutes);
|
||||
// Git API Routes (protected)
|
||||
app.use('/api/git', authenticateToken, gitRoutes);
|
||||
|
||||
// MCP API Routes (protected)
|
||||
app.use('/api/mcp', authenticateToken, mcpRoutes);
|
||||
|
||||
// Cursor API Routes (protected)
|
||||
app.use('/api/cursor', authenticateToken, cursorRoutes);
|
||||
|
||||
@@ -587,23 +582,6 @@ app.delete('/api/projects/:projectName', authenticateToken, async (req, res) =>
|
||||
}
|
||||
});
|
||||
|
||||
// Create project endpoint
|
||||
app.post('/api/projects/create', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { path: projectPath } = req.body;
|
||||
|
||||
if (!projectPath || !projectPath.trim()) {
|
||||
return res.status(400).json({ error: 'Project path is required' });
|
||||
}
|
||||
|
||||
const project = await addProjectManually(projectPath.trim());
|
||||
res.json({ success: true, project });
|
||||
} catch (error) {
|
||||
console.error('Error creating project:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Search conversations content (SSE streaming)
|
||||
app.get('/api/search/conversations', authenticateToken, async (req, res) => {
|
||||
const query = typeof req.query.q === 'string' ? req.query.q.trim() : '';
|
||||
|
||||
@@ -180,19 +180,6 @@ router.post(
|
||||
}),
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/:provider/mcp/servers/:name',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const payload = parseMcpUpsertPayload({
|
||||
...((req.body && typeof req.body === 'object') ? req.body as Record<string, unknown> : {}),
|
||||
name: readPathParam(req.params.name, 'name'),
|
||||
});
|
||||
const server = await providerMcpService.upsertProviderMcpServer(provider, payload);
|
||||
res.json(createApiSuccessResponse({ server }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:provider/mcp/servers/:name',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
@@ -208,22 +195,6 @@ router.delete(
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/mcp/servers/:name/run',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const body = (req.body as Record<string, unknown> | undefined) ?? {};
|
||||
const scope = parseMcpScope(body.scope ?? req.query.scope);
|
||||
const workspacePath = readOptionalQueryString(body.workspacePath ?? req.query.workspacePath);
|
||||
const result = await providerMcpService.runProviderMcpServer(provider, {
|
||||
name: readPathParam(req.params.name, 'name'),
|
||||
scope,
|
||||
workspacePath,
|
||||
});
|
||||
res.json(createApiSuccessResponse(result));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/mcp/servers/global',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
|
||||
@@ -60,25 +60,6 @@ export const providerMcpService = {
|
||||
return provider.mcp.removeServer(input);
|
||||
},
|
||||
|
||||
/**
|
||||
* Runs one provider MCP server probe.
|
||||
*/
|
||||
async runProviderMcpServer(
|
||||
providerName: string,
|
||||
input: { name: string; scope?: McpScope; workspacePath?: string },
|
||||
): Promise<{
|
||||
provider: LLMProvider;
|
||||
name: string;
|
||||
scope: McpScope;
|
||||
transport: 'stdio' | 'http' | 'sse';
|
||||
reachable: boolean;
|
||||
statusCode?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
const provider = providerRegistry.resolveProvider(providerName);
|
||||
return provider.mcp.runServer(input);
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds one HTTP/stdio MCP server to every provider.
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { once } from 'node:events';
|
||||
import path from 'node:path';
|
||||
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderMcp } from '@/shared/interfaces.js';
|
||||
import type { LLMProvider, McpScope, McpTransport, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
@@ -22,74 +19,6 @@ const normalizeServerName = (name: string): string => {
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const runStdioServerProbe = async (
|
||||
server: ProviderMcpServer,
|
||||
workspacePath: string,
|
||||
): Promise<{ reachable: boolean; error?: string }> => {
|
||||
if (!server.command) {
|
||||
return { reachable: false, error: 'Missing stdio command.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const child = spawn(server.command, server.args ?? [], {
|
||||
cwd: server.cwd ? path.resolve(workspacePath, server.cwd) : workspacePath,
|
||||
env: {
|
||||
...process.env,
|
||||
...(server.env ?? {}),
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!child.killed && child.exitCode === null) {
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
}, 1_500);
|
||||
|
||||
const errorPromise = once(child, 'error').then(([error]) => {
|
||||
throw error;
|
||||
});
|
||||
const closePromise = once(child, 'close');
|
||||
await Promise.race([closePromise, errorPromise]);
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (typeof child.exitCode === 'number' && child.exitCode !== 0) {
|
||||
return {
|
||||
reachable: false,
|
||||
error: `Process exited with code ${child.exitCode}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { reachable: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
reachable: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to start stdio process',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const runHttpServerProbe = async (
|
||||
url: string,
|
||||
): Promise<{ reachable: boolean; statusCode?: number; error?: string }> => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3_000);
|
||||
try {
|
||||
const response = await fetch(url, { method: 'GET', signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
return {
|
||||
reachable: true,
|
||||
statusCode: response.status,
|
||||
};
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
return {
|
||||
reachable: false,
|
||||
error: error instanceof Error ? error.message : 'Network probe failed',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared MCP provider for provider-specific config readers/writers.
|
||||
*/
|
||||
@@ -182,63 +111,6 @@ export abstract class McpProvider implements IProviderMcp {
|
||||
return { removed, provider: this.provider, name: normalizedName, scope };
|
||||
}
|
||||
|
||||
async runServer(
|
||||
input: { name: string; scope?: McpScope; workspacePath?: string },
|
||||
): Promise<{
|
||||
provider: LLMProvider;
|
||||
name: string;
|
||||
scope: McpScope;
|
||||
transport: McpTransport;
|
||||
reachable: boolean;
|
||||
statusCode?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
const scope = input.scope ?? 'project';
|
||||
this.assertScope(scope);
|
||||
|
||||
const workspacePath = resolveWorkspacePath(input.workspacePath);
|
||||
const normalizedName = normalizeServerName(input.name);
|
||||
const scopedServers = await this.readScopedServers(scope, workspacePath);
|
||||
const rawConfig = scopedServers[normalizedName];
|
||||
if (!rawConfig || typeof rawConfig !== 'object') {
|
||||
throw new AppError(`MCP server "${normalizedName}" was not found.`, {
|
||||
code: 'MCP_SERVER_NOT_FOUND',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const normalized = this.normalizeServerConfig(scope, normalizedName, rawConfig);
|
||||
if (!normalized) {
|
||||
throw new AppError(`MCP server "${normalizedName}" has an invalid configuration.`, {
|
||||
code: 'MCP_SERVER_INVALID_CONFIG',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (normalized.transport === 'stdio') {
|
||||
const result = await runStdioServerProbe(normalized, workspacePath);
|
||||
return {
|
||||
provider: this.provider,
|
||||
name: normalizedName,
|
||||
scope,
|
||||
transport: normalized.transport,
|
||||
reachable: result.reachable,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await runHttpServerProbe(normalized.url ?? '');
|
||||
return {
|
||||
provider: this.provider,
|
||||
name: normalizedName,
|
||||
scope,
|
||||
transport: normalized.transport,
|
||||
reachable: result.reachable,
|
||||
statusCode: result.statusCode,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
protected abstract readScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
@@ -292,62 +291,3 @@ test('providerMcpService global adder writes to all providers and rejects unsupp
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers "run" behavior for both stdio and http MCP servers.
|
||||
*/
|
||||
test('providerMcpService runProviderServer probes stdio and http MCP servers', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-run-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
const server = http.createServer((_req, res) => {
|
||||
res.statusCode = 200;
|
||||
res.end('ok');
|
||||
});
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === 'object');
|
||||
const url = `http://127.0.0.1:${address.port}/mcp`;
|
||||
|
||||
await providerMcpService.upsertProviderMcpServer('gemini', {
|
||||
name: 'probe-http',
|
||||
scope: 'project',
|
||||
transport: 'http',
|
||||
url,
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
await providerMcpService.upsertProviderMcpServer('cursor', {
|
||||
name: 'probe-stdio',
|
||||
scope: 'project',
|
||||
transport: 'stdio',
|
||||
command: process.execPath,
|
||||
args: ['-e', 'process.exit(0)'],
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
const httpProbe = await providerMcpService.runProviderMcpServer('gemini', {
|
||||
name: 'probe-http',
|
||||
scope: 'project',
|
||||
workspacePath,
|
||||
});
|
||||
assert.equal(httpProbe.reachable, true);
|
||||
assert.equal(httpProbe.transport, 'http');
|
||||
|
||||
const stdioProbe = await providerMcpService.runProviderMcpServer('cursor', {
|
||||
name: 'probe-stdio',
|
||||
scope: 'project',
|
||||
workspacePath,
|
||||
});
|
||||
assert.equal(stdioProbe.reachable, true);
|
||||
assert.equal(stdioProbe.transport, 'stdio');
|
||||
} finally {
|
||||
server.close();
|
||||
restoreHomeDir();
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,73 +1,9 @@
|
||||
import express from 'express';
|
||||
import { spawn } from 'child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import TOML from '@iarna/toml';
|
||||
import { getCodexSessions, deleteCodexSession } from '../projects.js';
|
||||
import { applyCustomSessionNames, sessionNamesDb } from '../database/db.js';
|
||||
import { deleteCodexSession } from '../projects.js';
|
||||
import { sessionNamesDb } from '../database/db.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function createCliResponder(res) {
|
||||
let responded = false;
|
||||
return (status, payload) => {
|
||||
if (responded || res.headersSent) {
|
||||
return;
|
||||
}
|
||||
responded = true;
|
||||
res.status(status).json(payload);
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/config', async (req, res) => {
|
||||
try {
|
||||
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
|
||||
const content = await fs.readFile(configPath, 'utf8');
|
||||
const config = TOML.parse(content);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
config: {
|
||||
model: config.model || null,
|
||||
mcpServers: config.mcp_servers || {},
|
||||
approvalMode: config.approval_mode || 'suggest'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
res.json({
|
||||
success: true,
|
||||
config: {
|
||||
model: null,
|
||||
mcpServers: {},
|
||||
approvalMode: 'suggest'
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error('Error reading Codex config:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/sessions', async (req, res) => {
|
||||
try {
|
||||
const { projectPath } = req.query;
|
||||
|
||||
if (!projectPath) {
|
||||
return res.status(400).json({ success: false, error: 'projectPath query parameter required' });
|
||||
}
|
||||
|
||||
const sessions = await getCodexSessions(projectPath);
|
||||
applyCustomSessionNames(sessions, 'codex');
|
||||
res.json({ success: true, sessions });
|
||||
} catch (error) {
|
||||
console.error('Error fetching Codex sessions:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/sessions/:sessionId', async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
@@ -80,250 +16,4 @@ router.delete('/sessions/:sessionId', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// MCP Server Management Routes
|
||||
|
||||
router.get('/mcp/cli/list', async (req, res) => {
|
||||
try {
|
||||
const respond = createCliResponder(res);
|
||||
const proc = spawn('codex', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
|
||||
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
respond(200, { success: true, output: stdout, servers: parseCodexListOutput(stdout) });
|
||||
} else {
|
||||
respond(500, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
const isMissing = error?.code === 'ENOENT';
|
||||
respond(isMissing ? 503 : 500, {
|
||||
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
|
||||
details: error.message,
|
||||
code: error.code
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/mcp/cli/add', async (req, res) => {
|
||||
try {
|
||||
const { name, command, args = [], env = {} } = req.body;
|
||||
|
||||
if (!name || !command) {
|
||||
return res.status(400).json({ error: 'name and command are required' });
|
||||
}
|
||||
|
||||
// Build: codex mcp add <name> [-e KEY=VAL]... -- <command> [args...]
|
||||
let cliArgs = ['mcp', 'add', name];
|
||||
|
||||
Object.entries(env).forEach(([key, value]) => {
|
||||
cliArgs.push('-e', `${key}=${value}`);
|
||||
});
|
||||
|
||||
cliArgs.push('--', command);
|
||||
|
||||
if (args && args.length > 0) {
|
||||
cliArgs.push(...args);
|
||||
}
|
||||
|
||||
const respond = createCliResponder(res);
|
||||
const proc = spawn('codex', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
|
||||
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
respond(200, { success: true, output: stdout, message: `MCP server "${name}" added successfully` });
|
||||
} else {
|
||||
respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
const isMissing = error?.code === 'ENOENT';
|
||||
respond(isMissing ? 503 : 500, {
|
||||
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
|
||||
details: error.message,
|
||||
code: error.code
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/mcp/cli/remove/:name', async (req, res) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
const respond = createCliResponder(res);
|
||||
const proc = spawn('codex', ['mcp', 'remove', name], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
|
||||
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
respond(200, { success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
|
||||
} else {
|
||||
respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
const isMissing = error?.code === 'ENOENT';
|
||||
respond(isMissing ? 503 : 500, {
|
||||
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
|
||||
details: error.message,
|
||||
code: error.code
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/mcp/cli/get/:name', async (req, res) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
const respond = createCliResponder(res);
|
||||
const proc = spawn('codex', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout?.on('data', (data) => { stdout += data.toString(); });
|
||||
proc.stderr?.on('data', (data) => { stderr += data.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
respond(200, { success: true, output: stdout, server: parseCodexGetOutput(stdout) });
|
||||
} else {
|
||||
respond(404, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
const isMissing = error?.code === 'ENOENT';
|
||||
respond(isMissing ? 503 : 500, {
|
||||
error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',
|
||||
details: error.message,
|
||||
code: error.code
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/mcp/config/read', async (req, res) => {
|
||||
try {
|
||||
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
|
||||
|
||||
let configData = null;
|
||||
|
||||
try {
|
||||
const fileContent = await fs.readFile(configPath, 'utf8');
|
||||
configData = TOML.parse(fileContent);
|
||||
} catch (error) {
|
||||
// Config file doesn't exist
|
||||
}
|
||||
|
||||
if (!configData) {
|
||||
return res.json({ success: true, configPath, servers: [] }); }
|
||||
|
||||
const servers = [];
|
||||
|
||||
if (configData.mcp_servers && typeof configData.mcp_servers === 'object') {
|
||||
for (const [name, config] of Object.entries(configData.mcp_servers)) {
|
||||
servers.push({
|
||||
id: name,
|
||||
name: name,
|
||||
type: 'stdio',
|
||||
scope: 'user',
|
||||
config: {
|
||||
command: config.command || '',
|
||||
args: config.args || [],
|
||||
env: config.env || {}
|
||||
},
|
||||
raw: config
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, configPath, servers });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to read Codex configuration', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
function parseCodexListOutput(output) {
|
||||
const servers = [];
|
||||
const lines = output.split('\n').filter(line => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes(':')) {
|
||||
const colonIndex = line.indexOf(':');
|
||||
const name = line.substring(0, colonIndex).trim();
|
||||
|
||||
if (!name) continue;
|
||||
|
||||
const rest = line.substring(colonIndex + 1).trim();
|
||||
let description = rest;
|
||||
let status = 'unknown';
|
||||
|
||||
if (rest.includes('✓') || rest.includes('✗')) {
|
||||
const statusMatch = rest.match(/(.*?)\s*-\s*([✓✗].*)$/);
|
||||
if (statusMatch) {
|
||||
description = statusMatch[1].trim();
|
||||
status = statusMatch[2].includes('✓') ? 'connected' : 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
servers.push({ name, type: 'stdio', status, description });
|
||||
}
|
||||
}
|
||||
|
||||
return servers;
|
||||
}
|
||||
|
||||
function parseCodexGetOutput(output) {
|
||||
try {
|
||||
const jsonMatch = output.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
return JSON.parse(jsonMatch[0]);
|
||||
}
|
||||
|
||||
const server = { raw_output: output };
|
||||
const lines = output.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('Name:')) server.name = line.split(':')[1]?.trim();
|
||||
else if (line.includes('Type:')) server.type = line.split(':')[1]?.trim();
|
||||
else if (line.includes('Command:')) server.command = line.split(':')[1]?.trim();
|
||||
}
|
||||
|
||||
return server;
|
||||
} catch (error) {
|
||||
return { raw_output: output, parse_error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -451,55 +451,6 @@ router.post('/list', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/commands/load
|
||||
* Load a specific command file and return its content and metadata
|
||||
*/
|
||||
router.post('/load', async (req, res) => {
|
||||
try {
|
||||
const { commandPath } = req.body;
|
||||
|
||||
if (!commandPath) {
|
||||
return res.status(400).json({
|
||||
error: 'Command path is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Security: Prevent path traversal
|
||||
const resolvedPath = path.resolve(commandPath);
|
||||
if (!resolvedPath.startsWith(path.resolve(os.homedir())) &&
|
||||
!resolvedPath.includes('.claude/commands')) {
|
||||
return res.status(403).json({
|
||||
error: 'Access denied',
|
||||
message: 'Command must be in .claude/commands directory'
|
||||
});
|
||||
}
|
||||
|
||||
// Read and parse the command file
|
||||
const content = await fs.readFile(commandPath, 'utf8');
|
||||
const { data: metadata, content: commandContent } = parseFrontmatter(content);
|
||||
|
||||
res.json({
|
||||
path: commandPath,
|
||||
metadata,
|
||||
content: commandContent
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return res.status(404).json({
|
||||
error: 'Command not found',
|
||||
message: `Command file not found: ${req.body.commandPath}`
|
||||
});
|
||||
}
|
||||
|
||||
console.error('Error loading command:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to load command',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/commands/execute
|
||||
* Execute a command with argument replacement
|
||||
|
||||
@@ -2,11 +2,7 @@ import express from 'express';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import sqlite3 from 'sqlite3';
|
||||
import { open } from 'sqlite';
|
||||
import crypto from 'crypto';
|
||||
import { CURSOR_MODELS } from '../../shared/modelConstants.js';
|
||||
import { applyCustomSessionNames } from '../database/db.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -14,20 +10,20 @@ const router = express.Router();
|
||||
router.get('/config', async (req, res) => {
|
||||
try {
|
||||
const configPath = path.join(os.homedir(), '.cursor', 'cli-config.json');
|
||||
|
||||
|
||||
try {
|
||||
const configContent = await fs.readFile(configPath, 'utf8');
|
||||
const config = JSON.parse(configContent);
|
||||
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
config: config,
|
||||
path: configPath
|
||||
config,
|
||||
path: configPath,
|
||||
});
|
||||
} catch (error) {
|
||||
// Config doesn't exist or is invalid
|
||||
console.log('Cursor config not found or invalid:', error.message);
|
||||
|
||||
|
||||
// Return default config
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -35,381 +31,23 @@ router.get('/config', async (req, res) => {
|
||||
version: 1,
|
||||
model: {
|
||||
modelId: CURSOR_MODELS.DEFAULT,
|
||||
displayName: "GPT-5"
|
||||
displayName: 'GPT-5',
|
||||
},
|
||||
permissions: {
|
||||
allow: [],
|
||||
deny: []
|
||||
}
|
||||
deny: [],
|
||||
},
|
||||
},
|
||||
isDefault: true
|
||||
isDefault: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading Cursor config:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to read Cursor configuration',
|
||||
details: error.message
|
||||
res.status(500).json({
|
||||
error: 'Failed to read Cursor configuration',
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/cursor/config - Update Cursor CLI configuration
|
||||
router.post('/config', async (req, res) => {
|
||||
try {
|
||||
const { permissions, model } = req.body;
|
||||
const configPath = path.join(os.homedir(), '.cursor', 'cli-config.json');
|
||||
|
||||
// Read existing config or create default
|
||||
let config = {
|
||||
version: 1,
|
||||
editor: {
|
||||
vimMode: false
|
||||
},
|
||||
hasChangedDefaultModel: false,
|
||||
privacyCache: {
|
||||
ghostMode: false,
|
||||
privacyMode: 3,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const existing = await fs.readFile(configPath, 'utf8');
|
||||
config = JSON.parse(existing);
|
||||
} catch (error) {
|
||||
// Config doesn't exist, use defaults
|
||||
console.log('Creating new Cursor config');
|
||||
}
|
||||
|
||||
// Update permissions if provided
|
||||
if (permissions) {
|
||||
config.permissions = {
|
||||
allow: permissions.allow || [],
|
||||
deny: permissions.deny || []
|
||||
};
|
||||
}
|
||||
|
||||
// Update model if provided
|
||||
if (model) {
|
||||
config.model = model;
|
||||
config.hasChangedDefaultModel = true;
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
const configDir = path.dirname(configPath);
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
|
||||
// Write updated config
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
config: config,
|
||||
message: 'Cursor configuration updated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating Cursor config:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to update Cursor configuration',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/cursor/mcp - Read Cursor MCP servers configuration
|
||||
router.get('/mcp', async (req, res) => {
|
||||
try {
|
||||
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
|
||||
|
||||
try {
|
||||
const mcpContent = await fs.readFile(mcpPath, 'utf8');
|
||||
const mcpConfig = JSON.parse(mcpContent);
|
||||
|
||||
// Convert to UI-friendly format
|
||||
const servers = [];
|
||||
if (mcpConfig.mcpServers && typeof mcpConfig.mcpServers === 'object') {
|
||||
for (const [name, config] of Object.entries(mcpConfig.mcpServers)) {
|
||||
const server = {
|
||||
id: name,
|
||||
name: name,
|
||||
type: 'stdio',
|
||||
scope: 'cursor',
|
||||
config: {},
|
||||
raw: config
|
||||
};
|
||||
|
||||
// Determine transport type and extract config
|
||||
if (config.command) {
|
||||
server.type = 'stdio';
|
||||
server.config.command = config.command;
|
||||
server.config.args = config.args || [];
|
||||
server.config.env = config.env || {};
|
||||
} else if (config.url) {
|
||||
server.type = config.transport || 'http';
|
||||
server.config.url = config.url;
|
||||
server.config.headers = config.headers || {};
|
||||
}
|
||||
|
||||
servers.push(server);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
servers: servers,
|
||||
path: mcpPath
|
||||
});
|
||||
} catch (error) {
|
||||
// MCP config doesn't exist
|
||||
console.log('Cursor MCP config not found:', error.message);
|
||||
res.json({
|
||||
success: true,
|
||||
servers: [],
|
||||
isDefault: true
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading Cursor MCP config:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to read Cursor MCP configuration',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/cursor/sessions - Get Cursor sessions from SQLite database
|
||||
router.get('/sessions', async (req, res) => {
|
||||
try {
|
||||
const { projectPath } = req.query;
|
||||
|
||||
// Calculate cwdID hash for the project path (Cursor uses MD5 hash)
|
||||
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
|
||||
const cursorChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
|
||||
|
||||
|
||||
// Check if the directory exists
|
||||
try {
|
||||
await fs.access(cursorChatsPath);
|
||||
} catch (error) {
|
||||
// No sessions for this project
|
||||
return res.json({
|
||||
success: true,
|
||||
sessions: [],
|
||||
cwdId: cwdId,
|
||||
path: cursorChatsPath
|
||||
});
|
||||
}
|
||||
|
||||
// List all session directories
|
||||
const sessionDirs = await fs.readdir(cursorChatsPath);
|
||||
const sessions = [];
|
||||
|
||||
for (const sessionId of sessionDirs) {
|
||||
const sessionPath = path.join(cursorChatsPath, sessionId);
|
||||
const storeDbPath = path.join(sessionPath, 'store.db');
|
||||
let dbStatMtimeMs = null;
|
||||
|
||||
try {
|
||||
// Check if store.db exists
|
||||
await fs.access(storeDbPath);
|
||||
|
||||
// Capture store.db mtime as a reliable fallback timestamp (last activity)
|
||||
try {
|
||||
const stat = await fs.stat(storeDbPath);
|
||||
dbStatMtimeMs = stat.mtimeMs;
|
||||
} catch (_) {}
|
||||
|
||||
// Open SQLite database
|
||||
const db = await open({
|
||||
filename: storeDbPath,
|
||||
driver: sqlite3.Database,
|
||||
mode: sqlite3.OPEN_READONLY
|
||||
});
|
||||
|
||||
// Get metadata from meta table
|
||||
const metaRows = await db.all(`
|
||||
SELECT key, value FROM meta
|
||||
`);
|
||||
|
||||
let sessionData = {
|
||||
id: sessionId,
|
||||
name: 'Untitled Session',
|
||||
createdAt: null,
|
||||
mode: null,
|
||||
projectPath: projectPath,
|
||||
lastMessage: null,
|
||||
messageCount: 0
|
||||
};
|
||||
|
||||
// Parse meta table entries
|
||||
for (const row of metaRows) {
|
||||
if (row.value) {
|
||||
try {
|
||||
// Try to decode as hex-encoded JSON
|
||||
const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/);
|
||||
if (hexMatch) {
|
||||
const jsonStr = Buffer.from(row.value, 'hex').toString('utf8');
|
||||
const data = JSON.parse(jsonStr);
|
||||
|
||||
if (row.key === 'agent') {
|
||||
sessionData.name = data.name || sessionData.name;
|
||||
// Normalize createdAt to ISO string in milliseconds
|
||||
let createdAt = data.createdAt;
|
||||
if (typeof createdAt === 'number') {
|
||||
if (createdAt < 1e12) {
|
||||
createdAt = createdAt * 1000; // seconds -> ms
|
||||
}
|
||||
sessionData.createdAt = new Date(createdAt).toISOString();
|
||||
} else if (typeof createdAt === 'string') {
|
||||
const n = Number(createdAt);
|
||||
if (!Number.isNaN(n)) {
|
||||
const ms = n < 1e12 ? n * 1000 : n;
|
||||
sessionData.createdAt = new Date(ms).toISOString();
|
||||
} else {
|
||||
// Assume it's already an ISO/date string
|
||||
const d = new Date(createdAt);
|
||||
sessionData.createdAt = isNaN(d.getTime()) ? null : d.toISOString();
|
||||
}
|
||||
} else {
|
||||
sessionData.createdAt = sessionData.createdAt || null;
|
||||
}
|
||||
sessionData.mode = data.mode;
|
||||
sessionData.agentId = data.agentId;
|
||||
sessionData.latestRootBlobId = data.latestRootBlobId;
|
||||
}
|
||||
} else {
|
||||
// If not hex, use raw value for simple keys
|
||||
if (row.key === 'name') {
|
||||
sessionData.name = row.value.toString();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`Could not parse meta value for key ${row.key}:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get message count from JSON blobs only (actual messages, not DAG structure)
|
||||
try {
|
||||
const blobCount = await db.get(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM blobs
|
||||
WHERE substr(data, 1, 1) = X'7B'
|
||||
`);
|
||||
sessionData.messageCount = blobCount.count;
|
||||
|
||||
// Get the most recent JSON blob for preview (actual message, not DAG structure)
|
||||
const lastBlob = await db.get(`
|
||||
SELECT data FROM blobs
|
||||
WHERE substr(data, 1, 1) = X'7B'
|
||||
ORDER BY rowid DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
if (lastBlob && lastBlob.data) {
|
||||
try {
|
||||
// Try to extract readable preview from blob (may contain binary with embedded JSON)
|
||||
const raw = lastBlob.data.toString('utf8');
|
||||
let preview = '';
|
||||
// Attempt direct JSON parse
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed?.content) {
|
||||
if (Array.isArray(parsed.content)) {
|
||||
const firstText = parsed.content.find(p => p?.type === 'text' && p.text)?.text || '';
|
||||
preview = firstText;
|
||||
} else if (typeof parsed.content === 'string') {
|
||||
preview = parsed.content;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
if (!preview) {
|
||||
// Strip non-printable and try to find JSON chunk
|
||||
const cleaned = raw.replace(/[^\x09\x0A\x0D\x20-\x7E]/g, '');
|
||||
const s = cleaned;
|
||||
const start = s.indexOf('{');
|
||||
const end = s.lastIndexOf('}');
|
||||
if (start !== -1 && end > start) {
|
||||
const jsonStr = s.slice(start, end + 1);
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
if (parsed?.content) {
|
||||
if (Array.isArray(parsed.content)) {
|
||||
const firstText = parsed.content.find(p => p?.type === 'text' && p.text)?.text || '';
|
||||
preview = firstText;
|
||||
} else if (typeof parsed.content === 'string') {
|
||||
preview = parsed.content;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
preview = s;
|
||||
}
|
||||
} else {
|
||||
preview = s;
|
||||
}
|
||||
}
|
||||
if (preview && preview.length > 0) {
|
||||
sessionData.lastMessage = preview.substring(0, 100) + (preview.length > 100 ? '...' : '');
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Could not parse blob data:', e.message);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Could not read blobs:', e.message);
|
||||
}
|
||||
|
||||
await db.close();
|
||||
|
||||
// Finalize createdAt: use parsed meta value when valid, else fall back to store.db mtime
|
||||
if (!sessionData.createdAt) {
|
||||
if (dbStatMtimeMs && Number.isFinite(dbStatMtimeMs)) {
|
||||
sessionData.createdAt = new Date(dbStatMtimeMs).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
sessions.push(sessionData);
|
||||
|
||||
} catch (error) {
|
||||
console.log(`Could not read session ${sessionId}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: ensure createdAt is a valid ISO string (use session directory mtime as last resort)
|
||||
for (const s of sessions) {
|
||||
if (!s.createdAt) {
|
||||
try {
|
||||
const sessionDir = path.join(cursorChatsPath, s.id);
|
||||
const st = await fs.stat(sessionDir);
|
||||
s.createdAt = new Date(st.mtimeMs).toISOString();
|
||||
} catch {
|
||||
s.createdAt = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort sessions by creation date (newest first)
|
||||
sessions.sort((a, b) => {
|
||||
if (!a.createdAt) return 1;
|
||||
if (!b.createdAt) return -1;
|
||||
return new Date(b.createdAt) - new Date(a.createdAt);
|
||||
});
|
||||
|
||||
applyCustomSessionNames(sessions, 'cursor');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
sessions: sessions,
|
||||
cwdId: cwdId,
|
||||
path: cursorChatsPath
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error reading Cursor sessions:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to read Cursor sessions',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
export default router;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { detectTaskMasterMCPServer, getAllMCPServers } from '../utils/mcp-detector.js';
|
||||
import { detectTaskMasterMCPServer } from '../utils/mcp-detector.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -28,21 +28,4 @@ router.get('/taskmaster-server', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/mcp-utils/all-servers
|
||||
* Get all configured MCP servers
|
||||
*/
|
||||
router.get('/all-servers', async (req, res) => {
|
||||
try {
|
||||
const result = await getAllMCPServers();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('MCP servers detection error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to get MCP servers',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
export default router;
|
||||
|
||||
@@ -1,552 +0,0 @@
|
||||
import express from 'express';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
const router = express.Router();
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Claude CLI command routes
|
||||
|
||||
// GET /api/mcp/cli/list - List MCP servers using Claude CLI
|
||||
router.get('/cli/list', async (req, res) => {
|
||||
try {
|
||||
console.log('📋 Listing MCP servers using Claude CLI');
|
||||
|
||||
const { spawn } = await import('child_process');
|
||||
const { promisify } = await import('util');
|
||||
const exec = promisify(spawn);
|
||||
|
||||
const process = spawn('claude', ['mcp', 'list'], {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
process.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
process.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
process.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
res.json({ success: true, output: stdout, servers: parseClaudeListOutput(stdout) });
|
||||
} else {
|
||||
console.error('Claude CLI error:', stderr);
|
||||
res.status(500).json({ error: 'Claude CLI command failed', details: stderr });
|
||||
}
|
||||
});
|
||||
|
||||
process.on('error', (error) => {
|
||||
console.error('Error running Claude CLI:', error);
|
||||
res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error listing MCP servers via CLI:', error);
|
||||
res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/mcp/cli/add - Add MCP server using Claude CLI
|
||||
router.post('/cli/add', async (req, res) => {
|
||||
try {
|
||||
const { name, type = 'stdio', command, args = [], url, headers = {}, env = {}, scope = 'user', projectPath } = req.body;
|
||||
|
||||
console.log(`➕ Adding MCP server using Claude CLI (${scope} scope):`, name);
|
||||
|
||||
const { spawn } = await import('child_process');
|
||||
|
||||
let cliArgs = ['mcp', 'add'];
|
||||
|
||||
// Add scope flag
|
||||
cliArgs.push('--scope', scope);
|
||||
|
||||
if (type === 'http') {
|
||||
cliArgs.push('--transport', 'http', name, url);
|
||||
// Add headers if provided
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
cliArgs.push('--header', `${key}: ${value}`);
|
||||
});
|
||||
} else if (type === 'sse') {
|
||||
cliArgs.push('--transport', 'sse', name, url);
|
||||
// Add headers if provided
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
cliArgs.push('--header', `${key}: ${value}`);
|
||||
});
|
||||
} else {
|
||||
// stdio (default): claude mcp add --scope user <name> <command> [args...]
|
||||
cliArgs.push(name);
|
||||
// Add environment variables
|
||||
Object.entries(env).forEach(([key, value]) => {
|
||||
cliArgs.push('-e', `${key}=${value}`);
|
||||
});
|
||||
cliArgs.push(command);
|
||||
if (args && args.length > 0) {
|
||||
cliArgs.push(...args);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' '));
|
||||
|
||||
// For local scope, we need to run the command in the project directory
|
||||
const spawnOptions = {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
};
|
||||
|
||||
if (scope === 'local' && projectPath) {
|
||||
spawnOptions.cwd = projectPath;
|
||||
console.log('📁 Running in project directory:', projectPath);
|
||||
}
|
||||
|
||||
const process = spawn('claude', cliArgs, spawnOptions);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
process.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
process.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
process.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully` });
|
||||
} else {
|
||||
console.error('Claude CLI error:', stderr);
|
||||
res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
|
||||
}
|
||||
});
|
||||
|
||||
process.on('error', (error) => {
|
||||
console.error('Error running Claude CLI:', error);
|
||||
res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error adding MCP server via CLI:', error);
|
||||
res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/mcp/cli/add-json - Add MCP server using JSON format
|
||||
router.post('/cli/add-json', async (req, res) => {
|
||||
try {
|
||||
const { name, jsonConfig, scope = 'user', projectPath } = req.body;
|
||||
|
||||
console.log('➕ Adding MCP server using JSON format:', name);
|
||||
|
||||
// Validate and parse JSON config
|
||||
let parsedConfig;
|
||||
try {
|
||||
parsedConfig = typeof jsonConfig === 'string' ? JSON.parse(jsonConfig) : jsonConfig;
|
||||
} catch (parseError) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid JSON configuration',
|
||||
details: parseError.message
|
||||
});
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!parsedConfig.type) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid configuration',
|
||||
details: 'Missing required field: type'
|
||||
});
|
||||
}
|
||||
|
||||
if (parsedConfig.type === 'stdio' && !parsedConfig.command) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid configuration',
|
||||
details: 'stdio type requires a command field'
|
||||
});
|
||||
}
|
||||
|
||||
if ((parsedConfig.type === 'http' || parsedConfig.type === 'sse') && !parsedConfig.url) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid configuration',
|
||||
details: `${parsedConfig.type} type requires a url field`
|
||||
});
|
||||
}
|
||||
|
||||
const { spawn } = await import('child_process');
|
||||
|
||||
// Build the command: claude mcp add-json --scope <scope> <name> '<json>'
|
||||
const cliArgs = ['mcp', 'add-json', '--scope', scope, name];
|
||||
|
||||
// Add the JSON config as a properly formatted string
|
||||
const jsonString = JSON.stringify(parsedConfig);
|
||||
cliArgs.push(jsonString);
|
||||
|
||||
console.log('🔧 Running Claude CLI command:', 'claude', cliArgs[0], cliArgs[1], cliArgs[2], cliArgs[3], cliArgs[4], jsonString);
|
||||
|
||||
// For local scope, we need to run the command in the project directory
|
||||
const spawnOptions = {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
};
|
||||
|
||||
if (scope === 'local' && projectPath) {
|
||||
spawnOptions.cwd = projectPath;
|
||||
console.log('📁 Running in project directory:', projectPath);
|
||||
}
|
||||
|
||||
const process = spawn('claude', cliArgs, spawnOptions);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
process.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
process.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
process.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully via JSON` });
|
||||
} else {
|
||||
console.error('Claude CLI error:', stderr);
|
||||
res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
|
||||
}
|
||||
});
|
||||
|
||||
process.on('error', (error) => {
|
||||
console.error('Error running Claude CLI:', error);
|
||||
res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error adding MCP server via JSON:', error);
|
||||
res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/mcp/cli/remove/:name - Remove MCP server using Claude CLI
|
||||
router.delete('/cli/remove/:name', async (req, res) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const { scope } = req.query; // Get scope from query params
|
||||
|
||||
// Handle the ID format (remove scope prefix if present)
|
||||
let actualName = name;
|
||||
let actualScope = scope;
|
||||
|
||||
// If the name includes a scope prefix like "local:test", extract it
|
||||
if (name.includes(':')) {
|
||||
const [prefix, serverName] = name.split(':');
|
||||
actualName = serverName;
|
||||
actualScope = actualScope || prefix; // Use prefix as scope if not provided in query
|
||||
}
|
||||
|
||||
console.log('🗑️ Removing MCP server using Claude CLI:', actualName, 'scope:', actualScope);
|
||||
|
||||
const { spawn } = await import('child_process');
|
||||
|
||||
// Build command args based on scope
|
||||
let cliArgs = ['mcp', 'remove'];
|
||||
|
||||
// Add scope flag if it's local scope
|
||||
if (actualScope === 'local') {
|
||||
cliArgs.push('--scope', 'local');
|
||||
} else if (actualScope === 'user' || !actualScope) {
|
||||
// User scope is default, but we can be explicit
|
||||
cliArgs.push('--scope', 'user');
|
||||
}
|
||||
|
||||
cliArgs.push(actualName);
|
||||
|
||||
console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' '));
|
||||
|
||||
const process = spawn('claude', cliArgs, {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
process.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
process.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
process.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
res.json({ success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
|
||||
} else {
|
||||
console.error('Claude CLI error:', stderr);
|
||||
res.status(400).json({ error: 'Claude CLI command failed', details: stderr });
|
||||
}
|
||||
});
|
||||
|
||||
process.on('error', (error) => {
|
||||
console.error('Error running Claude CLI:', error);
|
||||
res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error removing MCP server via CLI:', error);
|
||||
res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/mcp/cli/get/:name - Get MCP server details using Claude CLI
|
||||
router.get('/cli/get/:name', async (req, res) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
console.log('📄 Getting MCP server details using Claude CLI:', name);
|
||||
|
||||
const { spawn } = await import('child_process');
|
||||
|
||||
const process = spawn('claude', ['mcp', 'get', name], {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
process.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
process.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
process.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
res.json({ success: true, output: stdout, server: parseClaudeGetOutput(stdout) });
|
||||
} else {
|
||||
console.error('Claude CLI error:', stderr);
|
||||
res.status(404).json({ error: 'Claude CLI command failed', details: stderr });
|
||||
}
|
||||
});
|
||||
|
||||
process.on('error', (error) => {
|
||||
console.error('Error running Claude CLI:', error);
|
||||
res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting MCP server details via CLI:', error);
|
||||
res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/mcp/config/read - Read MCP servers directly from Claude config files
|
||||
router.get('/config/read', async (req, res) => {
|
||||
try {
|
||||
console.log('📖 Reading MCP servers from Claude config files');
|
||||
|
||||
const homeDir = os.homedir();
|
||||
const configPaths = [
|
||||
path.join(homeDir, '.claude.json'),
|
||||
path.join(homeDir, '.claude', 'settings.json')
|
||||
];
|
||||
|
||||
let configData = null;
|
||||
let configPath = null;
|
||||
|
||||
// Try to read from either config file
|
||||
for (const filepath of configPaths) {
|
||||
try {
|
||||
const fileContent = await fs.readFile(filepath, 'utf8');
|
||||
configData = JSON.parse(fileContent);
|
||||
configPath = filepath;
|
||||
console.log(`✅ Found Claude config at: ${filepath}`);
|
||||
break;
|
||||
} catch (error) {
|
||||
// File doesn't exist or is not valid JSON, try next
|
||||
console.log(`ℹ️ Config not found or invalid at: ${filepath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!configData) {
|
||||
return res.json({
|
||||
success: false,
|
||||
message: 'No Claude configuration file found',
|
||||
servers: []
|
||||
});
|
||||
}
|
||||
|
||||
// Extract MCP servers from the config
|
||||
const servers = [];
|
||||
|
||||
// Check for user-scoped MCP servers (at root level)
|
||||
if (configData.mcpServers && typeof configData.mcpServers === 'object' && Object.keys(configData.mcpServers).length > 0) {
|
||||
console.log('🔍 Found user-scoped MCP servers:', Object.keys(configData.mcpServers));
|
||||
for (const [name, config] of Object.entries(configData.mcpServers)) {
|
||||
const server = {
|
||||
id: name,
|
||||
name: name,
|
||||
type: 'stdio', // Default type
|
||||
scope: 'user', // User scope - available across all projects
|
||||
config: {},
|
||||
raw: config // Include raw config for full details
|
||||
};
|
||||
|
||||
// Determine transport type and extract config
|
||||
if (config.command) {
|
||||
server.type = 'stdio';
|
||||
server.config.command = config.command;
|
||||
server.config.args = config.args || [];
|
||||
server.config.env = config.env || {};
|
||||
} else if (config.url) {
|
||||
server.type = config.transport || 'http';
|
||||
server.config.url = config.url;
|
||||
server.config.headers = config.headers || {};
|
||||
}
|
||||
|
||||
servers.push(server);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for local-scoped MCP servers (project-specific)
|
||||
const currentProjectPath = process.cwd();
|
||||
|
||||
// Check under 'projects' key
|
||||
if (configData.projects && configData.projects[currentProjectPath]) {
|
||||
const projectConfig = configData.projects[currentProjectPath];
|
||||
if (projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object' && Object.keys(projectConfig.mcpServers).length > 0) {
|
||||
console.log(`🔍 Found local-scoped MCP servers for ${currentProjectPath}:`, Object.keys(projectConfig.mcpServers));
|
||||
for (const [name, config] of Object.entries(projectConfig.mcpServers)) {
|
||||
const server = {
|
||||
id: `local:${name}`, // Prefix with scope for uniqueness
|
||||
name: name, // Keep original name
|
||||
type: 'stdio', // Default type
|
||||
scope: 'local', // Local scope - only for this project
|
||||
projectPath: currentProjectPath,
|
||||
config: {},
|
||||
raw: config // Include raw config for full details
|
||||
};
|
||||
|
||||
// Determine transport type and extract config
|
||||
if (config.command) {
|
||||
server.type = 'stdio';
|
||||
server.config.command = config.command;
|
||||
server.config.args = config.args || [];
|
||||
server.config.env = config.env || {};
|
||||
} else if (config.url) {
|
||||
server.type = config.transport || 'http';
|
||||
server.config.url = config.url;
|
||||
server.config.headers = config.headers || {};
|
||||
}
|
||||
|
||||
servers.push(server);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📋 Found ${servers.length} MCP servers in config`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
configPath: configPath,
|
||||
servers: servers
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error reading Claude config:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to read Claude configuration',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Helper functions to parse Claude CLI output
|
||||
function parseClaudeListOutput(output) {
|
||||
const servers = [];
|
||||
const lines = output.split('\n').filter(line => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip the header line
|
||||
if (line.includes('Checking MCP server health')) continue;
|
||||
|
||||
// Parse lines like "test: test test - ✗ Failed to connect"
|
||||
// or "server-name: command or description - ✓ Connected"
|
||||
if (line.includes(':')) {
|
||||
const colonIndex = line.indexOf(':');
|
||||
const name = line.substring(0, colonIndex).trim();
|
||||
|
||||
// Skip empty names
|
||||
if (!name) continue;
|
||||
|
||||
// Extract the rest after the name
|
||||
const rest = line.substring(colonIndex + 1).trim();
|
||||
|
||||
// Try to extract description and status
|
||||
let description = rest;
|
||||
let status = 'unknown';
|
||||
let type = 'stdio'; // default type
|
||||
|
||||
// Check for status indicators
|
||||
if (rest.includes('✓') || rest.includes('✗')) {
|
||||
const statusMatch = rest.match(/(.*?)\s*-\s*([✓✗].*)$/);
|
||||
if (statusMatch) {
|
||||
description = statusMatch[1].trim();
|
||||
status = statusMatch[2].includes('✓') ? 'connected' : 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
// Try to determine type from description
|
||||
if (description.startsWith('http://') || description.startsWith('https://')) {
|
||||
type = 'http';
|
||||
}
|
||||
|
||||
servers.push({
|
||||
name,
|
||||
type,
|
||||
status: status || 'active',
|
||||
description
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🔍 Parsed Claude CLI servers:', servers);
|
||||
return servers;
|
||||
}
|
||||
|
||||
function parseClaudeGetOutput(output) {
|
||||
// Parse the output from 'claude mcp get <name>' command
|
||||
// This is a simple parser - might need adjustment based on actual output format
|
||||
try {
|
||||
// Try to extract JSON if present
|
||||
const jsonMatch = output.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
return JSON.parse(jsonMatch[0]);
|
||||
}
|
||||
|
||||
// Otherwise, parse as text
|
||||
const server = { raw_output: output };
|
||||
const lines = output.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('Name:')) {
|
||||
server.name = line.split(':')[1]?.trim();
|
||||
} else if (line.includes('Type:')) {
|
||||
server.type = line.split(':')[1]?.trim();
|
||||
} else if (line.includes('Command:')) {
|
||||
server.command = line.split(':')[1]?.trim();
|
||||
} else if (line.includes('URL:')) {
|
||||
server.url = line.split(':')[1]?.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return server;
|
||||
} catch (error) {
|
||||
return { raw_output: output, parse_error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -13,16 +13,10 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import { spawn } from 'child_process';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
import os from 'os';
|
||||
import { extractProjectDirectory } from '../projects.js';
|
||||
import { detectTaskMasterMCPServer } from '../utils/mcp-detector.js';
|
||||
import { broadcastTaskMasterProjectUpdate, broadcastTaskMasterTasksUpdate } from '../utils/taskmaster-websocket.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
@@ -100,140 +94,6 @@ async function checkTaskMasterInstallation() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect .taskmaster folder presence in a given project directory
|
||||
* @param {string} projectPath - Absolute path to project directory
|
||||
* @returns {Promise<Object>} Detection result with status and metadata
|
||||
*/
|
||||
async function detectTaskMasterFolder(projectPath) {
|
||||
try {
|
||||
const taskMasterPath = path.join(projectPath, '.taskmaster');
|
||||
|
||||
// Check if .taskmaster directory exists
|
||||
try {
|
||||
const stats = await fsPromises.stat(taskMasterPath);
|
||||
if (!stats.isDirectory()) {
|
||||
return {
|
||||
hasTaskmaster: false,
|
||||
reason: '.taskmaster exists but is not a directory'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return {
|
||||
hasTaskmaster: false,
|
||||
reason: '.taskmaster directory not found'
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Check for key TaskMaster files
|
||||
const keyFiles = [
|
||||
'tasks/tasks.json',
|
||||
'config.json'
|
||||
];
|
||||
|
||||
const fileStatus = {};
|
||||
let hasEssentialFiles = true;
|
||||
|
||||
for (const file of keyFiles) {
|
||||
const filePath = path.join(taskMasterPath, file);
|
||||
try {
|
||||
await fsPromises.access(filePath, fs.constants.R_OK);
|
||||
fileStatus[file] = true;
|
||||
} catch (error) {
|
||||
fileStatus[file] = false;
|
||||
if (file === 'tasks/tasks.json') {
|
||||
hasEssentialFiles = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse tasks.json if it exists for metadata
|
||||
let taskMetadata = null;
|
||||
if (fileStatus['tasks/tasks.json']) {
|
||||
try {
|
||||
const tasksPath = path.join(taskMasterPath, 'tasks/tasks.json');
|
||||
const tasksContent = await fsPromises.readFile(tasksPath, 'utf8');
|
||||
const tasksData = JSON.parse(tasksContent);
|
||||
|
||||
// Handle both tagged and legacy formats
|
||||
let tasks = [];
|
||||
if (tasksData.tasks) {
|
||||
// Legacy format
|
||||
tasks = tasksData.tasks;
|
||||
} else {
|
||||
// Tagged format - get tasks from all tags
|
||||
Object.values(tasksData).forEach(tagData => {
|
||||
if (tagData.tasks) {
|
||||
tasks = tasks.concat(tagData.tasks);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate task statistics
|
||||
const stats = tasks.reduce((acc, task) => {
|
||||
acc.total++;
|
||||
acc[task.status] = (acc[task.status] || 0) + 1;
|
||||
|
||||
// Count subtasks
|
||||
if (task.subtasks) {
|
||||
task.subtasks.forEach(subtask => {
|
||||
acc.subtotalTasks++;
|
||||
acc.subtasks = acc.subtasks || {};
|
||||
acc.subtasks[subtask.status] = (acc.subtasks[subtask.status] || 0) + 1;
|
||||
});
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {
|
||||
total: 0,
|
||||
subtotalTasks: 0,
|
||||
pending: 0,
|
||||
'in-progress': 0,
|
||||
done: 0,
|
||||
review: 0,
|
||||
deferred: 0,
|
||||
cancelled: 0,
|
||||
subtasks: {}
|
||||
});
|
||||
|
||||
taskMetadata = {
|
||||
taskCount: stats.total,
|
||||
subtaskCount: stats.subtotalTasks,
|
||||
completed: stats.done || 0,
|
||||
pending: stats.pending || 0,
|
||||
inProgress: stats['in-progress'] || 0,
|
||||
review: stats.review || 0,
|
||||
completionPercentage: stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0,
|
||||
lastModified: (await fsPromises.stat(tasksPath)).mtime.toISOString()
|
||||
};
|
||||
} catch (parseError) {
|
||||
console.warn('Failed to parse tasks.json:', parseError.message);
|
||||
taskMetadata = { error: 'Failed to parse tasks.json' };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasTaskmaster: true,
|
||||
hasEssentialFiles,
|
||||
files: fileStatus,
|
||||
metadata: taskMetadata,
|
||||
path: taskMasterPath
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error detecting TaskMaster folder:', error);
|
||||
return {
|
||||
hasTaskmaster: false,
|
||||
reason: `Error checking directory: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// MCP detection is now handled by the centralized utility
|
||||
|
||||
// API Routes
|
||||
|
||||
/**
|
||||
@@ -271,298 +131,6 @@ router.get('/installation-status', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/taskmaster/detect/:projectName
|
||||
* Detect TaskMaster configuration for a specific project
|
||||
*/
|
||||
router.get('/detect/:projectName', async (req, res) => {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
|
||||
// Use the existing extractProjectDirectory function to get actual project path
|
||||
let projectPath;
|
||||
try {
|
||||
projectPath = await extractProjectDirectory(projectName);
|
||||
} catch (error) {
|
||||
console.error('Error extracting project directory:', error);
|
||||
return res.status(404).json({
|
||||
error: 'Project path not found',
|
||||
projectName,
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the project path exists
|
||||
try {
|
||||
await fsPromises.access(projectPath, fs.constants.R_OK);
|
||||
} catch (error) {
|
||||
return res.status(404).json({
|
||||
error: 'Project path not accessible',
|
||||
projectPath,
|
||||
projectName,
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
// Run detection in parallel
|
||||
const [taskMasterResult, mcpResult] = await Promise.all([
|
||||
detectTaskMasterFolder(projectPath),
|
||||
detectTaskMasterMCPServer()
|
||||
]);
|
||||
|
||||
// Determine overall status
|
||||
let status = 'not-configured';
|
||||
if (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles) {
|
||||
if (mcpResult.hasMCPServer && mcpResult.isConfigured) {
|
||||
status = 'fully-configured';
|
||||
} else {
|
||||
status = 'taskmaster-only';
|
||||
}
|
||||
} else if (mcpResult.hasMCPServer && mcpResult.isConfigured) {
|
||||
status = 'mcp-only';
|
||||
}
|
||||
|
||||
const responseData = {
|
||||
projectName,
|
||||
projectPath,
|
||||
status,
|
||||
taskmaster: taskMasterResult,
|
||||
mcp: mcpResult,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
res.json(responseData);
|
||||
|
||||
} catch (error) {
|
||||
console.error('TaskMaster detection error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to detect TaskMaster configuration',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/taskmaster/detect-all
|
||||
* Detect TaskMaster configuration for all known projects
|
||||
* This endpoint works with the existing projects system
|
||||
*/
|
||||
router.get('/detect-all', async (req, res) => {
|
||||
try {
|
||||
// Import getProjects from the projects module
|
||||
const { getProjects } = await import('../projects.js');
|
||||
const projects = await getProjects();
|
||||
|
||||
// Run detection for all projects in parallel
|
||||
const detectionPromises = projects.map(async (project) => {
|
||||
try {
|
||||
// Use the project's fullPath if available, otherwise extract the directory
|
||||
let projectPath;
|
||||
if (project.fullPath) {
|
||||
projectPath = project.fullPath;
|
||||
} else {
|
||||
try {
|
||||
projectPath = await extractProjectDirectory(project.name);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to extract project directory: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const [taskMasterResult, mcpResult] = await Promise.all([
|
||||
detectTaskMasterFolder(projectPath),
|
||||
detectTaskMasterMCPServer()
|
||||
]);
|
||||
|
||||
// Determine status
|
||||
let status = 'not-configured';
|
||||
if (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles) {
|
||||
if (mcpResult.hasMCPServer && mcpResult.isConfigured) {
|
||||
status = 'fully-configured';
|
||||
} else {
|
||||
status = 'taskmaster-only';
|
||||
}
|
||||
} else if (mcpResult.hasMCPServer && mcpResult.isConfigured) {
|
||||
status = 'mcp-only';
|
||||
}
|
||||
|
||||
return {
|
||||
projectName: project.name,
|
||||
displayName: project.displayName,
|
||||
projectPath,
|
||||
status,
|
||||
taskmaster: taskMasterResult,
|
||||
mcp: mcpResult
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
projectName: project.name,
|
||||
displayName: project.displayName,
|
||||
status: 'error',
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(detectionPromises);
|
||||
|
||||
res.json({
|
||||
projects: results,
|
||||
summary: {
|
||||
total: results.length,
|
||||
fullyConfigured: results.filter(p => p.status === 'fully-configured').length,
|
||||
taskmasterOnly: results.filter(p => p.status === 'taskmaster-only').length,
|
||||
mcpOnly: results.filter(p => p.status === 'mcp-only').length,
|
||||
notConfigured: results.filter(p => p.status === 'not-configured').length,
|
||||
errors: results.filter(p => p.status === 'error').length
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Bulk TaskMaster detection error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to detect TaskMaster configuration for projects',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/taskmaster/initialize/:projectName
|
||||
* Initialize TaskMaster in a project (placeholder for future CLI integration)
|
||||
*/
|
||||
router.post('/initialize/:projectName', async (req, res) => {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
const { rules } = req.body; // Optional rule profiles
|
||||
|
||||
// This will be implemented in a later subtask with CLI integration
|
||||
res.status(501).json({
|
||||
error: 'TaskMaster initialization not yet implemented',
|
||||
message: 'This endpoint will execute task-master init via CLI in a future update',
|
||||
projectName,
|
||||
rules
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('TaskMaster initialization error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to initialize TaskMaster',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/taskmaster/next/:projectName
|
||||
* Get the next recommended task using task-master CLI
|
||||
*/
|
||||
router.get('/next/:projectName', async (req, res) => {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
|
||||
// Get project path
|
||||
let projectPath;
|
||||
try {
|
||||
projectPath = await extractProjectDirectory(projectName);
|
||||
} catch (error) {
|
||||
return res.status(404).json({
|
||||
error: 'Project not found',
|
||||
message: `Project "${projectName}" does not exist`
|
||||
});
|
||||
}
|
||||
|
||||
// Try to execute task-master next command
|
||||
try {
|
||||
const { spawn } = await import('child_process');
|
||||
|
||||
const nextTaskCommand = spawn('task-master', ['next'], {
|
||||
cwd: projectPath,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
nextTaskCommand.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
nextTaskCommand.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
nextTaskCommand.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`task-master next failed with code ${code}: ${stderr}`));
|
||||
}
|
||||
});
|
||||
|
||||
nextTaskCommand.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
// Parse the output - task-master next usually returns JSON
|
||||
let nextTaskData = null;
|
||||
if (stdout.trim()) {
|
||||
try {
|
||||
nextTaskData = JSON.parse(stdout);
|
||||
} catch (parseError) {
|
||||
// If not JSON, treat as plain text
|
||||
nextTaskData = { message: stdout.trim() };
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
projectName,
|
||||
projectPath,
|
||||
nextTask: nextTaskData,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
} catch (cliError) {
|
||||
console.warn('Failed to execute task-master CLI:', cliError.message);
|
||||
|
||||
// Fallback to loading tasks and finding next one locally
|
||||
// Use localhost to bypass proxy for internal server-to-server calls
|
||||
const tasksResponse = await fetch(`http://localhost:${process.env.SERVER_PORT || process.env.PORT || '3001'}/api/taskmaster/tasks/${encodeURIComponent(projectName)}`, {
|
||||
headers: {
|
||||
'Authorization': req.headers.authorization
|
||||
}
|
||||
});
|
||||
|
||||
if (tasksResponse.ok) {
|
||||
const tasksData = await tasksResponse.json();
|
||||
const nextTask = tasksData.tasks?.find(task =>
|
||||
task.status === 'pending' || task.status === 'in-progress'
|
||||
) || null;
|
||||
|
||||
res.json({
|
||||
projectName,
|
||||
projectPath,
|
||||
nextTask,
|
||||
fallback: true,
|
||||
message: 'Used fallback method (CLI not available)',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
throw new Error('Failed to load tasks via fallback method');
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('TaskMaster next task error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to get next task',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/taskmaster/tasks/:projectName
|
||||
* Load actual tasks from .taskmaster/tasks/tasks.json
|
||||
@@ -904,66 +472,6 @@ router.get('/prd/:projectName/:fileName', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/taskmaster/prd/:projectName/:fileName
|
||||
* Delete a specific PRD file
|
||||
*/
|
||||
router.delete('/prd/:projectName/:fileName', async (req, res) => {
|
||||
try {
|
||||
const { projectName, fileName } = req.params;
|
||||
|
||||
// Get project path
|
||||
let projectPath;
|
||||
try {
|
||||
projectPath = await extractProjectDirectory(projectName);
|
||||
} catch (error) {
|
||||
return res.status(404).json({
|
||||
error: 'Project not found',
|
||||
message: `Project "${projectName}" does not exist`
|
||||
});
|
||||
}
|
||||
|
||||
const filePath = path.join(projectPath, '.taskmaster', 'docs', fileName);
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await fsPromises.access(filePath, fs.constants.F_OK);
|
||||
} catch (error) {
|
||||
return res.status(404).json({
|
||||
error: 'PRD file not found',
|
||||
message: `File "${fileName}" does not exist`
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the file
|
||||
try {
|
||||
await fsPromises.unlink(filePath);
|
||||
|
||||
res.json({
|
||||
projectName,
|
||||
projectPath,
|
||||
fileName,
|
||||
message: 'PRD file deleted successfully',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
} catch (deleteError) {
|
||||
console.error('Failed to delete PRD file:', deleteError);
|
||||
return res.status(500).json({
|
||||
error: 'Failed to delete PRD file',
|
||||
message: deleteError.message
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('PRD delete error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to delete PRD file',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/taskmaster/init/:projectName
|
||||
* Initialize TaskMaster in a project
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
FetchHistoryResult,
|
||||
LLMProvider,
|
||||
McpScope,
|
||||
McpTransport,
|
||||
NormalizedMessage,
|
||||
ProviderAuthStatus,
|
||||
ProviderMcpServer,
|
||||
@@ -46,15 +45,4 @@ export interface IProviderMcp {
|
||||
removeServer(
|
||||
input: { name: string; scope?: McpScope; workspacePath?: string },
|
||||
): Promise<{ removed: boolean; provider: LLMProvider; name: string; scope: McpScope }>;
|
||||
runServer(
|
||||
input: { name: string; scope?: McpScope; workspacePath?: string },
|
||||
): Promise<{
|
||||
provider: LLMProvider;
|
||||
name: string;
|
||||
scope: McpScope;
|
||||
transport: McpTransport;
|
||||
reachable: boolean;
|
||||
statusCode?: number;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -145,54 +145,3 @@ export async function detectTaskMasterMCPServer() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all configured MCP servers (not just TaskMaster)
|
||||
* @returns {Promise<Object>} All MCP servers configuration
|
||||
*/
|
||||
export async function getAllMCPServers() {
|
||||
try {
|
||||
const homeDir = os.homedir();
|
||||
const configPaths = [
|
||||
path.join(homeDir, '.claude.json'),
|
||||
path.join(homeDir, '.claude', 'settings.json')
|
||||
];
|
||||
|
||||
let configData = null;
|
||||
let configPath = null;
|
||||
|
||||
// Try to read from either config file
|
||||
for (const filepath of configPaths) {
|
||||
try {
|
||||
const fileContent = await fsPromises.readFile(filepath, 'utf8');
|
||||
configData = JSON.parse(fileContent);
|
||||
configPath = filepath;
|
||||
break;
|
||||
} catch (error) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!configData) {
|
||||
return {
|
||||
hasConfig: false,
|
||||
servers: {},
|
||||
projectServers: {}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hasConfig: true,
|
||||
configPath,
|
||||
servers: configData.mcpServers || {},
|
||||
projectServers: configData.projects || {}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting all MCP servers:', error);
|
||||
return {
|
||||
hasConfig: false,
|
||||
error: error.message,
|
||||
servers: {},
|
||||
projectServers: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -99,11 +99,6 @@ export const api = {
|
||||
if (token) params.set('token', token);
|
||||
return `/api/search/conversations?${params.toString()}`;
|
||||
},
|
||||
createProject: (path) =>
|
||||
authenticatedFetch('/api/projects/create', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path }),
|
||||
}),
|
||||
createWorkspace: (workspaceData) =>
|
||||
authenticatedFetch('/api/projects/create-workspace', {
|
||||
method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user