mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-03 02:55:39 +08:00
Compare commits
14 Commits
chore/use-
...
feature/un
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
907cf510a3 | ||
|
|
eb6268748b | ||
|
|
4e962272cd | ||
|
|
0f1e515b39 | ||
|
|
b74b5fb967 | ||
|
|
32dfd27156 | ||
|
|
7832429011 | ||
|
|
1a6eb57043 | ||
|
|
d979c315cd | ||
|
|
5143a92021 | ||
|
|
358f47d020 | ||
|
|
5c53100651 | ||
|
|
63b9606e78 | ||
|
|
016e8673f2 |
@@ -148,9 +148,27 @@ export default tseslint.config(
|
||||
],
|
||||
"boundaries/elements": [
|
||||
{
|
||||
type: "backend-shared-types", // shared backend type contract that modules may consume without creating runtime coupling
|
||||
pattern: ["server/shared/types.{js,ts}"], // support the current shared types path
|
||||
mode: "file", // treat the types file itself as the boundary element instead of the whole folder
|
||||
type: "backend-shared-type-contract", // shared backend type/interface contracts that modules may consume without creating runtime coupling
|
||||
pattern: [
|
||||
"server/shared/types.{js,ts}",
|
||||
"server/shared/interfaces.{js,ts}",
|
||||
], // keep backend modules on explicit shared contract files for erased imports only
|
||||
mode: "file", // treat each shared contract file itself as the boundary element instead of the whole folder
|
||||
},
|
||||
{
|
||||
type: "backend-shared-utils", // shared backend runtime helpers that modules may import directly
|
||||
pattern: ["server/shared/utils.{js,ts}"], // classify the shared utils file so modules can depend on it explicitly
|
||||
mode: "file",
|
||||
},
|
||||
{
|
||||
type: "backend-legacy-runtime", // legacy runtime persistence modules used while providers migrate into server/modules
|
||||
pattern: [
|
||||
"server/projects.js",
|
||||
"server/sessionManager.js",
|
||||
"server/database/*.{js,ts}",
|
||||
"server/utils/runtime-paths.js",
|
||||
], // provider history loading still resolves session data through these legacy runtime/database files
|
||||
mode: "file",
|
||||
},
|
||||
{
|
||||
type: "backend-module", // logical element name used by boundaries rules below
|
||||
@@ -196,13 +214,13 @@ export default tseslint.config(
|
||||
checkInternals: false, // do not apply these cross-module rules to imports inside the same module
|
||||
rules: [
|
||||
{
|
||||
from: { type: "backend-module" }, // modules may depend on the shared types contract only as erased type-only imports
|
||||
to: { type: "backend-shared-types" },
|
||||
from: { type: "backend-module" }, // modules may depend on shared type/interface contracts only as erased type-only imports
|
||||
to: { type: "backend-shared-type-contract" },
|
||||
disallow: {
|
||||
dependency: { kind: ["value", "typeof"] },
|
||||
}, // block runtime imports so shared types stay a compile-time contract instead of a hidden shared module
|
||||
}, // block runtime imports so shared contracts stay compile-time only instead of becoming hidden shared modules
|
||||
message:
|
||||
"Backend modules may only use `import type` when importing from server/shared/types.ts (or server/types.ts).",
|
||||
"Backend modules may only use `import type` when importing from server/shared/types.ts or server/shared/interfaces.ts.",
|
||||
},
|
||||
{
|
||||
to: { type: "backend-module" }, // when importing anything that belongs to another backend module
|
||||
|
||||
100
package-lock.json
generated
100
package-lock.json
generated
@@ -76,6 +76,8 @@
|
||||
"@commitlint/config-conventional": "^20.4.3",
|
||||
"@eslint/js": "^9.39.3",
|
||||
"@release-it/conventional-changelog": "^10.0.5",
|
||||
"@types/cross-spawn": "^6.0.6",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.19.7",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
@@ -3752,6 +3754,37 @@
|
||||
"@babel/types": "^7.20.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cross-spawn": {
|
||||
"version": "6.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.6.tgz",
|
||||
"integrity": "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/debug": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||
@@ -3776,6 +3809,31 @@
|
||||
"@types/estree": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
|
||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
"@types/serve-static": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express-serve-static-core": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
|
||||
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/qs": "*",
|
||||
"@types/range-parser": "*",
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/hast": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||
@@ -3785,6 +3843,13 @@
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -3843,6 +3908,20 @@
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||
"integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
|
||||
@@ -3863,6 +3942,27 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/serve-static": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
|
||||
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
|
||||
@@ -128,6 +128,8 @@
|
||||
"@commitlint/config-conventional": "^20.4.3",
|
||||
"@eslint/js": "^9.39.3",
|
||||
"@release-it/conventional-changelog": "^10.0.5",
|
||||
"@types/cross-spawn": "^6.0.6",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.19.7",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
|
||||
@@ -24,8 +24,8 @@ import {
|
||||
notifyRunStopped,
|
||||
notifyUserIfEnabled
|
||||
} from './services/notification-orchestrator.js';
|
||||
import { claudeAdapter } from './providers/claude/adapter.js';
|
||||
import { createNormalizedMessage } from './providers/types.js';
|
||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||
import { createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
const activeSessions = new Map();
|
||||
const pendingToolApprovals = new Map();
|
||||
@@ -649,7 +649,7 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
const sid = capturedSessionId || sessionId || null;
|
||||
|
||||
// Use adapter to normalize SDK events into NormalizedMessage[]
|
||||
const normalized = claudeAdapter.normalizeMessage(transformedMessage, sid);
|
||||
const normalized = sessionsService.normalizeMessage('claude', transformedMessage, sid);
|
||||
for (const msg of normalized) {
|
||||
// Preserve parentToolUseId from SDK wrapper for subagent tool grouping
|
||||
if (transformedMessage.parentToolUseId && !msg.parentToolUseId) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { spawn } from 'child_process';
|
||||
import crossSpawn from 'cross-spawn';
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
import { cursorAdapter } from './providers/cursor/adapter.js';
|
||||
import { createNormalizedMessage } from './providers/types.js';
|
||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||
import { createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
// Use cross-spawn on Windows for better command execution
|
||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||
@@ -189,7 +189,7 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
case 'assistant':
|
||||
// Accumulate assistant message chunks
|
||||
if (response.message && response.message.content && response.message.content.length > 0) {
|
||||
const normalized = cursorAdapter.normalizeMessage(response, capturedSessionId || sessionId || null);
|
||||
const normalized = sessionsService.normalizeMessage('cursor', response, capturedSessionId || sessionId || null);
|
||||
for (const msg of normalized) ws.send(msg);
|
||||
}
|
||||
break;
|
||||
@@ -219,7 +219,7 @@ async function spawnCursor(command, options = {}, ws) {
|
||||
}
|
||||
|
||||
// If not JSON, send as stream delta via adapter
|
||||
const normalized = cursorAdapter.normalizeMessage(line, capturedSessionId || sessionId || null);
|
||||
const normalized = sessionsService.normalizeMessage('cursor', line, capturedSessionId || sessionId || null);
|
||||
for (const msg of normalized) ws.send(msg);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ import os from 'os';
|
||||
import sessionManager from './sessionManager.js';
|
||||
import GeminiResponseHandler from './gemini-response-handler.js';
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
import { createNormalizedMessage } from './providers/types.js';
|
||||
import { createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
let activeGeminiProcesses = new Map(); // Track active processes by session ID
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Gemini Response Handler - JSON Stream processing
|
||||
import { geminiAdapter } from './providers/gemini/adapter.js';
|
||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||
|
||||
class GeminiResponseHandler {
|
||||
constructor(ws, options = {}) {
|
||||
@@ -56,7 +56,7 @@ class GeminiResponseHandler {
|
||||
}
|
||||
|
||||
// Normalize via adapter and send all resulting messages
|
||||
const normalized = geminiAdapter.normalizeMessage(event, sid);
|
||||
const normalized = sessionsService.normalizeMessage('gemini', event, sid);
|
||||
for (const msg of normalized) {
|
||||
this.ws.send(msg);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { findAppRoot, getModuleDir } from './utils/runtime-paths.js';
|
||||
|
||||
import { AppError, createNormalizedMessage } from '@/shared/utils.js';
|
||||
|
||||
|
||||
const __dirname = getModuleDir(import.meta.url);
|
||||
// The server source runs from /server, while the compiled output runs from /dist-server/server.
|
||||
// Resolving the app root once keeps every repo-level lookup below aligned across both layouts.
|
||||
@@ -41,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';
|
||||
@@ -52,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';
|
||||
@@ -60,13 +61,12 @@ import commandsRoutes from './routes/commands.js';
|
||||
import settingsRoutes from './routes/settings.js';
|
||||
import agentRoutes from './routes/agent.js';
|
||||
import projectsRoutes, { WORKSPACES_ROOT, validateWorkspacePath } from './routes/projects.js';
|
||||
import cliAuthRoutes from './routes/cli-auth.js';
|
||||
import userRoutes from './routes/user.js';
|
||||
import codexRoutes from './routes/codex.js';
|
||||
import geminiRoutes from './routes/gemini.js';
|
||||
import pluginsRoutes from './routes/plugins.js';
|
||||
import messagesRoutes from './routes/messages.js';
|
||||
import { createNormalizedMessage } from './providers/types.js';
|
||||
import providerRoutes from './modules/providers/provider.routes.js';
|
||||
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
|
||||
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
|
||||
import { configureWebPush } from './services/vapid-keys.js';
|
||||
@@ -365,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);
|
||||
|
||||
@@ -383,9 +380,6 @@ app.use('/api/commands', authenticateToken, commandsRoutes);
|
||||
// Settings API Routes (protected)
|
||||
app.use('/api/settings', authenticateToken, settingsRoutes);
|
||||
|
||||
// CLI Authentication API Routes (protected)
|
||||
app.use('/api/cli', authenticateToken, cliAuthRoutes);
|
||||
|
||||
// User API Routes (protected)
|
||||
app.use('/api/user', authenticateToken, userRoutes);
|
||||
|
||||
@@ -401,6 +395,9 @@ app.use('/api/plugins', authenticateToken, pluginsRoutes);
|
||||
// Unified session messages route (protected)
|
||||
app.use('/api/sessions', authenticateToken, messagesRoutes);
|
||||
|
||||
// Unified provider MCP routes (protected)
|
||||
app.use('/api/providers', authenticateToken, providerRoutes);
|
||||
|
||||
// Agent API Routes (uses API key authentication)
|
||||
app.use('/api/agent', agentRoutes);
|
||||
|
||||
@@ -585,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() : '';
|
||||
@@ -1454,7 +1434,7 @@ wss.on('connection', (ws, request) => {
|
||||
/**
|
||||
* WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface
|
||||
*
|
||||
* Provider files use `createNormalizedMessage()` from `providers/types.js` and
|
||||
* Provider files use `createNormalizedMessage()` from `shared/utils.js` and
|
||||
* adapter `normalizeMessage()` to produce unified NormalizedMessage events.
|
||||
* The writer simply serialises and sends.
|
||||
*/
|
||||
@@ -2289,6 +2269,30 @@ app.get('*', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// global error middleware must be last
|
||||
app.use((err, req, res, next) => {
|
||||
if (err instanceof AppError) {
|
||||
return res.status(err.statusCode).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
details: err.details,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Internal server error',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to convert permissions to rwx format
|
||||
function permToRwx(perm) {
|
||||
const r = perm & 4 ? 'r' : '-';
|
||||
|
||||
123
server/modules/providers/list/claude/claude-auth.provider.ts
Normal file
123
server/modules/providers/list/claude/claude-auth.provider.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||
import type { ProviderAuthStatus } from '@/shared/types.js';
|
||||
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
||||
|
||||
type ClaudeCredentialsStatus = {
|
||||
authenticated: boolean;
|
||||
email: string | null;
|
||||
method: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export class ClaudeProviderAuth implements IProviderAuth {
|
||||
/**
|
||||
* Checks whether the Claude Code CLI is available on this host.
|
||||
*/
|
||||
private checkInstalled(): boolean {
|
||||
const cliPath = process.env.CLAUDE_CLI_PATH || 'claude';
|
||||
try {
|
||||
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Claude installation and credential status using Claude Code's auth priority.
|
||||
*/
|
||||
async getStatus(): Promise<ProviderAuthStatus> {
|
||||
const installed = this.checkInstalled();
|
||||
|
||||
if (!installed) {
|
||||
return {
|
||||
installed,
|
||||
provider: 'claude',
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Claude Code CLI is not installed',
|
||||
};
|
||||
}
|
||||
|
||||
const credentials = await this.checkCredentials();
|
||||
|
||||
return {
|
||||
installed,
|
||||
provider: 'claude',
|
||||
authenticated: credentials.authenticated,
|
||||
email: credentials.authenticated ? credentials.email || 'Authenticated' : credentials.email,
|
||||
method: credentials.method,
|
||||
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads Claude settings env values that the CLI can use even when the server process env is empty.
|
||||
*/
|
||||
private async loadSettingsEnv(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
||||
const content = await readFile(settingsPath, 'utf8');
|
||||
const settings = readObjectRecord(JSON.parse(content));
|
||||
return readObjectRecord(settings?.env) ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks Claude credentials in the same priority order used by Claude Code.
|
||||
*/
|
||||
private async checkCredentials(): Promise<ClaudeCredentialsStatus> {
|
||||
if (process.env.ANTHROPIC_API_KEY?.trim()) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||
}
|
||||
|
||||
const settingsEnv = await this.loadSettingsEnv();
|
||||
if (readOptionalString(settingsEnv.ANTHROPIC_API_KEY)) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||
}
|
||||
|
||||
if (readOptionalString(settingsEnv.ANTHROPIC_AUTH_TOKEN)) {
|
||||
return { authenticated: true, email: 'Configured via settings.json', method: 'api_key' };
|
||||
}
|
||||
|
||||
try {
|
||||
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
||||
const content = await readFile(credPath, 'utf8');
|
||||
const creds = readObjectRecord(JSON.parse(content)) ?? {};
|
||||
const oauth = readObjectRecord(creds.claudeAiOauth);
|
||||
const accessToken = readOptionalString(oauth?.accessToken);
|
||||
|
||||
if (accessToken) {
|
||||
const expiresAt = typeof oauth?.expiresAt === 'number' ? oauth.expiresAt : undefined;
|
||||
const email = readOptionalString(creds.email) ?? readOptionalString(creds.user) ?? null;
|
||||
if (!expiresAt || Date.now() < expiresAt) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email,
|
||||
method: 'credentials_file',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email,
|
||||
method: 'credentials_file',
|
||||
error: 'OAuth token has expired. Please re-authenticate with claude login',
|
||||
};
|
||||
}
|
||||
|
||||
return { authenticated: false, email: null, method: null };
|
||||
} catch {
|
||||
return { authenticated: false, email: null, method: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
135
server/modules/providers/list/claude/claude-mcp.provider.ts
Normal file
135
server/modules/providers/list/claude/claude-mcp.provider.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
|
||||
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import {
|
||||
AppError,
|
||||
readJsonConfig,
|
||||
readObjectRecord,
|
||||
readOptionalString,
|
||||
readStringArray,
|
||||
readStringRecord,
|
||||
writeJsonConfig,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
export class ClaudeMcpProvider extends McpProvider {
|
||||
constructor() {
|
||||
super('claude', ['user', 'local', 'project'], ['stdio', 'http', 'sse']);
|
||||
}
|
||||
|
||||
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
|
||||
if (scope === 'project') {
|
||||
const filePath = path.join(workspacePath, '.mcp.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
return readObjectRecord(config.mcpServers) ?? {};
|
||||
}
|
||||
|
||||
const filePath = path.join(os.homedir(), '.claude.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
if (scope === 'user') {
|
||||
return readObjectRecord(config.mcpServers) ?? {};
|
||||
}
|
||||
|
||||
const projects = readObjectRecord(config.projects) ?? {};
|
||||
const projectConfig = readObjectRecord(projects[workspacePath]) ?? {};
|
||||
return readObjectRecord(projectConfig.mcpServers) ?? {};
|
||||
}
|
||||
|
||||
protected async writeScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
servers: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
if (scope === 'project') {
|
||||
const filePath = path.join(workspacePath, '.mcp.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
config.mcpServers = servers;
|
||||
await writeJsonConfig(filePath, config);
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.join(os.homedir(), '.claude.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
if (scope === 'user') {
|
||||
config.mcpServers = servers;
|
||||
await writeJsonConfig(filePath, config);
|
||||
return;
|
||||
}
|
||||
|
||||
const projects = readObjectRecord(config.projects) ?? {};
|
||||
const projectConfig = readObjectRecord(projects[workspacePath]) ?? {};
|
||||
projectConfig.mcpServers = servers;
|
||||
projects[workspacePath] = projectConfig;
|
||||
config.projects = projects;
|
||||
await writeJsonConfig(filePath, config);
|
||||
}
|
||||
|
||||
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
|
||||
if (input.transport === 'stdio') {
|
||||
if (!input.command?.trim()) {
|
||||
throw new AppError('command is required for stdio MCP servers.', {
|
||||
code: 'MCP_COMMAND_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'stdio',
|
||||
command: input.command,
|
||||
args: input.args ?? [],
|
||||
env: input.env ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.url?.trim()) {
|
||||
throw new AppError('url is required for http/sse MCP servers.', {
|
||||
code: 'MCP_URL_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: input.transport,
|
||||
url: input.url,
|
||||
headers: input.headers ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
protected normalizeServerConfig(
|
||||
scope: McpScope,
|
||||
name: string,
|
||||
rawConfig: unknown,
|
||||
): ProviderMcpServer | null {
|
||||
if (!rawConfig || typeof rawConfig !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = rawConfig as Record<string, unknown>;
|
||||
if (typeof config.command === 'string') {
|
||||
return {
|
||||
provider: 'claude',
|
||||
name,
|
||||
scope,
|
||||
transport: 'stdio',
|
||||
command: config.command,
|
||||
args: readStringArray(config.args),
|
||||
env: readStringRecord(config.env),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof config.url === 'string') {
|
||||
const transport = readOptionalString(config.type) === 'sse' ? 'sse' : 'http';
|
||||
return {
|
||||
provider: 'claude',
|
||||
name,
|
||||
scope,
|
||||
transport,
|
||||
url: config.url,
|
||||
headers: readStringRecord(config.headers),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
321
server/modules/providers/list/claude/claude.provider.ts
Normal file
321
server/modules/providers/list/claude/claude.provider.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import { getSessionMessages } from '@/projects.js';
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import { ClaudeProviderAuth } from '@/modules/providers/list/claude/claude-auth.provider.js';
|
||||
import { ClaudeMcpProvider } from '@/modules/providers/list/claude/claude-mcp.provider.js';
|
||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||
import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'claude';
|
||||
|
||||
type RawProviderMessage = Record<string, any>;
|
||||
|
||||
type ClaudeToolResult = {
|
||||
content: unknown;
|
||||
isError: boolean;
|
||||
subagentTools?: unknown;
|
||||
toolUseResult?: unknown;
|
||||
};
|
||||
|
||||
type ClaudeHistoryResult =
|
||||
| RawProviderMessage[]
|
||||
| {
|
||||
messages?: RawProviderMessage[];
|
||||
total?: number;
|
||||
hasMore?: boolean;
|
||||
};
|
||||
|
||||
const loadClaudeSessionMessages = getSessionMessages as unknown as (
|
||||
projectName: string,
|
||||
sessionId: string,
|
||||
limit: number | null,
|
||||
offset: number,
|
||||
) => Promise<ClaudeHistoryResult>;
|
||||
|
||||
/**
|
||||
* Claude writes internal command and system reminder entries into history.
|
||||
* Those are useful for the CLI but should not appear in the user-facing chat.
|
||||
*/
|
||||
const INTERNAL_CONTENT_PREFIXES = [
|
||||
'<command-name>',
|
||||
'<command-message>',
|
||||
'<command-args>',
|
||||
'<local-command-stdout>',
|
||||
'<system-reminder>',
|
||||
'Caveat:',
|
||||
'This session is being continued from a previous',
|
||||
'[Request interrupted',
|
||||
] as const;
|
||||
|
||||
function isInternalContent(content: string): boolean {
|
||||
return INTERNAL_CONTENT_PREFIXES.some((prefix) => content.startsWith(prefix));
|
||||
}
|
||||
|
||||
function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
|
||||
return readObjectRecord(raw) as RawProviderMessage | null;
|
||||
}
|
||||
|
||||
export class ClaudeProvider extends AbstractProvider {
|
||||
readonly mcp = new ClaudeMcpProvider();
|
||||
readonly auth: IProviderAuth = new ClaudeProviderAuth();
|
||||
|
||||
constructor() {
|
||||
super('claude');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes one Claude JSONL entry or live SDK stream event into the shared
|
||||
* message shape consumed by REST and WebSocket clients.
|
||||
*/
|
||||
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
|
||||
const raw = readRawProviderMessage(rawMessage);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (raw.type === 'content_block_delta' && raw.delta?.text) {
|
||||
return [createNormalizedMessage({ kind: 'stream_delta', content: raw.delta.text, sessionId, provider: PROVIDER })];
|
||||
}
|
||||
if (raw.type === 'content_block_stop') {
|
||||
return [createNormalizedMessage({ kind: 'stream_end', sessionId, provider: PROVIDER })];
|
||||
}
|
||||
|
||||
const messages: NormalizedMessage[] = [];
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('claude');
|
||||
|
||||
if (raw.message?.role === 'user' && raw.message?.content) {
|
||||
if (Array.isArray(raw.message.content)) {
|
||||
for (const part of raw.message.content) {
|
||||
if (part.type === 'tool_result') {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_tr_${part.tool_use_id}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: part.tool_use_id,
|
||||
content: typeof part.content === 'string' ? part.content : JSON.stringify(part.content),
|
||||
isError: Boolean(part.is_error),
|
||||
subagentTools: raw.subagentTools,
|
||||
toolUseResult: raw.toolUseResult,
|
||||
}));
|
||||
} else if (part.type === 'text') {
|
||||
const text = part.text || '';
|
||||
if (text && !isInternalContent(text)) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_text`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content: text,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
const textParts = raw.message.content
|
||||
.filter((part: RawProviderMessage) => part.type === 'text')
|
||||
.map((part: RawProviderMessage) => part.text)
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
if (textParts && !isInternalContent(textParts)) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_text`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content: textParts,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else if (typeof raw.message.content === 'string') {
|
||||
const text = raw.message.content;
|
||||
if (text && !isInternalContent(text)) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content: text,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
if (raw.type === 'thinking' && raw.message?.content) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: raw.message.content,
|
||||
}));
|
||||
return messages;
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_use' && raw.toolName) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: raw.toolName,
|
||||
toolInput: raw.toolInput,
|
||||
toolId: raw.toolCallId || baseId,
|
||||
}));
|
||||
return messages;
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_result') {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: raw.toolCallId || '',
|
||||
content: raw.output || '',
|
||||
isError: false,
|
||||
}));
|
||||
return messages;
|
||||
}
|
||||
|
||||
if (raw.message?.role === 'assistant' && raw.message?.content) {
|
||||
if (Array.isArray(raw.message.content)) {
|
||||
let partIndex = 0;
|
||||
for (const part of raw.message.content) {
|
||||
if (part.type === 'text' && part.text) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIndex}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'assistant',
|
||||
content: part.text,
|
||||
}));
|
||||
} else if (part.type === 'tool_use') {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIndex}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: part.name,
|
||||
toolInput: part.input,
|
||||
toolId: part.id,
|
||||
}));
|
||||
} else if (part.type === 'thinking' && part.thinking) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIndex}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: part.thinking,
|
||||
}));
|
||||
}
|
||||
partIndex++;
|
||||
}
|
||||
} else if (typeof raw.message.content === 'string') {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'assistant',
|
||||
content: raw.message.content,
|
||||
}));
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads Claude JSONL history for a project/session and returns normalized
|
||||
* messages, preserving the existing pagination behavior from projects.js.
|
||||
*/
|
||||
async fetchHistory(
|
||||
sessionId: string,
|
||||
options: FetchHistoryOptions = {},
|
||||
): Promise<FetchHistoryResult> {
|
||||
const { projectName, limit = null, offset = 0 } = options;
|
||||
if (!projectName) {
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
|
||||
let result: ClaudeHistoryResult;
|
||||
try {
|
||||
result = await loadClaudeSessionMessages(projectName, sessionId, limit, offset);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[ClaudeProvider] Failed to load session ${sessionId}:`, message);
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
|
||||
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
|
||||
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
|
||||
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
|
||||
|
||||
const toolResultMap = new Map<string, ClaudeToolResult>();
|
||||
for (const raw of rawMessages) {
|
||||
if (raw.message?.role === 'user' && Array.isArray(raw.message?.content)) {
|
||||
for (const part of raw.message.content) {
|
||||
if (part.type === 'tool_result' && part.tool_use_id) {
|
||||
toolResultMap.set(part.tool_use_id, {
|
||||
content: part.content,
|
||||
isError: Boolean(part.is_error),
|
||||
subagentTools: raw.subagentTools,
|
||||
toolUseResult: raw.toolUseResult,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalized: NormalizedMessage[] = [];
|
||||
for (const raw of rawMessages) {
|
||||
normalized.push(...this.normalizeMessage(raw, sessionId));
|
||||
}
|
||||
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
|
||||
const toolResult = toolResultMap.get(msg.toolId);
|
||||
if (!toolResult) {
|
||||
continue;
|
||||
}
|
||||
|
||||
msg.toolResult = {
|
||||
content: typeof toolResult.content === 'string'
|
||||
? toolResult.content
|
||||
: JSON.stringify(toolResult.content),
|
||||
isError: toolResult.isError,
|
||||
toolUseResult: toolResult.toolUseResult,
|
||||
};
|
||||
msg.subagentTools = toolResult.subagentTools;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages: normalized,
|
||||
total,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
}
|
||||
100
server/modules/providers/list/codex/codex-auth.provider.ts
Normal file
100
server/modules/providers/list/codex/codex-auth.provider.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||
import type { ProviderAuthStatus } from '@/shared/types.js';
|
||||
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
||||
|
||||
type CodexCredentialsStatus = {
|
||||
authenticated: boolean;
|
||||
email: string | null;
|
||||
method: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export class CodexProviderAuth implements IProviderAuth {
|
||||
/**
|
||||
* Checks whether Codex is available to the server runtime.
|
||||
*/
|
||||
private checkInstalled(): boolean {
|
||||
try {
|
||||
spawn.sync('codex', ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Codex SDK availability and credential status.
|
||||
*/
|
||||
async getStatus(): Promise<ProviderAuthStatus> {
|
||||
const installed = this.checkInstalled();
|
||||
const credentials = await this.checkCredentials();
|
||||
|
||||
return {
|
||||
installed,
|
||||
provider: 'codex',
|
||||
authenticated: credentials.authenticated,
|
||||
email: credentials.email,
|
||||
method: credentials.method,
|
||||
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads Codex auth.json and checks OAuth tokens or an API key fallback.
|
||||
*/
|
||||
private async checkCredentials(): Promise<CodexCredentialsStatus> {
|
||||
try {
|
||||
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
|
||||
const content = await readFile(authPath, 'utf8');
|
||||
const auth = readObjectRecord(JSON.parse(content)) ?? {};
|
||||
const tokens = readObjectRecord(auth.tokens) ?? {};
|
||||
const idToken = readOptionalString(tokens.id_token);
|
||||
const accessToken = readOptionalString(tokens.access_token);
|
||||
|
||||
if (idToken || accessToken) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: idToken ? this.readEmailFromIdToken(idToken) : 'Authenticated',
|
||||
method: 'credentials_file',
|
||||
};
|
||||
}
|
||||
|
||||
if (readOptionalString(auth.OPENAI_API_KEY)) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||
}
|
||||
|
||||
return { authenticated: false, email: null, method: null, error: 'No valid tokens found' };
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: code === 'ENOENT' ? 'Codex not configured' : error instanceof Error ? error.message : 'Failed to read Codex auth',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the user email from a Codex id_token when a readable JWT payload exists.
|
||||
*/
|
||||
private readEmailFromIdToken(idToken: string): string {
|
||||
try {
|
||||
const parts = idToken.split('.');
|
||||
if (parts.length >= 2) {
|
||||
const payload = readObjectRecord(JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')));
|
||||
return readOptionalString(payload?.email) ?? readOptionalString(payload?.user) ?? 'Authenticated';
|
||||
}
|
||||
} catch {
|
||||
// Fall back to a generic authenticated marker if the token payload is not readable.
|
||||
}
|
||||
|
||||
return 'Authenticated';
|
||||
}
|
||||
}
|
||||
135
server/modules/providers/list/codex/codex-mcp.provider.ts
Normal file
135
server/modules/providers/list/codex/codex-mcp.provider.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import TOML from '@iarna/toml';
|
||||
|
||||
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
|
||||
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import {
|
||||
AppError,
|
||||
readObjectRecord,
|
||||
readOptionalString,
|
||||
readStringArray,
|
||||
readStringRecord,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
const readTomlConfig = async (filePath: string): Promise<Record<string, unknown>> => {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const parsed = TOML.parse(content) as Record<string, unknown>;
|
||||
return readObjectRecord(parsed) ?? {};
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const writeTomlConfig = async (filePath: string, data: Record<string, unknown>): Promise<void> => {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
const toml = TOML.stringify(data as never);
|
||||
await writeFile(filePath, toml, 'utf8');
|
||||
};
|
||||
|
||||
export class CodexMcpProvider extends McpProvider {
|
||||
constructor() {
|
||||
super('codex', ['user', 'project'], ['stdio', 'http']);
|
||||
}
|
||||
|
||||
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.codex', 'config.toml')
|
||||
: path.join(workspacePath, '.codex', 'config.toml');
|
||||
const config = await readTomlConfig(filePath);
|
||||
return readObjectRecord(config.mcp_servers) ?? {};
|
||||
}
|
||||
|
||||
protected async writeScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
servers: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.codex', 'config.toml')
|
||||
: path.join(workspacePath, '.codex', 'config.toml');
|
||||
const config = await readTomlConfig(filePath);
|
||||
config.mcp_servers = servers;
|
||||
await writeTomlConfig(filePath, config);
|
||||
}
|
||||
|
||||
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
|
||||
if (input.transport === 'stdio') {
|
||||
if (!input.command?.trim()) {
|
||||
throw new AppError('command is required for stdio MCP servers.', {
|
||||
code: 'MCP_COMMAND_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
command: input.command,
|
||||
args: input.args ?? [],
|
||||
env: input.env ?? {},
|
||||
env_vars: input.envVars ?? [],
|
||||
cwd: input.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.url?.trim()) {
|
||||
throw new AppError('url is required for http MCP servers.', {
|
||||
code: 'MCP_URL_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
url: input.url,
|
||||
bearer_token_env_var: input.bearerTokenEnvVar,
|
||||
http_headers: input.headers ?? {},
|
||||
env_http_headers: input.envHttpHeaders ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
protected normalizeServerConfig(
|
||||
scope: McpScope,
|
||||
name: string,
|
||||
rawConfig: unknown,
|
||||
): ProviderMcpServer | null {
|
||||
if (!rawConfig || typeof rawConfig !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = rawConfig as Record<string, unknown>;
|
||||
if (typeof config.command === 'string') {
|
||||
return {
|
||||
provider: 'codex',
|
||||
name,
|
||||
scope,
|
||||
transport: 'stdio',
|
||||
command: config.command,
|
||||
args: readStringArray(config.args),
|
||||
env: readStringRecord(config.env),
|
||||
cwd: readOptionalString(config.cwd),
|
||||
envVars: readStringArray(config.env_vars),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof config.url === 'string') {
|
||||
return {
|
||||
provider: 'codex',
|
||||
name,
|
||||
scope,
|
||||
transport: 'http',
|
||||
url: config.url,
|
||||
headers: readStringRecord(config.http_headers),
|
||||
bearerTokenEnvVar: readOptionalString(config.bearer_token_env_var),
|
||||
envHttpHeaders: readStringRecord(config.env_http_headers),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
335
server/modules/providers/list/codex/codex.provider.ts
Normal file
335
server/modules/providers/list/codex/codex.provider.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { getCodexSessionMessages } from '@/projects.js';
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import { CodexProviderAuth } from '@/modules/providers/list/codex/codex-auth.provider.js';
|
||||
import { CodexMcpProvider } from '@/modules/providers/list/codex/codex-mcp.provider.js';
|
||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||
import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'codex';
|
||||
|
||||
type RawProviderMessage = Record<string, any>;
|
||||
|
||||
type CodexHistoryResult =
|
||||
| RawProviderMessage[]
|
||||
| {
|
||||
messages?: RawProviderMessage[];
|
||||
total?: number;
|
||||
hasMore?: boolean;
|
||||
tokenUsage?: unknown;
|
||||
};
|
||||
|
||||
const loadCodexSessionMessages = getCodexSessionMessages as unknown as (
|
||||
sessionId: string,
|
||||
limit: number | null,
|
||||
offset: number,
|
||||
) => Promise<CodexHistoryResult>;
|
||||
|
||||
function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
|
||||
return readObjectRecord(raw) as RawProviderMessage | null;
|
||||
}
|
||||
|
||||
export class CodexProvider extends AbstractProvider {
|
||||
readonly mcp = new CodexMcpProvider();
|
||||
readonly auth: IProviderAuth = new CodexProviderAuth();
|
||||
|
||||
constructor() {
|
||||
super('codex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a persisted Codex JSONL entry.
|
||||
*
|
||||
* Live Codex SDK events are transformed before they reach normalizeMessage(),
|
||||
* while history entries already use a compact message/tool shape from projects.js.
|
||||
*/
|
||||
private normalizeHistoryEntry(raw: RawProviderMessage, sessionId: string | null): NormalizedMessage[] {
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('codex');
|
||||
|
||||
if (raw.message?.role === 'user') {
|
||||
const content = typeof raw.message.content === 'string'
|
||||
? raw.message.content
|
||||
: Array.isArray(raw.message.content)
|
||||
? raw.message.content
|
||||
.map((part: string | RawProviderMessage) => typeof part === 'string' ? part : part?.text || '')
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
: String(raw.message.content || '');
|
||||
if (!content.trim()) {
|
||||
return [];
|
||||
}
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content,
|
||||
})];
|
||||
}
|
||||
|
||||
if (raw.message?.role === 'assistant') {
|
||||
const content = typeof raw.message.content === 'string'
|
||||
? raw.message.content
|
||||
: Array.isArray(raw.message.content)
|
||||
? raw.message.content
|
||||
.map((part: string | RawProviderMessage) => typeof part === 'string' ? part : part?.text || '')
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
: '';
|
||||
if (!content.trim()) {
|
||||
return [];
|
||||
}
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'assistant',
|
||||
content,
|
||||
})];
|
||||
}
|
||||
|
||||
if (raw.type === 'thinking' || raw.isReasoning) {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: raw.message?.content || '',
|
||||
})];
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_use' || raw.toolName) {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: raw.toolName || 'Unknown',
|
||||
toolInput: raw.toolInput,
|
||||
toolId: raw.toolCallId || baseId,
|
||||
})];
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_result') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: raw.toolCallId || '',
|
||||
content: raw.output || '',
|
||||
isError: Boolean(raw.isError),
|
||||
})];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes either a Codex history entry or a transformed live SDK event.
|
||||
*/
|
||||
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
|
||||
const raw = readRawProviderMessage(rawMessage);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (raw.message?.role) {
|
||||
return this.normalizeHistoryEntry(raw, sessionId);
|
||||
}
|
||||
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('codex');
|
||||
|
||||
if (raw.type === 'item') {
|
||||
switch (raw.itemType) {
|
||||
case 'agent_message':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'assistant',
|
||||
content: raw.message?.content || '',
|
||||
})];
|
||||
case 'reasoning':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: raw.message?.content || '',
|
||||
})];
|
||||
case 'command_execution':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: 'Bash',
|
||||
toolInput: { command: raw.command },
|
||||
toolId: baseId,
|
||||
output: raw.output,
|
||||
exitCode: raw.exitCode,
|
||||
status: raw.status,
|
||||
})];
|
||||
case 'file_change':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: 'FileChanges',
|
||||
toolInput: raw.changes,
|
||||
toolId: baseId,
|
||||
status: raw.status,
|
||||
})];
|
||||
case 'mcp_tool_call':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: raw.tool || 'MCP',
|
||||
toolInput: raw.arguments,
|
||||
toolId: baseId,
|
||||
server: raw.server,
|
||||
result: raw.result,
|
||||
error: raw.error,
|
||||
status: raw.status,
|
||||
})];
|
||||
case 'web_search':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: 'WebSearch',
|
||||
toolInput: { query: raw.query },
|
||||
toolId: baseId,
|
||||
})];
|
||||
case 'todo_list':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: 'TodoList',
|
||||
toolInput: { items: raw.items },
|
||||
toolId: baseId,
|
||||
})];
|
||||
case 'error':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'error',
|
||||
content: raw.message?.content || 'Unknown error',
|
||||
})];
|
||||
default:
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: raw.itemType || 'Unknown',
|
||||
toolInput: raw.item || raw,
|
||||
toolId: baseId,
|
||||
})];
|
||||
}
|
||||
}
|
||||
|
||||
if (raw.type === 'turn_complete') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'complete',
|
||||
})];
|
||||
}
|
||||
if (raw.type === 'turn_failed') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'error',
|
||||
content: raw.error?.message || 'Turn failed',
|
||||
})];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads Codex JSONL history and keeps token usage metadata when projects.js
|
||||
* provides it.
|
||||
*/
|
||||
async fetchHistory(
|
||||
sessionId: string,
|
||||
options: FetchHistoryOptions = {},
|
||||
): Promise<FetchHistoryResult> {
|
||||
const { limit = null, offset = 0 } = options;
|
||||
|
||||
let result: CodexHistoryResult;
|
||||
try {
|
||||
result = await loadCodexSessionMessages(sessionId, limit, offset);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[CodexProvider] Failed to load session ${sessionId}:`, message);
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
|
||||
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
|
||||
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
|
||||
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
|
||||
const tokenUsage = Array.isArray(result) ? undefined : result.tokenUsage;
|
||||
|
||||
const normalized: NormalizedMessage[] = [];
|
||||
for (const raw of rawMessages) {
|
||||
normalized.push(...this.normalizeHistoryEntry(raw, sessionId));
|
||||
}
|
||||
|
||||
const toolResultMap = new Map<string, NormalizedMessage>();
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_result' && msg.toolId) {
|
||||
toolResultMap.set(msg.toolId, msg);
|
||||
}
|
||||
}
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
|
||||
const toolResult = toolResultMap.get(msg.toolId);
|
||||
if (toolResult) {
|
||||
msg.toolResult = { content: toolResult.content, isError: toolResult.isError };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages: normalized,
|
||||
total,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
tokenUsage,
|
||||
};
|
||||
}
|
||||
}
|
||||
143
server/modules/providers/list/cursor/cursor-auth.provider.ts
Normal file
143
server/modules/providers/list/cursor/cursor-auth.provider.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||
import type { ProviderAuthStatus } from '@/shared/types.js';
|
||||
|
||||
type CursorLoginStatus = {
|
||||
authenticated: boolean;
|
||||
email: string | null;
|
||||
method: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export class CursorProviderAuth implements IProviderAuth {
|
||||
/**
|
||||
* Checks whether the cursor-agent CLI is available on this host.
|
||||
*/
|
||||
private checkInstalled(): boolean {
|
||||
try {
|
||||
spawn.sync('cursor-agent', ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Cursor CLI installation and login status.
|
||||
*/
|
||||
async getStatus(): Promise<ProviderAuthStatus> {
|
||||
const installed = this.checkInstalled();
|
||||
|
||||
if (!installed) {
|
||||
return {
|
||||
installed,
|
||||
provider: 'cursor',
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Cursor CLI is not installed',
|
||||
};
|
||||
}
|
||||
|
||||
const login = await this.checkCursorLogin();
|
||||
|
||||
return {
|
||||
installed,
|
||||
provider: 'cursor',
|
||||
authenticated: login.authenticated,
|
||||
email: login.email,
|
||||
method: login.method,
|
||||
error: login.authenticated ? undefined : login.error || 'Not logged in',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs cursor-agent status and parses the login marker from stdout.
|
||||
*/
|
||||
private checkCursorLogin(): Promise<CursorLoginStatus> {
|
||||
return new Promise((resolve) => {
|
||||
let processCompleted = false;
|
||||
let childProcess: ReturnType<typeof spawn> | undefined;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!processCompleted) {
|
||||
processCompleted = true;
|
||||
childProcess?.kill();
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Command timeout',
|
||||
});
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
try {
|
||||
childProcess = spawn('cursor-agent', ['status']);
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
processCompleted = true;
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Cursor CLI not found or not installed',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
childProcess.stdout?.on('data', (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
childProcess.stderr?.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
childProcess.on('close', (code) => {
|
||||
if (processCompleted) {
|
||||
return;
|
||||
}
|
||||
processCompleted = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (code === 0) {
|
||||
const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
|
||||
if (emailMatch?.[1]) {
|
||||
resolve({ authenticated: true, email: emailMatch[1], method: 'cli' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (stdout.includes('Logged in')) {
|
||||
resolve({ authenticated: true, email: 'Logged in', method: 'cli' });
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({ authenticated: false, email: null, method: null, error: 'Not logged in' });
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({ authenticated: false, email: null, method: null, error: stderr || 'Not logged in' });
|
||||
});
|
||||
|
||||
childProcess.on('error', () => {
|
||||
if (processCompleted) {
|
||||
return;
|
||||
}
|
||||
processCompleted = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Cursor CLI not found or not installed',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
108
server/modules/providers/list/cursor/cursor-mcp.provider.ts
Normal file
108
server/modules/providers/list/cursor/cursor-mcp.provider.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
|
||||
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import {
|
||||
AppError,
|
||||
readJsonConfig,
|
||||
readObjectRecord,
|
||||
readOptionalString,
|
||||
readStringArray,
|
||||
readStringRecord,
|
||||
writeJsonConfig,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
export class CursorMcpProvider extends McpProvider {
|
||||
constructor() {
|
||||
super('cursor', ['user', 'project'], ['stdio', 'http', 'sse']);
|
||||
}
|
||||
|
||||
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.cursor', 'mcp.json')
|
||||
: path.join(workspacePath, '.cursor', 'mcp.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
return readObjectRecord(config.mcpServers) ?? {};
|
||||
}
|
||||
|
||||
protected async writeScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
servers: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.cursor', 'mcp.json')
|
||||
: path.join(workspacePath, '.cursor', 'mcp.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
config.mcpServers = servers;
|
||||
await writeJsonConfig(filePath, config);
|
||||
}
|
||||
|
||||
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
|
||||
if (input.transport === 'stdio') {
|
||||
if (!input.command?.trim()) {
|
||||
throw new AppError('command is required for stdio MCP servers.', {
|
||||
code: 'MCP_COMMAND_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
command: input.command,
|
||||
args: input.args ?? [],
|
||||
env: input.env ?? {},
|
||||
cwd: input.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.url?.trim()) {
|
||||
throw new AppError('url is required for http/sse MCP servers.', {
|
||||
code: 'MCP_URL_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
url: input.url,
|
||||
headers: input.headers ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
protected normalizeServerConfig(
|
||||
scope: McpScope,
|
||||
name: string,
|
||||
rawConfig: unknown,
|
||||
): ProviderMcpServer | null {
|
||||
if (!rawConfig || typeof rawConfig !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = rawConfig as Record<string, unknown>;
|
||||
if (typeof config.command === 'string') {
|
||||
return {
|
||||
provider: 'cursor',
|
||||
name,
|
||||
scope,
|
||||
transport: 'stdio',
|
||||
command: config.command,
|
||||
args: readStringArray(config.args),
|
||||
env: readStringRecord(config.env),
|
||||
cwd: readOptionalString(config.cwd),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof config.url === 'string') {
|
||||
return {
|
||||
provider: 'cursor',
|
||||
name,
|
||||
scope,
|
||||
transport: 'http',
|
||||
url: config.url,
|
||||
headers: readStringRecord(config.headers),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
403
server/modules/providers/list/cursor/cursor.provider.ts
Normal file
403
server/modules/providers/list/cursor/cursor.provider.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import crypto from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import { CursorProviderAuth } from '@/modules/providers/list/cursor/cursor-auth.provider.js';
|
||||
import { CursorMcpProvider } from '@/modules/providers/list/cursor/cursor-mcp.provider.js';
|
||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||
import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'cursor';
|
||||
|
||||
type RawProviderMessage = Record<string, any>;
|
||||
|
||||
type CursorDbBlob = {
|
||||
rowid: number;
|
||||
id: string;
|
||||
data?: Buffer;
|
||||
};
|
||||
|
||||
type CursorJsonBlob = CursorDbBlob & {
|
||||
parsed: RawProviderMessage;
|
||||
};
|
||||
|
||||
type CursorMessageBlob = {
|
||||
id: string;
|
||||
sequence: number;
|
||||
rowid: number;
|
||||
content: RawProviderMessage;
|
||||
};
|
||||
|
||||
function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
|
||||
return readObjectRecord(raw) as RawProviderMessage | null;
|
||||
}
|
||||
|
||||
export class CursorProvider extends AbstractProvider {
|
||||
readonly mcp = new CursorMcpProvider();
|
||||
readonly auth: IProviderAuth = new CursorProviderAuth();
|
||||
|
||||
constructor() {
|
||||
super('cursor');
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads Cursor's SQLite blob DAG and returns message blobs in conversation
|
||||
* order. Cursor history is stored as content-addressed blobs rather than JSONL.
|
||||
*/
|
||||
private async loadCursorBlobs(sessionId: string, projectPath: string): Promise<CursorMessageBlob[]> {
|
||||
const sqlite3Module = await import('sqlite3');
|
||||
const sqlite3 = sqlite3Module.default;
|
||||
const { open } = await import('sqlite');
|
||||
|
||||
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
|
||||
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
|
||||
|
||||
const db = await open({
|
||||
filename: storeDbPath,
|
||||
driver: sqlite3.Database,
|
||||
mode: sqlite3.OPEN_READONLY,
|
||||
});
|
||||
|
||||
try {
|
||||
const allBlobs = await db.all('SELECT rowid, id, data FROM blobs') as CursorDbBlob[];
|
||||
|
||||
const blobMap = new Map<string, CursorDbBlob>();
|
||||
const parentRefs = new Map<string, string[]>();
|
||||
const childRefs = new Map<string, string[]>();
|
||||
const jsonBlobs: CursorJsonBlob[] = [];
|
||||
|
||||
for (const blob of allBlobs) {
|
||||
blobMap.set(blob.id, blob);
|
||||
|
||||
if (blob.data && blob.data[0] === 0x7B) {
|
||||
try {
|
||||
const parsed = JSON.parse(blob.data.toString('utf8')) as RawProviderMessage;
|
||||
jsonBlobs.push({ ...blob, parsed });
|
||||
} catch {
|
||||
// Cursor can include binary or partial blobs; only JSON blobs become messages.
|
||||
}
|
||||
} else if (blob.data) {
|
||||
const parents: string[] = [];
|
||||
let i = 0;
|
||||
while (i < blob.data.length - 33) {
|
||||
if (blob.data[i] === 0x0A && blob.data[i + 1] === 0x20) {
|
||||
const parentHash = blob.data.slice(i + 2, i + 34).toString('hex');
|
||||
if (blobMap.has(parentHash)) {
|
||||
parents.push(parentHash);
|
||||
}
|
||||
i += 34;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (parents.length > 0) {
|
||||
parentRefs.set(blob.id, parents);
|
||||
for (const parentId of parents) {
|
||||
if (!childRefs.has(parentId)) {
|
||||
childRefs.set(parentId, []);
|
||||
}
|
||||
childRefs.get(parentId)?.push(blob.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const visited = new Set<string>();
|
||||
const sorted: CursorDbBlob[] = [];
|
||||
const visit = (nodeId: string): void => {
|
||||
if (visited.has(nodeId)) {
|
||||
return;
|
||||
}
|
||||
visited.add(nodeId);
|
||||
for (const parentId of parentRefs.get(nodeId) || []) {
|
||||
visit(parentId);
|
||||
}
|
||||
const blob = blobMap.get(nodeId);
|
||||
if (blob) {
|
||||
sorted.push(blob);
|
||||
}
|
||||
};
|
||||
|
||||
for (const blob of allBlobs) {
|
||||
if (!parentRefs.has(blob.id)) {
|
||||
visit(blob.id);
|
||||
}
|
||||
}
|
||||
for (const blob of allBlobs) {
|
||||
visit(blob.id);
|
||||
}
|
||||
|
||||
const messageOrder = new Map<string, number>();
|
||||
let orderIndex = 0;
|
||||
for (const blob of sorted) {
|
||||
if (blob.data && blob.data[0] !== 0x7B) {
|
||||
for (const jsonBlob of jsonBlobs) {
|
||||
try {
|
||||
const idBytes = Buffer.from(jsonBlob.id, 'hex');
|
||||
if (blob.data.includes(idBytes) && !messageOrder.has(jsonBlob.id)) {
|
||||
messageOrder.set(jsonBlob.id, orderIndex++);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed blob ids that cannot be decoded as hex.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sortedJsonBlobs = jsonBlobs.sort((a, b) => {
|
||||
const aOrder = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
const bOrder = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
return aOrder !== bOrder ? aOrder - bOrder : a.rowid - b.rowid;
|
||||
});
|
||||
|
||||
const messages: CursorMessageBlob[] = [];
|
||||
for (let idx = 0; idx < sortedJsonBlobs.length; idx++) {
|
||||
const blob = sortedJsonBlobs[idx];
|
||||
const parsed = blob.parsed;
|
||||
const role = parsed?.role || parsed?.message?.role;
|
||||
if (role === 'system') {
|
||||
continue;
|
||||
}
|
||||
messages.push({
|
||||
id: blob.id,
|
||||
sequence: idx + 1,
|
||||
rowid: blob.rowid,
|
||||
content: parsed,
|
||||
});
|
||||
}
|
||||
|
||||
return messages;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes live Cursor CLI NDJSON events. Persisted Cursor history is
|
||||
* normalized from SQLite blobs in fetchHistory().
|
||||
*/
|
||||
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
|
||||
const raw = readRawProviderMessage(rawMessage);
|
||||
if (raw?.type === 'assistant' && raw.message?.content?.[0]?.text) {
|
||||
return [createNormalizedMessage({
|
||||
kind: 'stream_delta',
|
||||
content: raw.message.content[0].text,
|
||||
sessionId,
|
||||
provider: PROVIDER,
|
||||
})];
|
||||
}
|
||||
|
||||
if (typeof rawMessage === 'string' && rawMessage.trim()) {
|
||||
return [createNormalizedMessage({
|
||||
kind: 'stream_delta',
|
||||
content: rawMessage,
|
||||
sessionId,
|
||||
provider: PROVIDER,
|
||||
})];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and paginates Cursor session history from its project-scoped store.db.
|
||||
*/
|
||||
async fetchHistory(
|
||||
sessionId: string,
|
||||
options: FetchHistoryOptions = {},
|
||||
): Promise<FetchHistoryResult> {
|
||||
const { projectPath = '', limit = null, offset = 0 } = options;
|
||||
|
||||
try {
|
||||
const blobs = await this.loadCursorBlobs(sessionId, projectPath);
|
||||
const allNormalized = this.normalizeCursorBlobs(blobs, sessionId);
|
||||
|
||||
if (limit !== null && limit > 0) {
|
||||
const start = offset;
|
||||
const page = allNormalized.slice(start, start + limit);
|
||||
return {
|
||||
messages: page,
|
||||
total: allNormalized.length,
|
||||
hasMore: start + limit < allNormalized.length,
|
||||
offset,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
messages: allNormalized,
|
||||
total: allNormalized.length,
|
||||
hasMore: false,
|
||||
offset: 0,
|
||||
limit: null,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[CursorProvider] Failed to load session ${sessionId}:`, message);
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Cursor SQLite message blobs into normalized messages and attaches
|
||||
* matching tool results to their tool_use entries.
|
||||
*/
|
||||
private normalizeCursorBlobs(blobs: CursorMessageBlob[], sessionId: string | null): NormalizedMessage[] {
|
||||
const messages: NormalizedMessage[] = [];
|
||||
const toolUseMap = new Map<string, NormalizedMessage>();
|
||||
const baseTime = Date.now();
|
||||
|
||||
for (let i = 0; i < blobs.length; i++) {
|
||||
const blob = blobs[i];
|
||||
const content = blob.content;
|
||||
const ts = new Date(baseTime + (blob.sequence ?? i) * 100).toISOString();
|
||||
const baseId = blob.id || generateMessageId('cursor');
|
||||
|
||||
try {
|
||||
if (!content?.role || !content?.content) {
|
||||
if (content?.message?.role && content?.message?.content) {
|
||||
if (content.message.role === 'system') {
|
||||
continue;
|
||||
}
|
||||
const role = content.message.role === 'user' ? 'user' : 'assistant';
|
||||
let text = '';
|
||||
if (Array.isArray(content.message.content)) {
|
||||
text = content.message.content
|
||||
.map((part: string | RawProviderMessage) => typeof part === 'string' ? part : part?.text || '')
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
} else if (typeof content.message.content === 'string') {
|
||||
text = content.message.content;
|
||||
}
|
||||
if (text?.trim()) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role,
|
||||
content: text,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
}));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.role === 'system') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.role === 'tool') {
|
||||
const toolItems = Array.isArray(content.content) ? content.content : [];
|
||||
for (const item of toolItems) {
|
||||
if (item?.type !== 'tool-result') {
|
||||
continue;
|
||||
}
|
||||
const toolCallId = item.toolCallId || content.id;
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_tr`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: toolCallId,
|
||||
content: item.result || '',
|
||||
isError: false,
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = content.role === 'user' ? 'user' : 'assistant';
|
||||
|
||||
if (Array.isArray(content.content)) {
|
||||
for (let partIdx = 0; partIdx < content.content.length; partIdx++) {
|
||||
const part = content.content[partIdx];
|
||||
|
||||
if (part?.type === 'text' && part?.text) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role,
|
||||
content: part.text,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
}));
|
||||
} else if (part?.type === 'reasoning' && part?.text) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: part.text,
|
||||
}));
|
||||
} else if (part?.type === 'tool-call' || part?.type === 'tool_use') {
|
||||
const rawToolName = part.toolName || part.name || 'Unknown Tool';
|
||||
const toolName = rawToolName === 'ApplyPatch' ? 'Edit' : rawToolName;
|
||||
const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`;
|
||||
const message = createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName,
|
||||
toolInput: part.args || part.input,
|
||||
toolId,
|
||||
});
|
||||
messages.push(message);
|
||||
toolUseMap.set(toolId, message);
|
||||
}
|
||||
}
|
||||
} else if (typeof content.content === 'string' && content.content.trim()) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role,
|
||||
content: content.content,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error normalizing cursor blob:', error);
|
||||
}
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.kind === 'tool_result' && msg.toolId && toolUseMap.has(msg.toolId)) {
|
||||
const toolUse = toolUseMap.get(msg.toolId);
|
||||
if (toolUse) {
|
||||
toolUse.toolResult = {
|
||||
content: msg.content,
|
||||
isError: msg.isError,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messages.sort((a, b) => {
|
||||
if (a.sequence !== undefined && b.sequence !== undefined) {
|
||||
return a.sequence - b.sequence;
|
||||
}
|
||||
if (a.rowid !== undefined && b.rowid !== undefined) {
|
||||
return a.rowid - b.rowid;
|
||||
}
|
||||
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
|
||||
});
|
||||
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
151
server/modules/providers/list/gemini/gemini-auth.provider.ts
Normal file
151
server/modules/providers/list/gemini/gemini-auth.provider.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||
import type { ProviderAuthStatus } from '@/shared/types.js';
|
||||
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
||||
|
||||
type GeminiCredentialsStatus = {
|
||||
authenticated: boolean;
|
||||
email: string | null;
|
||||
method: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export class GeminiProviderAuth implements IProviderAuth {
|
||||
/**
|
||||
* Checks whether the Gemini CLI is available on this host.
|
||||
*/
|
||||
private checkInstalled(): boolean {
|
||||
const cliPath = process.env.GEMINI_PATH || 'gemini';
|
||||
try {
|
||||
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Gemini CLI installation and credential status.
|
||||
*/
|
||||
async getStatus(): Promise<ProviderAuthStatus> {
|
||||
const installed = this.checkInstalled();
|
||||
|
||||
if (!installed) {
|
||||
return {
|
||||
installed,
|
||||
provider: 'gemini',
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Gemini CLI is not installed',
|
||||
};
|
||||
}
|
||||
|
||||
const credentials = await this.checkCredentials();
|
||||
|
||||
return {
|
||||
installed,
|
||||
provider: 'gemini',
|
||||
authenticated: credentials.authenticated,
|
||||
email: credentials.email,
|
||||
method: credentials.method,
|
||||
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks Gemini credentials from API key env vars or local OAuth credential files.
|
||||
*/
|
||||
private async checkCredentials(): Promise<GeminiCredentialsStatus> {
|
||||
if (process.env.GEMINI_API_KEY?.trim()) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||
}
|
||||
|
||||
try {
|
||||
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
|
||||
const content = await readFile(credsPath, 'utf8');
|
||||
const creds = readObjectRecord(JSON.parse(content)) ?? {};
|
||||
const accessToken = readOptionalString(creds.access_token);
|
||||
|
||||
if (!accessToken) {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'No valid tokens found in oauth_creds',
|
||||
};
|
||||
}
|
||||
|
||||
const refreshToken = readOptionalString(creds.refresh_token);
|
||||
const tokenInfo = await this.getTokenInfoEmail(accessToken);
|
||||
if (tokenInfo.valid) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: tokenInfo.email || 'OAuth Session',
|
||||
method: 'credentials_file',
|
||||
};
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: 'credentials_file',
|
||||
error: 'Access token invalid and no refresh token found',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
email: await this.getActiveAccountEmail() || 'OAuth Session',
|
||||
method: 'credentials_file',
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Gemini CLI not configured',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Gemini OAuth access token and returns an email when Google reports one.
|
||||
*/
|
||||
private async getTokenInfoEmail(accessToken: string): Promise<{ valid: boolean; email: string | null }> {
|
||||
try {
|
||||
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${accessToken}`);
|
||||
if (!tokenRes.ok) {
|
||||
return { valid: false, email: null };
|
||||
}
|
||||
|
||||
const tokenInfo = readObjectRecord(await tokenRes.json());
|
||||
return {
|
||||
valid: true,
|
||||
email: readOptionalString(tokenInfo?.email) ?? null,
|
||||
};
|
||||
} catch {
|
||||
return { valid: false, email: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads Gemini's active local Google account as an offline fallback for display.
|
||||
*/
|
||||
private async getActiveAccountEmail(): Promise<string | null> {
|
||||
try {
|
||||
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
|
||||
const accContent = await readFile(accPath, 'utf8');
|
||||
const accounts = readObjectRecord(JSON.parse(accContent));
|
||||
return readOptionalString(accounts?.active) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
110
server/modules/providers/list/gemini/gemini-mcp.provider.ts
Normal file
110
server/modules/providers/list/gemini/gemini-mcp.provider.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
|
||||
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import {
|
||||
AppError,
|
||||
readJsonConfig,
|
||||
readObjectRecord,
|
||||
readOptionalString,
|
||||
readStringArray,
|
||||
readStringRecord,
|
||||
writeJsonConfig,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
export class GeminiMcpProvider extends McpProvider {
|
||||
constructor() {
|
||||
super('gemini', ['user', 'project'], ['stdio', 'http', 'sse']);
|
||||
}
|
||||
|
||||
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.gemini', 'settings.json')
|
||||
: path.join(workspacePath, '.gemini', 'settings.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
return readObjectRecord(config.mcpServers) ?? {};
|
||||
}
|
||||
|
||||
protected async writeScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
servers: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.gemini', 'settings.json')
|
||||
: path.join(workspacePath, '.gemini', 'settings.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
config.mcpServers = servers;
|
||||
await writeJsonConfig(filePath, config);
|
||||
}
|
||||
|
||||
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
|
||||
if (input.transport === 'stdio') {
|
||||
if (!input.command?.trim()) {
|
||||
throw new AppError('command is required for stdio MCP servers.', {
|
||||
code: 'MCP_COMMAND_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
command: input.command,
|
||||
args: input.args ?? [],
|
||||
env: input.env ?? {},
|
||||
cwd: input.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.url?.trim()) {
|
||||
throw new AppError('url is required for http/sse MCP servers.', {
|
||||
code: 'MCP_URL_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: input.transport,
|
||||
url: input.url,
|
||||
headers: input.headers ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
protected normalizeServerConfig(
|
||||
scope: McpScope,
|
||||
name: string,
|
||||
rawConfig: unknown,
|
||||
): ProviderMcpServer | null {
|
||||
if (!rawConfig || typeof rawConfig !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = rawConfig as Record<string, unknown>;
|
||||
if (typeof config.command === 'string') {
|
||||
return {
|
||||
provider: 'gemini',
|
||||
name,
|
||||
scope,
|
||||
transport: 'stdio',
|
||||
command: config.command,
|
||||
args: readStringArray(config.args),
|
||||
env: readStringRecord(config.env),
|
||||
cwd: readOptionalString(config.cwd),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof config.url === 'string') {
|
||||
const transport = readOptionalString(config.type) === 'sse' ? 'sse' : 'http';
|
||||
return {
|
||||
provider: 'gemini',
|
||||
name,
|
||||
scope,
|
||||
transport,
|
||||
url: config.url,
|
||||
headers: readStringRecord(config.headers),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
235
server/modules/providers/list/gemini/gemini.provider.ts
Normal file
235
server/modules/providers/list/gemini/gemini.provider.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import sessionManager from '@/sessionManager.js';
|
||||
import { getGeminiCliSessionMessages } from '@/projects.js';
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import { GeminiProviderAuth } from '@/modules/providers/list/gemini/gemini-auth.provider.js';
|
||||
import { GeminiMcpProvider } from '@/modules/providers/list/gemini/gemini-mcp.provider.js';
|
||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||
import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'gemini';
|
||||
|
||||
type RawProviderMessage = Record<string, any>;
|
||||
|
||||
function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
|
||||
return readObjectRecord(raw) as RawProviderMessage | null;
|
||||
}
|
||||
|
||||
export class GeminiProvider extends AbstractProvider {
|
||||
readonly mcp = new GeminiMcpProvider();
|
||||
readonly auth: IProviderAuth = new GeminiProviderAuth();
|
||||
|
||||
constructor() {
|
||||
super('gemini');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes live Gemini stream-json events into the shared message shape.
|
||||
*
|
||||
* Gemini history uses a different session file shape, so fetchHistory handles
|
||||
* that separately after loading raw persisted messages.
|
||||
*/
|
||||
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
|
||||
const raw = readRawProviderMessage(rawMessage);
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('gemini');
|
||||
|
||||
if (raw.type === 'message' && raw.role === 'assistant') {
|
||||
const content = raw.content || '';
|
||||
const messages: NormalizedMessage[] = [];
|
||||
if (content) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'stream_delta',
|
||||
content,
|
||||
}));
|
||||
}
|
||||
if (raw.delta !== true) {
|
||||
messages.push(createNormalizedMessage({
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'stream_end',
|
||||
}));
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_use') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: raw.tool_name,
|
||||
toolInput: raw.parameters || {},
|
||||
toolId: raw.tool_id || baseId,
|
||||
})];
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_result') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: raw.tool_id || '',
|
||||
content: raw.output === undefined ? '' : String(raw.output),
|
||||
isError: raw.status === 'error',
|
||||
})];
|
||||
}
|
||||
|
||||
if (raw.type === 'result') {
|
||||
const messages = [createNormalizedMessage({
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'stream_end',
|
||||
})];
|
||||
if (raw.stats?.total_tokens) {
|
||||
messages.push(createNormalizedMessage({
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'status',
|
||||
text: 'Complete',
|
||||
tokens: raw.stats.total_tokens,
|
||||
canInterrupt: false,
|
||||
}));
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
if (raw.type === 'error') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'error',
|
||||
content: raw.error || raw.message || 'Unknown Gemini streaming error',
|
||||
})];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads Gemini history from the in-memory session manager first, then falls
|
||||
* back to Gemini CLI session files on disk.
|
||||
*/
|
||||
async fetchHistory(
|
||||
sessionId: string,
|
||||
_options: FetchHistoryOptions = {},
|
||||
): Promise<FetchHistoryResult> {
|
||||
let rawMessages: RawProviderMessage[];
|
||||
try {
|
||||
rawMessages = sessionManager.getSessionMessages(sessionId) as RawProviderMessage[];
|
||||
|
||||
if (rawMessages.length === 0) {
|
||||
rawMessages = await getGeminiCliSessionMessages(sessionId) as RawProviderMessage[];
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[GeminiProvider] Failed to load session ${sessionId}:`, message);
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
|
||||
const normalized: NormalizedMessage[] = [];
|
||||
for (let i = 0; i < rawMessages.length; i++) {
|
||||
const raw = rawMessages[i];
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('gemini');
|
||||
|
||||
const role = raw.message?.role || raw.role;
|
||||
const content = raw.message?.content || raw.content;
|
||||
|
||||
if (!role || !content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedRole = role === 'user' ? 'user' : 'assistant';
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
for (let partIdx = 0; partIdx < content.length; partIdx++) {
|
||||
const part = content[partIdx];
|
||||
if (part.type === 'text' && part.text) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: normalizedRole,
|
||||
content: part.text,
|
||||
}));
|
||||
} else if (part.type === 'tool_use') {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: part.name,
|
||||
toolInput: part.input,
|
||||
toolId: part.id || generateMessageId('gemini_tool'),
|
||||
}));
|
||||
} else if (part.type === 'tool_result') {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: part.tool_use_id || '',
|
||||
content: part.content === undefined ? '' : String(part.content),
|
||||
isError: Boolean(part.is_error),
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else if (typeof content === 'string' && content.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: normalizedRole,
|
||||
content,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const toolResultMap = new Map<string, NormalizedMessage>();
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_result' && msg.toolId) {
|
||||
toolResultMap.set(msg.toolId, msg);
|
||||
}
|
||||
}
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
|
||||
const toolResult = toolResultMap.get(msg.toolId);
|
||||
if (toolResult) {
|
||||
msg.toolResult = { content: toolResult.content, isError: toolResult.isError };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages: normalized,
|
||||
total: normalized.length,
|
||||
hasMore: false,
|
||||
offset: 0,
|
||||
limit: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
36
server/modules/providers/provider.registry.ts
Normal file
36
server/modules/providers/provider.registry.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { ClaudeProvider } from '@/modules/providers/list/claude/claude.provider.js';
|
||||
import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js';
|
||||
import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js';
|
||||
import { GeminiProvider } from '@/modules/providers/list/gemini/gemini.provider.js';
|
||||
import type { IProvider } from '@/shared/interfaces.js';
|
||||
import type { LLMProvider } from '@/shared/types.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
const providers: Record<LLMProvider, IProvider> = {
|
||||
claude: new ClaudeProvider(),
|
||||
codex: new CodexProvider(),
|
||||
cursor: new CursorProvider(),
|
||||
gemini: new GeminiProvider(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Central registry for resolving concrete provider implementations by id.
|
||||
*/
|
||||
export const providerRegistry = {
|
||||
listProviders(): IProvider[] {
|
||||
return Object.values(providers);
|
||||
},
|
||||
|
||||
resolveProvider(provider: string): IProvider {
|
||||
const key = provider as LLMProvider;
|
||||
const resolvedProvider = providers[key];
|
||||
if (!resolvedProvider) {
|
||||
throw new AppError(`Unsupported provider "${provider}".`, {
|
||||
code: 'UNSUPPORTED_PROVIDER',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return resolvedProvider;
|
||||
},
|
||||
};
|
||||
217
server/modules/providers/provider.routes.ts
Normal file
217
server/modules/providers/provider.routes.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import express, { type Request, type Response } from 'express';
|
||||
|
||||
import { providerAuthService } from '@/modules/providers/services/provider-auth.service.js';
|
||||
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
|
||||
import type { LLMProvider, McpScope, McpTransport, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import { AppError, asyncHandler, createApiSuccessResponse } from '@/shared/utils.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const readPathParam = (value: unknown, name: string): string => {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && typeof value[0] === 'string') {
|
||||
return value[0];
|
||||
}
|
||||
|
||||
throw new AppError(`${name} path parameter is invalid.`, {
|
||||
code: 'INVALID_PATH_PARAMETER',
|
||||
statusCode: 400,
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeProviderParam = (value: unknown): string =>
|
||||
readPathParam(value, 'provider').trim().toLowerCase();
|
||||
|
||||
const readOptionalQueryString = (value: unknown): string | undefined => {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
};
|
||||
|
||||
const parseMcpScope = (value: unknown): McpScope | undefined => {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = readOptionalQueryString(value);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (normalized === 'user' || normalized === 'local' || normalized === 'project') {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
throw new AppError(`Unsupported MCP scope "${normalized}".`, {
|
||||
code: 'INVALID_MCP_SCOPE',
|
||||
statusCode: 400,
|
||||
});
|
||||
};
|
||||
|
||||
const parseMcpTransport = (value: unknown): McpTransport => {
|
||||
const normalized = readOptionalQueryString(value);
|
||||
if (!normalized) {
|
||||
throw new AppError('transport is required.', {
|
||||
code: 'MCP_TRANSPORT_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (normalized === 'stdio' || normalized === 'http' || normalized === 'sse') {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
throw new AppError(`Unsupported MCP transport "${normalized}".`, {
|
||||
code: 'INVALID_MCP_TRANSPORT',
|
||||
statusCode: 400,
|
||||
});
|
||||
};
|
||||
|
||||
const parseMcpUpsertPayload = (payload: unknown): UpsertProviderMcpServerInput => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
throw new AppError('Request body must be an object.', {
|
||||
code: 'INVALID_REQUEST_BODY',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const body = payload as Record<string, unknown>;
|
||||
const name = readOptionalQueryString(body.name);
|
||||
if (!name) {
|
||||
throw new AppError('name is required.', {
|
||||
code: 'MCP_NAME_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const transport = parseMcpTransport(body.transport);
|
||||
const scope = parseMcpScope(body.scope);
|
||||
const workspacePath = readOptionalQueryString(body.workspacePath);
|
||||
|
||||
return {
|
||||
name,
|
||||
transport,
|
||||
scope,
|
||||
workspacePath,
|
||||
command: readOptionalQueryString(body.command),
|
||||
args: Array.isArray(body.args) ? body.args.filter((entry): entry is string => typeof entry === 'string') : undefined,
|
||||
env: typeof body.env === 'object' && body.env !== null
|
||||
? Object.fromEntries(
|
||||
Object.entries(body.env as Record<string, unknown>).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
cwd: readOptionalQueryString(body.cwd),
|
||||
url: readOptionalQueryString(body.url),
|
||||
headers: typeof body.headers === 'object' && body.headers !== null
|
||||
? Object.fromEntries(
|
||||
Object.entries(body.headers as Record<string, unknown>).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
envVars: Array.isArray(body.envVars)
|
||||
? body.envVars.filter((entry): entry is string => typeof entry === 'string')
|
||||
: undefined,
|
||||
bearerTokenEnvVar: readOptionalQueryString(body.bearerTokenEnvVar),
|
||||
envHttpHeaders: typeof body.envHttpHeaders === 'object' && body.envHttpHeaders !== null
|
||||
? Object.fromEntries(
|
||||
Object.entries(body.envHttpHeaders as Record<string, unknown>).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const parseProvider = (value: unknown): LLMProvider => {
|
||||
const normalized = normalizeProviderParam(value);
|
||||
if (normalized === 'claude' || normalized === 'codex' || normalized === 'cursor' || normalized === 'gemini') {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
throw new AppError(`Unsupported provider "${normalized}".`, {
|
||||
code: 'UNSUPPORTED_PROVIDER',
|
||||
statusCode: 400,
|
||||
});
|
||||
};
|
||||
|
||||
router.get(
|
||||
'/:provider/auth/status',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const status = await providerAuthService.getProviderAuthStatus(provider);
|
||||
res.json(status);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:provider/mcp/servers',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const workspacePath = readOptionalQueryString(req.query.workspacePath);
|
||||
const scope = parseMcpScope(req.query.scope);
|
||||
|
||||
if (scope) {
|
||||
const servers = await providerMcpService.listProviderMcpServersForScope(provider, scope, { workspacePath });
|
||||
res.json(createApiSuccessResponse({ provider, scope, servers }));
|
||||
return;
|
||||
}
|
||||
|
||||
const groupedServers = await providerMcpService.listProviderMcpServers(provider, { workspacePath });
|
||||
res.json(createApiSuccessResponse({ provider, scopes: groupedServers }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:provider/mcp/servers',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const payload = parseMcpUpsertPayload(req.body);
|
||||
const server = await providerMcpService.upsertProviderMcpServer(provider, payload);
|
||||
res.status(201).json(createApiSuccessResponse({ server }));
|
||||
}),
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:provider/mcp/servers/:name',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const provider = parseProvider(req.params.provider);
|
||||
const scope = parseMcpScope(req.query.scope);
|
||||
const workspacePath = readOptionalQueryString(req.query.workspacePath);
|
||||
const result = await providerMcpService.removeProviderMcpServer(provider, {
|
||||
name: readPathParam(req.params.name, 'name'),
|
||||
scope,
|
||||
workspacePath,
|
||||
});
|
||||
res.json(createApiSuccessResponse(result));
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/mcp/servers/global',
|
||||
asyncHandler(async (req: Request, res: Response) => {
|
||||
const payload = parseMcpUpsertPayload(req.body);
|
||||
if (payload.scope === 'local') {
|
||||
throw new AppError('Global MCP add supports only "user" or "project" scopes.', {
|
||||
code: 'INVALID_GLOBAL_MCP_SCOPE',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const results = await providerMcpService.addMcpServerToAllProviders({
|
||||
...payload,
|
||||
scope: payload.scope === 'user' ? 'user' : 'project',
|
||||
});
|
||||
res.status(201).json(createApiSuccessResponse({ results }));
|
||||
}),
|
||||
);
|
||||
|
||||
export default router;
|
||||
94
server/modules/providers/services/mcp.service.ts
Normal file
94
server/modules/providers/services/mcp.service.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import os from 'node:os';
|
||||
|
||||
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||
import type { LLMProvider, McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
/** Cursor MCP is not supported on Windows hosts (no Cursor CLI integration). */
|
||||
function includeProviderInGlobalMcp(providerId: LLMProvider): boolean {
|
||||
if (providerId === 'cursor' && os.platform() === 'win32') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
export const providerMcpService = {
|
||||
/**
|
||||
* Lists MCP servers for one provider grouped by supported scopes.
|
||||
*/
|
||||
async listProviderMcpServers(
|
||||
providerName: string,
|
||||
options?: { workspacePath?: string },
|
||||
): Promise<Record<McpScope, ProviderMcpServer[]>> {
|
||||
const provider = providerRegistry.resolveProvider(providerName);
|
||||
return provider.mcp.listServers(options);
|
||||
},
|
||||
|
||||
/**
|
||||
* Lists MCP servers for one provider scope.
|
||||
*/
|
||||
async listProviderMcpServersForScope(
|
||||
providerName: string,
|
||||
scope: McpScope,
|
||||
options?: { workspacePath?: string },
|
||||
): Promise<ProviderMcpServer[]> {
|
||||
const provider = providerRegistry.resolveProvider(providerName);
|
||||
return provider.mcp.listServersForScope(scope, options);
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds or updates one provider MCP server.
|
||||
*/
|
||||
async upsertProviderMcpServer(
|
||||
providerName: string,
|
||||
input: UpsertProviderMcpServerInput,
|
||||
): Promise<ProviderMcpServer> {
|
||||
const provider = providerRegistry.resolveProvider(providerName);
|
||||
return provider.mcp.upsertServer(input);
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes one provider MCP server.
|
||||
*/
|
||||
async removeProviderMcpServer(
|
||||
providerName: string,
|
||||
input: { name: string; scope?: McpScope; workspacePath?: string },
|
||||
): Promise<{ removed: boolean; provider: LLMProvider; name: string; scope: McpScope }> {
|
||||
const provider = providerRegistry.resolveProvider(providerName);
|
||||
return provider.mcp.removeServer(input);
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds one HTTP/stdio MCP server to every provider.
|
||||
*/
|
||||
async addMcpServerToAllProviders(
|
||||
input: Omit<UpsertProviderMcpServerInput, 'scope'> & { scope?: Exclude<McpScope, 'local'> },
|
||||
): Promise<Array<{ provider: LLMProvider; created: boolean; error?: string }>> {
|
||||
if (input.transport !== 'stdio' && input.transport !== 'http') {
|
||||
throw new AppError('Global MCP add supports only "stdio" and "http".', {
|
||||
code: 'INVALID_GLOBAL_MCP_TRANSPORT',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const scope = input.scope ?? 'project';
|
||||
const results: Array<{ provider: LLMProvider; created: boolean; error?: string }> = [];
|
||||
const providers = providerRegistry.listProviders().filter((p) => includeProviderInGlobalMcp(p.id));
|
||||
for (const provider of providers) {
|
||||
try {
|
||||
await provider.mcp.upsertServer({ ...input, scope });
|
||||
results.push({ provider: provider.id, created: true });
|
||||
} catch (error) {
|
||||
results.push({
|
||||
provider: provider.id,
|
||||
created: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
};
|
||||
12
server/modules/providers/services/provider-auth.service.ts
Normal file
12
server/modules/providers/services/provider-auth.service.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||
import type { ProviderAuthStatus } from '@/shared/types.js';
|
||||
|
||||
export const providerAuthService = {
|
||||
/**
|
||||
* Resolves a provider and returns its installation/authentication status.
|
||||
*/
|
||||
async getProviderAuthStatus(providerName: string): Promise<ProviderAuthStatus> {
|
||||
const provider = providerRegistry.resolveProvider(providerName);
|
||||
return provider.auth.getStatus();
|
||||
},
|
||||
};
|
||||
45
server/modules/providers/services/sessions.service.ts
Normal file
45
server/modules/providers/services/sessions.service.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { providerRegistry } from '@/modules/providers/provider.registry.js';
|
||||
import type {
|
||||
FetchHistoryOptions,
|
||||
FetchHistoryResult,
|
||||
LLMProvider,
|
||||
NormalizedMessage,
|
||||
} from '@/shared/types.js';
|
||||
|
||||
/**
|
||||
* Application service for provider-backed session message operations.
|
||||
*
|
||||
* Callers pass a provider id and this service resolves the concrete provider
|
||||
* class, keeping normalization/history call sites decoupled from implementation
|
||||
* file layout.
|
||||
*/
|
||||
export const sessionsService = {
|
||||
/**
|
||||
* Lists provider ids that can load session history and normalize live messages.
|
||||
*/
|
||||
listProviderIds(): LLMProvider[] {
|
||||
return providerRegistry.listProviders().map((provider) => provider.id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Normalizes one provider-native event into frontend session message events.
|
||||
*/
|
||||
normalizeMessage(
|
||||
providerName: string,
|
||||
raw: unknown,
|
||||
sessionId: string | null,
|
||||
): NormalizedMessage[] {
|
||||
return providerRegistry.resolveProvider(providerName).normalizeMessage(raw, sessionId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches normalized persisted session history for one provider/session pair.
|
||||
*/
|
||||
fetchHistory(
|
||||
providerName: string,
|
||||
sessionId: string,
|
||||
options?: FetchHistoryOptions,
|
||||
): Promise<FetchHistoryResult> {
|
||||
return providerRegistry.resolveProvider(providerName).fetchHistory(sessionId, options);
|
||||
},
|
||||
};
|
||||
31
server/modules/providers/shared/base/abstract.provider.ts
Normal file
31
server/modules/providers/shared/base/abstract.provider.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { IProvider, IProviderAuth, IProviderMcp } from '@/shared/interfaces.js';
|
||||
import type {
|
||||
FetchHistoryOptions,
|
||||
FetchHistoryResult,
|
||||
LLMProvider,
|
||||
NormalizedMessage,
|
||||
} from '@/shared/types.js';
|
||||
|
||||
/**
|
||||
* Shared provider base.
|
||||
*
|
||||
* Concrete providers must expose auth/MCP handlers and implement message
|
||||
* normalization/history loading because those behaviors depend on native
|
||||
* SDK/CLI formats.
|
||||
*/
|
||||
export abstract class AbstractProvider implements IProvider {
|
||||
readonly id: LLMProvider;
|
||||
abstract readonly mcp: IProviderMcp;
|
||||
abstract readonly auth: IProviderAuth;
|
||||
|
||||
protected constructor(id: LLMProvider) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
abstract normalizeMessage(raw: unknown, sessionId: string | null): NormalizedMessage[];
|
||||
|
||||
abstract fetchHistory(
|
||||
sessionId: string,
|
||||
options?: FetchHistoryOptions,
|
||||
): Promise<FetchHistoryResult>;
|
||||
}
|
||||
151
server/modules/providers/shared/mcp/mcp.provider.ts
Normal file
151
server/modules/providers/shared/mcp/mcp.provider.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type { IProviderMcp } from '@/shared/interfaces.js';
|
||||
import type { LLMProvider, McpScope, McpTransport, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
const resolveWorkspacePath = (workspacePath?: string): string =>
|
||||
path.resolve(workspacePath ?? process.cwd());
|
||||
|
||||
const normalizeServerName = (name: string): string => {
|
||||
const normalized = name.trim();
|
||||
if (!normalized) {
|
||||
throw new AppError('MCP server name is required.', {
|
||||
code: 'MCP_SERVER_NAME_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared MCP provider for provider-specific config readers/writers.
|
||||
*/
|
||||
export abstract class McpProvider implements IProviderMcp {
|
||||
protected readonly provider: LLMProvider;
|
||||
protected readonly supportedScopes: McpScope[];
|
||||
protected readonly supportedTransports: McpTransport[];
|
||||
|
||||
protected constructor(
|
||||
provider: LLMProvider,
|
||||
supportedScopes: McpScope[],
|
||||
supportedTransports: McpTransport[],
|
||||
) {
|
||||
this.provider = provider;
|
||||
this.supportedScopes = supportedScopes;
|
||||
this.supportedTransports = supportedTransports;
|
||||
}
|
||||
|
||||
async listServers(options?: { workspacePath?: string }): Promise<Record<McpScope, ProviderMcpServer[]>> {
|
||||
const grouped: Record<McpScope, ProviderMcpServer[]> = {
|
||||
user: [],
|
||||
local: [],
|
||||
project: [],
|
||||
};
|
||||
|
||||
for (const scope of this.supportedScopes) {
|
||||
grouped[scope] = await this.listServersForScope(scope, options);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
async listServersForScope(
|
||||
scope: McpScope,
|
||||
options?: { workspacePath?: string },
|
||||
): Promise<ProviderMcpServer[]> {
|
||||
if (!this.supportedScopes.includes(scope)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const workspacePath = resolveWorkspacePath(options?.workspacePath);
|
||||
const scopedServers = await this.readScopedServers(scope, workspacePath);
|
||||
return Object.entries(scopedServers)
|
||||
.map(([name, rawConfig]) => this.normalizeServerConfig(scope, name, rawConfig))
|
||||
.filter((entry): entry is ProviderMcpServer => entry !== null);
|
||||
}
|
||||
|
||||
async upsertServer(input: UpsertProviderMcpServerInput): Promise<ProviderMcpServer> {
|
||||
const scope = input.scope ?? 'project';
|
||||
this.assertScopeAndTransport(scope, input.transport);
|
||||
|
||||
const workspacePath = resolveWorkspacePath(input.workspacePath);
|
||||
const normalizedName = normalizeServerName(input.name);
|
||||
const scopedServers = await this.readScopedServers(scope, workspacePath);
|
||||
scopedServers[normalizedName] = this.buildServerConfig(input);
|
||||
await this.writeScopedServers(scope, workspacePath, scopedServers);
|
||||
|
||||
return {
|
||||
provider: this.provider,
|
||||
name: normalizedName,
|
||||
scope,
|
||||
transport: input.transport,
|
||||
command: input.command,
|
||||
args: input.args,
|
||||
env: input.env,
|
||||
cwd: input.cwd,
|
||||
url: input.url,
|
||||
headers: input.headers,
|
||||
envVars: input.envVars,
|
||||
bearerTokenEnvVar: input.bearerTokenEnvVar,
|
||||
envHttpHeaders: input.envHttpHeaders,
|
||||
};
|
||||
}
|
||||
|
||||
async removeServer(
|
||||
input: { name: string; scope?: McpScope; workspacePath?: string },
|
||||
): Promise<{ removed: boolean; provider: LLMProvider; name: string; scope: McpScope }> {
|
||||
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 removed = Object.prototype.hasOwnProperty.call(scopedServers, normalizedName);
|
||||
if (removed) {
|
||||
delete scopedServers[normalizedName];
|
||||
await this.writeScopedServers(scope, workspacePath, scopedServers);
|
||||
}
|
||||
|
||||
return { removed, provider: this.provider, name: normalizedName, scope };
|
||||
}
|
||||
|
||||
protected abstract readScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
): Promise<Record<string, unknown>>;
|
||||
|
||||
protected abstract writeScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
servers: Record<string, unknown>,
|
||||
): Promise<void>;
|
||||
|
||||
protected abstract buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown>;
|
||||
|
||||
protected abstract normalizeServerConfig(
|
||||
scope: McpScope,
|
||||
name: string,
|
||||
rawConfig: unknown,
|
||||
): ProviderMcpServer | null;
|
||||
|
||||
protected assertScope(scope: McpScope): void {
|
||||
if (!this.supportedScopes.includes(scope)) {
|
||||
throw new AppError(`Provider "${this.provider}" does not support "${scope}" MCP scope.`, {
|
||||
code: 'MCP_SCOPE_NOT_SUPPORTED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected assertScopeAndTransport(scope: McpScope, transport: McpTransport): void {
|
||||
this.assertScope(scope);
|
||||
if (!this.supportedTransports.includes(transport)) {
|
||||
throw new AppError(`Provider "${this.provider}" does not support "${transport}" MCP transport.`, {
|
||||
code: 'MCP_TRANSPORT_NOT_SUPPORTED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
293
server/modules/providers/tests/mcp.test.ts
Normal file
293
server/modules/providers/tests/mcp.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import TOML from '@iarna/toml';
|
||||
|
||||
import { providerMcpService } from '@/modules/providers/services/mcp.service.js';
|
||||
import { AppError } from '@/shared/utils.js';
|
||||
|
||||
const patchHomeDir = (nextHomeDir: string) => {
|
||||
const original = os.homedir;
|
||||
(os as any).homedir = () => nextHomeDir;
|
||||
return () => {
|
||||
(os as any).homedir = original;
|
||||
};
|
||||
};
|
||||
|
||||
const readJson = async (filePath: string): Promise<Record<string, unknown>> => {
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
return JSON.parse(content) as Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* This test covers Claude MCP support for all scopes (user/local/project) and all transports (stdio/http/sse),
|
||||
* including add, update/list, and remove operations.
|
||||
*/
|
||||
test('providerMcpService handles claude MCP scopes/transports with file-backed persistence', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-claude-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
try {
|
||||
await providerMcpService.upsertProviderMcpServer('claude', {
|
||||
name: 'claude-user-stdio',
|
||||
scope: 'user',
|
||||
transport: 'stdio',
|
||||
command: 'npx',
|
||||
args: ['-y', 'my-server'],
|
||||
env: { API_KEY: 'secret' },
|
||||
});
|
||||
|
||||
await providerMcpService.upsertProviderMcpServer('claude', {
|
||||
name: 'claude-local-http',
|
||||
scope: 'local',
|
||||
transport: 'http',
|
||||
url: 'https://example.com/mcp',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
await providerMcpService.upsertProviderMcpServer('claude', {
|
||||
name: 'claude-project-sse',
|
||||
scope: 'project',
|
||||
transport: 'sse',
|
||||
url: 'https://example.com/sse',
|
||||
headers: { 'X-API-Key': 'abc' },
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
const grouped = await providerMcpService.listProviderMcpServers('claude', { workspacePath });
|
||||
assert.ok(grouped.user.some((server) => server.name === 'claude-user-stdio' && server.transport === 'stdio'));
|
||||
assert.ok(grouped.local.some((server) => server.name === 'claude-local-http' && server.transport === 'http'));
|
||||
assert.ok(grouped.project.some((server) => server.name === 'claude-project-sse' && server.transport === 'sse'));
|
||||
|
||||
// update behavior is the same upsert route with same name
|
||||
await providerMcpService.upsertProviderMcpServer('claude', {
|
||||
name: 'claude-project-sse',
|
||||
scope: 'project',
|
||||
transport: 'sse',
|
||||
url: 'https://example.com/sse-updated',
|
||||
headers: { 'X-API-Key': 'updated' },
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
const projectConfig = await readJson(path.join(workspacePath, '.mcp.json'));
|
||||
const projectServers = projectConfig.mcpServers as Record<string, unknown>;
|
||||
const projectServer = projectServers['claude-project-sse'] as Record<string, unknown>;
|
||||
assert.equal(projectServer.url, 'https://example.com/sse-updated');
|
||||
|
||||
const removeResult = await providerMcpService.removeProviderMcpServer('claude', {
|
||||
name: 'claude-local-http',
|
||||
scope: 'local',
|
||||
workspacePath,
|
||||
});
|
||||
assert.equal(removeResult.removed, true);
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers Codex MCP support for user/project scopes, stdio/http formats,
|
||||
* and validation for unsupported scope/transport combinations.
|
||||
*/
|
||||
test('providerMcpService handles codex MCP TOML config and capability validation', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-codex-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
try {
|
||||
await providerMcpService.upsertProviderMcpServer('codex', {
|
||||
name: 'codex-user-stdio',
|
||||
scope: 'user',
|
||||
transport: 'stdio',
|
||||
command: 'python',
|
||||
args: ['server.py'],
|
||||
env: { API_KEY: 'x' },
|
||||
envVars: ['API_KEY'],
|
||||
cwd: '/tmp',
|
||||
});
|
||||
|
||||
await providerMcpService.upsertProviderMcpServer('codex', {
|
||||
name: 'codex-project-http',
|
||||
scope: 'project',
|
||||
transport: 'http',
|
||||
url: 'https://codex.example.com/mcp',
|
||||
headers: { 'X-Custom-Header': 'value' },
|
||||
envHttpHeaders: { 'X-API-Key': 'MY_API_KEY_ENV' },
|
||||
bearerTokenEnvVar: 'MY_API_TOKEN',
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
const userTomlPath = path.join(tempRoot, '.codex', 'config.toml');
|
||||
const userConfig = TOML.parse(await fs.readFile(userTomlPath, 'utf8')) as Record<string, unknown>;
|
||||
const userServers = userConfig.mcp_servers as Record<string, unknown>;
|
||||
const userStdio = userServers['codex-user-stdio'] as Record<string, unknown>;
|
||||
assert.equal(userStdio.command, 'python');
|
||||
|
||||
const projectTomlPath = path.join(workspacePath, '.codex', 'config.toml');
|
||||
const projectConfig = TOML.parse(await fs.readFile(projectTomlPath, 'utf8')) as Record<string, unknown>;
|
||||
const projectServers = projectConfig.mcp_servers as Record<string, unknown>;
|
||||
const projectHttp = projectServers['codex-project-http'] as Record<string, unknown>;
|
||||
assert.equal(projectHttp.url, 'https://codex.example.com/mcp');
|
||||
|
||||
await assert.rejects(
|
||||
providerMcpService.upsertProviderMcpServer('codex', {
|
||||
name: 'codex-local',
|
||||
scope: 'local',
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof AppError &&
|
||||
error.code === 'MCP_SCOPE_NOT_SUPPORTED' &&
|
||||
error.statusCode === 400,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
providerMcpService.upsertProviderMcpServer('codex', {
|
||||
name: 'codex-sse',
|
||||
scope: 'project',
|
||||
transport: 'sse',
|
||||
url: 'https://example.com/sse',
|
||||
workspacePath,
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof AppError &&
|
||||
error.code === 'MCP_TRANSPORT_NOT_SUPPORTED' &&
|
||||
error.statusCode === 400,
|
||||
);
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers Gemini/Cursor MCP JSON formats and user/project scope persistence.
|
||||
*/
|
||||
test('providerMcpService handles gemini and cursor MCP JSON config formats', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-gc-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
try {
|
||||
await providerMcpService.upsertProviderMcpServer('gemini', {
|
||||
name: 'gemini-stdio',
|
||||
scope: 'user',
|
||||
transport: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: { TOKEN: '$TOKEN' },
|
||||
cwd: './server',
|
||||
});
|
||||
|
||||
await providerMcpService.upsertProviderMcpServer('gemini', {
|
||||
name: 'gemini-http',
|
||||
scope: 'project',
|
||||
transport: 'http',
|
||||
url: 'https://gemini.example.com/mcp',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
await providerMcpService.upsertProviderMcpServer('cursor', {
|
||||
name: 'cursor-stdio',
|
||||
scope: 'project',
|
||||
transport: 'stdio',
|
||||
command: 'npx',
|
||||
args: ['-y', 'mcp-server'],
|
||||
env: { API_KEY: 'value' },
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
await providerMcpService.upsertProviderMcpServer('cursor', {
|
||||
name: 'cursor-http',
|
||||
scope: 'user',
|
||||
transport: 'http',
|
||||
url: 'http://localhost:3333/mcp',
|
||||
headers: { API_KEY: 'value' },
|
||||
});
|
||||
|
||||
const geminiUserConfig = await readJson(path.join(tempRoot, '.gemini', 'settings.json'));
|
||||
const geminiUserServer = (geminiUserConfig.mcpServers as Record<string, unknown>)['gemini-stdio'] as Record<string, unknown>;
|
||||
assert.equal(geminiUserServer.command, 'node');
|
||||
assert.equal(geminiUserServer.type, undefined);
|
||||
|
||||
const geminiProjectConfig = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
|
||||
const geminiProjectServer = (geminiProjectConfig.mcpServers as Record<string, unknown>)['gemini-http'] as Record<string, unknown>;
|
||||
assert.equal(geminiProjectServer.type, 'http');
|
||||
|
||||
const cursorUserConfig = await readJson(path.join(tempRoot, '.cursor', 'mcp.json'));
|
||||
const cursorHttpServer = (cursorUserConfig.mcpServers as Record<string, unknown>)['cursor-http'] as Record<string, unknown>;
|
||||
assert.equal(cursorHttpServer.url, 'http://localhost:3333/mcp');
|
||||
assert.equal(cursorHttpServer.type, undefined);
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* This test covers the global MCP adder requirement: only http/stdio are allowed and
|
||||
* one payload is written to all providers.
|
||||
*/
|
||||
test('providerMcpService global adder writes to all providers and rejects unsupported transports', { concurrency: false }, async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'llm-mcp-global-'));
|
||||
const workspacePath = path.join(tempRoot, 'workspace');
|
||||
await fs.mkdir(workspacePath, { recursive: true });
|
||||
|
||||
const restoreHomeDir = patchHomeDir(tempRoot);
|
||||
try {
|
||||
const globalResult = await providerMcpService.addMcpServerToAllProviders({
|
||||
name: 'global-http',
|
||||
scope: 'project',
|
||||
transport: 'http',
|
||||
url: 'https://global.example.com/mcp',
|
||||
workspacePath,
|
||||
});
|
||||
|
||||
const expectCursorGlobal = process.platform !== 'win32';
|
||||
assert.equal(globalResult.length, expectCursorGlobal ? 4 : 3);
|
||||
assert.ok(globalResult.every((entry) => entry.created === true));
|
||||
|
||||
const claudeProject = await readJson(path.join(workspacePath, '.mcp.json'));
|
||||
assert.ok((claudeProject.mcpServers as Record<string, unknown>)['global-http']);
|
||||
|
||||
const codexProject = TOML.parse(await fs.readFile(path.join(workspacePath, '.codex', 'config.toml'), 'utf8')) as Record<string, unknown>;
|
||||
assert.ok((codexProject.mcp_servers as Record<string, unknown>)['global-http']);
|
||||
|
||||
const geminiProject = await readJson(path.join(workspacePath, '.gemini', 'settings.json'));
|
||||
assert.ok((geminiProject.mcpServers as Record<string, unknown>)['global-http']);
|
||||
|
||||
if (expectCursorGlobal) {
|
||||
const cursorProject = await readJson(path.join(workspacePath, '.cursor', 'mcp.json'));
|
||||
assert.ok((cursorProject.mcpServers as Record<string, unknown>)['global-http']);
|
||||
}
|
||||
|
||||
await assert.rejects(
|
||||
providerMcpService.addMcpServerToAllProviders({
|
||||
name: 'global-sse',
|
||||
scope: 'project',
|
||||
transport: 'sse',
|
||||
url: 'https://example.com/sse',
|
||||
workspacePath,
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof AppError &&
|
||||
error.code === 'INVALID_GLOBAL_MCP_TRANSPORT' &&
|
||||
error.statusCode === 400,
|
||||
);
|
||||
} finally {
|
||||
restoreHomeDir();
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
|
||||
import { Codex } from '@openai/codex-sdk';
|
||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||
import { codexAdapter } from './providers/codex/adapter.js';
|
||||
import { createNormalizedMessage } from './providers/types.js';
|
||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||
import { createNormalizedMessage } from './shared/utils.js';
|
||||
|
||||
// Track active sessions
|
||||
const activeCodexSessions = new Map();
|
||||
@@ -264,7 +264,7 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
const transformed = transformCodexEvent(event);
|
||||
|
||||
// Normalize the transformed event into NormalizedMessage(s) via adapter
|
||||
const normalizedMsgs = codexAdapter.normalizeMessage(transformed, currentSessionId);
|
||||
const normalizedMsgs = sessionsService.normalizeMessage('codex', transformed, currentSessionId);
|
||||
for (const msg of normalizedMsgs) {
|
||||
sendMessage(ws, msg);
|
||||
}
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
/**
|
||||
* Claude provider adapter.
|
||||
*
|
||||
* Normalizes Claude SDK session history into NormalizedMessage format.
|
||||
* @module adapters/claude
|
||||
*/
|
||||
|
||||
import { getSessionMessages } from '../../projects.js';
|
||||
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
||||
import { isInternalContent } from '../utils.js';
|
||||
|
||||
const PROVIDER = 'claude';
|
||||
|
||||
/**
|
||||
* Normalize a raw JSONL message or realtime SDK event into NormalizedMessage(s).
|
||||
* Handles both history entries (JSONL `{ message: { role, content } }`) and
|
||||
* realtime streaming events (`content_block_delta`, `content_block_stop`, etc.).
|
||||
* @param {object} raw - A single entry from JSONL or a live SDK event
|
||||
* @param {string} sessionId
|
||||
* @returns {import('../types.js').NormalizedMessage[]}
|
||||
*/
|
||||
export function normalizeMessage(raw, sessionId) {
|
||||
// ── Streaming events (realtime) ──────────────────────────────────────────
|
||||
if (raw.type === 'content_block_delta' && raw.delta?.text) {
|
||||
return [createNormalizedMessage({ kind: 'stream_delta', content: raw.delta.text, sessionId, provider: PROVIDER })];
|
||||
}
|
||||
if (raw.type === 'content_block_stop') {
|
||||
return [createNormalizedMessage({ kind: 'stream_end', sessionId, provider: PROVIDER })];
|
||||
}
|
||||
|
||||
// ── History / full-message events ────────────────────────────────────────
|
||||
const messages = [];
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('claude');
|
||||
|
||||
// User message
|
||||
if (raw.message?.role === 'user' && raw.message?.content) {
|
||||
if (Array.isArray(raw.message.content)) {
|
||||
// Handle tool_result parts
|
||||
for (const part of raw.message.content) {
|
||||
if (part.type === 'tool_result') {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_tr_${part.tool_use_id}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: part.tool_use_id,
|
||||
content: typeof part.content === 'string' ? part.content : JSON.stringify(part.content),
|
||||
isError: Boolean(part.is_error),
|
||||
subagentTools: raw.subagentTools,
|
||||
toolUseResult: raw.toolUseResult,
|
||||
}));
|
||||
} else if (part.type === 'text') {
|
||||
// Regular text parts from user
|
||||
const text = part.text || '';
|
||||
if (text && !isInternalContent(text)) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_text`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content: text,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no text parts were found, check if it's a pure user message
|
||||
if (messages.length === 0) {
|
||||
const textParts = raw.message.content
|
||||
.filter(p => p.type === 'text')
|
||||
.map(p => p.text)
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
if (textParts && !isInternalContent(textParts)) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_text`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content: textParts,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else if (typeof raw.message.content === 'string') {
|
||||
const text = raw.message.content;
|
||||
if (text && !isInternalContent(text)) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content: text,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
// Thinking message
|
||||
if (raw.type === 'thinking' && raw.message?.content) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: raw.message.content,
|
||||
}));
|
||||
return messages;
|
||||
}
|
||||
|
||||
// Tool use result (codex-style in Claude)
|
||||
if (raw.type === 'tool_use' && raw.toolName) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: raw.toolName,
|
||||
toolInput: raw.toolInput,
|
||||
toolId: raw.toolCallId || baseId,
|
||||
}));
|
||||
return messages;
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_result') {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: raw.toolCallId || '',
|
||||
content: raw.output || '',
|
||||
isError: false,
|
||||
}));
|
||||
return messages;
|
||||
}
|
||||
|
||||
// Assistant message
|
||||
if (raw.message?.role === 'assistant' && raw.message?.content) {
|
||||
if (Array.isArray(raw.message.content)) {
|
||||
let partIndex = 0;
|
||||
for (const part of raw.message.content) {
|
||||
if (part.type === 'text' && part.text) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIndex}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'assistant',
|
||||
content: part.text,
|
||||
}));
|
||||
} else if (part.type === 'tool_use') {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIndex}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: part.name,
|
||||
toolInput: part.input,
|
||||
toolId: part.id,
|
||||
}));
|
||||
} else if (part.type === 'thinking' && part.thinking) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIndex}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: part.thinking,
|
||||
}));
|
||||
}
|
||||
partIndex++;
|
||||
}
|
||||
} else if (typeof raw.message.content === 'string') {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'assistant',
|
||||
content: raw.message.content,
|
||||
}));
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import('../types.js').ProviderAdapter}
|
||||
*/
|
||||
export const claudeAdapter = {
|
||||
normalizeMessage,
|
||||
|
||||
/**
|
||||
* Fetch session history from JSONL files, returning normalized messages.
|
||||
*/
|
||||
async fetchHistory(sessionId, opts = {}) {
|
||||
const { projectName, limit = null, offset = 0 } = opts;
|
||||
if (!projectName) {
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await getSessionMessages(projectName, sessionId, limit, offset);
|
||||
} catch (error) {
|
||||
console.warn(`[ClaudeAdapter] Failed to load session ${sessionId}:`, error.message);
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
|
||||
// getSessionMessages returns either an array (no limit) or { messages, total, hasMore }
|
||||
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
|
||||
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
|
||||
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
|
||||
|
||||
// First pass: collect tool results for attachment to tool_use messages
|
||||
const toolResultMap = new Map();
|
||||
for (const raw of rawMessages) {
|
||||
if (raw.message?.role === 'user' && Array.isArray(raw.message?.content)) {
|
||||
for (const part of raw.message.content) {
|
||||
if (part.type === 'tool_result') {
|
||||
toolResultMap.set(part.tool_use_id, {
|
||||
content: part.content,
|
||||
isError: Boolean(part.is_error),
|
||||
timestamp: raw.timestamp,
|
||||
subagentTools: raw.subagentTools,
|
||||
toolUseResult: raw.toolUseResult,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: normalize all messages
|
||||
const normalized = [];
|
||||
for (const raw of rawMessages) {
|
||||
const entries = normalizeMessage(raw, sessionId);
|
||||
normalized.push(...entries);
|
||||
}
|
||||
|
||||
// Attach tool results to their corresponding tool_use messages
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
|
||||
const tr = toolResultMap.get(msg.toolId);
|
||||
msg.toolResult = {
|
||||
content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
|
||||
isError: tr.isError,
|
||||
toolUseResult: tr.toolUseResult,
|
||||
};
|
||||
msg.subagentTools = tr.subagentTools;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages: normalized,
|
||||
total,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,248 +0,0 @@
|
||||
/**
|
||||
* Codex (OpenAI) provider adapter.
|
||||
*
|
||||
* Normalizes Codex SDK session history into NormalizedMessage format.
|
||||
* @module adapters/codex
|
||||
*/
|
||||
|
||||
import { getCodexSessionMessages } from '../../projects.js';
|
||||
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
||||
|
||||
const PROVIDER = 'codex';
|
||||
|
||||
/**
|
||||
* Normalize a raw Codex JSONL message into NormalizedMessage(s).
|
||||
* @param {object} raw - A single parsed message from Codex JSONL
|
||||
* @param {string} sessionId
|
||||
* @returns {import('../types.js').NormalizedMessage[]}
|
||||
*/
|
||||
function normalizeCodexHistoryEntry(raw, sessionId) {
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('codex');
|
||||
|
||||
// User message
|
||||
if (raw.message?.role === 'user') {
|
||||
const content = typeof raw.message.content === 'string'
|
||||
? raw.message.content
|
||||
: Array.isArray(raw.message.content)
|
||||
? raw.message.content.map(p => typeof p === 'string' ? p : p?.text || '').filter(Boolean).join('\n')
|
||||
: String(raw.message.content || '');
|
||||
if (!content.trim()) return [];
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'user',
|
||||
content,
|
||||
})];
|
||||
}
|
||||
|
||||
// Assistant message
|
||||
if (raw.message?.role === 'assistant') {
|
||||
const content = typeof raw.message.content === 'string'
|
||||
? raw.message.content
|
||||
: Array.isArray(raw.message.content)
|
||||
? raw.message.content.map(p => typeof p === 'string' ? p : p?.text || '').filter(Boolean).join('\n')
|
||||
: '';
|
||||
if (!content.trim()) return [];
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: 'assistant',
|
||||
content,
|
||||
})];
|
||||
}
|
||||
|
||||
// Thinking/reasoning
|
||||
if (raw.type === 'thinking' || raw.isReasoning) {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: raw.message?.content || '',
|
||||
})];
|
||||
}
|
||||
|
||||
// Tool use
|
||||
if (raw.type === 'tool_use' || raw.toolName) {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: raw.toolName || 'Unknown',
|
||||
toolInput: raw.toolInput,
|
||||
toolId: raw.toolCallId || baseId,
|
||||
})];
|
||||
}
|
||||
|
||||
// Tool result
|
||||
if (raw.type === 'tool_result') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: raw.toolCallId || '',
|
||||
content: raw.output || '',
|
||||
isError: Boolean(raw.isError),
|
||||
})];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw Codex event (history JSONL or transformed SDK event) into NormalizedMessage(s).
|
||||
* @param {object} raw - A history entry (has raw.message.role) or transformed SDK event (has raw.type)
|
||||
* @param {string} sessionId
|
||||
* @returns {import('../types.js').NormalizedMessage[]}
|
||||
*/
|
||||
export function normalizeMessage(raw, sessionId) {
|
||||
// History format: has message.role
|
||||
if (raw.message?.role) {
|
||||
return normalizeCodexHistoryEntry(raw, sessionId);
|
||||
}
|
||||
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('codex');
|
||||
|
||||
// SDK event format (output of transformCodexEvent)
|
||||
if (raw.type === 'item') {
|
||||
switch (raw.itemType) {
|
||||
case 'agent_message':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'text', role: 'assistant', content: raw.message?.content || '',
|
||||
})];
|
||||
case 'reasoning':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'thinking', content: raw.message?.content || '',
|
||||
})];
|
||||
case 'command_execution':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'tool_use', toolName: 'Bash', toolInput: { command: raw.command },
|
||||
toolId: baseId,
|
||||
output: raw.output, exitCode: raw.exitCode, status: raw.status,
|
||||
})];
|
||||
case 'file_change':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'tool_use', toolName: 'FileChanges', toolInput: raw.changes,
|
||||
toolId: baseId, status: raw.status,
|
||||
})];
|
||||
case 'mcp_tool_call':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'tool_use', toolName: raw.tool || 'MCP', toolInput: raw.arguments,
|
||||
toolId: baseId, server: raw.server, result: raw.result,
|
||||
error: raw.error, status: raw.status,
|
||||
})];
|
||||
case 'web_search':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'tool_use', toolName: 'WebSearch', toolInput: { query: raw.query },
|
||||
toolId: baseId,
|
||||
})];
|
||||
case 'todo_list':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'tool_use', toolName: 'TodoList', toolInput: { items: raw.items },
|
||||
toolId: baseId,
|
||||
})];
|
||||
case 'error':
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'error', content: raw.message?.content || 'Unknown error',
|
||||
})];
|
||||
default:
|
||||
// Unknown item type — pass through as generic tool_use
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'tool_use', toolName: raw.itemType || 'Unknown',
|
||||
toolInput: raw.item || raw, toolId: baseId,
|
||||
})];
|
||||
}
|
||||
}
|
||||
|
||||
if (raw.type === 'turn_complete') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'complete',
|
||||
})];
|
||||
}
|
||||
if (raw.type === 'turn_failed') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'error', content: raw.error?.message || 'Turn failed',
|
||||
})];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import('../types.js').ProviderAdapter}
|
||||
*/
|
||||
export const codexAdapter = {
|
||||
normalizeMessage,
|
||||
/**
|
||||
* Fetch session history from Codex JSONL files.
|
||||
*/
|
||||
async fetchHistory(sessionId, opts = {}) {
|
||||
const { limit = null, offset = 0 } = opts;
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await getCodexSessionMessages(sessionId, limit, offset);
|
||||
} catch (error) {
|
||||
console.warn(`[CodexAdapter] Failed to load session ${sessionId}:`, error.message);
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
|
||||
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
|
||||
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
|
||||
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
|
||||
const tokenUsage = result.tokenUsage || null;
|
||||
|
||||
const normalized = [];
|
||||
for (const raw of rawMessages) {
|
||||
const entries = normalizeCodexHistoryEntry(raw, sessionId);
|
||||
normalized.push(...entries);
|
||||
}
|
||||
|
||||
// Attach tool results to tool_use messages
|
||||
const toolResultMap = new Map();
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_result' && msg.toolId) {
|
||||
toolResultMap.set(msg.toolId, msg);
|
||||
}
|
||||
}
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
|
||||
const tr = toolResultMap.get(msg.toolId);
|
||||
msg.toolResult = { content: tr.content, isError: tr.isError };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages: normalized,
|
||||
total,
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
tokenUsage,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,353 +0,0 @@
|
||||
/**
|
||||
* Cursor provider adapter.
|
||||
*
|
||||
* Normalizes Cursor CLI session history into NormalizedMessage format.
|
||||
* @module adapters/cursor
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import crypto from 'crypto';
|
||||
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
||||
|
||||
const PROVIDER = 'cursor';
|
||||
|
||||
/**
|
||||
* Load raw blobs from Cursor's SQLite store.db, parse the DAG structure,
|
||||
* and return sorted message blobs in chronological order.
|
||||
* @param {string} sessionId
|
||||
* @param {string} projectPath - Absolute project path (used to compute cwdId hash)
|
||||
* @returns {Promise<Array<{id: string, sequence: number, rowid: number, content: object}>>}
|
||||
*/
|
||||
async function loadCursorBlobs(sessionId, projectPath) {
|
||||
// Lazy-import sqlite so the module doesn't fail if sqlite3 is unavailable
|
||||
const { default: sqlite3 } = await import('sqlite3');
|
||||
const { open } = await import('sqlite');
|
||||
|
||||
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
|
||||
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
|
||||
|
||||
const db = await open({
|
||||
filename: storeDbPath,
|
||||
driver: sqlite3.Database,
|
||||
mode: sqlite3.OPEN_READONLY,
|
||||
});
|
||||
|
||||
try {
|
||||
const allBlobs = await db.all('SELECT rowid, id, data FROM blobs');
|
||||
|
||||
const blobMap = new Map();
|
||||
const parentRefs = new Map();
|
||||
const childRefs = new Map();
|
||||
const jsonBlobs = [];
|
||||
|
||||
for (const blob of allBlobs) {
|
||||
blobMap.set(blob.id, blob);
|
||||
|
||||
if (blob.data && blob.data[0] === 0x7B) {
|
||||
try {
|
||||
const parsed = JSON.parse(blob.data.toString('utf8'));
|
||||
jsonBlobs.push({ ...blob, parsed });
|
||||
} catch {
|
||||
// skip unparseable blobs
|
||||
}
|
||||
} else if (blob.data) {
|
||||
const parents = [];
|
||||
let i = 0;
|
||||
while (i < blob.data.length - 33) {
|
||||
if (blob.data[i] === 0x0A && blob.data[i + 1] === 0x20) {
|
||||
const parentHash = blob.data.slice(i + 2, i + 34).toString('hex');
|
||||
if (blobMap.has(parentHash)) {
|
||||
parents.push(parentHash);
|
||||
}
|
||||
i += 34;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (parents.length > 0) {
|
||||
parentRefs.set(blob.id, parents);
|
||||
for (const parentId of parents) {
|
||||
if (!childRefs.has(parentId)) childRefs.set(parentId, []);
|
||||
childRefs.get(parentId).push(blob.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Topological sort (DFS)
|
||||
const visited = new Set();
|
||||
const sorted = [];
|
||||
function visit(nodeId) {
|
||||
if (visited.has(nodeId)) return;
|
||||
visited.add(nodeId);
|
||||
for (const pid of (parentRefs.get(nodeId) || [])) visit(pid);
|
||||
const b = blobMap.get(nodeId);
|
||||
if (b) sorted.push(b);
|
||||
}
|
||||
for (const blob of allBlobs) {
|
||||
if (!parentRefs.has(blob.id)) visit(blob.id);
|
||||
}
|
||||
for (const blob of allBlobs) visit(blob.id);
|
||||
|
||||
// Order JSON blobs by DAG appearance
|
||||
const messageOrder = new Map();
|
||||
let orderIndex = 0;
|
||||
for (const blob of sorted) {
|
||||
if (blob.data && blob.data[0] !== 0x7B) {
|
||||
for (const jb of jsonBlobs) {
|
||||
try {
|
||||
const idBytes = Buffer.from(jb.id, 'hex');
|
||||
if (blob.data.includes(idBytes) && !messageOrder.has(jb.id)) {
|
||||
messageOrder.set(jb.id, orderIndex++);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sortedJsonBlobs = jsonBlobs.sort((a, b) => {
|
||||
const oa = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
const ob = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
return oa !== ob ? oa - ob : a.rowid - b.rowid;
|
||||
});
|
||||
|
||||
const messages = [];
|
||||
for (let idx = 0; idx < sortedJsonBlobs.length; idx++) {
|
||||
const blob = sortedJsonBlobs[idx];
|
||||
const parsed = blob.parsed;
|
||||
if (!parsed) continue;
|
||||
const role = parsed?.role || parsed?.message?.role;
|
||||
if (role === 'system') continue;
|
||||
messages.push({
|
||||
id: blob.id,
|
||||
sequence: idx + 1,
|
||||
rowid: blob.rowid,
|
||||
content: parsed,
|
||||
});
|
||||
}
|
||||
|
||||
return messages;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a realtime NDJSON event from Cursor CLI into NormalizedMessage(s).
|
||||
* History uses normalizeCursorBlobs (SQLite DAG), this handles streaming NDJSON.
|
||||
* @param {object|string} raw - A parsed NDJSON event or a raw text line
|
||||
* @param {string} sessionId
|
||||
* @returns {import('../types.js').NormalizedMessage[]}
|
||||
*/
|
||||
export function normalizeMessage(raw, sessionId) {
|
||||
// Structured assistant message with content array
|
||||
if (raw && typeof raw === 'object' && raw.type === 'assistant' && raw.message?.content?.[0]?.text) {
|
||||
return [createNormalizedMessage({ kind: 'stream_delta', content: raw.message.content[0].text, sessionId, provider: PROVIDER })];
|
||||
}
|
||||
// Plain string line (non-JSON output)
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
return [createNormalizedMessage({ kind: 'stream_delta', content: raw, sessionId, provider: PROVIDER })];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import('../types.js').ProviderAdapter}
|
||||
*/
|
||||
export const cursorAdapter = {
|
||||
normalizeMessage,
|
||||
/**
|
||||
* Fetch session history for Cursor from SQLite store.db.
|
||||
*/
|
||||
async fetchHistory(sessionId, opts = {}) {
|
||||
const { projectPath = '', limit = null, offset = 0 } = opts;
|
||||
|
||||
try {
|
||||
const blobs = await loadCursorBlobs(sessionId, projectPath);
|
||||
const allNormalized = cursorAdapter.normalizeCursorBlobs(blobs, sessionId);
|
||||
|
||||
// Apply pagination
|
||||
if (limit !== null && limit > 0) {
|
||||
const start = offset;
|
||||
const page = allNormalized.slice(start, start + limit);
|
||||
return {
|
||||
messages: page,
|
||||
total: allNormalized.length,
|
||||
hasMore: start + limit < allNormalized.length,
|
||||
offset,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
messages: allNormalized,
|
||||
total: allNormalized.length,
|
||||
hasMore: false,
|
||||
offset: 0,
|
||||
limit: null,
|
||||
};
|
||||
} catch (error) {
|
||||
// DB doesn't exist or is unreadable — return empty
|
||||
console.warn(`[CursorAdapter] Failed to load session ${sessionId}:`, error.message);
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Normalize raw Cursor blob messages into NormalizedMessage[].
|
||||
* @param {any[]} blobs - Raw cursor blobs from store.db ({id, sequence, rowid, content})
|
||||
* @param {string} sessionId
|
||||
* @returns {import('../types.js').NormalizedMessage[]}
|
||||
*/
|
||||
normalizeCursorBlobs(blobs, sessionId) {
|
||||
const messages = [];
|
||||
const toolUseMap = new Map();
|
||||
|
||||
// Use a fixed base timestamp so messages have stable, monotonically-increasing
|
||||
// timestamps based on their sequence number rather than wall-clock time.
|
||||
const baseTime = Date.now();
|
||||
|
||||
for (let i = 0; i < blobs.length; i++) {
|
||||
const blob = blobs[i];
|
||||
const content = blob.content;
|
||||
const ts = new Date(baseTime + (blob.sequence ?? i) * 100).toISOString();
|
||||
const baseId = blob.id || generateMessageId('cursor');
|
||||
|
||||
try {
|
||||
if (!content?.role || !content?.content) {
|
||||
// Try nested message format
|
||||
if (content?.message?.role && content?.message?.content) {
|
||||
if (content.message.role === 'system') continue;
|
||||
const role = content.message.role === 'user' ? 'user' : 'assistant';
|
||||
let text = '';
|
||||
if (Array.isArray(content.message.content)) {
|
||||
text = content.message.content
|
||||
.map(p => typeof p === 'string' ? p : p?.text || '')
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
} else if (typeof content.message.content === 'string') {
|
||||
text = content.message.content;
|
||||
}
|
||||
if (text?.trim()) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role,
|
||||
content: text,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
}));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.role === 'system') continue;
|
||||
|
||||
// Tool results
|
||||
if (content.role === 'tool') {
|
||||
const toolItems = Array.isArray(content.content) ? content.content : [];
|
||||
for (const item of toolItems) {
|
||||
if (item?.type !== 'tool-result') continue;
|
||||
const toolCallId = item.toolCallId || content.id;
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_tr`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: toolCallId,
|
||||
content: item.result || '',
|
||||
isError: false,
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = content.role === 'user' ? 'user' : 'assistant';
|
||||
|
||||
if (Array.isArray(content.content)) {
|
||||
for (let partIdx = 0; partIdx < content.content.length; partIdx++) {
|
||||
const part = content.content[partIdx];
|
||||
|
||||
if (part?.type === 'text' && part?.text) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role,
|
||||
content: part.text,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
}));
|
||||
} else if (part?.type === 'reasoning' && part?.text) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: part.text,
|
||||
}));
|
||||
} else if (part?.type === 'tool-call' || part?.type === 'tool_use') {
|
||||
const toolName = (part.toolName || part.name || 'Unknown Tool') === 'ApplyPatch'
|
||||
? 'Edit' : (part.toolName || part.name || 'Unknown Tool');
|
||||
const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`;
|
||||
messages.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName,
|
||||
toolInput: part.args || part.input,
|
||||
toolId,
|
||||
}));
|
||||
toolUseMap.set(toolId, messages[messages.length - 1]);
|
||||
}
|
||||
}
|
||||
} else if (typeof content.content === 'string' && content.content.trim()) {
|
||||
messages.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role,
|
||||
content: content.content,
|
||||
sequence: blob.sequence,
|
||||
rowid: blob.rowid,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error normalizing cursor blob:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Attach tool results to tool_use messages
|
||||
for (const msg of messages) {
|
||||
if (msg.kind === 'tool_result' && msg.toolId && toolUseMap.has(msg.toolId)) {
|
||||
const toolUse = toolUseMap.get(msg.toolId);
|
||||
toolUse.toolResult = {
|
||||
content: msg.content,
|
||||
isError: msg.isError,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by sequence/rowid
|
||||
messages.sort((a, b) => {
|
||||
if (a.sequence !== undefined && b.sequence !== undefined) return a.sequence - b.sequence;
|
||||
if (a.rowid !== undefined && b.rowid !== undefined) return a.rowid - b.rowid;
|
||||
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
|
||||
});
|
||||
|
||||
return messages;
|
||||
},
|
||||
};
|
||||
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* Gemini provider adapter.
|
||||
*
|
||||
* Normalizes Gemini CLI session history into NormalizedMessage format.
|
||||
* @module adapters/gemini
|
||||
*/
|
||||
|
||||
import sessionManager from '../../sessionManager.js';
|
||||
import { getGeminiCliSessionMessages } from '../../projects.js';
|
||||
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
||||
|
||||
const PROVIDER = 'gemini';
|
||||
|
||||
/**
|
||||
* Normalize a realtime NDJSON event from Gemini CLI into NormalizedMessage(s).
|
||||
* Handles: message (delta/final), tool_use, tool_result, result, error.
|
||||
* @param {object} raw - A parsed NDJSON event
|
||||
* @param {string} sessionId
|
||||
* @returns {import('../types.js').NormalizedMessage[]}
|
||||
*/
|
||||
export function normalizeMessage(raw, sessionId) {
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('gemini');
|
||||
|
||||
if (raw.type === 'message' && raw.role === 'assistant') {
|
||||
const content = raw.content || '';
|
||||
const msgs = [];
|
||||
if (content) {
|
||||
msgs.push(createNormalizedMessage({ id: baseId, sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_delta', content }));
|
||||
}
|
||||
// If not a delta, also send stream_end
|
||||
if (raw.delta !== true) {
|
||||
msgs.push(createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' }));
|
||||
}
|
||||
return msgs;
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_use') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'tool_use', toolName: raw.tool_name, toolInput: raw.parameters || {},
|
||||
toolId: raw.tool_id || baseId,
|
||||
})];
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_result') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'tool_result', toolId: raw.tool_id || '',
|
||||
content: raw.output === undefined ? '' : String(raw.output),
|
||||
isError: raw.status === 'error',
|
||||
})];
|
||||
}
|
||||
|
||||
if (raw.type === 'result') {
|
||||
const msgs = [createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' })];
|
||||
if (raw.stats?.total_tokens) {
|
||||
msgs.push(createNormalizedMessage({
|
||||
sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'status', text: 'Complete', tokens: raw.stats.total_tokens, canInterrupt: false,
|
||||
}));
|
||||
}
|
||||
return msgs;
|
||||
}
|
||||
|
||||
if (raw.type === 'error') {
|
||||
return [createNormalizedMessage({
|
||||
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'error', content: raw.error || raw.message || 'Unknown Gemini streaming error',
|
||||
})];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {import('../types.js').ProviderAdapter}
|
||||
*/
|
||||
export const geminiAdapter = {
|
||||
normalizeMessage,
|
||||
/**
|
||||
* Fetch session history for Gemini.
|
||||
* First tries in-memory session manager, then falls back to CLI sessions on disk.
|
||||
*/
|
||||
async fetchHistory(sessionId, opts = {}) {
|
||||
let rawMessages;
|
||||
try {
|
||||
rawMessages = sessionManager.getSessionMessages(sessionId);
|
||||
|
||||
// Fallback to Gemini CLI sessions on disk
|
||||
if (rawMessages.length === 0) {
|
||||
rawMessages = await getGeminiCliSessionMessages(sessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[GeminiAdapter] Failed to load session ${sessionId}:`, error.message);
|
||||
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
||||
}
|
||||
|
||||
const normalized = [];
|
||||
for (let i = 0; i < rawMessages.length; i++) {
|
||||
const raw = rawMessages[i];
|
||||
const ts = raw.timestamp || new Date().toISOString();
|
||||
const baseId = raw.uuid || generateMessageId('gemini');
|
||||
|
||||
// sessionManager format: { type: 'message', message: { role, content }, timestamp }
|
||||
// CLI format: { role: 'user'|'gemini'|'assistant', content: string|array }
|
||||
const role = raw.message?.role || raw.role;
|
||||
const content = raw.message?.content || raw.content;
|
||||
|
||||
if (!role || !content) continue;
|
||||
|
||||
const normalizedRole = (role === 'user') ? 'user' : 'assistant';
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
for (let partIdx = 0; partIdx < content.length; partIdx++) {
|
||||
const part = content[partIdx];
|
||||
if (part.type === 'text' && part.text) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: normalizedRole,
|
||||
content: part.text,
|
||||
}));
|
||||
} else if (part.type === 'tool_use') {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: part.name,
|
||||
toolInput: part.input,
|
||||
toolId: part.id || generateMessageId('gemini_tool'),
|
||||
}));
|
||||
} else if (part.type === 'tool_result') {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: part.tool_use_id || '',
|
||||
content: part.content === undefined ? '' : String(part.content),
|
||||
isError: Boolean(part.is_error),
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else if (typeof content === 'string' && content.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: normalizedRole,
|
||||
content,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Attach tool results to tool_use messages
|
||||
const toolResultMap = new Map();
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_result' && msg.toolId) {
|
||||
toolResultMap.set(msg.toolId, msg);
|
||||
}
|
||||
}
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
|
||||
const tr = toolResultMap.get(msg.toolId);
|
||||
msg.toolResult = { content: tr.content, isError: tr.isError };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages: normalized,
|
||||
total: normalized.length,
|
||||
hasMore: false,
|
||||
offset: 0,
|
||||
limit: null,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* Provider Registry
|
||||
*
|
||||
* Centralizes provider adapter lookup. All code that needs a provider adapter
|
||||
* should go through this registry instead of importing individual adapters directly.
|
||||
*
|
||||
* @module providers/registry
|
||||
*/
|
||||
|
||||
import { claudeAdapter } from './claude/adapter.js';
|
||||
import { cursorAdapter } from './cursor/adapter.js';
|
||||
import { codexAdapter } from './codex/adapter.js';
|
||||
import { geminiAdapter } from './gemini/adapter.js';
|
||||
|
||||
/**
|
||||
* @typedef {import('./types.js').ProviderAdapter} ProviderAdapter
|
||||
* @typedef {import('./types.js').SessionProvider} SessionProvider
|
||||
*/
|
||||
|
||||
/** @type {Map<string, ProviderAdapter>} */
|
||||
const providers = new Map();
|
||||
|
||||
// Register built-in providers
|
||||
providers.set('claude', claudeAdapter);
|
||||
providers.set('cursor', cursorAdapter);
|
||||
providers.set('codex', codexAdapter);
|
||||
providers.set('gemini', geminiAdapter);
|
||||
|
||||
/**
|
||||
* Get a provider adapter by name.
|
||||
* @param {string} name - Provider name (e.g., 'claude', 'cursor', 'codex', 'gemini')
|
||||
* @returns {ProviderAdapter | undefined}
|
||||
*/
|
||||
export function getProvider(name) {
|
||||
return providers.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered provider names.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getAllProviders() {
|
||||
return Array.from(providers.keys());
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* Provider Types & Interface
|
||||
*
|
||||
* Defines the normalized message format and the provider adapter interface.
|
||||
* All providers normalize their native formats into NormalizedMessage
|
||||
* before sending over REST or WebSocket.
|
||||
*
|
||||
* @module providers/types
|
||||
*/
|
||||
|
||||
// ─── Session Provider ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @typedef {'claude' | 'cursor' | 'codex' | 'gemini'} SessionProvider
|
||||
*/
|
||||
|
||||
// ─── Message Kind ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @typedef {'text' | 'tool_use' | 'tool_result' | 'thinking' | 'stream_delta' | 'stream_end'
|
||||
* | 'error' | 'complete' | 'status' | 'permission_request' | 'permission_cancelled'
|
||||
* | 'session_created' | 'interactive_prompt' | 'task_notification'} MessageKind
|
||||
*/
|
||||
|
||||
// ─── NormalizedMessage ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @typedef {Object} NormalizedMessage
|
||||
* @property {string} id - Unique message id (for dedup between server + realtime)
|
||||
* @property {string} sessionId
|
||||
* @property {string} timestamp - ISO 8601
|
||||
* @property {SessionProvider} provider
|
||||
* @property {MessageKind} kind
|
||||
*
|
||||
* Additional fields depending on kind:
|
||||
* - text: role ('user'|'assistant'), content, images?
|
||||
* - tool_use: toolName, toolInput, toolId
|
||||
* - tool_result: toolId, content, isError
|
||||
* - thinking: content
|
||||
* - stream_delta: content
|
||||
* - stream_end: (no extra fields)
|
||||
* - error: content
|
||||
* - complete: (no extra fields)
|
||||
* - status: text, tokens?, canInterrupt?
|
||||
* - permission_request: requestId, toolName, input, context?
|
||||
* - permission_cancelled: requestId
|
||||
* - session_created: newSessionId
|
||||
* - interactive_prompt: content
|
||||
* - task_notification: status, summary
|
||||
*/
|
||||
|
||||
// ─── Fetch History ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @typedef {Object} FetchHistoryOptions
|
||||
* @property {string} [projectName] - Project name (required for Claude)
|
||||
* @property {string} [projectPath] - Absolute project path (required for Cursor cwdId hash)
|
||||
* @property {number|null} [limit] - Page size (null = all messages)
|
||||
* @property {number} [offset] - Pagination offset (default: 0)
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} FetchHistoryResult
|
||||
* @property {NormalizedMessage[]} messages - Normalized messages
|
||||
* @property {number} total - Total number of messages in the session
|
||||
* @property {boolean} hasMore - Whether more messages exist before the current page
|
||||
* @property {number} offset - Current offset
|
||||
* @property {number|null} limit - Page size used
|
||||
* @property {object} [tokenUsage] - Token usage data (provider-specific)
|
||||
*/
|
||||
|
||||
// ─── Provider Adapter Interface ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every provider adapter MUST implement this interface.
|
||||
*
|
||||
* @typedef {Object} ProviderAdapter
|
||||
*
|
||||
* @property {(sessionId: string, opts?: FetchHistoryOptions) => Promise<FetchHistoryResult>} fetchHistory
|
||||
* Read persisted session messages from disk/database and return them as NormalizedMessage[].
|
||||
* The backend calls this from the unified GET /api/sessions/:id/messages endpoint.
|
||||
*
|
||||
* Provider implementations:
|
||||
* - Claude: reads ~/.claude/projects/{projectName}/*.jsonl
|
||||
* - Cursor: reads from SQLite store.db (via normalizeCursorBlobs helper)
|
||||
* - Codex: reads ~/.codex/sessions/*.jsonl
|
||||
* - Gemini: reads from in-memory sessionManager or ~/.gemini/tmp/ JSON files
|
||||
*
|
||||
* @property {(raw: any, sessionId: string) => NormalizedMessage[]} normalizeMessage
|
||||
* Normalize a provider-specific event (JSONL entry or live SDK event) into NormalizedMessage[].
|
||||
* Used by provider files to convert both history and realtime events.
|
||||
*/
|
||||
|
||||
// ─── Runtime Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generate a unique message ID.
|
||||
* Uses crypto.randomUUID() to avoid collisions across server restarts and workers.
|
||||
* @param {string} [prefix='msg'] - Optional prefix
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generateMessageId(prefix = 'msg') {
|
||||
return `${prefix}_${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a NormalizedMessage with common fields pre-filled.
|
||||
* @param {Partial<NormalizedMessage> & {kind: MessageKind, provider: SessionProvider}} fields
|
||||
* @returns {NormalizedMessage}
|
||||
*/
|
||||
export function createNormalizedMessage(fields) {
|
||||
return {
|
||||
...fields,
|
||||
id: fields.id || generateMessageId(fields.kind),
|
||||
sessionId: fields.sessionId || '',
|
||||
timestamp: fields.timestamp || new Date().toISOString(),
|
||||
provider: fields.provider,
|
||||
};
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Shared provider utilities.
|
||||
*
|
||||
* @module providers/utils
|
||||
*/
|
||||
|
||||
/**
|
||||
* Prefixes that indicate internal/system content which should be hidden from the UI.
|
||||
* @type {readonly string[]}
|
||||
*/
|
||||
export const INTERNAL_CONTENT_PREFIXES = Object.freeze([
|
||||
'<command-name>',
|
||||
'<command-message>',
|
||||
'<command-args>',
|
||||
'<local-command-stdout>',
|
||||
'<system-reminder>',
|
||||
'Caveat:',
|
||||
'This session is being continued from a previous',
|
||||
'[Request interrupted',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if user text content is internal/system that should be skipped.
|
||||
* @param {string} content
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isInternalContent(content) {
|
||||
return INTERNAL_CONTENT_PREFIXES.some(prefix => content.startsWith(prefix));
|
||||
}
|
||||
@@ -1,434 +0,0 @@
|
||||
import express from 'express';
|
||||
import { spawn } from 'child_process';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/claude/status', async (req, res) => {
|
||||
try {
|
||||
const credentialsResult = await checkClaudeCredentials();
|
||||
|
||||
if (credentialsResult.authenticated) {
|
||||
return res.json({
|
||||
authenticated: true,
|
||||
email: credentialsResult.email || 'Authenticated',
|
||||
method: credentialsResult.method // 'api_key' or 'credentials_file'
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: credentialsResult.error || 'Not authenticated'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking Claude auth status:', error);
|
||||
res.status(500).json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/cursor/status', async (req, res) => {
|
||||
try {
|
||||
const result = await checkCursorStatus();
|
||||
|
||||
res.json({
|
||||
authenticated: result.authenticated,
|
||||
email: result.email,
|
||||
error: result.error
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking Cursor auth status:', error);
|
||||
res.status(500).json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/codex/status', async (req, res) => {
|
||||
try {
|
||||
const result = await checkCodexCredentials();
|
||||
|
||||
res.json({
|
||||
authenticated: result.authenticated,
|
||||
email: result.email,
|
||||
error: result.error
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking Codex auth status:', error);
|
||||
res.status(500).json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/gemini/status', async (req, res) => {
|
||||
try {
|
||||
const result = await checkGeminiCredentials();
|
||||
|
||||
res.json({
|
||||
authenticated: result.authenticated,
|
||||
email: result.email,
|
||||
error: result.error
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking Gemini auth status:', error);
|
||||
res.status(500).json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function loadClaudeSettingsEnv() {
|
||||
try {
|
||||
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
||||
const content = await fs.readFile(settingsPath, 'utf8');
|
||||
const settings = JSON.parse(content);
|
||||
|
||||
if (settings?.env && typeof settings.env === 'object') {
|
||||
return settings.env;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore missing or malformed settings and fall back to other auth sources.
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks Claude authentication credentials using two methods with priority order:
|
||||
*
|
||||
* Priority 1: ANTHROPIC_API_KEY environment variable
|
||||
* Priority 1b: ~/.claude/settings.json env values
|
||||
* Priority 2: ~/.claude/.credentials.json OAuth tokens
|
||||
*
|
||||
* The Claude Agent SDK prioritizes environment variables over authenticated subscriptions.
|
||||
* This matching behavior ensures consistency with how the SDK authenticates.
|
||||
*
|
||||
* References:
|
||||
* - https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code
|
||||
* "Claude Code prioritizes environment variable API keys over authenticated subscriptions"
|
||||
* - https://platform.claude.com/docs/en/agent-sdk/overview
|
||||
* SDK authentication documentation
|
||||
*
|
||||
* @returns {Promise<Object>} Authentication status with { authenticated, email, method }
|
||||
* - authenticated: boolean indicating if valid credentials exist
|
||||
* - email: user email or auth method identifier
|
||||
* - method: 'api_key' for env var, 'credentials_file' for OAuth tokens
|
||||
*/
|
||||
async function checkClaudeCredentials() {
|
||||
// Priority 1: Check for ANTHROPIC_API_KEY environment variable
|
||||
// The SDK checks this first and uses it if present, even if OAuth tokens exist.
|
||||
// When set, API calls are charged via pay-as-you-go rates instead of subscription.
|
||||
if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: 'API Key Auth',
|
||||
method: 'api_key'
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 1b: Check ~/.claude/settings.json env values.
|
||||
// Claude Code can read proxy/auth values from settings.json even when the
|
||||
// CloudCLI server process itself was not started with those env vars exported.
|
||||
const settingsEnv = await loadClaudeSettingsEnv();
|
||||
|
||||
if (typeof settingsEnv.ANTHROPIC_API_KEY === 'string' && settingsEnv.ANTHROPIC_API_KEY.trim()) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: 'API Key Auth',
|
||||
method: 'api_key'
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof settingsEnv.ANTHROPIC_AUTH_TOKEN === 'string' && settingsEnv.ANTHROPIC_AUTH_TOKEN.trim()) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: 'Configured via settings.json',
|
||||
method: 'api_key'
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 2: Check ~/.claude/.credentials.json for OAuth tokens
|
||||
// This is the standard authentication method used by Claude CLI after running
|
||||
// 'claude /login' or 'claude setup-token' commands.
|
||||
try {
|
||||
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
||||
const content = await fs.readFile(credPath, 'utf8');
|
||||
const creds = JSON.parse(content);
|
||||
|
||||
const oauth = creds.claudeAiOauth;
|
||||
if (oauth && oauth.accessToken) {
|
||||
const isExpired = oauth.expiresAt && Date.now() >= oauth.expiresAt;
|
||||
|
||||
if (!isExpired) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: creds.email || creds.user || null,
|
||||
method: 'credentials_file'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function checkCursorStatus() {
|
||||
return new Promise((resolve) => {
|
||||
let processCompleted = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!processCompleted) {
|
||||
processCompleted = true;
|
||||
if (childProcess) {
|
||||
childProcess.kill();
|
||||
}
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Command timeout'
|
||||
});
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
let childProcess;
|
||||
try {
|
||||
childProcess = spawn('cursor-agent', ['status']);
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
processCompleted = true;
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Cursor CLI not found or not installed'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
childProcess.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
childProcess.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
childProcess.on('close', (code) => {
|
||||
if (processCompleted) return;
|
||||
processCompleted = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (code === 0) {
|
||||
const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i);
|
||||
|
||||
if (emailMatch) {
|
||||
resolve({
|
||||
authenticated: true,
|
||||
email: emailMatch[1],
|
||||
output: stdout
|
||||
});
|
||||
} else if (stdout.includes('Logged in')) {
|
||||
resolve({
|
||||
authenticated: true,
|
||||
email: 'Logged in',
|
||||
output: stdout
|
||||
});
|
||||
} else {
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Not logged in'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: stderr || 'Not logged in'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.on('error', (err) => {
|
||||
if (processCompleted) return;
|
||||
processCompleted = true;
|
||||
clearTimeout(timeout);
|
||||
|
||||
resolve({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Cursor CLI not found or not installed'
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function checkCodexCredentials() {
|
||||
try {
|
||||
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
|
||||
const content = await fs.readFile(authPath, 'utf8');
|
||||
const auth = JSON.parse(content);
|
||||
|
||||
// Tokens are nested under 'tokens' key
|
||||
const tokens = auth.tokens || {};
|
||||
|
||||
// Check for valid tokens (id_token or access_token)
|
||||
if (tokens.id_token || tokens.access_token) {
|
||||
// Try to extract email from id_token JWT payload
|
||||
let email = 'Authenticated';
|
||||
if (tokens.id_token) {
|
||||
try {
|
||||
// JWT is base64url encoded: header.payload.signature
|
||||
const parts = tokens.id_token.split('.');
|
||||
if (parts.length >= 2) {
|
||||
// Decode the payload (second part)
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
||||
email = payload.email || payload.user || 'Authenticated';
|
||||
}
|
||||
} catch {
|
||||
// If JWT decoding fails, use fallback
|
||||
email = 'Authenticated';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
email
|
||||
};
|
||||
}
|
||||
|
||||
// Also check for OPENAI_API_KEY as fallback auth method
|
||||
if (auth.OPENAI_API_KEY) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: 'API Key Auth'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'No valid tokens found'
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Codex not configured'
|
||||
};
|
||||
}
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkGeminiCredentials() {
|
||||
if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: 'API Key Auth'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
|
||||
const content = await fs.readFile(credsPath, 'utf8');
|
||||
const creds = JSON.parse(content);
|
||||
|
||||
if (creds.access_token) {
|
||||
let email = 'OAuth Session';
|
||||
|
||||
try {
|
||||
// Validate token against Google API
|
||||
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`);
|
||||
if (tokenRes.ok) {
|
||||
const tokenInfo = await tokenRes.json();
|
||||
if (tokenInfo.email) {
|
||||
email = tokenInfo.email;
|
||||
}
|
||||
} else if (!creds.refresh_token) {
|
||||
// Token invalid and no refresh token available
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Access token invalid and no refresh token found'
|
||||
};
|
||||
} else {
|
||||
// Token might be expired but we have a refresh token, so CLI will refresh it
|
||||
try {
|
||||
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
|
||||
const accContent = await fs.readFile(accPath, 'utf8');
|
||||
const accounts = JSON.parse(accContent);
|
||||
if (accounts.active) {
|
||||
email = accounts.active;
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
} catch (e) {
|
||||
// Network error, fallback to checking local accounts file
|
||||
try {
|
||||
const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');
|
||||
const accContent = await fs.readFile(accPath, 'utf8');
|
||||
const accounts = JSON.parse(accContent);
|
||||
if (accounts.active) {
|
||||
email = accounts.active;
|
||||
}
|
||||
} catch (err) { }
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
email: email
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'No valid tokens found in oauth_creds'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Gemini CLI not configured'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
@@ -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,546 +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
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/cursor/mcp/add - Add MCP server to Cursor configuration
|
||||
router.post('/mcp/add', async (req, res) => {
|
||||
try {
|
||||
const { name, type = 'stdio', command, args = [], url, headers = {}, env = {} } = req.body;
|
||||
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
|
||||
|
||||
console.log(`➕ Adding MCP server to Cursor config: ${name}`);
|
||||
|
||||
// Read existing config or create new
|
||||
let mcpConfig = { mcpServers: {} };
|
||||
|
||||
try {
|
||||
const existing = await fs.readFile(mcpPath, 'utf8');
|
||||
mcpConfig = JSON.parse(existing);
|
||||
if (!mcpConfig.mcpServers) {
|
||||
mcpConfig.mcpServers = {};
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Creating new Cursor MCP config');
|
||||
}
|
||||
|
||||
// Build server config based on type
|
||||
let serverConfig = {};
|
||||
|
||||
if (type === 'stdio') {
|
||||
serverConfig = {
|
||||
command: command,
|
||||
args: args,
|
||||
env: env
|
||||
};
|
||||
} else if (type === 'http' || type === 'sse') {
|
||||
serverConfig = {
|
||||
url: url,
|
||||
transport: type,
|
||||
headers: headers
|
||||
};
|
||||
}
|
||||
|
||||
// Add server to config
|
||||
mcpConfig.mcpServers[name] = serverConfig;
|
||||
|
||||
// Ensure directory exists
|
||||
const mcpDir = path.dirname(mcpPath);
|
||||
await fs.mkdir(mcpDir, { recursive: true });
|
||||
|
||||
// Write updated config
|
||||
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `MCP server "${name}" added to Cursor configuration`,
|
||||
config: mcpConfig
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error adding MCP server to Cursor:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to add MCP server',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/cursor/mcp/:name - Remove MCP server from Cursor configuration
|
||||
router.delete('/mcp/:name', async (req, res) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
|
||||
|
||||
console.log(`🗑️ Removing MCP server from Cursor config: ${name}`);
|
||||
|
||||
// Read existing config
|
||||
let mcpConfig = { mcpServers: {} };
|
||||
|
||||
try {
|
||||
const existing = await fs.readFile(mcpPath, 'utf8');
|
||||
mcpConfig = JSON.parse(existing);
|
||||
} catch (error) {
|
||||
return res.status(404).json({
|
||||
error: 'Cursor MCP configuration not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if server exists
|
||||
if (!mcpConfig.mcpServers || !mcpConfig.mcpServers[name]) {
|
||||
return res.status(404).json({
|
||||
error: `MCP server "${name}" not found in Cursor configuration`
|
||||
});
|
||||
}
|
||||
|
||||
// Remove server from config
|
||||
delete mcpConfig.mcpServers[name];
|
||||
|
||||
// Write updated config
|
||||
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `MCP server "${name}" removed from Cursor configuration`,
|
||||
config: mcpConfig
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error removing MCP server from Cursor:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to remove MCP server',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/cursor/mcp/add-json - Add MCP server using JSON format
|
||||
router.post('/mcp/add-json', async (req, res) => {
|
||||
try {
|
||||
const { name, jsonConfig } = req.body;
|
||||
const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');
|
||||
|
||||
console.log(`➕ Adding MCP server to Cursor config via JSON: ${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
|
||||
});
|
||||
}
|
||||
|
||||
// Read existing config or create new
|
||||
let mcpConfig = { mcpServers: {} };
|
||||
|
||||
try {
|
||||
const existing = await fs.readFile(mcpPath, 'utf8');
|
||||
mcpConfig = JSON.parse(existing);
|
||||
if (!mcpConfig.mcpServers) {
|
||||
mcpConfig.mcpServers = {};
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Creating new Cursor MCP config');
|
||||
}
|
||||
|
||||
// Add server to config
|
||||
mcpConfig.mcpServers[name] = parsedConfig;
|
||||
|
||||
// Ensure directory exists
|
||||
const mcpDir = path.dirname(mcpPath);
|
||||
await fs.mkdir(mcpDir, { recursive: true });
|
||||
|
||||
// Write updated config
|
||||
await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `MCP server "${name}" added to Cursor configuration via JSON`,
|
||||
config: mcpConfig
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error adding MCP server to Cursor via JSON:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to add MCP server',
|
||||
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;
|
||||
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;
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { getProvider, getAllProviders } from '../providers/registry.js';
|
||||
import { sessionsService } from '../modules/providers/services/sessions.service.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -29,7 +29,7 @@ const router = express.Router();
|
||||
router.get('/:sessionId/messages', async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
const provider = req.query.provider || 'claude';
|
||||
const provider = String(req.query.provider || 'claude').trim().toLowerCase();
|
||||
const projectName = req.query.projectName || '';
|
||||
const projectPath = req.query.projectPath || '';
|
||||
const limitParam = req.query.limit;
|
||||
@@ -38,13 +38,13 @@ router.get('/:sessionId/messages', async (req, res) => {
|
||||
: null;
|
||||
const offset = parseInt(req.query.offset || '0', 10);
|
||||
|
||||
const adapter = getProvider(provider);
|
||||
if (!adapter) {
|
||||
const available = getAllProviders().join(', ');
|
||||
const availableProviders = sessionsService.listProviderIds();
|
||||
if (!availableProviders.includes(provider)) {
|
||||
const available = availableProviders.join(', ');
|
||||
return res.status(400).json({ error: `Unknown provider: ${provider}. Available: ${available}` });
|
||||
}
|
||||
|
||||
const result = await adapter.fetchHistory(sessionId, {
|
||||
const result = await sessionsService.fetchHistory(provider, sessionId, {
|
||||
projectName,
|
||||
projectPath,
|
||||
limit,
|
||||
|
||||
@@ -273,4 +273,14 @@ router.post('/push/unsubscribe', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Host OS for UI (e.g. hide Cursor agent when the backend runs on Windows).
|
||||
router.get('/server-env', async (req, res) => {
|
||||
try {
|
||||
res.json({ platform: process.platform });
|
||||
} catch (error) {
|
||||
console.error('Error reading server environment:', error);
|
||||
res.status(500).json({ error: 'Failed to read server environment' });
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
48
server/shared/interfaces.ts
Normal file
48
server/shared/interfaces.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type {
|
||||
FetchHistoryOptions,
|
||||
FetchHistoryResult,
|
||||
LLMProvider,
|
||||
McpScope,
|
||||
NormalizedMessage,
|
||||
ProviderAuthStatus,
|
||||
ProviderMcpServer,
|
||||
UpsertProviderMcpServerInput,
|
||||
} from '@/shared/types.js';
|
||||
|
||||
/**
|
||||
* Main provider contract for CLI and SDK integrations.
|
||||
*
|
||||
* Each concrete provider owns its MCP/auth handlers plus the provider-specific
|
||||
* logic for converting native events/history into the app's normalized shape.
|
||||
*/
|
||||
export interface IProvider {
|
||||
readonly id: LLMProvider;
|
||||
readonly mcp: IProviderMcp;
|
||||
readonly auth: IProviderAuth;
|
||||
|
||||
normalizeMessage(raw: unknown, sessionId: string | null): NormalizedMessage[];
|
||||
fetchHistory(sessionId: string, options?: FetchHistoryOptions): Promise<FetchHistoryResult>;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Auth contract for one provider.
|
||||
*/
|
||||
export interface IProviderAuth {
|
||||
/**
|
||||
* Checks whether the provider is installed and has usable credentials.
|
||||
*/
|
||||
getStatus(): Promise<ProviderAuthStatus>;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP contract for one provider.
|
||||
*/
|
||||
export interface IProviderMcp {
|
||||
listServers(options?: { workspacePath?: string }): Promise<Record<McpScope, ProviderMcpServer[]>>;
|
||||
listServersForScope(scope: McpScope, options?: { workspacePath?: string }): Promise<ProviderMcpServer[]>;
|
||||
upsertServer(input: UpsertProviderMcpServerInput): Promise<ProviderMcpServer>;
|
||||
removeServer(
|
||||
input: { name: string; scope?: McpScope; workspacePath?: string },
|
||||
): Promise<{ removed: boolean; provider: LLMProvider; name: string; scope: McpScope }>;
|
||||
}
|
||||
179
server/shared/types.ts
Normal file
179
server/shared/types.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
// -------------- HTTP API response shapes for the server, shared across modules --------------
|
||||
|
||||
export type ApiSuccessShape<TData = unknown> = {
|
||||
success: true;
|
||||
data: TData;
|
||||
};
|
||||
|
||||
export type ApiErrorShape = {
|
||||
success: false;
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export type LLMProvider = 'claude' | 'codex' | 'gemini' | 'cursor';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export type MessageKind =
|
||||
| 'text'
|
||||
| 'tool_use'
|
||||
| 'tool_result'
|
||||
| 'thinking'
|
||||
| 'stream_delta'
|
||||
| 'stream_end'
|
||||
| 'error'
|
||||
| 'complete'
|
||||
| 'status'
|
||||
| 'permission_request'
|
||||
| 'permission_cancelled'
|
||||
| 'session_created'
|
||||
| 'interactive_prompt'
|
||||
| 'task_notification';
|
||||
|
||||
/**
|
||||
* Provider-neutral message event emitted over REST and realtime transports.
|
||||
*
|
||||
* Providers all produce their own native SDK/CLI event shapes, so this type keeps
|
||||
* the common envelope strict while allowing provider-specific details to ride
|
||||
* along as optional properties.
|
||||
*/
|
||||
export type NormalizedMessage = {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
timestamp: string;
|
||||
provider: LLMProvider;
|
||||
kind: MessageKind;
|
||||
role?: 'user' | 'assistant';
|
||||
content?: string;
|
||||
images?: unknown;
|
||||
toolName?: string;
|
||||
toolInput?: unknown;
|
||||
toolId?: string;
|
||||
toolResult?: {
|
||||
content?: string;
|
||||
isError?: boolean;
|
||||
toolUseResult?: unknown;
|
||||
};
|
||||
isError?: boolean;
|
||||
text?: string;
|
||||
tokens?: number;
|
||||
canInterrupt?: boolean;
|
||||
requestId?: string;
|
||||
input?: unknown;
|
||||
context?: unknown;
|
||||
reason?: string;
|
||||
newSessionId?: string;
|
||||
status?: string;
|
||||
summary?: string;
|
||||
tokenBudget?: unknown;
|
||||
subagentTools?: unknown;
|
||||
toolUseResult?: unknown;
|
||||
sequence?: number;
|
||||
rowid?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pagination and provider lookup options for reading persisted session history.
|
||||
*/
|
||||
export type FetchHistoryOptions = {
|
||||
/** Claude project folder name. Required by Claude history lookup. */
|
||||
projectName?: string;
|
||||
/** Absolute workspace path. Required by Cursor to compute its chat hash. */
|
||||
projectPath?: string;
|
||||
/** Page size. `null` means all messages. */
|
||||
limit?: number | null;
|
||||
/** Pagination offset from the newest messages. */
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider-neutral history result returned by the unified messages endpoint.
|
||||
*/
|
||||
export type FetchHistoryResult = {
|
||||
messages: NormalizedMessage[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
offset: number;
|
||||
limit: number | null;
|
||||
tokenUsage?: unknown;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export type AppErrorOptions = {
|
||||
code?: string;
|
||||
statusCode?: number;
|
||||
details?: unknown;
|
||||
};
|
||||
|
||||
// -------------------- MCP related shared types --------------------
|
||||
export type McpScope = 'user' | 'local' | 'project';
|
||||
|
||||
export type McpTransport = 'stdio' | 'http' | 'sse';
|
||||
|
||||
/**
|
||||
* Provider MCP server descriptor normalized for frontend consumption.
|
||||
*/
|
||||
export type ProviderMcpServer = {
|
||||
provider: LLMProvider;
|
||||
name: string;
|
||||
scope: McpScope;
|
||||
transport: McpTransport;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
envVars?: string[];
|
||||
bearerTokenEnvVar?: string;
|
||||
envHttpHeaders?: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared payload shape for MCP server create/update operations.
|
||||
*/
|
||||
export type UpsertProviderMcpServerInput = {
|
||||
name: string;
|
||||
scope?: McpScope;
|
||||
transport: McpTransport;
|
||||
workspacePath?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
envVars?: string[];
|
||||
bearerTokenEnvVar?: string;
|
||||
envHttpHeaders?: Record<string, string>;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
// -------------------- Provider auth status types --------------------
|
||||
/**
|
||||
* Result of a provider status check (installation + authentication).
|
||||
*
|
||||
* installed - Whether the provider's CLI/SDK is available
|
||||
* provider - Provider id the status belongs to
|
||||
* authenticated - Whether valid credentials exist
|
||||
* email - User email or auth method identifier
|
||||
* method - Auth method (e.g. 'api_key', 'credentials_file')
|
||||
* [error] - Error message if not installed or not authenticated
|
||||
*/
|
||||
export type ProviderAuthStatus = {
|
||||
installed: boolean;
|
||||
provider: LLMProvider;
|
||||
authenticated: boolean;
|
||||
email: string | null;
|
||||
method: string | null;
|
||||
error?: string;
|
||||
};
|
||||
208
server/shared/utils.ts
Normal file
208
server/shared/utils.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
|
||||
import type {
|
||||
ApiErrorShape,
|
||||
ApiSuccessShape,
|
||||
AppErrorOptions,
|
||||
NormalizedMessage,
|
||||
} from '@/shared/types.js';
|
||||
|
||||
type NormalizedMessageInput =
|
||||
{
|
||||
kind: NormalizedMessage['kind'];
|
||||
provider: NormalizedMessage['provider'];
|
||||
id?: string | null;
|
||||
sessionId?: string | null;
|
||||
timestamp?: string | null;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
export function createApiSuccessResponse<TData>(
|
||||
data: TData,
|
||||
): ApiSuccessShape<TData> {
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
export function createApiErrorResponse(
|
||||
code: string,
|
||||
message: string,
|
||||
details?: unknown
|
||||
): ApiErrorShape {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
details,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function asyncHandler(
|
||||
handler: (req: Request, res: Response, next: NextFunction) => Promise<unknown>
|
||||
): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
void Promise.resolve(handler(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
// --------- Global app error class for consistent error handling across the server ---------
|
||||
export class AppError extends Error {
|
||||
readonly code: string;
|
||||
readonly statusCode: number;
|
||||
readonly details?: unknown;
|
||||
|
||||
constructor(message: string, options: AppErrorOptions = {}) {
|
||||
super(message);
|
||||
this.name = 'AppError';
|
||||
this.code = options.code ?? 'INTERNAL_ERROR';
|
||||
this.statusCode = options.statusCode ?? 500;
|
||||
this.details = options.details;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
// ------------------------ Normalized provider message helpers ------------------------
|
||||
/**
|
||||
* Generates a stable unique id for normalized provider messages.
|
||||
*/
|
||||
export function generateMessageId(prefix = 'msg'): string {
|
||||
return `${prefix}_${randomUUID()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a normalized provider message and fills the shared envelope fields.
|
||||
*
|
||||
* Provider adapters and live SDK handlers pass through provider-specific fields,
|
||||
* while this helper guarantees every emitted event has an id, session id,
|
||||
* timestamp, and provider marker.
|
||||
*/
|
||||
export function createNormalizedMessage(fields: NormalizedMessageInput): NormalizedMessage {
|
||||
return {
|
||||
...fields,
|
||||
id: fields.id || generateMessageId(fields.kind),
|
||||
sessionId: fields.sessionId || '',
|
||||
timestamp: fields.timestamp || new Date().toISOString(),
|
||||
provider: fields.provider,
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
// ------------------------ The following are mainly for provider MCP runtimes ------------------------
|
||||
/**
|
||||
* Safely narrows an unknown value to a plain object record.
|
||||
*
|
||||
* This deliberately rejects arrays, `null`, and primitive values so callers can
|
||||
* treat the returned value as a JSON-style object map without repeating the same
|
||||
* defensive shape checks at every config read site.
|
||||
*/
|
||||
export const readObjectRecord = (value: unknown): Record<string, unknown> | null => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads an optional string from unknown input and normalizes empty or whitespace-only
|
||||
* values to `undefined`.
|
||||
*
|
||||
* This is useful when parsing config files where a field may be missing, present
|
||||
* with the wrong type, or present as an empty string that should be treated as
|
||||
* "not configured".
|
||||
*/
|
||||
export const readOptionalString = (value: unknown): string | undefined => {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads an optional string array from unknown input.
|
||||
*
|
||||
* Non-array values are ignored, and any array entries that are not strings are
|
||||
* filtered out. This lets provider config readers consume loosely shaped JSON/TOML
|
||||
* data without failing on incidental invalid members.
|
||||
*/
|
||||
export const readStringArray = (value: unknown): string[] | undefined => {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value.filter((entry): entry is string => typeof entry === 'string');
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads an optional string-to-string map from unknown input.
|
||||
*
|
||||
* The function first ensures the source value is a plain object, then keeps only
|
||||
* keys whose values are strings. If no valid entries remain, it returns `undefined`
|
||||
* so callers can distinguish "no usable map" from an empty object that was
|
||||
* intentionally authored downstream.
|
||||
*/
|
||||
export const readStringRecord = (value: unknown): Record<string, string> | undefined => {
|
||||
const record = readObjectRecord(value);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized: Record<string, string> = {};
|
||||
for (const [key, entry] of Object.entries(record)) {
|
||||
if (typeof entry === 'string') {
|
||||
normalized[key] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads a JSON config file and guarantees a plain object result.
|
||||
*
|
||||
* Missing files are treated as an empty config object so provider-specific MCP
|
||||
* readers can operate against first-run environments without special-case file
|
||||
* existence checks. If the file exists but contains invalid JSON, the parse error
|
||||
* is preserved and rethrown.
|
||||
*/
|
||||
export const readJsonConfig = async (filePath: string): Promise<Record<string, unknown>> => {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(content) as Record<string, unknown>;
|
||||
return readObjectRecord(parsed) ?? {};
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') {
|
||||
return {};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Writes a JSON config file with stable, human-readable formatting.
|
||||
*
|
||||
* The parent directory is created automatically so callers can persist config into
|
||||
* provider-specific folders without pre-creating the directory tree. Output always
|
||||
* ends with a trailing newline to keep the file diff-friendly.
|
||||
*/
|
||||
export const writeJsonConfig = async (filePath: string, data: Record<string, unknown>): Promise<void> => {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -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: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useServerPlatform } from "../../../../hooks/useServerPlatform";
|
||||
import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo";
|
||||
import {
|
||||
CLAUDE_MODELS,
|
||||
@@ -115,6 +116,23 @@ export default function ProviderSelectionEmptyState({
|
||||
setInput,
|
||||
}: ProviderSelectionEmptyStateProps) {
|
||||
const { t } = useTranslation("chat");
|
||||
const { isWindowsServer } = useServerPlatform();
|
||||
|
||||
const visibleProviders = useMemo(
|
||||
() =>
|
||||
isWindowsServer
|
||||
? PROVIDERS.filter((p) => p.id !== "cursor")
|
||||
: PROVIDERS,
|
||||
[isWindowsServer],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWindowsServer && provider === "cursor") {
|
||||
setProvider("claude");
|
||||
localStorage.setItem("selected-provider", "claude");
|
||||
}
|
||||
}, [isWindowsServer, provider, setProvider]);
|
||||
|
||||
const nextTaskPrompt = t("tasks.nextTaskPrompt", {
|
||||
defaultValue: "Start the next task",
|
||||
});
|
||||
@@ -166,8 +184,10 @@ export default function ProviderSelectionEmptyState({
|
||||
</div>
|
||||
|
||||
{/* Provider cards — horizontal row, equal width */}
|
||||
<div className="mb-6 grid grid-cols-2 gap-2 sm:grid-cols-4 sm:gap-2.5">
|
||||
{PROVIDERS.map((p) => {
|
||||
<div
|
||||
className={`mb-6 grid grid-cols-2 gap-2 sm:gap-2.5 ${visibleProviders.length >= 4 ? "sm:grid-cols-4" : "sm:grid-cols-3"}`}
|
||||
>
|
||||
{visibleProviders.map((p) => {
|
||||
const active = provider === p.id;
|
||||
return (
|
||||
<button
|
||||
|
||||
58
src/components/mcp/constants.ts
Normal file
58
src/components/mcp/constants.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { McpFormState, McpProvider, McpScope, McpTransport } from './types';
|
||||
|
||||
export const MCP_PROVIDER_NAMES: Record<McpProvider, string> = {
|
||||
claude: 'Claude',
|
||||
cursor: 'Cursor',
|
||||
codex: 'Codex',
|
||||
gemini: 'Gemini',
|
||||
};
|
||||
|
||||
export const MCP_SUPPORTED_SCOPES: Record<McpProvider, McpScope[]> = {
|
||||
claude: ['user', 'project', 'local'],
|
||||
cursor: ['user', 'project'],
|
||||
codex: ['user', 'project'],
|
||||
gemini: ['user', 'project'],
|
||||
};
|
||||
|
||||
export const MCP_SUPPORTED_TRANSPORTS: Record<McpProvider, McpTransport[]> = {
|
||||
claude: ['stdio', 'http', 'sse'],
|
||||
cursor: ['stdio', 'http'],
|
||||
codex: ['stdio', 'http'],
|
||||
gemini: ['stdio', 'http', 'sse'],
|
||||
};
|
||||
|
||||
export const MCP_GLOBAL_SUPPORTED_SCOPES: McpScope[] = ['user', 'project'];
|
||||
|
||||
export const MCP_GLOBAL_SUPPORTED_TRANSPORTS: McpTransport[] = ['stdio', 'http'];
|
||||
|
||||
export const MCP_PROVIDER_BUTTON_CLASSES: Record<McpProvider, string> = {
|
||||
claude: 'bg-purple-600 text-white hover:bg-purple-700',
|
||||
cursor: 'bg-purple-600 text-white hover:bg-purple-700',
|
||||
codex: 'bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',
|
||||
gemini: 'bg-blue-600 text-white hover:bg-blue-700',
|
||||
};
|
||||
|
||||
export const MCP_SUPPORTS_WORKING_DIRECTORY: Record<McpProvider, boolean> = {
|
||||
claude: false,
|
||||
cursor: false,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_MCP_FORM: McpFormState = {
|
||||
name: '',
|
||||
scope: 'user',
|
||||
workspacePath: '',
|
||||
transport: 'stdio',
|
||||
command: '',
|
||||
args: [],
|
||||
env: {},
|
||||
cwd: '',
|
||||
url: '',
|
||||
headers: {},
|
||||
envVars: [],
|
||||
bearerTokenEnvVar: '',
|
||||
envHttpHeaders: {},
|
||||
importMode: 'form',
|
||||
jsonInput: '',
|
||||
};
|
||||
248
src/components/mcp/hooks/useMcpServerForm.ts
Normal file
248
src/components/mcp/hooks/useMcpServerForm.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { type FormEvent, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { DEFAULT_MCP_FORM, MCP_SUPPORTED_SCOPES, MCP_SUPPORTED_TRANSPORTS } from '../constants';
|
||||
import type { McpFormState, McpProject, McpProvider, McpScope, McpTransport, ProviderMcpServer } from '../types';
|
||||
import {
|
||||
formatKeyValueLines,
|
||||
getErrorMessage,
|
||||
getProjectPath,
|
||||
isMcpTransport,
|
||||
parseKeyValueLines,
|
||||
parseListLines,
|
||||
} from '../utils/mcpFormatting';
|
||||
|
||||
type UseMcpServerFormArgs = {
|
||||
provider: McpProvider;
|
||||
isOpen: boolean;
|
||||
editingServer: ProviderMcpServer | null;
|
||||
currentProjects: McpProject[];
|
||||
supportedScopes?: McpScope[];
|
||||
supportedTransports?: McpTransport[];
|
||||
unsupportedTransportMessage?: (transport: McpTransport) => string;
|
||||
onSubmit: (formData: McpFormState, editingServer: ProviderMcpServer | null) => Promise<void>;
|
||||
};
|
||||
|
||||
type MultilineFieldText = {
|
||||
args: string;
|
||||
env: string;
|
||||
headers: string;
|
||||
envVars: string;
|
||||
envHttpHeaders: string;
|
||||
};
|
||||
|
||||
const cloneDefaultForm = (
|
||||
provider: McpProvider,
|
||||
supportedScopes = MCP_SUPPORTED_SCOPES[provider],
|
||||
supportedTransports = MCP_SUPPORTED_TRANSPORTS[provider],
|
||||
): McpFormState => ({
|
||||
...DEFAULT_MCP_FORM,
|
||||
scope: supportedScopes[0],
|
||||
transport: supportedTransports[0],
|
||||
args: [],
|
||||
env: {},
|
||||
headers: {},
|
||||
envVars: [],
|
||||
envHttpHeaders: {},
|
||||
});
|
||||
|
||||
const createFormStateFromServer = (
|
||||
provider: McpProvider,
|
||||
server: ProviderMcpServer,
|
||||
supportedScopes?: McpScope[],
|
||||
supportedTransports?: McpTransport[],
|
||||
): McpFormState => ({
|
||||
...cloneDefaultForm(provider, supportedScopes, supportedTransports),
|
||||
name: server.name,
|
||||
scope: server.scope,
|
||||
workspacePath: server.workspacePath || '',
|
||||
transport: server.transport,
|
||||
command: server.command || '',
|
||||
args: server.args || [],
|
||||
env: server.env || {},
|
||||
cwd: server.cwd || '',
|
||||
url: server.url || '',
|
||||
headers: server.headers || {},
|
||||
envVars: server.envVars || [],
|
||||
bearerTokenEnvVar: server.bearerTokenEnvVar || '',
|
||||
envHttpHeaders: server.envHttpHeaders || {},
|
||||
});
|
||||
|
||||
const createMultilineTextFromForm = (formData: McpFormState): MultilineFieldText => ({
|
||||
args: formData.args.join('\n'),
|
||||
env: formatKeyValueLines(formData.env),
|
||||
headers: formatKeyValueLines(formData.headers),
|
||||
envVars: formData.envVars.join('\n'),
|
||||
envHttpHeaders: formatKeyValueLines(formData.envHttpHeaders),
|
||||
});
|
||||
|
||||
const normalizeScope = (supportedScopes: McpScope[], value: McpScope): McpScope => (
|
||||
supportedScopes.includes(value) ? value : supportedScopes[0]
|
||||
);
|
||||
|
||||
const normalizeTransport = (supportedTransports: McpTransport[], value: McpTransport): McpTransport => (
|
||||
supportedTransports.includes(value) ? value : supportedTransports[0]
|
||||
);
|
||||
|
||||
export function useMcpServerForm({
|
||||
provider,
|
||||
isOpen,
|
||||
editingServer,
|
||||
currentProjects,
|
||||
supportedScopes = MCP_SUPPORTED_SCOPES[provider],
|
||||
supportedTransports = MCP_SUPPORTED_TRANSPORTS[provider],
|
||||
unsupportedTransportMessage,
|
||||
onSubmit,
|
||||
}: UseMcpServerFormArgs) {
|
||||
const { t } = useTranslation('settings');
|
||||
const [formData, setFormData] = useState<McpFormState>(() => (
|
||||
cloneDefaultForm(provider, supportedScopes, supportedTransports)
|
||||
));
|
||||
const [multilineText, setMultilineText] = useState<MultilineFieldText>(() => (
|
||||
createMultilineTextFromForm(cloneDefaultForm(provider, supportedScopes, supportedTransports))
|
||||
));
|
||||
const [jsonValidationError, setJsonValidationError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const isEditing = Boolean(editingServer);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
setJsonValidationError('');
|
||||
if (editingServer) {
|
||||
const nextFormData = createFormStateFromServer(provider, editingServer, supportedScopes, supportedTransports);
|
||||
setFormData(nextFormData);
|
||||
setMultilineText(createMultilineTextFromForm(nextFormData));
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFormData = cloneDefaultForm(provider, supportedScopes, supportedTransports);
|
||||
setFormData(nextFormData);
|
||||
setMultilineText(createMultilineTextFromForm(nextFormData));
|
||||
}, [editingServer, isOpen, provider, supportedScopes, supportedTransports]);
|
||||
|
||||
const projectOptions = useMemo(() => (
|
||||
currentProjects
|
||||
.map((project) => ({
|
||||
value: getProjectPath(project),
|
||||
label: project.displayName || project.name,
|
||||
}))
|
||||
.filter((project) => project.value)
|
||||
), [currentProjects]);
|
||||
|
||||
const updateForm = <K extends keyof McpFormState>(key: K, value: McpFormState[K]) => {
|
||||
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const updateScope = (scope: McpScope) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
scope: normalizeScope(supportedScopes, scope),
|
||||
workspacePath: scope === 'user' ? '' : prev.workspacePath,
|
||||
}));
|
||||
};
|
||||
|
||||
const updateTransport = (transport: McpTransport) => {
|
||||
setFormData((prev) => ({ ...prev, transport: normalizeTransport(supportedTransports, transport) }));
|
||||
};
|
||||
|
||||
const validateJsonInput = (value: string) => {
|
||||
if (!value.trim()) {
|
||||
setJsonValidationError('');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as { type?: unknown; transport?: unknown; command?: unknown; url?: unknown };
|
||||
const transportInput = parsed.transport || parsed.type;
|
||||
if (!isMcpTransport(transportInput)) {
|
||||
setJsonValidationError(t('mcpForm.validation.missingType'));
|
||||
} else if (!supportedTransports.includes(transportInput)) {
|
||||
setJsonValidationError(
|
||||
unsupportedTransportMessage?.(transportInput) ?? `${provider} does not support ${transportInput} MCP servers`,
|
||||
);
|
||||
} else if (transportInput === 'stdio' && !parsed.command) {
|
||||
setJsonValidationError(t('mcpForm.validation.stdioRequiresCommand'));
|
||||
} else if ((transportInput === 'http' || transportInput === 'sse') && !parsed.url) {
|
||||
setJsonValidationError(t('mcpForm.validation.httpRequiresUrl', { type: transportInput }));
|
||||
} else {
|
||||
setJsonValidationError('');
|
||||
}
|
||||
} catch {
|
||||
setJsonValidationError(t('mcpForm.validation.invalidJson'));
|
||||
}
|
||||
};
|
||||
|
||||
const updateJsonInput = (value: string) => {
|
||||
setFormData((prev) => ({ ...prev, jsonInput: value }));
|
||||
validateJsonInput(value);
|
||||
};
|
||||
|
||||
const updateMultilineText = <K extends keyof MultilineFieldText>(key: K, value: MultilineFieldText[K]) => {
|
||||
setMultilineText((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const createSubmitFormData = (): McpFormState => ({
|
||||
...formData,
|
||||
args: parseListLines(multilineText.args),
|
||||
env: parseKeyValueLines(multilineText.env),
|
||||
headers: parseKeyValueLines(multilineText.headers),
|
||||
envVars: parseListLines(multilineText.envVars),
|
||||
envHttpHeaders: parseKeyValueLines(multilineText.envHttpHeaders),
|
||||
});
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (!formData.name.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (formData.scope !== 'user' && !formData.workspacePath.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (formData.importMode === 'json') {
|
||||
return Boolean(formData.jsonInput.trim()) && !jsonValidationError;
|
||||
}
|
||||
|
||||
if (formData.transport === 'stdio') {
|
||||
return Boolean(formData.command.trim());
|
||||
}
|
||||
|
||||
return Boolean(formData.url.trim());
|
||||
}, [formData, jsonValidationError]);
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Textareas keep raw strings while editing so users can create blank
|
||||
// lines or partial KEY=value entries without the form rewriting them.
|
||||
await onSubmit(createSubmitFormData(), editingServer);
|
||||
} catch (error) {
|
||||
alert(`Error: ${getErrorMessage(error)}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
formData,
|
||||
setFormData,
|
||||
multilineText,
|
||||
projectOptions,
|
||||
isEditing,
|
||||
isSubmitting,
|
||||
jsonValidationError,
|
||||
canSubmit,
|
||||
updateForm,
|
||||
updateScope,
|
||||
updateTransport,
|
||||
updateJsonInput,
|
||||
updateMultilineText,
|
||||
handleSubmit,
|
||||
};
|
||||
}
|
||||
535
src/components/mcp/hooks/useMcpServers.ts
Normal file
535
src/components/mcp/hooks/useMcpServers.ts
Normal file
@@ -0,0 +1,535 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import { MCP_GLOBAL_SUPPORTED_TRANSPORTS, MCP_PROVIDER_NAMES, MCP_SUPPORTED_SCOPES } from '../constants';
|
||||
import type {
|
||||
ApiResponse,
|
||||
GlobalMcpServerResult,
|
||||
McpFormState,
|
||||
McpProject,
|
||||
McpProvider,
|
||||
McpScope,
|
||||
McpTransport,
|
||||
ProviderMcpServer,
|
||||
UpsertProviderMcpServerPayload,
|
||||
} from '../types';
|
||||
import {
|
||||
createMcpPayloadFromForm,
|
||||
getErrorMessage,
|
||||
getProjectPath,
|
||||
isMcpScope,
|
||||
isMcpTransport,
|
||||
} from '../utils/mcpFormatting';
|
||||
|
||||
type ProviderMcpServerResponse = {
|
||||
provider: McpProvider;
|
||||
scope: McpScope;
|
||||
servers: Array<Partial<ProviderMcpServer>>;
|
||||
};
|
||||
|
||||
type GlobalMcpServerResponse = {
|
||||
results: GlobalMcpServerResult[];
|
||||
};
|
||||
|
||||
type ProjectTarget = {
|
||||
name: string;
|
||||
displayName: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type McpServersCacheEntry = {
|
||||
servers: ProviderMcpServer[];
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
type ScopedProjectRequest = {
|
||||
scope: McpScope;
|
||||
project: ProjectTarget;
|
||||
};
|
||||
|
||||
const MCP_CACHE_TTL_MS = 30_000;
|
||||
const mcpServersCache = new Map<string, McpServersCacheEntry>();
|
||||
|
||||
// Settings users often switch between provider tabs repeatedly. A short module
|
||||
// cache prevents those tab switches from refetching every project config file.
|
||||
|
||||
const toResponseJson = async <T>(response: Response): Promise<T> => response.json() as Promise<T>;
|
||||
|
||||
const getApiErrorMessage = (payload: unknown, fallback: string): string => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const record = payload as Record<string, unknown>;
|
||||
const error = record.error;
|
||||
if (error && typeof error === 'object') {
|
||||
const message = (error as Record<string, unknown>).message;
|
||||
if (typeof message === 'string' && message.trim()) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof error === 'string' && error.trim()) {
|
||||
return error;
|
||||
}
|
||||
|
||||
const details = record.details;
|
||||
if (typeof details === 'string' && details.trim()) {
|
||||
return details;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const normalizeTransport = (value: unknown, fallback: McpTransport = 'stdio'): McpTransport => (
|
||||
isMcpTransport(value) ? value : fallback
|
||||
);
|
||||
|
||||
const normalizeScope = (value: unknown, fallback: McpScope): McpScope => (
|
||||
isMcpScope(value) ? value : fallback
|
||||
);
|
||||
|
||||
const normalizeServer = (
|
||||
provider: McpProvider,
|
||||
scope: McpScope,
|
||||
server: Partial<ProviderMcpServer>,
|
||||
project?: ProjectTarget,
|
||||
): ProviderMcpServer => {
|
||||
const transport = normalizeTransport(server.transport, server.url ? 'http' : 'stdio');
|
||||
return {
|
||||
provider,
|
||||
name: String(server.name ?? ''),
|
||||
scope: normalizeScope(server.scope, scope),
|
||||
transport,
|
||||
command: server.command,
|
||||
args: server.args ?? [],
|
||||
env: server.env ?? {},
|
||||
cwd: server.cwd,
|
||||
url: server.url,
|
||||
headers: server.headers ?? {},
|
||||
envVars: server.envVars ?? [],
|
||||
bearerTokenEnvVar: server.bearerTokenEnvVar,
|
||||
envHttpHeaders: server.envHttpHeaders ?? {},
|
||||
workspacePath: project?.path || server.workspacePath,
|
||||
projectName: project?.name || server.projectName,
|
||||
projectDisplayName: project?.displayName || server.projectDisplayName,
|
||||
};
|
||||
};
|
||||
|
||||
const createProjectTargets = (projects: McpProject[]): ProjectTarget[] => {
|
||||
const seen = new Set<string>();
|
||||
return projects.reduce<ProjectTarget[]>((acc, project) => {
|
||||
const projectPath = getProjectPath(project);
|
||||
if (!projectPath || seen.has(projectPath)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
seen.add(projectPath);
|
||||
acc.push({
|
||||
name: project.name,
|
||||
displayName: project.displayName || project.name,
|
||||
path: projectPath,
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const fetchProviderScopeServers = async (
|
||||
provider: McpProvider,
|
||||
scope: McpScope,
|
||||
project?: ProjectTarget,
|
||||
): Promise<ProviderMcpServer[]> => {
|
||||
const params = new URLSearchParams({ scope });
|
||||
if (project?.path) {
|
||||
params.set('workspacePath', project.path);
|
||||
}
|
||||
|
||||
const response = await authenticatedFetch(`/api/providers/${provider}/mcp/servers?${params.toString()}`);
|
||||
const data = await toResponseJson<ApiResponse<ProviderMcpServerResponse>>(response);
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(getApiErrorMessage(data, `Failed to load ${provider} MCP servers`));
|
||||
}
|
||||
|
||||
return (data.data.servers || []).map((server) => normalizeServer(provider, scope, server, project));
|
||||
};
|
||||
|
||||
const deleteProviderServer = async (
|
||||
provider: McpProvider,
|
||||
server: ProviderMcpServer,
|
||||
): Promise<void> => {
|
||||
const params = new URLSearchParams({ scope: server.scope });
|
||||
if (server.workspacePath) {
|
||||
params.set('workspacePath', server.workspacePath);
|
||||
}
|
||||
|
||||
const response = await authenticatedFetch(
|
||||
`/api/providers/${provider}/mcp/servers/${encodeURIComponent(server.name)}?${params.toString()}`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
const data = await toResponseJson<ApiResponse<{ removed: boolean }>>(response);
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(getApiErrorMessage(data, 'Failed to delete MCP server'));
|
||||
}
|
||||
};
|
||||
|
||||
const saveProviderServer = async (
|
||||
provider: McpProvider,
|
||||
payload: UpsertProviderMcpServerPayload,
|
||||
): Promise<void> => {
|
||||
const response = await authenticatedFetch(`/api/providers/${provider}/mcp/servers`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await toResponseJson<ApiResponse<{ server: ProviderMcpServer }>>(response);
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(getApiErrorMessage(data, 'Failed to save MCP server'));
|
||||
}
|
||||
};
|
||||
|
||||
const saveGlobalServer = async (
|
||||
payload: UpsertProviderMcpServerPayload,
|
||||
): Promise<GlobalMcpServerResult[]> => {
|
||||
const response = await authenticatedFetch('/api/providers/mcp/servers/global', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await toResponseJson<ApiResponse<GlobalMcpServerResponse>>(response);
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(getApiErrorMessage(data, 'Failed to save MCP server to all providers'));
|
||||
}
|
||||
|
||||
return data.data.results || [];
|
||||
};
|
||||
|
||||
const didServerIdentityChange = (
|
||||
editingServer: ProviderMcpServer,
|
||||
payload: UpsertProviderMcpServerPayload,
|
||||
): boolean => (
|
||||
editingServer.name !== payload.name
|
||||
|| editingServer.scope !== payload.scope
|
||||
|| (editingServer.workspacePath || '') !== (payload.workspacePath || '')
|
||||
);
|
||||
|
||||
const getServerIdentity = (server: ProviderMcpServer): string => (
|
||||
`${server.provider}:${server.scope}:${server.workspacePath || 'global'}:${server.name}`
|
||||
);
|
||||
|
||||
const getCacheKey = (provider: McpProvider, projects: ProjectTarget[]): string => {
|
||||
const projectKey = projects.map((project) => project.path).sort().join('|');
|
||||
return `${provider}:${projectKey}`;
|
||||
};
|
||||
|
||||
const formatGlobalAddFailures = (failures: GlobalMcpServerResult[]): string => (
|
||||
failures
|
||||
.map((failure) => `${MCP_PROVIDER_NAMES[failure.provider]}: ${failure.error || 'Unknown error'}`)
|
||||
.join('; ')
|
||||
);
|
||||
|
||||
const sortServers = (servers: ProviderMcpServer[]): ProviderMcpServer[] => {
|
||||
const scopeOrder: Record<McpScope, number> = {
|
||||
user: 0,
|
||||
project: 1,
|
||||
local: 2,
|
||||
};
|
||||
|
||||
return [...servers].sort((left, right) => {
|
||||
const scopeDelta = scopeOrder[left.scope] - scopeOrder[right.scope];
|
||||
if (scopeDelta !== 0) {
|
||||
return scopeDelta;
|
||||
}
|
||||
|
||||
const projectDelta = (left.projectDisplayName || '').localeCompare(right.projectDisplayName || '');
|
||||
if (projectDelta !== 0) {
|
||||
return projectDelta;
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
};
|
||||
|
||||
const mergeServers = (
|
||||
existingServers: ProviderMcpServer[],
|
||||
incomingServers: ProviderMcpServer[],
|
||||
): ProviderMcpServer[] => {
|
||||
const serversById = new Map<string, ProviderMcpServer>();
|
||||
existingServers.forEach((server) => {
|
||||
serversById.set(getServerIdentity(server), server);
|
||||
});
|
||||
incomingServers.forEach((server) => {
|
||||
serversById.set(getServerIdentity(server), server);
|
||||
});
|
||||
|
||||
return sortServers([...serversById.values()]);
|
||||
};
|
||||
|
||||
const replaceScopedServers = (
|
||||
existingServers: ProviderMcpServer[],
|
||||
incomingServers: ProviderMcpServer[],
|
||||
scope: McpScope,
|
||||
workspacePath?: string,
|
||||
): ProviderMcpServer[] => {
|
||||
const remainingServers = existingServers.filter((server) => (
|
||||
server.scope !== scope || (server.workspacePath || '') !== (workspacePath || '')
|
||||
));
|
||||
|
||||
return mergeServers(remainingServers, incomingServers);
|
||||
};
|
||||
|
||||
type UseMcpServersArgs = {
|
||||
selectedProvider: McpProvider;
|
||||
currentProjects: McpProject[];
|
||||
};
|
||||
|
||||
export function useMcpServers({ selectedProvider, currentProjects }: UseMcpServersArgs) {
|
||||
const [servers, setServers] = useState<ProviderMcpServer[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
|
||||
const [isLoadingProjectScopes, setIsLoadingProjectScopes] = useState(false);
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [isGlobalFormOpen, setIsGlobalFormOpen] = useState(false);
|
||||
const [editingServer, setEditingServer] = useState<ProviderMcpServer | null>(null);
|
||||
const activeLoadIdRef = useRef(0);
|
||||
|
||||
const projectTargets = useMemo(() => createProjectTargets(currentProjects), [currentProjects]);
|
||||
const cacheKey = useMemo(() => getCacheKey(selectedProvider, projectTargets), [projectTargets, selectedProvider]);
|
||||
|
||||
const refreshServers = useCallback(async (options: { force?: boolean } = {}) => {
|
||||
const loadId = activeLoadIdRef.current + 1;
|
||||
activeLoadIdRef.current = loadId;
|
||||
|
||||
const cachedEntry = mcpServersCache.get(cacheKey);
|
||||
const canUseCache = !options.force && cachedEntry && Date.now() - cachedEntry.updatedAt < MCP_CACHE_TTL_MS;
|
||||
if (canUseCache) {
|
||||
setServers(cachedEntry.servers);
|
||||
setIsLoading(false);
|
||||
setIsLoadingProjectScopes(false);
|
||||
setLoadError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cachedEntry && !options.force) {
|
||||
setServers(cachedEntry.servers);
|
||||
} else {
|
||||
setServers([]);
|
||||
}
|
||||
|
||||
setIsLoading(!cachedEntry);
|
||||
setIsLoadingProjectScopes(false);
|
||||
setLoadError(null);
|
||||
|
||||
const supportedScopes = MCP_SUPPORTED_SCOPES[selectedProvider];
|
||||
let nextServers: ProviderMcpServer[] = cachedEntry && !options.force ? cachedEntry.servers : [];
|
||||
let firstError: string | null = null;
|
||||
|
||||
// Load the global/user scope first so the visible list can paint quickly.
|
||||
// Project and local scopes can involve many project config files, so they
|
||||
// are appended below as background requests instead of blocking this render.
|
||||
if (supportedScopes.includes('user')) {
|
||||
try {
|
||||
const userServers = await fetchProviderScopeServers(selectedProvider, 'user');
|
||||
if (activeLoadIdRef.current !== loadId) {
|
||||
return;
|
||||
}
|
||||
|
||||
nextServers = replaceScopedServers(nextServers, userServers, 'user');
|
||||
setServers(sortServers(nextServers));
|
||||
} catch (error) {
|
||||
firstError = getErrorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (activeLoadIdRef.current !== loadId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
const projectScopeRequests: ScopedProjectRequest[] = [];
|
||||
projectTargets.forEach((project) => {
|
||||
if (supportedScopes.includes('project')) {
|
||||
projectScopeRequests.push({ scope: 'project', project });
|
||||
}
|
||||
|
||||
if (supportedScopes.includes('local')) {
|
||||
projectScopeRequests.push({ scope: 'local', project });
|
||||
}
|
||||
});
|
||||
|
||||
if (projectScopeRequests.length === 0) {
|
||||
const finalServers = sortServers(nextServers);
|
||||
mcpServersCache.set(cacheKey, { servers: finalServers, updatedAt: Date.now() });
|
||||
setLoadError(firstError);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingProjectScopes(true);
|
||||
|
||||
// Update the UI as each project scope resolves. This avoids waiting for the
|
||||
// slowest project before showing servers from faster config files.
|
||||
await Promise.all(projectScopeRequests.map(async ({ scope, project }) => {
|
||||
try {
|
||||
const scopedServers = await fetchProviderScopeServers(selectedProvider, scope, project);
|
||||
if (activeLoadIdRef.current !== loadId) {
|
||||
return;
|
||||
}
|
||||
|
||||
nextServers = replaceScopedServers(nextServers, scopedServers, scope, project.path);
|
||||
setServers(nextServers);
|
||||
} catch (error) {
|
||||
firstError = firstError || getErrorMessage(error);
|
||||
}
|
||||
}));
|
||||
|
||||
if (activeLoadIdRef.current !== loadId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const finalServers = sortServers(nextServers);
|
||||
mcpServersCache.set(cacheKey, { servers: finalServers, updatedAt: Date.now() });
|
||||
setServers(finalServers);
|
||||
setLoadError(firstError);
|
||||
setIsLoadingProjectScopes(false);
|
||||
}, [cacheKey, projectTargets, selectedProvider]);
|
||||
|
||||
const openForm = useCallback((server?: ProviderMcpServer) => {
|
||||
setEditingServer(server || null);
|
||||
setIsFormOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeForm = useCallback(() => {
|
||||
setIsFormOpen(false);
|
||||
setEditingServer(null);
|
||||
}, []);
|
||||
|
||||
const openGlobalForm = useCallback(() => {
|
||||
setIsGlobalFormOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeGlobalForm = useCallback(() => {
|
||||
setIsGlobalFormOpen(false);
|
||||
}, []);
|
||||
|
||||
const submitForm = useCallback(
|
||||
async (formData: McpFormState, serverBeingEdited: ProviderMcpServer | null) => {
|
||||
const payload = createMcpPayloadFromForm(selectedProvider, formData);
|
||||
if (payload.scope !== 'user' && !payload.workspacePath) {
|
||||
throw new Error('Select a project for project-scoped MCP servers');
|
||||
}
|
||||
|
||||
await saveProviderServer(selectedProvider, payload);
|
||||
|
||||
if (serverBeingEdited && didServerIdentityChange(serverBeingEdited, payload)) {
|
||||
await deleteProviderServer(selectedProvider, serverBeingEdited);
|
||||
}
|
||||
|
||||
mcpServersCache.delete(cacheKey);
|
||||
await refreshServers({ force: true });
|
||||
setSaveStatus('success');
|
||||
closeForm();
|
||||
},
|
||||
[cacheKey, closeForm, refreshServers, selectedProvider],
|
||||
);
|
||||
|
||||
const submitGlobalForm = useCallback(
|
||||
async (formData: McpFormState) => {
|
||||
const payload = createMcpPayloadFromForm(selectedProvider, formData, {
|
||||
supportedTransports: MCP_GLOBAL_SUPPORTED_TRANSPORTS,
|
||||
supportsWorkingDirectory: false,
|
||||
includeProviderSpecificFields: false,
|
||||
unsupportedTransportMessage: (transport) =>
|
||||
`Add MCP Server supports only stdio and http across all providers, not ${transport}.`,
|
||||
});
|
||||
|
||||
if (payload.scope === 'local') {
|
||||
throw new Error('Add MCP Server supports only user or project scope across all providers.');
|
||||
}
|
||||
|
||||
if (payload.scope !== 'user' && !payload.workspacePath) {
|
||||
throw new Error('Select a project for project-scoped MCP servers');
|
||||
}
|
||||
|
||||
// The global endpoint updates every provider, so clear every provider
|
||||
// cache entry instead of only the currently visible provider tab.
|
||||
const results = await saveGlobalServer(payload);
|
||||
mcpServersCache.clear();
|
||||
await refreshServers({ force: true });
|
||||
|
||||
const failures = results.filter((result) => !result.created);
|
||||
if (failures.length > 0) {
|
||||
setSaveStatus('error');
|
||||
throw new Error(`Failed to add MCP server to all providers. ${formatGlobalAddFailures(failures)}`);
|
||||
}
|
||||
|
||||
setSaveStatus('success');
|
||||
closeGlobalForm();
|
||||
},
|
||||
[closeGlobalForm, refreshServers, selectedProvider],
|
||||
);
|
||||
|
||||
const deleteServer = useCallback(
|
||||
async (server: ProviderMcpServer) => {
|
||||
if (!window.confirm('Are you sure you want to delete this MCP server?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await deleteProviderServer(selectedProvider, server);
|
||||
mcpServersCache.delete(cacheKey);
|
||||
await refreshServers({ force: true });
|
||||
setSaveStatus('success');
|
||||
} catch (error) {
|
||||
setDeleteError(getErrorMessage(error));
|
||||
setSaveStatus('error');
|
||||
}
|
||||
},
|
||||
[cacheKey, refreshServers, selectedProvider],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshServers();
|
||||
}, [refreshServers]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsFormOpen(false);
|
||||
setIsGlobalFormOpen(false);
|
||||
setEditingServer(null);
|
||||
setDeleteError(null);
|
||||
setSaveStatus(null);
|
||||
}, [selectedProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (saveStatus === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => setSaveStatus(null), 2000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [saveStatus]);
|
||||
|
||||
return {
|
||||
servers,
|
||||
isLoading,
|
||||
isLoadingProjectScopes,
|
||||
loadError,
|
||||
deleteError,
|
||||
saveStatus,
|
||||
isFormOpen,
|
||||
isGlobalFormOpen,
|
||||
editingServer,
|
||||
openForm,
|
||||
openGlobalForm,
|
||||
closeForm,
|
||||
closeGlobalForm,
|
||||
submitForm,
|
||||
submitGlobalForm,
|
||||
deleteServer,
|
||||
refreshServers,
|
||||
};
|
||||
}
|
||||
1
src/components/mcp/index.ts
Normal file
1
src/components/mcp/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as McpServers } from './view/McpServers';
|
||||
90
src/components/mcp/types.ts
Normal file
90
src/components/mcp/types.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { LLMProvider } from '../../types/app';
|
||||
|
||||
export type McpProvider = LLMProvider;
|
||||
export type McpScope = 'user' | 'local' | 'project';
|
||||
export type McpTransport = 'stdio' | 'http' | 'sse';
|
||||
export type McpImportMode = 'form' | 'json';
|
||||
export type McpFormMode = 'provider' | 'global';
|
||||
export type KeyValueMap = Record<string, string>;
|
||||
|
||||
export type McpProject = {
|
||||
name: string;
|
||||
displayName?: string;
|
||||
fullPath?: string;
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export type ProviderMcpServer = {
|
||||
provider: McpProvider;
|
||||
name: string;
|
||||
scope: McpScope;
|
||||
transport: McpTransport;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: KeyValueMap;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
headers?: KeyValueMap;
|
||||
envVars?: string[];
|
||||
bearerTokenEnvVar?: string;
|
||||
envHttpHeaders?: KeyValueMap;
|
||||
workspacePath?: string;
|
||||
projectName?: string;
|
||||
projectDisplayName?: string;
|
||||
};
|
||||
|
||||
export type McpFormState = {
|
||||
name: string;
|
||||
scope: McpScope;
|
||||
workspacePath: string;
|
||||
transport: McpTransport;
|
||||
command: string;
|
||||
args: string[];
|
||||
env: KeyValueMap;
|
||||
cwd: string;
|
||||
url: string;
|
||||
headers: KeyValueMap;
|
||||
envVars: string[];
|
||||
bearerTokenEnvVar: string;
|
||||
envHttpHeaders: KeyValueMap;
|
||||
importMode: McpImportMode;
|
||||
jsonInput: string;
|
||||
};
|
||||
|
||||
export type UpsertProviderMcpServerPayload = {
|
||||
name: string;
|
||||
scope: McpScope;
|
||||
transport: McpTransport;
|
||||
workspacePath?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: KeyValueMap;
|
||||
cwd?: string;
|
||||
url?: string;
|
||||
headers?: KeyValueMap;
|
||||
envVars?: string[];
|
||||
bearerTokenEnvVar?: string;
|
||||
envHttpHeaders?: KeyValueMap;
|
||||
};
|
||||
|
||||
export type GlobalMcpServerResult = {
|
||||
provider: McpProvider;
|
||||
created: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ApiSuccessResponse<T> = {
|
||||
success: true;
|
||||
data: T;
|
||||
};
|
||||
|
||||
export type ApiErrorResponse = {
|
||||
success: false;
|
||||
error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
details?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;
|
||||
184
src/components/mcp/utils/mcpFormatting.ts
Normal file
184
src/components/mcp/utils/mcpFormatting.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { MCP_SUPPORTED_TRANSPORTS, MCP_SUPPORTS_WORKING_DIRECTORY } from '../constants';
|
||||
import type {
|
||||
KeyValueMap,
|
||||
McpFormState,
|
||||
McpProvider,
|
||||
McpScope,
|
||||
McpTransport,
|
||||
UpsertProviderMcpServerPayload,
|
||||
} from '../types';
|
||||
|
||||
type CreateMcpPayloadOptions = {
|
||||
supportedTransports?: McpTransport[];
|
||||
supportsWorkingDirectory?: boolean;
|
||||
includeProviderSpecificFields?: boolean;
|
||||
unsupportedTransportMessage?: (transport: McpTransport) => string;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => (
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
);
|
||||
|
||||
const readString = (value: unknown): string | undefined => (
|
||||
typeof value === 'string' && value.trim() ? value.trim() : undefined
|
||||
);
|
||||
|
||||
const readStringArray = (value: unknown): string[] | undefined => (
|
||||
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : undefined
|
||||
);
|
||||
|
||||
const readStringRecord = (value: unknown): KeyValueMap | undefined => {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized: KeyValueMap = {};
|
||||
Object.entries(value).forEach(([key, entry]) => {
|
||||
if (typeof entry === 'string') {
|
||||
normalized[key] = entry;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
};
|
||||
|
||||
export const formatKeyValueLines = (value: KeyValueMap): string => (
|
||||
Object.entries(value).map(([key, entry]) => `${key}=${entry}`).join('\n')
|
||||
);
|
||||
|
||||
export const parseKeyValueLines = (value: string): KeyValueMap => {
|
||||
const normalized: KeyValueMap = {};
|
||||
value.split('\n').forEach((line) => {
|
||||
const [key, ...valueParts] = line.split('=');
|
||||
if (key?.trim()) {
|
||||
normalized[key.trim()] = valueParts.join('=').trim();
|
||||
}
|
||||
});
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const parseListLines = (value: string): string[] => (
|
||||
value.split('\n').map((entry) => entry.trim()).filter(Boolean)
|
||||
);
|
||||
|
||||
export const maskSecret = (value: unknown): string => {
|
||||
const normalizedValue = String(value ?? '');
|
||||
if (normalizedValue.length <= 4) {
|
||||
return '****';
|
||||
}
|
||||
|
||||
return `${normalizedValue.slice(0, 2)}****${normalizedValue.slice(-2)}`;
|
||||
};
|
||||
|
||||
export const isMcpScope = (value: unknown): value is McpScope => (
|
||||
value === 'user' || value === 'local' || value === 'project'
|
||||
);
|
||||
|
||||
export const isMcpTransport = (value: unknown): value is McpTransport => (
|
||||
value === 'stdio' || value === 'http' || value === 'sse'
|
||||
);
|
||||
|
||||
export const getProjectPath = (project: { fullPath?: string; path?: string }): string => (
|
||||
project.fullPath || project.path || ''
|
||||
);
|
||||
|
||||
export const getErrorMessage = (error: unknown): string => (
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
);
|
||||
|
||||
const assertSupportedTransport = (
|
||||
provider: McpProvider,
|
||||
transport: McpTransport,
|
||||
options?: CreateMcpPayloadOptions,
|
||||
) => {
|
||||
const supportedTransports = options?.supportedTransports ?? MCP_SUPPORTED_TRANSPORTS[provider];
|
||||
if (supportedTransports.includes(transport)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
options?.unsupportedTransportMessage?.(transport) ?? `${provider} does not support ${transport} MCP servers`,
|
||||
);
|
||||
};
|
||||
|
||||
export const parseJsonMcpPayload = (
|
||||
provider: McpProvider,
|
||||
formData: McpFormState,
|
||||
options?: CreateMcpPayloadOptions,
|
||||
): UpsertProviderMcpServerPayload => {
|
||||
const parsed = JSON.parse(formData.jsonInput) as unknown;
|
||||
if (!isRecord(parsed)) {
|
||||
throw new Error('JSON configuration must be an object');
|
||||
}
|
||||
|
||||
const transportInput = readString(parsed.transport) ?? readString(parsed.type);
|
||||
const transport = isMcpTransport(transportInput) ? transportInput : undefined;
|
||||
if (!transport) {
|
||||
throw new Error('Missing required field: type');
|
||||
}
|
||||
|
||||
assertSupportedTransport(provider, transport, options);
|
||||
|
||||
if (transport === 'stdio' && !readString(parsed.command)) {
|
||||
throw new Error('stdio type requires a command field');
|
||||
}
|
||||
|
||||
if ((transport === 'http' || transport === 'sse') && !readString(parsed.url)) {
|
||||
throw new Error(`${transport} type requires a url field`);
|
||||
}
|
||||
|
||||
return {
|
||||
name: formData.name.trim(),
|
||||
scope: formData.scope,
|
||||
workspacePath: formData.scope === 'user' ? undefined : formData.workspacePath,
|
||||
transport,
|
||||
command: readString(parsed.command),
|
||||
args: readStringArray(parsed.args) ?? [],
|
||||
env: readStringRecord(parsed.env) ?? {},
|
||||
cwd: (options?.supportsWorkingDirectory ?? MCP_SUPPORTS_WORKING_DIRECTORY[provider])
|
||||
? readString(parsed.cwd)
|
||||
: undefined,
|
||||
url: readString(parsed.url),
|
||||
headers: readStringRecord(parsed.headers ?? parsed.http_headers) ?? {},
|
||||
envVars: (options?.includeProviderSpecificFields ?? provider === 'codex')
|
||||
? readStringArray(parsed.envVars ?? parsed.env_vars) ?? []
|
||||
: undefined,
|
||||
bearerTokenEnvVar: (options?.includeProviderSpecificFields ?? provider === 'codex')
|
||||
? readString(parsed.bearerTokenEnvVar ?? parsed.bearer_token_env_var)
|
||||
: undefined,
|
||||
envHttpHeaders: (options?.includeProviderSpecificFields ?? provider === 'codex')
|
||||
? readStringRecord(parsed.envHttpHeaders ?? parsed.env_http_headers) ?? {}
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const createMcpPayloadFromForm = (
|
||||
provider: McpProvider,
|
||||
formData: McpFormState,
|
||||
options?: CreateMcpPayloadOptions,
|
||||
): UpsertProviderMcpServerPayload => {
|
||||
if (formData.importMode === 'json') {
|
||||
return parseJsonMcpPayload(provider, formData, options);
|
||||
}
|
||||
|
||||
assertSupportedTransport(provider, formData.transport, options);
|
||||
|
||||
const supportsWorkingDirectory = options?.supportsWorkingDirectory ?? MCP_SUPPORTS_WORKING_DIRECTORY[provider];
|
||||
const includeProviderSpecificFields = options?.includeProviderSpecificFields ?? provider === 'codex';
|
||||
|
||||
return {
|
||||
name: formData.name.trim(),
|
||||
scope: formData.scope,
|
||||
workspacePath: formData.scope === 'user' ? undefined : formData.workspacePath,
|
||||
transport: formData.transport,
|
||||
command: formData.transport === 'stdio' ? formData.command.trim() : undefined,
|
||||
args: formData.transport === 'stdio' ? formData.args : undefined,
|
||||
env: formData.env,
|
||||
cwd: supportsWorkingDirectory ? formData.cwd.trim() || undefined : undefined,
|
||||
url: formData.transport !== 'stdio' ? formData.url.trim() : undefined,
|
||||
headers: formData.transport !== 'stdio' ? formData.headers : undefined,
|
||||
envVars: includeProviderSpecificFields ? formData.envVars : undefined,
|
||||
bearerTokenEnvVar: includeProviderSpecificFields ? formData.bearerTokenEnvVar.trim() || undefined : undefined,
|
||||
envHttpHeaders: includeProviderSpecificFields ? formData.envHttpHeaders : undefined,
|
||||
};
|
||||
};
|
||||
281
src/components/mcp/view/McpServers.tsx
Normal file
281
src/components/mcp/view/McpServers.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { Edit3, ExternalLink, Globe, Lock, Plus, Server, Terminal, Trash2, Users, Zap } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { McpProject, McpProvider, McpScope, ProviderMcpServer } from '../types';
|
||||
import { IS_PLATFORM } from '../../../constants/config';
|
||||
import { Badge, Button } from '../../../shared/view/ui';
|
||||
import {
|
||||
MCP_GLOBAL_SUPPORTED_SCOPES,
|
||||
MCP_GLOBAL_SUPPORTED_TRANSPORTS,
|
||||
MCP_PROVIDER_BUTTON_CLASSES,
|
||||
MCP_PROVIDER_NAMES,
|
||||
} from '../constants';
|
||||
import { useMcpServers } from '../hooks/useMcpServers';
|
||||
import { maskSecret } from '../utils/mcpFormatting';
|
||||
|
||||
import McpServerFormModal from './modals/McpServerFormModal';
|
||||
|
||||
type McpServersProps = {
|
||||
selectedProvider: McpProvider;
|
||||
currentProjects: McpProject[];
|
||||
};
|
||||
|
||||
const getTransportIcon = (transport: string | undefined) => {
|
||||
if (transport === 'stdio') {
|
||||
return <Terminal className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
if (transport === 'sse') {
|
||||
return <Zap className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
if (transport === 'http') {
|
||||
return <Globe className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
return <Server className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
const getScopeLabel = (scope: McpScope): string => {
|
||||
if (scope === 'user') {
|
||||
return 'user';
|
||||
}
|
||||
|
||||
if (scope === 'local') {
|
||||
return 'local';
|
||||
}
|
||||
|
||||
return 'project';
|
||||
};
|
||||
|
||||
const getServerKey = (server: ProviderMcpServer): string => (
|
||||
`${server.provider}:${server.scope}:${server.workspacePath || 'global'}:${server.name}`
|
||||
);
|
||||
|
||||
function ConfigLine({ label, children }: { label: string; children: string }) {
|
||||
if (!children) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label}:{' '}
|
||||
<code className="rounded bg-muted px-1 text-xs">{children}</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamMcpFeatureCard() {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-border/60 bg-muted/20 p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-muted/60 text-muted-foreground">
|
||||
<Users className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-foreground">Team MCP Configs</h4>
|
||||
<Lock className="h-3 w-3 text-muted-foreground/60" />
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
Share MCP server configurations across your team. Everyone stays in sync automatically.
|
||||
</p>
|
||||
<a
|
||||
href="https://cloudcli.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-primary transition-colors hover:underline"
|
||||
>
|
||||
Available with CloudCLI Pro
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function McpServers({ selectedProvider, currentProjects }: McpServersProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const {
|
||||
servers,
|
||||
isLoading,
|
||||
isLoadingProjectScopes,
|
||||
loadError,
|
||||
deleteError,
|
||||
saveStatus,
|
||||
isFormOpen,
|
||||
isGlobalFormOpen,
|
||||
editingServer,
|
||||
openForm,
|
||||
openGlobalForm,
|
||||
closeForm,
|
||||
closeGlobalForm,
|
||||
submitForm,
|
||||
submitGlobalForm,
|
||||
deleteServer,
|
||||
} = useMcpServers({ selectedProvider, currentProjects });
|
||||
|
||||
const providerName = MCP_PROVIDER_NAMES[selectedProvider];
|
||||
const description = t(`mcpServers.description.${selectedProvider}`, {
|
||||
defaultValue: `Model Context Protocol servers provide additional tools and data sources to ${providerName}`,
|
||||
});
|
||||
const globalButtonLabel = 'Add Global MCP Server';
|
||||
const providerButtonLabel = `Add ${providerName} MCP Server`;
|
||||
const globalAddDescription = 'Add Global MCP Server writes one common stdio or HTTP server to Claude, Cursor, Codex, and Gemini.';
|
||||
const providerAddDescription = `${providerButtonLabel} only changes ${providerName}.`;
|
||||
const globalModalDescription = 'Adds this MCP server to every provider: Claude, Cursor, Codex, and Gemini. '
|
||||
+ 'Only stdio and HTTP transports are supported because the same config must work across all providers.';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="h-5 w-5 text-purple-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
onClick={openGlobalForm}
|
||||
className={MCP_PROVIDER_BUTTON_CLASSES[selectedProvider]}
|
||||
size="sm"
|
||||
title={globalAddDescription}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{globalButtonLabel}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => openForm()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
title={providerAddDescription}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{providerButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-h-4">
|
||||
{saveStatus === 'success' && (
|
||||
<span className="animate-in fade-in text-xs text-muted-foreground">{t('saveStatus.success')}</span>
|
||||
)}
|
||||
{isLoadingProjectScopes && (
|
||||
<span className="animate-in fade-in text-xs text-muted-foreground">Refreshing project scopes...</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(loadError || deleteError) && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
|
||||
{deleteError || loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{isLoading && servers.length === 0 && (
|
||||
<div className="py-8 text-center text-muted-foreground">Loading MCP servers...</div>
|
||||
)}
|
||||
|
||||
{servers.map((server) => (
|
||||
<div key={getServerKey(server)} className="rounded-lg border border-border bg-card/50 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
{getTransportIcon(server.transport)}
|
||||
<span className="font-medium text-foreground">{server.name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{server.transport || 'stdio'}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getScopeLabel(server.scope)}
|
||||
</Badge>
|
||||
{server.projectDisplayName && (
|
||||
<Badge variant="outline" className="max-w-full truncate text-xs">
|
||||
{server.projectDisplayName}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
<ConfigLine label={t('mcpServers.config.command')}>{server.command || ''}</ConfigLine>
|
||||
<ConfigLine label={t('mcpServers.config.url')}>{server.url || ''}</ConfigLine>
|
||||
<ConfigLine label={t('mcpServers.config.args')}>{(server.args || []).join(' ')}</ConfigLine>
|
||||
<ConfigLine label="Cwd">{server.cwd || ''}</ConfigLine>
|
||||
{server.env && Object.keys(server.env).length > 0 && (
|
||||
<ConfigLine label={t('mcpServers.config.environment')}>
|
||||
{Object.entries(server.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}
|
||||
</ConfigLine>
|
||||
)}
|
||||
{server.envVars && server.envVars.length > 0 && (
|
||||
<ConfigLine label="Env Vars">{server.envVars.join(', ')}</ConfigLine>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-4 flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => openForm(server)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title={t('mcpServers.actions.edit')}
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => deleteServer(server)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700"
|
||||
title={t('mcpServers.actions.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!isLoading && !isLoadingProjectScopes && servers.length === 0 && (
|
||||
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedProvider === 'codex' && (
|
||||
<div className="rounded-lg border border-border bg-muted/50 p-4">
|
||||
<h4 className="mb-2 font-medium text-foreground">{t('mcpServers.help.title')}</h4>
|
||||
<p className="text-sm text-muted-foreground">{t('mcpServers.help.description')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedProvider === 'claude' && !IS_PLATFORM && <TeamMcpFeatureCard />}
|
||||
|
||||
<McpServerFormModal
|
||||
provider={selectedProvider}
|
||||
isOpen={isFormOpen}
|
||||
editingServer={editingServer}
|
||||
currentProjects={currentProjects}
|
||||
title={editingServer ? undefined : providerButtonLabel}
|
||||
submitLabel={providerButtonLabel}
|
||||
onClose={closeForm}
|
||||
onSubmit={submitForm}
|
||||
/>
|
||||
|
||||
<McpServerFormModal
|
||||
provider={selectedProvider}
|
||||
mode="global"
|
||||
isOpen={isGlobalFormOpen}
|
||||
editingServer={null}
|
||||
currentProjects={currentProjects}
|
||||
title={globalButtonLabel}
|
||||
description={globalModalDescription}
|
||||
submitLabel={globalButtonLabel}
|
||||
supportedScopes={MCP_GLOBAL_SUPPORTED_SCOPES}
|
||||
supportedTransports={MCP_GLOBAL_SUPPORTED_TRANSPORTS}
|
||||
onClose={closeGlobalForm}
|
||||
onSubmit={(formData) => submitGlobalForm(formData)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
434
src/components/mcp/view/modals/McpServerFormModal.tsx
Normal file
434
src/components/mcp/view/modals/McpServerFormModal.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
import { FolderOpen, Globe, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, Input } from '../../../../shared/view/ui';
|
||||
import {
|
||||
MCP_PROVIDER_NAMES,
|
||||
MCP_SUPPORTED_SCOPES,
|
||||
MCP_SUPPORTED_TRANSPORTS,
|
||||
MCP_SUPPORTS_WORKING_DIRECTORY,
|
||||
} from '../../constants';
|
||||
import { useMcpServerForm } from '../../hooks/useMcpServerForm';
|
||||
import type {
|
||||
McpFormMode,
|
||||
McpFormState,
|
||||
McpProject,
|
||||
McpProvider,
|
||||
McpScope,
|
||||
McpTransport,
|
||||
ProviderMcpServer,
|
||||
} from '../../types';
|
||||
|
||||
type McpServerFormModalProps = {
|
||||
provider: McpProvider;
|
||||
mode?: McpFormMode;
|
||||
isOpen: boolean;
|
||||
editingServer: ProviderMcpServer | null;
|
||||
currentProjects: McpProject[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
submitLabel?: string;
|
||||
supportedScopes?: McpScope[];
|
||||
supportedTransports?: McpTransport[];
|
||||
onClose: () => void;
|
||||
onSubmit: (formData: McpFormState, editingServer: ProviderMcpServer | null) => Promise<void>;
|
||||
};
|
||||
|
||||
const getScopeLabel = (scope: McpScope, mode: McpFormMode): string => {
|
||||
if (scope === 'user') {
|
||||
return mode === 'global' ? 'User (All Providers)' : 'User (Global)';
|
||||
}
|
||||
|
||||
if (scope === 'local') {
|
||||
return 'Claude Local';
|
||||
}
|
||||
|
||||
return mode === 'global' ? 'Project (All Providers)' : 'Project';
|
||||
};
|
||||
|
||||
const getScopeDescription = (scope: McpScope, mode: McpFormMode): string => {
|
||||
if (scope === 'user') {
|
||||
return mode === 'global'
|
||||
? 'Writes to each provider user config and is available across projects on this machine'
|
||||
: 'Available across all projects on your machine';
|
||||
}
|
||||
|
||||
if (scope === 'local') {
|
||||
return 'Stored in Claude user settings for the selected project';
|
||||
}
|
||||
|
||||
return mode === 'global'
|
||||
? 'Writes to the selected project workspace for every provider'
|
||||
: 'Stored in the selected project workspace';
|
||||
};
|
||||
|
||||
export default function McpServerFormModal({
|
||||
provider,
|
||||
mode = 'provider',
|
||||
isOpen,
|
||||
editingServer,
|
||||
currentProjects,
|
||||
title,
|
||||
description,
|
||||
submitLabel,
|
||||
supportedScopes,
|
||||
supportedTransports,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: McpServerFormModalProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const isGlobalMode = mode === 'global';
|
||||
const availableScopes = supportedScopes ?? MCP_SUPPORTED_SCOPES[provider];
|
||||
const availableTransports = supportedTransports ?? MCP_SUPPORTED_TRANSPORTS[provider];
|
||||
const {
|
||||
formData,
|
||||
multilineText,
|
||||
projectOptions,
|
||||
isEditing,
|
||||
isSubmitting,
|
||||
jsonValidationError,
|
||||
canSubmit,
|
||||
updateForm,
|
||||
updateScope,
|
||||
updateTransport,
|
||||
updateJsonInput,
|
||||
updateMultilineText,
|
||||
handleSubmit,
|
||||
} = useMcpServerForm({
|
||||
provider,
|
||||
isOpen,
|
||||
editingServer,
|
||||
currentProjects,
|
||||
supportedScopes: availableScopes,
|
||||
supportedTransports: availableTransports,
|
||||
unsupportedTransportMessage: isGlobalMode
|
||||
? (transport) => `Add MCP Server supports only stdio and http across all providers, not ${transport}.`
|
||||
: undefined,
|
||||
onSubmit,
|
||||
});
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providerName = MCP_PROVIDER_NAMES[provider];
|
||||
const modalTitle = title ?? (isEditing ? t('mcpForm.title.edit') : t('mcpForm.title.add'));
|
||||
const addButtonLabel = submitLabel ?? `${t('mcpForm.actions.addServer')} to ${providerName}`;
|
||||
const showProjectSelector = formData.scope !== 'user';
|
||||
const supportsHttpHeaders = formData.transport === 'http' || formData.transport === 'sse';
|
||||
const supportsWorkingDirectory = !isGlobalMode && MCP_SUPPORTS_WORKING_DIRECTORY[provider];
|
||||
const showCodexOnlyFields = provider === 'codex' && !isGlobalMode;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-border bg-background">
|
||||
<div className="flex items-center justify-between border-b border-border p-4">
|
||||
<h3 className="text-lg font-medium text-foreground">{modalTitle}</h3>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 p-4">
|
||||
{description && (
|
||||
<div className="rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditing && (
|
||||
<div className="mb-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateForm('importMode', 'form')}
|
||||
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||
formData.importMode === 'form'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{t('mcpForm.importMode.form')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateForm('importMode', 'json')}
|
||||
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||
formData.importMode === 'json'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{t('mcpForm.importMode.json')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing && (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/50">
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.scope.label')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{formData.scope === 'user' ? <Globe className="h-4 w-4" /> : <FolderOpen className="h-4 w-4" />}
|
||||
<span className="text-sm">{getScopeLabel(formData.scope, mode)}</span>
|
||||
{formData.workspacePath && (
|
||||
<span className="truncate text-xs text-muted-foreground">- {formData.workspacePath}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t('mcpForm.scope.cannotChange')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditing && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.scope.label')} *
|
||||
</label>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{availableScopes.map((scope) => (
|
||||
<button
|
||||
key={scope}
|
||||
type="button"
|
||||
onClick={() => updateScope(scope)}
|
||||
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||
formData.scope === scope
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{scope === 'user' ? <Globe className="h-4 w-4" /> : <FolderOpen className="h-4 w-4" />}
|
||||
<span>{getScopeLabel(scope, mode)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{getScopeDescription(formData.scope, mode)}</p>
|
||||
</div>
|
||||
|
||||
{showProjectSelector && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.fields.selectProject')} *
|
||||
</label>
|
||||
<select
|
||||
value={formData.workspacePath}
|
||||
onChange={(event) => updateForm('workspacePath', event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
required
|
||||
>
|
||||
<option value="">{t('mcpForm.fields.selectProject')}</option>
|
||||
{projectOptions.map((project) => (
|
||||
<option key={project.value} value={project.value}>
|
||||
{project.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{formData.workspacePath && (
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{t('mcpForm.projectPath', { path: formData.workspacePath })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className={formData.importMode === 'json' ? 'md:col-span-2' : ''}>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.fields.serverName')} *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(event) => updateForm('name', event.target.value)}
|
||||
placeholder={t('mcpForm.placeholders.serverName')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.importMode === 'form' && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.fields.transportType')} *
|
||||
</label>
|
||||
<select
|
||||
value={formData.transport}
|
||||
onChange={(event) => updateTransport(event.target.value as McpFormState['transport'])}
|
||||
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
>
|
||||
{availableTransports.map((transport) => (
|
||||
<option key={transport} value={transport}>
|
||||
{transport === 'sse' ? 'SSE' : transport.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formData.importMode === 'json' && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.fields.jsonConfig')} *
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.jsonInput}
|
||||
onChange={(event) => updateJsonInput(event.target.value)}
|
||||
className={`w-full border px-3 py-2 ${
|
||||
jsonValidationError ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
} rounded-lg bg-gray-50 font-mono text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-800 dark:text-gray-100`}
|
||||
rows={8}
|
||||
placeholder={'{\n "type": "stdio",\n "command": "npx",\n "args": ["@upstash/context7-mcp"]\n}'}
|
||||
required
|
||||
/>
|
||||
{jsonValidationError && (
|
||||
<p className="mt-1 text-xs text-red-500">{jsonValidationError}</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t('mcpForm.validation.jsonHelp')}
|
||||
<br />
|
||||
- stdio: {`{"type":"stdio","command":"npx","args":["@upstash/context7-mcp"]}`}
|
||||
<br />
|
||||
- http/sse: {`{"type":"http","url":"https://api.example.com/mcp"}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.importMode === 'form' && formData.transport === 'stdio' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.fields.command')} *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.command}
|
||||
onChange={(event) => updateForm('command', event.target.value)}
|
||||
placeholder="npx @my-org/mcp-server"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.fields.arguments')}
|
||||
</label>
|
||||
<textarea
|
||||
value={multilineText.args}
|
||||
onChange={(event) => updateMultilineText('args', event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
rows={3}
|
||||
placeholder="--port 3000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{supportsWorkingDirectory && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
Working Directory
|
||||
</label>
|
||||
<Input
|
||||
value={formData.cwd}
|
||||
onChange={(event) => updateForm('cwd', event.target.value)}
|
||||
placeholder="."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.importMode === 'form' && formData.transport !== 'stdio' && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.fields.url')} *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.url}
|
||||
onChange={(event) => updateForm('url', event.target.value)}
|
||||
placeholder="https://api.example.com/mcp"
|
||||
type="url"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.importMode === 'form' && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.fields.envVars')}
|
||||
</label>
|
||||
<textarea
|
||||
value={multilineText.env}
|
||||
onChange={(event) => updateMultilineText('env', event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
rows={3}
|
||||
placeholder="API_KEY=your-key DEBUG=true"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.importMode === 'form' && supportsHttpHeaders && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.fields.headers')}
|
||||
</label>
|
||||
<textarea
|
||||
value={multilineText.headers}
|
||||
onChange={(event) => updateMultilineText('headers', event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
rows={3}
|
||||
placeholder="Authorization=Bearer token X-API-Key=your-key"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCodexOnlyFields && formData.importMode === 'form' && formData.transport === 'stdio' && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
Environment Variable Names
|
||||
</label>
|
||||
<textarea
|
||||
value={multilineText.envVars}
|
||||
onChange={(event) => updateMultilineText('envVars', event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
rows={3}
|
||||
placeholder="GITHUB_TOKEN API_KEY"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCodexOnlyFields && formData.importMode === 'form' && formData.transport === 'http' && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
Bearer Token Environment Variable
|
||||
</label>
|
||||
<Input
|
||||
value={formData.bearerTokenEnvVar}
|
||||
onChange={(event) => updateForm('bearerTokenEnvVar', event.target.value)}
|
||||
placeholder="MCP_TOKEN"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t('mcpForm.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !canSubmit}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting
|
||||
? t('mcpForm.actions.saving')
|
||||
: isEditing
|
||||
? t('mcpForm.actions.updateServer')
|
||||
: addButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import { useCallback, useState } from 'react';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import type { LLMProvider } from '../../../types/app';
|
||||
import {
|
||||
CLI_AUTH_STATUS_ENDPOINTS,
|
||||
CLI_PROVIDERS,
|
||||
PROVIDER_AUTH_STATUS_ENDPOINTS,
|
||||
createInitialProviderAuthStatusMap,
|
||||
} from '../types';
|
||||
import type {
|
||||
@@ -69,7 +69,7 @@ export function useProviderAuthStatus(
|
||||
setProviderLoading(provider);
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(CLI_AUTH_STATUS_ENDPOINTS[provider]);
|
||||
const response = await authenticatedFetch(PROVIDER_AUTH_STATUS_ENDPOINTS[provider]);
|
||||
|
||||
if (!response.ok) {
|
||||
setProviderStatus(provider, {
|
||||
|
||||
@@ -12,11 +12,11 @@ export type ProviderAuthStatusMap = Record<LLMProvider, ProviderAuthStatus>;
|
||||
|
||||
export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
|
||||
|
||||
export const CLI_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
|
||||
claude: '/api/cli/claude/status',
|
||||
cursor: '/api/cli/cursor/status',
|
||||
codex: '/api/cli/codex/status',
|
||||
gemini: '/api/cli/gemini/status',
|
||||
export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record<LLMProvider, string> = {
|
||||
claude: '/api/providers/claude/auth/status',
|
||||
cursor: '/api/providers/cursor/auth/status',
|
||||
codex: '/api/providers/codex/auth/status',
|
||||
gemini: '/api/providers/gemini/auth/status',
|
||||
};
|
||||
|
||||
export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuthStatusMap => ({
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import type {
|
||||
AgentCategory,
|
||||
AgentProvider,
|
||||
ClaudeMcpFormState,
|
||||
CodexMcpFormState,
|
||||
CodeEditorSettingsState,
|
||||
CursorPermissionsState,
|
||||
McpToolsResult,
|
||||
McpTestResult,
|
||||
ProjectSortOrder,
|
||||
SettingsMainTab,
|
||||
} from '../types/types';
|
||||
@@ -20,7 +16,7 @@ export const SETTINGS_MAIN_TABS: SettingsMainTab[] = [
|
||||
'notifications',
|
||||
];
|
||||
|
||||
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex'];
|
||||
export const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
|
||||
export const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];
|
||||
|
||||
export const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';
|
||||
@@ -33,47 +29,6 @@ export const DEFAULT_CODE_EDITOR_SETTINGS: CodeEditorSettingsState = {
|
||||
fontSize: '14',
|
||||
};
|
||||
|
||||
export const DEFAULT_MCP_TEST_RESULT: McpTestResult = {
|
||||
success: false,
|
||||
message: '',
|
||||
details: [],
|
||||
loading: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_MCP_TOOLS_RESULT: McpToolsResult = {
|
||||
success: false,
|
||||
tools: [],
|
||||
resources: [],
|
||||
prompts: [],
|
||||
};
|
||||
|
||||
export const DEFAULT_CLAUDE_MCP_FORM: ClaudeMcpFormState = {
|
||||
name: '',
|
||||
type: 'stdio',
|
||||
scope: 'user',
|
||||
projectPath: '',
|
||||
config: {
|
||||
command: '',
|
||||
args: [],
|
||||
env: {},
|
||||
url: '',
|
||||
headers: {},
|
||||
timeout: 30000,
|
||||
},
|
||||
importMode: 'form',
|
||||
jsonInput: '',
|
||||
};
|
||||
|
||||
export const DEFAULT_CODEX_MCP_FORM: CodexMcpFormState = {
|
||||
name: '',
|
||||
type: 'stdio',
|
||||
config: {
|
||||
command: '',
|
||||
args: [],
|
||||
env: {},
|
||||
},
|
||||
};
|
||||
|
||||
export const DEFAULT_CURSOR_PERMISSIONS: CursorPermissionsState = {
|
||||
allowedCommands: [],
|
||||
disallowedCommands: [],
|
||||
|
||||
@@ -8,16 +8,11 @@ import {
|
||||
} from '../constants/constants';
|
||||
import type {
|
||||
AgentProvider,
|
||||
ClaudeMcpFormState,
|
||||
ClaudePermissionsState,
|
||||
CodeEditorSettingsState,
|
||||
CodexMcpFormState,
|
||||
CodexPermissionMode,
|
||||
CursorPermissionsState,
|
||||
GeminiPermissionMode,
|
||||
McpServer,
|
||||
McpToolsResult,
|
||||
McpTestResult,
|
||||
NotificationPreferencesState,
|
||||
ProjectSortOrder,
|
||||
SettingsMainTab,
|
||||
@@ -33,41 +28,6 @@ type UseSettingsControllerArgs = {
|
||||
initialTab: string;
|
||||
};
|
||||
|
||||
type JsonResult = {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type McpReadResponse = {
|
||||
success?: boolean;
|
||||
servers?: McpServer[];
|
||||
};
|
||||
|
||||
type McpCliServer = {
|
||||
name: string;
|
||||
type?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
type McpCliReadResponse = {
|
||||
success?: boolean;
|
||||
servers?: McpCliServer[];
|
||||
};
|
||||
|
||||
type McpTestResponse = {
|
||||
testResult?: McpTestResult;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type McpToolsResponse = {
|
||||
toolsResult?: McpToolsResult;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type ClaudeSettingsStorage = {
|
||||
allowedTools?: string[];
|
||||
disallowedTools?: string[];
|
||||
@@ -103,10 +63,6 @@ const normalizeMainTab = (tab: string): SettingsMainTab => {
|
||||
return KNOWN_MAIN_TABS.includes(tab as SettingsMainTab) ? (tab as SettingsMainTab) : 'agents';
|
||||
};
|
||||
|
||||
const getErrorMessage = (error: unknown): string => (
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
);
|
||||
|
||||
const parseJson = <T>(value: string | null, fallback: T): T => {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
@@ -135,25 +91,6 @@ const readCodeEditorSettings = (): CodeEditorSettingsState => ({
|
||||
fontSize: localStorage.getItem('codeEditorFontSize') ?? DEFAULT_CODE_EDITOR_SETTINGS.fontSize,
|
||||
});
|
||||
|
||||
const mapCliServersToMcpServers = (servers: McpCliServer[] = []): McpServer[] => (
|
||||
servers.map((server) => ({
|
||||
id: server.name,
|
||||
name: server.name,
|
||||
type: server.type || 'stdio',
|
||||
scope: 'user',
|
||||
config: {
|
||||
command: server.command || '',
|
||||
args: server.args || [],
|
||||
env: server.env || {},
|
||||
url: server.url || '',
|
||||
headers: server.headers || {},
|
||||
timeout: 30000,
|
||||
},
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
}))
|
||||
);
|
||||
|
||||
const toResponseJson = async <T>(response: Response): Promise<T> => response.json() as Promise<T>;
|
||||
|
||||
const createEmptyClaudePermissions = (): ClaudePermissionsState => ({
|
||||
@@ -184,7 +121,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
||||
|
||||
const [activeTab, setActiveTab] = useState<SettingsMainTab>(() => normalizeMainTab(initialTab));
|
||||
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [projectSortOrder, setProjectSortOrder] = useState<ProjectSortOrder>('name');
|
||||
const [codeEditorSettings, setCodeEditorSettings] = useState<CodeEditorSettingsState>(() => (
|
||||
readCodeEditorSettings()
|
||||
@@ -202,18 +138,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
||||
const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');
|
||||
const [geminiPermissionMode, setGeminiPermissionMode] = useState<GeminiPermissionMode>('default');
|
||||
|
||||
const [mcpServers, setMcpServers] = useState<McpServer[]>([]);
|
||||
const [cursorMcpServers, setCursorMcpServers] = useState<McpServer[]>([]);
|
||||
const [codexMcpServers, setCodexMcpServers] = useState<McpServer[]>([]);
|
||||
const [mcpTestResults, setMcpTestResults] = useState<Record<string, McpTestResult>>({});
|
||||
const [mcpServerTools, setMcpServerTools] = useState<Record<string, McpToolsResult>>({});
|
||||
const [mcpToolsLoading, setMcpToolsLoading] = useState<Record<string, boolean>>({});
|
||||
|
||||
const [showMcpForm, setShowMcpForm] = useState(false);
|
||||
const [editingMcpServer, setEditingMcpServer] = useState<McpServer | null>(null);
|
||||
const [showCodexMcpForm, setShowCodexMcpForm] = useState(false);
|
||||
const [editingCodexMcpServer, setEditingCodexMcpServer] = useState<McpServer | null>(null);
|
||||
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
const [loginProvider, setLoginProvider] = useState<ActiveLoginProvider>('');
|
||||
const {
|
||||
@@ -222,361 +146,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
||||
refreshProviderAuthStatuses,
|
||||
} = useProviderAuthStatus();
|
||||
|
||||
const fetchCursorMcpServers = useCallback(async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/cursor/mcp');
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch Cursor MCP servers');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await toResponseJson<{ servers?: McpServer[] }>(response);
|
||||
setCursorMcpServers(data.servers || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching Cursor MCP servers:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchCodexMcpServers = useCallback(async () => {
|
||||
try {
|
||||
const configResponse = await authenticatedFetch('/api/codex/mcp/config/read');
|
||||
|
||||
if (configResponse.ok) {
|
||||
const configData = await toResponseJson<McpReadResponse>(configResponse);
|
||||
if (configData.success && configData.servers) {
|
||||
setCodexMcpServers(configData.servers);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const cliResponse = await authenticatedFetch('/api/codex/mcp/cli/list');
|
||||
if (!cliResponse.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cliData = await toResponseJson<McpCliReadResponse>(cliResponse);
|
||||
if (!cliData.success || !cliData.servers) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCodexMcpServers(mapCliServersToMcpServers(cliData.servers));
|
||||
} catch (error) {
|
||||
console.error('Error fetching Codex MCP servers:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchMcpServers = useCallback(async () => {
|
||||
try {
|
||||
const configResponse = await authenticatedFetch('/api/mcp/config/read');
|
||||
if (configResponse.ok) {
|
||||
const configData = await toResponseJson<McpReadResponse>(configResponse);
|
||||
if (configData.success && configData.servers) {
|
||||
setMcpServers(configData.servers);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const cliResponse = await authenticatedFetch('/api/mcp/cli/list');
|
||||
if (cliResponse.ok) {
|
||||
const cliData = await toResponseJson<McpCliReadResponse>(cliResponse);
|
||||
if (cliData.success && cliData.servers) {
|
||||
setMcpServers(mapCliServersToMcpServers(cliData.servers));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackResponse = await authenticatedFetch('/api/mcp/servers?scope=user');
|
||||
if (!fallbackResponse.ok) {
|
||||
console.error('Failed to fetch MCP servers');
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackData = await toResponseJson<{ servers?: McpServer[] }>(fallbackResponse);
|
||||
setMcpServers(fallbackData.servers || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching MCP servers:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteMcpServer = useCallback(async (serverId: string, scope = 'user') => {
|
||||
const response = await authenticatedFetch(`/api/mcp/cli/remove/${serverId}?scope=${scope}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await toResponseJson<JsonResult>(response);
|
||||
throw new Error(error.error || 'Failed to delete server');
|
||||
}
|
||||
|
||||
const result = await toResponseJson<JsonResult>(response);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to delete server via Claude CLI');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const saveMcpServer = useCallback(
|
||||
async (serverData: ClaudeMcpFormState, editingServer: McpServer | null) => {
|
||||
const newServerScope = serverData.scope || 'user';
|
||||
|
||||
const response = await authenticatedFetch('/api/mcp/cli/add', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: serverData.name,
|
||||
type: serverData.type,
|
||||
scope: newServerScope,
|
||||
projectPath: serverData.projectPath,
|
||||
command: serverData.config.command,
|
||||
args: serverData.config.args || [],
|
||||
url: serverData.config.url,
|
||||
headers: serverData.config.headers || {},
|
||||
env: serverData.config.env || {},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await toResponseJson<JsonResult>(response);
|
||||
throw new Error(error.error || 'Failed to save server');
|
||||
}
|
||||
|
||||
const result = await toResponseJson<JsonResult>(response);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to save server via Claude CLI');
|
||||
}
|
||||
|
||||
if (!editingServer?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousServerScope = editingServer.scope || 'user';
|
||||
const didServerIdentityChange =
|
||||
editingServer.id !== serverData.name || previousServerScope !== newServerScope;
|
||||
|
||||
if (!didServerIdentityChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteMcpServer(editingServer.id, previousServerScope);
|
||||
} catch (error) {
|
||||
console.warn('Saved MCP server update but failed to remove the previous server entry.', {
|
||||
previousServerId: editingServer.id,
|
||||
previousServerScope,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
[deleteMcpServer],
|
||||
);
|
||||
|
||||
const submitMcpForm = useCallback(
|
||||
async (formData: ClaudeMcpFormState, editingServer: McpServer | null) => {
|
||||
if (formData.importMode === 'json') {
|
||||
const response = await authenticatedFetch('/api/mcp/cli/add-json', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: formData.name,
|
||||
jsonConfig: formData.jsonInput,
|
||||
scope: formData.scope,
|
||||
projectPath: formData.projectPath,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await toResponseJson<JsonResult>(response);
|
||||
throw new Error(error.error || 'Failed to add server');
|
||||
}
|
||||
|
||||
const result = await toResponseJson<JsonResult>(response);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to add server via JSON');
|
||||
}
|
||||
} else {
|
||||
await saveMcpServer(formData, editingServer);
|
||||
}
|
||||
|
||||
await fetchMcpServers();
|
||||
setSaveStatus('success');
|
||||
setShowMcpForm(false);
|
||||
setEditingMcpServer(null);
|
||||
},
|
||||
[fetchMcpServers, saveMcpServer],
|
||||
);
|
||||
|
||||
const handleMcpDelete = useCallback(
|
||||
async (serverId: string, scope = 'user') => {
|
||||
if (!window.confirm('Are you sure you want to delete this MCP server?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await deleteMcpServer(serverId, scope);
|
||||
await fetchMcpServers();
|
||||
setDeleteError(null);
|
||||
setSaveStatus('success');
|
||||
} catch (error) {
|
||||
setDeleteError(getErrorMessage(error));
|
||||
setSaveStatus('error');
|
||||
}
|
||||
},
|
||||
[deleteMcpServer, fetchMcpServers],
|
||||
);
|
||||
|
||||
const testMcpServer = useCallback(async (serverId: string, scope = 'user') => {
|
||||
const response = await authenticatedFetch(`/api/mcp/servers/${serverId}/test?scope=${scope}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await toResponseJson<McpTestResponse>(response);
|
||||
throw new Error(error.error || 'Failed to test server');
|
||||
}
|
||||
|
||||
const data = await toResponseJson<McpTestResponse>(response);
|
||||
return data.testResult || { success: false, message: 'No test result returned' };
|
||||
}, []);
|
||||
|
||||
const discoverMcpTools = useCallback(async (serverId: string, scope = 'user') => {
|
||||
const response = await authenticatedFetch(`/api/mcp/servers/${serverId}/tools?scope=${scope}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await toResponseJson<McpToolsResponse>(response);
|
||||
throw new Error(error.error || 'Failed to discover tools');
|
||||
}
|
||||
|
||||
const data = await toResponseJson<McpToolsResponse>(response);
|
||||
return data.toolsResult || { success: false, tools: [], resources: [], prompts: [] };
|
||||
}, []);
|
||||
|
||||
const handleMcpTest = useCallback(
|
||||
async (serverId: string, scope = 'user') => {
|
||||
try {
|
||||
setMcpTestResults((prev) => ({
|
||||
...prev,
|
||||
[serverId]: { success: false, message: 'Testing server...', details: [], loading: true },
|
||||
}));
|
||||
|
||||
const result = await testMcpServer(serverId, scope);
|
||||
setMcpTestResults((prev) => ({ ...prev, [serverId]: result }));
|
||||
} catch (error) {
|
||||
setMcpTestResults((prev) => ({
|
||||
...prev,
|
||||
[serverId]: {
|
||||
success: false,
|
||||
message: getErrorMessage(error),
|
||||
details: [],
|
||||
},
|
||||
}));
|
||||
}
|
||||
},
|
||||
[testMcpServer],
|
||||
);
|
||||
|
||||
const handleMcpToolsDiscovery = useCallback(
|
||||
async (serverId: string, scope = 'user') => {
|
||||
try {
|
||||
setMcpToolsLoading((prev) => ({ ...prev, [serverId]: true }));
|
||||
const result = await discoverMcpTools(serverId, scope);
|
||||
setMcpServerTools((prev) => ({ ...prev, [serverId]: result }));
|
||||
} catch {
|
||||
setMcpServerTools((prev) => ({
|
||||
...prev,
|
||||
[serverId]: { success: false, tools: [], resources: [], prompts: [] },
|
||||
}));
|
||||
} finally {
|
||||
setMcpToolsLoading((prev) => ({ ...prev, [serverId]: false }));
|
||||
}
|
||||
},
|
||||
[discoverMcpTools],
|
||||
);
|
||||
|
||||
const deleteCodexMcpServer = useCallback(async (serverId: string) => {
|
||||
const response = await authenticatedFetch(`/api/codex/mcp/cli/remove/${serverId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await toResponseJson<JsonResult>(response);
|
||||
throw new Error(error.error || 'Failed to delete server');
|
||||
}
|
||||
|
||||
const result = await toResponseJson<JsonResult>(response);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to delete Codex MCP server');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const saveCodexMcpServer = useCallback(
|
||||
async (serverData: CodexMcpFormState, editingServer: McpServer | null) => {
|
||||
const response = await authenticatedFetch('/api/codex/mcp/cli/add', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: serverData.name,
|
||||
command: serverData.config.command,
|
||||
args: serverData.config.args || [],
|
||||
env: serverData.config.env || {},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await toResponseJson<JsonResult>(response);
|
||||
throw new Error(error.error || 'Failed to save server');
|
||||
}
|
||||
|
||||
const result = await toResponseJson<JsonResult>(response);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to save Codex MCP server');
|
||||
}
|
||||
|
||||
if (!editingServer?.name || editingServer.name === serverData.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteCodexMcpServer(editingServer.name);
|
||||
} catch (error) {
|
||||
console.warn('Saved Codex MCP server update but failed to remove the previous server entry.', {
|
||||
previousServerName: editingServer.name,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
[deleteCodexMcpServer],
|
||||
);
|
||||
|
||||
const submitCodexMcpForm = useCallback(
|
||||
async (formData: CodexMcpFormState, editingServer: McpServer | null) => {
|
||||
await saveCodexMcpServer(formData, editingServer);
|
||||
await fetchCodexMcpServers();
|
||||
setSaveStatus('success');
|
||||
setShowCodexMcpForm(false);
|
||||
setEditingCodexMcpServer(null);
|
||||
},
|
||||
[fetchCodexMcpServers, saveCodexMcpServer],
|
||||
);
|
||||
|
||||
const handleCodexMcpDelete = useCallback(
|
||||
async (serverName: string) => {
|
||||
if (!window.confirm('Are you sure you want to delete this MCP server?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await deleteCodexMcpServer(serverName);
|
||||
await fetchCodexMcpServers();
|
||||
setDeleteError(null);
|
||||
setSaveStatus('success');
|
||||
} catch (error) {
|
||||
setDeleteError(getErrorMessage(error));
|
||||
setSaveStatus('error');
|
||||
}
|
||||
},
|
||||
[deleteCodexMcpServer, fetchCodexMcpServers],
|
||||
);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
try {
|
||||
const savedClaudeSettings = parseJson<ClaudeSettingsStorage>(
|
||||
@@ -628,11 +197,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
||||
setNotificationPreferences(createDefaultNotificationPreferences());
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
fetchMcpServers(),
|
||||
fetchCursorMcpServers(),
|
||||
fetchCodexMcpServers(),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
setClaudePermissions(createEmptyClaudePermissions());
|
||||
@@ -641,7 +205,7 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
||||
setCodexPermissionMode('default');
|
||||
setProjectSortOrder('name');
|
||||
}
|
||||
}, [fetchCodexMcpServers, fetchCursorMcpServers, fetchMcpServers]);
|
||||
}, []);
|
||||
|
||||
const openLoginForProvider = useCallback((provider: AgentProvider) => {
|
||||
setLoginProvider(provider);
|
||||
@@ -720,26 +284,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
||||
[],
|
||||
);
|
||||
|
||||
const openMcpForm = useCallback((server?: McpServer) => {
|
||||
setEditingMcpServer(server || null);
|
||||
setShowMcpForm(true);
|
||||
}, []);
|
||||
|
||||
const closeMcpForm = useCallback(() => {
|
||||
setShowMcpForm(false);
|
||||
setEditingMcpServer(null);
|
||||
}, []);
|
||||
|
||||
const openCodexMcpForm = useCallback((server?: McpServer) => {
|
||||
setEditingCodexMcpServer(server || null);
|
||||
setShowCodexMcpForm(true);
|
||||
}, []);
|
||||
|
||||
const closeCodexMcpForm = useCallback(() => {
|
||||
setShowCodexMcpForm(false);
|
||||
setEditingCodexMcpServer(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
@@ -819,7 +363,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
||||
isDarkMode,
|
||||
toggleDarkMode,
|
||||
saveStatus,
|
||||
deleteError,
|
||||
projectSortOrder,
|
||||
setProjectSortOrder,
|
||||
codeEditorSettings,
|
||||
@@ -832,26 +375,6 @@ export function useSettingsController({ isOpen, initialTab }: UseSettingsControl
|
||||
setNotificationPreferences,
|
||||
codexPermissionMode,
|
||||
setCodexPermissionMode,
|
||||
mcpServers,
|
||||
cursorMcpServers,
|
||||
codexMcpServers,
|
||||
mcpTestResults,
|
||||
mcpServerTools,
|
||||
mcpToolsLoading,
|
||||
showMcpForm,
|
||||
editingMcpServer,
|
||||
openMcpForm,
|
||||
closeMcpForm,
|
||||
submitMcpForm,
|
||||
handleMcpDelete,
|
||||
handleMcpTest,
|
||||
handleMcpToolsDiscovery,
|
||||
showCodexMcpForm,
|
||||
editingCodexMcpServer,
|
||||
openCodexMcpForm,
|
||||
closeCodexMcpForm,
|
||||
submitCodexMcpForm,
|
||||
handleCodexMcpDelete,
|
||||
providerAuthStatus,
|
||||
geminiPermissionMode,
|
||||
setGeminiPermissionMode,
|
||||
|
||||
@@ -9,9 +9,6 @@ export type ProjectSortOrder = 'name' | 'date';
|
||||
export type SaveStatus = 'success' | 'error' | null;
|
||||
export type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';
|
||||
export type GeminiPermissionMode = 'default' | 'auto_edit' | 'yolo';
|
||||
export type McpImportMode = 'form' | 'json';
|
||||
export type McpScope = 'user' | 'local';
|
||||
export type McpTransportType = 'stdio' | 'sse' | 'http';
|
||||
|
||||
export type SettingsProject = {
|
||||
name: string;
|
||||
@@ -22,80 +19,6 @@ export type SettingsProject = {
|
||||
|
||||
export type AuthStatus = ProviderAuthStatus;
|
||||
|
||||
export type KeyValueMap = Record<string, string>;
|
||||
|
||||
export type McpServerConfig = {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: KeyValueMap;
|
||||
url?: string;
|
||||
headers?: KeyValueMap;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export type McpServer = {
|
||||
id?: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
scope?: string;
|
||||
projectPath?: string;
|
||||
config?: McpServerConfig;
|
||||
raw?: unknown;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
};
|
||||
|
||||
export type ClaudeMcpFormConfig = {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: KeyValueMap;
|
||||
url: string;
|
||||
headers: KeyValueMap;
|
||||
timeout: number;
|
||||
};
|
||||
|
||||
export type ClaudeMcpFormState = {
|
||||
name: string;
|
||||
type: McpTransportType;
|
||||
scope: McpScope;
|
||||
projectPath: string;
|
||||
config: ClaudeMcpFormConfig;
|
||||
importMode: McpImportMode;
|
||||
jsonInput: string;
|
||||
raw?: unknown;
|
||||
};
|
||||
|
||||
export type CodexMcpFormConfig = {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: KeyValueMap;
|
||||
};
|
||||
|
||||
export type CodexMcpFormState = {
|
||||
name: string;
|
||||
type: 'stdio';
|
||||
config: CodexMcpFormConfig;
|
||||
};
|
||||
|
||||
export type McpTestResult = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
details?: string[];
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export type McpTool = {
|
||||
name: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type McpToolsResult = {
|
||||
success?: boolean;
|
||||
tools?: McpTool[];
|
||||
resources?: unknown[];
|
||||
prompts?: unknown[];
|
||||
};
|
||||
|
||||
export type ClaudePermissionsState = {
|
||||
allowedTools: string[];
|
||||
disallowedTools: string[];
|
||||
|
||||
@@ -2,8 +2,6 @@ import { X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ProviderLoginModal from '../../provider-auth/view/ProviderLoginModal';
|
||||
import { Button } from '../../../shared/view/ui';
|
||||
import ClaudeMcpFormModal from '../view/modals/ClaudeMcpFormModal';
|
||||
import CodexMcpFormModal from '../view/modals/CodexMcpFormModal';
|
||||
import SettingsSidebar from '../view/SettingsSidebar';
|
||||
import AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';
|
||||
import AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';
|
||||
@@ -23,7 +21,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
saveStatus,
|
||||
deleteError,
|
||||
projectSortOrder,
|
||||
setProjectSortOrder,
|
||||
codeEditorSettings,
|
||||
@@ -36,26 +33,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
||||
setCursorPermissions,
|
||||
codexPermissionMode,
|
||||
setCodexPermissionMode,
|
||||
mcpServers,
|
||||
cursorMcpServers,
|
||||
codexMcpServers,
|
||||
mcpTestResults,
|
||||
mcpServerTools,
|
||||
mcpToolsLoading,
|
||||
showMcpForm,
|
||||
editingMcpServer,
|
||||
openMcpForm,
|
||||
closeMcpForm,
|
||||
submitMcpForm,
|
||||
handleMcpDelete,
|
||||
handleMcpTest,
|
||||
handleMcpToolsDiscovery,
|
||||
showCodexMcpForm,
|
||||
editingCodexMcpServer,
|
||||
openCodexMcpForm,
|
||||
closeCodexMcpForm,
|
||||
submitCodexMcpForm,
|
||||
handleCodexMcpDelete,
|
||||
providerAuthStatus,
|
||||
geminiPermissionMode,
|
||||
setGeminiPermissionMode,
|
||||
@@ -156,19 +133,7 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
||||
onCodexPermissionModeChange={setCodexPermissionMode}
|
||||
geminiPermissionMode={geminiPermissionMode}
|
||||
onGeminiPermissionModeChange={setGeminiPermissionMode}
|
||||
mcpServers={mcpServers}
|
||||
cursorMcpServers={cursorMcpServers}
|
||||
codexMcpServers={codexMcpServers}
|
||||
mcpTestResults={mcpTestResults}
|
||||
mcpServerTools={mcpServerTools}
|
||||
mcpToolsLoading={mcpToolsLoading}
|
||||
onOpenMcpForm={openMcpForm}
|
||||
onDeleteMcpServer={handleMcpDelete}
|
||||
onTestMcpServer={handleMcpTest}
|
||||
onDiscoverMcpTools={handleMcpToolsDiscovery}
|
||||
onOpenCodexMcpForm={openCodexMcpForm}
|
||||
onDeleteCodexMcpServer={handleCodexMcpDelete}
|
||||
deleteError={deleteError}
|
||||
projects={projects}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -205,20 +170,6 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
|
||||
isAuthenticated={isAuthenticated}
|
||||
/>
|
||||
|
||||
<ClaudeMcpFormModal
|
||||
isOpen={showMcpForm}
|
||||
editingServer={editingMcpServer}
|
||||
projects={projects}
|
||||
onClose={closeMcpForm}
|
||||
onSubmit={submitMcpForm}
|
||||
/>
|
||||
|
||||
<CodexMcpFormModal
|
||||
isOpen={showCodexMcpForm}
|
||||
editingServer={editingCodexMcpServer}
|
||||
onClose={closeCodexMcpForm}
|
||||
onSubmit={submitCodexMcpForm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,478 +0,0 @@
|
||||
import { FolderOpen, Globe, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input } from '../../../../shared/view/ui';
|
||||
import { DEFAULT_CLAUDE_MCP_FORM } from '../../constants/constants';
|
||||
import type { ClaudeMcpFormState, McpServer, McpScope, McpTransportType, SettingsProject } from '../../types/types';
|
||||
|
||||
type ClaudeMcpFormModalProps = {
|
||||
isOpen: boolean;
|
||||
editingServer: McpServer | null;
|
||||
projects: SettingsProject[];
|
||||
onClose: () => void;
|
||||
onSubmit: (formData: ClaudeMcpFormState, editingServer: McpServer | null) => Promise<void>;
|
||||
};
|
||||
|
||||
const getSafeTransportType = (value: unknown): McpTransportType => {
|
||||
if (value === 'sse' || value === 'http') {
|
||||
return value;
|
||||
}
|
||||
|
||||
return 'stdio';
|
||||
};
|
||||
|
||||
const getSafeScope = (value: unknown): McpScope => (value === 'local' ? 'local' : 'user');
|
||||
|
||||
const getErrorMessage = (error: unknown): string => (
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
);
|
||||
|
||||
const createFormStateFromServer = (server: McpServer): ClaudeMcpFormState => ({
|
||||
name: server.name || '',
|
||||
type: getSafeTransportType(server.type),
|
||||
scope: getSafeScope(server.scope),
|
||||
projectPath: server.projectPath || '',
|
||||
config: {
|
||||
command: server.config?.command || '',
|
||||
args: server.config?.args || [],
|
||||
env: server.config?.env || {},
|
||||
url: server.config?.url || '',
|
||||
headers: server.config?.headers || {},
|
||||
timeout: server.config?.timeout || 30000,
|
||||
},
|
||||
importMode: 'form',
|
||||
jsonInput: '',
|
||||
raw: server.raw,
|
||||
});
|
||||
|
||||
export default function ClaudeMcpFormModal({
|
||||
isOpen,
|
||||
editingServer,
|
||||
projects,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: ClaudeMcpFormModalProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const [formData, setFormData] = useState<ClaudeMcpFormState>(DEFAULT_CLAUDE_MCP_FORM);
|
||||
const [jsonValidationError, setJsonValidationError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const isEditing = Boolean(editingServer);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
setJsonValidationError('');
|
||||
if (editingServer) {
|
||||
setFormData(createFormStateFromServer(editingServer));
|
||||
return;
|
||||
}
|
||||
|
||||
setFormData(DEFAULT_CLAUDE_MCP_FORM);
|
||||
}, [editingServer, isOpen]);
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (!formData.name.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (formData.importMode === 'json') {
|
||||
return Boolean(formData.jsonInput.trim()) && !jsonValidationError;
|
||||
}
|
||||
|
||||
if (formData.scope === 'local' && !formData.projectPath.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (formData.type === 'stdio') {
|
||||
return Boolean(formData.config.command.trim());
|
||||
}
|
||||
|
||||
return Boolean(formData.config.url.trim());
|
||||
}, [formData, jsonValidationError]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updateConfig = <K extends keyof ClaudeMcpFormState['config']>(
|
||||
key: K,
|
||||
value: ClaudeMcpFormState['config'][K],
|
||||
) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
config: {
|
||||
...prev.config,
|
||||
[key]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleJsonValidation = (value: string) => {
|
||||
if (!value.trim()) {
|
||||
setJsonValidationError('');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as { type?: string; command?: string; url?: string };
|
||||
if (!parsed.type) {
|
||||
setJsonValidationError(t('mcpForm.validation.missingType'));
|
||||
} else if (parsed.type === 'stdio' && !parsed.command) {
|
||||
setJsonValidationError(t('mcpForm.validation.stdioRequiresCommand'));
|
||||
} else if ((parsed.type === 'http' || parsed.type === 'sse') && !parsed.url) {
|
||||
setJsonValidationError(t('mcpForm.validation.httpRequiresUrl', { type: parsed.type }));
|
||||
} else {
|
||||
setJsonValidationError('');
|
||||
}
|
||||
} catch {
|
||||
setJsonValidationError(t('mcpForm.validation.invalidJson'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await onSubmit(formData, editingServer);
|
||||
} catch (error) {
|
||||
alert(`Error: ${getErrorMessage(error)}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-border bg-background">
|
||||
<div className="flex items-center justify-between border-b border-border p-4">
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
{isEditing ? t('mcpForm.title.edit') : t('mcpForm.title.add')}
|
||||
</h3>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 p-4">
|
||||
{!isEditing && (
|
||||
<div className="mb-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData((prev) => ({ ...prev, importMode: 'form' }))}
|
||||
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||
formData.importMode === 'form'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{t('mcpForm.importMode.form')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData((prev) => ({ ...prev, importMode: 'json' }))}
|
||||
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||
formData.importMode === 'json'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{t('mcpForm.importMode.json')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.importMode === 'form' && isEditing && (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/50">
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.scope.label')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{formData.scope === 'user' ? <Globe className="h-4 w-4" /> : <FolderOpen className="h-4 w-4" />}
|
||||
<span className="text-sm">
|
||||
{formData.scope === 'user' ? t('mcpForm.scope.userGlobal') : t('mcpForm.scope.projectLocal')}
|
||||
</span>
|
||||
{formData.scope === 'local' && formData.projectPath && (
|
||||
<span className="text-xs text-muted-foreground">- {formData.projectPath}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{t('mcpForm.scope.cannotChange')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.importMode === 'form' && !isEditing && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.scope.label')} *
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData((prev) => ({ ...prev, scope: 'user', projectPath: '' }))}
|
||||
className={`flex-1 rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||
formData.scope === 'user'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
<span>{t('mcpForm.scope.userGlobal')}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData((prev) => ({ ...prev, scope: 'local' }))}
|
||||
className={`flex-1 rounded-lg px-4 py-2 font-medium transition-colors ${
|
||||
formData.scope === 'local'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span>{t('mcpForm.scope.projectLocal')}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{formData.scope === 'user'
|
||||
? t('mcpForm.scope.userDescription')
|
||||
: t('mcpForm.scope.projectDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{formData.scope === 'local' && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.fields.selectProject')} *
|
||||
</label>
|
||||
<select
|
||||
value={formData.projectPath}
|
||||
onChange={(event) => {
|
||||
setFormData((prev) => ({ ...prev, projectPath: event.target.value }));
|
||||
}}
|
||||
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
required
|
||||
>
|
||||
<option value="">{t('mcpForm.fields.selectProject')}...</option>
|
||||
{projects.map((project) => (
|
||||
<option key={project.name} value={project.path || project.fullPath}>
|
||||
{project.displayName || project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{formData.projectPath && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t('mcpForm.projectPath', { path: formData.projectPath })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className={formData.importMode === 'json' ? 'md:col-span-2' : ''}>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.fields.serverName')} *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, name: event.target.value }))}
|
||||
placeholder={t('mcpForm.placeholders.serverName')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.importMode === 'form' && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.fields.transportType')} *
|
||||
</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(event) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
type: getSafeTransportType(event.target.value),
|
||||
}));
|
||||
}}
|
||||
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
>
|
||||
<option value="stdio">stdio</option>
|
||||
<option value="sse">SSE</option>
|
||||
<option value="http">HTTP</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing && Boolean(formData.raw) && formData.importMode === 'form' && (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50">
|
||||
<h4 className="mb-2 text-sm font-medium text-foreground">
|
||||
{t('mcpForm.configDetails', {
|
||||
configFile: editingServer?.scope === 'global' ? '~/.claude.json' : 'project config',
|
||||
})}
|
||||
</h4>
|
||||
<pre className="overflow-x-auto rounded bg-gray-100 p-3 text-xs dark:bg-gray-800">
|
||||
{JSON.stringify(formData.raw, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.importMode === 'json' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.fields.jsonConfig')} *
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.jsonInput}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setFormData((prev) => ({ ...prev, jsonInput: value }));
|
||||
handleJsonValidation(value);
|
||||
}}
|
||||
className={`w-full border px-3 py-2 ${
|
||||
jsonValidationError ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
} rounded-lg bg-gray-50 font-mono text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-800 dark:text-gray-100`}
|
||||
rows={8}
|
||||
placeholder={'{\n "type": "stdio",\n "command": "/path/to/server",\n "args": ["--api-key", "abc123"],\n "env": {\n "CACHE_DIR": "/tmp"\n }\n}'}
|
||||
required
|
||||
/>
|
||||
{jsonValidationError && (
|
||||
<p className="mt-1 text-xs text-red-500">{jsonValidationError}</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t('mcpForm.validation.jsonHelp')}
|
||||
<br />
|
||||
- stdio: {`{"type":"stdio","command":"npx","args":["@upstash/context7-mcp"]}`}
|
||||
<br />
|
||||
- http/sse: {`{"type":"http","url":"https://api.example.com/mcp"}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.importMode === 'form' && formData.type === 'stdio' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.fields.command')} *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.config.command}
|
||||
onChange={(event) => updateConfig('command', event.target.value)}
|
||||
placeholder="/path/to/mcp-server"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.fields.arguments')}
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.config.args.join('\n')}
|
||||
onChange={(event) => {
|
||||
const args = event.target.value.split('\n').filter((arg) => arg.trim());
|
||||
updateConfig('args', args);
|
||||
}}
|
||||
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
rows={3}
|
||||
placeholder="--api-key abc123"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.importMode === 'form' && (formData.type === 'sse' || formData.type === 'http') && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.fields.url')} *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.config.url}
|
||||
onChange={(event) => updateConfig('url', event.target.value)}
|
||||
placeholder="https://api.example.com/mcp"
|
||||
type="url"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.importMode === 'form' && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.fields.envVars')}
|
||||
</label>
|
||||
<textarea
|
||||
value={Object.entries(formData.config.env).map(([key, value]) => `${key}=${value}`).join('\n')}
|
||||
onChange={(event) => {
|
||||
const env: Record<string, string> = {};
|
||||
event.target.value.split('\n').forEach((line) => {
|
||||
const [key, ...valueParts] = line.split('=');
|
||||
if (key && key.trim()) {
|
||||
env[key.trim()] = valueParts.join('=').trim();
|
||||
}
|
||||
});
|
||||
updateConfig('env', env);
|
||||
}}
|
||||
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
rows={3}
|
||||
placeholder="API_KEY=your-key DEBUG=true"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.importMode === 'form' && (formData.type === 'sse' || formData.type === 'http') && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.fields.headers')}
|
||||
</label>
|
||||
<textarea
|
||||
value={Object.entries(formData.config.headers).map(([key, value]) => `${key}=${value}`).join('\n')}
|
||||
onChange={(event) => {
|
||||
const headers: Record<string, string> = {};
|
||||
event.target.value.split('\n').forEach((line) => {
|
||||
const [key, ...valueParts] = line.split('=');
|
||||
if (key && key.trim()) {
|
||||
headers[key.trim()] = valueParts.join('=').trim();
|
||||
}
|
||||
});
|
||||
updateConfig('headers', headers);
|
||||
}}
|
||||
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
rows={3}
|
||||
placeholder="Authorization=Bearer token X-API-Key=your-key"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t('mcpForm.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !canSubmit}
|
||||
className="bg-purple-600 hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting
|
||||
? t('mcpForm.actions.saving')
|
||||
: isEditing
|
||||
? t('mcpForm.actions.updateServer')
|
||||
: t('mcpForm.actions.addServer')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input } from '../../../../shared/view/ui';
|
||||
import { DEFAULT_CODEX_MCP_FORM } from '../../constants/constants';
|
||||
import type { CodexMcpFormState, McpServer } from '../../types/types';
|
||||
|
||||
type CodexMcpFormModalProps = {
|
||||
isOpen: boolean;
|
||||
editingServer: McpServer | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (formData: CodexMcpFormState, editingServer: McpServer | null) => Promise<void>;
|
||||
};
|
||||
|
||||
const getErrorMessage = (error: unknown): string => (
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
);
|
||||
|
||||
const createFormStateFromServer = (server: McpServer): CodexMcpFormState => ({
|
||||
name: server.name || '',
|
||||
type: 'stdio',
|
||||
config: {
|
||||
command: server.config?.command || '',
|
||||
args: server.config?.args || [],
|
||||
env: server.config?.env || {},
|
||||
},
|
||||
});
|
||||
|
||||
export default function CodexMcpFormModal({
|
||||
isOpen,
|
||||
editingServer,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: CodexMcpFormModalProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const [formData, setFormData] = useState<CodexMcpFormState>(DEFAULT_CODEX_MCP_FORM);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (editingServer) {
|
||||
setFormData(createFormStateFromServer(editingServer));
|
||||
return;
|
||||
}
|
||||
|
||||
setFormData(DEFAULT_CODEX_MCP_FORM);
|
||||
}, [editingServer, isOpen]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await onSubmit(formData, editingServer);
|
||||
} catch (error) {
|
||||
alert(`Error: ${getErrorMessage(error)}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-lg border border-border bg-background">
|
||||
<div className="flex items-center justify-between border-b border-border p-4">
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
{editingServer ? t('mcpForm.title.edit') : t('mcpForm.title.add')}
|
||||
</h3>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 p-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.fields.serverName')} *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(event) => setFormData((prev) => ({ ...prev, name: event.target.value }))}
|
||||
placeholder={t('mcpForm.placeholders.serverName')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.fields.command')} *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.config.command}
|
||||
onChange={(event) => {
|
||||
const command = event.target.value;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
config: { ...prev.config, command },
|
||||
}));
|
||||
}}
|
||||
placeholder="npx @my-org/mcp-server"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.fields.arguments')}
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.config.args.join('\n')}
|
||||
onChange={(event) => {
|
||||
const args = event.target.value.split('\n').filter((arg) => arg.trim());
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
config: { ...prev.config, args },
|
||||
}));
|
||||
}}
|
||||
placeholder="--port 3000"
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-foreground">
|
||||
{t('mcpForm.fields.envVars')}
|
||||
</label>
|
||||
<textarea
|
||||
value={Object.entries(formData.config.env).map(([key, value]) => `${key}=${value}`).join('\n')}
|
||||
onChange={(event) => {
|
||||
const env: Record<string, string> = {};
|
||||
event.target.value.split('\n').forEach((line) => {
|
||||
const [key, ...valueParts] = line.split('=');
|
||||
if (key && valueParts.length > 0) {
|
||||
env[key.trim()] = valueParts.join('=').trim();
|
||||
}
|
||||
});
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
config: { ...prev.config, env },
|
||||
}));
|
||||
}}
|
||||
placeholder="API_KEY=xxx DEBUG=true"
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-border pt-4">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t('mcpForm.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !formData.name.trim() || !formData.config.command.trim()}
|
||||
className="bg-green-600 text-white hover:bg-green-700"
|
||||
>
|
||||
{isSubmitting
|
||||
? t('mcpForm.actions.saving')
|
||||
: editingServer
|
||||
? t('mcpForm.actions.updateServer')
|
||||
: t('mcpForm.actions.addServer')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useServerPlatform } from '../../../../../hooks/useServerPlatform';
|
||||
import type { AgentCategory, AgentProvider } from '../../../types/types';
|
||||
|
||||
import type { AgentContext, AgentsSettingsTabProps } from './types';
|
||||
import AgentCategoryContentSection from './sections/AgentCategoryContentSection';
|
||||
import AgentCategoryTabsSection from './sections/AgentCategoryTabsSection';
|
||||
import AgentSelectorSection from './sections/AgentSelectorSection';
|
||||
import type { AgentContext, AgentsSettingsTabProps } from './types';
|
||||
|
||||
export default function AgentsSettingsTab({
|
||||
providerAuthStatus,
|
||||
@@ -16,22 +19,26 @@ export default function AgentsSettingsTab({
|
||||
onCodexPermissionModeChange,
|
||||
geminiPermissionMode,
|
||||
onGeminiPermissionModeChange,
|
||||
mcpServers,
|
||||
cursorMcpServers,
|
||||
codexMcpServers,
|
||||
mcpTestResults,
|
||||
mcpServerTools,
|
||||
mcpToolsLoading,
|
||||
deleteError,
|
||||
onOpenMcpForm,
|
||||
onDeleteMcpServer,
|
||||
onTestMcpServer,
|
||||
onDiscoverMcpTools,
|
||||
onOpenCodexMcpForm,
|
||||
onDeleteCodexMcpServer,
|
||||
projects,
|
||||
}: AgentsSettingsTabProps) {
|
||||
const [selectedAgent, setSelectedAgent] = useState<AgentProvider>('claude');
|
||||
const [selectedCategory, setSelectedCategory] = useState<AgentCategory>('account');
|
||||
const { isWindowsServer } = useServerPlatform();
|
||||
|
||||
const visibleAgents = useMemo<AgentProvider[]>(() => {
|
||||
const all: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
|
||||
if (isWindowsServer) {
|
||||
return all.filter((id) => id !== 'cursor');
|
||||
}
|
||||
|
||||
return all;
|
||||
}, [isWindowsServer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isWindowsServer && selectedAgent === 'cursor') {
|
||||
setSelectedAgent('claude');
|
||||
}
|
||||
}, [isWindowsServer, selectedAgent]);
|
||||
|
||||
const agentContextById = useMemo<Record<AgentProvider, AgentContext>>(() => ({
|
||||
claude: {
|
||||
@@ -61,6 +68,7 @@ export default function AgentsSettingsTab({
|
||||
return (
|
||||
<div className="-mx-4 -mb-4 -mt-2 flex min-h-[300px] flex-col overflow-hidden md:-mx-6 md:-mb-6 md:-mt-2 md:min-h-[500px]">
|
||||
<AgentSelectorSection
|
||||
agents={visibleAgents}
|
||||
selectedAgent={selectedAgent}
|
||||
onSelectAgent={setSelectedAgent}
|
||||
agentContextById={agentContextById}
|
||||
@@ -84,19 +92,7 @@ export default function AgentsSettingsTab({
|
||||
onCodexPermissionModeChange={onCodexPermissionModeChange}
|
||||
geminiPermissionMode={geminiPermissionMode}
|
||||
onGeminiPermissionModeChange={onGeminiPermissionModeChange}
|
||||
mcpServers={mcpServers}
|
||||
cursorMcpServers={cursorMcpServers}
|
||||
codexMcpServers={codexMcpServers}
|
||||
mcpTestResults={mcpTestResults}
|
||||
mcpServerTools={mcpServerTools}
|
||||
mcpToolsLoading={mcpToolsLoading}
|
||||
deleteError={deleteError}
|
||||
onOpenMcpForm={onOpenMcpForm}
|
||||
onDeleteMcpServer={onDeleteMcpServer}
|
||||
onTestMcpServer={onTestMcpServer}
|
||||
onDiscoverMcpTools={onDiscoverMcpTools}
|
||||
onOpenCodexMcpForm={onOpenCodexMcpForm}
|
||||
onDeleteCodexMcpServer={onDeleteCodexMcpServer}
|
||||
projects={projects}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentCategoryContentSectionProps } from '../types';
|
||||
import { McpServers } from '../../../../../mcp';
|
||||
|
||||
import AccountContent from './content/AccountContent';
|
||||
import McpServersContent from './content/McpServersContent';
|
||||
import PermissionsContent from './content/PermissionsContent';
|
||||
|
||||
export default function AgentCategoryContentSection({
|
||||
@@ -13,23 +14,8 @@ export default function AgentCategoryContentSection({
|
||||
onCursorPermissionsChange,
|
||||
codexPermissionMode,
|
||||
onCodexPermissionModeChange,
|
||||
mcpServers,
|
||||
cursorMcpServers,
|
||||
codexMcpServers,
|
||||
mcpTestResults,
|
||||
mcpServerTools,
|
||||
mcpToolsLoading,
|
||||
deleteError,
|
||||
onOpenMcpForm,
|
||||
onDeleteMcpServer,
|
||||
onTestMcpServer,
|
||||
onDiscoverMcpTools,
|
||||
onOpenCodexMcpForm,
|
||||
onDeleteCodexMcpServer,
|
||||
projects,
|
||||
}: AgentCategoryContentSectionProps) {
|
||||
// Cursor MCP add/edit/delete was previously a placeholder and is intentionally preserved.
|
||||
const noopCursorMcpAction = () => {};
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-3 md:p-4">
|
||||
{selectedCategory === 'account' && (
|
||||
@@ -84,40 +70,10 @@ export default function AgentCategoryContentSection({
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedCategory === 'mcp' && selectedAgent === 'claude' && (
|
||||
<McpServersContent
|
||||
agent="claude"
|
||||
servers={mcpServers}
|
||||
onAdd={() => onOpenMcpForm()}
|
||||
onEdit={(server) => onOpenMcpForm(server)}
|
||||
onDelete={onDeleteMcpServer}
|
||||
onTest={onTestMcpServer}
|
||||
onDiscoverTools={onDiscoverMcpTools}
|
||||
testResults={mcpTestResults}
|
||||
serverTools={mcpServerTools}
|
||||
toolsLoading={mcpToolsLoading}
|
||||
deleteError={deleteError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedCategory === 'mcp' && selectedAgent === 'cursor' && (
|
||||
<McpServersContent
|
||||
agent="cursor"
|
||||
servers={cursorMcpServers}
|
||||
onAdd={noopCursorMcpAction}
|
||||
onEdit={noopCursorMcpAction}
|
||||
onDelete={noopCursorMcpAction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedCategory === 'mcp' && selectedAgent === 'codex' && (
|
||||
<McpServersContent
|
||||
agent="codex"
|
||||
servers={codexMcpServers}
|
||||
onAdd={() => onOpenCodexMcpForm()}
|
||||
onEdit={(server) => onOpenCodexMcpForm(server)}
|
||||
onDelete={(serverId) => onDeleteCodexMcpServer(serverId)}
|
||||
deleteError={deleteError}
|
||||
{selectedCategory === 'mcp' && (
|
||||
<McpServers
|
||||
selectedProvider={selectedAgent}
|
||||
currentProjects={projects}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,6 @@ import SessionProviderLogo from '../../../../../llm-logo-provider/SessionProvide
|
||||
import type { AgentProvider } from '../../../../types/types';
|
||||
import type { AgentSelectorSectionProps } from '../types';
|
||||
|
||||
const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];
|
||||
|
||||
const AGENT_NAMES: Record<AgentProvider, string> = {
|
||||
claude: 'Claude',
|
||||
cursor: 'Cursor',
|
||||
@@ -13,6 +11,7 @@ const AGENT_NAMES: Record<AgentProvider, string> = {
|
||||
};
|
||||
|
||||
export default function AgentSelectorSection({
|
||||
agents,
|
||||
selectedAgent,
|
||||
onSelectAgent,
|
||||
agentContextById,
|
||||
@@ -20,7 +19,7 @@ export default function AgentSelectorSection({
|
||||
return (
|
||||
<div className="flex-shrink-0 border-b border-border px-3 py-2 md:px-4 md:py-3">
|
||||
<PillBar className="w-full md:w-auto">
|
||||
{AGENT_PROVIDERS.map((agent) => {
|
||||
{agents.map((agent) => {
|
||||
const dotColor =
|
||||
agent === 'claude' ? 'bg-blue-500' :
|
||||
agent === 'cursor' ? 'bg-purple-500' :
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
import { Edit3, Globe, Plus, Server, Terminal, Trash2, Users, Zap } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge, Button } from '../../../../../../../shared/view/ui';
|
||||
import { IS_PLATFORM } from '../../../../../../../constants/config';
|
||||
import PremiumFeatureCard from '../../../../PremiumFeatureCard';
|
||||
import type { McpServer, McpToolsResult, McpTestResult } from '../../../../../types/types';
|
||||
|
||||
const getTransportIcon = (type: string | undefined) => {
|
||||
if (type === 'stdio') {
|
||||
return <Terminal className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
if (type === 'sse') {
|
||||
return <Zap className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
if (type === 'http') {
|
||||
return <Globe className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
return <Server className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
const maskSecret = (value: unknown): string => {
|
||||
const normalizedValue = String(value ?? '');
|
||||
if (normalizedValue.length <= 4) {
|
||||
return '****';
|
||||
}
|
||||
|
||||
return `${normalizedValue.slice(0, 2)}****${normalizedValue.slice(-2)}`;
|
||||
};
|
||||
|
||||
type ClaudeMcpServersProps = {
|
||||
agent: 'claude';
|
||||
servers: McpServer[];
|
||||
onAdd: () => void;
|
||||
onEdit: (server: McpServer) => void;
|
||||
onDelete: (serverId: string, scope?: string) => void;
|
||||
onTest: (serverId: string, scope?: string) => void;
|
||||
onDiscoverTools: (serverId: string, scope?: string) => void;
|
||||
testResults: Record<string, McpTestResult>;
|
||||
serverTools: Record<string, McpToolsResult>;
|
||||
toolsLoading: Record<string, boolean>;
|
||||
deleteError?: string | null;
|
||||
};
|
||||
|
||||
function ClaudeMcpServers({
|
||||
servers,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
testResults,
|
||||
serverTools,
|
||||
deleteError,
|
||||
}: Omit<ClaudeMcpServersProps, 'agent' | 'onTest' | 'onDiscoverTools' | 'toolsLoading'>) {
|
||||
const { t } = useTranslation('settings');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="h-5 w-5 text-purple-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t('mcpServers.description.claude')}</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button onClick={onAdd} className="bg-purple-600 text-white hover:bg-purple-700" size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('mcpServers.addButton')}
|
||||
</Button>
|
||||
</div>
|
||||
{deleteError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
|
||||
{deleteError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{servers.map((server) => {
|
||||
const serverId = server.id || server.name;
|
||||
const testResult = testResults[serverId];
|
||||
const toolsResult = serverTools[serverId];
|
||||
|
||||
return (
|
||||
<div key={serverId} className="rounded-lg border border-border bg-card/50 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{getTransportIcon(server.type)}
|
||||
<span className="font-medium text-foreground">{server.name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{server.type || 'stdio'}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{server.scope === 'local'
|
||||
? t('mcpServers.scope.local')
|
||||
: server.scope === 'user'
|
||||
? t('mcpServers.scope.user')
|
||||
: server.scope}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
{server.type === 'stdio' && server.config?.command && (
|
||||
<div>
|
||||
{t('mcpServers.config.command')}:{' '}
|
||||
<code className="rounded bg-muted px-1 text-xs">{server.config.command}</code>
|
||||
</div>
|
||||
)}
|
||||
{(server.type === 'sse' || server.type === 'http') && server.config?.url && (
|
||||
<div>
|
||||
{t('mcpServers.config.url')}:{' '}
|
||||
<code className="rounded bg-muted px-1 text-xs">{server.config.url}</code>
|
||||
</div>
|
||||
)}
|
||||
{server.config?.args && server.config.args.length > 0 && (
|
||||
<div>
|
||||
{t('mcpServers.config.args')}:{' '}
|
||||
<code className="rounded bg-muted px-1 text-xs">{server.config.args.join(' ')}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className={`mt-2 rounded p-2 text-xs ${
|
||||
testResult.success
|
||||
? 'bg-green-50 text-green-800 dark:bg-green-900/20 dark:text-green-200'
|
||||
: 'bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-200'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{testResult.message}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolsResult && toolsResult.tools && toolsResult.tools.length > 0 && (
|
||||
<div className="mt-2 rounded bg-blue-50 p-2 text-xs text-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
|
||||
<div className="font-medium">
|
||||
{t('mcpServers.tools.title')} {t('mcpServers.tools.count', { count: toolsResult.tools.length })}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{toolsResult.tools.slice(0, 5).map((tool, index) => (
|
||||
<code key={`${tool.name}-${index}`} className="rounded bg-blue-100 px-1 dark:bg-blue-800">
|
||||
{tool.name}
|
||||
</code>
|
||||
))}
|
||||
{toolsResult.tools.length > 5 && (
|
||||
<span className="text-xs opacity-75">
|
||||
{t('mcpServers.tools.more', { count: toolsResult.tools.length - 5 })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-4 flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => onEdit(server)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title={t('mcpServers.actions.edit')}
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onDelete(serverId, server.scope)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700"
|
||||
title={t('mcpServers.actions.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{servers.length === 0 && (
|
||||
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!IS_PLATFORM && (
|
||||
<PremiumFeatureCard
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
title="Team MCP Configs"
|
||||
description="Share MCP server configurations across your team. Everyone stays in sync automatically."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CursorMcpServersProps = {
|
||||
agent: 'cursor';
|
||||
servers: McpServer[];
|
||||
onAdd: () => void;
|
||||
onEdit: (server: McpServer) => void;
|
||||
onDelete: (serverId: string) => void;
|
||||
};
|
||||
|
||||
function CursorMcpServers({ servers, onAdd, onEdit, onDelete }: Omit<CursorMcpServersProps, 'agent'>) {
|
||||
const { t } = useTranslation('settings');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="h-5 w-5 text-purple-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t('mcpServers.description.cursor')}</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button onClick={onAdd} className="bg-purple-600 text-white hover:bg-purple-700" size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('mcpServers.addButton')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{servers.map((server) => {
|
||||
const serverId = server.id || server.name;
|
||||
|
||||
return (
|
||||
<div key={serverId} className="rounded-lg border border-border bg-card/50 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span className="font-medium text-foreground">{server.name}</span>
|
||||
<Badge variant="outline" className="text-xs">stdio</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{server.config?.command && (
|
||||
<div>
|
||||
{t('mcpServers.config.command')}:{' '}
|
||||
<code className="rounded bg-muted px-1 text-xs">{server.config.command}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4 flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => onEdit(server)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title={t('mcpServers.actions.edit')}
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onDelete(serverId)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700"
|
||||
title={t('mcpServers.actions.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{servers.length === 0 && (
|
||||
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CodexMcpServersProps = {
|
||||
agent: 'codex';
|
||||
servers: McpServer[];
|
||||
onAdd: () => void;
|
||||
onEdit: (server: McpServer) => void;
|
||||
onDelete: (serverId: string) => void;
|
||||
deleteError?: string | null;
|
||||
};
|
||||
|
||||
function CodexMcpServers({ servers, onAdd, onEdit, onDelete, deleteError }: Omit<CodexMcpServersProps, 'agent'>) {
|
||||
const { t } = useTranslation('settings');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="h-5 w-5 text-muted-foreground" />
|
||||
<h3 className="text-lg font-medium text-foreground">{t('mcpServers.title')}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t('mcpServers.description.codex')}</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button onClick={onAdd} className="bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600" size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('mcpServers.addButton')}
|
||||
</Button>
|
||||
</div>
|
||||
{deleteError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200">
|
||||
{deleteError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{servers.map((server) => (
|
||||
<div key={server.name} className="rounded-lg border border-border bg-card/50 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4" />
|
||||
<span className="font-medium text-foreground">{server.name}</span>
|
||||
<Badge variant="outline" className="text-xs">stdio</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm text-muted-foreground">
|
||||
{server.config?.command && (
|
||||
<div>
|
||||
{t('mcpServers.config.command')}:{' '}
|
||||
<code className="rounded bg-muted px-1 text-xs">{server.config.command}</code>
|
||||
</div>
|
||||
)}
|
||||
{server.config?.args && server.config.args.length > 0 && (
|
||||
<div>
|
||||
{t('mcpServers.config.args')}:{' '}
|
||||
<code className="rounded bg-muted px-1 text-xs">{server.config.args.join(' ')}</code>
|
||||
</div>
|
||||
)}
|
||||
{server.config?.env && Object.keys(server.config.env).length > 0 && (
|
||||
<div>
|
||||
{t('mcpServers.config.environment')}:{' '}
|
||||
<code className="rounded bg-muted px-1 text-xs">
|
||||
{Object.entries(server.config.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-4 flex items-center gap-2">
|
||||
<Button
|
||||
onClick={() => onEdit(server)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title={t('mcpServers.actions.edit')}
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onDelete(server.name)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700"
|
||||
title={t('mcpServers.actions.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{servers.length === 0 && (
|
||||
<div className="py-8 text-center text-muted-foreground">{t('mcpServers.empty')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-muted/50 p-4">
|
||||
<h4 className="mb-2 font-medium text-foreground">{t('mcpServers.help.title')}</h4>
|
||||
<p className="text-sm text-muted-foreground">{t('mcpServers.help.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type McpServersContentProps = ClaudeMcpServersProps | CursorMcpServersProps | CodexMcpServersProps;
|
||||
|
||||
export default function McpServersContent(props: McpServersContentProps) {
|
||||
if (props.agent === 'claude') {
|
||||
return <ClaudeMcpServers {...props} />;
|
||||
}
|
||||
|
||||
if (props.agent === 'cursor') {
|
||||
return <CursorMcpServers {...props} />;
|
||||
}
|
||||
|
||||
return <CodexMcpServers {...props} />;
|
||||
}
|
||||
@@ -6,9 +6,7 @@ import type {
|
||||
CursorPermissionsState,
|
||||
CodexPermissionMode,
|
||||
GeminiPermissionMode,
|
||||
McpServer,
|
||||
McpToolsResult,
|
||||
McpTestResult,
|
||||
SettingsProject,
|
||||
} from '../../../types/types';
|
||||
|
||||
export type AgentContext = {
|
||||
@@ -30,19 +28,7 @@ export type AgentsSettingsTabProps = {
|
||||
onCodexPermissionModeChange: (value: CodexPermissionMode) => void;
|
||||
geminiPermissionMode: GeminiPermissionMode;
|
||||
onGeminiPermissionModeChange: (value: GeminiPermissionMode) => void;
|
||||
mcpServers: McpServer[];
|
||||
cursorMcpServers: McpServer[];
|
||||
codexMcpServers: McpServer[];
|
||||
mcpTestResults: Record<string, McpTestResult>;
|
||||
mcpServerTools: Record<string, McpToolsResult>;
|
||||
mcpToolsLoading: Record<string, boolean>;
|
||||
deleteError: string | null;
|
||||
onOpenMcpForm: (server?: McpServer) => void;
|
||||
onDeleteMcpServer: (serverId: string, scope?: string) => void;
|
||||
onTestMcpServer: (serverId: string, scope?: string) => void;
|
||||
onDiscoverMcpTools: (serverId: string, scope?: string) => void;
|
||||
onOpenCodexMcpForm: (server?: McpServer) => void;
|
||||
onDeleteCodexMcpServer: (serverId: string) => void;
|
||||
projects: SettingsProject[];
|
||||
};
|
||||
|
||||
export type AgentCategoryTabsSectionProps = {
|
||||
@@ -51,6 +37,7 @@ export type AgentCategoryTabsSectionProps = {
|
||||
};
|
||||
|
||||
export type AgentSelectorSectionProps = {
|
||||
agents: AgentProvider[];
|
||||
selectedAgent: AgentProvider;
|
||||
onSelectAgent: (agent: AgentProvider) => void;
|
||||
agentContextById: AgentContextByProvider;
|
||||
@@ -68,17 +55,5 @@ export type AgentCategoryContentSectionProps = {
|
||||
onCodexPermissionModeChange: (value: CodexPermissionMode) => void;
|
||||
geminiPermissionMode: GeminiPermissionMode;
|
||||
onGeminiPermissionModeChange: (value: GeminiPermissionMode) => void;
|
||||
mcpServers: McpServer[];
|
||||
cursorMcpServers: McpServer[];
|
||||
codexMcpServers: McpServer[];
|
||||
mcpTestResults: Record<string, McpTestResult>;
|
||||
mcpServerTools: Record<string, McpToolsResult>;
|
||||
mcpToolsLoading: Record<string, boolean>;
|
||||
deleteError: string | null;
|
||||
onOpenMcpForm: (server?: McpServer) => void;
|
||||
onDeleteMcpServer: (serverId: string, scope?: string) => void;
|
||||
onTestMcpServer: (serverId: string, scope?: string) => void;
|
||||
onDiscoverMcpTools: (serverId: string, scope?: string) => void;
|
||||
onOpenCodexMcpForm: (server?: McpServer) => void;
|
||||
onDeleteCodexMcpServer: (serverId: string) => void;
|
||||
projects: SettingsProject[];
|
||||
};
|
||||
|
||||
40
src/hooks/useServerPlatform.ts
Normal file
40
src/hooks/useServerPlatform.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
|
||||
/**
|
||||
* Node `process.platform` from the API host (e.g. win32, darwin, linux).
|
||||
* Null until loaded or if the request fails.
|
||||
*/
|
||||
export function useServerPlatform(): {
|
||||
serverPlatform: string | null;
|
||||
isWindowsServer: boolean;
|
||||
} {
|
||||
const [serverPlatform, setServerPlatform] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/settings/server-env');
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const body = (await response.json()) as { platform?: string };
|
||||
if (!cancelled && typeof body.platform === 'string') {
|
||||
setServerPlatform(body.platform);
|
||||
}
|
||||
} catch {
|
||||
// Keep null: treat as unknown host.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
serverPlatform,
|
||||
isWindowsServer: serverPlatform === 'win32',
|
||||
};
|
||||
}
|
||||
@@ -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