Compare commits

...

14 Commits

Author SHA1 Message Date
Haileyesus
907cf510a3 refactor(providers): remove debug logging from Claude authentication status checks 2026-04-17 16:01:16 +03:00
Haileyesus
eb6268748b chore(api): remove unused backend endpoints after MCP audit
Remove legacy backend routes that no longer have frontend or internal
callers, including the old Claude/Codex MCP APIs, unused Cursor and Codex
helper endpoints, stale TaskMaster detection/next/initialize routes,
and unused command/project helpers.

This reduces duplicated MCP behavior now handled by the provider-based
MCP API, shrinks the exposed backend surface, and removes probe/service
code that only existed for deleted endpoints.

Add an MCP settings API audit document to capture the route-usage
analysis and explain why the legacy MCP endpoints were considered safe
to remove.
2026-04-17 15:57:07 +03:00
Haileyesus
4e962272cd refactor(providers): move auth status routes under provider API
Move provider authentication status endpoints out of the legacy `/api/cli` route
namespace so auth status is exposed through the same provider module that owns
provider auth and MCP behavior.

Add `GET /api/providers/:provider/auth/status` to the provider router and route
it through the provider auth service. Remove the old `cli-auth` route file and
`/api/cli` mount now that provider auth status is handled by the unified provider
API.

Update the frontend provider auth endpoint map to call the new provider-scoped
routes and rename the endpoint constant to reflect that it is no longer CLI
specific.
2026-04-17 15:39:25 +03:00
Haileyesus
0f1e515b39 refactor(providers): move session message delegation into sessions service
Move provider-backed session history and message normalization calls out of the
generic providers service so the service name reflects the behavior it owns.

Add a dedicated sessions service for listing session-capable providers,
normalizing live provider events, and fetching persisted session history through
the provider registry. Update realtime handlers and the unified messages route to
depend on `sessionsService` instead of `providersService`.

This separates session message operations from other provider concerns such as
auth and MCP, keeping the provider services easier to navigate as the module
grows.
2026-04-17 15:29:35 +03:00
Haileyesus
b74b5fb967 refactor(providers): clarify provider auth and MCP naming
Rename provider auth/MCP contracts to remove the overloaded Runtime suffix so
the shared interfaces read as stable provider capabilities instead of execution
implementation details.

Add a consistent provider-first auth class naming convention by renaming
ClaudeAuthProvider, CodexAuthProvider, CursorAuthProvider, and GeminiAuthProvider
to ClaudeProviderAuth, CodexProviderAuth, CursorProviderAuth, and
GeminiProviderAuth.

This keeps the provider module API easier to scan and aligns auth naming with
the main provider ownership model.
2026-04-17 15:23:12 +03:00
Haileyesus
32dfd27156 refactor(providers): move auth status checks into provider runtimes
Move provider authentication status logic out of the CLI auth route so auth checks
live with the provider implementations that understand each provider's install
and credential model.

Add provider-specific auth runtime classes for Claude, Codex, Cursor, and Gemini,
and expose them through the shared provider contract as `provider.auth`. Add a
provider auth service that resolves providers through the registry and delegates
status checks via `auth.getStatus()`.

Keep the existing `/api/cli/<provider>/status` endpoints, but make them thin route
adapters over the new provider auth service. This removes duplicated route-local
credential parsing and makes auth status a first-class provider capability beside
MCP and message handling.
2026-04-17 15:15:26 +03:00
Haileyesus
7832429011 refactor(providers): centralize message handling in provider module
Move provider-specific normalizeMessage and fetchHistory logic out of the legacy
server/providers adapters and into the refactored provider classes so callers can
depend on the main provider contract instead of parallel adapter plumbing.

Add a providers service to resolve concrete providers through the registry and
delegate message normalization/history loading from realtime handlers and the
unified messages route. Add shared TypeScript message/history types and normalized
message helpers so provider implementations and callers use the same contract.

Remove the old adapter registry/files now that Claude, Codex, Cursor, and Gemini
implement the required behavior directly.
2026-04-17 14:22:29 +03:00
Haileyesus
1a6eb57043 feat: implement platform-specific provider visibility for cursor agent 2026-04-16 23:21:21 +03:00
Haileyesus
d979c315cd feat(mcp): add global MCP server creation flow
Add a separate global MCP add path in the settings MCP module so users can create
one shared MCP server configuration across Claude, Cursor, Codex, and Gemini from
the same screen.

The provider-specific add flow is still kept next to it because these two actions
have different intent. A global MCP server must be constrained to the subset of
configuration that every provider can accept, while a provider-specific server can
still use that provider's own supported scopes, transports, and fields. Naming the
buttons as "Add Global MCP Server" and "Add <Provider> MCP Server" makes that
distinction explicit without forcing users to infer it from the selected tab.

This also moves the explanatory copy to button hover text to keep the MCP toolbar
compact while still documenting the difference between global and provider-only
adds at the point of action.

