Files
gitmost/apps/client/src/features/workspace/services/ai-mcp-server-service.ts
T
agent_vscode 1e7bd1f9d2 ci(#476): гейты наблюдаемых свойств перед publish — image-smoke, migration-order на push, allowlist fail-closed, property-тесты
Retrospective of 22.06-10.07 merges showed one recurring miss class: local
logic verified, integration property never checked (#361, #353, #452, #172,
#435). This lands four gates so each of those classes fails BEFORE the
:develop image is pushed:

1. Image boot-smoke in the publish job (develop.yml + scripts/ci/image-smoke.sh):
   the exact image watchtower pulls is booted against postgres/redis services
   before the push — /api/health (startup migrator, #361-boot/#353), auth/setup,
   client dist served, hashed assets immutable + brotli (#452).
2. migration-order gate now also runs on push (test.yml): direct pushes used to
   bypass the PR-only gate; base = event.before, zero-SHA skips, force-push
   fails closed.
3. External-MCP tool allowlist fails closed (#172 class): corrupt stored value
   now reads as [] (deny-all) with an error log instead of null (allow-all);
   [] round-trips as jsonb [] via jsonbBind({preserveEmpty}) and means deny-all
   in the toolset filter. The settings form sends null for an empty tag field
   so existing "unrestricted" servers are not silently narrowed.
4. Property tests for the silent-degradation classes: converter fixpoint
   through the live server path (mcp e2e), and CollabSession cache-key
   stability under per-call fresh tokens (#435/#439 lesson) incl. a negative
   control with the token cache disabled.

Closes #476

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:24:03 +03:00

107 lines
3.2 KiB
TypeScript

import api from "@/lib/api-client";
// External MCP server transports (mirrors the server's MCP_TRANSPORTS).
export type McpTransport = "http" | "sse";
// Admin-facing view of a configured external MCP server.
// SECURITY (§8.10): the auth headers are NEVER returned — only `hasHeaders`
// signals whether any are stored. `toolAllowlist` is null when unrestricted.
export interface IAiMcpServer {
id: string;
name: string;
transport: McpTransport;
url: string;
enabled: boolean;
toolAllowlist: string[] | null;
hasHeaders: boolean;
// Admin-authored guidance injected into the agent system prompt (#180).
// NON-secret, so it IS returned. Null when no guidance is configured.
instructions: string | null;
}
// Create payload. `headers` is write-only: omit => no auth headers.
export interface IAiMcpServerCreate {
name: string;
transport: McpTransport;
url: string;
// Auth headers map (e.g. { Authorization: 'Bearer ...' }). Encrypted on save;
// never returned.
headers?: Record<string, string>;
// Omit/null => no restriction; `[]` is persisted verbatim and means
// deny-all (zero tools) since #476.
toolAllowlist?: string[] | null;
// Admin-authored prompt guidance (#180). Blank => stored as null.
instructions?: string;
enabled?: boolean;
}
// Update payload. Every field is optional (partial update). `headers` semantics:
// - omit -> auth headers unchanged
// - {} (empty) -> auth headers cleared
// - non-empty value -> auth headers replaced
export interface IAiMcpServerUpdate {
id: string;
name?: string;
transport?: McpTransport;
url?: string;
headers?: Record<string, string>;
// Absent => unchanged; null => no restriction; `[]` is persisted verbatim
// and means deny-all (zero tools) since #476.
toolAllowlist?: string[] | null;
// Admin-authored prompt guidance (#180). Absent => unchanged; blank => cleared.
instructions?: string;
enabled?: boolean;
}
// Result of a "Test connection" against a SAVED server (by id).
// The error string is already sanitized server-side; never carries secrets.
export type IAiMcpServerTestResult =
| { ok: true; tools: string[] }
| { ok: false; error: string };
export async function getAiMcpServers(): Promise<IAiMcpServer[]> {
const req = await api.post<IAiMcpServer[]>("/workspace/ai-mcp-servers");
return req.data;
}
export async function createAiMcpServer(
data: IAiMcpServerCreate,
): Promise<IAiMcpServer> {
const req = await api.post<IAiMcpServer>(
"/workspace/ai-mcp-servers/create",
data,
);
return req.data;
}
export async function updateAiMcpServer(
data: IAiMcpServerUpdate,
): Promise<IAiMcpServer> {
const req = await api.post<IAiMcpServer>(
"/workspace/ai-mcp-servers/update",
data,
);
return req.data;
}
export async function deleteAiMcpServer(
id: string,
): Promise<{ success: true }> {
const req = await api.post<{ success: true }>(
"/workspace/ai-mcp-servers/delete",
{ id },
);
return req.data;
}
// Tests a SAVED server by id (the server connects with the stored headers).
export async function testAiMcpServer(
id: string,
): Promise<IAiMcpServerTestResult> {
const req = await api.post<IAiMcpServerTestResult>(
"/workspace/ai-mcp-servers/test",
{ id },
);
return req.data;
}