diff --git a/docs/MCP.md b/docs/MCP.md new file mode 100644 index 00000000..fd1825a8 --- /dev/null +++ b/docs/MCP.md @@ -0,0 +1,104 @@ +# MCP Server + +Plexus exposes two MCP (Model Context Protocol) server endpoints: + +1. **MCP Gateway** at `/mcp/:name` — proxies requests to configured upstream MCP servers. +2. **Plexus Management MCP** at `/mcp/plexus` — an admin-only MCP server for managing Plexus itself. + +Both endpoints have independent controls. The Plexus Management MCP at `/mcp/plexus` can be disabled via the toggle on the MCP Servers page. When disabled, `/mcp/plexus` responds with HTTP 418; gateway servers (`/mcp/:name`) are unaffected. + +## Authentication + +### MCP Gateway (`/mcp/:name`) + +The MCP Gateway requires an inference API key passed as a Bearer token or in the `x-api-key` header. Requests are authenticated against the same key store used by the Plexus inference API. + +- Missing key → 401 +- Invalid key → 401 +- Valid key with route access → proxied to the upstream MCP server + +### Plexus Management MCP (`/mcp/plexus`) + +The management MCP requires the admin key in the `x-admin-key` header. Inference API keys and Bearer tokens are **not** accepted. + +- Missing `x-admin-key` → 401 +- Incorrect `x-admin-key` → 401 +- Inference API key or Bearer token alone → 401 +- Valid admin key → request processed + +## MCP Gateway Commands + +The gateway proxies all MCP protocol messages (JSON-RPC over HTTP POST, GET, DELETE) to the configured upstream server. It does not interpret or modify tool calls. Logs are recorded in the MCP usage storage and viewable in the MCP Logs page. + +### Configuring Upstream Servers + +Upstream MCP servers are configured on the MCP page. Each server requires: + +- **Name** — slug-safe identifier used in the URL path +- **Upstream URL** — the upstream MCP server endpoint +- **Headers** — optional headers forwarded to the upstream server +- **Timeout** — request timeout in milliseconds + +Server names must match `[a-z0-9][a-z0-9-_]{1,62}`. The name `plexus` is reserved for the management MCP server and cannot be used as a gateway server name. + +## Plexus Management MCP Tools + +The management MCP server at `/mcp/plexus` provides domain-oriented tools for inspecting and managing Plexus configuration. Each tool uses an `operation` argument to select the action, keeping the tool surface compact. + +### Input Shape + +```json +{ + "operation": "list | get | ...", + "id": "optional-resource-id", + "category": "optional-settings-category", + "query": {}, + "body": {}, + "destructive": "acknowledged" +} +``` + +### Destructive Operations + +Destructive or high-impact operations (delete, restore, restart, rotate, etc.) require `"destructive": "acknowledged"`. If omitted, the tool call is rejected with a `confirmation_required` error. + +### Secret Redaction + +All normal responses redact sensitive fields (API keys, secrets, tokens, cookies, sessions, passwords). Secrets are replaced with `[REDACTED]`. + +### Available Tools + +| Tool | Operations | Description | +|------|-----------|-------------| +| `plexus_config` | `get`, `export`, `status` | Inspect full Plexus configuration or summary status. | +| `plexus_provider` | `list`, `get`, `put`, `create`, `update`, `delete`, `fetch_models` | Inspect and manage providers and routing configuration. | +| `plexus_model_alias` | `list`, `get`, `put`, `create`, `update`, `delete`, `delete_all` | Inspect and manage model aliases, targets, and target groups. | +| `plexus_key` | `list`, `get`, `put`, `create`, `update`, `delete` | Inspect and manage inference keys (secrets redacted). | +| `plexus_quota` | `list`, `get`, `put`, `create`, `update`, `delete` | Inspect and manage user quota definitions. | +| `plexus_quota_checker` | `types`, `list`, `get` | Inspect upstream quota checker configuration. | +| `plexus_usage` | `list`, `summary`, `delete`, `delete_all` | Review request logs and usage summaries; delete individual or bulk usage logs. | +| `plexus_debug` | `state`, `update`, `logs`, `get_log`, `delete_log`, `delete_all_logs` | Inspect and manage debug tracing and stored debug logs. | +| `plexus_mcp_gateway` | `servers_list`, `list`, `get`, `put`, `create`, `update`, `delete` | Inspect and manage upstream MCP gateway server configuration. | +| `plexus_settings` | `get` | Get settings by category (failover, cooldown, timeout, stall, exploration, etc.) | +| `plexus_system_logs` | `recent`, `level`, `set_level`, `reset_level` | Inspect recent in-memory Plexus system logs from the bounded ring buffer and control the runtime logging level. | +| `plexus_operations` | `backup`, `restore`, `restart`, `list_cooldowns`, `clear_cooldowns`, `reset_logs` | High-impact operational actions, backups/restores, cooldown inspection, and log resets. | + +### Prompt Resource + +The management MCP server registers a `plexus_management_guide` prompt that MCP clients can request. It describes Plexus, the tool design, destructive acknowledgement, secret redaction, and recommended workflows. + +## Enabling / Disabling + +The Plexus Management MCP can be toggled from the **MCP Servers** page. The "Plexus Management MCP" row at the top of the server list provides the toggle. When disabled, only `/mcp/plexus` responds with: + +``` +HTTP 418 I'm a teapot +{ + "error": { + "message": "Plexus Management MCP is disabled. Enable it on the MCP Servers page.", + "type": "mcp_disabled" + } +} +``` + +The default state is **enabled**. diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index a46d1980..27170a91 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -540,6 +540,8 @@ paths: $ref: paths/v0_management_events.yaml /v0/system/logs/stream: $ref: paths/v0_system_logs_stream.yaml + /v0/system/logs/recent: + $ref: paths/v0_system_logs_recent.yaml /mcp/{name}: $ref: paths/mcp_{name}.yaml /.well-known/oauth-authorization-server: diff --git a/docs/openapi/paths/v0_management_aliases_{slug}.yaml b/docs/openapi/paths/v0_management_aliases_{slug}.yaml index 1ce393ce..4e3a6aa9 100644 --- a/docs/openapi/paths/v0_management_aliases_{slug}.yaml +++ b/docs/openapi/paths/v0_management_aliases_{slug}.yaml @@ -5,6 +5,31 @@ parameters: schema: type: string description: Alias slug (e.g. "gpt-4", "claude-3-opus"). +get: + tags: + - Management — Config + summary: Get a model alias by slug (admin only) + description: | + Returns a single model alias configuration by slug. + + **Admin only** — limited principals receive 403. + security: + - AdminKey: [] + responses: + '200': + description: Alias configuration. + content: + application/json: + schema: + allOf: + - type: object + properties: + slug: + type: string + - $ref: ../components/schemas/AliasConfig.yaml + '404': + description: Alias not found. + operationId: getV0ManagementAliasesByslug put: tags: - Management — Config diff --git a/docs/openapi/paths/v0_management_config.yaml b/docs/openapi/paths/v0_management_config.yaml index 34f025c4..a32784ad 100644 --- a/docs/openapi/paths/v0_management_config.yaml +++ b/docs/openapi/paths/v0_management_config.yaml @@ -6,12 +6,13 @@ get: Returns the full Plexus configuration object as JSON. This is the same structure used by the database. - ## JSON vs YAML + ## JSON endpoints - **This endpoint** — Returns the in-memory configuration as JSON - - **`GET /v0/management/config/export`** — Returns the raw YAML as stored on disk + - **`GET /v0/management/config/export`** — Returns an export-friendly JSON object - Both contain the same data; the JSON form is easier for programmatic access. + Both contain nearly the same data; the export form omits derived runtime-only + fields and is suitable for backups or round-tripping through restore/import flows. ## What's included diff --git a/docs/openapi/paths/v0_management_config_export.yaml b/docs/openapi/paths/v0_management_config_export.yaml index 9129015b..7dc2038b 100644 --- a/docs/openapi/paths/v0_management_config_export.yaml +++ b/docs/openapi/paths/v0_management_config_export.yaml @@ -1,32 +1,29 @@ get: tags: - Management — Config - summary: Export the configuration as YAML (admin only) + summary: Export the configuration as JSON (admin only) description: | - Exports the full configuration as YAML — the same format used in - the database. + Exports the persisted configuration graph as JSON. - ## JSON vs YAML + ## JSON endpoints - **`GET /v0/management/config`** — Returns configuration as JSON - - **This endpoint** — Returns configuration as YAML (same as on disk) + - **This endpoint** — Returns an export-friendly JSON object - Both contain the same data; the YAML form is suitable for saving - back to the config file. + This endpoint is useful for programmatic export/backup flows. It does not + return YAML and Plexus no longer relies on an on-disk YAML source of truth. **Admin only** — limited principals receive 403. security: - AdminKey: [] responses: '200': - description: YAML export. + description: JSON export. content: - application/x-yaml: + application/json: schema: - type: string - text/yaml: - schema: - type: string + type: object + additionalProperties: true '401': description: Authentication required or invalid credentials. operationId: getV0ManagementConfigExport \ No newline at end of file diff --git a/docs/openapi/paths/v0_management_keys_{name}.yaml b/docs/openapi/paths/v0_management_keys_{name}.yaml index 5269fdbe..ae7e9b75 100644 --- a/docs/openapi/paths/v0_management_keys_{name}.yaml +++ b/docs/openapi/paths/v0_management_keys_{name}.yaml @@ -5,6 +5,35 @@ parameters: schema: type: string description: API key name (e.g. "sk-prod", "sk-dev"). +get: + tags: + - Management — Config + summary: Get an API key by name (admin only) + description: | + Returns a single API key record by name. + + The stored secret is returned decrypted by the HTTP management API. + Clients that need redaction should apply it themselves or use the MCP + management surface instead. + + **Admin only** — limited principals receive 403. + security: + - AdminKey: [] + responses: + '200': + description: API key configuration. + content: + application/json: + schema: + allOf: + - type: object + properties: + name: + type: string + - $ref: ../components/schemas/KeyConfig.yaml + '404': + description: API key not found. + operationId: getV0ManagementKeysByname put: tags: - Management — Config @@ -72,6 +101,45 @@ put: '401': description: Authentication required or invalid credentials. operationId: putV0ManagementKeysByname +patch: + tags: + - Management — Config + summary: Merge updates into an API key (admin only) + description: | + Merges the request body into the existing API key configuration, then + validates the result against `KeyConfig`. + + PATCH performs a shallow merge; fields omitted from the request keep their + existing values. + + **Admin only** — limited principals receive 403. + security: + - AdminKey: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: true + responses: + '200': + description: Saved. + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + const: true + name: + type: string + '400': + description: Validation failed. + '404': + description: API key not found. + operationId: patchV0ManagementKeysByname delete: tags: - Management — Config diff --git a/docs/openapi/paths/v0_management_mcp-servers_{serverName}.yaml b/docs/openapi/paths/v0_management_mcp-servers_{serverName}.yaml index a96bfe99..6f09773e 100644 --- a/docs/openapi/paths/v0_management_mcp-servers_{serverName}.yaml +++ b/docs/openapi/paths/v0_management_mcp-servers_{serverName}.yaml @@ -8,6 +8,31 @@ parameters: description: > Server name (lowercase alphanumeric, max 64 chars). Must match pattern `^[a-z0-9][a-z0-9-_]{1,62}$`. +get: + tags: + - Management — Config + summary: Get an MCP server entry by name (admin only) + description: | + Returns a single configured MCP gateway server entry. + + **Admin only** — limited principals receive 403. + security: + - AdminKey: [] + responses: + '200': + description: MCP server configuration. + content: + application/json: + schema: + allOf: + - type: object + properties: + name: + type: string + - $ref: ../components/schemas/McpServerConfig.yaml + '404': + description: MCP server not found. + operationId: getV0ManagementMcpserversByserverName put: tags: - Management — Config @@ -21,14 +46,8 @@ put: - Max 64 characters - Can contain lowercase letters, numbers, and hyphens/underscores - ## Proxy auth fields - - These fields affect how the MCP proxy authenticates to the upstream: - - `auth_type` — Authentication type (none, bearer, basic, header) - - `auth_header_value` — Static value for custom auth headers - - The proxy strips incoming `Authorization` / `x-api-key` headers and - uses the configured static auth instead. + The request body contains the upstream URL, enabled flag, and optional + static headers forwarded to the upstream MCP server. **Admin only** — limited principals receive 403. security: @@ -55,6 +74,44 @@ put: '400': description: Invalid server name or body. operationId: putV0ManagementMcpserversByserverName +patch: + tags: + - Management — Config + summary: Merge updates into an MCP server entry (admin only) + description: | + Merges the request body into the existing MCP server configuration, then + validates the result against `McpServerConfig`. + + PATCH performs a shallow merge; omitted fields keep their current values. + + **Admin only** — limited principals receive 403. + security: + - AdminKey: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: true + responses: + '200': + description: Saved. + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + const: true + name: + type: string + '400': + description: Invalid server name or body. + '404': + description: MCP server not found. + operationId: patchV0ManagementMcpserversByserverName delete: tags: - Management — Config diff --git a/docs/openapi/paths/v0_system_logs_recent.yaml b/docs/openapi/paths/v0_system_logs_recent.yaml new file mode 100644 index 00000000..4562620c --- /dev/null +++ b/docs/openapi/paths/v0_system_logs_recent.yaml @@ -0,0 +1,50 @@ +get: + tags: + - Events + summary: Get recent in-memory backend logs (admin only) + description: | + Returns the most recent backend log entries from Plexus's in-memory log ring buffer. + + ## Retention + + Plexus keeps a bounded in-memory buffer of recent raw log objects. This endpoint + returns the newest entries first and does not persist logs across restarts. + + ## Query parameters + + - `limit` — Maximum number of log entries to return (default 100, max 1000) + + **Admin only** — limited principals receive 403. + security: + - AdminKey: [] + parameters: + - in: query + name: limit + required: false + schema: + type: integer + minimum: 1 + maximum: 1000 + default: 100 + description: Maximum number of recent log entries to return. + responses: + '200': + description: Recent in-memory log entries, newest first. + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + additionalProperties: true + total: + type: integer + required: + - data + - total + '401': + description: Authentication required or invalid credentials. + operationId: getV0SystemLogsRecent \ No newline at end of file diff --git a/docs/openapi/paths/v0_system_logs_stream.yaml b/docs/openapi/paths/v0_system_logs_stream.yaml index 00a7f5a4..001a9f2f 100644 --- a/docs/openapi/paths/v0_system_logs_stream.yaml +++ b/docs/openapi/paths/v0_system_logs_stream.yaml @@ -3,7 +3,8 @@ get: - Events summary: Live backend log stream (admin only) description: | - Server-Sent Events stream of all backend log lines atDEBUG and above. + Server-Sent Events stream of backend log entries emitted at or above the + current runtime log level. ## Event format @@ -15,7 +16,7 @@ get: ## Event payload Each `syslog` event contains: - - **level** — Log level: `trace`, `debug`, `info`, `warn`, `error`, `fatal` + - **level** — Log level: `error`, `warn`, `info`, `debug`, `verbose`, or `silly` - **message** — Log message text - **timestamp** — ISO 8601 timestamp - **context** — Additional fields (provider, model, requestId, etc.) diff --git a/packages/backend/src/routes/management/__tests__/system-logs.test.ts b/packages/backend/src/routes/management/__tests__/system-logs.test.ts new file mode 100644 index 00000000..c6561e06 --- /dev/null +++ b/packages/backend/src/routes/management/__tests__/system-logs.test.ts @@ -0,0 +1,39 @@ +import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest'; +import Fastify, { FastifyInstance } from 'fastify'; +import { registerSystemLogRoutes } from '../system-logs'; + +vi.mock('../../../utils/logger', async (importOriginal) => { + const actual = await importOriginal(); + return actual; +}); + +import { clearRecentLogsForTesting, logger } from '../../../utils/logger'; + +describe('system log routes', () => { + let fastify: FastifyInstance; + + beforeAll(async () => { + fastify = Fastify(); + await registerSystemLogRoutes(fastify); + await fastify.ready(); + }); + + afterEach(() => { + clearRecentLogsForTesting(); + }); + + test('returns recent system logs', async () => { + logger.info('recent-system-log-test'); + await new Promise((resolve) => setTimeout(resolve, 20)); + + const response = await fastify.inject({ + method: 'GET', + url: '/v0/system/logs/recent?limit=10', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.total).toBeGreaterThanOrEqual(1); + expect(body.data[0].message).toBe('recent-system-log-test'); + }); +}); diff --git a/packages/backend/src/routes/management/config.ts b/packages/backend/src/routes/management/config.ts index 66bf0b31..bd4afe3a 100644 --- a/packages/backend/src/routes/management/config.ts +++ b/packages/backend/src/routes/management/config.ts @@ -200,6 +200,20 @@ export async function registerConfigRoutes( } }); + // Using wildcard to support slugs containing '/' (e.g. "provider/model") + fastify.get('/v0/management/aliases/*', async (request, reply) => { + const slug = (request.params as { '*': string })['*']; + try { + const alias = await configService.getRepository().getAlias(slug); + if (!alias) { + return reply.code(404).send({ error: `Alias '${slug}' not found` }); + } + return reply.send({ slug, ...alias }); + } catch (e: any) { + return reply.code(500).send({ error: 'Internal server error' }); + } + }); + // PUT — full create-or-replace with Zod validation // Using wildcard to support slugs containing '/' (e.g. "provider/model") fastify.put('/v0/management/aliases/*', async (request, reply) => { @@ -297,6 +311,20 @@ export async function registerConfigRoutes( } }); + fastify.get('/v0/management/keys/:name', async (request, reply) => { + const { name } = request.params as { name: string }; + try { + const keys = await configService.getRepository().getAllKeys(); + const key = keys[name]; + if (!key) { + return reply.code(404).send({ error: `API key '${name}' not found` }); + } + return reply.send({ name, ...key }); + } catch (e: any) { + return reply.code(500).send({ error: 'Internal server error' }); + } + }); + // PUT — full create-or-replace with Zod validation fastify.put('/v0/management/keys/:name', async (request, reply) => { const { name } = request.params as { name: string }; @@ -314,6 +342,33 @@ export async function registerConfigRoutes( } }); + fastify.patch('/v0/management/keys/:name', async (request, reply) => { + const { name } = request.params as { name: string }; + const body = request.body as Record | null; + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return reply.code(400).send({ error: 'Object body is required' }); + } + + try { + const keys = await configService.getRepository().getAllKeys(); + const existing = keys[name]; + if (!existing) { + return reply.code(404).send({ error: `API key '${name}' not found` }); + } + const merged = { ...existing, ...body }; + const result = KeyConfigSchema.safeParse(merged); + if (!result.success) { + return reply.code(400).send({ error: 'Validation failed', details: result.error.issues }); + } + await configService.saveKey(name, result.data); + logger.debug(`API key '${name}' updated via API (PATCH)`); + return reply.send({ success: true, name }); + } catch (e: any) { + logger.error(`Failed to patch API key '${name}'`, e); + return reply.code(500).send({ error: 'Internal server error' }); + } + }); + fastify.delete('/v0/management/keys/:name', async (request, reply) => { const { name } = request.params as { name: string }; @@ -788,6 +843,21 @@ export async function registerConfigRoutes( } }); + fastify.get('/v0/management/mcp-servers/:serverName', async (request, reply) => { + const { serverName } = request.params as { serverName: string }; + + try { + const servers = await configService.getRepository().getAllMcpServers(); + const server = servers[serverName]; + if (!server) { + return reply.code(404).send({ error: `MCP server '${serverName}' not found` }); + } + return reply.send({ name: serverName, ...server }); + } catch (e: any) { + return reply.code(500).send({ error: 'Internal server error' }); + } + }); + fastify.put('/v0/management/mcp-servers/:serverName', async (request, reply) => { const { serverName } = request.params as { serverName: string }; @@ -813,6 +883,40 @@ export async function registerConfigRoutes( } }); + fastify.patch('/v0/management/mcp-servers/:serverName', async (request, reply) => { + const { serverName } = request.params as { serverName: string }; + const body = request.body as Record | null; + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return reply.code(400).send({ error: 'Object body is required' }); + } + + if (!validateServerName(serverName)) { + return reply.code(400).send({ + error: + 'Invalid server name. Must be a non-reserved slug (lowercase letters, numbers, hyphens, underscores, 2-63 characters)', + }); + } + + try { + const servers = await configService.getRepository().getAllMcpServers(); + const existing = servers[serverName]; + if (!existing) { + return reply.code(404).send({ error: `MCP server '${serverName}' not found` }); + } + const merged = { ...existing, ...body }; + const result = McpServerConfigSchema.safeParse(merged); + if (!result.success) { + return reply.code(400).send({ error: 'Validation failed', details: result.error.issues }); + } + await configService.saveMcpServer(serverName, result.data); + logger.debug(`MCP server '${serverName}' updated via API (PATCH)`); + return reply.send({ success: true, name: serverName }); + } catch (e: any) { + logger.error(`Failed to patch MCP server '${serverName}'`, e); + return reply.code(500).send({ error: 'Internal server error' }); + } + }); + fastify.delete('/v0/management/mcp-servers/:serverName', async (request, reply) => { const { serverName } = request.params as { serverName: string }; @@ -826,6 +930,37 @@ export async function registerConfigRoutes( } }); + // ─── MCP Server Enabled ────────────────────────────────────────── + + fastify.get('/v0/management/config/mcp-enabled', async (_request, reply) => { + try { + const enabled = await configService.getSetting('mcpEnabled', true); + return reply.send({ enabled }); + } catch (e: any) { + logger.error('Failed to read mcp-enabled setting', e); + return reply.code(500).send({ error: 'Internal server error' }); + } + }); + + fastify.patch('/v0/management/config/mcp-enabled', async (request, reply) => { + const body = request.body as Record | null; + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return reply.code(400).send({ error: 'Object body is required' }); + } + if (typeof body.enabled !== 'boolean') { + return reply.code(400).send({ error: 'enabled must be a boolean' }); + } + + try { + await configService.setSetting('mcpEnabled', body.enabled); + logger.debug(`MCP server ${body.enabled ? 'enabled' : 'disabled'} via API`); + return reply.send({ enabled: body.enabled }); + } catch (e: any) { + logger.error('Failed to update mcp-enabled setting', e); + return reply.code(500).send({ error: 'Internal server error' }); + } + }); + // ─── Quota Checker Types ────────────────────────────────────────── fastify.get('/v0/management/quota-checker-types', async (_request, reply) => { diff --git a/packages/backend/src/routes/management/system-logs.ts b/packages/backend/src/routes/management/system-logs.ts index 1cfec59c..3148d976 100644 --- a/packages/backend/src/routes/management/system-logs.ts +++ b/packages/backend/src/routes/management/system-logs.ts @@ -1,8 +1,18 @@ import { FastifyInstance } from 'fastify'; import { encode } from 'eventsource-encoder'; -import { logEmitter } from '../../utils/logger'; +import { getRecentLogCount, getRecentLogs, logEmitter } from '../../utils/logger'; export async function registerSystemLogRoutes(fastify: FastifyInstance) { + fastify.get('/v0/system/logs/recent', async (request, reply) => { + const query = request.query as { limit?: string }; + const limit = Math.max(1, Math.min(parseInt(query.limit || '100', 10) || 100, 1000)); + + return reply.send({ + data: getRecentLogs(limit).map((entry) => serializeRecentLog(entry)), + total: getRecentLogCount(), + }); + }); + fastify.get('/v0/system/logs/stream', async (request, reply) => { reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', @@ -52,3 +62,42 @@ export async function registerSystemLogRoutes(fastify: FastifyInstance) { cleanup(); }); } + +function serializeRecentLog(entry: unknown): unknown { + const seen = new WeakSet(); + + try { + return JSON.parse( + JSON.stringify(entry, (_key, value) => { + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack, + }; + } + + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return '[Circular]'; + } + seen.add(value); + } + + return value; + }) + ); + } catch { + const fallback = entry as Record | null; + return { + level: fallback && typeof fallback.level === 'string' ? fallback.level : 'unknown', + message: + fallback && typeof fallback.message === 'string' + ? fallback.message + : '[unserializable log entry]', + timestamp: + fallback && typeof fallback.timestamp === 'string' ? fallback.timestamp : undefined, + serialization_error: true, + }; + } +} diff --git a/packages/backend/src/routes/mcp/__tests__/plexus-mcp-routes.test.ts b/packages/backend/src/routes/mcp/__tests__/plexus-mcp-routes.test.ts index 6b0212f4..7b95f1d3 100644 --- a/packages/backend/src/routes/mcp/__tests__/plexus-mcp-routes.test.ts +++ b/packages/backend/src/routes/mcp/__tests__/plexus-mcp-routes.test.ts @@ -1,21 +1,29 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import Fastify, { FastifyInstance } from 'fastify'; import { registerSpy } from '../../../../test/test-utils'; -import { setConfigForTesting } from '../../../config'; +import { getConfig, setConfigForTesting } from '../../../config'; import { registerMcpRoutes } from '../index'; import { McpUsageStorageService } from '../../../services/mcp-proxy/mcp-usage-storage'; +import { UsageStorageService } from '../../../services/usage-storage'; import * as mcpProxyService from '../../../services/mcp-proxy/mcp-proxy-service'; +import { DebugManager } from '../../../services/debug-manager'; +import { CooldownManager } from '../../../services/cooldown-manager'; +import { BackupService } from '../../../services/backup-service'; describe('Plexus management MCP routes', () => { let fastify: FastifyInstance; + let originalInject: FastifyInstance['inject']; let originalAdminKey: string | undefined; let mockMcpUsageStorage: McpUsageStorageService; + let mockUsageStorage: UsageStorageService; + let mockLogLevel = 'info'; beforeAll(async () => { originalAdminKey = process.env.ADMIN_KEY; process.env.ADMIN_KEY = 'test-admin-key'; fastify = Fastify(); + originalInject = fastify.inject.bind(fastify) as FastifyInstance['inject']; mockMcpUsageStorage = { saveRequest: vi.fn(), saveDebugLog: vi.fn(), @@ -24,6 +32,33 @@ describe('Plexus management MCP routes', () => { deleteAllLogs: vi.fn(), } as unknown as McpUsageStorageService; + mockUsageStorage = { + getUsage: vi.fn(async () => ({ + data: [{ requestId: 'req-1', provider: 'openrouter', apiKey: 'test-key' }], + total: 1, + })), + getDb: vi.fn(() => ({ + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + groupBy: vi.fn(() => ({ orderBy: vi.fn(async () => []) })), + })), + })), + })), + })), + deleteUsageLog: vi.fn(async () => true), + deleteAllUsageLogs: vi.fn(async () => true), + getDebugLogs: vi.fn(async () => [ + { requestId: 'req-debug', createdAt: 123, responseStatus: 200 }, + ]), + getDebugLog: vi.fn(async (requestId: string) => + requestId === 'req-debug' ? { requestId, createdAt: 123, responseStatus: 200 } : null + ), + deleteDebugLog: vi.fn(async () => true), + deleteAllDebugLogs: vi.fn(async () => true), + deleteAllErrors: vi.fn(async () => true), + } as unknown as UsageStorageService; + setConfigForTesting({ providers: { openrouter: { @@ -91,6 +126,260 @@ describe('Plexus management MCP routes', () => { }); beforeEach(() => { + DebugManager.getInstance().setEnabled(false); + DebugManager.getInstance().setProviderFilter(null); + mockLogLevel = 'info'; + registerSpy(fastify, 'inject').mockImplementation(async (options: any) => { + const request = typeof options === 'string' ? { url: options, method: 'GET' } : options; + const url = request.url as string; + const method = (request.method ?? 'GET').toUpperCase(); + + if (!url.startsWith('/v0/management/') && !url.startsWith('/v0/system/logs/')) { + return originalInject(request as any); + } + + const parts = url.split('?'); + const path = parts[0] ?? ''; + const queryString = parts[1] ?? ''; + const query = Object.fromEntries(new URLSearchParams(queryString)); + const json = (body: unknown, statusCode: number = 200) => ({ + statusCode, + body: JSON.stringify(body), + rawPayload: Buffer.from(JSON.stringify(body)), + }); + + if (method === 'GET' && path === '/v0/management/config') { + return json(setConfigSnapshot()); + } + if (method === 'GET' && path === '/v0/management/config/export') { + return json(setConfigSnapshot()); + } + if (method === 'GET' && path === '/v0/management/providers') { + return json(setConfigSnapshot().providers ?? {}); + } + if (method === 'GET' && path.startsWith('/v0/management/providers/')) { + const id = decodeURIComponent(path.replace('/v0/management/providers/', '')); + const provider = setConfigSnapshot().providers?.[id]; + return provider ? json(provider) : json({ error: `Provider '${id}' not found` }, 404); + } + if (method === 'GET' && path === '/v0/management/aliases') { + return json(setConfigSnapshot().models ?? {}); + } + if (method === 'GET' && path.startsWith('/v0/management/aliases/')) { + const id = decodeURIComponent(path.replace('/v0/management/aliases/', '')); + const alias = setConfigSnapshot().models?.[id]; + return alias + ? json({ slug: id, ...alias }) + : json({ error: `Alias '${id}' not found` }, 404); + } + if (method === 'GET' && path === '/v0/management/keys') { + return json(setConfigSnapshot().keys ?? {}); + } + if (method === 'GET' && path.startsWith('/v0/management/keys/')) { + const id = decodeURIComponent(path.replace('/v0/management/keys/', '')); + const key = setConfigSnapshot().keys?.[id]; + return key ? json({ name: id, ...key }) : json({ error: `API key '${id}' not found` }, 404); + } + if (method === 'PATCH' && path.startsWith('/v0/management/keys/')) { + const id = decodeURIComponent(path.replace('/v0/management/keys/', '')); + const current = setConfigSnapshot(); + const existing = current.keys?.[id]; + if (!existing) return json({ error: `API key '${id}' not found` }, 404); + setConfigForTesting({ + ...current, + keys: { + ...current.keys, + [id]: { ...existing, ...(request.payload as Record) }, + }, + } as any); + return json({ success: true, name: id }); + } + if (method === 'GET' && path === '/v0/management/user-quotas') { + return json(setConfigSnapshot().user_quotas ?? {}); + } + if (method === 'GET' && path.startsWith('/v0/management/user-quotas/')) { + const id = decodeURIComponent(path.replace('/v0/management/user-quotas/', '')); + const quota = setConfigSnapshot().user_quotas?.[id]; + return quota + ? json({ name: id, ...quota }) + : json({ error: { message: `Quota not found: ${id}`, type: 'not_found_error' } }, 404); + } + if (method === 'GET' && path === '/v0/management/mcp-servers') { + return json(setConfigSnapshot().mcpServers ?? setConfigSnapshot().mcp_servers ?? {}); + } + if (method === 'GET' && path.startsWith('/v0/management/mcp-servers/')) { + const id = decodeURIComponent(path.replace('/v0/management/mcp-servers/', '')); + const server = (setConfigSnapshot().mcpServers ?? setConfigSnapshot().mcp_servers ?? {})[ + id + ]; + return server + ? json({ name: id, ...server }) + : json({ error: `MCP server '${id}' not found` }, 404); + } + if (method === 'GET' && path === '/v0/management/config/failover') { + return json(setConfigSnapshot().failover ?? {}); + } + if (method === 'GET' && path === '/v0/management/config/cooldown') { + return json(setConfigSnapshot().cooldown ?? {}); + } + if (method === 'GET' && path === '/v0/management/config/timeout') { + return json(setConfigSnapshot().timeout ?? {}); + } + if (method === 'GET' && path === '/v0/management/config/stall') { + return json(setConfigSnapshot().stall ?? {}); + } + if (method === 'GET' && path === '/v0/management/config/trusted-proxies') { + return json({ trustedProxies: setConfigSnapshot().trustedProxies ?? [] }); + } + if (method === 'GET' && path === '/v0/management/config/vision-fallthrough') { + return json(setConfigSnapshot().vision_fallthrough ?? {}); + } + if (method === 'GET' && path === '/v0/management/config/background-exploration') { + return json(setConfigSnapshot().backgroundExploration ?? {}); + } + if (method === 'GET' && path === '/v0/management/config/exploration-rate') { + return json({ + performanceExplorationRate: setConfigSnapshot().performanceExplorationRate ?? 0.05, + latencyExplorationRate: setConfigSnapshot().latencyExplorationRate ?? 0.05, + e2ePerformanceExplorationRate: setConfigSnapshot().e2ePerformanceExplorationRate ?? 0.05, + }); + } + if (method === 'GET' && path === '/v0/system/logs/recent') { + return json({ + data: [{ level: 'info', message: 'system-log', timestamp: '2026-01-01 00:00:00' }], + total: 1, + }); + } + if (method === 'GET' && path === '/v0/management/logging/level') { + return json({ + level: mockLogLevel, + startupLevel: 'info', + supportedLevels: ['error', 'warn', 'info', 'debug', 'verbose', 'silly'], + ephemeral: true, + }); + } + if (method === 'PUT' && path === '/v0/management/logging/level') { + mockLogLevel = (request.payload as any)?.level ?? mockLogLevel; + return json({ + level: mockLogLevel, + startupLevel: 'info', + supportedLevels: ['error', 'warn', 'info', 'debug', 'verbose', 'silly'], + ephemeral: true, + }); + } + if (method === 'DELETE' && path === '/v0/management/logging/level') { + mockLogLevel = 'info'; + return json({ + level: mockLogLevel, + startupLevel: 'info', + supportedLevels: ['error', 'warn', 'info', 'debug', 'verbose', 'silly'], + ephemeral: true, + }); + } + if (method === 'GET' && path === '/v0/management/usage') { + return json(await (mockUsageStorage.getUsage as any)({}, { limit: 50, offset: 0 })); + } + if (method === 'GET' && path === '/v0/management/usage/summary') { + return json({ + range: query.range ?? 'day', + series: [], + stats: { totalRequests: 1 }, + today: { requests: 1 }, + }); + } + if (method === 'DELETE' && path === '/v0/management/usage') { + await (mockUsageStorage.deleteAllUsageLogs as any)(); + return json({ + success: true, + olderThanDays: query.olderThanDays ? Number(query.olderThanDays) : null, + }); + } + if (method === 'DELETE' && path.startsWith('/v0/management/usage/')) { + await (mockUsageStorage.deleteUsageLog as any)( + decodeURIComponent(path.replace('/v0/management/usage/', '')) + ); + return json({ success: true }); + } + if (method === 'GET' && path === '/v0/management/debug') { + return json({ + enabled: DebugManager.getInstance().isEnabled(), + enabledGlobal: DebugManager.getInstance().isEnabled(), + enabledKeys: DebugManager.getInstance().getEnabledKeys(), + providers: DebugManager.getInstance().getProviderFilter(), + }); + } + if (method === 'PATCH' && path === '/v0/management/debug') { + const body = (request.payload ?? {}) as any; + if (typeof body.enabled === 'boolean') DebugManager.getInstance().setEnabled(body.enabled); + if (body.providers !== undefined) + DebugManager.getInstance().setProviderFilter(body.providers ?? null); + return json({ + enabled: DebugManager.getInstance().isEnabled(), + enabledGlobal: DebugManager.getInstance().isEnabled(), + enabledKeys: DebugManager.getInstance().getEnabledKeys(), + providers: DebugManager.getInstance().getProviderFilter(), + }); + } + if (method === 'GET' && path === '/v0/management/debug/logs') { + return json(await (mockUsageStorage.getDebugLogs as any)(50, 0)); + } + if (method === 'GET' && path.startsWith('/v0/management/debug/logs/')) { + const id = decodeURIComponent(path.replace('/v0/management/debug/logs/', '')); + const log = await (mockUsageStorage.getDebugLog as any)(id); + return log ? json(log) : json({ error: 'Log not found' }, 404); + } + if (method === 'DELETE' && path === '/v0/management/debug/logs') { + await (mockUsageStorage.deleteAllDebugLogs as any)(); + return json({ success: true }); + } + if (method === 'DELETE' && path.startsWith('/v0/management/debug/logs/')) { + await (mockUsageStorage.deleteDebugLog as any)( + decodeURIComponent(path.replace('/v0/management/debug/logs/', '')) + ); + return json({ success: true }); + } + if (method === 'GET' && path === '/v0/management/backup') { + return json({ + plexus_backup: true, + version: 1, + created_at: '2026-01-01T00:00:00.000Z', + dialect: 'sqlite', + data: { + providers: {}, + models: {}, + keys: {}, + user_quotas: {}, + mcp_servers: {}, + settings: {}, + oauth_credentials: [], + }, + }); + } + if (method === 'POST' && path === '/v0/management/restore') { + return json({ + success: true, + restored: {}, + message: 'Config restore complete. Server is restarting to apply changes.', + }); + } + if (method === 'GET' && path === '/v0/management/cooldowns') { + return json([]); + } + if (method === 'DELETE' && path === '/v0/management/cooldowns') { + return json({ success: true }); + } + if (method === 'DELETE' && path.startsWith('/v0/management/cooldowns/')) { + return json({ success: true }); + } + if (method === 'DELETE' && path === '/v0/management/logs/reset') { + return json({ success: true, message: 'All logs have been reset successfully' }); + } + if (method === 'POST' && path === '/v0/management/restart') { + return json({ success: true, message: 'Server is restarting' }); + } + + return json({ error: `Unhandled management route in test: ${method} ${url}` }, 404); + }); registerSpy(mcpProxyService, 'proxyMcpRequest').mockResolvedValue({ status: 200, headers: { 'content-type': 'application/json' }, @@ -151,6 +440,31 @@ describe('Plexus management MCP routes', () => { expect(mcpProxyService.proxyMcpRequest).not.toHaveBeenCalled(); }); + test('records plexus admin MCP requests in MCP usage logs', async () => { + const response = await postPlexusMcp( + { + method: 'tools/call', + id: 1, + params: { + name: 'plexus_config', + arguments: { operation: 'status' }, + }, + }, + adminHeaders() + ); + + expect(response.statusCode).toBe(200); + expect(mockMcpUsageStorage.saveRequest).toHaveBeenCalled(); + const record = (mockMcpUsageStorage.saveRequest as any).mock.calls.at(-1)?.[0]; + expect(record.server_name).toBe('plexus'); + expect(record.upstream_url).toBe('/mcp/plexus'); + expect(record.method).toBe('POST'); + expect(record.jsonrpc_method).toBe('tools/call'); + expect(record.tool_name).toBe('plexus_config'); + expect(record.api_key).toBe('admin'); + expect(record.response_status).toBe(200); + }); + test('keeps other /mcp/:name gateway routes working', async () => { const response = await fastify.inject({ method: 'POST', @@ -187,8 +501,8 @@ describe('Plexus management MCP routes', () => { 'plexus_debug', 'plexus_mcp_gateway', 'plexus_settings', - 'plexus_operations', 'plexus_system_logs', + 'plexus_operations', ]) ); }); @@ -299,8 +613,272 @@ describe('Plexus management MCP routes', () => { ); const body = parseJsonRpcResponse(response); - expect(body.result.isError).toBe(true); - expect(body.result.structuredContent.error.type).toBe('not_implemented'); + expect(body.result.isError).toBe(false); + expect(body.result.structuredContent.data.success).toBe(true); + }); + + test('implements plexus_usage list', async () => { + const response = await postPlexusMcp( + { + method: 'tools/call', + id: 1, + params: { + name: 'plexus_usage', + arguments: { operation: 'list', query: { limit: 10 } }, + }, + }, + adminHeaders() + ); + const body = parseJsonRpcResponse(response); + + expect(body.result.structuredContent.ok).toBe(true); + expect(body.result.structuredContent.data.total).toBe(1); + expect(mockUsageStorage.getUsage).toHaveBeenCalled(); + }); + + test('implements plexus_debug state and update', async () => { + const updateResponse = await postPlexusMcp( + { + method: 'tools/call', + id: 1, + params: { + name: 'plexus_debug', + arguments: { + operation: 'update', + body: { enabled: true, providers: ['openrouter'] }, + }, + }, + }, + adminHeaders() + ); + const updateBody = parseJsonRpcResponse(updateResponse); + expect(updateBody.result.structuredContent.ok).toBe(true); + expect(updateBody.result.structuredContent.data.enabledGlobal).toBe(true); + expect(updateBody.result.structuredContent.data.providers).toEqual(['openrouter']); + + const stateResponse = await postPlexusMcp( + { + method: 'tools/call', + id: 2, + params: { + name: 'plexus_debug', + arguments: { operation: 'state' }, + }, + }, + adminHeaders() + ); + const stateBody = parseJsonRpcResponse(stateResponse); + expect(stateBody.result.structuredContent.ok).toBe(true); + expect(stateBody.result.structuredContent.data.enabledGlobal).toBe(true); + }); + + test('implements plexus_debug log operations', async () => { + const logsResponse = await postPlexusMcp( + { + method: 'tools/call', + id: 1, + params: { + name: 'plexus_debug', + arguments: { operation: 'logs' }, + }, + }, + adminHeaders() + ); + const logsBody = parseJsonRpcResponse(logsResponse); + expect(logsBody.result.structuredContent.data[0].requestId).toBe('req-debug'); + + const getResponse = await postPlexusMcp( + { + method: 'tools/call', + id: 2, + params: { + name: 'plexus_debug', + arguments: { operation: 'get_log', id: 'req-debug' }, + }, + }, + adminHeaders() + ); + const getBody = parseJsonRpcResponse(getResponse); + expect(getBody.result.structuredContent.data.requestId).toBe('req-debug'); + }); + + test('implements plexus_system_logs recent', async () => { + const response = await postPlexusMcp( + { + method: 'tools/call', + id: 1, + params: { + name: 'plexus_system_logs', + arguments: { operation: 'recent', query: { limit: 10 } }, + }, + }, + adminHeaders() + ); + const body = parseJsonRpcResponse(response); + expect(body.result.structuredContent.ok).toBe(true); + expect(body.result.structuredContent.data.total).toBe(1); + expect(body.result.structuredContent.data.data[0].message).toBe('system-log'); + }); + + test('implements plexus_system_logs level operations', async () => { + const levelResponse = await postPlexusMcp( + { + method: 'tools/call', + id: 1, + params: { + name: 'plexus_system_logs', + arguments: { operation: 'level' }, + }, + }, + adminHeaders() + ); + const levelBody = parseJsonRpcResponse(levelResponse); + expect(levelBody.result.structuredContent.data.level).toBe('info'); + + const setLevelResponse = await postPlexusMcp( + { + method: 'tools/call', + id: 2, + params: { + name: 'plexus_system_logs', + arguments: { operation: 'set_level', body: { level: 'debug' } }, + }, + }, + adminHeaders() + ); + const setLevelBody = parseJsonRpcResponse(setLevelResponse); + expect(setLevelBody.result.structuredContent.data.level).toBe('debug'); + + const resetLevelResponse = await postPlexusMcp( + { + method: 'tools/call', + id: 3, + params: { + name: 'plexus_system_logs', + arguments: { operation: 'reset_level' }, + }, + }, + adminHeaders() + ); + const resetLevelBody = parseJsonRpcResponse(resetLevelResponse); + expect(resetLevelBody.result.structuredContent.data.level).toBe('info'); + }); + + test('implements plexus_operations cooldown operations', async () => { + const listResponse = await postPlexusMcp( + { + method: 'tools/call', + id: 1, + params: { + name: 'plexus_operations', + arguments: { operation: 'list_cooldowns' }, + }, + }, + adminHeaders() + ); + const listBody = parseJsonRpcResponse(listResponse); + expect(listBody.result.structuredContent.ok).toBe(true); + + const clearResponse = await postPlexusMcp( + { + method: 'tools/call', + id: 2, + params: { + name: 'plexus_operations', + arguments: { + operation: 'clear_cooldowns', + destructive: 'acknowledged', + query: { provider: 'openrouter', model: 'openai/gpt-5' }, + }, + }, + }, + adminHeaders() + ); + const clearBody = parseJsonRpcResponse(clearResponse); + expect(clearBody.result.structuredContent.data.success).toBe(true); + }); + + test('implements plexus_operations backup and restart response', async () => { + registerSpy(BackupService.prototype, 'exportConfigBackup').mockResolvedValue({ + plexus_backup: true, + version: 1, + created_at: '2026-01-01T00:00:00.000Z', + dialect: 'sqlite', + data: { + providers: {}, + models: {}, + keys: {}, + user_quotas: {}, + mcp_servers: {}, + settings: {}, + oauth_credentials: [], + }, + }); + + const backupResponse = await postPlexusMcp( + { + method: 'tools/call', + id: 1, + params: { + name: 'plexus_operations', + arguments: { operation: 'backup' }, + }, + }, + adminHeaders() + ); + const backupBody = parseJsonRpcResponse(backupResponse); + expect(backupBody.result.structuredContent.ok).toBe(true); + expect(backupBody.result.structuredContent.data.full).toBe(false); + + const restartResponse = await postPlexusMcp( + { + method: 'tools/call', + id: 2, + params: { + name: 'plexus_operations', + arguments: { operation: 'restart', destructive: 'acknowledged' }, + }, + }, + adminHeaders() + ); + const restartBody = parseJsonRpcResponse(restartResponse); + expect(restartBody.result.structuredContent.ok).toBe(true); + expect(restartBody.result.structuredContent.data.success).toBe(true); + expect(restartBody.result.structuredContent.data.message).toContain('restarting'); + }); + + test('implements plexus_key update through the management shim', async () => { + const updateResponse = await postPlexusMcp( + { + method: 'tools/call', + id: 1, + params: { + name: 'plexus_key', + arguments: { + operation: 'update', + id: 'test-key', + body: { beta: true }, + }, + }, + }, + adminHeaders() + ); + const updateBody = parseJsonRpcResponse(updateResponse); + expect(updateBody.result.structuredContent.ok).toBe(true); + + const getResponse = await postPlexusMcp( + { + method: 'tools/call', + id: 2, + params: { + name: 'plexus_key', + arguments: { operation: 'get', id: 'test-key' }, + }, + }, + adminHeaders() + ); + const getBody = parseJsonRpcResponse(getResponse); + expect(getBody.result.structuredContent.data.beta).toBe(true); }); function postPlexusMcp(payload: Record, headers: Record = {}) { @@ -320,6 +898,10 @@ describe('Plexus management MCP routes', () => { return { 'x-admin-key': 'test-admin-key' }; } + function setConfigSnapshot() { + return structuredClone(getConfig()); + } + function parseJsonRpcResponse(response: Awaited>) { expect(response.statusCode).toBe(200); return JSON.parse(response.body); diff --git a/packages/backend/src/routes/mcp/index.ts b/packages/backend/src/routes/mcp/index.ts index 66997679..2ab7170a 100644 --- a/packages/backend/src/routes/mcp/index.ts +++ b/packages/backend/src/routes/mcp/index.ts @@ -60,7 +60,7 @@ export async function registerMcpRoutes( }); }); - await registerPlexusMcpRoutes(fastify); + await registerPlexusMcpRoutes(fastify, mcpUsageStorage); fastify.register(async (protectedRoutes) => { const auth = createAuthHook(); diff --git a/packages/backend/src/routes/mcp/plexus.ts b/packages/backend/src/routes/mcp/plexus.ts index 716d7b35..a0462bd2 100644 --- a/packages/backend/src/routes/mcp/plexus.ts +++ b/packages/backend/src/routes/mcp/plexus.ts @@ -5,6 +5,9 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import { getConfig, type PlexusConfig } from '../../config'; import { logger } from '../../utils/logger'; +import { ConfigService } from '../../services/config-service'; +import { McpUsageStorageService } from '../../services/mcp-proxy/mcp-usage-storage'; +import { getClientIp } from '../../utils/ip'; import { ManagementAuthError, authenticate, requireAdmin } from '../management/_principal'; const PLEXUS_MANAGEMENT_PROMPT = `Plexus is a unified API gateway for LLMs. It exposes OpenAI- and Anthropic-compatible endpoints, routes requests to configured providers, records usage, and manages provider, model alias, key, quota, debug, and MCP gateway configuration. @@ -17,14 +20,17 @@ Destructive or high-impact operations require destructive: "acknowledged". Secre Common workflows: - Review request activity with plexus_usage list or summary. -- Inspect provider setup with plexus_provider list or get. -- Inspect model routing with plexus_model_alias list or get. -- Inspect inference keys with plexus_key list or get; normal responses redact secrets. -- Check upstream quota state with plexus_quota_checker list, get, history, or check. -- Inspect user quota definitions with plexus_quota list or get. -- Review MCP gateway configuration with plexus_mcp_gateway servers_list. +- Inspect or update provider setup with plexus_provider list, get, put, update, delete, or fetch_models. +- Inspect or update model routing with plexus_model_alias list, get, put, update, delete, or delete_all. +- Inspect or update inference keys with plexus_key list, get, put, update, or delete; normal responses redact secrets. +- Check upstream quota state with plexus_quota_checker types, list, or get. +- Inspect or update user quota definitions with plexus_quota list, get, put, update, or delete. +- Review or update MCP gateway configuration with plexus_mcp_gateway servers_list, get, put, update, or delete. - Inspect general settings with plexus_settings get and a category. +- Inspect recent system logs with plexus_system_logs recent. +- Inspect or change the runtime logging level with plexus_system_logs level, set_level, or reset_level. - Use plexus_debug state before enabling debug tracing. +- Use plexus_operations backup, restore, list_cooldowns, or clear_cooldowns for operational actions. Best practices: - Keep changes narrow and reversible. @@ -43,8 +49,8 @@ const TOOL_NAMES = [ 'plexus_debug', 'plexus_mcp_gateway', 'plexus_settings', - 'plexus_operations', 'plexus_system_logs', + 'plexus_operations', ] as const; const DESTRUCTIVE_OPERATIONS = new Set([ @@ -112,6 +118,11 @@ type ToolResponse = { }; }; +type ManagementShimContext = { + fastify: FastifyInstance; + headers: Record; +}; + class McpToolError extends Error { type: string; code: number; @@ -123,7 +134,10 @@ class McpToolError extends Error { } } -export async function registerPlexusMcpRoutes(fastify: FastifyInstance) { +export async function registerPlexusMcpRoutes( + fastify: FastifyInstance, + mcpUsageStorage: McpUsageStorageService +) { fastify.register(async (plexusMcp) => { plexusMcp.setErrorHandler(async (error, _request, reply) => { if (error instanceof ManagementAuthError) { @@ -132,19 +146,68 @@ export async function registerPlexusMcpRoutes(fastify: FastifyInstance) { throw error; }); + // Reject early when the admin MCP is disabled (before auth, to avoid leaking key validity) + plexusMcp.addHook('preHandler', async (_request, reply) => { + try { + const configService = ConfigService.getInstance(); + const mcpEnabled = await configService.getSetting('mcpEnabled', true); + if (!mcpEnabled) { + return reply.code(418).send({ + error: { + message: 'Plexus Management MCP is disabled. Enable it on the MCP Servers page.', + type: 'mcp_disabled', + }, + }); + } + } catch { + // ConfigService not initialized — default to enabled + } + }); + plexusMcp.addHook('preHandler', authenticate); plexusMcp.addHook('preHandler', requireAdmin); - plexusMcp.post('/mcp/plexus', handlePlexusMcpRequest); - plexusMcp.get('/mcp/plexus', handlePlexusMcpRequest); - plexusMcp.delete('/mcp/plexus', handlePlexusMcpRequest); + plexusMcp.post('/mcp/plexus', (request, reply) => + handlePlexusMcpRequest(request, reply, mcpUsageStorage) + ); + plexusMcp.get('/mcp/plexus', (request, reply) => + handlePlexusMcpRequest(request, reply, mcpUsageStorage) + ); + plexusMcp.delete('/mcp/plexus', (request, reply) => + handlePlexusMcpRequest(request, reply, mcpUsageStorage) + ); }); } -async function handlePlexusMcpRequest(request: FastifyRequest, reply: FastifyReply) { +async function handlePlexusMcpRequest( + request: FastifyRequest, + reply: FastifyReply, + mcpUsageStorage: McpUsageStorageService +) { + const startTime = Date.now(); + const requestId = crypto.randomUUID(); + const sourceIp = getClientIp(request); + const method = request.method as 'POST' | 'GET' | 'DELETE'; + const requestBody = + request.body && typeof request.body === 'object' + ? (request.body as Record) + : undefined; + const jsonrpcMethod = typeof requestBody?.method === 'string' ? requestBody.method : null; + const toolName = + jsonrpcMethod === 'tools/call' && + requestBody?.params && + typeof requestBody.params === 'object' && + typeof (requestBody.params as Record).name === 'string' + ? ((requestBody.params as Record).name as string) + : null; + const shimContext: ManagementShimContext = { + fastify: reply.server, + headers: buildShimHeaders(request), + }; + // The SDK server owns one active transport at a time. A singleton would need // close/reconnect queueing, so stateless per-request servers are simpler and safer. - const server = createPlexusMcpServer(); + const server = createPlexusMcpServer(shimContext); const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true, sessionIdGenerator: undefined, @@ -161,13 +224,32 @@ async function handlePlexusMcpRequest(request: FastifyRequest, reply: FastifyRep } const body = await webResponse.text(); + await mcpUsageStorage.saveRequest({ + request_id: requestId, + created_at: new Date().toISOString(), + start_time: startTime, + duration_ms: Date.now() - startTime, + server_name: 'plexus', + upstream_url: '/mcp/plexus', + method, + jsonrpc_method: jsonrpcMethod, + tool_name: toolName, + api_key: 'admin', + attribution: null, + source_ip: sourceIp, + response_status: webResponse.status, + is_streamed: false, + has_debug: false, + error_code: webResponse.status >= 400 ? 'MCP_ERROR' : null, + error_message: webResponse.status >= 400 ? body || 'MCP request failed' : null, + }); return reply.code(webResponse.status).send(body || undefined); } finally { await server.close(); } } -function createPlexusMcpServer() { +function createPlexusMcpServer(shimContext: ManagementShimContext) { const server = new McpServer( { name: 'plexus-management', @@ -225,14 +307,18 @@ function createPlexusMcpServer() { description: getToolDescription(toolName), inputSchema: ToolInputSchema, }, - async (input) => toToolResult(await handleToolCall(toolName, input as ToolInput)) + async (input) => toToolResult(await handleToolCall(toolName, input as ToolInput, shimContext)) ); } return server; } -async function handleToolCall(toolName: (typeof TOOL_NAMES)[number], input: ToolInput) { +async function handleToolCall( + toolName: (typeof TOOL_NAMES)[number], + input: ToolInput, + shimContext: ManagementShimContext +) { try { if (DESTRUCTIVE_OPERATIONS.has(input.operation)) { requireDestructiveAck(input); @@ -242,30 +328,29 @@ async function handleToolCall(toolName: (typeof TOOL_NAMES)[number], input: Tool switch (toolName) { case 'plexus_config': - return handleConfigTool(input, config); + return handleConfigTool(input, config, shimContext); case 'plexus_provider': - return handleRecordTool(input, config.providers ?? {}, 'provider'); + return handleProviderTool(input, shimContext); case 'plexus_model_alias': - return handleRecordTool(input, config.models ?? {}, 'model_alias'); + return handleModelAliasTool(input, shimContext); case 'plexus_key': - return handleRecordTool(input, config.keys ?? {}, 'key'); + return handleKeyTool(input, shimContext); case 'plexus_quota': - return handleRecordTool(input, config.user_quotas ?? {}, 'quota'); + return handleQuotaTool(input, shimContext); case 'plexus_quota_checker': return handleQuotaCheckerTool(input, config); case 'plexus_mcp_gateway': - return handleMcpGatewayTool(input, config); + return handleMcpGatewayTool(input, shimContext); case 'plexus_settings': - return handleSettingsTool(input, config); + return handleSettingsTool(input, shimContext); + case 'plexus_system_logs': + return handleSystemLogsTool(input, shimContext); case 'plexus_usage': + return handleUsageTool(input, shimContext); case 'plexus_debug': + return handleDebugTool(input, shimContext); case 'plexus_operations': - case 'plexus_system_logs': - throw new McpToolError( - `${toolName} operation '${input.operation}' is not implemented yet.`, - 'not_implemented', - 501 - ); + return handleOperationsTool(input, shimContext); } } catch (error) { if (error instanceof McpToolError) { @@ -276,11 +361,264 @@ async function handleToolCall(toolName: (typeof TOOL_NAMES)[number], input: Tool } } -function handleConfigTool(input: ToolInput, config: PlexusConfig): ToolResponse { +async function handleUsageTool( + input: ToolInput, + shimContext: ManagementShimContext +): Promise { + switch (input.operation) { + case 'list': { + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'GET', + '/v0/management/usage', + undefined, + input.query + ) + ); + } + case 'summary': { + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'GET', + '/v0/management/usage/summary', + undefined, + input.query + ) + ); + } + case 'delete': { + if (!input.id) { + throw new McpToolError('Missing id for usage delete operation.', 'invalid_request', 400); + } + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'DELETE', + `/v0/management/usage/${encodePathPreservingSlashes(input.id)}` + ) + ); + } + case 'delete_all': { + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'DELETE', + '/v0/management/usage', + undefined, + input.query + ) + ); + } + default: + throw unsupportedOperation(input.operation, ['list', 'summary', 'delete', 'delete_all']); + } +} + +async function handleDebugTool( + input: ToolInput, + shimContext: ManagementShimContext +): Promise { + switch (input.operation) { + case 'state': + return successResponse( + input.operation, + await callManagementRoute(shimContext, 'GET', '/v0/management/debug') + ); + case 'update': + return successResponse( + input.operation, + await callManagementRoute(shimContext, 'PATCH', '/v0/management/debug', input.body ?? {}) + ); + case 'logs': + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'GET', + '/v0/management/debug/logs', + undefined, + input.query + ) + ); + case 'get_log': { + if (!input.id) { + throw new McpToolError('Missing id for debug get_log operation.', 'invalid_request', 400); + } + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'GET', + `/v0/management/debug/logs/${encodePathPreservingSlashes(input.id)}` + ) + ); + } + case 'delete_log': { + if (!input.id) { + throw new McpToolError( + 'Missing id for debug delete_log operation.', + 'invalid_request', + 400 + ); + } + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'DELETE', + `/v0/management/debug/logs/${encodePathPreservingSlashes(input.id)}` + ) + ); + } + case 'delete_all_logs': + return successResponse( + input.operation, + await callManagementRoute(shimContext, 'DELETE', '/v0/management/debug/logs') + ); + default: + throw unsupportedOperation(input.operation, [ + 'state', + 'update', + 'logs', + 'get_log', + 'delete_log', + 'delete_all_logs', + ]); + } +} + +async function handleOperationsTool( + input: ToolInput, + shimContext: ManagementShimContext +): Promise { + switch (input.operation) { + case 'backup': { + const full = input.query?.full === true || asOptionalString(input.query?.full) === 'true'; + if (full) { + const archive = await callManagementRoute( + shimContext, + 'GET', + '/v0/management/backup', + undefined, + { full: 'true' }, + true + ); + return successResponse(input.operation, { + full: true, + bytes: archive.byteLength, + contentType: 'application/gzip', + encoding: 'base64', + archive: archive.toString('base64'), + }); + } + return successResponse(input.operation, { + full: false, + backup: await callManagementRoute(shimContext, 'GET', '/v0/management/backup'), + }); + } + case 'restore': { + const body = input.body ?? {}; + if (body.full === true || typeof body.archive === 'string') { + if (typeof body.archive !== 'string') { + throw new McpToolError( + 'body.archive must be a base64 string for full restore.', + 'invalid_request', + 400 + ); + } + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'POST', + '/v0/management/restore', + Buffer.from(body.archive, 'base64'), + undefined, + false, + { 'content-type': 'application/gzip' } + ) + ); + } + if (!body.plexus_backup) { + throw new McpToolError( + 'Invalid backup: missing plexus_backup field', + 'invalid_request', + 400 + ); + } + return successResponse(input.operation, { + ...(await callManagementRoute(shimContext, 'POST', '/v0/management/restore', body)), + }); + } + case 'restart': + return successResponse( + input.operation, + await callManagementRoute(shimContext, 'POST', '/v0/management/restart') + ); + case 'list_cooldowns': + return successResponse( + input.operation, + await callManagementRoute(shimContext, 'GET', '/v0/management/cooldowns') + ); + case 'clear_cooldowns': { + const provider = input.id ?? asOptionalString(input.query?.provider); + const model = asOptionalString(input.query?.model); + return successResponse( + input.operation, + provider + ? await callManagementRoute( + shimContext, + 'DELETE', + `/v0/management/cooldowns/${encodePathPreservingSlashes(provider)}`, + undefined, + model ? { model } : undefined + ) + : await callManagementRoute(shimContext, 'DELETE', '/v0/management/cooldowns') + ); + } + case 'reset_logs': + return successResponse( + input.operation, + await callManagementRoute(shimContext, 'DELETE', '/v0/management/logs/reset') + ); + default: + throw unsupportedOperation(input.operation, [ + 'backup', + 'restore', + 'restart', + 'list_cooldowns', + 'clear_cooldowns', + 'reset_logs', + ]); + } +} + +function asOptionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +async function handleConfigTool( + input: ToolInput, + config: PlexusConfig, + shimContext: ManagementShimContext +): Promise { switch (input.operation) { case 'get': + return successResponse( + input.operation, + redactSecrets(await callManagementRoute(shimContext, 'GET', '/v0/management/config')) + ); case 'export': - return successResponse(input.operation, redactSecrets(config)); + return successResponse( + input.operation, + redactSecrets(await callManagementRoute(shimContext, 'GET', '/v0/management/config/export')) + ); case 'status': return successResponse(input.operation, { providerCount: Object.keys(config.providers ?? {}).length, @@ -294,35 +632,262 @@ function handleConfigTool(input: ToolInput, config: PlexusConfig): ToolResponse } } -function handleRecordTool( +async function handleProviderTool( input: ToolInput, - records: Record, - resourceType: string -): ToolResponse { + shimContext: ManagementShimContext +): Promise { switch (input.operation) { - case 'list': + case 'list': { + const providers = await callManagementRoute(shimContext, 'GET', '/v0/management/providers'); + return successResponse(input.operation, mapRecordResponse(redactSecrets(providers))); + } + case 'get': { + const id = requireId(input, 'provider'); + const provider = await callManagementRoute( + shimContext, + 'GET', + `/v0/management/providers/${encodePathPreservingSlashes(id)}` + ); + return successResponse(input.operation, { id, ...asObject(redactSecrets(provider)) }); + } + case 'put': + case 'create': + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'PUT', + `/v0/management/providers/${encodePathPreservingSlashes(requireId(input, 'provider'))}`, + input.body ?? {} + ) + ); + case 'update': + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'PATCH', + `/v0/management/providers/${encodePathPreservingSlashes(requireId(input, 'provider'))}`, + input.body ?? {} + ) + ); + case 'delete': return successResponse( input.operation, - Object.entries(records).map(([id, value]) => ({ id, ...asObject(redactSecrets(value)) })) + await callManagementRoute( + shimContext, + 'DELETE', + `/v0/management/providers/${encodePathPreservingSlashes(requireId(input, 'provider'))}`, + undefined, + input.query + ) ); + case 'fetch_models': + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'POST', + '/v0/management/providers/fetch-models', + input.body ?? {} + ) + ); + default: + throw unsupportedOperation(input.operation, [ + 'list', + 'get', + 'put', + 'create', + 'update', + 'delete', + 'fetch_models', + ]); + } +} + +async function handleModelAliasTool( + input: ToolInput, + shimContext: ManagementShimContext +): Promise { + switch (input.operation) { + case 'list': { + const aliases = await callManagementRoute(shimContext, 'GET', '/v0/management/aliases'); + return successResponse(input.operation, mapRecordResponse(aliases)); + } case 'get': { - if (!input.id) { - throw new McpToolError( - `Missing id for ${resourceType} get operation.`, - 'invalid_request', - 400 - ); - } - if (!(input.id in records)) { - throw new McpToolError(`${resourceType} '${input.id}' was not found.`, 'not_found', 404); - } - return successResponse(input.operation, { - id: input.id, - ...asObject(redactSecrets(records[input.id])), - }); + const id = requireId(input, 'model_alias'); + const alias = await callManagementRoute( + shimContext, + 'GET', + `/v0/management/aliases/${encodePathPreservingSlashes(id)}` + ); + return successResponse(input.operation, { id, ...stripNamedId(alias, 'slug') }); } + case 'put': + case 'create': + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'PUT', + `/v0/management/aliases/${encodePathPreservingSlashes(requireId(input, 'model_alias'))}`, + input.body ?? {} + ) + ); + case 'update': + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'PATCH', + `/v0/management/aliases/${encodePathPreservingSlashes(requireId(input, 'model_alias'))}`, + input.body ?? {} + ) + ); + case 'delete': + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'DELETE', + `/v0/management/models/${encodePathPreservingSlashes(requireId(input, 'model_alias'))}` + ) + ); + case 'delete_all': + return successResponse( + input.operation, + await callManagementRoute(shimContext, 'DELETE', '/v0/management/models') + ); default: - throw unsupportedOperation(input.operation, ['list', 'get']); + throw unsupportedOperation(input.operation, [ + 'list', + 'get', + 'put', + 'create', + 'update', + 'delete', + 'delete_all', + ]); + } +} + +async function handleKeyTool( + input: ToolInput, + shimContext: ManagementShimContext +): Promise { + switch (input.operation) { + case 'list': { + const keys = await callManagementRoute(shimContext, 'GET', '/v0/management/keys'); + return successResponse(input.operation, mapRecordResponse(redactSecrets(keys))); + } + case 'get': { + const id = requireId(input, 'key'); + const key = await callManagementRoute( + shimContext, + 'GET', + `/v0/management/keys/${encodeURIComponent(id)}` + ); + return successResponse(input.operation, { id, ...stripNamedId(redactSecrets(key), 'name') }); + } + case 'put': + case 'create': + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'PUT', + `/v0/management/keys/${encodeURIComponent(requireId(input, 'key'))}`, + input.body ?? {} + ) + ); + case 'update': + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'PATCH', + `/v0/management/keys/${encodeURIComponent(requireId(input, 'key'))}`, + input.body ?? {} + ) + ); + case 'delete': + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'DELETE', + `/v0/management/keys/${encodeURIComponent(requireId(input, 'key'))}` + ) + ); + default: + throw unsupportedOperation(input.operation, [ + 'list', + 'get', + 'put', + 'create', + 'update', + 'delete', + ]); + } +} + +async function handleQuotaTool( + input: ToolInput, + shimContext: ManagementShimContext +): Promise { + switch (input.operation) { + case 'list': { + const quotas = await callManagementRoute(shimContext, 'GET', '/v0/management/user-quotas'); + return successResponse(input.operation, mapRecordResponse(quotas)); + } + case 'get': { + const id = requireId(input, 'quota'); + const quota = await callManagementRoute( + shimContext, + 'GET', + `/v0/management/user-quotas/${encodeURIComponent(id)}` + ); + return successResponse(input.operation, { id, ...stripNamedId(quota, 'name') }); + } + case 'put': + case 'create': + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'PUT', + `/v0/management/user-quotas/${encodeURIComponent(requireId(input, 'quota'))}`, + input.body ?? {} + ) + ); + case 'update': + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'PATCH', + `/v0/management/user-quotas/${encodeURIComponent(requireId(input, 'quota'))}`, + input.body ?? {} + ) + ); + case 'delete': + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'DELETE', + `/v0/management/user-quotas/${encodeURIComponent(requireId(input, 'quota'))}` + ) + ); + default: + throw unsupportedOperation(input.operation, [ + 'list', + 'get', + 'put', + 'create', + 'update', + 'delete', + ]); } } @@ -369,58 +934,123 @@ function handleQuotaCheckerTool(input: ToolInput, config: PlexusConfig): ToolRes } } -function handleMcpGatewayTool(input: ToolInput, config: PlexusConfig): ToolResponse { - const servers = getMcpServers(config); - +async function handleMcpGatewayTool( + input: ToolInput, + shimContext: ManagementShimContext +): Promise { switch (input.operation) { case 'servers_list': - case 'list': - return successResponse( - input.operation, - Object.entries(servers).map(([id, value]) => ({ id, ...asObject(redactSecrets(value)) })) - ); + case 'list': { + const servers = await callManagementRoute(shimContext, 'GET', '/v0/management/mcp-servers'); + return successResponse(input.operation, mapRecordResponse(redactSecrets(servers))); + } case 'get': { - if (!input.id) { - throw new McpToolError('Missing id for MCP gateway get operation.', 'invalid_request', 400); - } - if (!(input.id in servers)) { - throw new McpToolError(`mcp_gateway server '${input.id}' was not found.`, 'not_found', 404); - } + const id = requireId(input, 'mcp_gateway'); + const server = await callManagementRoute( + shimContext, + 'GET', + `/v0/management/mcp-servers/${encodeURIComponent(id)}` + ); return successResponse(input.operation, { - id: input.id, - ...asObject(redactSecrets(servers[input.id])), + id, + ...stripNamedId(redactSecrets(server), 'name'), }); } + case 'put': + case 'create': + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'PUT', + `/v0/management/mcp-servers/${encodeURIComponent(requireId(input, 'mcp_gateway'))}`, + input.body ?? {} + ) + ); + case 'update': + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'PATCH', + `/v0/management/mcp-servers/${encodeURIComponent(requireId(input, 'mcp_gateway'))}`, + input.body ?? {} + ) + ); + case 'delete': + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'DELETE', + `/v0/management/mcp-servers/${encodeURIComponent(requireId(input, 'mcp_gateway'))}` + ) + ); default: - throw unsupportedOperation(input.operation, ['servers_list', 'list', 'get']); + throw unsupportedOperation(input.operation, [ + 'servers_list', + 'list', + 'get', + 'put', + 'create', + 'update', + 'delete', + ]); } } -function handleSettingsTool(input: ToolInput, config: PlexusConfig): ToolResponse { +async function handleSettingsTool( + input: ToolInput, + shimContext: ManagementShimContext +): Promise { if (input.operation !== 'get') { throw unsupportedOperation(input.operation, ['get']); } - const settings = { - failover: config.failover, - cooldown: config.cooldown, - timeout: config.timeout, - stall: config.stall, - trusted_proxies: config.trustedProxies, - vision_fallthrough: config.vision_fallthrough, - background_exploration: config.backgroundExploration, - exploration: { - performanceExplorationRate: config.performanceExplorationRate, - latencyExplorationRate: config.latencyExplorationRate, - e2ePerformanceExplorationRate: config.e2ePerformanceExplorationRate, - }, - }; + const categories = { + failover: '/v0/management/config/failover', + cooldown: '/v0/management/config/cooldown', + timeout: '/v0/management/config/timeout', + stall: '/v0/management/config/stall', + trusted_proxies: '/v0/management/config/trusted-proxies', + vision_fallthrough: '/v0/management/config/vision-fallthrough', + background_exploration: '/v0/management/config/background-exploration', + exploration: '/v0/management/config/exploration-rate', + } as const; if (!input.category || input.category === 'all') { - return successResponse(input.operation, redactSecrets(settings)); + const [ + failover, + cooldown, + timeout, + stall, + trusted_proxies, + vision_fallthrough, + background_exploration, + exploration, + ] = await Promise.all([ + callManagementRoute(shimContext, 'GET', categories.failover), + callManagementRoute(shimContext, 'GET', categories.cooldown), + callManagementRoute(shimContext, 'GET', categories.timeout), + callManagementRoute(shimContext, 'GET', categories.stall), + callManagementRoute(shimContext, 'GET', categories.trusted_proxies), + callManagementRoute(shimContext, 'GET', categories.vision_fallthrough), + callManagementRoute(shimContext, 'GET', categories.background_exploration), + callManagementRoute(shimContext, 'GET', categories.exploration), + ]); + return successResponse(input.operation, { + failover, + cooldown, + timeout, + stall, + trusted_proxies, + vision_fallthrough, + background_exploration, + exploration, + }); } - if (!(input.category in settings)) { + if (!(input.category in categories)) { throw new McpToolError( `settings category '${input.category}' was not found.`, 'not_found', @@ -430,10 +1060,172 @@ function handleSettingsTool(input: ToolInput, config: PlexusConfig): ToolRespons return successResponse( input.operation, - redactSecrets(settings[input.category as keyof typeof settings]) + await callManagementRoute( + shimContext, + 'GET', + categories[input.category as keyof typeof categories] + ) ); } +async function handleSystemLogsTool( + input: ToolInput, + shimContext: ManagementShimContext +): Promise { + switch (input.operation) { + case 'recent': + return successResponse( + input.operation, + await callManagementRoute( + shimContext, + 'GET', + '/v0/system/logs/recent', + undefined, + input.query + ) + ); + case 'level': + return successResponse( + input.operation, + await callManagementRoute(shimContext, 'GET', '/v0/management/logging/level') + ); + case 'set_level': { + const level = asOptionalString(input.body?.level) ?? asOptionalString(input.query?.level); + if (!level) { + throw new McpToolError( + 'Missing level for set_level operation. Provide body.level or query.level.', + 'invalid_request', + 400 + ); + } + return successResponse( + input.operation, + await callManagementRoute(shimContext, 'PUT', '/v0/management/logging/level', { level }) + ); + } + case 'reset_level': + return successResponse( + input.operation, + await callManagementRoute(shimContext, 'DELETE', '/v0/management/logging/level') + ); + default: + throw unsupportedOperation(input.operation, ['recent', 'level', 'set_level', 'reset_level']); + } +} + +function requireId(input: ToolInput, resourceType: string): string { + if (!input.id) { + throw new McpToolError(`Missing id for ${resourceType} operation.`, 'invalid_request', 400); + } + return input.id; +} + +function mapRecordResponse(records: unknown): Array> { + const object = asObject(records); + return Object.entries(object).map(([id, value]) => ({ id, ...asObject(value) })); +} + +function stripNamedId(value: unknown, key: string): Record { + const object = asObject(value); + const { [key]: _ignored, ...rest } = object; + return rest; +} + +function buildShimHeaders(request: FastifyRequest): Record { + const headers: Record = {}; + const adminKey = request.headers['x-admin-key']; + if (typeof adminKey === 'string') { + headers['x-admin-key'] = adminKey; + } + return headers; +} + +async function callManagementRoute( + shimContext: ManagementShimContext, + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', + path: string, + body?: unknown, + query?: Record, + raw: boolean = false, + extraHeaders?: Record +): Promise { + const url = appendQuery(path, query); + const response: any = await (shimContext.fastify.inject as any)({ + method, + url, + headers: { + ...shimContext.headers, + ...(body !== undefined && !Buffer.isBuffer(body) + ? { 'content-type': 'application/json' } + : {}), + ...extraHeaders, + }, + payload: body as any, + }); + + if (response.statusCode >= 400) { + throw toMcpToolError(response); + } + + if (raw) { + return response.rawPayload; + } + + if (!response.body) { + return {}; + } + + try { + return JSON.parse(response.body); + } catch { + return response.body; + } +} + +function appendQuery(path: string, query?: Record): string { + if (!query) return path; + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null || value === '') continue; + params.set(key, String(value)); + } + const qs = params.toString(); + return qs ? `${path}?${qs}` : path; +} + +function toMcpToolError(response: { statusCode: number; body: string }): McpToolError { + let parsed: any = null; + try { + parsed = response.body ? JSON.parse(response.body) : null; + } catch { + parsed = null; + } + + const message = + parsed?.error?.message ?? + parsed?.error ?? + parsed?.message ?? + (response.body || 'Management request failed'); + const type = + parsed?.error?.type ?? + (response.statusCode === 404 + ? 'not_found' + : response.statusCode === 409 + ? 'conflict_error' + : response.statusCode === 400 + ? 'invalid_request' + : 'server_error'); + const code = parsed?.error?.code ?? response.statusCode; + return new McpToolError(message, type, code); +} + +function encodePathPreservingSlashes(value: string): string { + return value + .split('/') + .map((part) => encodeURIComponent(part)) + .join('/'); +} + function requireDestructiveAck(input: ToolInput) { if (input.destructive !== 'acknowledged') { throw new McpToolError( @@ -532,27 +1324,27 @@ function getToolDescription(toolName: string) { case 'plexus_config': return 'Inspect Plexus configuration and status. Initial operations: get, export, status.'; case 'plexus_provider': - return 'Inspect providers and provider routing configuration. Initial operations: list, get.'; + return 'Inspect and manage providers and provider routing configuration. Operations: list, get, put, create, update, delete, fetch_models.'; case 'plexus_model_alias': - return 'Inspect model aliases, targets, and target groups. Initial operations: list, get.'; + return 'Inspect and manage model aliases, targets, and target groups. Operations: list, get, put, create, update, delete, delete_all.'; case 'plexus_key': - return 'Inspect inference keys with secrets redacted. Initial operations: list, get.'; + return 'Inspect and manage inference keys with secrets redacted. Operations: list, get, put, create, update, delete.'; case 'plexus_quota': - return 'Inspect user quota definitions. Initial operations: list, get.'; + return 'Inspect and manage user quota definitions. Operations: list, get, put, create, update, delete.'; case 'plexus_quota_checker': return 'Inspect upstream provider quota checker configuration. Initial operations: types, list, get.'; case 'plexus_usage': - return 'Review request logs and summaries. Planned operations: list, summary, delete, delete_all.'; + return 'Review request logs and summaries. Operations: list, summary, delete, delete_all.'; case 'plexus_debug': - return 'Review and manage debug tracing. Planned operations: state, update, logs, get_log, delete_log, delete_all_logs.'; + return 'Review and manage debug tracing. Operations: state, update, logs, get_log, delete_log, delete_all_logs.'; case 'plexus_mcp_gateway': - return 'Inspect Plexus upstream MCP gateway configuration. Initial operations: servers_list, list, get.'; + return 'Inspect and manage Plexus upstream MCP gateway configuration. Operations: servers_list, list, get, put, create, update, delete.'; case 'plexus_settings': return 'Inspect Plexus settings by category. Initial operations: get.'; - case 'plexus_operations': - return 'Run high-impact operational actions. Planned operations: backup, restore, restart, list_cooldowns, clear_cooldowns.'; case 'plexus_system_logs': - return 'Access Plexus system logs. Planned operations: recent, stream.'; + return 'Inspect recent Plexus system logs and control runtime log verbosity. Operations: recent, level, set_level, reset_level.'; + case 'plexus_operations': + return 'Run high-impact operational actions. Operations: backup, restore, restart, list_cooldowns, clear_cooldowns, reset_logs.'; default: return 'Plexus management tool.'; } diff --git a/packages/backend/src/utils/logger.ts b/packages/backend/src/utils/logger.ts index 5d90c147..f9b035c7 100644 --- a/packages/backend/src/utils/logger.ts +++ b/packages/backend/src/utils/logger.ts @@ -7,14 +7,35 @@ const { combine, timestamp, printf, colorize, splat, json } = winston.format; export const SUPPORTED_LOG_LEVELS = ['error', 'warn', 'info', 'debug', 'verbose', 'silly'] as const; export type LogLevel = (typeof SUPPORTED_LOG_LEVELS)[number]; +const RECENT_LOG_BUFFER_SIZE = 1000; // Event emitter for streaming logs export const logEmitter = new EventEmitter(); +const recentLogs: any[] = []; + +function appendRecentLog(info: any): void { + recentLogs.push(info); + if (recentLogs.length > RECENT_LOG_BUFFER_SIZE) { + recentLogs.shift(); + } +} + +export const getRecentLogs = (limit: number = 100): any[] => { + const safeLimit = Math.max(1, Math.min(limit, RECENT_LOG_BUFFER_SIZE)); + return recentLogs.slice(-safeLimit).reverse(); +}; + +export const getRecentLogCount = (): number => recentLogs.length; + +export const clearRecentLogsForTesting = (): void => { + recentLogs.length = 0; +}; // Custom transport to emit logs export class StreamTransport extends Transport { override log(info: any, callback: () => void) { setImmediate(() => { + appendRecentLog(info); logEmitter.emit('log', info); }); callback(); diff --git a/packages/frontend/src/lib/api.ts b/packages/frontend/src/lib/api.ts index ab2ae94b..6d691c58 100644 --- a/packages/frontend/src/lib/api.ts +++ b/packages/frontend/src/lib/api.ts @@ -3264,6 +3264,26 @@ export const api = { return res.json(); }, + // ─── MCP Server Enabled ─────────────────────────────────────────── + + /** Fetch current MCP server enabled state. */ + getMcpEnabled: async (): Promise<{ enabled: boolean }> => { + const res = await fetchWithAuth(`${API_BASE}/v0/management/config/mcp-enabled`); + if (!res.ok) throw new Error('Failed to fetch MCP enabled state'); + return res.json(); + }, + + /** Enable or disable the MCP server. */ + patchMcpEnabled: async (enabled: boolean): Promise<{ enabled: boolean }> => { + const res = await fetchWithAuth(`${API_BASE}/v0/management/config/mcp-enabled`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }), + }); + if (!res.ok) throw new Error('Failed to update MCP enabled state'); + return res.json(); + }, + // ─── Stall Detection Settings ──────────────────────────────────── /** Fetch current stall detection settings. */ diff --git a/packages/frontend/src/pages/Mcp.tsx b/packages/frontend/src/pages/Mcp.tsx index e92a6105..cd793e1e 100644 --- a/packages/frontend/src/pages/Mcp.tsx +++ b/packages/frontend/src/pages/Mcp.tsx @@ -41,6 +41,7 @@ export const McpPage: React.FC = () => { const toast = useToast(); const [servers, setServers] = useState>({}); const [isLoading, setIsLoading] = useState(true); + const [mcpEnabled, setMcpEnabled] = useState(true); const [isModalOpen, setIsModalOpen] = useState(false); const [editingServerName, setEditingServerName] = useState(null); const [serverNameInput, setServerNameInput] = useState(''); @@ -83,8 +84,9 @@ export const McpPage: React.FC = () => { const loadData = async () => { setIsLoading(true); try { - const data = await api.getMcpServers(); + const [data, enabled] = await Promise.all([api.getMcpServers(), api.getMcpEnabled()]); setServers(data); + setMcpEnabled(enabled.enabled); } catch (e) { console.error('Failed to load MCP servers', e); } finally { @@ -249,6 +251,17 @@ export const McpPage: React.FC = () => { } }; + const handleToggleMcpEnabled = async (enabled: boolean) => { + setMcpEnabled(enabled); + try { + await api.patchMcpEnabled(enabled); + toast.success(`MCP server ${enabled ? 'enabled' : 'disabled'}`); + } catch (e) { + setMcpEnabled(!enabled); // revert on failure + toast.error((e as Error).message, 'Failed to update MCP server state'); + } + }; + const isValidServerName = (name: string): boolean => { return /^[a-z0-9][a-z0-9-_]{1,62}$/.test(name); }; @@ -369,6 +382,39 @@ export const McpPage: React.FC = () => {
{/* Servers Config Card */} + {/* Plexus Management MCP — global enable/disable, always visible */} +
+
+
+
+ + Plexus Management MCP + + + ADMIN + +
+
+ /mcp/plexus — admin-only management endpoint +
+
+
+ handleToggleMcpEnabled(val)} + size="sm" + /> +
+
+
+ + Toggle for the Plexus Management MCP only. When disabled, /mcp/plexus responds + with HTTP 418, while configured gateway MCP routes under /mcp/:name continue to + work. + +
+
+ {serverNames.length === 0 ? (
No MCP servers configured. Click "Add MCP Server" to create one. @@ -457,6 +503,40 @@ export const McpPage: React.FC = () => { + {/* Plexus Management MCP — fixed row */} + + +
+
Plexus Management MCP
+ + ADMIN + +
+ + + /mcp/plexus — admin-only management endpoint + + + handleToggleMcpEnabled(val)} + size="sm" + /> + + + Disables /mcp/plexus only — gateway /mcp/:name routes still work + + + — + + + {serverNames.map((name) => { const server = servers[name]; const headerCount = server.headers ? Object.keys(server.headers).length : 0;