Implementation details:
- Add global MCP form mode with shared user/project scopes and stdio/http transports.
- Submit global creates through `/api/providers/mcp/servers/global`.
- Reuse the existing MCP form modal with configurable scopes, transports, labels,
  and descriptions instead of duplicating form logic.
- Disable provider-only fields for the global flow because those fields cannot be
  safely written to every provider.
- Clear the MCP server cache globally after a global add because every provider tab
  may have changed.
- Surface partial global add failures with provider-specific error messages.

Validation:
- npx eslint src/components/mcp/view/McpServers.tsx
- npm run typecheck
- npm run build:client
2026-04-16 22:43:18 +03:00
Haileyesus
5143a92021 fix(mcp): form with multiline text handling for args, env, headers, and envVars 2026-04-16 22:29:34 +03:00
Haileyesus
358f47d020 refactor(settings): move MCP server management into provider module
Extract MCP server settings out of the settings controller and agents tab into a
dedicated frontend MCP module. The settings UI now delegates MCP rendering and
behavior to a single module that only needs the selected provider and current
projects.

Changes:
- Add `src/components/mcp` as the single frontend MCP module
- Move MCP server list rendering into `McpServers`
- Move MCP add/edit modal into `McpServerFormModal`
- Move MCP API/state logic into `useMcpServers`
- Move MCP form state/validation logic into `useMcpServerForm`
- Add provider-specific MCP constants, types, and formatting helpers
- Use the unified `/api/providers/:provider/mcp/servers` API for all providers
- Support MCP management for Claude, Cursor, Codex, and Gemini
- Remove old settings-owned Claude/Codex MCP modal components
- Remove old provider-specific `McpServersContent` branching from settings
- Strip MCP server state, fetch, save, delete, and modal ownership from
  `useSettingsController`
- Simplify agents settings props so MCP only receives `selectedProvider` and
  `currentProjects`
- Keep Claude working-directory unsupported while preserving cwd support for
  Cursor, Codex, and Gemini
- Add progressive MCP loading:
  - render user/global scope first
  - load project/local scopes in the background
  - append project results as they resolve
  - cache MCP lists briefly to avoid slow tab-switch refetches
  - ignore stale async responses after provider switches

Verification:
- `npx eslint src/components/mcp`
- `npm run typecheck`
- `npm run build:client`
2026-04-16 22:22:35 +03:00
Haileyesus
5c53100651 refactor: put /api/providers in index.js and remove /providers prefix from provider.routes.ts 2026-04-16 20:58:16 +03:00
Haileyesus
63b9606e78 chore: remove dead code related to MCP server 2026-04-16 20:57:17 +03:00
Haileyesus
016e8673f2 feat: implement MCP provider registry and service
- Add provider registry to manage LLM providers (Claude, Codex, Cursor, Gemini).
- Create provider routes for MCP server operations (list, upsert, delete, run).
- Implement MCP service for handling server operations and validations.
- Introduce abstract provider class and MCP provider base for shared functionality.
- Add tests for MCP server operations across different providers and scopes.
- Define shared interfaces and types for MCP functionality.
- Implement utility functions for handling JSON config files and API responses.
2026-04-15 20:16:26 +03:00
73 changed files with 5760 additions and 5584 deletions

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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);
}
};

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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' : '-';

View 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 };
}
}
}

View 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;
}
}

View 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,
};
}
}

View 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';
}
}

View 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;
}
}

View 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,
};
}
}

View 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',
});
});
});
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}
}

View 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;
}
}

View 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,
};
}
}

View 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;
},
};

View 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;

View 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;
},
};

View 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();
},
};

View 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);
},
};

View 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>;
}

View 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,
});
}
}
}

View 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 });
}
});

View File

@@ -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);
}

View File

@@ -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,
};
},
};

View File

@@ -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,
};
},
};

View File

@@ -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;
},
};

View File

@@ -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,
};
},
};

View File

@@ -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());
}

View File

@@ -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,
};
}

View File

@@ -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));
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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

View 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
View 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
View 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');
};
// -------------------------------------------------------------------------------------------

View File

@@ -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: {}
};
}
}

View File

@@ -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

View 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: '',
};

View 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,
};
}

View 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,
};
}

View File

@@ -0,0 +1 @@
export { default as McpServers } from './view/McpServers';

View 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;

View 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,
};
};

View 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>
);
}

View 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&#10;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&#10;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&#10;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&#10;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>
);
}

View File

@@ -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, {

View File

@@ -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 => ({

View File

@@ -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: [],

View File

@@ -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,

View File

@@ -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[];

View File

@@ -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>
);
}

View File

@@ -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&#10;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&#10;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&#10;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>
);
}

View File

@@ -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&#10;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&#10;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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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' :

View File

@@ -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} />;
}

View File

@@ -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[];
};

View 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',
};
}

View File

@@ -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',