From b20a3d1f9a113bcb9d9e69f327d18f1dec3da6d3 Mon Sep 17 00:00:00 2001 From: ru-code-dev <53821477+ru-code-dev@users.noreply.github.com> Date: Sun, 14 Jun 2026 14:35:30 -0400 Subject: [PATCH] feat(ru-fork): MCP server management (catalog, bindings, overlay engine) Event-sourced management of MCP servers for qwen sessions: a catalog of server templates (command/args/headers with ${VAR} holes) bound per project, projected to SQLite, and handed to qwen via a per-project settings overlay. Core - packages/mcp-core: config resolver, connectivity probe, structural config identity + overlay fingerprint, tool-policy model. Server - Catalog/bindings as event-sourced commands + projections; config- uniqueness guard, required-var validation, built-in template sync. - Overlay engine: writes the per-project qwen settings overlay (QWEN_CODE_SYSTEM_SETTINGS_PATH) + server allowlist at turn-start; the provider reactor respawns the session only when the overlay fingerprint changes. Whole engine gated by MCP_ENGINE_USE_OVERLAY. - Secrets live server-side only (scrypt + AES-GCM at rest), never in events, projections, or the client. - Ephemeral overlay: the resolved overlay (plaintext secrets) is deleted on every spawn settle and swept on app start/shutdown; a new ACP start timeout (ACP_SESSION_START_TIMEOUT_MS) turns a wedged `qwen --acp` boot into an error so the cleanup reliably fires. - Per-server trust flag, 64-bit dedup hash, readable error toasts. Web - MCP management UI: catalog list + detail, per-project config dialog, shared item card / control cluster. Tests - mcp-core unit tests + server integration (overlay apply & fingerprint, reactor respawn, secret-at-rest, ephemeral deletion, start timeout) plus the mcp-probe harness against real qwen. --- apps/server/package.json | 1 + apps/server/src/atomicWrite.ts | 10 + .../src/auth/Layers/ServerSecretStore.ts | 43 +- .../src/auth/Services/ServerSecretStore.ts | 11 + apps/server/src/auth/secretCrypto.ts | 36 + apps/server/src/config.ts | 22 + apps/server/src/fastShutdown.ts | 13 + .../Layers/OrchestrationEngine.ts | 48 +- .../Layers/ProjectionPipeline.ts | 27 + .../Layers/ProjectionSnapshotQuery.ts | 16 +- .../Layers/ProviderCommandReactor.ts | 191 +- apps/server/src/orchestration/decider.ts | 234 +- apps/server/src/orchestration/projector.ts | 44 + .../Layers/OrchestrationEventStore.ts | 5 +- .../Layers/ProjectionMcpBinding.ts | 124 + .../Layers/ProjectionMcpCatalog.ts | 105 + .../Layers/ProjectionMcpProbeCache.ts | 81 + apps/server/src/persistence/Migrations.ts | 2 + .../src/persistence/Migrations/031_Mcp.ts | 78 + .../src/persistence/Services/McpBinding.ts | 36 + .../src/persistence/Services/McpCatalog.ts | 26 + .../src/persistence/Services/McpProbeCache.ts | 33 + .../Services/OrchestrationCommandReceipts.ts | 3 +- .../src/provider/Layers/CliAcpSupport.ts | 41 +- apps/server/src/provider/Layers/CliAdapter.ts | 54 + .../src/provider/acp/AcpSessionRuntime.ts | 10 + apps/server/src/ru-fork/mcp/McpBuiltins.ts | 82 + .../src/ru-fork/mcp/McpCatalogBuilders.ts | 239 + apps/server/src/ru-fork/mcp/McpInvariants.ts | 95 + apps/server/src/ru-fork/mcp/McpLayers.ts | 44 + apps/server/src/ru-fork/mcp/McpOverlay.ts | 291 ++ .../src/ru-fork/mcp/McpProjectionQuery.ts | 79 + apps/server/src/ru-fork/mcp/McpProjectors.ts | 56 + apps/server/src/ru-fork/mcp/McpReactor.ts | 632 +++ apps/server/src/ru-fork/mcp/McpReadModel.ts | 61 + apps/server/src/ru-fork/mcp/McpRuntime.ts | 135 + apps/server/src/ru-fork/mcp/McpSecretNames.ts | 19 + apps/server/src/ru-fork/mcp/McpSecrets.ts | 182 + apps/server/src/ru-fork/mcp/McpSupervisor.ts | 515 +++ .../src/ru-fork/mcp/mcpBuiltinDefinitions.ts | 70 + apps/server/src/server.ts | 5 + apps/server/src/serverRuntimeStartup.ts | 17 + apps/server/src/timeouts.ts | 13 + apps/server/src/ws.ts | 44 + .../auth/Layers/ServerSecretStore.test.ts | 46 + .../tests/fastShutdownOverlaySweep.test.ts | 62 + .../Layers/OrchestrationEngine.test.ts | 5 + .../Layers/ProviderCommandReactor.test.ts | 17 + .../RuForkProviderCommandReactor.test.ts | 320 ++ .../persistence/Layers/McpProbeCache.test.ts | 108 + .../provider/cliAdapterErrorEngine.test.ts | 58 +- apps/server/tests/provider/fakeAcpCore.ts | 20 +- .../mcp/branch3BackfillOrdering.test.ts | 132 + .../ru-fork/mcp/branch3DeciderGuards.test.ts | 98 + .../tests/ru-fork/mcp/branch3Hash64.test.ts | 25 + .../tests/ru-fork/mcp/branch3Helpers.ts | 235 + .../ru-fork/mcp/branch3MissingSecret.test.ts | 39 + .../tests/ru-fork/mcp/branch3OrphanGc.test.ts | 41 + .../ru-fork/mcp/branch3OverlayGuards.test.ts | 119 + .../ru-fork/mcp/branch3SecretAtRest.test.ts | 24 + .../server/tests/ru-fork/mcp/builtins.test.ts | 595 +++ apps/server/tests/ru-fork/mcp/mcpCore.test.ts | 327 ++ .../tests/ru-fork/mcp/overlayApply.test.ts | 342 ++ .../ru-fork/mcp/overlayFingerprint.test.ts | 179 + .../ru-fork/mcp/overlayRemoveLogging.test.ts | 129 + apps/server/tests/ru-fork/mcp/probe.test.ts | 58 + .../tests/ru-fork/mcp/projectionQuery.test.ts | 136 + .../tests/ru-fork/mcp/reactorEffects.test.ts | 216 + .../ru-fork/mcp/reactorErrorArms.test.ts | 187 + .../tests/ru-fork/mcp/reactorWorker.test.ts | 321 ++ .../mcp/reconciliationLifecycle.test.ts | 466 ++ .../tests/ru-fork/mcp/runtimeSnapshot.test.ts | 129 + .../mcp/runtimeSnapshotResilience.test.ts | 104 + .../tests/ru-fork/mcp/secretsKeep.test.ts | 181 + .../ru-fork/mcp/supervisorDecisions.test.ts | 166 + .../tests/ru-fork/mcp/supervisorProbe.test.ts | 244 + .../ru-fork/mcp/supervisorReconcile.test.ts | 96 + .../tests/ru-fork/mcp/supervisorSweep.test.ts | 141 + .../components/settings/SettingsPanels.tsx | 44 + apps/web/src/hooks/useActiveProject.ts | 75 + apps/web/src/routes/__root.tsx | 15 + .../routes/_chat.$environmentId.$threadId.tsx | 16 +- apps/web/src/routes/_chat.draft.$draftId.tsx | 2 + apps/web/src/routes/_chat.tsx | 25 + apps/web/src/rpc/mcpState.ts | 126 + apps/web/src/rpc/wsRpcClient.ts | 28 + apps/web/src/ru-fork/mcp-manage/adapters.ts | 336 ++ .../components/AddToProjectControl.tsx | 12 +- .../mcp-manage/components/ConfigSummary.tsx | 76 +- .../mcp-manage/components/ExtraArgsField.tsx | 36 + .../components/ExtraHeadersField.tsx | 37 + .../mcp-manage/components/McpItemActions.tsx | 93 + .../mcp-manage/components/McpPanel.tsx | 24 +- .../mcp-manage/components/McpPanelMount.tsx | 31 + .../mcp-manage/components/McpServerDialog.tsx | 385 +- .../components/McpServerItemCard.tsx | 132 + .../components/ProjectBindingRow.tsx | 268 +- .../components/ProjectConfigDialog.tsx | 160 +- .../mcp-manage/components/ProjectsTab.tsx | 47 +- .../mcp-manage/components/RecheckButton.tsx | 49 + .../mcp-manage/components/RegistryDetail.tsx | 160 +- .../mcp-manage/components/RegistryTab.tsx | 207 +- .../components/SecretAwareInput.tsx | 61 + .../components/ServerConfigFields.tsx | 51 +- .../mcp-manage/components/TimeoutField.tsx | 35 + .../mcp-manage/components/VarsEditor.tsx | 188 + .../mcp-manage/components/addMcpParsing.ts | 63 +- .../mcp-manage/components/serverConfigForm.ts | 171 +- apps/web/src/ru-fork/mcp-manage/fakeData.ts | 249 - apps/web/src/ru-fork/mcp-manage/index.ts | 8 +- apps/web/src/ru-fork/mcp-manage/store.ts | 185 +- apps/web/src/ru-fork/mcp-manage/types.ts | 115 +- apps/web/src/ru-fork/mcp-manage/useMcp.ts | 359 ++ apps/web/src/ru-fork/mcp-manage/visuals.ts | 12 + mcp-probe/test.js | 31 + mcp-specs/current/ARCHITECTURE.md | 364 ++ mcp-specs/current/AUDIT.md | 324 ++ mcp-specs/current/DATA-MODEL.md | 236 + mcp-specs/current/FIXES.md | 379 ++ mcp-specs/current/GAP-ANALYSIS.md | 128 + mcp-specs/current/GLOSSARY.md | 60 + mcp-specs/current/HANDOFF.md | 79 + mcp-specs/current/IMPLEMENTATION.md | 463 ++ mcp-specs/current/README.md | 42 + mcp-specs/current/TESTING.md | 141 + mcp-specs/current/WORKING-LOGIC.md | 638 +++ mcp-specs/current/check-mcp-feature.md | 107 + mcp-specs/current/improvments-banch-1.md | 750 +++ mcp-specs/current/improvments-banch-2.md | 495 ++ mcp-specs/current/improvments-banch-3.md | 1492 ++++++ mcp-specs/current/improvments-banch-4.md | 670 +++ mcp-specs/legacy/mcp-final-plan.md | 4078 +++++++++++++++++ mcp-specs/legacy/mcp-impl-progress.md | 90 + mcp-specs/legacy/mcp-progress.md | 170 + mcp-specs/legacy/mcp-vars-redesign.md | 381 ++ packages/contracts/src/index.ts | 1 + packages/contracts/src/orchestration.ts | 122 +- packages/contracts/src/provider.ts | 10 + packages/contracts/src/rpc.ts | 60 + packages/contracts/src/ru-fork/mcp.ts | 345 ++ packages/contracts/src/settings.ts | 25 + packages/mcp-core/package.json | 23 + packages/mcp-core/src/fingerprint.ts | 33 + packages/mcp-core/src/index.ts | 4 + packages/mcp-core/src/probe.ts | 250 + packages/mcp-core/src/resolver.ts | 254 + packages/mcp-core/src/toolPolicy.ts | 24 + .../test-fixtures/fakeMcpStdioServer.mjs | 30 + .../fakeMcpStdioServerWithDescription.mjs | 24 + packages/mcp-core/tsconfig.json | 5 + 150 files changed, 24487 insertions(+), 961 deletions(-) create mode 100644 apps/server/src/auth/secretCrypto.ts create mode 100644 apps/server/src/persistence/Layers/ProjectionMcpBinding.ts create mode 100644 apps/server/src/persistence/Layers/ProjectionMcpCatalog.ts create mode 100644 apps/server/src/persistence/Layers/ProjectionMcpProbeCache.ts create mode 100644 apps/server/src/persistence/Migrations/031_Mcp.ts create mode 100644 apps/server/src/persistence/Services/McpBinding.ts create mode 100644 apps/server/src/persistence/Services/McpCatalog.ts create mode 100644 apps/server/src/persistence/Services/McpProbeCache.ts create mode 100644 apps/server/src/ru-fork/mcp/McpBuiltins.ts create mode 100644 apps/server/src/ru-fork/mcp/McpCatalogBuilders.ts create mode 100644 apps/server/src/ru-fork/mcp/McpInvariants.ts create mode 100644 apps/server/src/ru-fork/mcp/McpLayers.ts create mode 100644 apps/server/src/ru-fork/mcp/McpOverlay.ts create mode 100644 apps/server/src/ru-fork/mcp/McpProjectionQuery.ts create mode 100644 apps/server/src/ru-fork/mcp/McpProjectors.ts create mode 100644 apps/server/src/ru-fork/mcp/McpReactor.ts create mode 100644 apps/server/src/ru-fork/mcp/McpReadModel.ts create mode 100644 apps/server/src/ru-fork/mcp/McpRuntime.ts create mode 100644 apps/server/src/ru-fork/mcp/McpSecretNames.ts create mode 100644 apps/server/src/ru-fork/mcp/McpSecrets.ts create mode 100644 apps/server/src/ru-fork/mcp/McpSupervisor.ts create mode 100644 apps/server/src/ru-fork/mcp/mcpBuiltinDefinitions.ts create mode 100644 apps/server/tests/fastShutdownOverlaySweep.test.ts create mode 100644 apps/server/tests/persistence/Layers/McpProbeCache.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/branch3BackfillOrdering.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/branch3DeciderGuards.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/branch3Hash64.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/branch3Helpers.ts create mode 100644 apps/server/tests/ru-fork/mcp/branch3MissingSecret.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/branch3OrphanGc.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/branch3OverlayGuards.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/branch3SecretAtRest.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/builtins.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/mcpCore.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/overlayApply.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/overlayFingerprint.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/overlayRemoveLogging.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/probe.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/projectionQuery.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/reactorEffects.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/reactorErrorArms.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/reactorWorker.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/reconciliationLifecycle.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/runtimeSnapshot.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/runtimeSnapshotResilience.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/secretsKeep.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/supervisorDecisions.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/supervisorProbe.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/supervisorReconcile.test.ts create mode 100644 apps/server/tests/ru-fork/mcp/supervisorSweep.test.ts create mode 100644 apps/web/src/hooks/useActiveProject.ts create mode 100644 apps/web/src/rpc/mcpState.ts create mode 100644 apps/web/src/ru-fork/mcp-manage/adapters.ts create mode 100644 apps/web/src/ru-fork/mcp-manage/components/ExtraArgsField.tsx create mode 100644 apps/web/src/ru-fork/mcp-manage/components/ExtraHeadersField.tsx create mode 100644 apps/web/src/ru-fork/mcp-manage/components/McpItemActions.tsx create mode 100644 apps/web/src/ru-fork/mcp-manage/components/McpPanelMount.tsx create mode 100644 apps/web/src/ru-fork/mcp-manage/components/McpServerItemCard.tsx create mode 100644 apps/web/src/ru-fork/mcp-manage/components/RecheckButton.tsx create mode 100644 apps/web/src/ru-fork/mcp-manage/components/SecretAwareInput.tsx create mode 100644 apps/web/src/ru-fork/mcp-manage/components/TimeoutField.tsx create mode 100644 apps/web/src/ru-fork/mcp-manage/components/VarsEditor.tsx delete mode 100644 apps/web/src/ru-fork/mcp-manage/fakeData.ts create mode 100644 apps/web/src/ru-fork/mcp-manage/useMcp.ts create mode 100644 mcp-specs/current/ARCHITECTURE.md create mode 100644 mcp-specs/current/AUDIT.md create mode 100644 mcp-specs/current/DATA-MODEL.md create mode 100644 mcp-specs/current/FIXES.md create mode 100644 mcp-specs/current/GAP-ANALYSIS.md create mode 100644 mcp-specs/current/GLOSSARY.md create mode 100644 mcp-specs/current/HANDOFF.md create mode 100644 mcp-specs/current/IMPLEMENTATION.md create mode 100644 mcp-specs/current/README.md create mode 100644 mcp-specs/current/TESTING.md create mode 100644 mcp-specs/current/WORKING-LOGIC.md create mode 100644 mcp-specs/current/check-mcp-feature.md create mode 100644 mcp-specs/current/improvments-banch-1.md create mode 100644 mcp-specs/current/improvments-banch-2.md create mode 100644 mcp-specs/current/improvments-banch-3.md create mode 100644 mcp-specs/current/improvments-banch-4.md create mode 100644 mcp-specs/legacy/mcp-final-plan.md create mode 100644 mcp-specs/legacy/mcp-impl-progress.md create mode 100644 mcp-specs/legacy/mcp-progress.md create mode 100644 mcp-specs/legacy/mcp-vars-redesign.md create mode 100644 packages/contracts/src/ru-fork/mcp.ts create mode 100644 packages/mcp-core/package.json create mode 100644 packages/mcp-core/src/fingerprint.ts create mode 100644 packages/mcp-core/src/index.ts create mode 100644 packages/mcp-core/src/probe.ts create mode 100644 packages/mcp-core/src/resolver.ts create mode 100644 packages/mcp-core/src/toolPolicy.ts create mode 100644 packages/mcp-core/test-fixtures/fakeMcpStdioServer.mjs create mode 100644 packages/mcp-core/test-fixtures/fakeMcpStdioServerWithDescription.mjs create mode 100644 packages/mcp-core/tsconfig.json diff --git a/apps/server/package.json b/apps/server/package.json index 55a5a3e373a..58f67485a7f 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -34,6 +34,7 @@ "@effect/vitest": "catalog:", "@pierre/diffs": "1.1.20", "@ru-fork/branding": "workspace:*", + "@ru-fork/mcp-core": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/web": "workspace:*", diff --git a/apps/server/src/atomicWrite.ts b/apps/server/src/atomicWrite.ts index 764b6781919..045504747c4 100644 --- a/apps/server/src/atomicWrite.ts +++ b/apps/server/src/atomicWrite.ts @@ -6,6 +6,10 @@ import * as Random from "effect/Random"; export const writeFileStringAtomically = (input: { readonly filePath: string; readonly contents: string; + // ru-fork: optional owner-only perms for secret-bearing files (e.g. the MCP + // overlay). Omitted ⇒ the process umask default (unchanged behaviour). + readonly mode?: number; + readonly dirMode?: number; }) => Effect.scoped( Effect.gen(function* () { @@ -15,6 +19,9 @@ export const writeFileStringAtomically = (input: { const targetDirectory = path.dirname(input.filePath); yield* fs.makeDirectory(targetDirectory, { recursive: true }); + if (input.dirMode !== undefined) { + yield* fs.chmod(targetDirectory, input.dirMode); + } const tempDirectory = yield* fs.makeTempDirectoryScoped({ directory: targetDirectory, prefix: `${path.basename(input.filePath)}.`, @@ -22,6 +29,9 @@ export const writeFileStringAtomically = (input: { const tempPath = path.join(tempDirectory, `${tempFileId}.tmp`); yield* fs.writeFileString(tempPath, input.contents); + if (input.mode !== undefined) { + yield* fs.chmod(tempPath, input.mode); + } yield* fs.rename(tempPath, input.filePath); }), ); diff --git a/apps/server/src/auth/Layers/ServerSecretStore.ts b/apps/server/src/auth/Layers/ServerSecretStore.ts index d80242280bd..fc96321cb2e 100644 --- a/apps/server/src/auth/Layers/ServerSecretStore.ts +++ b/apps/server/src/auth/Layers/ServerSecretStore.ts @@ -8,6 +8,7 @@ import * as Predicate from "effect/Predicate"; import * as PlatformError from "effect/PlatformError"; import { ServerConfig } from "../../config.ts"; +import { decryptSecret, encryptSecret } from "../secretCrypto.ts"; import { SecretStoreError, ServerSecretStore, @@ -37,7 +38,6 @@ export const makeServerSecretStore = Effect.gen(function* () { const get: ServerSecretStoreShape["get"] = (name) => fileSystem.readFile(resolveSecretPath(name)).pipe( - Effect.map((bytes) => Uint8Array.from(bytes)), Effect.catch((cause) => cause.reason._tag === "NotFound" ? Effect.succeed(null) @@ -48,13 +48,25 @@ export const makeServerSecretStore = Effect.gen(function* () { }), ), ), + // ru-fork #4: at-rest decryption runs AFTER the read-error catch — a NotFound stays null; a + // tampered/unwrappable blob becomes a typed SecretStoreError (Effect.try converts the throw). + Effect.flatMap((bytes) => + bytes === null + ? Effect.succeed(null) + : Effect.try({ + try: () => decryptSecret(Uint8Array.from(bytes)), + catch: (cause) => + new SecretStoreError({ message: `Failed to decrypt secret ${name}.`, cause }), + }), + ), ); const set: ServerSecretStoreShape["set"] = (name, value) => { const secretPath = resolveSecretPath(name); const tempPath = `${secretPath}.${Crypto.randomUUID()}.tmp`; + const encrypted = encryptSecret(value); // ru-fork #4: at-rest encryption return Effect.gen(function* () { - yield* fileSystem.writeFile(tempPath, value); + yield* fileSystem.writeFile(tempPath, encrypted); yield* fileSystem.chmod(tempPath, 0o600); yield* fileSystem.rename(tempPath, secretPath); yield* fileSystem.chmod(secretPath, 0o600); @@ -77,13 +89,14 @@ export const makeServerSecretStore = Effect.gen(function* () { const create: ServerSecretStoreShape["set"] = (name, value) => { const secretPath = resolveSecretPath(name); + const encrypted = encryptSecret(value); // ru-fork #4: at-rest encryption return Effect.scoped( Effect.gen(function* () { const file = yield* fileSystem.open(secretPath, { flag: "wx", mode: 0o600, }); - yield* file.writeAll(value); + yield* file.writeAll(encrypted); yield* file.sync; yield* fileSystem.chmod(secretPath, 0o600); }), @@ -141,11 +154,35 @@ export const makeServerSecretStore = Effect.gen(function* () { ), ); + const pruneByPrefix: ServerSecretStoreShape["pruneByPrefix"] = (prefix, keep) => + Effect.gen(function* () { + const entries = yield* fileSystem + .readDirectory(serverConfig.secretsDir) + .pipe(Effect.catch(() => Effect.succeed>([]))); + for (const entry of entries) { + if (!entry.endsWith(".bin")) { + continue; + } + const name = entry.slice(0, -".bin".length); + if (!name.startsWith(prefix) || keep.has(name)) { + continue; + } + // ru-fork: best-effort GC. `force` makes an already-gone file a no-op; a real remove failure is + // logged (not lost) but must NOT abort pruning the remaining orphans, so it's swallowed per file. + yield* fileSystem.remove(path.join(serverConfig.secretsDir, entry), { force: true }).pipe( + Effect.catchCause((cause) => + Effect.logDebug("mcp secret prune: failed to remove a secret file", { entry, cause }), + ), + ); + } + }); + return { get, set, getOrCreateRandom, remove, + pruneByPrefix, } satisfies ServerSecretStoreShape; }); diff --git a/apps/server/src/auth/Services/ServerSecretStore.ts b/apps/server/src/auth/Services/ServerSecretStore.ts index f5c6f6dfef4..e4715e46464 100644 --- a/apps/server/src/auth/Services/ServerSecretStore.ts +++ b/apps/server/src/auth/Services/ServerSecretStore.ts @@ -15,6 +15,17 @@ export interface ServerSecretStoreShape { bytes: number, ) => Effect.Effect; readonly remove: (name: string) => Effect.Effect; + /** + * Remove every stored secret whose name starts with `prefix` and is NOT in `keep`. + * Used to GC orphaned MCP var secrets (sibling of the probe-cache `deleteKeysNotIn`). + * A missing store directory / individual removal error is swallowed (best-effort GC). + */ + // ru-fork: best-effort GC of orphaned secret files — never fails (a remove failure is logged, not + // surfaced), so the error channel is `never`. + readonly pruneByPrefix: ( + prefix: string, + keep: ReadonlySet, + ) => Effect.Effect; } export class ServerSecretStore extends Context.Service()( diff --git a/apps/server/src/auth/secretCrypto.ts b/apps/server/src/auth/secretCrypto.ts new file mode 100644 index 00000000000..11ef0392157 --- /dev/null +++ b/apps/server/src/auth/secretCrypto.ts @@ -0,0 +1,36 @@ +// ru-fork #4: at-rest encryption for the ServerSecretStore `.bin` files. Mirrors qwen-code 0.13.1's +// headless file scheme (file-token-storage.ts): scrypt(host+user) → aes-256-gcm, format +// `ivHex:authTagHex:cipherHex`. Host+user-derived key matches qwen's threat model (protects a +// copied-off-host file, not a local same-user attacker). The MCP feature has never shipped, so there +// is no legacy plaintext to migrate. +import * as Crypto from "node:crypto"; +import * as os from "node:os"; + +const deriveKey = (): Buffer => { + const salt = `${os.hostname()}-${os.userInfo().username}-qwen-code`; + return Crypto.scryptSync("qwen-code-oauth", salt, 32); +}; + +// Derived once per process (scrypt is intentionally slow); host/user don't change at runtime. +const KEY = deriveKey(); + +export function encryptSecret(plaintext: Uint8Array): Uint8Array { + const iv = Crypto.randomBytes(16); + const cipher = Crypto.createCipheriv("aes-256-gcm", KEY, iv); + const encrypted = Buffer.concat([cipher.update(Buffer.from(plaintext)), cipher.final()]); + const authTag = cipher.getAuthTag(); + const blob = `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`; + return new TextEncoder().encode(blob); +} + +export function decryptSecret(blob: Uint8Array): Uint8Array { + const parts = new TextDecoder().decode(blob).split(":"); + if (parts.length !== 3) { + throw new Error("Invalid encrypted secret format"); + } + const iv = Buffer.from(parts[0]!, "hex"); + const authTag = Buffer.from(parts[1]!, "hex"); + const decipher = Crypto.createDecipheriv("aes-256-gcm", KEY, iv); + decipher.setAuthTag(authTag); + return new Uint8Array(Buffer.concat([decipher.update(Buffer.from(parts[2]!, "hex")), decipher.final()])); +} diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index dc2f0d3df8a..99a3fef4e0b 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -68,6 +68,16 @@ export const SLASH_COMMAND_NOTIFICATION_METHODS: readonly string[] = */ export const ACP_SERVER_NO_SSL = true; +/** + * MCP_ENGINE_USE_OVERLAY — ru-fork kill-switch for the MCP overlay engine. When + * true (default), starting a qwen ACP session injects the per-project settings + * overlay (`QWEN_CODE_SYSTEM_SETTINGS_PATH`) + server allowlist + * (`--allowed-mcp-server-names`), and the reactor restarts live sessions on + * fingerprint change. When false, spawns are byte-for-byte identical to today + * (no overlay, no allowlist) — qwen falls back to the user's own ~/.qwen config. + */ +export const MCP_ENGINE_USE_OVERLAY = true; + /** * STOP_BUTTON_METHOD — what the Stop button (`thread.turn.interrupt`) * does. "end-force" because CLI builds we ship against can ignore @@ -171,6 +181,14 @@ export interface ServerDerivedPaths { readonly environmentIdPath: string; readonly serverRuntimeStatePath: string; readonly secretsDir: string; + // ru-fork: per-project MCP overlay files consumed by qwen via QWEN_CODE_SYSTEM_SETTINGS_PATH. + readonly mcpOverlayDir: string; + // ru-fork: neutral working directory for MCP probes. Probes only verify + // install/reachability + discover tools (cwd-independent), so `${PROJECT_CWD}` + // resolves here instead of any project dir — one probe (and one cache row) per + // authored config, shared across all projects. qwen still gets the REAL project + // cwd at overlay-write time; this dir is never handed to a live session. + readonly mcpProbeCwd: string; } /** @@ -233,6 +251,8 @@ export const deriveServerPaths = Effect.fn(function* ( environmentIdPath: join(stateDir, "environment-id"), serverRuntimeStatePath: join(stateDir, "server-runtime.json"), secretsDir: join(stateDir, "secrets"), + mcpOverlayDir: join(stateDir, "mcp", "overlays"), + mcpProbeCwd: join(stateDir, "mcp", "probe-cwd"), }; }); @@ -252,6 +272,8 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }), fs.makeDirectory(derivedPaths.providerStatusCacheDir, { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.serverRuntimeStatePath), { recursive: true }), + fs.makeDirectory(derivedPaths.mcpOverlayDir, { recursive: true }), + fs.makeDirectory(derivedPaths.mcpProbeCwd, { recursive: true }), ], { concurrency: "unbounded" }, ); diff --git a/apps/server/src/fastShutdown.ts b/apps/server/src/fastShutdown.ts index ba26cb6e535..580be6298f1 100644 --- a/apps/server/src/fastShutdown.ts +++ b/apps/server/src/fastShutdown.ts @@ -8,6 +8,8 @@ * 2. SIGKILL every PTY (`terminalManager.killAll`). * 3. Remove the persisted runtime-state file so the next launcher run * doesn't see a stale pid. + * 4. ru-fork #4: sweep the MCP overlay dir (plaintext-secret files) — SYNC, + * since this runs in the SIGINT/SIGTERM handler under Effect.runSync. * * Step ordering matters: kill child processes first (those are what * blocks the node process from exiting fast) then drop bookkeeping. @@ -42,4 +44,15 @@ export const runFastShutdownCleanup = Effect.gen(function* () { // Already absent or another writer touched it — fine. } }); + // ru-fork #4: drop every per-project overlay (plaintext secrets) on graceful stop, + // shrinking the on-disk window. SYNC (rmSync) because this runs in the SIGINT/SIGTERM + // handler under Effect.runSync — an async FileSystem.remove would throw here. + // Best-effort; the next start's sweep is the backstop for anything left. + yield* Effect.sync(() => { + try { + nodeFs.rmSync(config.mcpOverlayDir, { recursive: true, force: true }); + } catch { + // Missing or racing writer — fine; start-sweep nets it. + } + }); }); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index cf8407bd212..b85397b396d 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -1,10 +1,11 @@ import type { + McpCatalogAggregateId, OrchestrationEvent, OrchestrationReadModel, ProjectId, ThreadId, } from "@t3tools/contracts"; -import { OrchestrationCommand } from "@t3tools/contracts"; +import { MCP_CATALOG_AGGREGATE_ID, OrchestrationCommand } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as DateTime from "effect/DateTime"; @@ -30,6 +31,8 @@ import { import { toPersistenceSqlError } from "../../persistence/Errors.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepository } from "../../persistence/Services/OrchestrationCommandReceipts.ts"; +import { ServerSecretStore } from "../../auth/Services/ServerSecretStore.ts"; +import { ServerSecretStoreLive } from "../../auth/Layers/ServerSecretStore.ts"; import { OrchestrationCommandInvariantError, OrchestrationCommandPreviouslyRejectedError, @@ -56,8 +59,8 @@ interface CommandEnvelope { } function commandToAggregateRef(command: OrchestrationCommand): { - readonly aggregateKind: "project" | "thread"; - readonly aggregateId: ProjectId | ThreadId; + readonly aggregateKind: "project" | "thread" | "mcp-catalog"; + readonly aggregateId: ProjectId | ThreadId | McpCatalogAggregateId; } { switch (command.type) { case "project.create": @@ -67,6 +70,22 @@ function commandToAggregateRef(command: OrchestrationCommand): { aggregateKind: "project", aggregateId: command.projectId, }; + // ru-fork: MCP catalog commands target the singleton catalog aggregate. + case "mcp.server-add": + case "mcp.server-update": + case "mcp.server-remove": + case "mcp.builtin-sync": + return { + aggregateKind: "mcp-catalog", + aggregateId: MCP_CATALOG_AGGREGATE_ID, + }; + // ru-fork: MCP binding commands are project-scoped (cascade with project.deleted). + case "mcp.binding-set": + case "mcp.binding-remove": + return { + aggregateKind: "project", + aggregateId: command.projectId, + }; default: return { aggregateKind: "thread", @@ -81,6 +100,8 @@ const makeOrchestrationEngine = Effect.gen(function* () { const commandReceiptRepository = yield* OrchestrationCommandReceiptRepository; const projectionPipeline = yield* OrchestrationProjectionPipeline; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + // ru-fork: the decider splits MCP draft secrets into the secret store. + const serverSecretStore = yield* ServerSecretStore; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); let commandReadModel = createEmptyReadModel(yield* nowIso); @@ -151,7 +172,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { const eventBase = yield* decideOrchestrationCommand({ command: envelope.command, readModel: commandReadModel, - }); + }).pipe(Effect.provideService(ServerSecretStore, serverSecretStore)); const eventBases = Array.isArray(eventBase) ? eventBase : [eventBase]; const committedCommand = yield* sql .withTransaction( @@ -275,6 +296,22 @@ const makeOrchestrationEngine = Effect.gen(function* () { error: error.message, }) .pipe(Effect.catch(() => Effect.void)); + // ru-fork: surface the rejection in the terminal too — DEBUG, because an invariant + // failure is an EXPECTED user-input rejection (e.g. a duplicate-config add), not a + // system fault. The same message also reaches the UI (cleaned) and the rejected receipt. + yield* Effect.logDebug("orchestration command rejected", { + commandType: envelope.command.type, + commandId: envelope.command.commandId, + detail: error.message, + }); + } else { + // ru-fork: a genuine, blocking failure (SQL, projection, defect) — log FULL details as + // an ERROR; this is something significantly broken, not expected user input. + yield* Effect.logError("orchestration command failed", { + commandType: envelope.command.type, + commandId: envelope.command.commandId, + cause: Cause.pretty(exit.cause), + }); } } @@ -322,4 +359,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { export const OrchestrationEngineLive = Layer.effect( OrchestrationEngineService, makeOrchestrationEngine, +).pipe( + // ru-fork: the decider splits MCP draft secrets; memoized → shared with auth. + Layer.provideMerge(ServerSecretStoreLive), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f03c711a880..fe4a3d6b05a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -16,6 +16,10 @@ import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../per import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; import { ProjectionPendingApprovalRepository } from "../../persistence/Services/ProjectionPendingApprovals.ts"; import { ProjectionProjectRepository } from "../../persistence/Services/ProjectionProjects.ts"; +import { McpCatalogRepository } from "../../persistence/Services/McpCatalog.ts"; +import { McpBindingRepository } from "../../persistence/Services/McpBinding.ts"; +import { makeMcpBindingProjector, makeMcpCatalogProjector } from "../../ru-fork/mcp/McpProjectors.ts"; +import { McpRepositoriesLive } from "../../ru-fork/mcp/McpLayers.ts"; import { ProjectionStateRepository } from "../../persistence/Services/ProjectionState.ts"; import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts"; import { type ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts"; @@ -64,6 +68,8 @@ export const ORCHESTRATION_PROJECTOR_NAMES = { threadTurns: "projection.thread-turns", checkpoints: "projection.checkpoints", pendingApprovals: "projection.pending-approvals", + mcpCatalog: "projection.mcp-catalog", + mcpBindings: "projection.mcp-bindings", } as const; type ProjectorName = @@ -514,6 +520,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository; const projectionTurnRepository = yield* ProjectionTurnRepository; const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository; + // ru-fork: MCP catalog + binding SQL projections. + const mcpCatalogRepository = yield* McpCatalogRepository; + const mcpBindingRepository = yield* McpBindingRepository; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -1425,11 +1434,27 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } }); + // ru-fork: MCP catalog/binding SQL projectors (bodies in ru-fork/mcp). + const applyMcpCatalogProjection: ProjectorDefinition["apply"] = makeMcpCatalogProjector( + mcpCatalogRepository, + mcpBindingRepository, + ); + const applyMcpBindingProjection: ProjectorDefinition["apply"] = + makeMcpBindingProjector(mcpBindingRepository); + const projectors: ReadonlyArray = [ { name: ORCHESTRATION_PROJECTOR_NAMES.projects, apply: applyProjectsProjection, }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.mcpCatalog, + apply: applyMcpCatalogProjection, + }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.mcpBindings, + apply: applyMcpBindingProjection, + }, { name: ORCHESTRATION_PROJECTOR_NAMES.threadMessages, apply: applyThreadMessagesProjection, @@ -1565,4 +1590,6 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( Layer.provideMerge(ProjectionTurnRepositoryLive), Layer.provideMerge(ProjectionPendingApprovalRepositoryLive), Layer.provideMerge(ProjectionStateRepositoryLive), + // ru-fork: MCP catalog + binding repos (memoized → shared with snapshot query). + Layer.provideMerge(McpRepositoriesLive), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 5a1272bbc1c..c352af2768d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -45,6 +45,9 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; +import { McpCatalogRepository } from "../../persistence/Services/McpCatalog.ts"; +import { McpBindingRepository } from "../../persistence/Services/McpBinding.ts"; +import { McpRepositoriesLive } from "../../ru-fork/mcp/McpLayers.ts"; import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { @@ -247,6 +250,9 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st const makeProjectionSnapshotQuery = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; const repositoryIdentityResolver = yield* RepositoryIdentityResolver; + // ru-fork: MCP catalog + bindings feed the decider's in-memory read model. + const mcpCatalogRepository = yield* McpCatalogRepository; + const mcpBindingRepository = yield* McpBindingRepository; const repositoryIdentityResolutionConcurrency = 4; const resolveRepositoryIdentitiesForProjects = Effect.fn( "ProjectionSnapshotQuery.resolveRepositoryIdentitiesForProjects", @@ -1232,7 +1238,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { .pipe( Effect.flatMap( ([projectRows, threadRows, proposedPlanRows, sessionRows, latestTurnRows, stateRows]) => - Effect.sync(() => { + Effect.gen(function* () { let updatedAt: string | null = null; const projects: OrchestrationProject[] = []; const threads: OrchestrationThread[] = []; @@ -1352,10 +1358,15 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); } + const mcpCatalog = yield* mcpCatalogRepository.listAll(); + const mcpBindings = yield* mcpBindingRepository.listAll(); + return { snapshotSequence: computeSnapshotSequence(stateRows), projects, threads, + mcpCatalog, + mcpBindings, updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", } satisfies OrchestrationReadModel; }), @@ -1985,4 +1996,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { export const OrchestrationProjectionSnapshotQueryLive = Layer.effect( ProjectionSnapshotQuery, makeProjectionSnapshotQuery, +).pipe( + // ru-fork: MCP repos to rebuild the command read model (memoized → shared). + Layer.provideMerge(McpRepositoriesLive), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 4563bd8999a..38d628da95e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -41,6 +41,9 @@ import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; // `ru-fork-instrumental/changes/server-errors-handaling.md`. import { classify, UNRECOGNIZED_DECISION } from "../../ru-fork/cli-errors-handling/recognizers.ts"; import { dispatchCause } from "../../ru-fork/cli-errors-handling/dispatch.ts"; +// ru-fork: per-project MCP overlay resolved at spawn (gated by the kill-switch). +import { MCP_ENGINE_USE_OVERLAY } from "../../config.ts"; +import { McpOverlay } from "../../ru-fork/mcp/McpOverlay.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderDriverKind = Schema.is(ProviderDriverKind); @@ -88,6 +91,8 @@ const serverCommandId = (tag: string): CommandId => const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); +const SESSION_OVERLAY_FINGERPRINT_MAX = 10_000; +const SESSION_OVERLAY_FINGERPRINT_TTL = Duration.minutes(30); const DEFAULT_THREAD_TITLE = "Новый диалог"; export function providerErrorLabel(value: string | undefined): string { @@ -225,6 +230,16 @@ const make = Effect.gen(function* () { const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; + const mcpOverlay = yield* McpOverlay; + // ru-fork: per-thread MCP overlay fingerprint the live session spawned with. At turn-start we + // re-write the overlay and, if its fingerprint differs from what this thread's session spawned + // with, re-spawn (overlayChanged → restart on next turn, history preserved via resume). Bounded + // (capacity + TTL) so it can't grow unbounded; an evicted entry ⇒ a safe respawn on the next turn. + const sessionOverlayFingerprints = yield* Cache.make({ + capacity: SESSION_OVERLAY_FINGERPRINT_MAX, + timeToLive: SESSION_OVERLAY_FINGERPRINT_TTL, + lookup: () => Effect.succeed(""), // unused — access is via getOption + set (mirrors handledTurnStartKeys) + }); const handledTurnStartKeys = yield* Cache.make({ capacity: HANDLED_TURN_START_KEY_MAX, timeToLive: HANDLED_TURN_START_KEY_TTL, @@ -510,11 +525,26 @@ const make = Effect.gen(function* () { projects: project ? [project] : [], }); + // ru-fork: write the per-project MCP overlay ONCE at turn-start (idempotent), so qwen sees the + // current servers + tool policy AND we can compare its fingerprint against what this thread's + // live session spawned with. Best-effort — an overlay failure must not block the turn. + const mcpOverlayResult = MCP_ENGINE_USE_OVERLAY + ? yield* mcpOverlay.writeOverlay(thread.projectId).pipe( + Effect.catch((cause) => + Effect.logError("[mcp] overlay write failed — spawning without MCP overlay", { + threadId, + cause, + }).pipe(Effect.as(null)), + ), + ) + : null; + const currentOverlayFingerprint = mcpOverlayResult?.fingerprint; + const startProviderSession = (input?: { readonly resumeCursor?: unknown; readonly provider?: ProviderDriverKind; - }) => { - return providerService.startSession(threadId, { + }) => + providerService.startSession(threadId, { threadId, ...(preferredProvider ? { provider: preferredProvider } : {}), providerInstanceId: desiredInstanceId, @@ -522,8 +552,13 @@ const make = Effect.gen(function* () { modelSelection: desiredModelSelection, ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, + ...(mcpOverlayResult + ? { + settingsOverlayPath: mcpOverlayResult.overlayPath, + allowedMcpServers: mcpOverlayResult.allowedServerNames, + } + : {}), }); - }; const bindSessionToThread = (session: ProviderSession) => Effect.gen(function* () { @@ -549,69 +584,103 @@ const make = Effect.gen(function* () { }, createdAt, }); + // ru-fork: record the overlay fingerprint this (re)spawn was based on, so the next turn's + // `overlayChanged` check compares against it. + if (currentOverlayFingerprint !== undefined) { + yield* Cache.set(sessionOverlayFingerprints, threadId, currentOverlayFingerprint); + } }); - const existingSessionThreadId = - thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null; - if (existingSessionThreadId) { - // ru-fork: runtimeModeChanged removed from restart triggers — the - // adapter receives live runtimeMode on every sendTurn / respondToRequest - // input (see ProviderSendTurnInput / ProviderRespondToRequestInput), - // so dropdown changes no longer require a session restart. Restart still - // fires for cwd / provider-instance / model changes. - const cwdChanged = effectiveCwd !== activeSession?.cwd; - const sessionModelSwitch = (yield* providerService.getCapabilities(desiredInstanceId)) - .sessionModelSwitch; - const modelChanged = - requestedModelSelection !== undefined && - requestedModelSelection.model !== activeSession?.model; - const instanceChanged = - requestedModelSelection !== undefined && - activeSession?.providerInstanceId !== requestedModelSelection.instanceId; - const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "unsupported"; - - if (!cwdChanged && !instanceChanged && !shouldRestartForModelChange) { - return existingSessionThreadId; + // ru-fork #4: the spawn-decision region — respawn, reuse, or fresh spawn. The overlay + // file (plaintext secrets) is only needed until a freshly-spawned child boots and reads + // it; on a reuse turn nothing reads it at all. So the moment this region settles — + // success, a (now timeout-bounded) start failure, or interrupt — delete the file. The + // restart DECISION uses the in-memory fingerprint, never the file, so deleting it can't + // trigger a spurious respawn next turn (test G1). + const decideAndSpawn = Effect.gen(function* () { + const existingSessionThreadId = + thread.session && thread.session.status !== "stopped" && activeSession ? thread.id : null; + if (existingSessionThreadId) { + // ru-fork: runtimeModeChanged removed from restart triggers — the + // adapter receives live runtimeMode on every sendTurn / respondToRequest + // input (see ProviderSendTurnInput / ProviderRespondToRequestInput), + // so dropdown changes no longer require a session restart. Restart still + // fires for cwd / provider-instance / model changes. + const cwdChanged = effectiveCwd !== activeSession?.cwd; + const sessionModelSwitch = (yield* providerService.getCapabilities(desiredInstanceId)) + .sessionModelSwitch; + const modelChanged = + requestedModelSelection !== undefined && + requestedModelSelection.model !== activeSession?.model; + const instanceChanged = + requestedModelSelection !== undefined && + activeSession?.providerInstanceId !== requestedModelSelection.instanceId; + const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "unsupported"; + // ru-fork: the MCP overlay this thread's live session spawned with vs. the current one. A + // changed overlay (server edited / bound / unbound / extraArgs / tool policy) re-spawns on the + // next turn with resumeCursor — qwen only reads the overlay at spawn. The fingerprint subsumes + // the allow-list (removing an MCP changes it). Items 8 & 9 dissolve: a stale first spawn + // self-heals on the next turn. + const spawnFingerprint = Option.getOrUndefined( + yield* Cache.getOption(sessionOverlayFingerprints, threadId), + ); + const overlayChanged = + currentOverlayFingerprint !== undefined && spawnFingerprint !== currentOverlayFingerprint; + + if (!cwdChanged && !instanceChanged && !shouldRestartForModelChange && !overlayChanged) { + return existingSessionThreadId; + } + + const resumeCursor = shouldRestartForModelChange + ? undefined + : (activeSession?.resumeCursor ?? undefined); + yield* Effect.logInfo("provider command reactor restarting provider session", { + threadId, + existingSessionThreadId, + currentProvider: activeSession?.provider, + currentInstanceId, + desiredInstanceId, + desiredProvider: desiredModelSelection.instanceId, + currentRuntimeMode: activeSession?.runtimeMode, + desiredRuntimeMode: thread.runtimeMode, + previousCwd: activeSession?.cwd, + desiredCwd: effectiveCwd, + cwdChanged, + modelChanged, + instanceChanged, + shouldRestartForModelChange, + // ru-fork: the MCP overlay trigger — a changed overlay fingerprint re-spawns the session so qwen + // re-reads the overlay. Logged here so a restart caused by an MCP config change is visible. + overlayChanged, + spawnOverlayFingerprint: spawnFingerprint, + currentOverlayFingerprint, + hasResumeCursor: resumeCursor !== undefined, + }); + const restartedSession = yield* startProviderSession( + resumeCursor !== undefined ? { resumeCursor } : undefined, + ); + yield* Effect.logInfo("provider command reactor restarted provider session", { + threadId, + previousSessionId: existingSessionThreadId, + restartedSessionThreadId: restartedSession.threadId, + provider: restartedSession.provider, + runtimeMode: restartedSession.runtimeMode, + cwd: restartedSession.cwd, + }); + yield* bindSessionToThread(restartedSession); + return restartedSession.threadId; } - const resumeCursor = shouldRestartForModelChange - ? undefined - : (activeSession?.resumeCursor ?? undefined); - yield* Effect.logInfo("provider command reactor restarting provider session", { - threadId, - existingSessionThreadId, - currentProvider: activeSession?.provider, - currentInstanceId, - desiredInstanceId, - desiredProvider: desiredModelSelection.instanceId, - currentRuntimeMode: activeSession?.runtimeMode, - desiredRuntimeMode: thread.runtimeMode, - previousCwd: activeSession?.cwd, - desiredCwd: effectiveCwd, - cwdChanged, - modelChanged, - instanceChanged, - shouldRestartForModelChange, - hasResumeCursor: resumeCursor !== undefined, - }); - const restartedSession = yield* startProviderSession( - resumeCursor !== undefined ? { resumeCursor } : undefined, - ); - yield* Effect.logInfo("provider command reactor restarted provider session", { - threadId, - previousSessionId: existingSessionThreadId, - restartedSessionThreadId: restartedSession.threadId, - provider: restartedSession.provider, - runtimeMode: restartedSession.runtimeMode, - cwd: restartedSession.cwd, - }); - yield* bindSessionToThread(restartedSession); - return restartedSession.threadId; - } + const startedSession = yield* startProviderSession(undefined); + yield* bindSessionToThread(startedSession); + return startedSession.threadId; + }); - const startedSession = yield* startProviderSession(undefined); - yield* bindSessionToThread(startedSession); - return startedSession.threadId; + return yield* (mcpOverlayResult === null + ? decideAndSpawn + : decideAndSpawn.pipe( + Effect.ensuring(mcpOverlay.deleteOverlayFile(mcpOverlayResult.overlayPath)), + )); }); const buildSendTurnRequestForThread = Effect.fnUntraced(function* (input: { diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 1004c945dbf..c4b36a9171d 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -3,9 +3,28 @@ import type { OrchestrationEvent, OrchestrationReadModel, } from "@t3tools/contracts"; +import { MCP_CATALOG_AGGREGATE_ID } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import { type SecretStoreError, ServerSecretStore } from "../auth/Services/ServerSecretStore.ts"; +import { + applyServerUpdate, + buildAddedServer, + buildBinding, + buildSyncedBuiltin, + mergeTemplateVars, + resolveBindingVarValues, +} from "../ru-fork/mcp/McpCatalogBuilders.ts"; +import { + findBinding, + findCatalogServerById, + requireCatalogConfigUnique, + requireCatalogServer, + requireCatalogServerAbsent, +} from "../ru-fork/mcp/McpInvariants.ts"; +import { configIdentity, configPlaceholders } from "@ru-fork/mcp-core"; +import { splitServerVars } from "../ru-fork/mcp/McpSecrets.ts"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; import { listThreadsByProjectId, @@ -52,7 +71,11 @@ const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({ }: { readonly commands: ReadonlyArray; readonly readModel: OrchestrationReadModel; -}): Effect.fn.Return, OrchestrationCommandInvariantError> { +}): Effect.fn.Return< + ReadonlyArray, + OrchestrationCommandInvariantError | SecretStoreError, + ServerSecretStore +> { let nextReadModel = readModel; let nextSequence = readModel.snapshotSequence; const plannedEvents: PlannedOrchestrationEvent[] = []; @@ -82,7 +105,11 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }: { readonly command: OrchestrationCommand; readonly readModel: OrchestrationReadModel; -}): Effect.fn.Return { +}): Effect.fn.Return< + DecideOrchestrationCommandResult, + OrchestrationCommandInvariantError | SecretStoreError, + ServerSecretStore +> { switch (command.type) { case "project.create": { yield* requireProjectAbsent({ @@ -732,6 +759,209 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + // ── ru-fork: MCP catalog commands (aggregate = mcp-catalog) ────────────── + case "mcp.server-add": { + yield* requireCatalogServerAbsent({ readModel, command, serverId: command.serverId }); + // ru-fork #2: a server's identity is its config, not its name — reject a duplicate of any + // existing catalog server (incl. built-ins) BEFORE writing secrets. + yield* requireCatalogConfigUnique({ + readModel, + command, + identity: configIdentity( + command.draft.config, + command.draft.vars, + command.draft.extraArgs ?? [], + command.draft.extraHeaders ?? {}, + ), + excludeServerId: null, + }); + // ru-fork #8: every ${VAR} in the config must resolve to a declared var (or ${PROJECT_CWD}). + { + const declaredVarNames = new Set(command.draft.vars.map((variable) => variable.name)); + const danglingPlaceholders = [...configPlaceholders(command.draft.config)].filter( + (name) => !declaredVarNames.has(name), + ); + if (danglingPlaceholders.length > 0) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Шаблон ссылается на необъявленные переменные: ${danglingPlaceholders.join(", ")}.`, + }); + } + } + const vars = yield* splitServerVars(command.serverId, command.draft.vars, []); + return { + ...withEventBase({ + aggregateKind: "mcp-catalog", + aggregateId: MCP_CATALOG_AGGREGATE_ID, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "mcp.server-added", + payload: { server: buildAddedServer(command.serverId, command.draft, vars, command.createdAt) }, + }; + } + + case "mcp.server-update": { + const existing = yield* requireCatalogServer({ readModel, command, serverId: command.serverId }); + const occurredAt = yield* nowIso; + // Identity lock: a template's command and shipped var declarations are read-only. A patch that + // would change the command is rejected; configuring (extraArgs, var values, user vars) is allowed. + if (existing.locked && command.patch.config !== undefined) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `MCP server '${command.serverId}' is a locked template; its command cannot be edited.`, + }); + } + // ru-fork #2: config-uniqueness on edit — only when a config-affecting field changes. Compute the + // RESULTING identity and reject if it collides with a DIFFERENT server (exclude self). + if ( + command.patch.config !== undefined || + command.patch.vars !== undefined || + command.patch.extraArgs !== undefined || + command.patch.extraHeaders !== undefined + ) { + yield* requireCatalogConfigUnique({ + readModel, + command, + identity: configIdentity( + command.patch.config ?? existing.config, + command.patch.vars ?? existing.vars, + command.patch.extraArgs ?? existing.extraArgs, + command.patch.extraHeaders ?? existing.extraHeaders, + ), + excludeServerId: command.serverId, + }); + } + const vars = command.patch.vars + ? yield* mergeTemplateVars(command.serverId, existing, command.patch.vars) + : existing.vars; + return { + ...withEventBase({ + aggregateKind: "mcp-catalog", + aggregateId: MCP_CATALOG_AGGREGATE_ID, + occurredAt, + commandId: command.commandId, + }), + type: "mcp.server-updated", + payload: { server: applyServerUpdate(existing, command.patch, vars, occurredAt) }, + }; + } + + // ru-fork: migrator → catalog. Add/update a managed built-in by its shipped definition (3-way + // merge preserving user data); emits the existing added/updated event (no fork, no new event). + case "mcp.builtin-sync": { + const existing = findCatalogServerById(readModel, command.serverId); // undefined ⇒ add + const occurredAt = yield* nowIso; + return { + ...withEventBase({ + aggregateKind: "mcp-catalog", + aggregateId: MCP_CATALOG_AGGREGATE_ID, + occurredAt, + commandId: command.commandId, + }), + type: existing ? "mcp.server-updated" : "mcp.server-added", + payload: { + server: buildSyncedBuiltin({ + serverId: command.serverId, + builtinId: command.builtinId, + builtinHash: command.builtinHash, + name: command.name, + description: command.description, + websiteUrl: command.websiteUrl, + config: command.config, + shippedVars: command.shippedVars, + timeoutMs: command.timeoutMs, + existing, + occurredAt, + }), + }, + }; + } + + case "mcp.server-remove": { + yield* requireCatalogServer({ readModel, command, serverId: command.serverId }); + const occurredAt = yield* nowIso; + return { + ...withEventBase({ + aggregateKind: "mcp-catalog", + aggregateId: MCP_CATALOG_AGGREGATE_ID, + occurredAt, + commandId: command.commandId, + }), + type: "mcp.server-removed", + payload: { serverId: command.serverId, removedAt: occurredAt }, + }; + } + + // ── ru-fork: MCP binding commands (aggregate = project) ─────────────────── + case "mcp.binding-set": { + yield* requireProject({ readModel, command, projectId: command.projectId }); + yield* requireCatalogServer({ readModel, command, serverId: command.serverId }); + const existing = findBinding(readModel, command.projectId, command.serverId); + const server = yield* requireCatalogServer({ readModel, command, serverId: command.serverId }); + // ru-fork #8: reject binding varValues whose keys are not declared vars (catches typos that + // would otherwise be stored and silently ignored). + { + const declaredNames = new Set(server.vars.map((declared) => declared.name)); + const unknownKeys = Object.keys(command.patch.varValues ?? {}).filter( + (key) => !declaredNames.has(key), + ); + if (unknownKeys.length > 0) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Неизвестные переменные для сервера «${server.name}»: ${unknownKeys.join(", ")}.`, + }); + } + } + const occurredAt = yield* nowIso; + const varValues = yield* resolveBindingVarValues({ + patch: command.patch.varValues, + keepNames: command.patch.keepVarValues, + existing: existing?.varValues ?? {}, + vars: server.vars, + projectId: command.projectId, + serverId: command.serverId, + }); + return { + ...withEventBase({ + aggregateKind: "project", + aggregateId: command.projectId, + occurredAt, + commandId: command.commandId, + }), + type: "mcp.binding-set", + payload: { + binding: buildBinding({ + projectId: command.projectId, + serverId: command.serverId, + patch: command.patch, + existing, + varValues, + occurredAt, + }), + }, + }; + } + + case "mcp.binding-remove": { + yield* requireProject({ readModel, command, projectId: command.projectId }); + const occurredAt = yield* nowIso; + return { + ...withEventBase({ + aggregateKind: "project", + aggregateId: command.projectId, + occurredAt, + commandId: command.commandId, + }), + type: "mcp.binding-removed", + payload: { + projectId: command.projectId, + serverId: command.serverId, + removedAt: occurredAt, + }, + }; + } + default: { command satisfies never; const fallback = command as never as { type: string }; diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index fd8362ae6af..918bdd65493 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -8,6 +8,14 @@ import { import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; +import { + removeMcpBinding, + removeMcpBindingsByProject, + removeMcpBindingsByServer, + removeMcpServer, + upsertMcpBinding, + upsertMcpServer, +} from "../ru-fork/mcp/McpReadModel.ts"; import { toProjectorDecodeError, type OrchestrationProjectorDecodeError } from "./Errors.ts"; import { MessageSentPayloadSchema, @@ -160,6 +168,8 @@ export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { snapshotSequence: 0, projects: [], threads: [], + mcpCatalog: [], + mcpBindings: [], updatedAt: nowIso, }; } @@ -237,6 +247,8 @@ export function projectEvent( } : project, ), + // ru-fork: cascade — a deleted project's MCP bindings die with it. + mcpBindings: removeMcpBindingsByProject(nextBase.mcpBindings, payload.projectId), })), ); @@ -654,6 +666,38 @@ export function projectEvent( }), ); + // ru-fork: MCP catalog + bindings folds. event.payload is already validated + // by the event union discriminant, so no defensive decode is needed here. + case "mcp.server-added": + case "mcp.server-updated": + return Effect.succeed({ + ...nextBase, + mcpCatalog: upsertMcpServer(nextBase.mcpCatalog, event.payload.server), + }); + + case "mcp.server-removed": + return Effect.succeed({ + ...nextBase, + mcpCatalog: removeMcpServer(nextBase.mcpCatalog, event.payload.serverId), + mcpBindings: removeMcpBindingsByServer(nextBase.mcpBindings, event.payload.serverId), + }); + + case "mcp.binding-set": + return Effect.succeed({ + ...nextBase, + mcpBindings: upsertMcpBinding(nextBase.mcpBindings, event.payload.binding), + }); + + case "mcp.binding-removed": + return Effect.succeed({ + ...nextBase, + mcpBindings: removeMcpBinding( + nextBase.mcpBindings, + event.payload.projectId, + event.payload.serverId, + ), + }); + default: return Effect.succeed(nextBase); } diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index 18d0e9aa578..7efd2b8e40e 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -2,6 +2,7 @@ import { CommandId, EventId, IsoDateTime, + McpCatalogAggregateId, NonNegativeInt, OrchestrationActorKind, OrchestrationAggregateKind, @@ -35,7 +36,7 @@ const EventMetadataFromJsonString = Schema.fromJsonString(OrchestrationEventMeta const AppendEventRequestSchema = Schema.Struct({ eventId: EventId, aggregateKind: OrchestrationAggregateKind, - streamId: Schema.Union([ProjectId, ThreadId]), + streamId: Schema.Union([ProjectId, ThreadId, McpCatalogAggregateId]), type: OrchestrationEventType, causationEventId: Schema.NullOr(EventId), correlationId: Schema.NullOr(CommandId), @@ -51,7 +52,7 @@ const OrchestrationEventPersistedRowSchema = Schema.Struct({ eventId: EventId, type: OrchestrationEventType, aggregateKind: OrchestrationAggregateKind, - aggregateId: Schema.Union([ProjectId, ThreadId]), + aggregateId: Schema.Union([ProjectId, ThreadId, McpCatalogAggregateId]), occurredAt: IsoDateTime, commandId: Schema.NullOr(CommandId), causationEventId: Schema.NullOr(EventId), diff --git a/apps/server/src/persistence/Layers/ProjectionMcpBinding.ts b/apps/server/src/persistence/Layers/ProjectionMcpBinding.ts new file mode 100644 index 00000000000..e68108c9135 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionMcpBinding.ts @@ -0,0 +1,124 @@ +import { McpBinding, McpToolPolicy, McpVarValue, NonNegativeInt } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + BindingsByProjectInput, + BindingsByServerInput, + McpBindingRepository, + RemoveBindingInput, + type McpBindingRepositoryShape, +} from "../Services/McpBinding.ts"; + +// `enabled` is stored as 0/1 (SQLite has no boolean); JSON columns decode to +// their domain shapes. We read the row as-is then convert enabled → boolean. +const McpBindingDbRow = McpBinding.mapFields( + Struct.assign({ + enabled: NonNegativeInt, + toolPolicy: Schema.fromJsonString(McpToolPolicy), + varValues: Schema.fromJsonString(Schema.Record(Schema.String, McpVarValue)), + }), +); +type McpBindingDbRow = typeof McpBindingDbRow.Type; + +function rowToBinding(row: McpBindingDbRow): McpBinding { + return { ...row, enabled: row.enabled !== 0 }; +} + +const makeMcpBindingRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertRow = SqlSchema.void({ + Request: McpBinding, + execute: (binding) => + sql` + INSERT INTO mcp_project_binding ( + project_id, server_id, enabled, tool_policy_json, var_values_json, timeout_ms, + created_at, updated_at + ) VALUES ( + ${binding.projectId}, ${binding.serverId}, ${binding.enabled ? 1 : 0}, + ${JSON.stringify(binding.toolPolicy)}, ${JSON.stringify(binding.varValues)}, + ${binding.timeoutMs}, ${binding.createdAt}, ${binding.updatedAt} + ) + ON CONFLICT (project_id, server_id) DO UPDATE SET + enabled = excluded.enabled, + tool_policy_json = excluded.tool_policy_json, + var_values_json = excluded.var_values_json, + timeout_ms = excluded.timeout_ms, + updated_at = excluded.updated_at + `, + }); + + const listByProjectRows = SqlSchema.findAll({ + Request: BindingsByProjectInput, + Result: McpBindingDbRow, + execute: ({ projectId }) => + sql` + SELECT project_id AS "projectId", server_id AS "serverId", enabled, + tool_policy_json AS "toolPolicy", var_values_json AS "varValues", + timeout_ms AS "timeoutMs", created_at AS "createdAt", updated_at AS "updatedAt" + FROM mcp_project_binding WHERE project_id = ${projectId} + ORDER BY created_at ASC, server_id ASC + `, + }); + + const listAllRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: McpBindingDbRow, + execute: () => + sql` + SELECT project_id AS "projectId", server_id AS "serverId", enabled, + tool_policy_json AS "toolPolicy", var_values_json AS "varValues", + timeout_ms AS "timeoutMs", created_at AS "createdAt", updated_at AS "updatedAt" + FROM mcp_project_binding ORDER BY project_id ASC, server_id ASC + `, + }); + + const removeRow = SqlSchema.void({ + Request: RemoveBindingInput, + execute: ({ projectId, serverId }) => + sql`DELETE FROM mcp_project_binding WHERE project_id = ${projectId} AND server_id = ${serverId}`, + }); + + const removeByProjectRow = SqlSchema.void({ + Request: BindingsByProjectInput, + execute: ({ projectId }) => sql`DELETE FROM mcp_project_binding WHERE project_id = ${projectId}`, + }); + + const removeByServerRow = SqlSchema.void({ + Request: BindingsByServerInput, + execute: ({ serverId }) => sql`DELETE FROM mcp_project_binding WHERE server_id = ${serverId}`, + }); + + return { + upsert: (binding) => + upsertRow(binding).pipe(Effect.mapError(toPersistenceSqlError("McpBinding.upsert"))), + remove: (input) => + removeRow(input).pipe(Effect.mapError(toPersistenceSqlError("McpBinding.remove"))), + listByProject: (input) => + listByProjectRows(input).pipe( + Effect.map((rows) => rows.map(rowToBinding)), + Effect.mapError(toPersistenceSqlError("McpBinding.listByProject")), + ), + removeByProject: (input) => + removeByProjectRow(input).pipe( + Effect.mapError(toPersistenceSqlError("McpBinding.removeByProject")), + ), + removeByServer: (input) => + removeByServerRow(input).pipe( + Effect.mapError(toPersistenceSqlError("McpBinding.removeByServer")), + ), + listAll: () => + listAllRows().pipe( + Effect.map((rows) => rows.map(rowToBinding)), + Effect.mapError(toPersistenceSqlError("McpBinding.listAll")), + ), + } satisfies McpBindingRepositoryShape; +}); + +export const McpBindingRepositoryLive = Layer.effect(McpBindingRepository, makeMcpBindingRepository); diff --git a/apps/server/src/persistence/Layers/ProjectionMcpCatalog.ts b/apps/server/src/persistence/Layers/ProjectionMcpCatalog.ts new file mode 100644 index 00000000000..04954dd61ab --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionMcpCatalog.ts @@ -0,0 +1,105 @@ +import { McpCatalogServer, McpServerConfig, McpServerVar, NonNegativeInt } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + McpCatalogRepository, + RemoveCatalogServerInput, + type McpCatalogRepositoryShape, +} from "../Services/McpCatalog.ts"; + +// config_json / vars_json / extra_headers_json decode into their domain shapes; description + +// website_url + timeout_ms are plain nullable columns; locked + enabled are 0/1 INTEGER columns. +const McpCatalogServerDbRow = McpCatalogServer.mapFields( + Struct.assign({ + config: Schema.fromJsonString(McpServerConfig), + vars: Schema.fromJsonString(Schema.Array(McpServerVar)), + extraArgs: Schema.fromJsonString(Schema.Array(Schema.String)), + extraHeaders: Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)), + locked: NonNegativeInt, // 0/1 column → converted to boolean by rowToServer + enabled: NonNegativeInt, // 0/1 column → converted to boolean by rowToServer + trust: NonNegativeInt, // ru-fork #6: 0/1 column → boolean by rowToServer + }), +); +type McpCatalogServerDbRow = typeof McpCatalogServerDbRow.Type; + +/** `locked`/`enabled`/`trust` are 0/1 (SQLite has no boolean); convert them. JSON columns decode in-schema. */ +function rowToServer(row: McpCatalogServerDbRow): McpCatalogServer { + return { ...row, locked: row.locked !== 0, enabled: row.enabled !== 0, trust: row.trust !== 0 }; +} + +const makeMcpCatalogRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertRow = SqlSchema.void({ + Request: McpCatalogServer, + execute: (server) => + sql` + INSERT INTO mcp_catalog_server ( + id, name, description, website_url, source, config_json, vars_json, extra_args_json, + extra_headers_json, builtin_id, builtin_hash, locked, enabled, trust, timeout_ms, created_at, updated_at + ) VALUES ( + ${server.id}, ${server.name}, ${server.description}, ${server.websiteUrl}, ${server.source}, + ${JSON.stringify(server.config)}, ${JSON.stringify(server.vars)}, + ${JSON.stringify(server.extraArgs)}, ${JSON.stringify(server.extraHeaders)}, + ${server.builtinId}, ${server.builtinHash}, + ${server.locked ? 1 : 0}, ${server.enabled ? 1 : 0}, ${server.trust ? 1 : 0}, ${server.timeoutMs}, + ${server.createdAt}, ${server.updatedAt} + ) + ON CONFLICT (id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + website_url = excluded.website_url, + source = excluded.source, + config_json = excluded.config_json, + vars_json = excluded.vars_json, + extra_args_json = excluded.extra_args_json, + extra_headers_json = excluded.extra_headers_json, + builtin_id = excluded.builtin_id, + builtin_hash = excluded.builtin_hash, + locked = excluded.locked, + enabled = excluded.enabled, + trust = excluded.trust, + timeout_ms = excluded.timeout_ms, + updated_at = excluded.updated_at + `, + }); + + const listRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: McpCatalogServerDbRow, + execute: () => + sql` + SELECT id, name, description, website_url AS "websiteUrl", source, config_json AS "config", + vars_json AS "vars", extra_args_json AS "extraArgs", extra_headers_json AS "extraHeaders", + builtin_id AS "builtinId", builtin_hash AS "builtinHash", + locked AS "locked", enabled AS "enabled", trust AS "trust", timeout_ms AS "timeoutMs", + created_at AS "createdAt", updated_at AS "updatedAt" + FROM mcp_catalog_server ORDER BY created_at ASC, id ASC + `, + }); + + const removeRow = SqlSchema.void({ + Request: RemoveCatalogServerInput, + execute: ({ serverId }) => sql`DELETE FROM mcp_catalog_server WHERE id = ${serverId}`, + }); + + return { + upsert: (server) => + upsertRow(server).pipe(Effect.mapError(toPersistenceSqlError("McpCatalog.upsert"))), + listAll: () => + listRows().pipe( + Effect.map((rows) => rows.map(rowToServer)), + Effect.mapError(toPersistenceSqlError("McpCatalog.listAll")), + ), + remove: (input) => + removeRow(input).pipe(Effect.mapError(toPersistenceSqlError("McpCatalog.remove"))), + } satisfies McpCatalogRepositoryShape; +}); + +export const McpCatalogRepositoryLive = Layer.effect(McpCatalogRepository, makeMcpCatalogRepository); diff --git a/apps/server/src/persistence/Layers/ProjectionMcpProbeCache.ts b/apps/server/src/persistence/Layers/ProjectionMcpProbeCache.ts new file mode 100644 index 00000000000..90a05b05fac --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionMcpProbeCache.ts @@ -0,0 +1,81 @@ +import { McpProbeRecord, McpTool } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Struct from "effect/Struct"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + GetProbeRecordInput, + McpProbeCacheRepository, + type McpProbeCacheRepositoryShape, +} from "../Services/McpProbeCache.ts"; + +// tools_json decodes into McpTool[]; the rest are plain TEXT. +const McpProbeRecordDbRow = McpProbeRecord.mapFields( + Struct.assign({ tools: Schema.fromJsonString(Schema.Array(McpTool)) }), +); + +const makeMcpProbeCacheRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertRow = SqlSchema.void({ + Request: McpProbeRecord, + execute: (record) => + sql` + INSERT INTO mcp_probe_cache ( + config_key, transport, status, tools_json, last_error, + server_description, server_website_url, checked_at, checked_at_ms + ) + VALUES ( + ${record.configKey}, ${record.transport}, ${record.status}, + ${JSON.stringify(record.tools)}, ${record.lastError}, + ${record.serverDescription}, ${record.serverWebsiteUrl}, ${record.checkedAt}, ${record.checkedAtMs} + ) + ON CONFLICT (config_key) DO UPDATE SET + transport = excluded.transport, + status = excluded.status, + tools_json = excluded.tools_json, + last_error = excluded.last_error, + server_description = excluded.server_description, + server_website_url = excluded.server_website_url, + checked_at = excluded.checked_at, + checked_at_ms = excluded.checked_at_ms + `, + }); + + const getRow = SqlSchema.findOneOption({ + Request: GetProbeRecordInput, + Result: McpProbeRecordDbRow, + execute: ({ configKey }) => + sql` + SELECT config_key AS "configKey", transport, status, tools_json AS "tools", + last_error AS "lastError", server_description AS "serverDescription", + server_website_url AS "serverWebsiteUrl", checked_at AS "checkedAt", + checked_at_ms AS "checkedAtMs" + FROM mcp_probe_cache WHERE config_key = ${configKey} + `, + }); + + return { + upsert: (record) => + upsertRow(record).pipe(Effect.mapError(toPersistenceSqlError("McpProbeCache.upsert"))), + getByKey: (input) => + getRow(input).pipe(Effect.mapError(toPersistenceSqlError("McpProbeCache.getByKey"))), + deleteKeysNotIn: (configKeys) => + (configKeys.length === 0 + ? sql`DELETE FROM mcp_probe_cache` + : sql`DELETE FROM mcp_probe_cache WHERE config_key NOT IN ${sql.in(configKeys)}` + ).pipe( + Effect.asVoid, + Effect.mapError(toPersistenceSqlError("McpProbeCache.deleteKeysNotIn")), + ), + } satisfies McpProbeCacheRepositoryShape; +}); + +export const McpProbeCacheRepositoryLive = Layer.effect( + McpProbeCacheRepository, + makeMcpProbeCacheRepository, +); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index be211534663..93450185893 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -43,6 +43,7 @@ import Migration0027 from "./Migrations/027_ProviderSessionRuntimeInstanceId.ts" import Migration0028 from "./Migrations/028_ProjectionThreadSessionInstanceId.ts"; import Migration0029 from "./Migrations/029_ProjectionThreadDetailOrderingIndexes.ts"; import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes.ts"; +import Migration0031 from "./Migrations/031_Mcp.ts"; /** * Migration loader with all migrations defined inline. @@ -85,6 +86,7 @@ export const migrationEntries = [ [28, "ProjectionThreadSessionInstanceId", Migration0028], [29, "ProjectionThreadDetailOrderingIndexes", Migration0029], [30, "ProjectionThreadShellArchiveIndexes", Migration0030], + [31, "Mcp", Migration0031], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/031_Mcp.ts b/apps/server/src/persistence/Migrations/031_Mcp.ts new file mode 100644 index 00000000000..70f1c1ff6b2 --- /dev/null +++ b/apps/server/src/persistence/Migrations/031_Mcp.ts @@ -0,0 +1,78 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +// ru-fork: the SINGLE migration for the whole MCP feature (unreleased — final +// users get this final schema, no migration-on-migration stacking; edit THIS +// file rather than adding 032+). +// - mcp_catalog_server: global catalog. config_json holds secret refs only +// (plaintext lives in ServerSecretStore). Status + discovered tools live in +// mcp_probe_cache (keyed by configCacheKey), never on the catalog row. +// - mcp_project_binding: per-project bindings; cascade with the project on +// project.deleted (enforced by the projector, indexed below). +// - mcp_probe_cache: probe result (status + discovered tools) keyed by the +// AUTHORED config (configCacheKey), so two projects on the catalog default +// share one entry and a per-project override gets its own. +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS mcp_catalog_server ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + website_url TEXT, + source TEXT NOT NULL, + config_json TEXT NOT NULL, + vars_json TEXT NOT NULL DEFAULT '[]', + extra_args_json TEXT NOT NULL DEFAULT '[]', + extra_headers_json TEXT NOT NULL DEFAULT '{}', + builtin_id TEXT, + builtin_hash TEXT, + locked INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + trust INTEGER NOT NULL DEFAULT 1, + timeout_ms INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE TABLE IF NOT EXISTS mcp_project_binding ( + project_id TEXT NOT NULL, + server_id TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + tool_policy_json TEXT NOT NULL DEFAULT '{"defaultDecision":"allow","exceptions":[]}', + var_values_json TEXT NOT NULL DEFAULT '{}', + timeout_ms INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (project_id, server_id) + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_mcp_binding_project ON mcp_project_binding(project_id) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_mcp_binding_server ON mcp_project_binding(server_id) + `; + + // Probe-result cache, keyed by configCacheKey (authored config, cwd-independent). + // checked_at = ISO (for display); checked_at_ms = epoch (for the due check on + // hydration, so a restart doesn't immediately re-probe a fresh config). + yield* sql` + CREATE TABLE IF NOT EXISTS mcp_probe_cache ( + config_key TEXT PRIMARY KEY, + transport TEXT NOT NULL, + status TEXT NOT NULL, + tools_json TEXT NOT NULL DEFAULT '[]', + last_error TEXT, + server_description TEXT, + server_website_url TEXT, + checked_at TEXT NOT NULL, + checked_at_ms INTEGER NOT NULL DEFAULT 0 + ) + `; +}); diff --git a/apps/server/src/persistence/Services/McpBinding.ts b/apps/server/src/persistence/Services/McpBinding.ts new file mode 100644 index 00000000000..e792f9f4208 --- /dev/null +++ b/apps/server/src/persistence/Services/McpBinding.ts @@ -0,0 +1,36 @@ +import { McpBinding, McpServerId, ProjectId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const RemoveBindingInput = Schema.Struct({ projectId: ProjectId, serverId: McpServerId }); +export type RemoveBindingInput = typeof RemoveBindingInput.Type; + +export const BindingsByProjectInput = Schema.Struct({ projectId: ProjectId }); +export type BindingsByProjectInput = typeof BindingsByProjectInput.Type; + +export const BindingsByServerInput = Schema.Struct({ serverId: McpServerId }); +export type BindingsByServerInput = typeof BindingsByServerInput.Type; + +/** Persistence for per-project MCP bindings (PK = project_id + server_id). */ +export interface McpBindingRepositoryShape { + readonly upsert: (binding: McpBinding) => Effect.Effect; + readonly remove: (input: RemoveBindingInput) => Effect.Effect; + readonly listByProject: ( + input: BindingsByProjectInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly removeByProject: ( + input: BindingsByProjectInput, + ) => Effect.Effect; + readonly removeByServer: ( + input: BindingsByServerInput, + ) => Effect.Effect; + readonly listAll: () => Effect.Effect, ProjectionRepositoryError>; +} + +export class McpBindingRepository extends Context.Service< + McpBindingRepository, + McpBindingRepositoryShape +>()("ru-fork/persistence/Services/McpBinding/McpBindingRepository") {} diff --git a/apps/server/src/persistence/Services/McpCatalog.ts b/apps/server/src/persistence/Services/McpCatalog.ts new file mode 100644 index 00000000000..cab324fc62e --- /dev/null +++ b/apps/server/src/persistence/Services/McpCatalog.ts @@ -0,0 +1,26 @@ +import { McpCatalogServer, McpServerId } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const RemoveCatalogServerInput = Schema.Struct({ serverId: McpServerId }); +export type RemoveCatalogServerInput = typeof RemoveCatalogServerInput.Type; + +/** Persistence for the global MCP catalog (one row per server definition). */ +export interface McpCatalogRepositoryShape { + readonly upsert: (server: McpCatalogServer) => Effect.Effect; + readonly listAll: () => Effect.Effect< + ReadonlyArray, + ProjectionRepositoryError + >; + readonly remove: ( + input: RemoveCatalogServerInput, + ) => Effect.Effect; +} + +export class McpCatalogRepository extends Context.Service< + McpCatalogRepository, + McpCatalogRepositoryShape +>()("ru-fork/persistence/Services/McpCatalog/McpCatalogRepository") {} diff --git a/apps/server/src/persistence/Services/McpProbeCache.ts b/apps/server/src/persistence/Services/McpProbeCache.ts new file mode 100644 index 00000000000..8533990c609 --- /dev/null +++ b/apps/server/src/persistence/Services/McpProbeCache.ts @@ -0,0 +1,33 @@ +import { McpProbeRecord } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const GetProbeRecordInput = Schema.Struct({ configKey: Schema.String }); +export type GetProbeRecordInput = typeof GetProbeRecordInput.Type; + +/** + * Persistence for the probe-result cache (one row per AUTHORED config, keyed by + * `configCacheKey`). The single source for MCP server status + discovered tools. + */ +export interface McpProbeCacheRepositoryShape { + readonly upsert: (record: McpProbeRecord) => Effect.Effect; + readonly getByKey: ( + input: GetProbeRecordInput, + ) => Effect.Effect, ProjectionRepositoryError>; + /** + * GC: delete every cache row whose `config_key` is NOT in `configKeys` (the + * live authored configs). An empty list clears the whole cache. + */ + readonly deleteKeysNotIn: ( + configKeys: ReadonlyArray, + ) => Effect.Effect; +} + +export class McpProbeCacheRepository extends Context.Service< + McpProbeCacheRepository, + McpProbeCacheRepositoryShape +>()("ru-fork/persistence/Services/McpProbeCache/McpProbeCacheRepository") {} diff --git a/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts b/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts index 1498984827e..a2c16ce32b1 100644 --- a/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts +++ b/apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts @@ -9,6 +9,7 @@ import { CommandId, IsoDateTime, + McpCatalogAggregateId, NonNegativeInt, OrchestrationAggregateKind, OrchestrationCommandReceiptStatus, @@ -25,7 +26,7 @@ import type { OrchestrationCommandReceiptRepositoryError } from "../Errors.ts"; export const OrchestrationCommandReceipt = Schema.Struct({ commandId: CommandId, aggregateKind: OrchestrationAggregateKind, - aggregateId: Schema.Union([ProjectId, ThreadId]), + aggregateId: Schema.Union([ProjectId, ThreadId, McpCatalogAggregateId]), acceptedAt: IsoDateTime, resultSequence: NonNegativeInt, status: OrchestrationCommandReceiptStatus, diff --git a/apps/server/src/provider/Layers/CliAcpSupport.ts b/apps/server/src/provider/Layers/CliAcpSupport.ts index e897d5fb5dd..a0878c0e27d 100644 --- a/apps/server/src/provider/Layers/CliAcpSupport.ts +++ b/apps/server/src/provider/Layers/CliAcpSupport.ts @@ -25,6 +25,17 @@ import { type CliAcpRuntimeSettings = Pick; +/** + * ru-fork: opaque spawn-time settings the adapter forwards verbatim. Produced by + * the MCP overlay engine (see ru-fork/mcp/McpOverlay.ts) but kept provider-neutral + * here: `settingsOverlayPath` is any highest-precedence settings file; + * `allowedMcpServers` is the qwen MCP server allowlist. Both independent + optional. + */ +export interface CliAcpSettingsOverlay { + readonly settingsOverlayPath?: string; + readonly allowedMcpServers?: ReadonlyArray; +} + export interface CliAcpRuntimeInput extends Omit< AcpSessionRuntimeOptions, "authMethodId" | "clientCapabilities" | "spawn" @@ -34,6 +45,8 @@ export interface CliAcpRuntimeInput extends Omit< readonly environment?: NodeJS.ProcessEnv; // ru-fork: resolved cli.js (ServerConfig.cliJs). Spawned as `node --acp`. readonly cliJs: string; + // ru-fork: forwarded verbatim into the spawn (env + launch arg). Absent ⇒ today's behaviour. + readonly settingsOverlay?: CliAcpSettingsOverlay; } /** @@ -53,6 +66,7 @@ export function buildCliAcpSpawnInput( cliSettings: CliAcpRuntimeSettings | null | undefined, cwd: string, environment?: NodeJS.ProcessEnv, + settingsOverlay?: CliAcpSettingsOverlay, ): AcpSpawnInput { const env: NodeJS.ProcessEnv = { ...environment }; const homePath = cliSettings?.homePath?.trim(); @@ -62,8 +76,23 @@ export function buildCliAcpSpawnInput( if (ACP_SERVER_NO_SSL) { env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; } - // ru-fork: `node [launchArgs] --acp` directly — no shell, no PATH lookup. - const spawn = buildCliSpawn(cliJs, [...parseLaunchArgs(cliSettings?.launchArgs), "--acp"]); + // ru-fork: a highest-precedence settings file overlaid onto qwen's own config. + if (settingsOverlay?.settingsOverlayPath) { + env.QWEN_CODE_SYSTEM_SETTINGS_PATH = settingsOverlay.settingsOverlayPath; + } + // ru-fork: restrict qwen to exactly the overlay's MCP servers (omit ⇒ no filter). + const allowedMcpServers = settingsOverlay?.allowedMcpServers ?? []; + const allowMcpArgs = + allowedMcpServers.length > 0 + ? ["--allowed-mcp-server-names", allowedMcpServers.join(",")] + : []; + // ru-fork: `node [launchArgs] [--allowed-mcp-server-names …] --acp` + // directly — no shell, no PATH lookup. + const spawn = buildCliSpawn(cliJs, [ + ...parseLaunchArgs(cliSettings?.launchArgs), + ...allowMcpArgs, + "--acp", + ]); return { command: spawn.command, args: [...spawn.args], @@ -79,7 +108,13 @@ export const makeCliAcpRuntime = ( const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ ...input, - spawn: buildCliAcpSpawnInput(input.cliJs, input.cliSettings, input.cwd, input.environment), + spawn: buildCliAcpSpawnInput( + input.cliJs, + input.cliSettings, + input.cwd, + input.environment, + input.settingsOverlay, + ), authMethodId: CLI_AUTH_METHOD_ID, }).pipe( Layer.provide( diff --git a/apps/server/src/provider/Layers/CliAdapter.ts b/apps/server/src/provider/Layers/CliAdapter.ts index 0052408da78..d2e09910a32 100644 --- a/apps/server/src/provider/Layers/CliAdapter.ts +++ b/apps/server/src/provider/Layers/CliAdapter.ts @@ -29,6 +29,7 @@ import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -55,6 +56,7 @@ import { STOP_BUTTON_METHOD, } from "../../config.ts"; import { + ACP_SESSION_START_TIMEOUT_MS, ACP_WIRE_STALL_KILL_MS, ACP_WIRE_STALL_WARN_MS, POST_ANSWER_RESUME_TIMEOUT_MS, @@ -107,6 +109,12 @@ export interface CliAdapterLiveOptions { readonly environment?: NodeJS.ProcessEnv; readonly nativeEventLogger?: EventNdjsonLogger; readonly instanceId?: typeof ProviderInstanceId.Type; + /** + * ru-fork: override the ACP start-handshake timeout. Production omits it ⇒ + * ACP_SESSION_START_TIMEOUT_MS. Tests pass a tiny value so a scripted hang at + * `session/new` trips the timeout without a real-time wait. + */ + readonly sessionStartTimeoutMs?: number; } interface PendingApproval { @@ -400,6 +408,9 @@ function parseCliResume(raw: unknown): { sessionId: string } | undefined { export function makeCliAdapter(cliSettings: CliSettings, options?: CliAdapterLiveOptions) { return Effect.gen(function* () { const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make(CLI_NAME); + // ru-fork: bound the start handshake so a wedged `cli --acp` boot fails instead + // of hanging the turn forever (and, for MCP, promptly releases the ephemeral overlay). + const sessionStartTimeoutMs = options?.sessionStartTimeoutMs ?? ACP_SESSION_START_TIMEOUT_MS; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -665,6 +676,22 @@ export function makeCliAdapter(cliSettings: CliSettings, options?: CliAdapterLiv stallWarned: false, }; + // ru-fork: opaque spawn-time settings (MCP overlay path + server + // allowlist) resolved upstream by the reactor and forwarded verbatim. + // The adapter has no MCP knowledge — see CliAcpSettingsOverlay. Absent + // fields ⇒ today's spawn behaviour. + const settingsOverlay = + input.settingsOverlayPath !== undefined || input.allowedMcpServers !== undefined + ? { + ...(input.settingsOverlayPath !== undefined + ? { settingsOverlayPath: input.settingsOverlayPath } + : {}), + ...(input.allowedMcpServers !== undefined + ? { allowedMcpServers: input.allowedMcpServers } + : {}), + } + : undefined; + const acp = yield* makeCliAcpRuntime({ cliSettings, ...(options?.environment ? { environment: options.environment } : {}), @@ -672,6 +699,7 @@ export function makeCliAdapter(cliSettings: CliSettings, options?: CliAdapterLiv // ru-fork: resolved cli.js — spawned as `node --acp` directly. cliJs: serverConfig.cliJs, cwd, + ...(settingsOverlay ? { settingsOverlay } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, ...(resumeSessionId ? { resumeSessionId } : {}), protocolLogging: { @@ -1068,6 +1096,32 @@ export function makeCliAdapter(cliSettings: CliSettings, options?: CliAdapterLiv Effect.mapError((error) => mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), ), + // ru-fork: hard ceiling on the start handshake. Nothing else bounds it + // (the stall/child-exit watchdogs are forked only AFTER start and act + // only during an active turn). On timeout the interrupt unwinds the + // pending `initialize`/`session.new` RPC and the session scope closes, + // SIGKILLing the child; we surface a typed adapter error so callers + // (and the reactor's overlay finalizer) see a settled failure. + Effect.timeoutOrElse({ + duration: Duration.millis(sessionStartTimeoutMs), + orElse: () => + Effect.logError("[cli-acp.start-timeout]", { + threadId: input.threadId, + method: "session/start", + timeoutMs: sessionStartTimeoutMs, + }).pipe( + Effect.andThen( + Effect.fail( + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: `ACP session did not complete its start handshake within ${sessionStartTimeoutMs}ms — the cli --acp child is unresponsive.`, + cause: new Error("acp-session-start-timeout"), + }), + ), + ), + ), + }), ); const resumeOutcome: "fresh" | "resumed" | "resume-fallback-fresh" = diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 1e987ad0907..6dd66a5e828 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -432,6 +432,10 @@ const makeAcpSessionRuntime = ( const loadPayload = { sessionId: options.resumeSessionId, cwd: options.cwd, + // ru-fork: MCP servers come from the settings overlay + // (QWEN_CODE_SYSTEM_SETTINGS_PATH), not this ACP array — the overlay + // is the single source and the only path that carries http + per-tool + // filters. See ru-fork/mcp/McpOverlay.ts. mcpServers: [], } satisfies EffectAcpSchema.LoadSessionRequest; yield* Ref.set(suppressUpdatesRef, true); @@ -446,6 +450,9 @@ const makeAcpSessionRuntime = ( } else { const createPayload = { cwd: options.cwd, + // ru-fork: empty by design — MCP servers come from the settings + // overlay (QWEN_CODE_SYSTEM_SETTINGS_PATH), not this ACP array. + // See ru-fork/mcp/McpOverlay.ts. mcpServers: [], } satisfies EffectAcpSchema.NewSessionRequest; const created = yield* runLoggedRequest( @@ -459,6 +466,9 @@ const makeAcpSessionRuntime = ( } else { const createPayload = { cwd: options.cwd, + // ru-fork: empty by design — MCP servers come from the settings + // overlay (QWEN_CODE_SYSTEM_SETTINGS_PATH), not this ACP array. + // See ru-fork/mcp/McpOverlay.ts. mcpServers: [], } satisfies EffectAcpSchema.NewSessionRequest; const created = yield* runLoggedRequest( diff --git a/apps/server/src/ru-fork/mcp/McpBuiltins.ts b/apps/server/src/ru-fork/mcp/McpBuiltins.ts new file mode 100644 index 00000000000..2de09fcd682 --- /dev/null +++ b/apps/server/src/ru-fork/mcp/McpBuiltins.ts @@ -0,0 +1,82 @@ +// ru-fork: built-in MCP templates — TYPES + pure helpers (the "engine"). The migrator (McpReactor) +// reconciles the shipped definitions against the installed catalog by `builtinId` on every startup: +// add new, update changed (by content hash), remove deleted. Command/args are LOCKED; the user +// configures var VALUES + `extraArgs`. Per-platform variants; a platform with no variant is skipped. +// +// The actual shipped list lives in `./mcpBuiltinDefinitions.ts` (DATA ONLY) — edit that file to add / +// remove / change a built-in; nothing here changes. This file holds only the shape + the functions +// that operate on any `McpBuiltinDefinition`. + +import type { McpServerConfig, McpServerVar } from "@t3tools/contracts"; +import { fnv1a } from "@ru-fork/mcp-core"; + +/** A var DECLARATION shipped by a template (no secret value — value is null for secrets). */ +export interface McpBuiltinVar { + readonly name: string; + readonly secret: boolean; + readonly perProject: boolean; + readonly required: boolean; + /** A non-secret default; null = a hole the user fills. Secrets are always null here. */ + readonly value: string | null; +} + +/** A built-in template. `config` is per-platform; the migrator picks `process.platform`. */ +export interface McpBuiltinDefinition { + readonly builtinId: string; // stable hidden reconciliation key — NEVER rename + readonly name: string; + readonly description?: string; + /** Shipped docs/website link — wins over the probe-reported one (B3 ②). Display-only. */ + readonly websiteUrl?: string; + /** Per-platform locked command/args. A missing key for the current platform ⇒ skip the built-in. */ + readonly config: Partial> & { + readonly default?: McpServerConfig; + }; + readonly vars: ReadonlyArray; + readonly timeoutMs?: number; +} + +/** Pick the platform-specific config (current OS, else `default`); null ⇒ unsupported ⇒ skip. */ +export function builtinConfigForPlatform( + definition: McpBuiltinDefinition, + platform: NodeJS.Platform, +): McpServerConfig | null { + return definition.config[platform] ?? definition.config.default ?? null; +} + +/** Shipped vars as domain `McpServerVar`s (origin:"shipped"; secrets carry value:null). */ +export function builtinShippedVars(definition: McpBuiltinDefinition): ReadonlyArray { + return definition.vars.map((variable) => ({ + name: variable.name, + secret: variable.secret, + perProject: variable.perProject, + required: variable.required, + value: variable.value, + origin: "shipped", + // A non-null shipped value is author-fixed ⇒ locked (read-only; the shipped value wins on re-sync). + // Holes (value:null — credentials/secrets) stay user-fillable. + valueLocked: variable.value !== null, + })); +} + +/** + * Content hash of the SHIPPED parts (platform-resolved config + shipped var declarations + timeout + + * name/description). Drives "shipped template changed → update". Excludes user data by construction + * (user vars/values/extraArgs are not part of the definition). Stable JSON of sorted keys. + */ +export function builtinHash(config: McpServerConfig, definition: McpBuiltinDefinition): string { + return fnv1a( + JSON.stringify({ + name: definition.name, + description: definition.description ?? null, + websiteUrl: definition.websiteUrl ?? null, + config, + vars: builtinShippedVars(definition), + timeoutMs: definition.timeoutMs ?? null, + }), + ); +} + +/** The catalog serverId for a built-in (stable, derived from builtinId). */ +export function builtinServerId(builtinId: string): string { + return `srv-builtin-${builtinId}`; +} diff --git a/apps/server/src/ru-fork/mcp/McpCatalogBuilders.ts b/apps/server/src/ru-fork/mcp/McpCatalogBuilders.ts new file mode 100644 index 00000000000..ca80982bf6f --- /dev/null +++ b/apps/server/src/ru-fork/mcp/McpCatalogBuilders.ts @@ -0,0 +1,239 @@ +// ru-fork: pure builders that turn MCP commands + (already split) vars into the +// catalog/binding records the events carry. Kept out of the shared decider so the +// decider branches stay thin (validate → split secrets → build → emit). + +import { + DEFAULT_TOOL_POLICY, + type McpBinding, + type McpBindingPatch, + type McpCatalogServer, + type McpServerConfig, + type McpServerDraft, + type McpServerDraftPatch, + type McpServerId, + type McpServerVar, + type McpServerVarDraft, + type McpVarValue, + type ProjectId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { type SecretStoreError, ServerSecretStore } from "../../auth/Services/ServerSecretStore.ts"; +import { splitBindingVarValues, splitServerVars } from "./McpSecrets.ts"; + +/** + * A catalog server from a manual add command (config = template; vars already split). Manual servers + * are always `source:"custom"`, never locked, with no built-in identity — managed templates come in + * only via the migrator's `buildSyncedBuiltin` (never the user draft path), so editing never forks. + */ +export function buildAddedServer( + serverId: McpServerId, + draft: McpServerDraft, + vars: ReadonlyArray, + occurredAt: string, +): McpCatalogServer { + return { + id: serverId, + name: draft.name, + description: draft.description ?? null, + websiteUrl: null, // a manual server has no shipped link; fills from its own probe (B3 ②) + source: "custom", + config: draft.config, + vars, + extraArgs: draft.extraArgs ?? [], + extraHeaders: draft.extraHeaders ?? {}, + builtinId: null, + builtinHash: null, + locked: false, + enabled: true, + trust: draft.trust ?? true, // ru-fork #6: default «доверять» (auto-approve) + timeoutMs: draft.timeoutMs ?? null, + createdAt: occurredAt, + updatedAt: occurredAt, + }; +} + +// undefined ⇒ keep existing; otherwise the patch wins (null clears, string sets). +function applyDescriptionPatch( + existing: McpCatalogServer["description"], + patch: McpServerDraftPatch["description"], +): McpCatalogServer["description"] { + return patch === undefined ? existing : patch; +} + +/** + * Apply an update patch to an existing server. Never forks: a managed template keeps its + * `source:"builtin"`, locked command, and built-in identity (the decider rejects a config patch for a + * locked template); a manual server stays `custom`. `extraArgs` is the user's escape hatch on a + * locked template. + */ +export function applyServerUpdate( + existing: McpCatalogServer, + patch: McpServerDraftPatch, + vars: ReadonlyArray, + occurredAt: string, +): McpCatalogServer { + return { + id: existing.id, + name: patch.name ?? existing.name, + description: applyDescriptionPatch(existing.description, patch.description), + // websiteUrl: backfilled (only-if-empty) or shipped — never user-cleared, so `?? existing` is safe. + websiteUrl: patch.websiteUrl ?? existing.websiteUrl, + source: existing.source, + // A locked template keeps its shipped command; the decider rejects a config patch for it. + config: existing.locked ? existing.config : (patch.config ?? existing.config), + vars, + extraArgs: patch.extraArgs ?? existing.extraArgs, + extraHeaders: patch.extraHeaders ?? existing.extraHeaders, + builtinId: existing.builtinId, + builtinHash: existing.builtinHash, + locked: existing.locked, + enabled: patch.enabled ?? existing.enabled, + trust: patch.trust ?? existing.trust, // ru-fork #6 + timeoutMs: patch.timeoutMs !== undefined ? patch.timeoutMs : existing.timeoutMs, + createdAt: existing.createdAt, + updatedAt: occurredAt, + }; +} + +/** A binding from a binding-set command, merged over any existing binding. */ +export function buildBinding(input: { + readonly projectId: ProjectId; + readonly serverId: McpServerId; + readonly patch: McpBindingPatch; + readonly existing: McpBinding | undefined; + readonly varValues: Readonly>; + readonly occurredAt: string; +}): McpBinding { + return { + projectId: input.projectId, + serverId: input.serverId, + enabled: input.patch.enabled ?? input.existing?.enabled ?? true, + toolPolicy: input.patch.toolPolicy ?? input.existing?.toolPolicy ?? DEFAULT_TOOL_POLICY, + varValues: input.varValues, + timeoutMs: + input.patch.timeoutMs !== undefined ? input.patch.timeoutMs : (input.existing?.timeoutMs ?? null), + createdAt: input.existing?.createdAt ?? input.occurredAt, + updatedAt: input.occurredAt, + }; +} + +/** + * Resolve a binding's per-project var-values patch, splitting secret vars into the + * store. undefined ⇒ keep existing; otherwise replace with the split draft values. + */ +export function resolveBindingVarValues(input: { + readonly patch: Readonly> | undefined; + readonly keepNames: ReadonlyArray | undefined; + readonly existing: Readonly>; + readonly vars: ReadonlyArray; + readonly projectId: ProjectId; + readonly serverId: McpServerId; +}): Effect.Effect, SecretStoreError, ServerSecretStore> { + if (input.patch === undefined) { + return Effect.succeed({ ...input.existing }); + } + return splitBindingVarValues({ + projectId: input.projectId, + serverId: input.serverId, + vars: input.vars, + draftVarValues: input.patch, + keepNames: input.keepNames ?? [], + existing: input.existing, + }); +} + +/** + * Build the catalog row for a managed built-in from its shipped definition, merged over any existing + * row: shipped command/vars REPLACE; user data (origin:"user" vars, var VALUES by name, extraArgs) + * is PRESERVED. Source is always "builtin", command locked. No secret-store interaction (shipped + * vars carry value:null; user values are kept verbatim by name). + */ +export function buildSyncedBuiltin(input: { + readonly serverId: McpServerId; + readonly builtinId: string; + readonly builtinHash: string; + readonly name: string; + readonly description: string | null; + readonly websiteUrl: string | null; + readonly config: McpServerConfig; + readonly shippedVars: ReadonlyArray; + readonly timeoutMs: number | null; + readonly existing: McpCatalogServer | undefined; + readonly occurredAt: string; +}): McpCatalogServer { + const existingByName = new Map((input.existing?.vars ?? []).map((variable) => [variable.name, variable])); + const shipped = input.shippedVars.map((variable): McpServerVar => { + const prior = existingByName.get(variable.name); + // Keep a value across a re-sync ONLY when it was USER-supplied: a hole the user filled (prior is + // not author-fixed and holds a value). Otherwise take the shipped value — so an author-fixed + // (valueLocked) var re-adopts a changed URL, and a var the author turns into a hole (value→null) + // clears the old author value instead of stranding it. A prior with no valueLocked (pre-bit data) + // counts as user-supplied if it has a value. + const keptValue = + variable.valueLocked !== true && + prior !== undefined && + prior.value !== null && + prior.valueLocked !== true + ? prior.value + : variable.value; + return { ...variable, value: keptValue }; + }); + // User-added vars (origin:"user") survive a template update verbatim. + const userVars = (input.existing?.vars ?? []).filter((variable) => variable.origin === "user"); + return { + id: input.serverId, + name: input.name, + // Shipped value wins; else preserve a probe-backfilled value so a re-sync never clobbers it (B3 ②). + description: input.description ?? input.existing?.description ?? null, + websiteUrl: input.websiteUrl ?? input.existing?.websiteUrl ?? null, + source: "builtin", + config: input.config, + vars: [...shipped, ...userVars], + extraArgs: input.existing?.extraArgs ?? [], + extraHeaders: input.existing?.extraHeaders ?? {}, + builtinId: input.builtinId, + builtinHash: input.builtinHash, + locked: true, + enabled: input.existing?.enabled ?? true, // preserve a user's disable across template syncs + trust: input.existing?.trust ?? true, // ru-fork #6: preserve a user's trust choice across syncs + timeoutMs: input.timeoutMs, + createdAt: input.existing?.createdAt ?? input.occurredAt, + updatedAt: input.occurredAt, + }; +} + +/** + * Vars for a server-update. An unlocked server: every draft is a user var. A locked template: the + * shipped DECLARATION SET (which names exist + their secret/perProject/required flags) is immutable + * — re-stamped from `existing` — but the shipped VALUES are settable (so the user can fill a shipped + * secret/required at the catalog level), and user-added vars are fully editable. A shipped var the + * draft omits is preserved untouched. + */ +export function mergeTemplateVars( + serverId: McpServerId, + existing: McpCatalogServer, + draftVars: ReadonlyArray, +): Effect.Effect, SecretStoreError, ServerSecretStore> { + return Effect.gen(function* () { + const split = yield* splitServerVars(serverId, draftVars, existing.vars); + if (!existing.locked) { + return split; + } + const shippedByName = new Map( + existing.vars.filter((variable) => variable.origin === "shipped").map((variable) => [variable.name, variable]), + ); + // Take the new VALUE from the split draft but keep the shipped declaration (origin + flags) for + // any row matching a shipped name; everything else stays a user var. + const merged = split.map((variable) => { + const shipped = shippedByName.get(variable.name); + return shipped ? { ...shipped, value: variable.value } : variable; + }); + // Preserve shipped declarations the draft did not include at all. + const draftNames = new Set(split.map((variable) => variable.name)); + const untouchedShipped = existing.vars.filter( + (variable) => variable.origin === "shipped" && !draftNames.has(variable.name), + ); + return [...merged, ...untouchedShipped]; + }); +} diff --git a/apps/server/src/ru-fork/mcp/McpInvariants.ts b/apps/server/src/ru-fork/mcp/McpInvariants.ts new file mode 100644 index 00000000000..58b798efec1 --- /dev/null +++ b/apps/server/src/ru-fork/mcp/McpInvariants.ts @@ -0,0 +1,95 @@ +// ru-fork: MCP catalog/binding command invariants, kept out of the shared +// orchestration/commandInvariants.ts so upstream re-syncs never conflict. + +import type { + McpBinding, + McpCatalogServer, + McpServerId, + OrchestrationCommand, + OrchestrationReadModel, + ProjectId, +} from "@t3tools/contracts"; +import { configIdentity } from "@ru-fork/mcp-core"; +import * as Effect from "effect/Effect"; + +import { OrchestrationCommandInvariantError } from "../../orchestration/Errors.ts"; + +export function findCatalogServerById( + readModel: OrchestrationReadModel, + serverId: McpServerId, +): McpCatalogServer | undefined { + return readModel.mcpCatalog.find((server) => server.id === serverId); +} + +export function findBinding( + readModel: OrchestrationReadModel, + projectId: ProjectId, + serverId: McpServerId, +): McpBinding | undefined { + return readModel.mcpBindings.find( + (binding) => binding.projectId === projectId && binding.serverId === serverId, + ); +} + +export function requireCatalogServer(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly serverId: McpServerId; +}): Effect.Effect { + const server = findCatalogServerById(input.readModel, input.serverId); + if (server) { + return Effect.succeed(server); + } + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: input.command.type, + detail: `MCP server '${input.serverId}' does not exist for command '${input.command.type}'.`, + }), + ); +} + +export function requireCatalogServerAbsent(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly serverId: McpServerId; +}): Effect.Effect { + if (!findCatalogServerById(input.readModel, input.serverId)) { + return Effect.void; + } + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: input.command.type, + detail: `MCP server '${input.serverId}' already exists and cannot be added twice.`, + }), + ); +} + +/** + * ru-fork: reject a catalog write whose structural config identity already exists on ANOTHER server + * (custom or built-in). A server's identity is its config, not its name. `excludeServerId` is the + * server being edited (so a non-config edit never collides with itself). + */ +export function requireCatalogConfigUnique(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly identity: string; + readonly excludeServerId: McpServerId | null; +}): Effect.Effect { + const clash = input.readModel.mcpCatalog.find( + (server) => + server.id !== input.excludeServerId && + configIdentity(server.config, server.vars, server.extraArgs, server.extraHeaders) === + input.identity, + ); + if (!clash) { + return Effect.void; + } + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: input.command.type, + detail: + `Сервер с такой конфигурацией уже существует: «${clash.name}». ` + + `Привяжите его к проекту или измените команду/URL, аргументы или заголовки.`, + }), + ); +} diff --git a/apps/server/src/ru-fork/mcp/McpLayers.ts b/apps/server/src/ru-fork/mcp/McpLayers.ts new file mode 100644 index 00000000000..0bcf07c855a --- /dev/null +++ b/apps/server/src/ru-fork/mcp/McpLayers.ts @@ -0,0 +1,44 @@ +// ru-fork: MCP layer composition, kept out of the shared runtime/server wiring +// so those files only reference one or two named layers. + +import * as Layer from "effect/Layer"; + +import { ServerSecretStoreLive } from "../../auth/Layers/ServerSecretStore.ts"; +import { McpBindingRepositoryLive } from "../../persistence/Layers/ProjectionMcpBinding.ts"; +import { McpCatalogRepositoryLive } from "../../persistence/Layers/ProjectionMcpCatalog.ts"; +import { McpProbeCacheRepositoryLive } from "../../persistence/Layers/ProjectionMcpProbeCache.ts"; +import { McpOverlayLive } from "./McpOverlay.ts"; +import { McpProjectionQueryLive } from "./McpProjectionQuery.ts"; +import { McpReactorLive } from "./McpReactor.ts"; +import { McpRuntimeLive } from "./McpRuntime.ts"; +import { McpSupervisorLive } from "./McpSupervisor.ts"; + +/** Catalog + binding + probe-cache repos — shared by the pipeline, snapshot query, and runtime services. */ +export const McpRepositoriesLive = Layer.mergeAll( + McpCatalogRepositoryLive, + McpBindingRepositoryLive, + McpProbeCacheRepositoryLive, +); + +/** + * Runtime MCP services: the supervisor (singleton instance registry), the + * reactor that keeps it reconciled to authored state, the runtime projection of + * its state, and the read-model query. The supervisor is provided to the reactor + * + runtime here; the repos, engine, snapshot query, settings, and secret store + * are consumed from / shared with the outer runtime graph. + */ +export const McpRuntimeServicesLive = Layer.mergeAll( + McpRuntimeLive, + McpProjectionQueryLive, + McpReactorLive, +).pipe( + // Overlay writer — provided to the reactor (restart-on-change) and exposed for + // ProviderCommandReactor (spawn-time overlay). Provided before the repos / + // secret store below so those reach it. + Layer.provideMerge(McpOverlayLive), + Layer.provideMerge(McpSupervisorLive), + // Repos + secret store (memoized → the same instances the pipeline / snapshot + // query / engine already use). + Layer.provideMerge(McpRepositoriesLive), + Layer.provideMerge(ServerSecretStoreLive), +); diff --git a/apps/server/src/ru-fork/mcp/McpOverlay.ts b/apps/server/src/ru-fork/mcp/McpOverlay.ts new file mode 100644 index 00000000000..5e24eaf60c8 --- /dev/null +++ b/apps/server/src/ru-fork/mcp/McpOverlay.ts @@ -0,0 +1,291 @@ +// ru-fork: writes a project's qwen settings overlay — the single source of +// truth for which MCP servers + tools that project's qwen sees. This is the +// only place the overlay JSON shape is constructed; CliAdapter stays +// MCP-agnostic and just forwards the resulting path + allowlist to the spawn. +// +// The overlay file (`//system.json`) is read by qwen +// via QWEN_CODE_SYSTEM_SETTINGS_PATH at highest precedence. It disables folder +// trust (so MCP discovery is ungated) and lists every ENABLED binding with its +// resolved config + policy-derived include/excludeTools. include/excludeTools +// come straight from the tool POLICY (default-allow → excludeTools: exceptions; +// default-deny → includeTools: exceptions), NOT from discovered tools — qwen +// intersects with what it discovers, so a server gaining/losing a tool needs no +// restart. This matches `overlayFingerprint`, which is also policy-based. +// +// Shapes (command/args/env, httpUrl/headers, includeTools/excludeTools, +// security.folderTrust, --allowed-mcp-server-names) are exactly what the +// mcp-probe proved against real qwen 0.13.1. + +import { + McpError, + type McpToolPolicy, + type ProjectId, +} from "@t3tools/contracts"; +import { + DEFAULT_PROBE_TIMEOUT_MS, + effectiveTimeoutMs, + missingRequiredVars, + overlayFingerprint, + type OverlayServerEntry, + resolveConfig, + type ResolvedServerConfig, +} from "@ru-fork/mcp-core"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { writeFileStringAtomically } from "../../atomicWrite.ts"; +import { ServerSecretStore } from "../../auth/Services/ServerSecretStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { McpBindingRepository } from "../../persistence/Services/McpBinding.ts"; +import { McpCatalogRepository } from "../../persistence/Services/McpCatalog.ts"; +import { materializeSecretValues, missingSecretVarNames } from "./McpSecrets.ts"; + +/** What the spawn needs (path + allowlist) plus the fingerprint for restart diffing. */ +export interface OverlayResult { + readonly overlayPath: string; + readonly allowedServerNames: ReadonlyArray; + readonly fingerprint: string; +} + +export interface McpOverlayShape { + /** + * (Re)write the project's overlay file from current catalog + bindings and + * return its path, server allowlist, and fingerprint. Atomic write — a partial + * file is never observed. + */ + readonly writeOverlay: (projectId: ProjectId) => Effect.Effect; + /** + * B4 ③: remove a deleted project's overlay directory (best-effort — a missing + * dir is a no-op). Never fails (errors are swallowed), so callers need no recovery. + */ + readonly removeOverlay: (projectId: ProjectId) => Effect.Effect; + /** + * ru-fork #4 ephemeral: delete a single resolved overlay FILE the moment the spawn + * it fed has settled. The file holds plaintext secrets and is only needed until the + * freshly-spawned child boots and reads it (qwen reads settings once at startup, + * never re-reads). Best-effort — a missing file is a no-op; never fails, so it is + * safe inside `Effect.ensuring`. `force` swallows ENOENT. + */ + readonly deleteOverlayFile: (overlayPath: string) => Effect.Effect; + /** + * ru-fork #4 ephemeral: sweep EVERY project's overlay under `mcpOverlayDir`. Used on + * app start to clear plaintext-secret files a hard crash (SIGKILL/power-loss) left + * behind. Best-effort — a missing dir is a no-op; never fails. The dir is recreated + * lazily by the next `writeOverlay` (atomic write makes its parents). + */ + readonly removeAllOverlays: Effect.Effect; +} + +export class McpOverlay extends Context.Service()( + "ru-fork/mcp/McpOverlay", +) {} + +/** qwen mcpServers entry: resolved transport fields + policy-derived tool filter. + * ru-fork: exported as a testability seam (overlay↔qwen schema-conformance guard). */ +export function buildServerEntry( + resolved: ResolvedServerConfig, + policy: McpToolPolicy, + trust: boolean, +): Record { + const toolFilter: Record> = + policy.defaultDecision === "deny" + ? { includeTools: [...policy.exceptions] } // deny-all-but: empty ⇒ no tools + : policy.exceptions.length > 0 + ? { excludeTools: [...policy.exceptions] } // allow-all-but; empty ⇒ omit + : {}; + // qwen reads `timeout` (ms) from the server entry — write the SAME value the + // probe uses so the monitor and the real session behave identically. + const timeout = resolved.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; + // ru-fork #6: qwen's per-server auto-approve flag. true ⇒ tools run without confirmation (folder is + // trusted); false ⇒ qwen asks for write tools. The field name is qwen's own `trust`. + switch (resolved.transport) { + case "stdio": + return { + command: resolved.command ?? "", + args: [...(resolved.args ?? [])], + env: { ...resolved.env }, + // qwen scopes the stdio process to `cwd` (the project dir); the probe does too. + ...(resolved.cwd ? { cwd: resolved.cwd } : {}), + timeout, + ...toolFilter, + trust, + }; + case "http": + return { + httpUrl: resolved.httpUrl ?? "", + headers: { ...resolved.headers }, + timeout, + ...toolFilter, + trust, + }; + } +} + +const make = Effect.gen(function* () { + const catalogRepository = yield* McpCatalogRepository; + const bindingRepository = yield* McpBindingRepository; + const snapshotQuery = yield* ProjectionSnapshotQuery; + const serverConfig = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const secretStore = yield* ServerSecretStore; + + const provideIo = (effect: Effect.Effect) => + effect.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + const provideSecretStore = Effect.provideService(ServerSecretStore, secretStore); + + const writeOverlay: McpOverlayShape["writeOverlay"] = (projectId) => + Effect.gen(function* () { + const shell = yield* snapshotQuery.getProjectShellById(projectId); + const projectCwd = Option.match(shell, { + onNone: () => null, + onSome: (projectShell) => projectShell.workspaceRoot, + }); + + const catalog = yield* catalogRepository.listAll(); + const serverById = new Map(catalog.map((server) => [server.id, server])); + const bindings = yield* bindingRepository.listByProject({ projectId }); + + const mcpServers: Record = {}; + const allowedServerNames: string[] = []; + const fingerprintEntries: OverlayServerEntry[] = []; + + // A missing project cwd means no `${PROJECT_CWD}` expansion is possible — + // emit an empty overlay rather than a half-resolved one. + if (projectCwd !== null) { + for (const binding of bindings) { + if (!binding.enabled) { + continue; + } + const server = serverById.get(binding.serverId); + if (!server) { + continue; + } + if (!server.enabled) { + continue; // ⑬ catalog-disabled ⇒ excluded from the qwen overlay (binding stays, shown grayed) + } + // Skip incomplete bindings — qwen must never spawn with an empty required + // value; the UI flags them «требует настройки» (§D8). + if (missingRequiredVars(server.vars, binding.varValues).length > 0) { + continue; + } + // ru-fork #9: a declared required secret whose stored value is gone ⇒ incomplete; never + // hand qwen a blank credential (defense in depth with computeDesiredEffect). + if ( + (yield* missingSecretVarNames(server.vars, binding.varValues).pipe(provideSecretStore)) + .length > 0 + ) { + continue; + } + const secretValues = yield* materializeSecretValues( + server.vars, + binding.varValues, + ).pipe(provideSecretStore); + // Per-server resolved env (the vars) lives ONLY in this server's entry, so + // secrets are isolated per MCP (never in a shared env) — see §D11. + const resolved = resolveConfig({ + config: server.config, + vars: server.vars, + varValues: binding.varValues, + extraArgs: server.extraArgs, + extraHeaders: server.extraHeaders, + timeoutMs: effectiveTimeoutMs(server, binding), + context: { projectCwd, secretValues }, + }); + mcpServers[binding.serverId] = buildServerEntry(resolved, binding.toolPolicy, server.trust); + allowedServerNames.push(binding.serverId); + fingerprintEntries.push({ + serverName: binding.serverId, + resolved, + toolPolicy: binding.toolPolicy, + trust: server.trust, + }); + } + } + + const overlayPath = path.join(serverConfig.mcpOverlayDir, projectId, "system.json"); + // This is qwen's external settings.json format, not one of our schemas — + // plain JSON is the right tool, so the prefer-Schema heuristic is off here. + // @effect-diagnostics-next-line preferSchemaOverJson:off + const contents = JSON.stringify( + { security: { folderTrust: { enabled: false } }, mcpServers }, + null, + 2, + ); + // mode 0600 / dir 0700: the overlay holds resolved secret values, so it must + // match the secret store's owner-only protection, not the default umask (§D13). + yield* provideIo( + writeFileStringAtomically({ filePath: overlayPath, contents, mode: 0o600, dirMode: 0o700 }), + ); + + const fingerprint = overlayFingerprint(fingerprintEntries); + yield* Effect.logDebug("[mcp] overlay written", { + projectId, + path: overlayPath, + servers: allowedServerNames.length, + allowedServerNames, + fingerprint, + }); + + return { overlayPath, allowedServerNames, fingerprint } satisfies OverlayResult; + }).pipe( + Effect.mapError( + (cause) => + new McpError({ detail: `Failed to write MCP overlay for project ${projectId}`, cause }), + ), + ); + + const removeOverlay: McpOverlayShape["removeOverlay"] = (projectId) => + provideIo( + fileSystem.remove(path.join(serverConfig.mcpOverlayDir, projectId), { + recursive: true, + force: true, + }), + ).pipe( + // ru-fork: best-effort cleanup — `force` makes a missing dir a no-op; a real failure is logged (the + // dir holds resolved secrets) but must not fail the project.deleted chain. + Effect.catchCause((cause) => + Effect.logDebug("mcp overlay: failed to remove project overlay", { projectId, cause }), + ), + ); + + const deleteOverlayFile: McpOverlayShape["deleteOverlayFile"] = (overlayPath) => + provideIo(fileSystem.remove(overlayPath, { force: true })).pipe( + // ru-fork #4: best-effort — never fails (safe under Effect.ensuring). `force` makes a + // missing file a no-op; a real failure is debug (non-blocking — sweep-on-start nets it). + Effect.catchCause((cause) => + Effect.logDebug("mcp overlay: failed to delete ephemeral overlay file", { + overlayPath, + cause, + }), + ), + ); + + const removeAllOverlays: McpOverlayShape["removeAllOverlays"] = provideIo( + fileSystem.remove(serverConfig.mcpOverlayDir, { recursive: true, force: true }), + ).pipe( + Effect.catchCause((cause) => + Effect.logDebug("mcp overlay: failed to sweep overlay dir", { + dir: serverConfig.mcpOverlayDir, + cause, + }), + ), + ); + + return { + writeOverlay, + removeOverlay, + deleteOverlayFile, + removeAllOverlays, + } satisfies McpOverlayShape; +}); + +export const McpOverlayLive = Layer.effect(McpOverlay, make); diff --git a/apps/server/src/ru-fork/mcp/McpProjectionQuery.ts b/apps/server/src/ru-fork/mcp/McpProjectionQuery.ts new file mode 100644 index 00000000000..aee1635457d --- /dev/null +++ b/apps/server/src/ru-fork/mcp/McpProjectionQuery.ts @@ -0,0 +1,79 @@ +// ru-fork: read model for the MCP panel — catalog + bindings snapshot and an +// authored-change stream. Config rows already hold secret refs only, so no +// client-side redaction pass is needed. The change stream is low-frequency +// (admin edits), so it re-reads the full snapshot per change rather than +// tracking cascade deltas — simplest correct behaviour. + +import { + McpError, + type McpProjectionStreamEvent, + type McpSnapshot, + type OrchestrationEvent, + type ProjectId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { McpBindingRepository } from "../../persistence/Services/McpBinding.ts"; +import { McpCatalogRepository } from "../../persistence/Services/McpCatalog.ts"; + +export interface McpProjectionQueryShape { + readonly getSnapshot: (projectId: ProjectId | null) => Effect.Effect; + /** Initial snapshot followed by a snapshot on every authored change. */ + readonly subscriptionStream: Stream.Stream; +} + +export class McpProjectionQuery extends Context.Service< + McpProjectionQuery, + McpProjectionQueryShape +>()("ru-fork/mcp/McpProjectionQuery") {} + +function isProjectionRelevant(event: OrchestrationEvent): boolean { + return event.type.startsWith("mcp.") || event.type === "project.deleted"; +} + +const makeMcpProjectionQuery = Effect.gen(function* () { + const catalogRepository = yield* McpCatalogRepository; + const bindingRepository = yield* McpBindingRepository; + const engine = yield* OrchestrationEngineService; + + const getSnapshot: McpProjectionQueryShape["getSnapshot"] = (projectId) => + Effect.gen(function* () { + const catalog = yield* catalogRepository.listAll(); + const bindings = + projectId === null + ? yield* bindingRepository.listAll() + : yield* bindingRepository.listByProject({ projectId }); + return { catalog, bindings } satisfies McpSnapshot; + }).pipe( + Effect.mapError((cause) => new McpError({ detail: "Failed to read MCP snapshot", cause })), + ); + + const changeSnapshots: Stream.Stream = + engine.streamDomainEvents.pipe( + Stream.filter(isProjectionRelevant), + Stream.mapEffect(() => + getSnapshot(null).pipe( + Effect.map((snapshot): McpProjectionStreamEvent | null => ({ type: "snapshot", snapshot })), + // A transient snapshot read failure drops THIS update instead of ending the subscription + // (matches McpRuntime's resilience); the next event re-reads fresh. + Effect.catch(() => Effect.succeed(null)), + ), + ), + Stream.filter((event): event is McpProjectionStreamEvent => event !== null), + ); + + const subscriptionStream: Stream.Stream = Stream.concat( + Stream.fromEffect(getSnapshot(null)).pipe( + Stream.map((snapshot): McpProjectionStreamEvent => ({ type: "snapshot", snapshot })), + ), + changeSnapshots, + ); + + return { getSnapshot, subscriptionStream } satisfies McpProjectionQueryShape; +}); + +export const McpProjectionQueryLive = Layer.effect(McpProjectionQuery, makeMcpProjectionQuery); diff --git a/apps/server/src/ru-fork/mcp/McpProjectors.ts b/apps/server/src/ru-fork/mcp/McpProjectors.ts new file mode 100644 index 00000000000..a267569d86d --- /dev/null +++ b/apps/server/src/ru-fork/mcp/McpProjectors.ts @@ -0,0 +1,56 @@ +// ru-fork: MCP SQL projector bodies, kept out of the shared ProjectionPipeline. +// Factories over the repos return the `apply(event)` the pipeline registers. + +import type { OrchestrationEvent } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import type { ProjectionRepositoryError } from "../../persistence/Errors.ts"; +import type { McpBindingRepositoryShape } from "../../persistence/Services/McpBinding.ts"; +import type { McpCatalogRepositoryShape } from "../../persistence/Services/McpCatalog.ts"; + +type McpProjector = (event: OrchestrationEvent) => Effect.Effect; + +/** Catalog rows + cascade-remove a server's bindings when the server is removed. */ +export function makeMcpCatalogProjector( + catalogRepository: McpCatalogRepositoryShape, + bindingRepository: McpBindingRepositoryShape, +): McpProjector { + return Effect.fn("applyMcpCatalogProjection")(function* (event: OrchestrationEvent) { + switch (event.type) { + case "mcp.server-added": + case "mcp.server-updated": + yield* catalogRepository.upsert(event.payload.server); + return; + case "mcp.server-removed": + yield* catalogRepository.remove({ serverId: event.payload.serverId }); + yield* bindingRepository.removeByServer({ serverId: event.payload.serverId }); + return; + default: + return; + } + }); +} + +/** Binding rows + cascade-remove a project's bindings on project.deleted. */ +export function makeMcpBindingProjector( + bindingRepository: McpBindingRepositoryShape, +): McpProjector { + return Effect.fn("applyMcpBindingProjection")(function* (event: OrchestrationEvent) { + switch (event.type) { + case "mcp.binding-set": + yield* bindingRepository.upsert(event.payload.binding); + return; + case "mcp.binding-removed": + yield* bindingRepository.remove({ + projectId: event.payload.projectId, + serverId: event.payload.serverId, + }); + return; + case "project.deleted": + yield* bindingRepository.removeByProject({ projectId: event.payload.projectId }); + return; + default: + return; + } + }); +} diff --git a/apps/server/src/ru-fork/mcp/McpReactor.ts b/apps/server/src/ru-fork/mcp/McpReactor.ts new file mode 100644 index 00000000000..d7af1a2d845 --- /dev/null +++ b/apps/server/src/ru-fork/mcp/McpReactor.ts @@ -0,0 +1,632 @@ +// ru-fork: the MCP reactor — the half that turns authored state (catalog + +// bindings) into the supervisor's *desired* instance set. It owns no +// connections and no health logic (that is McpSupervisor); it only diffs the +// projections and calls `supervisor.reconcile`. +// +// Flow: a drainable worker reconciles on every relevant domain event +// (`mcp.*` / project create/update/delete) plus an initial tick at startup +// (so bindings restored from the DB are reconciled after a restart). At startup +// it reconciles the shipped built-in TEMPLATES against the installed catalog by +// `builtinId` (add new / update changed / remove dropped — see McpBuiltins); on +// `project.created` it optionally autobinds the builtins (gated by +// `settings.mcp.autobindDefaults`, default off). Sync/autobind go through normal +// commands, so the resulting events flow back through the worker and reconcile +// naturally. + +import { CommandId, McpServerId, type OrchestrationEvent, type ProjectId } from "@t3tools/contracts"; +import type { McpCatalogServer, McpVarValue } from "@t3tools/contracts"; +import { + configCacheKey, + configIdentity, + dedupHash, + effectiveTimeoutMs, + missingRequiredVars, + resolveConfig, +} from "@ru-fork/mcp-core"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSecretStore } from "../../auth/Services/ServerSecretStore.ts"; +import { OrchestrationEngineService } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { McpBindingRepository } from "../../persistence/Services/McpBinding.ts"; +import { McpCatalogRepository } from "../../persistence/Services/McpCatalog.ts"; +import { McpProbeCacheRepository } from "../../persistence/Services/McpProbeCache.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { + builtinConfigForPlatform, + builtinHash, + builtinServerId, + builtinShippedVars, + type McpBuiltinDefinition, +} from "./McpBuiltins.ts"; +import { MCP_BUILTINS } from "./mcpBuiltinDefinitions.ts"; +import { McpOverlay } from "./McpOverlay.ts"; +import { MCP_VAR_SECRET_PREFIX } from "./McpSecretNames.ts"; +import { collectVarSecretRefs, materializeSecretValues, missingSecretVarNames } from "./McpSecrets.ts"; +import { type DesiredInstance, McpSupervisor } from "./McpSupervisor.ts"; + +// ru-fork: a reactor-internal commandId is UNIQUE per dispatch, never content-stable. The engine +// dedups by commandId (OrchestrationEngine: an "accepted" receipt short-circuits before the decider), +// so a stable id makes the FIRST dispatch the only one that ever runs — re-add after remove, a 2nd +// orphan prune, a re-cleared description, etc. would be silently dropped. The reconcile-side state diffs +// (the hash gate / presence / orphan / empty-field checks) are the real idempotency guard, so a fresh +// uuid here is correct — and matches CheckpointReactor / ProviderCommandReactor. (autobind is the +// deliberate exception below: its stable id IS its "bind a builtin to a project once ever" guard.) +const mkReconcileCommandId = (tag: string) => + CommandId.make(`server:${tag}:${crypto.randomUUID()}`); + +export interface McpReactorShape { + /** Start reacting to authored MCP/project changes. Run inside the reactor scope. */ + readonly start: () => Effect.Effect; + /** Resolves when the reconcile queue is idle. Test seam. */ + readonly drain: Effect.Effect; +} + +export class McpReactor extends Context.Service()( + "ru-fork/mcp/McpReactor", +) {} + +/** + * What the worker was woken for. `eager` reconciles force-probe newly-appeared instances (a + * config-affecting change happened); the startup/hydrate reconcile is NOT eager (no probing on + * load). A `project-created` always reconciles eagerly after autobinding. + */ +type ReactorSignal = + | { readonly kind: "reconcile"; readonly eager: boolean } + | { readonly kind: "project-created"; readonly projectId: ProjectId }; + +const isReconcileRelevant = (event: OrchestrationEvent): boolean => + event.type.startsWith("mcp.") || + event.type === "project.deleted" || + event.type === "project.meta-updated"; + +/** + * ru-fork: reconcile shipped built-ins against the installed catalog by builtinId — add new, update on + * content-hash change (3-way merge preserves user values/vars/extraArgs), remove no-longer-shipped. + * Acquires the engine + catalog repo from context. Parameterized by the definition list + platform + * ONLY so the lifecycle can be driven from tests against a real engine; production calls it with + * `MCP_BUILTINS` + `process.platform` (see `reconcileBuiltins` below). Behaviour is identical to the + * loop it replaced — this is a pure extraction (a testability seam), not a logic change. + */ +export const reconcileBuiltinsWith = ( + definitions: ReadonlyArray, + platform: NodeJS.Platform, +) => + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const catalogRepository = yield* McpCatalogRepository; + const catalog = yield* catalogRepository.listAll(); + const installedByBuiltinId = new Map( + catalog + .filter((server) => server.builtinId !== null) + .map((server) => [server.builtinId, server]), + ); + const shippedBuiltinIds = new Set(); + for (const definition of definitions) { + const config = builtinConfigForPlatform(definition, platform); + if (config === null) { + continue; // unsupported on this OS — skip + } + shippedBuiltinIds.add(definition.builtinId); + const serverId = builtinServerId(definition.builtinId); + const hash = builtinHash(config, definition); + const installed = installedByBuiltinId.get(definition.builtinId); + if (installed && installed.builtinHash === hash) { + continue; // up to date + } + // ru-fork #2: skip-on-collision — never duplicate/clobber a DIFFERENT server that already has + // this config (e.g. a user hand-added the same command). The built-in stays on its last good + // config and self-heals once the conflicting server is gone. + const shippedIdentity = configIdentity(config, builtinShippedVars(definition), [], {}); + const collision = catalog.find( + (server) => + server.id !== McpServerId.make(serverId) && + configIdentity(server.config, server.vars, server.extraArgs, server.extraHeaders) === + shippedIdentity, + ); + if (collision) { + yield* Effect.logDebug("mcp reactor: built-in skipped (config conflicts with existing server)", { + builtinId: definition.builtinId, + conflictsWith: collision.id, + }); + continue; + } + yield* engine + .dispatch({ + type: "mcp.builtin-sync", + // ru-fork: unique per dispatch (see mkReconcileCommandId). Identity is `serverId` (stable), and + // whether to dispatch at all is decided by the hash GATE above (`installed.builtinHash === hash` + // ⇒ skip), so a no-op restart mints nothing and a changed definition / a re-add always re-syncs. + commandId: mkReconcileCommandId(`mcp-builtin-sync:${definition.builtinId}`), + serverId: McpServerId.make(serverId), + builtinId: definition.builtinId, + builtinHash: hash, + name: definition.name, + description: definition.description ?? null, + websiteUrl: definition.websiteUrl ?? null, + config, + shippedVars: builtinShippedVars(definition), + timeoutMs: definition.timeoutMs ?? null, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logError("mcp reactor failed to sync builtin", { + builtinId: definition.builtinId, + cause: Cause.pretty(cause), + }), + ), + ); + } + // Remove installed built-ins no longer shipped (cascade: bindings + secret GC handle the rest). + for (const installed of catalog) { + if (installed.builtinId !== null && !shippedBuiltinIds.has(installed.builtinId)) { + yield* engine + .dispatch({ + type: "mcp.server-remove", + commandId: mkReconcileCommandId(`mcp-builtin-remove:${installed.builtinId}`), + serverId: installed.id, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logError("mcp reactor failed to remove dropped builtin", { + builtinId: installed.builtinId, + cause: Cause.pretty(cause), + }), + ), + ); + } + } + }).pipe( + Effect.catchCause((cause) => + Effect.logError("mcp reactor failed to reconcile builtins", { cause: Cause.pretty(cause) }), + ), + ); + +/** + * ru-fork: B3 ② — fill an empty catalog description/websiteUrl from the server's cached probe metadata. + * Extracted (context-acquiring) ONLY as a testability seam; behaviour identical to the inlined closure. + */ +export const backfillServerMetadataEffect = Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const catalogRepository = yield* McpCatalogRepository; + const probeCache = yield* McpProbeCacheRepository; + const catalog = yield* catalogRepository.listAll(); + yield* Effect.forEach( + catalog, + (server) => + Effect.gen(function* () { + if (server.description !== null && server.websiteUrl !== null) { + return; // nothing to fill + } + const configKey = configCacheKey( + server.config, + server.vars, + {}, + server.extraArgs, + server.extraHeaders, + ); + const cached = yield* probeCache.getByKey({ configKey }); + if (Option.isNone(cached)) { + return; + } + const row = cached.value; + const trimmedDesc = row.serverDescription?.trim(); + const trimmedUrl = row.serverWebsiteUrl?.trim(); + const patch = { + ...(server.description === null && trimmedDesc ? { description: trimmedDesc } : {}), + ...(server.websiteUrl === null && trimmedUrl ? { websiteUrl: trimmedUrl } : {}), + }; + if (Object.keys(patch).length === 0) { + return; + } + yield* engine + .dispatch({ + type: "mcp.server-update", + commandId: mkReconcileCommandId( + `mcp-meta-backfill:${server.id}:${Object.keys(patch).toSorted().join("-")}`, + ), + serverId: server.id, + patch, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logError("mcp reactor failed to backfill server metadata", { + serverId: server.id, + cause: Cause.pretty(cause), + }), + ), + ); + }), + { discard: true }, + ); +}); + +/** + * ru-fork: item 11 — drop per-project var-values stranded by a catalog edit that removed a var + * (ref-preserving). Extracted (context-acquiring) ONLY as a testability seam; behaviour identical. + */ +export const pruneOrphanedVarValuesEffect = Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const catalogRepository = yield* McpCatalogRepository; + const bindingRepository = yield* McpBindingRepository; + const catalog = yield* catalogRepository.listAll(); + const bindings = yield* bindingRepository.listAll(); + const declaredByServer = new Map( + catalog.map((server) => [server.id, new Set(server.vars.map((variable) => variable.name))]), + ); + for (const binding of bindings) { + const declared = declaredByServer.get(binding.serverId); + if (!declared) { + continue; + } + const names = Object.keys(binding.varValues); + const keep = names.filter((name) => declared.has(name)); + if (keep.length === names.length) { + continue; // no orphans + } + yield* engine + .dispatch({ + type: "mcp.binding-set", + commandId: mkReconcileCommandId(`mcp-prune-vars:${binding.projectId}:${binding.serverId}`), + projectId: binding.projectId, + serverId: binding.serverId, + patch: { varValues: {}, keepVarValues: keep }, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logError("mcp reactor failed to prune orphaned var values", { + projectId: binding.projectId, + serverId: binding.serverId, + cause: Cause.pretty(cause), + }), + ), + ); + } +}).pipe( + Effect.catchCause((cause) => + Effect.logError("mcp reactor failed to scan bindings for orphaned var values", { + cause: Cause.pretty(cause), + }), + ), +); + +/** + * ru-fork: compute the DESIRED probe instance set from catalog + bindings — every enabled, complete + * catalog default (ref `catalog:`) and every enabled, complete binding (ref `:`), + * deduplicated by resolved-config hash (refcounted). Context-acquiring; extracted ONLY as a testability + * seam, behaviour identical to the inlined closure. + */ +export const computeDesiredEffect = Effect.gen(function* () { + const catalogRepository = yield* McpCatalogRepository; + const bindingRepository = yield* McpBindingRepository; + const secretStore = yield* ServerSecretStore; + const serverConfig = yield* ServerConfig; + const provideSecretStore = Effect.provideService(ServerSecretStore, secretStore); + const mergeDesired = ( + desired: Map, + server: McpCatalogServer, + varValues: Readonly>, + timeoutMs: number | undefined, + ref: string, + ) => + Effect.gen(function* () { + const secretValues = yield* materializeSecretValues(server.vars, varValues).pipe(provideSecretStore); + const resolved = resolveConfig({ + config: server.config, + vars: server.vars, + varValues, + extraArgs: server.extraArgs, + extraHeaders: server.extraHeaders, + timeoutMs, + context: { projectCwd: serverConfig.mcpProbeCwd, secretValues }, + }); + const hash = dedupHash(resolved); + const configKey = configCacheKey( + server.config, + server.vars, + varValues, + server.extraArgs, + server.extraHeaders, + ); + const existing = desired.get(hash); + desired.set( + hash, + existing + ? { ...existing, refs: new Set([...existing.refs, ref]) } + : { hash, configKey, resolved, refs: new Set([ref]) }, + ); + }); + + const catalog = yield* catalogRepository.listAll(); + const bindings = yield* bindingRepository.listAll(); + const serverById = new Map(catalog.map((server) => [server.id, server])); + const desired = new Map(); + for (const server of catalog) { + if (!server.enabled) { + continue; // ⑬ catalog-disabled ⇒ never probed + } + if (missingRequiredVars(server.vars, {}).length > 0) { + continue; + } + // ru-fork #9: a declared required secret whose stored value is gone ⇒ incomplete (never launch blank). + if ((yield* missingSecretVarNames(server.vars, {}).pipe(provideSecretStore)).length > 0) { + continue; + } + yield* mergeDesired(desired, server, {}, server.timeoutMs ?? undefined, `catalog:${server.id}`); + } + for (const binding of bindings) { + if (!binding.enabled) { + continue; + } + const server = serverById.get(binding.serverId); + if (!server) { + continue; + } + if (!server.enabled) { + continue; + } + if (missingRequiredVars(server.vars, binding.varValues).length > 0) { + continue; // incomplete ⇒ not probed/spawned (§D8) + } + // ru-fork #9: missing stored secret ⇒ incomplete (don't launch a blank credential). + if ( + (yield* missingSecretVarNames(server.vars, binding.varValues).pipe(provideSecretStore)).length > 0 + ) { + continue; + } + yield* mergeDesired( + desired, + server, + binding.varValues, + effectiveTimeoutMs(server, binding), + `${binding.projectId}:${binding.serverId}`, + ); + } + return desired; +}); + +/** + * ru-fork: item 10 — prune secret .bin files no longer referenced by any catalog var or binding value. + * Context-acquiring; extracted ONLY as a testability seam, behaviour identical. + */ +export const gcOrphanedSecretsEffect = Effect.gen(function* () { + const catalogRepository = yield* McpCatalogRepository; + const bindingRepository = yield* McpBindingRepository; + const secretStore = yield* ServerSecretStore; + const catalog = yield* catalogRepository.listAll(); + const bindings = yield* bindingRepository.listAll(); + const serverById = new Map(catalog.map((server) => [server.id, server])); + const live = new Set(); + for (const server of catalog) { + for (const ref of collectVarSecretRefs(server.vars, {})) { + live.add(ref); + } + } + for (const binding of bindings) { + const server = serverById.get(binding.serverId); + if (!server) { + continue; + } + for (const ref of collectVarSecretRefs(server.vars, binding.varValues)) { + live.add(ref); + } + } + yield* secretStore.pruneByPrefix(MCP_VAR_SECRET_PREFIX, live); +}).pipe( + Effect.catch((error) => Effect.logError("mcp reactor failed to GC orphaned secrets", { error })), +); + +/** + * ru-fork: auto-bind every catalog built-in to a new project IFF `settings.mcp.autobindDefaults` is on + * (default off). Context-acquiring; extracted ONLY as a testability seam, behaviour identical. + */ +export const autobindBuiltinsForProjectWith = (projectId: ProjectId) => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const catalogRepository = yield* McpCatalogRepository; + const engine = yield* OrchestrationEngineService; + const settings = yield* serverSettings.getSettings.pipe(Effect.catch(() => Effect.succeed(null))); + if (settings === null || !settings.mcp.autobindDefaults) { + return; + } + const catalog = yield* catalogRepository.listAll(); + const builtins = catalog.filter((server) => server.builtinId !== null); + yield* Effect.forEach( + builtins, + (builtin) => + engine + .dispatch({ + type: "mcp.binding-set", + commandId: CommandId.make(`server:mcp-autobind:${projectId}:${builtin.id}`), + projectId, + serverId: builtin.id, + patch: { enabled: true }, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logError("mcp reactor failed to autobind builtin", { + projectId, + serverId: builtin.id, + cause: Cause.pretty(cause), + }), + ), + ), + { discard: true }, + ); + }).pipe( + Effect.catchCause((cause) => + Effect.logError("mcp reactor failed to autobind builtins for project", { + projectId, + cause: Cause.pretty(cause), + }), + ), + ); + +const make = Effect.gen(function* () { + const catalogRepository = yield* McpCatalogRepository; + const bindingRepository = yield* McpBindingRepository; + const probeCache = yield* McpProbeCacheRepository; + const supervisor = yield* McpSupervisor; + const engine = yield* OrchestrationEngineService; + const serverSettings = yield* ServerSettingsService; + const serverConfig = yield* ServerConfig; + const overlay = yield* McpOverlay; + // Provided so the extracted reactor effects (which `yield*` ServerSecretStore) run here. + const secretStore = yield* ServerSecretStore; + + const computeDesired = computeDesiredEffect.pipe( + Effect.provideService(McpCatalogRepository, catalogRepository), + Effect.provideService(McpBindingRepository, bindingRepository), + Effect.provideService(ServerSecretStore, secretStore), + Effect.provideService(ServerConfig, serverConfig), + ); + + const gcOrphanedSecrets = gcOrphanedSecretsEffect.pipe( + Effect.provideService(McpCatalogRepository, catalogRepository), + Effect.provideService(McpBindingRepository, bindingRepository), + Effect.provideService(ServerSecretStore, secretStore), + ); + + // B3 ②: back-fill catalog `description`/`websiteUrl` from a server's first successful probe — but + // ONLY when the catalog field is empty (a shipped/user value always wins). Reads the server's + // default-config cache row and dispatches a metadata-only `mcp.server-update`. Replay-idempotent + // (deterministic, field-scoped commandId); converges (once filled, the next pass produces no patch). + const backfillServerMetadata = backfillServerMetadataEffect.pipe( + Effect.provideService(OrchestrationEngineService, engine), + Effect.provideService(McpCatalogRepository, catalogRepository), + Effect.provideService(McpProbeCacheRepository, probeCache), + // ru-fork #1: self-contained error absorption — it now runs directly in processSignal (after the + // probe), no longer wrapped by reconcileNow's outer catch. + Effect.catchCause((cause) => + Effect.logError("mcp reactor failed to back-fill server metadata", { cause: Cause.pretty(cause) }), + ), + ); + + const reconcileNow: Effect.Effect> = Effect.gen(function* () { + const desired = yield* computeDesired; + const added = yield* supervisor.reconcile(desired); + // GC: drop persisted cache rows whose authored config no longer exists in the + // live desired set (catalog default removed / override changed or dropped). + const liveConfigKeys = new Set([...desired.values()].map((instance) => instance.configKey)); + // Never run a "delete-all" on an empty desired set (a transient all-incomplete state must not wipe + // the cache); orphan rows are tiny and the next non-empty reconcile cleans them. + if (liveConfigKeys.size > 0) { + yield* probeCache + .deleteKeysNotIn([...liveConfigKeys]) + .pipe( + Effect.catch((error) => + Effect.logError("mcp reactor failed to GC probe cache", { error }), + ), + ); + } + yield* gcOrphanedSecrets; // item 10 — prune secret .bin files no longer referenced + return added; + }).pipe( + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logError("mcp reactor failed to reconcile desired instances", { + cause: Cause.pretty(cause), + }); + const empty: ReadonlyArray = []; + return empty; // cast-free: a failed reconcile added nothing to probe + }), + ), + ); + + // ru-fork: the reactor NO LONGER writes overlays or restarts sessions. The overlay is written at + // turn-start (ProviderCommandReactor), which also detects an overlay change vs. what the live + // session spawned with and re-spawns on the next turn (history preserved via resume). See + // mcp-specs/current/WORKING-LOGIC.md §12. This dissolves the startup race + the stale-session bug. + + // Reconcile shipped built-ins against the installed catalog by builtinId: add new, update on + // content-hash change (3-way merge preserves user values/vars/extraArgs), remove deleted. Runs at + // startup. NOT eager (no probing on load). Unsupported platform (no config variant) ⇒ skip. + const reconcileBuiltins = reconcileBuiltinsWith(MCP_BUILTINS, process.platform).pipe( + Effect.provideService(OrchestrationEngineService, engine), + Effect.provideService(McpCatalogRepository, catalogRepository), + ); + + const autobindBuiltinsForProject = (projectId: ProjectId) => + autobindBuiltinsForProjectWith(projectId).pipe( + Effect.provideService(ServerSettingsService, serverSettings), + Effect.provideService(McpCatalogRepository, catalogRepository), + Effect.provideService(OrchestrationEngineService, engine), + ); + + // item 11: drop var values stranded by a catalog edit that removed a var. The resolver already + // ignores unknown names, so this is hygiene + makes the secret GC exact. Ref-preserving (no + // plaintext needed) — `keepVarValues` carries the surviving names' existing refs through. + const pruneOrphanedVarValues = pruneOrphanedVarValuesEffect.pipe( + Effect.provideService(OrchestrationEngineService, engine), + Effect.provideService(McpCatalogRepository, catalogRepository), + Effect.provideService(McpBindingRepository, bindingRepository), + ); + + const processSignal = (signal: ReactorSignal): Effect.Effect => + Effect.gen(function* () { + const eager = signal.kind === "project-created" ? true : signal.eager; + if (signal.kind === "project-created") { + yield* autobindBuiltinsForProject(signal.projectId); + } + yield* pruneOrphanedVarValues; // item 11 — drop stranded var values (ref-preserving) + const added = yield* reconcileNow; + if (eager) { + // A config-affecting change (or new project) just landed — probe the newly-appeared + // instances NOW instead of waiting for the sweep (item 3). Cosmetic edits add nothing. + yield* supervisor.probeHashes(added); + } + // ru-fork #1: back-fill catalog description/websiteUrl from the probe cache. MUST run AFTER + // probeHashes — a fresh add has no cache row until the eager probe writes it, so an earlier pass + // (inside reconcileNow) saw nothing. Idempotent + converges; description is not in the overlay + // fingerprint, so this never respawns a session. On the non-eager startup path it reads the + // prior-session cache. + yield* backfillServerMetadata; + }); + + const worker = yield* makeDrainableWorker(processSignal); + + const start: McpReactorShape["start"] = Effect.fn("start")(function* () { + yield* Effect.logDebug("[mcp] reactor starting"); + // ru-fork: reconcile the shipped built-ins BEFORE subscribing. `engine.dispatch` resolves only + // after its event is published, so by the time this returns every `mcp.builtin-sync` event has + // already been published — to a PubSub with no reactor subscriber yet. PubSub never replays to a + // late subscriber, so the seed cannot feed back as an eager "change": a fresh install does NOT + // probe on load (item 2). The explicit non-eager reconcile below registers the new built-ins as + // «не проверено» until a real trigger. + yield* reconcileBuiltins; + // Subscribe AFTER seeding: only genuine post-startup user/runtime changes drive an eager probe. + yield* Effect.forkScoped( + Stream.runForEach(engine.streamDomainEvents, (event) => { + if (event.type === "project.created") { + return worker.enqueue({ kind: "project-created", projectId: event.payload.projectId }); + } + if (event.type === "project.deleted") { + // B4 ③: remove the deleted project's overlay dir (the genuine orphan — a deleted *server* + // self-heals via fingerprint), then reconcile (bindings already cascade; this GCs the rest). + return overlay + .removeOverlay(event.payload.projectId) + .pipe(Effect.andThen(worker.enqueue({ kind: "reconcile", eager: true }))); + } + if (isReconcileRelevant(event)) { + // A user/runtime change (catalog/binding edit, autobind) ⇒ probe what changed now. + return worker.enqueue({ kind: "reconcile", eager: true }); + } + return Effect.void; + }), + ); + // Initial reconcile: cover bindings restored from the DB on restart — NOT eager (no probing + // on load; cached status is shown, never-probed stays «не проверено» until a trigger). + yield* worker.enqueue({ kind: "reconcile", eager: false }); + }); + + return { start, drain: worker.drain } satisfies McpReactorShape; +}); + +export const McpReactorLive = Layer.effect(McpReactor, make); diff --git a/apps/server/src/ru-fork/mcp/McpReadModel.ts b/apps/server/src/ru-fork/mcp/McpReadModel.ts new file mode 100644 index 00000000000..9f808db7b17 --- /dev/null +++ b/apps/server/src/ru-fork/mcp/McpReadModel.ts @@ -0,0 +1,61 @@ +// ru-fork: pure read-model folds for MCP catalog/bindings, kept out of the +// shared projector.ts so its switch only delegates. + +import type { McpBinding, McpCatalogServer, McpServerId, ProjectId } from "@t3tools/contracts"; + +export function upsertMcpServer( + catalog: ReadonlyArray, + server: McpCatalogServer, +): McpCatalogServer[] { + const exists = catalog.some((entry) => entry.id === server.id); + return exists + ? catalog.map((entry) => (entry.id === server.id ? server : entry)) + : [...catalog, server]; +} + +export function removeMcpServer( + catalog: ReadonlyArray, + serverId: McpServerId, +): McpCatalogServer[] { + return catalog.filter((entry) => entry.id !== serverId); +} + +function isSameBinding(binding: McpBinding, projectId: ProjectId, serverId: McpServerId): boolean { + return binding.projectId === projectId && binding.serverId === serverId; +} + +export function upsertMcpBinding( + bindings: ReadonlyArray, + binding: McpBinding, +): McpBinding[] { + const exists = bindings.some((entry) => + isSameBinding(entry, binding.projectId, binding.serverId), + ); + return exists + ? bindings.map((entry) => + isSameBinding(entry, binding.projectId, binding.serverId) ? binding : entry, + ) + : [...bindings, binding]; +} + +export function removeMcpBinding( + bindings: ReadonlyArray, + projectId: ProjectId, + serverId: McpServerId, +): McpBinding[] { + return bindings.filter((entry) => !isSameBinding(entry, projectId, serverId)); +} + +export function removeMcpBindingsByProject( + bindings: ReadonlyArray, + projectId: ProjectId, +): McpBinding[] { + return bindings.filter((entry) => entry.projectId !== projectId); +} + +export function removeMcpBindingsByServer( + bindings: ReadonlyArray, + serverId: McpServerId, +): McpBinding[] { + return bindings.filter((entry) => entry.serverId !== serverId); +} diff --git a/apps/server/src/ru-fork/mcp/McpRuntime.ts b/apps/server/src/ru-fork/mcp/McpRuntime.ts new file mode 100644 index 00000000000..b095d24aca4 --- /dev/null +++ b/apps/server/src/ru-fork/mcp/McpRuntime.ts @@ -0,0 +1,135 @@ +// ru-fork: flattens supervisor instances into runtime snapshots for the UI — a +// per-(project,server) row for project bindings (joining each binding's tool +// policy with the instance's discovered tools) AND a per-server catalog row +// (from the catalog default's probe), so the Каталог tab shows status + tools +// even before a server is bound. Advisory only; the cache is the source of truth. + +import { + IsoDateTime, + type McpCatalogRuntimeSnapshot, + type McpRuntimeSnapshot, + type McpRuntimeStreamEvent, +} from "@t3tools/contracts"; +import { configCacheKey, effectiveAllowedTools } from "@ru-fork/mcp-core"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; + +import { McpBindingRepository } from "../../persistence/Services/McpBinding.ts"; +import { McpCatalogRepository } from "../../persistence/Services/McpCatalog.ts"; +import { McpSupervisor, type SupervisorInstance } from "./McpSupervisor.ts"; + +const RUNTIME_DEBOUNCE = Duration.millis(200); + +export interface McpRuntimeShape { + /** Initial snapshot followed by debounced full-snapshot updates on every change. */ + readonly subscriptionStream: Stream.Stream; +} + +export class McpRuntime extends Context.Service()( + "ru-fork/mcp/McpRuntime", +) {} + +const instanceRefKey = (projectId: string, serverId: string): string => `${projectId}:${serverId}`; + +const makeMcpRuntime = Effect.gen(function* () { + const supervisor = yield* McpSupervisor; + const bindingRepository = yield* McpBindingRepository; + const catalogRepository = yield* McpCatalogRepository; + + const currentSnapshot: Effect.Effect<{ + readonly runtimes: ReadonlyArray; + readonly catalogRuntimes: ReadonlyArray; + }> = Effect.gen(function* () { + const instances = yield* supervisor.currentInstances; + const inFlight = yield* supervisor.currentInFlight; + const bindings = yield* bindingRepository.listAll(); + const catalog = yield* catalogRepository.listAll(); + + const instanceByRef = new Map(); + const instanceByConfigKey = new Map(); + for (const instance of instances) { + instanceByConfigKey.set(instance.configKey, instance); + for (const ref of instance.refs) { + instanceByRef.set(ref, instance); + } + } + + const runtimes: McpRuntimeSnapshot[] = []; + for (const binding of bindings) { + if (!binding.enabled) { + continue; + } + const instance = instanceByRef.get(instanceRefKey(binding.projectId, binding.serverId)); + if (!instance) { + continue; + } + runtimes.push({ + projectId: binding.projectId, + serverId: binding.serverId, + status: instance.status, + checking: inFlight.has(instance.hash), + ...(instance.message !== null ? { message: instance.message } : {}), + ...(instance.latencyMs !== null ? { latencyMs: instance.latencyMs } : {}), + ...(instance.checkedAt !== null ? { checkedAt: IsoDateTime.make(instance.checkedAt) } : {}), + discoveredTools: instance.discoveredTools, + effectiveAllowedTools: effectiveAllowedTools(binding.toolPolicy, instance.discoveredTools), + }); + } + + const catalogRuntimes: McpCatalogRuntimeSnapshot[] = []; + for (const server of catalog) { + // The catalog default's instance is keyed on the template + vars with NO + // per-project values — matching the reactor's `catalog:` desired entry. + const instance = instanceByConfigKey.get( + configCacheKey(server.config, server.vars, {}, server.extraArgs, server.extraHeaders), + ); + if (!instance) { + continue; + } + catalogRuntimes.push({ + serverId: server.id, + status: instance.status, + checking: inFlight.has(instance.hash), + ...(instance.message !== null ? { message: instance.message } : {}), + ...(instance.latencyMs !== null ? { latencyMs: instance.latencyMs } : {}), + ...(instance.checkedAt !== null ? { checkedAt: IsoDateTime.make(instance.checkedAt) } : {}), + discoveredTools: instance.discoveredTools, + }); + } + + return { runtimes, catalogRuntimes }; + }).pipe( + // ru-fork: a transient read failure must not crash the UI stream — return an empty snapshot, but log + // it (a persistent failure shouldn't be masked as "no servers configured"). + Effect.catch((cause) => + Effect.logDebug("mcp runtime: snapshot read failed; returning empty", { cause }).pipe( + Effect.as({ runtimes: [], catalogRuntimes: [] }), + ), + ), + ); + + const snapshotEvent = currentSnapshot.pipe( + Effect.map( + ({ runtimes, catalogRuntimes }): McpRuntimeStreamEvent => ({ + type: "snapshot", + runtimes, + catalogRuntimes, + }), + ), + ); + + const subscriptionStream: Stream.Stream = Stream.concat( + Stream.fromEffect(snapshotEvent), + supervisor.changes.pipe( + Stream.debounce(RUNTIME_DEBOUNCE), + Stream.mapEffect(() => snapshotEvent), + ), + ); + + return { subscriptionStream } satisfies McpRuntimeShape; +}); + +export const McpRuntimeLive = Layer.effect(McpRuntime, makeMcpRuntime); diff --git a/apps/server/src/ru-fork/mcp/McpSecretNames.ts b/apps/server/src/ru-fork/mcp/McpSecretNames.ts new file mode 100644 index 00000000000..efa0d095223 --- /dev/null +++ b/apps/server/src/ru-fork/mcp/McpSecretNames.ts @@ -0,0 +1,19 @@ +// ru-fork: deterministic, collision-free ServerSecretStore key for one MCP var +// value. Catalog-level secret vars are keyed by (serverId, varName); per-project +// (binding) secret values add the projectId. Mirrors the base64url compound-key +// scheme used for provider environment secrets (serverSettings.ts). + +const encode = (value: string): string => Buffer.from(value, "utf8").toString("base64url"); + +/** Shared prefix of every MCP var secret name — used by the reactor's secret GC (item 10). */ +export const MCP_VAR_SECRET_PREFIX = "mcp-var-"; + +export function mcpVarSecretName(input: { + readonly serverId: string; + readonly varName: string; + /** Present ⇒ a per-binding (per-project) secret value; absent ⇒ catalog-level. */ + readonly projectId?: string; +}): string { + const base = `${MCP_VAR_SECRET_PREFIX}${encode(input.serverId)}-${encode(input.varName)}`; + return input.projectId === undefined ? base : `${base}-${encode(input.projectId)}`; +} diff --git a/apps/server/src/ru-fork/mcp/McpSecrets.ts b/apps/server/src/ru-fork/mcp/McpSecrets.ts new file mode 100644 index 00000000000..cc7a1e80d38 --- /dev/null +++ b/apps/server/src/ru-fork/mcp/McpSecrets.ts @@ -0,0 +1,182 @@ +// ru-fork: split inbound draft vars (plaintext secret values) into +// ServerSecretStore + secret refs, and materialize refs back to plaintext for +// probing/overlay. Catalog-level secret vars are keyed per-server; per-project +// secret values are keyed per-binding. Plain vars stay plaintext. See +// mcp-vars-redesign.md §D5. + +import type { + McpServerVar, + McpServerVarDraft, + McpServerId, + McpVarValue, + ProjectId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { type SecretStoreError, ServerSecretStore } from "../../auth/Services/ServerSecretStore.ts"; +import { mcpVarSecretName } from "./McpSecretNames.ts"; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +/** A var value is a secret reference (vs a plain string) when it's an object. */ +function isSecretRef(value: McpVarValue): value is { readonly secretRef: string } { + return typeof value === "object"; +} + +/** + * Persist each catalog-level secret var's plaintext and return vars holding only + * refs. Plain vars keep their string value; a null value (per-project hole) stays + * null. A secret var with a null value (per-project secret, no catalog default) + * also stays null — its value is supplied per binding. + */ +export const splitServerVars = ( + serverId: McpServerId, + draftVars: ReadonlyArray, + existingVars: ReadonlyArray, +): Effect.Effect, SecretStoreError, ServerSecretStore> => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + const existingByName = new Map(existingVars.map((variable) => [variable.name, variable])); + const result: McpServerVar[] = []; + for (const draft of draftVars) { + const base = { + name: draft.name, + secret: draft.secret, + perProject: draft.perProject, + required: draft.required, + origin: "user" as const, + }; + // Keep an untouched secret: reuse the existing stored ref instead of overwriting. Only valid + // when the existing var is a secret holding a ref (not a per-project hole / plain value). + if (draft.secret && draft.keepSecret === true) { + const previousValue = existingByName.get(draft.name)?.value; + if (previousValue !== null && previousValue !== undefined && isSecretRef(previousValue)) { + result.push({ ...base, value: previousValue }); + continue; + } + // No prior ref to keep (renamed / new) ⇒ fall through to treat `value` normally. + } + if (draft.value === null) { + result.push({ ...base, value: null }); + continue; + } + if (draft.secret) { + const secretName = mcpVarSecretName({ serverId, varName: draft.name }); + yield* secretStore.set(secretName, textEncoder.encode(draft.value)); + result.push({ ...base, value: { secretRef: secretName } }); + } else { + result.push({ ...base, value: draft.value }); + } + } + return result; + }); + +/** + * Resolve a binding's plaintext per-project var values into stored refs (for + * secret vars) / plain strings, keyed by var name. Only the vars the binding + * actually supplied are present; the var's `secret` flag decides storage. + */ +export const splitBindingVarValues = (input: { + readonly projectId: ProjectId; + readonly serverId: McpServerId; + readonly vars: ReadonlyArray; + readonly draftVarValues: Readonly>; + /** Names whose existing stored value/ref must be preserved (client left the masked field blank). */ + readonly keepNames: ReadonlyArray; + /** The binding's existing var values, to source the preserved entries from. */ + readonly existing: Readonly>; +}): Effect.Effect, SecretStoreError, ServerSecretStore> => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + const secretVarNames = new Set( + input.vars.filter((declared) => declared.secret).map((declared) => declared.name), + ); + const result: Record = {}; + // Preserve untouched entries (masked secret left blank): carry the existing value/ref over. + for (const name of input.keepNames) { + const previous = input.existing[name]; + if (previous !== undefined) { + result[name] = previous; + } + } + for (const [name, value] of Object.entries(input.draftVarValues)) { + if (secretVarNames.has(name)) { + const secretName = mcpVarSecretName({ + serverId: input.serverId, + varName: name, + projectId: input.projectId, + }); + yield* secretStore.set(secretName, textEncoder.encode(value)); + result[name] = { secretRef: secretName }; + } else { + result[name] = value; + } + } + return result; + }); + +/** The effective var value for a name: binding override → catalog default → null. */ +function effectiveVarValue( + declared: McpServerVar, + varValues: Readonly>, +): McpVarValue | null { + return declared.name in varValues ? varValues[declared.name]! : declared.value; +} + +/** Every secret ref referenced by the effective var values (catalog + binding). */ +export function collectVarSecretRefs( + vars: ReadonlyArray, + varValues: Readonly>, +): ReadonlyArray { + const refs: string[] = []; + for (const declared of vars) { + const effective = effectiveVarValue(declared, varValues); + if (effective !== null && isSecretRef(effective)) { + refs.push(effective.secretRef); + } + } + return refs; +} + +/** + * ru-fork #9: names of REQUIRED secret vars whose stored secret is ABSENT (deleted / never written). + * Such a var resolves to "" and would launch a blank credential — the caller treats the instance as + * incomplete (excluded from probe + overlay), exactly like a missing required value. + */ +export const missingSecretVarNames = ( + vars: ReadonlyArray, + varValues: Readonly>, +): Effect.Effect, SecretStoreError, ServerSecretStore> => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + const missing: string[] = []; + for (const declared of vars) { + if (!declared.required) { + continue; // optional secret missing ⇒ "" is acceptable (matches missingRequiredVars semantics) + } + const effective = effectiveVarValue(declared, varValues); + if (effective !== null && isSecretRef(effective)) { + const bytes = yield* secretStore.get(effective.secretRef); + if (bytes === null) { + missing.push(declared.name); + } + } + } + return missing; + }); + +/** Materialize every effective secret ref to plaintext (ref-name → value). */ +export const materializeSecretValues = ( + vars: ReadonlyArray, + varValues: Readonly>, +): Effect.Effect, SecretStoreError, ServerSecretStore> => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + const resolved: Record = {}; + for (const ref of collectVarSecretRefs(vars, varValues)) { + const bytes = yield* secretStore.get(ref); + resolved[ref] = bytes ? textDecoder.decode(bytes) : ""; + } + return resolved; + }); diff --git a/apps/server/src/ru-fork/mcp/McpSupervisor.ts b/apps/server/src/ru-fork/mcp/McpSupervisor.ts new file mode 100644 index 00000000000..c1077a9e5d3 --- /dev/null +++ b/apps/server/src/ru-fork/mcp/McpSupervisor.ts @@ -0,0 +1,515 @@ +// ru-fork: MCP instance supervisor. Owns the in-memory registry of live MCP +// instances keyed by resolved-config hash (dedup: two bindings with the same +// resolved config share one instance, refcounted by their (project,server) keys). +// +// Monitoring is advisory and holds NO connections: a single sweep loop reprobes +// every instance on an interval (probeOnce = connect → listTools → close). The +// status transition is a small state machine. Reconcile is a pure diff against +// the registry Ref. Nothing here writes config or touches qwen. + +import { + IsoDateTime, + type McpProbeRecord, + type McpRuntimeStatus, + type McpTool, + type McpTransport, +} from "@t3tools/contracts"; +import { + DEFAULT_PROBE_TIMEOUT_MS, + probeOnce, + type ProbeResult, + type ResolvedServerConfig, +} from "@ru-fork/mcp-core"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import { McpProbeCacheRepository } from "../../persistence/Services/McpProbeCache.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; + +// The loop just ticks to check which instances are DUE — actual probes are +// gated by the per-transport recheck intervals, so a frequent tick is cheap. +const SWEEP_INTERVAL = Duration.seconds(60); +const OFFLINE_THRESHOLD = 3; + +/** Short description of what a probe targets, for logs. */ +const probeTarget = (resolved: ResolvedServerConfig): string => + resolved.transport === "stdio" + ? `${resolved.command ?? ""} ${(resolved.args ?? []).join(" ")}`.trim() + : (resolved.httpUrl ?? ""); + +/** One (project,server) reference onto a shared instance, as `${projectId}:${serverId}`. */ +export type InstanceRef = string; + +export interface SupervisorInstance { + readonly hash: string; + /** Authored-config cache key (cwd-independent) — the probe-cache row this instance maps to. */ + readonly configKey: string; + readonly resolved: ResolvedServerConfig; + readonly refs: ReadonlySet; + readonly status: McpRuntimeStatus; + readonly message: string | null; + readonly latencyMs: number | null; + readonly checkedAt: string | null; + /** Epoch ms of the last probe (null = never) — drives the due check without re-parsing ISO. */ + readonly checkedAtMs: number | null; + readonly discoveredTools: ReadonlyArray; + readonly consecutiveFailures: number; +} + +const MINUTE_MS = 60_000; + +/** + * A ref is `${projectId}:${serverId}` (a project binding) or `catalog:${serverId}` + * (the catalog default, even when unbound). Split into its two parts; the project + * part is "catalog" for catalog refs, which never matches a real project id. + */ +function parseRef(ref: InstanceRef): { readonly projectPart: string; readonly serverId: string } { + const colon = ref.indexOf(":"); + return colon > 0 + ? { projectPart: ref.slice(0, colon), serverId: ref.slice(colon + 1) } + : { projectPart: "", serverId: ref }; +} + +export function instanceInWatched( + instance: SupervisorInstance, + watched: ReadonlySet, +): boolean { + for (const ref of instance.refs) { + if (watched.has(parseRef(ref).projectPart)) { + return true; + } + } + return false; +} + +/** + * The sweep's per-instance decision (pure, so it can be unit-tested): + * - a NEVER-checked instance is never auto-probed (no probing on load); its first probe comes + * only from a manual recheck or a config-affecting change (reactor `probeHashes`); + * - otherwise only the WATCHED (active) project's already-probed instances re-probe, and only + * once their per-transport interval has elapsed (`watched === null` ⇒ all). + */ +export function isSweepDue( + instance: SupervisorInstance, + watched: ReadonlySet | null, + nowMs: number, + localIntervalMs: number, + remoteIntervalMs: number, +): boolean { + // A never-probed instance is NEVER auto-probed (no probing on load) — its first probe comes only + // from a manual recheck or a config-affecting change (reactor `probeHashes`). The sweep re-checks + // ONLY already-probed instances of the watched project, on their transport interval. + if (instance.checkedAtMs === null) { + return false; + } + if (watched !== null && !instanceInWatched(instance, watched)) { + return false; + } + return isProbeDue(instance, nowMs, localIntervalMs, remoteIntervalMs); +} + +/** Which live instances a manual recheck targets — all filters are AND-combined. */ +export interface RecheckFilter { + readonly projectId?: string; + readonly serverId?: string; + readonly transport?: McpTransport; +} + +/** True when any of the instance's refs satisfies the (project, server) filter. */ +export function instanceMatchesRecheck( + instance: SupervisorInstance, + filter: RecheckFilter, +): boolean { + if (filter.transport !== undefined && instance.resolved.transport !== filter.transport) { + return false; + } + for (const ref of instance.refs) { + const { projectPart, serverId } = parseRef(ref); + if (filter.projectId !== undefined && projectPart !== filter.projectId) { + continue; + } + if (filter.serverId !== undefined && serverId !== filter.serverId) { + continue; + } + return true; + } + return false; +} + +/** + * A never-probed instance is always due; otherwise re-probe only when its + * transport's interval (minutes) has elapsed. 0 minutes ⇒ never auto re-check. + */ +export function isProbeDue( + instance: SupervisorInstance, + nowMs: number, + localIntervalMs: number, + remoteIntervalMs: number, +): boolean { + if (instance.checkedAtMs === null) { + return false; // never probed ⇒ not auto-due (probe only on manual / config change) + } + const intervalMs = instance.resolved.transport === "stdio" ? localIntervalMs : remoteIntervalMs; + if (intervalMs <= 0) { + return false; + } + return nowMs - instance.checkedAtMs >= intervalMs; +} + +export interface DesiredInstance { + readonly hash: string; + readonly configKey: string; + readonly resolved: ResolvedServerConfig; + readonly refs: ReadonlySet; +} + +export interface McpSupervisorShape { + /** + * Reconcile the registry to exactly `desired` (add/keep/drop by hash). Returns the hashes that + * were newly added by this reconcile (not previously registered) — brand-new instances or configs + * whose key just changed. The reactor force-probes these on a config-affecting change. + */ + readonly reconcile: ( + desired: ReadonlyMap, + ) => Effect.Effect>; + /** + * Scope auto-probing to these project ids (what the client is viewing). Empty + * ⇒ probe nothing. Until first called the supervisor probes ALL projects, so + * probing never silently stops if the client never signals. + */ + readonly setWatchedProjects: (projectIds: ReadonlyArray) => Effect.Effect; + /** + * Force a probe NOW of every live instance matching the filter, bypassing the + * due-gate (manual "Проверить" / refresh). Coalesces with in-flight probes. + * Resolves once the triggered probes settle; results land via `changes`. + */ + readonly recheck: (filter: RecheckFilter) => Effect.Effect; + /** Force a probe NOW of the live instances with these hashes (config-change driven). */ + readonly probeHashes: (hashes: ReadonlyArray) => Effect.Effect; + readonly currentInstances: Effect.Effect>; + /** Hashes with a probe currently in flight — drives the UI «проверка…» indicator. */ + readonly currentInFlight: Effect.Effect>; + /** A tick on every registry/status change. */ + readonly changes: Stream.Stream; + /** Start the health-sweep loop (call inside the reactor scope). */ + readonly start: () => Effect.Effect; + /** + * ru-fork test seam: run ONE sweep tick synchronously — the exact body the periodic `start()` loop + * repeats every 60s. Exposed so the sweep orchestration (due-filter, watched scope, interval gate) + * can be tested deterministically without waiting on the real schedule. Production never calls this. + */ + readonly sweepOnce: Effect.Effect; +} + +export class McpSupervisor extends Context.Service()( + "ru-fork/mcp/McpSupervisor", +) {} + +// Exported for unit testing (alongside isProbeDue/isSweepDue). +export function nextStatus( + result: ProbeResult, + previousFailures: number, +): { readonly status: McpRuntimeStatus; readonly consecutiveFailures: number } { + switch (result.status) { + case "online": + return { status: "online", consecutiveFailures: 0 }; + case "offline": { + const consecutiveFailures = previousFailures + 1; + // A HARD failure (connection refused/closed, ENOENT, spawn error) is down NOW → red. Only a + // TIMEOUT gets the degraded/retry buffer (a slow server may recover within OFFLINE_THRESHOLD). + const status: McpRuntimeStatus = + !result.timedOut || consecutiveFailures >= OFFLINE_THRESHOLD ? "offline" : "degraded"; + return { status, consecutiveFailures }; + } + } +} + +const makeMcpSupervisor = Effect.gen(function* () { + const serverSettings = yield* ServerSettingsService; + const probeCache = yield* McpProbeCacheRepository; + const registryRef = yield* Ref.make>(new Map()); + // null ⇒ never told which project is active ⇒ probe all (safe default). + const watchedProjectsRef = yield* Ref.make | null>(null); + // Hashes currently being probed — coalesces concurrent sweep + manual probes so + // one authored config is never connected to twice at the same instant. + const inFlightRef = yield* Ref.make>(new Set()); + const changesPubSub = yield* PubSub.unbounded(); + + const setWatchedProjects: McpSupervisorShape["setWatchedProjects"] = (projectIds) => + Ref.set(watchedProjectsRef, new Set(projectIds)); + + const publishChange = PubSub.publish(changesPubSub, undefined).pipe(Effect.asVoid); + + const cachedSeed = (configKey: string) => + probeCache.getByKey({ configKey }).pipe( + Effect.map(Option.getOrUndefined), + Effect.catch(() => Effect.sync(() => undefined)), + ); + + const reconcile: McpSupervisorShape["reconcile"] = (desired) => + Effect.gen(function* () { + yield* Effect.logDebug("[mcp] reconcile", { + instances: desired.size, + refs: [...desired.values()].reduce((total, instance) => total + instance.refs.size, 0), + }); + const current = yield* Ref.get(registryRef); + // Hydrate brand-new instances from the persisted cache (status/tools/checkedAt) + // so a restart shows last-known state and does NOT immediately re-probe. + const seeds = new Map(); + for (const [hash, desiredInstance] of desired) { + if (current.has(hash)) { + continue; + } + const record = yield* cachedSeed(desiredInstance.configKey); + if (record) { + seeds.set(hash, record); + } + } + const next = new Map(); + for (const [hash, desiredInstance] of desired) { + const existing = current.get(hash); + if (existing) { + next.set(hash, { + ...existing, + configKey: desiredInstance.configKey, + resolved: desiredInstance.resolved, + refs: desiredInstance.refs, + }); + continue; + } + const seed = seeds.get(hash); + next.set( + hash, + seed + ? { + hash, + configKey: desiredInstance.configKey, + resolved: desiredInstance.resolved, + refs: desiredInstance.refs, + status: seed.status === "online" ? "online" : "offline", + message: seed.lastError, + latencyMs: null, + checkedAt: seed.checkedAt, + checkedAtMs: seed.checkedAtMs, + discoveredTools: seed.tools, + consecutiveFailures: seed.status === "online" ? 0 : 1, + } + : { + hash, + configKey: desiredInstance.configKey, + resolved: desiredInstance.resolved, + refs: desiredInstance.refs, + status: "unchecked", + message: null, + latencyMs: null, + checkedAt: null, + checkedAtMs: null, + discoveredTools: [], + consecutiveFailures: 0, + }, + ); + } + yield* Ref.set(registryRef, next); + yield* publishChange; + // Hashes that are brand-new OR that gained a ref this reconcile (e.g. a config equal to an + // already-registered unchecked catalog default that was just bound to a project). Both warrant + // a probe on an eager (user-driven) change; incomplete instances never reach here (computeDesired + // excludes them), so this can only ever probe COMPLETE instances. The reactor force-probes the + // returned hashes on a config-affecting change (item 3). + return [...next.entries()] + .filter(([hash, instance]) => { + const before = current.get(hash); + return !before || [...instance.refs].some((ref) => !before.refs.has(ref)); + }) + .map(([hash]) => hash); + }); + + const applyProbeResult = ( + hash: string, + result: ProbeResult, + checkedAt: string, + checkedAtMs: number, + ) => + Ref.update(registryRef, (current) => { + const latest = current.get(hash); + if (!latest) { + return current; // instance was reconciled away mid-probe + } + const transition = nextStatus(result, latest.consecutiveFailures); + const next = new Map(current); + next.set(hash, { + ...latest, + status: transition.status, + consecutiveFailures: transition.consecutiveFailures, + message: result.message ?? null, + latencyMs: result.latencyMs, + checkedAt, + checkedAtMs, + discoveredTools: result.status === "online" ? result.tools : latest.discoveredTools, + }); + return next; + }).pipe(Effect.andThen(publishChange)); + + // Claim the in-flight slot for a hash; returns true if another fiber already + // holds it (so the caller should skip). Explicit tuple type avoids `as const`. + const claimInFlight = (hash: string): Effect.Effect => + Ref.modify(inFlightRef, (set): readonly [boolean, ReadonlySet] => + set.has(hash) ? [true, set] : [false, new Set([...set, hash])], + ); + + const releaseInFlight = (hash: string) => + Ref.update(inFlightRef, (set) => { + const next = new Set(set); + next.delete(hash); + return next; + }); + + const runProbe = (instance: SupervisorInstance) => + Effect.gen(function* () { + const timeoutMs = instance.resolved.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; + yield* Effect.logDebug("[mcp] probe start", { + hash: instance.hash, + transport: instance.resolved.transport, + target: probeTarget(instance.resolved), + timeoutMs, + }); + const result = yield* Effect.promise(() => probeOnce(instance.resolved, timeoutMs)); + const checkedAtMs = yield* Clock.currentTimeMillis; + const checkedAt = DateTime.formatIso(yield* DateTime.now); + yield* Effect.logDebug("[mcp] probe result", { + hash: instance.hash, + target: probeTarget(instance.resolved), + status: result.status, + latencyMs: result.latencyMs, + tools: result.tools.length, + ...(result.timedOut ? { timedOut: true } : {}), + ...(result.message ? { message: result.message } : {}), + }); + yield* applyProbeResult(instance.hash, result, checkedAt, checkedAtMs); + // Write-through to the persisted, config-keyed cache (shared across projects + // on the same authored config; survives restart). + yield* probeCache + .upsert({ + configKey: instance.configKey, + transport: instance.resolved.transport, + status: result.status, + tools: result.tools, + lastError: result.message ?? null, + serverDescription: result.serverDescription ?? null, + serverWebsiteUrl: result.serverWebsiteUrl ?? null, + checkedAt: IsoDateTime.make(checkedAt), + checkedAtMs, + }) + .pipe( + Effect.catch((error) => + Effect.logError("[mcp] probe cache write failed", { hash: instance.hash, error }), + ), + ); + }); + + // Probe one instance, coalescing with any in-flight probe of the same config. The claim+release are + // bracketed (acquireUseRelease) so the in-flight slot is released even if the fiber is interrupted — + // `claimInFlight` only mutates when it returns false, so we release only when we acquired. + const probeInstance = (instance: SupervisorInstance) => + Effect.acquireUseRelease( + claimInFlight(instance.hash), + (alreadyRunning) => + alreadyRunning + ? Effect.void // another fiber is already probing this exact config + : Effect.gen(function* () { + yield* publishChange; // flip the UI to «проверка…» the instant the probe starts + yield* runProbe(instance); + }), + (alreadyRunning) => + alreadyRunning + ? Effect.void + : Effect.gen(function* () { + yield* releaseInFlight(instance.hash); + yield* publishChange; // and again when it settles, so «проверка…» clears + }), + ); + + const probeHashes: McpSupervisorShape["probeHashes"] = (hashes) => + Effect.gen(function* () { + if (hashes.length === 0) { + return; + } + const registry = yield* Ref.get(registryRef); + const matched = hashes + .map((hash) => registry.get(hash)) + .filter((instance): instance is SupervisorInstance => instance !== undefined); + yield* Effect.forEach(matched, probeInstance, { concurrency: 4, discard: true }); + }); + + const recheck: McpSupervisorShape["recheck"] = (filter) => + Effect.gen(function* () { + const instances = [...(yield* Ref.get(registryRef)).values()]; + const matched = instances.filter((instance) => instanceMatchesRecheck(instance, filter)); + yield* Effect.logDebug("[mcp] manual recheck", { ...filter, matched: matched.length }); + yield* Effect.forEach(matched, probeInstance, { concurrency: 4, discard: true }); + }); + + const runSweep = Effect.gen(function* () { + const settings = yield* serverSettings.getSettings.pipe( + Effect.catch(() => Effect.succeed(null)), + ); + const instances = [...(yield* Ref.get(registryRef)).values()]; + if (settings === null || instances.length === 0) { + return; // can't read cadence, or nothing to probe — keep last status + } + const localIntervalMs = settings.mcp.recheckLocalMinutes * MINUTE_MS; + const remoteIntervalMs = settings.mcp.recheckRemoteMinutes * MINUTE_MS; + // Both intervals 0 ⇒ no periodic re-checking at all: the loop is off. The first probe of any + // server then comes only from a manual recheck or a config-affecting change. + if (localIntervalMs <= 0 && remoteIntervalMs <= 0) { + return; + } + const nowMs = yield* Clock.currentTimeMillis; + const watched = yield* Ref.get(watchedProjectsRef); + // The loop ticks every 60s but probes only what's due (watched project + elapsed interval; + // never-probed instances are excluded — see `isSweepDue`). + const due = instances.filter((instance) => + isSweepDue(instance, watched, nowMs, localIntervalMs, remoteIntervalMs), + ); + if (due.length === 0) { + return; + } + yield* Effect.logDebug("[mcp] sweep", { due: due.length, total: instances.length }); + yield* Effect.forEach(due, probeInstance, { concurrency: 4, discard: true }); + }); + + const currentInstances: McpSupervisorShape["currentInstances"] = Ref.get(registryRef).pipe( + Effect.map((registry) => [...registry.values()]), + ); + + const currentInFlight: McpSupervisorShape["currentInFlight"] = Ref.get(inFlightRef); + + const start: McpSupervisorShape["start"] = () => + Effect.forkScoped(runSweep.pipe(Effect.repeat(Schedule.spaced(SWEEP_INTERVAL)))).pipe( + Effect.asVoid, + ); + + return { + reconcile, + setWatchedProjects, + recheck, + probeHashes, + currentInstances, + currentInFlight, + changes: Stream.fromPubSub(changesPubSub), + start, + sweepOnce: runSweep, // ru-fork test seam: one tick of the periodic sweep loop (see shape doc) + } satisfies McpSupervisorShape; +}); + +export const McpSupervisorLive = Layer.effect(McpSupervisor, makeMcpSupervisor); diff --git a/apps/server/src/ru-fork/mcp/mcpBuiltinDefinitions.ts b/apps/server/src/ru-fork/mcp/mcpBuiltinDefinitions.ts new file mode 100644 index 00000000000..83d5ae79ab6 --- /dev/null +++ b/apps/server/src/ru-fork/mcp/mcpBuiltinDefinitions.ts @@ -0,0 +1,70 @@ +// ru-fork: the SHIPPED built-in MCP catalog — DATA ONLY. This is the single edit point for +// built-ins: add / remove / change entries here freely (any number, per-platform configs, shipped +// vars). No logic lives here — the type + helpers + the migrator (McpReactor) all operate off +// `McpBuiltinDefinition`, so editing this list never requires touching other code. Ship +// DECLARATIONS only — never real secret values (secret vars carry value:null). + +import type { McpBuiltinDefinition } from "./McpBuiltins.ts"; + +export const MCP_BUILTINS: ReadonlyArray = [ + { + builtinId: "filesystem", + name: "filesystem", + description: + "Чтение и запись файлов в каталоге проекта. Запускается локально через npx, без секретов.", + websiteUrl: "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem", + config: { + default: { + transport: "stdio", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", "${PROJECT_CWD}"], + }, + }, + vars: [], + }, + { + builtinId: "context7", + name: "context7", + description: "Актуальная документация библиотек и фреймворков. Публичный HTTP-эндпоинт.", + websiteUrl: "https://context7.com", + config: { default: { transport: "http", httpUrl: "https://mcp.context7.com/mcp", headers: {} } }, + vars: [], + }, + { + builtinId: "atlassian", + name: "atlassian", + description: + "Доступ к Jira и Confluence: задачи, страницы, поиск. Укажите логины и API-токены в каталоге.", + websiteUrl: "https://github.com/sooperset/mcp-atlassian", + config: { + default: { + transport: "stdio", + command: "uvx", + args: ["mcp-atlassian"], + }, + }, + // All catalog-level (perProject:false), all required. The two URLs ship a placeholder value (the + // user edits them); the logins are unfilled; the API tokens are secrets (value:null — never ship a + // real secret). Until the unfilled ones are set, the catalog server shows «требует настройки». + vars: [ + { + name: "JIRA_URL", + secret: false, + perProject: false, + required: true, + value: "https://your-company.atlassian.net/wiki", + }, + { name: "JIRA_USERNAME", secret: false, perProject: false, required: true, value: null }, + { name: "JIRA_API_TOKEN", secret: true, perProject: false, required: true, value: null }, + { + name: "CONFLUENCE_URL", + secret: false, + perProject: false, + required: true, + value: "https://your-company.atlassian.net/wiki", + }, + { name: "CONFLUENCE_USERNAME", secret: false, perProject: false, required: true, value: null }, + { name: "CONFLUENCE_API_TOKEN", secret: true, perProject: false, required: true, value: null }, + ], + }, +]; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 87b613646d3..67d91c116e2 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -84,6 +84,7 @@ import { import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; +import { McpRuntimeServicesLive } from "./ru-fork/mcp/McpLayers.ts"; import { clearPersistedServerRuntimeState, makePersistedServerRuntimeState, @@ -216,6 +217,10 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), + // ru-fork: MCP supervisor + runtime + projection query (singleton supervisor + // shared with the reactor + startup). Its repo/engine/settings requirements are + // satisfied by the orchestration + settings layers provided below. + Layer.provideMerge(McpRuntimeServicesLive), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index cf21b39ead8..ecc3a61b96d 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -32,6 +32,9 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; import { ServerSettingsService } from "./serverSettings.ts"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; import { ProviderSessionReaper } from "./provider/Services/ProviderSessionReaper.ts"; +import { McpOverlay } from "./ru-fork/mcp/McpOverlay.ts"; +import { McpReactor } from "./ru-fork/mcp/McpReactor.ts"; +import { McpSupervisor } from "./ru-fork/mcp/McpSupervisor.ts"; import { formatHeadlessServeOutput, issueHeadlessServeAccessInfo, @@ -236,6 +239,9 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { const keybindings = yield* Keybindings; const orchestrationReactor = yield* OrchestrationReactor; const providerSessionReaper = yield* ProviderSessionReaper; + const mcpSupervisor = yield* McpSupervisor; + const mcpReactor = yield* McpReactor; + const mcpOverlay = yield* McpOverlay; const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; const serverEnvironment = yield* ServerEnvironment; @@ -277,12 +283,23 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { ), ); + // ru-fork #4: wipe stale per-project overlay files (plaintext secrets) left by a + // prior run that exited hard (SIGKILL / power-loss) before its ensuring/shutdown + // sweep could run. Done before reactors so nothing reads a stale file. Best-effort. + yield* Effect.logDebug("startup phase: sweeping stale MCP overlays"); + yield* runStartupPhase("mcp.overlay.sweep", mcpOverlay.removeAllOverlays); + yield* Effect.logDebug("startup phase: starting orchestration reactors"); yield* runStartupPhase( "reactors.start", Effect.gen(function* () { yield* orchestrationReactor.start().pipe(Scope.provide(reactorScope)); yield* providerSessionReaper.start().pipe(Scope.provide(reactorScope)); + // ru-fork: MCP monitoring + desired-set reconciliation. The supervisor + // owns the health-sweep loop; the reactor seeds builtins on first run + // and keeps the supervisor reconciled to authored catalog/bindings. + yield* mcpSupervisor.start().pipe(Scope.provide(reactorScope)); + yield* mcpReactor.start().pipe(Scope.provide(reactorScope)); }), ); diff --git a/apps/server/src/timeouts.ts b/apps/server/src/timeouts.ts index 12592930a79..3ce9f123599 100644 --- a/apps/server/src/timeouts.ts +++ b/apps/server/src/timeouts.ts @@ -79,3 +79,16 @@ export const ACP_WIRE_STALL_KILL_MS = 7_200_000; * recover snappily. */ export const POST_ANSWER_RESUME_TIMEOUT_MS = 3_600_000; + +/** + * ACP_SESSION_START_TIMEOUT_MS — hard ceiling on the ACP start handshake + * (`initialize` + `authenticate` + `session/new`|`session/load`). Unlike the + * wire-stall thresholds above (which only apply DURING an active turn), nothing + * else bounds the start: if a freshly-spawned `cli --acp` child hangs at boot + * and never answers `initialize`, `startSession` would otherwise hang forever. + * On timeout the adapter fails the start with a typed ProviderAdapterProcessError + * (the session scope then closes and SIGKILLs the child). 60s is generous for a + * cold node boot + qwen init (typically <10s) yet short enough to surface a wedge + * and — for ru-fork MCP — release the ephemeral overlay promptly. Tunable. + */ +export const ACP_SESSION_START_TIMEOUT_MS = 60_000; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 2793752150a..707fec94e9b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -45,6 +45,9 @@ import { Keybindings } from "./keybindings.ts"; import { Open, resolveAvailableEditors } from "./open.ts"; import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; +import { McpProjectionQuery } from "./ru-fork/mcp/McpProjectionQuery.ts"; +import { McpRuntime } from "./ru-fork/mcp/McpRuntime.ts"; +import { McpSupervisor } from "./ru-fork/mcp/McpSupervisor.ts"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; // ru-fork: telemetry/observability (spans + metrics) was removed, and the // per-RPC FAILURE CAPTURE that lived in this wrapper went with it — so every @@ -227,6 +230,10 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const sourceControlRepositories = yield* SourceControlRepositoryService; const bootstrapCredentials = yield* BootstrapCredentialService; const sessions = yield* SessionCredentialService; + // ru-fork: MCP management read model + live runtime. + const mcpProjectionQuery = yield* McpProjectionQuery; + const mcpRuntime = yield* McpRuntime; + const mcpSupervisor = yield* McpSupervisor; const serverCommandId = (tag: string) => CommandId.make(`server:${tag}:${crypto.randomUUID()}`); @@ -955,6 +962,43 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => "rpc.aggregate": "server", }, ), + // ru-fork: MCP reads + subscriptions. Stream construction lives in the + // ru-fork/mcp services so this seam stays a thin delegation. + [WS_METHODS.mcpGetSnapshot]: ({ projectId }) => + observeRpcEffect(WS_METHODS.mcpGetSnapshot, mcpProjectionQuery.getSnapshot(projectId), { + "rpc.aggregate": "mcp", + }), + [WS_METHODS.subscribeMcpProjection]: (_input) => + observeRpcStreamEffect( + WS_METHODS.subscribeMcpProjection, + Effect.succeed(mcpProjectionQuery.subscriptionStream), + { "rpc.aggregate": "mcp" }, + ), + [WS_METHODS.subscribeMcpRuntime]: (_input) => + observeRpcStreamEffect( + WS_METHODS.subscribeMcpRuntime, + Effect.succeed(mcpRuntime.subscriptionStream), + { "rpc.aggregate": "mcp" }, + ), + // ru-fork: the client signals which project it's viewing; the supervisor + // scopes auto-probing to it (null ⇒ clear → probe nothing until re-set). + [WS_METHODS.mcpSetActiveProject]: ({ projectId }) => + observeRpcEffect( + WS_METHODS.mcpSetActiveProject, + mcpSupervisor.setWatchedProjects(projectId !== null ? [projectId] : []), + { "rpc.aggregate": "mcp" }, + ), + // ru-fork: manual recheck — force-probe the matching live instances now. + [WS_METHODS.mcpRecheck]: ({ projectId, serverId, transport }) => + observeRpcEffect( + WS_METHODS.mcpRecheck, + mcpSupervisor.recheck({ + ...(projectId !== undefined ? { projectId } : {}), + ...(serverId !== undefined ? { serverId } : {}), + ...(transport !== undefined ? { transport } : {}), + }), + { "rpc.aggregate": "mcp" }, + ), [WS_METHODS.serverUpdateSettings]: ({ patch }) => observeRpcEffect( WS_METHODS.serverUpdateSettings, diff --git a/apps/server/tests/auth/Layers/ServerSecretStore.test.ts b/apps/server/tests/auth/Layers/ServerSecretStore.test.ts index 0f30e7e959c..2270aa00266 100644 --- a/apps/server/tests/auth/Layers/ServerSecretStore.test.ts +++ b/apps/server/tests/auth/Layers/ServerSecretStore.test.ts @@ -5,7 +5,9 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Ref from "effect/Ref"; +import * as References from "effect/References"; import * as PlatformError from "effect/PlatformError"; import { ServerConfig } from "../../../src/config.ts"; @@ -268,4 +270,48 @@ it.layer(NodeServices.layer)("ServerSecretStoreLive", (it) => { expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied"); }).pipe(Effect.provide(makeRemoveFailureSecretStoreLayer())), ); + + // ru-fork: pruneByPrefix is the MCP secret GC primitive (gcOrphanedSecrets calls it). Below: its + // correctness (untested until now), then its error path — which the language-service flagged as a + // dead `Effect.catch` because the body swallows `remove` failures via `Effect.ignore`. + it.effect("pruneByPrefix removes orphaned secrets but keeps the keep-set and other prefixes", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + yield* secretStore.getOrCreateRandom("mcp-secret-keep", 16); + yield* secretStore.getOrCreateRandom("mcp-secret-orphan", 16); + yield* secretStore.getOrCreateRandom("other-prefix-survivor", 16); + + yield* secretStore.pruneByPrefix("mcp-secret-", new Set(["mcp-secret-keep"])); + + expect(yield* secretStore.get("mcp-secret-orphan")).toBeNull(); // pruned + expect(yield* secretStore.get("mcp-secret-keep")).not.toBeNull(); // in keep-set + expect(yield* secretStore.get("other-prefix-survivor")).not.toBeNull(); // different prefix + }).pipe(Effect.provide(makeServerSecretStoreLayer())), + ); + + it.effect("pruneByPrefix logs (and does not fail) when a secret file cannot be removed", () => { + const captured: Array<{ readonly level: string; readonly message: string }> = []; + const logger = Logger.make(({ logLevel, message }) => { + captured.push({ level: String(logLevel), message: String(message) }); + }); + return Effect.gen(function* () { + const secretStore = yield* ServerSecretStore; + yield* secretStore.getOrCreateRandom("mcp-secret-stuck", 16); // write ok (only `remove` fails) + + // Best-effort: pruning must SUCCEED even though a remove fails (it must not crash the caller)... + yield* secretStore.pruneByPrefix("mcp-secret-", new Set()); + + // ...AND the failure must be observable at debug level. Current code swallows it silently via + // `Effect.ignore` ⇒ no log ⇒ this is RED until the swallow becomes a logDebug. + const debugLog = captured.find( + (entry) => + entry.level.toUpperCase().includes("DEBUG") && entry.message.toLowerCase().includes("remove"), + ); + expect(debugLog).toBeDefined(); + }).pipe( + Effect.provide(makeRemoveFailureSecretStoreLayer()), + Effect.provide(Logger.layer([logger], { mergeWithExisting: false })), + Effect.provide(Layer.succeed(References.MinimumLogLevel, "Debug")), + ); + }); }); diff --git a/apps/server/tests/fastShutdownOverlaySweep.test.ts b/apps/server/tests/fastShutdownOverlaySweep.test.ts new file mode 100644 index 00000000000..6d2874df8a3 --- /dev/null +++ b/apps/server/tests/fastShutdownOverlaySweep.test.ts @@ -0,0 +1,62 @@ +// ru-fork #4: graceful-shutdown sweep of the MCP overlay dir. runFastShutdownCleanup +// runs inside the synchronous SIGINT/SIGTERM handler, so it must drop every per-project +// overlay (plaintext secrets) via a SYNC delete. (RED until fastShutdown.ts adds the +// nodeFs.rmSync(config.mcpOverlayDir, …) step.) +// @effect-diagnostics nodeBuiltinImport:off +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ServerConfig } from "../src/config.ts"; +import { runFastShutdownCleanup } from "../src/fastShutdown.ts"; +import { ProviderService } from "../src/provider/Services/ProviderService.ts"; +import { TerminalManager } from "../src/terminal/Services/Manager.ts"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("runFastShutdownCleanup — MCP overlay sweep (ru-fork #4)", () => { + it("G11 — deletes every per-project overlay under mcpOverlayDir on shutdown", async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "ru-fork-shutdown-")); + tempDirs.push(baseDir); + + const layer = Layer.mergeAll( + Layer.mock(ProviderService, { stopAll: () => Effect.void }), + Layer.mock(TerminalManager, { killAll: Effect.void }), + // NodeServices must be PROVIDED to ServerConfig.layerTest (it derives paths via + // FileSystem/Path), not merged as a sibling. + ServerConfig.layerTest(process.cwd(), baseDir).pipe(Layer.provide(NodeServices.layer)), + ); + const runtime = ManagedRuntime.make(layer); + try { + const config = await runtime.runPromise(Effect.service(ServerConfig)); + + // Two projects' overlay files sitting on disk at shutdown time. + const a = path.join(config.mcpOverlayDir, "project-a", "system.json"); + const b = path.join(config.mcpOverlayDir, "project-b", "system.json"); + fs.mkdirSync(path.dirname(a), { recursive: true }); + fs.mkdirSync(path.dirname(b), { recursive: true }); + fs.writeFileSync(a, "{}"); + fs.writeFileSync(b, "{}"); + expect(fs.existsSync(a)).toBe(true); + expect(fs.existsSync(b)).toBe(true); + + await runtime.runPromise(runFastShutdownCleanup); + + expect(fs.existsSync(config.mcpOverlayDir)).toBe(false); + } finally { + await runtime.dispose(); + } + }); +}); diff --git a/apps/server/tests/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/tests/orchestration/Layers/OrchestrationEngine.test.ts index 9ba20288744..cad2ebf9437 100644 --- a/apps/server/tests/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/tests/orchestration/Layers/OrchestrationEngine.test.ts @@ -210,6 +210,9 @@ describe("OrchestrationEngine", () => { Layer.provide(Layer.succeed(OrchestrationEventStore, eventStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(SqlitePersistenceMemory), + // ru-fork: the engine's decider depends on ServerSecretStore (filesystem-backed). + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-engine-test-" })), + Layer.provideMerge(NodeServices.layer), ); const runtime = ManagedRuntime.make(layer); @@ -785,6 +788,7 @@ describe("OrchestrationEngine", () => { Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-engine-test-" })), Layer.provide(NodeServices.layer), ), ); @@ -928,6 +932,7 @@ describe("OrchestrationEngine", () => { Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolverLive), Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-engine-test-" })), Layer.provide(NodeServices.layer), ), ); diff --git a/apps/server/tests/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/tests/orchestration/Layers/ProviderCommandReactor.test.ts index 2d670124b29..afbbf04c95f 100644 --- a/apps/server/tests/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/tests/orchestration/Layers/ProviderCommandReactor.test.ts @@ -61,6 +61,7 @@ import { ProjectionSnapshotQuery } from "../../../src/orchestration/Services/Pro import * as NodeServices from "@effect/platform-node/NodeServices"; import { ServerSettingsService } from "../../../src/serverSettings.ts"; import { VcsStatusBroadcaster } from "../../../src/vcs/VcsStatusBroadcaster.ts"; +import { McpOverlay } from "../../../src/ru-fork/mcp/McpOverlay.ts"; import { GitWorkflowService, type GitWorkflowServiceShape, @@ -337,6 +338,22 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(orchestrationLayer), Layer.provideMerge(projectionSnapshotLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), + // ru-fork: the reactor resolves a spawn-time MCP overlay; stub it so this + // provider-reactor test stays free of the MCP repo/secret graph. + Layer.provideMerge( + Layer.succeed(McpOverlay, { + writeOverlay: () => + Effect.succeed({ + overlayPath: "/tmp/mcp-test-overlay.json", + allowedServerNames: [], + fingerprint: "test-overlay", + }), + removeOverlay: () => Effect.void, + // ru-fork #4: the reactor now deletes the ephemeral overlay after each spawn. + deleteOverlayFile: () => Effect.void, + removeAllOverlays: Effect.void, + }), + ), Layer.provideMerge( Layer.mock(GitWorkflowService)({ renameBranch, diff --git a/apps/server/tests/orchestration/Layers/RuForkProviderCommandReactor.test.ts b/apps/server/tests/orchestration/Layers/RuForkProviderCommandReactor.test.ts index c3ec93579df..9aebc584f1e 100644 --- a/apps/server/tests/orchestration/Layers/RuForkProviderCommandReactor.test.ts +++ b/apps/server/tests/orchestration/Layers/RuForkProviderCommandReactor.test.ts @@ -39,6 +39,7 @@ import { ApprovalRequestId, CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, + McpError, MessageId, ProjectId, ThreadId, @@ -78,11 +79,25 @@ import { ProjectionSnapshotQuery } from "../../../src/orchestration/Services/Pro import * as NodeServices from "@effect/platform-node/NodeServices"; import { ServerSettingsService } from "../../../src/serverSettings.ts"; import { VcsStatusBroadcaster } from "../../../src/vcs/VcsStatusBroadcaster.ts"; +import { McpOverlay } from "../../../src/ru-fork/mcp/McpOverlay.ts"; import { GitWorkflowService, type GitWorkflowServiceShape, } from "../../../src/git/GitWorkflowService.ts"; +// ru-fork: a controllable stand-in for what McpOverlay.writeOverlay(projectId) returns at turn-start. +// The reactor compares `fingerprint` against what the thread's session spawned with to decide on a +// respawn; a test mutates these entries between turns to simulate "the overlay changed / didn't". +type OverlayEntry = { + readonly overlayPath: string; + readonly allowedServerNames: ReadonlyArray; + readonly fingerprint: string; + readonly fail?: boolean; // writeOverlay rejects ⇒ reactor spawns WITHOUT an overlay (best-effort) + // ru-fork #4: when set, the stub writeOverlay creates a REAL file at overlayPath each turn + // (mirrors production) so deletion tests can observe the file appear and disappear. + readonly materialize?: boolean; +}; + const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); const asMessageId = (value: string): MessageId => MessageId.make(value); @@ -146,6 +161,12 @@ describe("RuForkProviderCommandReactor (runtimeMode Clean-1)", () => { readonly baseDir?: string; readonly threadModelSelection?: ModelSelection; readonly sessionModelSwitch?: "unsupported" | "in-session"; + // ru-fork: per-project overlay results the McpOverlay stub returns (keyed by projectId). A test + // owns this Map and mutates it between turns; absent ⇒ the default constant overlay (existing tests). + readonly overlayEntries?: Map; + // ru-fork #4: make the provider's startSession FAIL, to prove the reactor still + // deletes the ephemeral overlay on a failed spawn (Effect.ensuring fires on failure). + readonly failStartSession?: boolean; }) { const now = "2026-01-01T00:00:00.000Z"; const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "ru-fork-reactor-")); @@ -159,7 +180,29 @@ describe("RuForkProviderCommandReactor (runtimeMode Clean-1)", () => { instanceId: ProviderInstanceId.make(CLI_NAME), model: CLI_DEFAULT_MODEL, }; + const failStartSession = input?.failStartSession ?? false; + // ru-fork #4 ordering proof: snapshot the overlay file's on-disk state at the EXACT + // moment the spawn is invoked (the mock runs synchronously when the reactor calls + // startSession), so a test can assert the file was written + finalized BEFORE the spawn. + const overlayPresentAtSpawn: Array<{ readonly exists: boolean; readonly content: string | null }> = []; const startSession = vi.fn((_: unknown, input: unknown) => { + const spawnOverlayPath = + typeof input === "object" && + input !== null && + "settingsOverlayPath" in input && + typeof input.settingsOverlayPath === "string" + ? input.settingsOverlayPath + : null; + const exists = spawnOverlayPath !== null && fs.existsSync(spawnOverlayPath); + overlayPresentAtSpawn.push({ + exists, + content: exists ? fs.readFileSync(spawnOverlayPath!, "utf8") : null, + }); + if (failStartSession) { + // ru-fork #4: simulated spawn failure (e.g. a start-handshake timeout). The + // reactor's overlay finalizer must still delete the ephemeral file. + return Effect.fail(new McpError({ detail: "simulated start failure (test)" })); + } const sessionIndex = nextSessionIndex++; const resumeCursor = typeof input === "object" && input !== null && "resumeCursor" in input @@ -328,6 +371,42 @@ describe("RuForkProviderCommandReactor (runtimeMode Clean-1)", () => { Layer.provideMerge(orchestrationLayer), Layer.provideMerge(projectionSnapshotLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), + // ru-fork: stub the spawn-time MCP overlay (see ProviderCommandReactor.test.ts). Reads the + // test-controlled per-project entries; absent ⇒ a stable default (so existing tests are unaffected). + Layer.provideMerge( + Layer.succeed(McpOverlay, { + writeOverlay: (projectId) => { + const entry = input?.overlayEntries?.get(projectId) ?? { + overlayPath: "/tmp/mcp-test-overlay.json", + allowedServerNames: [], + fingerprint: "test-overlay", + }; + if (entry.fail) { + return Effect.fail(new McpError({ detail: "overlay write failed (test)" })); + } + return Effect.sync(() => { + // ru-fork #4: materialize a real file so deletion tests can observe it. + if (entry.materialize) { + fs.mkdirSync(path.dirname(entry.overlayPath), { recursive: true }); + fs.writeFileSync(entry.overlayPath, "{}"); + } + }).pipe( + Effect.as({ + overlayPath: entry.overlayPath, + allowedServerNames: entry.allowedServerNames, + fingerprint: entry.fingerprint, + }), + ); + }, + removeOverlay: () => Effect.void, + // ru-fork #4: real fs delete so the reactor's ensuring(deleteOverlayFile) is observable. + deleteOverlayFile: (overlayPath: string) => + Effect.sync(() => { + fs.rmSync(overlayPath, { force: true }); + }), + removeAllOverlays: Effect.void, + }), + ), Layer.provideMerge( Layer.mock(GitWorkflowService)({ renameBranch, @@ -398,6 +477,7 @@ describe("RuForkProviderCommandReactor (runtimeMode Clean-1)", () => { respondToUserInput, stopSession, runtimeSessions, + overlayPresentAtSpawn, stateDir, drain, }; @@ -751,4 +831,244 @@ describe("RuForkProviderCommandReactor (runtimeMode Clean-1)", () => { }); }); }); + + // ───────────────────────────────────────────────────────────────────── + // Group 4 — MCP overlay applied at spawn + respawn on overlay change + // (the qwen integration: apply overlay + allow-list each turn; restart + // the session iff the project's overlay fingerprint changed) + // ───────────────────────────────────────────────────────────────────── + + describe("MCP overlay spawn + respawn", () => { + const now = "2026-01-01T00:00:00.000Z"; + const modelSelection: ModelSelection = { + instanceId: ProviderInstanceId.make(CLI_NAME), + model: CLI_DEFAULT_MODEL, + }; + + const startTurn = (harness: Awaited>, threadId: string, turn: number) => + Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-turn-${threadId}-${turn}`), + threadId: ThreadId.make(threadId), + message: { + messageId: asMessageId(`msg-${threadId}-${turn}`), + role: "user", + text: `turn-${turn}`, + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + it("hands the overlay path + allow-list to startSession on spawn", async () => { + const overlayEntries = new Map([ + ["project-1", { overlayPath: "/tmp/ov-p1.json", allowedServerNames: ["srv-a", "srv-b"], fingerprint: "fp-1" }], + ]); + const harness = await createHarness({ overlayEntries }); + await startTurn(harness, "thread-1", 1); + await waitFor(() => harness.startSession.mock.calls.length === 1); + + expect(harness.startSession).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + settingsOverlayPath: "/tmp/ov-p1.json", + allowedMcpServers: ["srv-a", "srv-b"], + }), + ); + }); + + it("does NOT restart when the overlay fingerprint is unchanged between turns", async () => { + const overlayEntries = new Map([ + ["project-1", { overlayPath: "/tmp/ov.json", allowedServerNames: ["srv"], fingerprint: "stable" }], + ]); + const harness = await createHarness({ overlayEntries }); + await startTurn(harness, "thread-1", 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + await startTurn(harness, "thread-1", 2); // identical overlay + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + + expect(harness.startSession).toHaveBeenCalledTimes(1); // one spawn, no respawn + }); + + it("restarts WITH the prior resumeCursor when the overlay fingerprint changes", async () => { + const overlayEntries = new Map([ + ["project-1", { overlayPath: "/tmp/ov.json", allowedServerNames: ["srv"], fingerprint: "fp-1" }], + ]); + const harness = await createHarness({ overlayEntries }); + await startTurn(harness, "thread-1", 1); + await waitFor(() => harness.startSession.mock.calls.length === 1); + const spawnResumeCursor = harness.runtimeSessions[0]?.resumeCursor; + + // A config change moved this project's overlay fingerprint ⇒ next turn must respawn. + overlayEntries.set("project-1", { overlayPath: "/tmp/ov.json", allowedServerNames: ["srv"], fingerprint: "fp-2" }); + await startTurn(harness, "thread-1", 2); + await waitFor(() => harness.startSession.mock.calls.length === 2); + + // The respawn carries the prior session's resume cursor (history preserved). + expect(harness.startSession).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.objectContaining({ resumeCursor: spawnResumeCursor }), + ); + }); + + it("spawns WITHOUT an overlay (best-effort) when writeOverlay fails", async () => { + const overlayEntries = new Map([ + ["project-1", { overlayPath: "", allowedServerNames: [], fingerprint: "x", fail: true }], + ]); + const harness = await createHarness({ overlayEntries }); + await startTurn(harness, "thread-1", 1); + await waitFor(() => harness.startSession.mock.calls.length === 1); + + // The turn still spawned — just without a settingsOverlayPath (overlay failure must not block it). + expect(harness.startSession).toHaveBeenCalledTimes(1); + expect(harness.startSession).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.not.objectContaining({ settingsOverlayPath: expect.anything() }), + ); + }); + + it("an overlay change in ONE project does not restart another project's thread", async () => { + const overlayEntries = new Map([ + ["project-1", { overlayPath: "/tmp/p1.json", allowedServerNames: ["srv"], fingerprint: "p1-fp-1" }], + ["project-2", { overlayPath: "/tmp/p2.json", allowedServerNames: ["srv"], fingerprint: "p2-fp-1" }], + ]); + const harness = await createHarness({ overlayEntries }); + + // Stand up project-2 + thread-2 next to the harness's project-1/thread-1. + await Effect.runPromise( + harness.engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-project-2"), + projectId: asProjectId("project-2"), + title: "P2", + workspaceRoot: "/tmp/ru-fork-provider-project-2", + defaultModelSelection: modelSelection, + createdAt: now, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-thread-2"), + threadId: ThreadId.make("thread-2"), + projectId: asProjectId("project-2"), + title: "Thread 2", + modelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + }), + ); + + const spawnsFor = (threadId: string) => + harness.startSession.mock.calls.filter((call) => call[0] === ThreadId.make(threadId)).length; + + // First turn for each thread ⇒ exactly one spawn each. + await startTurn(harness, "thread-1", 1); + await startTurn(harness, "thread-2", 1); + await waitFor(() => spawnsFor("thread-1") === 1 && spawnsFor("thread-2") === 1); + + // Change ONLY project-1's overlay fingerprint, then take a turn on BOTH threads. + overlayEntries.set("project-1", { overlayPath: "/tmp/p1.json", allowedServerNames: ["srv"], fingerprint: "p1-fp-2" }); + await startTurn(harness, "thread-1", 2); + await startTurn(harness, "thread-2", 2); + await waitFor(() => spawnsFor("thread-1") === 2); // thread-1 respawned (its overlay changed) + await harness.drain(); + + expect(spawnsFor("thread-1")).toBe(2); + expect(spawnsFor("thread-2")).toBe(1); // thread-2's overlay unchanged ⇒ no respawn + }); + + // ── ru-fork #4: ephemeral overlay deletion ────────────────────────────── + // The overlay file (plaintext secrets) must be deleted the moment the spawn it + // fed settles — success, failure, OR reuse. (RED until the reactor wraps the + // spawn region in Effect.ensuring(deleteOverlayFile).) + + const freshOverlayPath = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ru-fork-ov4-")); + createdBaseDirs.add(dir); + return path.join(dir, "p1", "system.json"); + }; + + it("G4 — deletes the overlay file after a SUCCESSFUL spawn", async () => { + const overlayPath = freshOverlayPath(); + const overlayEntries = new Map([ + ["project-1", { overlayPath, allowedServerNames: [], fingerprint: "fp-1", materialize: true }], + ]); + const harness = await createHarness({ overlayEntries }); + await startTurn(harness, "thread-1", 1); + await waitFor(() => harness.startSession.mock.calls.length === 1); + await harness.drain(); + expect(fs.existsSync(overlayPath)).toBe(false); + }); + + it("G5 — deletes the overlay file even when the spawn ERRORS", async () => { + const overlayPath = freshOverlayPath(); + const overlayEntries = new Map([ + ["project-1", { overlayPath, allowedServerNames: [], fingerprint: "fp-err", materialize: true }], + ]); + const harness = await createHarness({ overlayEntries, failStartSession: true }); + await startTurn(harness, "thread-1", 1); + await waitFor(() => harness.startSession.mock.calls.length === 1); // attempted once + await harness.drain(); + expect(fs.existsSync(overlayPath)).toBe(false); + }); + + it("G6 — deletes the overlay file on a REUSE (no-respawn) turn", async () => { + const overlayPath = freshOverlayPath(); + const overlayEntries = new Map([ + ["project-1", { overlayPath, allowedServerNames: ["srv"], fingerprint: "stable", materialize: true }], + ]); + const harness = await createHarness({ overlayEntries }); + await startTurn(harness, "thread-1", 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + // Turn 2: identical fingerprint ⇒ reuse (no new spawn). writeOverlay re-materializes the file. + await startTurn(harness, "thread-1", 2); + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + await harness.drain(); + expect(harness.startSession).toHaveBeenCalledTimes(1); // reuse: no respawn + expect(fs.existsSync(overlayPath)).toBe(false); // still deleted on the reuse turn + }); + + it("G1 — deleting the overlay between turns NEVER triggers a spurious respawn", async () => { + const overlayPath = freshOverlayPath(); + const overlayEntries = new Map([ + ["project-1", { overlayPath, allowedServerNames: ["srv"], fingerprint: "stable", materialize: true }], + ]); + const harness = await createHarness({ overlayEntries }); + await startTurn(harness, "thread-1", 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + // Simulate the ephemeral delete having removed the file between turns. + fs.rmSync(overlayPath, { force: true }); + await startTurn(harness, "thread-1", 2); + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + await harness.drain(); + // The restart decision uses the in-memory fingerprint (unchanged), NOT the file — + // so a missing file must NOT cause a respawn. (Proves deletion ⊥ apply.) + expect(harness.startSession).toHaveBeenCalledTimes(1); + }); + + it("G13 — the overlay file is on disk AND complete at the instant startSession is invoked", async () => { + const overlayPath = freshOverlayPath(); + const overlayEntries = new Map([ + ["project-1", { overlayPath, allowedServerNames: ["srv"], fingerprint: "fp-1", materialize: true }], + ]); + const harness = await createHarness({ overlayEntries }); + await startTurn(harness, "thread-1", 1); + await waitFor(() => harness.startSession.mock.calls.length === 1); + + // Snapshot taken synchronously INSIDE the startSession mock ⇒ proves the reactor + // awaited writeOverlay (file written + finalized) BEFORE invoking the spawn. + const snapshot = harness.overlayPresentAtSpawn[0]; + expect(snapshot?.exists).toBe(true); + expect(() => JSON.parse(snapshot!.content!)).not.toThrow(); // complete, not a partial frame + }); + }); }); diff --git a/apps/server/tests/persistence/Layers/McpProbeCache.test.ts b/apps/server/tests/persistence/Layers/McpProbeCache.test.ts new file mode 100644 index 00000000000..223909f68aa --- /dev/null +++ b/apps/server/tests/persistence/Layers/McpProbeCache.test.ts @@ -0,0 +1,108 @@ +// ru-fork: round-trip test for the probe-result cache repository. One row per +// authored config (keyed by configCacheKey); JSON tools column decodes back to +// the structured McpTool[]; checked_at_ms survives so the supervisor's due check +// works after a restart hydrate. + +import { IsoDateTime, type McpProbeRecord } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import { McpProbeCacheRepository } from "../../../src/persistence/Services/McpProbeCache.ts"; +import { McpProbeCacheRepositoryLive } from "../../../src/persistence/Layers/ProjectionMcpProbeCache.ts"; +import { SqlitePersistenceMemory } from "../../../src/persistence/Layers/Sqlite.ts"; + +const layer = it.layer( + McpProbeCacheRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), +); + +const onlineRecord = { + configKey: "abc12345", + transport: "stdio", + status: "online", + tools: [ + { name: "read_file", description: "Read a file" }, + { name: "write_file", description: "Write a file" }, + ], + lastError: null, + serverDescription: null, + serverWebsiteUrl: null, + checkedAt: IsoDateTime.make("2026-06-07T10:00:00.000Z"), + checkedAtMs: 1_780_000_000_000, +} satisfies McpProbeRecord; + +layer("McpProbeCache", (it) => { + it.effect("upserts then reads back the decoded record", () => + Effect.gen(function* () { + const repo = yield* McpProbeCacheRepository; + yield* repo.upsert(onlineRecord); + + const found = yield* repo.getByKey({ configKey: onlineRecord.configKey }); + assert.isTrue(Option.isSome(found)); + const record = Option.getOrThrow(found); + assert.equal(record.status, "online"); + assert.equal(record.transport, "stdio"); + assert.equal(record.checkedAtMs, onlineRecord.checkedAtMs); + assert.equal(record.lastError, null); + assert.deepEqual([...record.tools], [...onlineRecord.tools]); + }), + ); + + it.effect("upsert overwrites the existing row for the same configKey", () => + Effect.gen(function* () { + const repo = yield* McpProbeCacheRepository; + yield* repo.upsert(onlineRecord); + yield* repo.upsert({ + ...onlineRecord, + status: "offline", + tools: [], + lastError: "connect timed out", + checkedAtMs: onlineRecord.checkedAtMs + 60_000, + }); + + const record = Option.getOrThrow( + yield* repo.getByKey({ configKey: onlineRecord.configKey }), + ); + assert.equal(record.status, "offline"); + assert.equal(record.lastError, "connect timed out"); + assert.equal(record.tools.length, 0); + }), + ); + + it.effect("getByKey returns None for an unknown key", () => + Effect.gen(function* () { + const repo = yield* McpProbeCacheRepository; + const found = yield* repo.getByKey({ configKey: "does-not-exist" }); + assert.isTrue(Option.isNone(found)); + }), + ); + + it.effect("deleteKeysNotIn keeps the live keys and drops the rest (GC)", () => + Effect.gen(function* () { + const repo = yield* McpProbeCacheRepository; + yield* repo.upsert({ ...onlineRecord, configKey: "keep-1" }); + yield* repo.upsert({ ...onlineRecord, configKey: "keep-2" }); + yield* repo.upsert({ ...onlineRecord, configKey: "orphan" }); + + yield* repo.deleteKeysNotIn(["keep-1", "keep-2"]); + + assert.isTrue(Option.isSome(yield* repo.getByKey({ configKey: "keep-1" }))); + assert.isTrue(Option.isSome(yield* repo.getByKey({ configKey: "keep-2" }))); + assert.isTrue(Option.isNone(yield* repo.getByKey({ configKey: "orphan" }))); + }), + ); + + it.effect("deleteKeysNotIn with an empty list clears the whole cache", () => + Effect.gen(function* () { + const repo = yield* McpProbeCacheRepository; + yield* repo.upsert({ ...onlineRecord, configKey: "a" }); + yield* repo.upsert({ ...onlineRecord, configKey: "b" }); + + yield* repo.deleteKeysNotIn([]); + + assert.isTrue(Option.isNone(yield* repo.getByKey({ configKey: "a" }))); + assert.isTrue(Option.isNone(yield* repo.getByKey({ configKey: "b" }))); + }), + ); +}); diff --git a/apps/server/tests/provider/cliAdapterErrorEngine.test.ts b/apps/server/tests/provider/cliAdapterErrorEngine.test.ts index 23916bef77a..08cd5deb9f8 100644 --- a/apps/server/tests/provider/cliAdapterErrorEngine.test.ts +++ b/apps/server/tests/provider/cliAdapterErrorEngine.test.ts @@ -26,8 +26,10 @@ import { ThreadId, } from "@t3tools/contracts"; import { CLI_NAME } from "@ru-fork/branding"; +import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -122,14 +124,25 @@ afterEach(async () => { } }); -async function createHarness(script: FakeAcpScript) { +async function createHarness( + script: FakeAcpScript, + opts?: { readonly sessionStartTimeoutMs?: number }, +) { const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "t3-error-engine-")); tempDirs.push(workspaceRoot); fs.mkdirSync(path.join(workspaceRoot, ".git")); const adapterLayer = Layer.effect( FakeCliAdapter, - makeCliAdapter(CLI_SETTINGS, { instanceId: INSTANCE_ID }), + // ru-fork #4: opts.sessionStartTimeoutMs is passed through to the real adapter so a + // "hang" start trips the timeout in ms. Until the adapter wires the option it is + // ignored at runtime (the test then fails by hitting its own guard — TDD red). + makeCliAdapter(CLI_SETTINGS, { + instanceId: INSTANCE_ID, + ...(opts?.sessionStartTimeoutMs !== undefined + ? { sessionStartTimeoutMs: opts.sessionStartTimeoutMs } + : {}), + }), ).pipe(Layer.provide(fakeAcpSpawnerLayer(script))); const orchestrationLayer = OrchestrationEngineLive.pipe( @@ -489,4 +502,45 @@ describe("cliAdapterErrorEngine", () => { expect(assistantStreamingStuck(thread)).toBe(false); expect(errorActivity(thread)).toBeUndefined(); // not a failure }); + + // ── Group 4: inner CLI start timeout (ru-fork #4) ── + // A wedged `cli --acp` boot must FAIL the start (so the reactor's overlay finalizer + // fires) instead of hanging forever. These drive the REAL adapter over the fake. + + it("G7 — start handshake HANG → typed start error within the timeout (NOT a hang)", async () => { + const harness = await createHarness( + { onPrompt: (steps) => steps.respondOk(), startBehavior: "hang" }, + { sessionStartTimeoutMs: 50 }, + ); + // Guard the call so red (no timeout wired) fails fast at 2s instead of hanging the suite. + const outcome = await Effect.runPromise( + startSession(harness.adapter, harness.workspaceRoot).pipe( + Effect.matchCause({ + onFailure: (cause) => ({ kind: "failed" as const, message: Cause.pretty(cause) }), + onSuccess: () => ({ kind: "established" as const }), + }), + Effect.timeoutOrElse({ + duration: Duration.millis(2000), + orElse: () => Effect.succeed({ kind: "guard-timeout" as const }), + }), + ), + ); + // GREEN once the adapter wires the start timeout: a typed failure mentioning the + // unresponsive handshake. RED today: production never times out ⇒ "guard-timeout". + expect(outcome.kind).toBe("failed"); + if (outcome.kind === "failed") { + expect(outcome.message).toMatch(/start handshake|unresponsive/i); + } + }); + + it("G8 — start handshake ERROR settles as a typed failure (no hang)", async () => { + const harness = await createHarness({ + onPrompt: (steps) => steps.respondOk(), + startBehavior: "error", + }); + const exit = await Effect.runPromise( + Effect.exit(startSession(harness.adapter, harness.workspaceRoot)), + ); + expect(Exit.isFailure(exit)).toBe(true); // a start RPC error must surface, not hang + }); }); diff --git a/apps/server/tests/provider/fakeAcpCore.ts b/apps/server/tests/provider/fakeAcpCore.ts index 094b0a49c88..cf4c6522100 100644 --- a/apps/server/tests/provider/fakeAcpCore.ts +++ b/apps/server/tests/provider/fakeAcpCore.ts @@ -54,6 +54,14 @@ export interface PromptSteps { export interface FakeAcpScript { /** Called once per `session/prompt`; build the response with the step DSL. */ readonly onPrompt: (steps: PromptSteps) => void; + /** + * ru-fork #4: how the fake answers the START handshake (`session/new` + `session/load`). + * - "ok" (default) → reply with FAKE_SESSION_ID (a real session establishes). + * - "hang" → never respond (simulates a wedged `cli --acp` boot; the + * adapter's start timeout must convert this into an error). + * - "error" → reply with a JSON-RPC error (start fails cleanly). + */ + readonly startBehavior?: "ok" | "hang" | "error"; } type FakeStep = @@ -114,8 +122,16 @@ export const runFakeAcpAgent = ( }), ); yield* agent.handleAuthenticate(() => Effect.succeed({})); - yield* agent.handleCreateSession(() => Effect.succeed({ sessionId: FAKE_SESSION_ID })); - yield* agent.handleLoadSession(() => Effect.succeed({ sessionId: FAKE_SESSION_ID })); + // ru-fork #4: the START handshake honours `script.startBehavior` so tests can drive + // a wedged ("hang") or failing ("error") `cli --acp` boot, not just the happy path. + const startResponse = () => + script.startBehavior === "hang" + ? Effect.never + : script.startBehavior === "error" + ? new AcpErrors.AcpRequestError({ code: -32000, errorMessage: "start handshake failed (fake)" }) + : Effect.succeed({ sessionId: FAKE_SESSION_ID }); + yield* agent.handleCreateSession(startResponse); + yield* agent.handleLoadSession(startResponse); yield* agent.handleSetSessionConfigOption(() => Effect.succeed({ configOptions: [] })); yield* agent.handleSetSessionModel(() => Effect.succeed({})); yield* agent.handleCancel(() => diff --git a/apps/server/tests/ru-fork/mcp/branch3BackfillOrdering.test.ts b/apps/server/tests/ru-fork/mcp/branch3BackfillOrdering.test.ts new file mode 100644 index 00000000000..3b83d87c0c4 --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/branch3BackfillOrdering.test.ts @@ -0,0 +1,132 @@ +// ru-fork: improvements-branch-3 #1 — RED test for the description/docs back-fill ORDERING bug. +// On a fresh add, `backfillServerMetadata` runs (inside reconcileNow) BEFORE `probeHashes`, so the +// probe cache is still empty when the back-fill looks — the catalog description is never filled that +// cycle (it only fills after a restart). This drives the WHOLE reactor against a fake stdio server +// that reports a serverInfo.description, waits for the probe to capture it, then asserts the catalog +// was back-filled in the same session. RED today; green once the back-fill runs AFTER the probe. +// No production logic touched (uses the reactor's public start()/engine + a description-reporting fixture). + +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { CommandId, McpServerId, type OrchestrationCommand } from "@t3tools/contracts"; +import { configCacheKey } from "@ru-fork/mcp-core"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Option from "effect/Option"; +import type * as Scope from "effect/Scope"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { afterEach, describe, expect, it } from "vitest"; + +import { OrchestrationCommandReceiptRepositoryLive } from "../../../src/persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../../../src/persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../../../src/persistence/Layers/Sqlite.ts"; +import { McpCatalogRepository } from "../../../src/persistence/Services/McpCatalog.ts"; +import { McpProbeCacheRepository } from "../../../src/persistence/Services/McpProbeCache.ts"; +import { RepositoryIdentityResolverLive } from "../../../src/project/Layers/RepositoryIdentityResolver.ts"; +import { OrchestrationEngineLive } from "../../../src/orchestration/Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "../../../src/orchestration/Layers/ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "../../../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; +import { OrchestrationEngineService } from "../../../src/orchestration/Services/OrchestrationEngine.ts"; +import { ServerConfig } from "../../../src/config.ts"; +import { ServerSettingsService } from "../../../src/serverSettings.ts"; +import { McpOverlayLive } from "../../../src/ru-fork/mcp/McpOverlay.ts"; +import { McpReactor, McpReactorLive } from "../../../src/ru-fork/mcp/McpReactor.ts"; +import { McpSupervisorLive } from "../../../src/ru-fork/mcp/McpSupervisor.ts"; + +const FAKE_DESC_SERVER = path.resolve( + fileURLToPath(import.meta.url), + "../../../../../../packages/mcp-core/test-fixtures/fakeMcpStdioServerWithDescription.mjs", +); + +const addDescServer = (serverId: string): OrchestrationCommand => ({ + type: "mcp.server-add", + commandId: CommandId.make(`cmd-add-${serverId}`), + serverId: McpServerId.make(serverId), + draft: { + name: serverId, + config: { transport: "stdio", command: "node", args: [FAKE_DESC_SERVER] }, + vars: [], + timeoutMs: 10_000, + }, + createdAt: "2026-01-01T00:00:00.000Z", +}); + +function makeSystem() { + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-branch3-bf-" }); + const base = Layer.mergeAll( + OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(OrchestrationProjectionPipelineLive), + ), + OrchestrationProjectionPipelineLive, + OrchestrationProjectionSnapshotQueryLive, + ).pipe( + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettingsService.layerTest({})), + ); + const withSupervisor = McpSupervisorLive.pipe(Layer.provideMerge(base)); + const withOverlay = McpOverlayLive.pipe(Layer.provideMerge(withSupervisor)); + const runtime = ManagedRuntime.make(McpReactorLive.pipe(Layer.provideMerge(withOverlay))); + return { + run: (body: Effect.Effect): Promise => + runtime.runPromise(Effect.scoped(body) as Effect.Effect), + dispose: () => runtime.dispose(), + }; +} + +const waitUntil = (query: Effect.Effect, predicate: (value: A) => boolean) => + Effect.gen(function* () { + for (let attempt = 0; attempt < 240; attempt += 1) { + const value = yield* query; + if (predicate(value)) { + return value; + } + yield* Effect.sleep("25 millis"); + } + return yield* Effect.dieMessage("waitUntil: condition not satisfied within timeout"); + }); + +describe("branch-3 #1 — description back-fill runs AFTER the probe", () => { + let system: ReturnType; + afterEach(async () => { + await system.dispose(); + }); + + it("fills the catalog description from the probe in the SAME session (RED)", async () => { + system = makeSystem(); + const configKey = configCacheKey( + { transport: "stdio", command: "node", args: [FAKE_DESC_SERVER] }, + [], + {}, + [], + {}, + ); + const description = await system.run( + Effect.gen(function* () { + const reactor = yield* McpReactor; + yield* reactor.start(); + const engine = yield* OrchestrationEngineService; + const probeCache = yield* McpProbeCacheRepository; + const catalogRepository = yield* McpCatalogRepository; + yield* engine.dispatch(addDescServer("desc")); + // Wait until the probe has actually captured the server's reported description into the cache. + yield* waitUntil( + probeCache.getByKey({ configKey }), + (cached) => Option.isSome(cached) && cached.value.serverDescription === "Probed description", + ); + const catalog = yield* catalogRepository.listAll(); + return catalog.find((server) => server.id === "desc")?.description ?? null; + }), + ); + // RED today: the probe captured the description, but the back-fill ran BEFORE the probe, so the + // catalog field is still null this session. The fix runs the back-fill after probeHashes. + expect(description).toBe("Probed description"); + }, 30_000); +}); diff --git a/apps/server/tests/ru-fork/mcp/branch3DeciderGuards.test.ts b/apps/server/tests/ru-fork/mcp/branch3DeciderGuards.test.ts new file mode 100644 index 00000000000..507f58c51f5 --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/branch3DeciderGuards.test.ts @@ -0,0 +1,98 @@ +// ru-fork: improvements-branch-3 — RED feature tests for the decider-level guards (#2 config +// uniqueness + built-in skip, #8 var/${VAR} validation). Written to the CORRECT expected behaviour; +// they currently FAIL because the guards are not implemented yet. The failure IS the spec. +// No production logic is touched — these only drive the REAL engine via shared test scaffolding. + +import { afterEach, describe, expect, it } from "vitest"; + +import { + addServerCmd, + bindWithVarsCmd, + builtinDefForArgs, + makeDeciderSystem, + projectCreateCmd, + setTrustCmd, + updateConfigCmd, + updateNameCmd, +} from "./branch3Helpers.ts"; + +describe("branch-3 #2 — catalog config-uniqueness", () => { + let system: ReturnType; + afterEach(async () => { + await system.dispose(); + }); + + it("rejects a second server with the SAME config under a different name (RED)", async () => { + system = makeDeciderSystem(); + await system.dispatch(addServerCmd({ serverId: "a", name: "Server A", args: ["ctx7"], commandId: "u:a" })); + // Same config (uvx ctx7), different id + name ⇒ must be rejected once uniqueness lands. + await expect( + system.dispatch(addServerCmd({ serverId: "b", name: "Server B", args: ["ctx7"], commandId: "u:b" })), + ).rejects.toThrow(); + expect((await system.catalog()).filter((server) => server.config.args?.[0] === "ctx7")).toHaveLength(1); + }); + + it("rejects an edit that makes one server collide with another (RED)", async () => { + system = makeDeciderSystem(); + await system.dispatch(addServerCmd({ serverId: "a", name: "A", args: ["x"], commandId: "u:a" })); + await system.dispatch(addServerCmd({ serverId: "b", name: "B", args: ["y"], commandId: "u:b" })); + await expect(system.dispatch(updateConfigCmd("a", ["y"], "u:collide"))).rejects.toThrow(); + }); + + it("ALLOWS a non-config edit (name only) — must not false-positive against itself (GUARD)", async () => { + system = makeDeciderSystem(); + await system.dispatch(addServerCmd({ serverId: "a", name: "A", args: ["x"], commandId: "u:a" })); + await system.dispatch(updateNameCmd("a", "A renamed", "u:rename")); + expect((await system.catalog()).find((server) => server.id === "a")?.name).toBe("A renamed"); + }); + + it("skips a built-in whose config collides with an existing custom server (RED)", async () => { + system = makeDeciderSystem(); + await system.dispatch(addServerCmd({ serverId: "custom", name: "Custom", args: ["demo"], commandId: "u:c" })); + await system.reconcile([builtinDefForArgs("demo", ["demo"])]); + // The built-in must be skipped (not duplicated): no catalog row carries its builtinId. + expect((await system.catalog()).filter((server) => server.builtinId === "demo")).toHaveLength(0); + }); +}); + +describe("branch-3 #8 — varValues / ${VAR} ↔ declared-vars validation", () => { + let system: ReturnType; + afterEach(async () => { + await system.dispose(); + }); + + it("rejects adding a server whose config references an undeclared ${VAR} (RED)", async () => { + system = makeDeciderSystem(); + await expect( + system.dispatch( + addServerCmd({ serverId: "s", name: "S", args: ["--key=${UNDECLARED}"], commandId: "u:s", vars: [] }), + ), + ).rejects.toThrow(); + }); + + it("rejects a binding whose varValues key is not a declared var (RED)", async () => { + system = makeDeciderSystem(); + await system.dispatch(addServerCmd({ serverId: "s", name: "S", args: ["s"], commandId: "u:s" })); + await system.dispatch(projectCreateCmd("p", "u:p")); + await expect( + system.dispatch( + bindWithVarsCmd({ projectId: "p", serverId: "s", varValues: { UNKNOWN_VAR: "x" }, commandId: "u:bind" }), + ), + ).rejects.toThrow(); + }); +}); + +describe("branch-3 #6 — catalog trust flag", () => { + let system: ReturnType; + afterEach(async () => { + await system.dispose(); + }); + + it("defaults trust=true on add; an update can turn it off", async () => { + system = makeDeciderSystem(); + await system.dispatch(addServerCmd({ serverId: "t", name: "T", args: ["t"], commandId: "u:t" })); + expect((await system.catalog()).find((server) => server.id === "t")?.trust).toBe(true); + await system.dispatch(setTrustCmd("t", false, "u:t-off")); + expect((await system.catalog()).find((server) => server.id === "t")?.trust).toBe(false); + }); +}); diff --git a/apps/server/tests/ru-fork/mcp/branch3Hash64.test.ts b/apps/server/tests/ru-fork/mcp/branch3Hash64.test.ts new file mode 100644 index 00000000000..1613904a094 --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/branch3Hash64.test.ts @@ -0,0 +1,25 @@ +// ru-fork: improvements-branch-3 #10 — RED test for widening the identity hash to 64-bit. +// `fnv1a` is currently 32-bit (8 hex chars); the fix makes it 64-bit (16 hex chars). These assert the +// target. No production logic touched. + +import { fnv1a } from "@ru-fork/mcp-core"; +import { describe, expect, it } from "vitest"; + +describe("branch-3 #10 — 64-bit identity hash", () => { + it("produces 16 hex chars (64-bit), not 8 (RED until widened)", () => { + expect(fnv1a("anything")).toHaveLength(16); + }); + + it("is deterministic + hex", () => { + expect(fnv1a("same")).toBe(fnv1a("same")); + expect(fnv1a("x")).toMatch(/^[0-9a-f]+$/u); + }); + + it("distinguishes a large set of distinct inputs (no collision in the sample)", () => { + const seen = new Set(); + for (let index = 0; index < 5000; index += 1) { + seen.add(fnv1a(`config-${index}-${index * 7}`)); + } + expect(seen.size).toBe(5000); + }); +}); diff --git a/apps/server/tests/ru-fork/mcp/branch3Helpers.ts b/apps/server/tests/ru-fork/mcp/branch3Helpers.ts new file mode 100644 index 00000000000..37be0fab246 --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/branch3Helpers.ts @@ -0,0 +1,235 @@ +// ru-fork: shared harness for the "improvements-branch-3" test suite. Mirrors the makeSystem + +// command builders from reconciliationLifecycle.test.ts so each branch-3 test file can drive the REAL +// decider/engine without re-deriving the layer stack. No production logic here — test scaffolding only. + +import { + CommandId, + McpServerId, + ProjectId, + type McpBinding, + type McpCatalogServer, + type McpProbeRecord, + type McpServerConfig, + type McpServerVarDraft, + type OrchestrationCommand, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Path from "effect/Path"; +import * as NodeServices from "@effect/platform-node/NodeServices"; + +import { OrchestrationCommandReceiptRepositoryLive } from "../../../src/persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../../../src/persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../../../src/persistence/Layers/Sqlite.ts"; +import { McpBindingRepository } from "../../../src/persistence/Services/McpBinding.ts"; +import { McpCatalogRepository } from "../../../src/persistence/Services/McpCatalog.ts"; +import { McpProbeCacheRepository } from "../../../src/persistence/Services/McpProbeCache.ts"; +import { RepositoryIdentityResolverLive } from "../../../src/project/Layers/RepositoryIdentityResolver.ts"; +import { OrchestrationEngineLive } from "../../../src/orchestration/Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "../../../src/orchestration/Layers/ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "../../../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; +import { OrchestrationEngineService } from "../../../src/orchestration/Services/OrchestrationEngine.ts"; +import { ServerConfig } from "../../../src/config.ts"; +import { ServerSecretStore } from "../../../src/auth/Services/ServerSecretStore.ts"; +import { ServerSecretStoreLive } from "../../../src/auth/Layers/ServerSecretStore.ts"; +import { mcpVarSecretName } from "../../../src/ru-fork/mcp/McpSecretNames.ts"; +import { + backfillServerMetadataEffect, + computeDesiredEffect, + gcOrphanedSecretsEffect, + pruneOrphanedVarValuesEffect, + reconcileBuiltinsWith, +} from "../../../src/ru-fork/mcp/McpReactor.ts"; +import { + builtinConfigForPlatform, + builtinHash, + builtinServerId, + builtinShippedVars, + type McpBuiltinDefinition, +} from "../../../src/ru-fork/mcp/McpBuiltins.ts"; + +const stdio = (args: ReadonlyArray): McpServerConfig => ({ + transport: "stdio", + command: "uvx", + args: [...args], +}); + +export function makeDeciderSystem() { + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-branch3-test-" }); + const layer = Layer.mergeAll( + OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(OrchestrationProjectionPipelineLive), + ), + OrchestrationProjectionPipelineLive, + ).pipe( + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(ServerSecretStoreLive), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), + ); + const runtime = ManagedRuntime.make(layer); + const readRawSecretBytes = (name: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; + return yield* fileSystem.readFile(path.join(serverConfig.secretsDir, `${name}.bin`)); + }); + return { + dispatch: (command: OrchestrationCommand) => + runtime.runPromise( + Effect.flatMap(Effect.service(OrchestrationEngineService), (engine) => engine.dispatch(command)), + ), + catalog: (): Promise> => + runtime.runPromise(Effect.flatMap(Effect.service(McpCatalogRepository), (repo) => repo.listAll())), + bindings: (): Promise> => + runtime.runPromise(Effect.flatMap(Effect.service(McpBindingRepository), (repo) => repo.listAll())), + reconcile: (definitions: ReadonlyArray) => + runtime.runPromise(reconcileBuiltinsWith(definitions, process.platform)), + prune: () => runtime.runPromise(pruneOrphanedVarValuesEffect), + backfill: () => runtime.runPromise(backfillServerMetadataEffect), + probeUpsert: (record: McpProbeRecord) => + runtime.runPromise( + Effect.flatMap(Effect.service(McpProbeCacheRepository), (repo) => repo.upsert(record)), + ), + computeDesired: () => runtime.runPromise(computeDesiredEffect), + secretSet: (name: string, value: Uint8Array) => + runtime.runPromise( + Effect.gen(function* () { + const store = yield* ServerSecretStore; + yield* store.set(name, value); + }), + ), + secretRemove: (name: string) => + runtime.runPromise( + Effect.gen(function* () { + const store = yield* ServerSecretStore; + yield* store.remove(name); + }), + ), + secretGet: (name: string): Promise => + runtime.runPromise( + Effect.gen(function* () { + const store = yield* ServerSecretStore; + return yield* store.get(name); + }), + ), + gcOrphans: () => runtime.runPromise(gcOrphanedSecretsEffect), + readRawSecretBytes: (name: string) => runtime.runPromise(readRawSecretBytes(name)), + mcpVarSecretName, + dispose: () => runtime.dispose(), + }; +} + +// ── command builders ───────────────────────────────────────────────────────── + +export function addServerCmd(input: { + readonly serverId: string; + readonly name: string; + readonly args: ReadonlyArray; + readonly commandId: string; + readonly vars?: ReadonlyArray; +}): OrchestrationCommand { + return { + type: "mcp.server-add", + commandId: CommandId.make(input.commandId), + serverId: McpServerId.make(input.serverId), + draft: { + name: input.name, + config: stdio(input.args), + vars: input.vars ? [...input.vars] : [], + timeoutMs: null, + }, + createdAt: "2026-01-01T00:00:00.000Z", + }; +} + +export function updateConfigCmd( + serverId: string, + args: ReadonlyArray, + commandId: string, +): OrchestrationCommand { + return { + type: "mcp.server-update", + commandId: CommandId.make(commandId), + serverId: McpServerId.make(serverId), + patch: { config: stdio(args) }, + }; +} + +export function setTrustCmd(serverId: string, trust: boolean, commandId: string): OrchestrationCommand { + return { + type: "mcp.server-update", + commandId: CommandId.make(commandId), + serverId: McpServerId.make(serverId), + patch: { trust }, + }; +} + +export function updateNameCmd(serverId: string, name: string, commandId: string): OrchestrationCommand { + return { + type: "mcp.server-update", + commandId: CommandId.make(commandId), + serverId: McpServerId.make(serverId), + patch: { name }, + }; +} + +export function projectCreateCmd(projectId: string, commandId: string): OrchestrationCommand { + return { + type: "project.create", + commandId: CommandId.make(commandId), + projectId: ProjectId.make(projectId), + title: "Test project", + workspaceRoot: `/tmp/${projectId}`, + createdAt: "2026-01-01T00:00:00.000Z", + }; +} + +export function bindWithVarsCmd(input: { + readonly projectId: string; + readonly serverId: string; + readonly varValues: Readonly>; + readonly commandId: string; +}): OrchestrationCommand { + return { + type: "mcp.binding-set", + commandId: CommandId.make(input.commandId), + projectId: ProjectId.make(input.projectId), + serverId: McpServerId.make(input.serverId), + patch: { enabled: true, varValues: { ...input.varValues } }, + }; +} + +export function builtinDefForArgs(builtinId: string, args: ReadonlyArray): McpBuiltinDefinition { + return { + builtinId, + name: builtinId, + description: `${builtinId} builtin`, + config: { default: stdio(args) }, + vars: [], + }; +} + +export function syncCmd(definition: McpBuiltinDefinition, commandId: string): OrchestrationCommand { + const config = builtinConfigForPlatform(definition, process.platform)!; + return { + type: "mcp.builtin-sync", + commandId: CommandId.make(commandId), + serverId: McpServerId.make(builtinServerId(definition.builtinId)), + builtinId: definition.builtinId, + builtinHash: builtinHash(config, definition), + name: definition.name, + description: definition.description ?? null, + websiteUrl: null, + config, + shippedVars: builtinShippedVars(definition), + timeoutMs: null, + }; +} diff --git a/apps/server/tests/ru-fork/mcp/branch3MissingSecret.test.ts b/apps/server/tests/ru-fork/mcp/branch3MissingSecret.test.ts new file mode 100644 index 00000000000..8cb9d3762d4 --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/branch3MissingSecret.test.ts @@ -0,0 +1,39 @@ +// ru-fork: improvements-branch-3 #9 — RED test for excluding a server whose required secret's stored +// value is gone (deleted out-of-band / failed write). Today the binding still resolves (secret → "") +// and the instance is probed/launched with a blank credential; the fix must treat it as incomplete +// and EXCLUDE it from the desired set. No production logic touched. + +import { afterEach, describe, expect, it } from "vitest"; + +import { addServerCmd, makeDeciderSystem } from "./branch3Helpers.ts"; + +describe("branch-3 #9 — missing secret ⇒ incomplete (excluded)", () => { + let system: ReturnType; + afterEach(async () => { + await system.dispose(); + }); + + it("excludes a catalog server whose required secret file is missing (RED)", async () => { + system = makeDeciderSystem(); + await system.dispatch( + addServerCmd({ + serverId: "sec", + name: "Sec", + args: ["${TOK}"], + commandId: "u:sec", + vars: [{ name: "TOK", secret: true, perProject: false, required: true, value: "stored" }], + }), + ); + // Decider stored the secret on add; now delete it out-of-band to simulate corruption / failed write. + await system.secretRemove(system.mcpVarSecretName({ serverId: "sec", varName: "TOK" })); + const desired = await system.computeDesired(); + const refs: string[] = []; + for (const instance of desired.values()) { + for (const ref of instance.refs) { + refs.push(ref); + } + } + // RED today: the instance is still desired (resolves to "") — the fix must exclude it. + expect(refs).not.toContain("catalog:sec"); + }); +}); diff --git a/apps/server/tests/ru-fork/mcp/branch3OrphanGc.test.ts b/apps/server/tests/ru-fork/mcp/branch3OrphanGc.test.ts new file mode 100644 index 00000000000..d13798c22c5 --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/branch3OrphanGc.test.ts @@ -0,0 +1,41 @@ +// ru-fork: improvements-branch-3 #7 GUARD (green) — the existing orphan-secret GC is the +// transactional safety net (we rely on it instead of decide-phase compensation). This pins that it +// (a) prunes an unreferenced `mcp-var-*` secret, (b) KEEPS a secret still referenced by a catalog +// server, and (c) never touches a non-`mcp-var-*` secret. No production logic touched. + +import { afterEach, describe, expect, it } from "vitest"; + +import { addServerCmd, makeDeciderSystem } from "./branch3Helpers.ts"; + +describe("branch-3 #7 — orphan-secret GC is the safety net", () => { + let system: ReturnType; + afterEach(async () => { + await system.dispose(); + }); + + it("prunes an orphan mcp-var secret, keeps a referenced one, ignores non-mcp secrets", async () => { + system = makeDeciderSystem(); + // A referenced secret: a catalog server with a secret var ⇒ its secret is "live". + await system.dispatch( + addServerCmd({ + serverId: "live", + name: "Live", + args: ["${TOK}"], + commandId: "u:live", + vars: [{ name: "TOK", secret: true, perProject: false, required: true, value: "kept" }], + }), + ); + const liveName = system.mcpVarSecretName({ serverId: "live", varName: "TOK" }); + + // An orphan mcp-var secret (no server references it) + a non-mcp secret (wrong prefix). + const encoder = new TextEncoder(); + await system.secretSet("mcp-var-orphan-zzz", encoder.encode("orphan")); + await system.secretSet("auth-session-key", encoder.encode("unrelated")); + + await system.gcOrphans(); + + expect(await system.secretGet("mcp-var-orphan-zzz")).toBeNull(); // pruned + expect(await system.secretGet(liveName)).not.toBeNull(); // referenced ⇒ kept + expect(await system.secretGet("auth-session-key")).not.toBeNull(); // wrong prefix ⇒ untouched + }); +}); diff --git a/apps/server/tests/ru-fork/mcp/branch3OverlayGuards.test.ts b/apps/server/tests/ru-fork/mcp/branch3OverlayGuards.test.ts new file mode 100644 index 00000000000..7fffa78f548 --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/branch3OverlayGuards.test.ts @@ -0,0 +1,119 @@ +// ru-fork: improvements-branch-3 GUARD tests (green now) — protect the parts we're about to change. +// (1) Overlay ↔ qwen schema conformance: every key buildServerEntry emits must be a field qwen's +// MCPServerConfig accepts — so a future change (e.g. adding `trust`, or a typo) can't silently +// drift out of qwen's schema. +// (2) Fingerprint respawn matrix: config/toolPolicy changes flip the overlay fingerprint (qwen +// respawns); identical input is stable; order-independent. Locks the "what triggers a restart" +// logic before `trust` is added to it. +// No production logic touched (buildServerEntry is exported as a testability seam). + +import type { ResolvedServerConfig } from "@ru-fork/mcp-core"; +import { overlayFingerprint, type OverlayServerEntry } from "@ru-fork/mcp-core"; +import { DEFAULT_TOOL_POLICY, type McpToolPolicy } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { buildServerEntry } from "../../../src/ru-fork/mcp/McpOverlay.ts"; + +// The exact field set qwen 0.13.1 accepts on an mcpServers entry (MCPServerConfig, +// sdk-typescript/src/types/protocol.ts:287-306). buildServerEntry must stay a subset of this. +const QWEN_MCP_SERVER_KEYS = new Set([ + "command", + "args", + "env", + "cwd", + "url", + "httpUrl", + "headers", + "tcp", + "timeout", + "trust", + "description", + "includeTools", + "excludeTools", + "extensionName", + "oauth", + "authProviderType", + "targetAudience", + "targetServiceAccount", +]); + +const stdioResolved: ResolvedServerConfig = { + transport: "stdio", + command: "uvx", + args: ["server"], + env: { TOKEN: "x" }, + timeoutMs: 30_000, +}; +const httpResolved: ResolvedServerConfig = { + transport: "http", + httpUrl: "https://example.test/mcp", + headers: { Authorization: "Bearer x" }, + timeoutMs: 30_000, +}; +const denyPolicy: McpToolPolicy = { defaultDecision: "deny", exceptions: ["read"] }; + +const entry = ( + serverName: string, + resolved: ResolvedServerConfig, + toolPolicy: McpToolPolicy, + trust = true, +): OverlayServerEntry => ({ serverName, resolved, toolPolicy, trust }); + +describe("branch-3 guard — overlay ↔ qwen schema conformance", () => { + it("stdio entry uses only keys qwen accepts + emits trust (#6)", () => { + const entry = buildServerEntry(stdioResolved, DEFAULT_TOOL_POLICY, true); + for (const key of Object.keys(entry)) { + expect(QWEN_MCP_SERVER_KEYS.has(key)).toBe(true); + } + expect(entry.trust).toBe(true); + }); + + it("http entry (with tool filter) uses only keys qwen accepts + emits trust=false (#6)", () => { + const entry = buildServerEntry(httpResolved, denyPolicy, false); + for (const key of Object.keys(entry)) { + expect(QWEN_MCP_SERVER_KEYS.has(key)).toBe(true); + } + expect(entry).toHaveProperty("includeTools"); + expect(entry.trust).toBe(false); + }); +}); + +describe("branch-3 guard — overlay fingerprint respawn matrix", () => { + it("identical input ⇒ identical fingerprint", () => { + expect(overlayFingerprint([entry("a", stdioResolved, DEFAULT_TOOL_POLICY)])).toBe( + overlayFingerprint([entry("a", stdioResolved, DEFAULT_TOOL_POLICY)]), + ); + }); + + it("a config change flips the fingerprint (⇒ respawn)", () => { + const before = overlayFingerprint([entry("a", stdioResolved, DEFAULT_TOOL_POLICY)]); + const after = overlayFingerprint([ + entry("a", { ...stdioResolved, args: ["server", "--flag"] }, DEFAULT_TOOL_POLICY), + ]); + expect(after).not.toBe(before); + }); + + it("a tool-policy change flips the fingerprint (⇒ respawn)", () => { + const before = overlayFingerprint([entry("a", stdioResolved, DEFAULT_TOOL_POLICY)]); + const after = overlayFingerprint([entry("a", stdioResolved, denyPolicy)]); + expect(after).not.toBe(before); + }); + + it("a TRUST change flips the fingerprint (⇒ respawn) — #6", () => { + const trusted = overlayFingerprint([entry("a", stdioResolved, DEFAULT_TOOL_POLICY, true)]); + const untrusted = overlayFingerprint([entry("a", stdioResolved, DEFAULT_TOOL_POLICY, false)]); + expect(untrusted).not.toBe(trusted); + }); + + it("is order-independent across servers", () => { + const left = overlayFingerprint([ + entry("a", stdioResolved, DEFAULT_TOOL_POLICY), + entry("b", httpResolved, DEFAULT_TOOL_POLICY), + ]); + const right = overlayFingerprint([ + entry("b", httpResolved, DEFAULT_TOOL_POLICY), + entry("a", stdioResolved, DEFAULT_TOOL_POLICY), + ]); + expect(left).toBe(right); + }); +}); diff --git a/apps/server/tests/ru-fork/mcp/branch3SecretAtRest.test.ts b/apps/server/tests/ru-fork/mcp/branch3SecretAtRest.test.ts new file mode 100644 index 00000000000..c1c611e04f2 --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/branch3SecretAtRest.test.ts @@ -0,0 +1,24 @@ +// ru-fork: improvements-branch-3 #4 — RED test for encrypting secrets at rest. Today the +// ServerSecretStore writes plaintext bytes to `${secretsDir}/.bin`; the fix encrypts them +// (scrypt + AES-GCM). This asserts the on-disk bytes are NOT the plaintext. No production logic touched. + +import { afterEach, describe, expect, it } from "vitest"; + +import { makeDeciderSystem } from "./branch3Helpers.ts"; + +describe("branch-3 #4 — secrets encrypted at rest", () => { + let system: ReturnType; + afterEach(async () => { + await system.dispose(); + }); + + it("does NOT store a secret as plaintext on disk (RED until encrypted)", async () => { + system = makeDeciderSystem(); + const plaintext = "super-secret-token-value-9c1f"; + const name = "branch3-at-rest"; + await system.secretSet(name, new TextEncoder().encode(plaintext)); + const onDisk = new TextDecoder().decode(await system.readRawSecretBytes(name)); + expect(onDisk).not.toBe(plaintext); + expect(onDisk).not.toContain(plaintext); + }); +}); diff --git a/apps/server/tests/ru-fork/mcp/builtins.test.ts b/apps/server/tests/ru-fork/mcp/builtins.test.ts new file mode 100644 index 00000000000..81364bae6f1 --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/builtins.test.ts @@ -0,0 +1,595 @@ +// ru-fork: unit tests for the managed built-ins migrator logic — the content hash (drives +// "shipped template changed → update"), platform resolution, shipped-var origin, the 3-way merge +// (buildSyncedBuiltin: shipped REPLACE, user data PRESERVED), and mergeTemplateVars (a locked +// template's shipped DECLARATIONS are immutable while shipped VALUES + user vars are editable). +// Encodes PART K4–K7. + +import { DEFAULT_TOOL_POLICY, McpServerId, ProjectId } from "@t3tools/contracts"; +import type { + McpBinding, + McpCatalogServer, + McpServerDraft, + McpServerDraftPatch, + McpServerVarDraft, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { describe, expect, it } from "vitest"; + +import { ServerSecretStore } from "../../../src/auth/Services/ServerSecretStore.ts"; +import { + applyServerUpdate, + buildAddedServer, + buildBinding, + buildSyncedBuiltin, + mergeTemplateVars, +} from "../../../src/ru-fork/mcp/McpCatalogBuilders.ts"; +import { + builtinConfigForPlatform, + builtinHash, + builtinServerId, + builtinShippedVars, + type McpBuiltinDefinition, +} from "../../../src/ru-fork/mcp/McpBuiltins.ts"; + +function fakeSecretStoreLayer() { + const store = new Map(); + return Layer.succeed(ServerSecretStore, { + get: (name: string) => Effect.succeed(store.get(name) ?? null), + set: (name: string, value: Uint8Array) => + Effect.sync(() => { + store.set(name, value); + }), + getOrCreateRandom: (name: string, bytes: number) => + Effect.sync(() => store.get(name) ?? new Uint8Array(bytes)), + remove: (name: string) => + Effect.sync(() => { + store.delete(name); + }), + pruneByPrefix: () => Effect.void, + }); +} + +const SECRET_TEMPLATE: McpBuiltinDefinition = { + builtinId: "demo", + name: "demo", + description: "Demo template", + config: { + default: { transport: "stdio", command: "npx", args: ["-y", "demo", "${PROJECT_CWD}"] }, + win32: { transport: "stdio", command: "npx.cmd", args: ["-y", "demo", "${PROJECT_CWD}"] }, + }, + vars: [{ name: "TOKEN", secret: true, perProject: false, required: true, value: null }], +}; + +describe("builtinConfigForPlatform", () => { + it("prefers the platform-specific variant, else default", () => { + expect(builtinConfigForPlatform(SECRET_TEMPLATE, "win32")?.command).toBe("npx.cmd"); + expect(builtinConfigForPlatform(SECRET_TEMPLATE, "linux")?.command).toBe("npx"); + }); + + it("returns null when neither a platform variant nor a default exists (skip the built-in)", () => { + const noDefault: McpBuiltinDefinition = { + builtinId: "x", + name: "x", + config: { win32: { transport: "stdio", command: "x", args: [] } }, + vars: [], + }; + expect(builtinConfigForPlatform(noDefault, "linux")).toBeNull(); + }); +}); + +describe("builtinShippedVars + builtinHash", () => { + it("stamps origin:shipped and keeps the declared (null) secret value", () => { + const [token] = builtinShippedVars(SECRET_TEMPLATE); + expect(token).toEqual({ + name: "TOKEN", + secret: true, + perProject: false, + required: true, + value: null, + origin: "shipped", + valueLocked: false, + }); + }); + + it("is stable for the same definition+config and changes when the command changes", () => { + const linux = builtinConfigForPlatform(SECRET_TEMPLATE, "linux")!; + const win = builtinConfigForPlatform(SECRET_TEMPLATE, "win32")!; + expect(builtinHash(linux, SECRET_TEMPLATE)).toBe(builtinHash(linux, SECRET_TEMPLATE)); + expect(builtinHash(linux, SECRET_TEMPLATE)).not.toBe(builtinHash(win, SECRET_TEMPLATE)); + }); +}); + +describe("buildSyncedBuiltin — 3-way merge", () => { + const shipped = builtinShippedVars(SECRET_TEMPLATE); + const config = builtinConfigForPlatform(SECRET_TEMPLATE, "linux")!; + const hash = builtinHash(config, SECRET_TEMPLATE); + + it("adds a fresh built-in: locked, source builtin, identity recorded", () => { + const server = buildSyncedBuiltin({ + serverId: McpServerId.make(builtinServerId("demo")), + builtinId: "demo", + builtinHash: hash, + name: "demo", + description: "Demo template", + websiteUrl: null, + config, + shippedVars: shipped, + timeoutMs: null, + existing: undefined, + occurredAt: "2026-01-01T00:00:00.000Z", + }); + expect(server.source).toBe("builtin"); + expect(server.locked).toBe(true); + expect(server.builtinId).toBe("demo"); + expect(server.builtinHash).toBe(hash); + expect(server.vars.map((variable) => variable.name)).toEqual(["TOKEN"]); + }); + + it("preserves the user's configured secret value + user vars + extraArgs across an update", () => { + const existing: McpCatalogServer = { + id: McpServerId.make(builtinServerId("demo")), + name: "demo", + description: "old", + websiteUrl: null, + source: "builtin", + config: { transport: "stdio", command: "npx", args: ["old"] }, + vars: [ + { name: "TOKEN", secret: true, perProject: false, required: true, value: { secretRef: "kept" }, origin: "shipped" }, + { name: "EXTRA", secret: false, perProject: false, required: false, value: "u", origin: "user" }, + ], + extraArgs: ["--flag"], + extraHeaders: {}, + builtinId: "demo", + builtinHash: "old-hash", + locked: true, + enabled: true, + timeoutMs: null, + createdAt: "2025-01-01T00:00:00.000Z", + updatedAt: "2025-01-01T00:00:00.000Z", + }; + const server = buildSyncedBuiltin({ + serverId: existing.id, + builtinId: "demo", + builtinHash: hash, + name: "demo", + description: "Demo template", + websiteUrl: null, + config, + shippedVars: shipped, + timeoutMs: null, + existing, + occurredAt: "2026-06-10T00:00:00.000Z", + }); + // shipped TOKEN keeps the user's stored ref; user EXTRA survives; extraArgs preserved; createdAt kept. + const token = server.vars.find((variable) => variable.name === "TOKEN"); + expect(token?.value).toEqual({ secretRef: "kept" }); + expect(token?.origin).toBe("shipped"); + expect(server.vars.find((variable) => variable.name === "EXTRA")?.origin).toBe("user"); + expect(server.extraArgs).toEqual(["--flag"]); + expect(server.createdAt).toBe("2025-01-01T00:00:00.000Z"); + expect(server.builtinHash).toBe(hash); + }); +}); + +// A template with an author-fixed value (URL) AND a user-fillable hole (USER) — exercises the +// value-lock / keptValue matrix that a single secret var can't. +const MIXED_TEMPLATE: McpBuiltinDefinition = { + builtinId: "atl", + name: "atl", + description: "Atlassian-like", + config: { default: { transport: "stdio", command: "uvx", args: ["atl"] } }, + vars: [ + { name: "URL", secret: false, perProject: false, required: true, value: "https://old.example.com" }, + { name: "USER", secret: false, perProject: false, required: true, value: null }, + ], +}; + +/** Build the catalog row a previous sync of `definition` would have produced (the "installed" state). */ +function installedFrom(definition: McpBuiltinDefinition, occurredAt = "2025-01-01T00:00:00.000Z") { + const config = builtinConfigForPlatform(definition, "linux")!; + return buildSyncedBuiltin({ + serverId: McpServerId.make(builtinServerId(definition.builtinId)), + builtinId: definition.builtinId, + builtinHash: builtinHash(config, definition), + name: definition.name, + description: definition.description ?? null, + websiteUrl: null, + config, + shippedVars: builtinShippedVars(definition), + timeoutMs: null, + existing: undefined, + occurredAt, + }); +} + +/** Re-sync `next` over an installed `prior` row (what a definition change produces on restart). */ +function resync(prior: McpCatalogServer, next: McpBuiltinDefinition, occurredAt = "2026-06-10T00:00:00.000Z") { + const config = builtinConfigForPlatform(next, "linux")!; + return buildSyncedBuiltin({ + serverId: prior.id, + builtinId: next.builtinId, + builtinHash: builtinHash(config, next), + name: next.name, + description: next.description ?? null, + websiteUrl: null, + config, + shippedVars: builtinShippedVars(next), + timeoutMs: null, + existing: prior, + occurredAt, + }); +} + +function findVar(server: McpCatalogServer, name: string) { + return server.vars.find((variable) => variable.name === name); +} + +const NOW = "2026-06-10T00:00:00.000Z"; + +const customServer: McpCatalogServer = { + id: McpServerId.make("srv-custom"), + name: "custom", + description: "old", + websiteUrl: "https://docs.example.com", + source: "custom", + config: { transport: "stdio", command: "uvx", args: ["mine"] }, + vars: [], + extraArgs: [], + extraHeaders: {}, + builtinId: null, + builtinHash: null, + locked: false, + enabled: true, + timeoutMs: null, + createdAt: "2025-01-01T00:00:00.000Z", + updatedAt: "2025-01-01T00:00:00.000Z", +}; + +describe("buildAddedServer (manual/custom add)", () => { + it("creates a custom, unlocked server with no built-in identity", () => { + const draft: McpServerDraft = { + name: "mine", + description: "my server", + config: { transport: "stdio", command: "uvx", args: ["mine"] }, + vars: [], + timeoutMs: null, + }; + const server = buildAddedServer(McpServerId.make("srv-1"), draft, [], NOW); + expect(server.source).toBe("custom"); + expect(server.locked).toBe(false); + expect(server.builtinId).toBeNull(); + expect(server.enabled).toBe(true); + expect(server.description).toBe("my server"); + expect(server.websiteUrl).toBeNull(); // a manual server has no shipped link + }); +}); + +describe("applyServerUpdate (patch semantics)", () => { + it("description: undefined keeps, null clears, string sets", () => { + expect(applyServerUpdate(customServer, {}, customServer.vars, NOW).description).toBe("old"); + const cleared: McpServerDraftPatch = { description: null }; + expect(applyServerUpdate(customServer, cleared, customServer.vars, NOW).description).toBeNull(); + const set: McpServerDraftPatch = { description: "new" }; + expect(applyServerUpdate(customServer, set, customServer.vars, NOW).description).toBe("new"); + }); + + it("websiteUrl is never cleared by an absent patch (patch ?? existing)", () => { + expect(applyServerUpdate(customServer, {}, customServer.vars, NOW).websiteUrl).toBe( + "https://docs.example.com", + ); + }); + + it("a LOCKED template ignores a config patch (keeps the shipped command)", () => { + const locked: McpCatalogServer = { ...customServer, locked: true }; + const patch: McpServerDraftPatch = { + config: { transport: "stdio", command: "EVIL", args: ["x"] }, + }; + expect(applyServerUpdate(locked, patch, locked.vars, NOW).config).toEqual(locked.config); + }); + + it("an UNLOCKED server applies a config patch", () => { + const patch: McpServerDraftPatch = { + config: { transport: "stdio", command: "npx", args: ["y"] }, + }; + expect(applyServerUpdate(customServer, patch, customServer.vars, NOW).config).toEqual({ + transport: "stdio", + command: "npx", + args: ["y"], + }); + }); + + it("enabled + timeoutMs patches apply; createdAt is preserved", () => { + const patch: McpServerDraftPatch = { enabled: false, timeoutMs: 5000 }; + const patched = applyServerUpdate(customServer, patch, customServer.vars, NOW); + expect(patched.enabled).toBe(false); + expect(patched.timeoutMs).toBe(5000); + expect(patched.createdAt).toBe(customServer.createdAt); + expect(patched.updatedAt).toBe(NOW); + }); +}); + +describe("buildBinding", () => { + it("a fresh binding defaults enabled=true + DEFAULT_TOOL_POLICY, stamps createdAt", () => { + const binding = buildBinding({ + projectId: ProjectId.make("proj"), + serverId: McpServerId.make("srv"), + patch: {}, + existing: undefined, + varValues: {}, + occurredAt: NOW, + }); + expect(binding.enabled).toBe(true); + expect(binding.toolPolicy).toEqual(DEFAULT_TOOL_POLICY); + expect(binding.createdAt).toBe(NOW); + }); + + it("an update preserves the original createdAt and carries the patch + varValues", () => { + const existing: McpBinding = { + projectId: ProjectId.make("proj"), + serverId: McpServerId.make("srv"), + enabled: true, + toolPolicy: DEFAULT_TOOL_POLICY, + varValues: {}, + timeoutMs: null, + createdAt: "2025-01-01T00:00:00.000Z", + updatedAt: "2025-01-01T00:00:00.000Z", + }; + const binding = buildBinding({ + projectId: ProjectId.make("proj"), + serverId: McpServerId.make("srv"), + patch: { enabled: false }, + existing, + varValues: { V: "x" }, + occurredAt: NOW, + }); + expect(binding.enabled).toBe(false); + expect(binding.varValues).toEqual({ V: "x" }); + expect(binding.createdAt).toBe("2025-01-01T00:00:00.000Z"); + expect(binding.updatedAt).toBe(NOW); + }); +}); + +describe("built-in definition changes drive an update (characterization)", () => { + it("valueLocked mirrors the shipped value: author-fixed URL locked, hole USER not", () => { + const [url, user] = builtinShippedVars(MIXED_TEMPLATE); + expect(url?.valueLocked).toBe(true); + expect(user?.valueLocked).toBe(false); + }); + + it("any value change flips the hash, so the reactor re-syncs", () => { + const v2: McpBuiltinDefinition = { + ...MIXED_TEMPLATE, + vars: [ + { name: "URL", secret: false, perProject: false, required: true, value: "https://new.example.com" }, + { name: "USER", secret: false, perProject: false, required: true, value: null }, + ], + }; + const linux = builtinConfigForPlatform(MIXED_TEMPLATE, "linux")!; + expect(builtinHash(linux, MIXED_TEMPLATE)).not.toBe(builtinHash(linux, v2)); + }); + + it("an author-fixed value change propagates to an existing install (shipped value wins)", () => { + const prior = installedFrom(MIXED_TEMPLATE); + expect(findVar(prior, "URL")?.value).toBe("https://old.example.com"); + const v2: McpBuiltinDefinition = { + ...MIXED_TEMPLATE, + vars: [ + { name: "URL", secret: false, perProject: false, required: true, value: "https://new.example.com" }, + { name: "USER", secret: false, perProject: false, required: true, value: null }, + ], + }; + const updated = resync(prior, v2); + expect(findVar(updated, "URL")?.value).toBe("https://new.example.com"); + expect(findVar(updated, "URL")?.valueLocked).toBe(true); + }); + + it("required → not-required changes the hash and the synced declaration", () => { + const v2: McpBuiltinDefinition = { + ...MIXED_TEMPLATE, + vars: [ + { name: "URL", secret: false, perProject: false, required: true, value: "https://old.example.com" }, + { name: "USER", secret: false, perProject: false, required: false, value: null }, + ], + }; + const linux = builtinConfigForPlatform(MIXED_TEMPLATE, "linux")!; + expect(builtinHash(linux, MIXED_TEMPLATE)).not.toBe(builtinHash(linux, v2)); + const updated = resync(installedFrom(MIXED_TEMPLATE), v2); + expect(findVar(updated, "USER")?.required).toBe(false); + }); + + it("a command change re-syncs the locked config", () => { + const v2: McpBuiltinDefinition = { + ...MIXED_TEMPLATE, + config: { default: { transport: "stdio", command: "uvx", args: ["atl", "--verbose"] } }, + }; + const linux = builtinConfigForPlatform(MIXED_TEMPLATE, "linux")!; + expect(builtinHash(linux, MIXED_TEMPLATE)).not.toBe( + builtinHash(builtinConfigForPlatform(v2, "linux")!, v2), + ); + const updated = resync(installedFrom(MIXED_TEMPLATE), v2); + expect(updated.config).toEqual({ transport: "stdio", command: "uvx", args: ["atl", "--verbose"] }); + }); + + it("a removed var disappears; a user-added var survives", () => { + const prior: McpCatalogServer = { + ...installedFrom(MIXED_TEMPLATE), + vars: [ + ...installedFrom(MIXED_TEMPLATE).vars, + { name: "MINE", secret: false, perProject: false, required: false, value: "x", origin: "user" }, + ], + }; + const v2: McpBuiltinDefinition = { + ...MIXED_TEMPLATE, + vars: [{ name: "URL", secret: false, perProject: false, required: true, value: "https://old.example.com" }], + }; + const updated = resync(prior, v2); + const names = updated.vars.map((variable) => variable.name); + expect(names).toContain("URL"); + expect(names).not.toContain("USER"); // shipped var removed from the definition ⇒ gone + expect(names).toContain("MINE"); // user-added var preserved + }); + + it("a user-filled hole keeps its value across an unrelated re-sync", () => { + const prior: McpCatalogServer = { + ...installedFrom(MIXED_TEMPLATE), + vars: installedFrom(MIXED_TEMPLATE).vars.map((variable) => + variable.name === "USER" ? Object.assign({}, variable, { value: "alice" }) : variable, + ), + }; + const v2: McpBuiltinDefinition = { + ...MIXED_TEMPLATE, + config: { default: { transport: "stdio", command: "uvx", args: ["atl", "--v2"] } }, + }; + const updated = resync(prior, v2); + expect(findVar(updated, "USER")?.value).toBe("alice"); + }); + + it("a locked value turned into a hole (value→null) clears the old value AND unlocks", () => { + const prior = installedFrom(MIXED_TEMPLATE); // URL = old, valueLocked true + const v2: McpBuiltinDefinition = { + ...MIXED_TEMPLATE, + vars: [ + { name: "URL", secret: false, perProject: false, required: true, value: null }, + { name: "USER", secret: false, perProject: false, required: true, value: null }, + ], + }; + const updated = resync(prior, v2); + expect(findVar(updated, "URL")?.value).toBeNull(); // old author URL is NOT stranded + expect(findVar(updated, "URL")?.valueLocked).toBe(false); // now a user-fillable hole + }); +}); + +describe("builtinHash is sensitive to EVERY definition field (no silent no-update)", () => { + const base = MIXED_TEMPLATE; + const linux = builtinConfigForPlatform(base, "linux")!; + const baseHash = builtinHash(linux, base); + const [url, user] = base.vars; + + // Each case changes exactly one field; the hash must move (so the reactor re-syncs). + const definitionFieldCases: ReadonlyArray = [ + ["name", { ...base, name: "atl-renamed" }], + ["description", { ...base, description: "changed description" }], + ["websiteUrl", { ...base, websiteUrl: "https://docs.example.com" }], + ["timeoutMs", { ...base, timeoutMs: 5000 }], + ["a var's value", { ...base, vars: [{ ...url!, value: "https://changed" }, user!] }], + ["a var's required flag", { ...base, vars: [url!, { ...user!, required: false }] }], + ["a var's secret flag", { ...base, vars: [url!, { ...user!, secret: true }] }], + ["a var's perProject flag", { ...base, vars: [url!, { ...user!, perProject: true }] }], + [ + "an added var", + { ...base, vars: [url!, user!, { name: "NEW", secret: false, perProject: false, required: false, value: null }] }, + ], + ["a removed var", { ...base, vars: [url!] }], + ]; + + for (const [label, changed] of definitionFieldCases) { + it(`changes when ${label} changes`, () => { + expect(builtinHash(linux, changed)).not.toBe(baseHash); + }); + } + + it("changes when the command args change", () => { + const v2: McpBuiltinDefinition = { + ...base, + config: { default: { transport: "stdio", command: "uvx", args: ["atl", "--verbose"] } }, + }; + expect(builtinHash(builtinConfigForPlatform(v2, "linux")!, v2)).not.toBe(baseHash); + }); + + it("changes when the command itself changes", () => { + const v2: McpBuiltinDefinition = { + ...base, + config: { default: { transport: "stdio", command: "npx", args: ["atl"] } }, + }; + expect(builtinHash(builtinConfigForPlatform(v2, "linux")!, v2)).not.toBe(baseHash); + }); + + it("changes when the transport switches stdio→http", () => { + const v2: McpBuiltinDefinition = { + ...base, + config: { default: { transport: "http", httpUrl: "https://x", headers: {} } }, + vars: [], + }; + expect(builtinHash(builtinConfigForPlatform(v2, "linux")!, v2)).not.toBe(baseHash); + }); + + it("changes when an http header value changes", () => { + const v1: McpBuiltinDefinition = { + ...base, + config: { default: { transport: "http", httpUrl: "https://x", headers: { A: "1" } } }, + vars: [], + }; + const v2: McpBuiltinDefinition = { + ...base, + config: { default: { transport: "http", httpUrl: "https://x", headers: { A: "2" } } }, + vars: [], + }; + expect(builtinHash(builtinConfigForPlatform(v1, "linux")!, v1)).not.toBe( + builtinHash(builtinConfigForPlatform(v2, "linux")!, v2), + ); + }); +}); + +describe("mergeTemplateVars", () => { + const lockedExisting: McpCatalogServer = { + id: McpServerId.make("srv-builtin-demo"), + name: "demo", + description: null, + websiteUrl: null, + source: "builtin", + config: { transport: "stdio", command: "npx", args: ["demo"] }, + vars: [ + { name: "TOKEN", secret: true, perProject: false, required: true, value: null, origin: "shipped" }, + ], + extraArgs: [], + extraHeaders: {}, + builtinId: "demo", + builtinHash: "h", + locked: true, + enabled: true, + timeoutMs: null, + createdAt: "2025-01-01T00:00:00.000Z", + updatedAt: "2025-01-01T00:00:00.000Z", + }; + + it("locked: re-stamps a shipped-named row to its shipped declaration but takes the new value", async () => { + const drafts: ReadonlyArray = [ + // The UI sends the shipped row (declaration read-only) with a filled value + a new user var. + { name: "TOKEN", secret: true, perProject: false, required: true, value: "filled" }, + { name: "MINE", secret: false, perProject: false, required: false, value: "v" }, + ]; + const result = await Effect.runPromise( + mergeTemplateVars(lockedExisting.id, lockedExisting, drafts).pipe( + Effect.provide(fakeSecretStoreLayer()), + ), + ); + const token = result.find((variable) => variable.name === "TOKEN"); + expect(token?.origin).toBe("shipped"); // declaration stays shipped (not flipped to user) + expect(token?.required).toBe(true); + expect(token?.value).not.toBeNull(); // the value WAS taken from the draft (stored as a ref) + expect(result.find((variable) => variable.name === "MINE")?.origin).toBe("user"); + }); + + it("locked: a shipped var omitted by the draft is preserved untouched", async () => { + const result = await Effect.runPromise( + mergeTemplateVars(lockedExisting.id, lockedExisting, []).pipe( + Effect.provide(fakeSecretStoreLayer()), + ), + ); + expect(result.map((variable) => variable.name)).toEqual(["TOKEN"]); + expect(result[0]?.origin).toBe("shipped"); + }); + + it("unlocked: every draft becomes a user var", async () => { + const unlocked: McpCatalogServer = { ...lockedExisting, locked: false, vars: [] }; + const drafts: ReadonlyArray = [ + { name: "A", secret: false, perProject: false, required: false, value: "1" }, + ]; + const result = await Effect.runPromise( + mergeTemplateVars(unlocked.id, unlocked, drafts).pipe(Effect.provide(fakeSecretStoreLayer())), + ); + expect(result).toHaveLength(1); + expect(result[0]?.origin).toBe("user"); + }); +}); diff --git a/apps/server/tests/ru-fork/mcp/mcpCore.test.ts b/apps/server/tests/ru-fork/mcp/mcpCore.test.ts new file mode 100644 index 00000000000..c05d722cfea --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/mcpCore.test.ts @@ -0,0 +1,327 @@ +// ru-fork: unit tests for the pure @ru-fork/mcp-core logic — template + vars +// resolution, the cache key, dedup hash, tool-policy intersection, the overlay +// fingerprint, and the incomplete-gating check. These encode the vars/template +// config model (mcp-vars-redesign.md). + +import type { McpServerConfig, McpServerVar, McpTool, McpToolPolicy, McpVarValue } from "@t3tools/contracts"; +import { + configCacheKey, + dedupHash, + effectiveAllowedTools, + missingRequiredVars, + overlayFingerprint, + paramsFromInputSchema, + resolveConfig, + resolveVarValues, + type ResolveContext, + type ResolvedServerConfig, +} from "@ru-fork/mcp-core"; +import { describe, expect, it } from "vitest"; + +// A Confluence-style stdio server: a secret token (env), a per-project space id +// referenced in args, and `${PROJECT_CWD}` in args. +const stdioConfig: McpServerConfig = { + transport: "stdio", + command: "uvx", + args: ["mcp-atlassian", "--root", "${PROJECT_CWD}", "--space", "${SPACE_ID}"], +}; + +const vars: ReadonlyArray = [ + { name: "CONFLUENCE_TOKEN", secret: true, perProject: false, required: false, value: { secretRef: "tok" }, origin: "user" }, + { name: "SPACE_ID", secret: false, perProject: true, required: true, value: null, origin: "user" }, +]; + +const secretValues = { tok: "ATATT-secret" }; + +const context = (projectCwd: string): ResolveContext => ({ projectCwd, secretValues }); + +const resolve = (input: { + varValues?: Record; + projectCwd?: string; + timeoutMs?: number; +}): ResolvedServerConfig => + resolveConfig({ + config: stdioConfig, + vars, + varValues: input.varValues ?? { SPACE_ID: "ENG" }, + extraArgs: [], + extraHeaders: {}, + timeoutMs: input.timeoutMs, + context: context(input.projectCwd ?? "/work/proj-a"), + }); + +describe("resolveConfig — template + vars", () => { + it("materializes secrets into env and substitutes vars + ${PROJECT_CWD} in args", () => { + const resolved = resolve({}); + expect(resolved.transport).toBe("stdio"); + expect(resolved.cwd).toBe("/work/proj-a"); + // every var is exported as an env var (secret materialized, plain as-is) + expect(resolved.env).toEqual({ CONFLUENCE_TOKEN: "ATATT-secret", SPACE_ID: "ENG" }); + // ${PROJECT_CWD} → project dir; ${SPACE_ID} → the per-project value + expect(resolved.args).toEqual(["mcp-atlassian", "--root", "/work/proj-a", "--space", "ENG"]); + }); + + it("carries the timeout through when provided", () => { + expect(resolve({ timeoutMs: 12_000 }).timeoutMs).toBe(12_000); + expect(resolve({}).timeoutMs).toBeUndefined(); + }); + + it("a per-project hole with no value resolves to an empty string", () => { + const resolved = resolve({ varValues: {} }); + expect(resolved.env?.SPACE_ID).toBe(""); + expect(resolved.args).toContain("--space"); + }); +}); + +describe("resolveConfig — http", () => { + const httpConfig: McpServerConfig = { + transport: "http", + httpUrl: "https://${HOST}/mcp", + headers: { Authorization: "Bearer ${TOKEN}", "X-Space": "${SPACE_ID}" }, + }; + const httpVars: ReadonlyArray = [ + { name: "HOST", secret: false, perProject: false, required: true, value: "api.example.com", origin: "user" }, + { name: "TOKEN", secret: true, perProject: false, required: true, value: { secretRef: "tok" }, origin: "user" }, + { name: "SPACE_ID", secret: false, perProject: true, required: false, value: null, origin: "user" }, + ]; + const resolveHttp = (input: { + varValues?: Record; + extraHeaders?: Record; + }): ResolvedServerConfig => + resolveConfig({ + config: httpConfig, + vars: httpVars, + varValues: input.varValues ?? { SPACE_ID: "ENG" }, + extraArgs: [], + extraHeaders: input.extraHeaders ?? {}, + timeoutMs: undefined, + context: { projectCwd: "/work", secretValues: { tok: "ATATT" } }, + }); + + it("expands ${VAR} in httpUrl and headers, materializing the secret", () => { + const resolved = resolveHttp({}); + if (resolved.transport !== "http") throw new Error("expected http transport"); + expect(resolved.httpUrl).toBe("https://api.example.com/mcp"); + expect(resolved.headers).toEqual({ Authorization: "Bearer ATATT", "X-Space": "ENG" }); + }); + + it("merges extraHeaders OVER config headers (and expands them too)", () => { + const resolved = resolveHttp({ extraHeaders: { "X-Space": "${SPACE_ID}-x", "X-New": "1" } }); + if (resolved.transport !== "http") throw new Error("expected http transport"); + expect(resolved.headers["X-Space"]).toBe("ENG-x"); // extra wins + expect(resolved.headers["X-New"]).toBe("1"); // extra adds + expect(resolved.headers.Authorization).toBe("Bearer ATATT"); // base kept + }); + + it("http resolve carries the timeout and has no env/cwd", () => { + const resolved = resolveConfig({ + config: httpConfig, + vars: httpVars, + varValues: { SPACE_ID: "ENG" }, + extraArgs: [], + extraHeaders: {}, + timeoutMs: 9000, + context: { projectCwd: "/work", secretValues: { tok: "ATATT" } }, + }); + if (resolved.transport !== "http") throw new Error("expected http transport"); + expect(resolved.timeoutMs).toBe(9000); + expect("env" in resolved).toBe(false); + }); +}); + +describe("substitution rules", () => { + const run = (args: ReadonlyArray, lookup: Record) => + resolveConfig({ + config: { transport: "stdio", command: "x", args }, + vars: Object.keys(lookup).map((name) => ({ + name, + secret: false, + perProject: false, + required: false, + value: null, + origin: "user" as const, + })), + varValues: lookup, + extraArgs: [], + extraHeaders: {}, + timeoutMs: undefined, + context: context("/cwd"), + }).args; + + it("only braced ${NAME} is a placeholder — bare $NAME stays literal", () => { + expect(run(["${V}", "$V", "a$Vb"], { V: "x" })).toEqual(["x", "$V", "a$Vb"]); + }); + + it("$$ escapes a literal $", () => { + expect(run(["price$$5", "$${V}"], { V: "x" })).toEqual(["price$5", "${V}"]); + }); + + it("undeclared ${X} resolves to empty", () => { + expect(run(["a${MISSING}b"], {})).toEqual(["ab"]); + }); + + it("secret values are opaque — a $ / ${} inside a secret is inserted verbatim", () => { + const resolved = resolveConfig({ + config: { transport: "stdio", command: "x", args: ["--t", "${TOKEN}"] }, + vars: [ + { name: "TOKEN", secret: true, perProject: false, required: false, value: { secretRef: "r" }, origin: "user" as const }, + ], + varValues: {}, + extraArgs: [], + extraHeaders: {}, + timeoutMs: undefined, + context: { projectCwd: "/cwd", secretValues: { r: "pa$$w${HOME}rd" } }, + }); + expect(resolved.args?.[1]).toBe("pa$$w${HOME}rd"); + }); +}); + +describe("resolveVarValues", () => { + it("binding value overrides the catalog default; plain expands ${PROJECT_CWD}", () => { + const resolved = resolveVarValues( + [ + { name: "ROOT", secret: false, perProject: true, required: false, value: "${PROJECT_CWD}", origin: "user" }, + { name: "TOKEN", secret: true, perProject: false, required: false, value: { secretRef: "tok" }, origin: "user" }, + ], + { ROOT: "${PROJECT_CWD}/sub" }, + context("/work/p"), + ); + expect(resolved).toEqual({ ROOT: "/work/p/sub", TOKEN: "ATATT-secret" }); + }); +}); + +describe("configCacheKey", () => { + it("is stable for the same config + vars + values", () => { + expect(configCacheKey(stdioConfig, vars, { SPACE_ID: "ENG" }, [], {})).toBe( + configCacheKey(stdioConfig, vars, { SPACE_ID: "ENG" }, [], {}), + ); + }); + + it("differs when a per-project value differs (separate cache rows)", () => { + expect(configCacheKey(stdioConfig, vars, { SPACE_ID: "ENG" }, [], {})).not.toBe( + configCacheKey(stdioConfig, vars, { SPACE_ID: "OPS" }, [], {}), + ); + }); + + it("is the same across projects on the catalog default (no per-project values)", () => { + expect(configCacheKey(stdioConfig, vars, {}, [], {})).toBe( + configCacheKey(stdioConfig, vars, {}, [], {}), + ); + }); + + it("differs when extraArgs differ (a template's extra args reset the cache → status)", () => { + expect(configCacheKey(stdioConfig, vars, {}, [], {})).not.toBe( + configCacheKey(stdioConfig, vars, {}, ["--read-only"], {}), + ); + }); + + it("differs when extraHeaders differ (an http template's extra headers reset the cache → status)", () => { + expect(configCacheKey(stdioConfig, vars, {}, [], {})).not.toBe( + configCacheKey(stdioConfig, vars, {}, [], { Authorization: "Bearer x" }), + ); + }); +}); + +describe("dedupHash", () => { + it("differs by resolved cwd, collapses when cwd + values match", () => { + expect(dedupHash(resolve({ projectCwd: "/a" }))).not.toBe(dedupHash(resolve({ projectCwd: "/b" }))); + expect(dedupHash(resolve({ projectCwd: "/x" }))).toBe(dedupHash(resolve({ projectCwd: "/x" }))); + }); +}); + +describe("missingRequiredVars", () => { + it("flags a required per-project var with no value or default", () => { + expect(missingRequiredVars(vars, {})).toEqual(["SPACE_ID"]); + expect(missingRequiredVars(vars, { SPACE_ID: "ENG" })).toEqual([]); + }); + + it("a required var with a catalog default is satisfied", () => { + const withDefault: ReadonlyArray = [ + { name: "X", secret: false, perProject: true, required: true, value: "def", origin: "user" }, + ]; + expect(missingRequiredVars(withDefault, {})).toEqual([]); + }); + + it("flags a CATALOG-level required var with no value (widened — not only per-project)", () => { + const catalogRequired: ReadonlyArray = [ + { name: "TOKEN", secret: true, perProject: false, required: true, value: null, origin: "shipped" }, + ]; + expect(missingRequiredVars(catalogRequired, {})).toEqual(["TOKEN"]); + // A catalog default satisfies it without any per-project value. + const withCatalogValue: ReadonlyArray = [ + { name: "TOKEN", secret: false, perProject: false, required: true, value: "abc", origin: "shipped" }, + ]; + expect(missingRequiredVars(withCatalogValue, {})).toEqual([]); + }); +}); + +describe("effectiveAllowedTools", () => { + const tools: ReadonlyArray = [ + { name: "read_file", description: "" }, + { name: "write_file", description: "" }, + { name: "list_dir", description: "" }, + ]; + + it("allow-by-default returns all except the exceptions", () => { + const policy: McpToolPolicy = { defaultDecision: "allow", exceptions: ["write_file"] }; + expect(effectiveAllowedTools(policy, tools)).toEqual(["read_file", "list_dir"]); + }); + + it("deny-by-default returns only the discovered exceptions", () => { + const policy: McpToolPolicy = { defaultDecision: "deny", exceptions: ["read_file", "nope"] }; + expect(effectiveAllowedTools(policy, tools)).toEqual(["read_file"]); + }); +}); + +describe("overlayFingerprint", () => { + const resolvedA = resolve({}); + const allowAll: McpToolPolicy = { defaultDecision: "allow", exceptions: [] }; + + it("is independent of entry order", () => { + const a = [ + { serverName: "fs", resolved: resolvedA, toolPolicy: allowAll }, + { serverName: "c7", resolved: resolve({ projectCwd: "/other" }), toolPolicy: allowAll }, + ]; + expect(overlayFingerprint(a)).toBe(overlayFingerprint(a.toReversed())); + }); + + it("flips when a tool policy changes", () => { + const before = [{ serverName: "fs", resolved: resolvedA, toolPolicy: allowAll }]; + const after = [ + { serverName: "fs", resolved: resolvedA, toolPolicy: { defaultDecision: "deny", exceptions: ["x"] } }, + ]; + expect(overlayFingerprint(before)).not.toBe(overlayFingerprint(after)); + }); +}); + +describe("paramsFromInputSchema", () => { + it("maps properties → name/type/required/description and resolves the required set", () => { + const params = paramsFromInputSchema({ + properties: { + path: { type: "string", description: "File path" }, + limit: { type: "number" }, + }, + required: ["path"], + }); + expect(params).toEqual([ + { name: "path", type: "string", required: true, description: "File path" }, + { name: "limit", type: "number", required: false, description: "" }, + ]); + }); + + it("labels an array as `[]`, falling back to `array` when items are untyped", () => { + const params = paramsFromInputSchema({ + properties: { + tags: { type: "array", items: { type: "string" } }, + mixed: { type: "array" }, + }, + }); + expect(params[0]?.type).toBe("string[]"); + expect(params[1]?.type).toBe("array"); + }); + + it("a property with no `type` is labelled `any`; no properties ⇒ no params", () => { + expect(paramsFromInputSchema({ properties: { x: { description: "?" } } })[0]?.type).toBe("any"); + expect(paramsFromInputSchema({})).toEqual([]); + }); +}); diff --git a/apps/server/tests/ru-fork/mcp/overlayApply.test.ts b/apps/server/tests/ru-fork/mcp/overlayApply.test.ts new file mode 100644 index 00000000000..964f9899f56 --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/overlayApply.test.ts @@ -0,0 +1,342 @@ +// ru-fork: behavior tests for the qwen settings overlay (McpOverlay.writeOverlay / removeOverlay) — +// the file qwen actually reads. Drives the REAL engine to populate catalog + bindings, writes the +// overlay to a temp dir, reads it back, and asserts the JSON qwen will see: which servers are +// included (enabled / not-disabled / complete), the stdio/http entry shape, secret materialization +// into env, tool-policy include/excludeTools, folderTrust off, and the empty-overlay / remove paths. + +import { + CommandId, + McpServerId, + ProjectId, + type McpServerVarDraft, + type McpToolPolicy, + type OrchestrationCommand, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { OrchestrationCommandReceiptRepositoryLive } from "../../../src/persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../../../src/persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../../../src/persistence/Layers/Sqlite.ts"; +import { RepositoryIdentityResolverLive } from "../../../src/project/Layers/RepositoryIdentityResolver.ts"; +import { OrchestrationEngineLive } from "../../../src/orchestration/Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "../../../src/orchestration/Layers/ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "../../../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; +import { OrchestrationEngineService } from "../../../src/orchestration/Services/OrchestrationEngine.ts"; +import { ServerConfig } from "../../../src/config.ts"; +import { McpOverlay } from "../../../src/ru-fork/mcp/McpOverlay.ts"; +import { McpOverlayLive } from "../../../src/ru-fork/mcp/McpOverlay.ts"; + +const ISO = "2026-01-01T00:00:00.000Z"; + +function projectCreate(projectId: string, workspaceRoot: string, commandId: string): OrchestrationCommand { + return { + type: "project.create", + commandId: CommandId.make(commandId), + projectId: ProjectId.make(projectId), + title: "P", + workspaceRoot, + createdAt: ISO, + }; +} + +function addStdio( + serverId: string, + name: string, + args: ReadonlyArray, + vars: ReadonlyArray, + commandId: string, +): OrchestrationCommand { + return { + type: "mcp.server-add", + commandId: CommandId.make(commandId), + serverId: McpServerId.make(serverId), + draft: { name, config: { transport: "stdio", command: "uvx", args: [...args] }, vars, timeoutMs: null }, + createdAt: ISO, + }; +} + +function addHttp( + serverId: string, + name: string, + httpUrl: string, + headers: Record, + vars: ReadonlyArray, + commandId: string, +): OrchestrationCommand { + return { + type: "mcp.server-add", + commandId: CommandId.make(commandId), + serverId: McpServerId.make(serverId), + draft: { name, config: { transport: "http", httpUrl, headers }, vars, timeoutMs: null }, + createdAt: ISO, + }; +} + +function setServerEnabled(serverId: string, enabled: boolean, commandId: string): OrchestrationCommand { + return { + type: "mcp.server-update", + commandId: CommandId.make(commandId), + serverId: McpServerId.make(serverId), + patch: { enabled }, + }; +} + +function bind( + projectId: string, + serverId: string, + patch: { enabled?: boolean; varValues?: Record; toolPolicy?: McpToolPolicy }, + commandId: string, +): OrchestrationCommand { + return { + type: "mcp.binding-set", + commandId: CommandId.make(commandId), + projectId: ProjectId.make(projectId), + serverId: McpServerId.make(serverId), + patch: { enabled: patch.enabled ?? true, ...(patch.varValues ? { varValues: patch.varValues } : {}), ...(patch.toolPolicy ? { toolPolicy: patch.toolPolicy } : {}) }, + }; +} + +interface OverlayJson { + readonly security: { readonly folderTrust: { readonly enabled: boolean } }; + readonly mcpServers: Record>; +} + +function makeSystem() { + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-overlay-test-" }); + const base = Layer.mergeAll( + OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(OrchestrationProjectionPipelineLive), + ), + OrchestrationProjectionPipelineLive, + OrchestrationProjectionSnapshotQueryLive, + ).pipe( + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), + ); + const runtime = ManagedRuntime.make(McpOverlayLive.pipe(Layer.provideMerge(base))); + return { + dispatch: (command: OrchestrationCommand) => + runtime.runPromise( + Effect.flatMap(Effect.service(OrchestrationEngineService), (engine) => engine.dispatch(command)), + ), + writeOverlay: (projectId: string) => + runtime.runPromise( + Effect.flatMap(Effect.service(McpOverlay), (overlay) => + overlay.writeOverlay(ProjectId.make(projectId)), + ), + ), + removeOverlay: (projectId: string) => + runtime.runPromise( + Effect.flatMap(Effect.service(McpOverlay), (overlay) => + overlay.removeOverlay(ProjectId.make(projectId)), + ), + ), + deleteOverlayFile: (overlayPath: string) => + runtime.runPromise( + Effect.flatMap(Effect.service(McpOverlay), (overlay) => + overlay.deleteOverlayFile(overlayPath), + ), + ), + removeAllOverlays: () => + runtime.runPromise( + Effect.flatMap(Effect.service(McpOverlay), (overlay) => overlay.removeAllOverlays), + ), + readOverlay: (path: string): Promise => + runtime.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const text = yield* fs.readFileString(path); + return JSON.parse(text) as OverlayJson; + }), + ), + fileExists: (path: string) => + runtime.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.exists(path); + }), + ), + dispose: () => runtime.dispose(), + }; +} + +describe("McpOverlay.writeOverlay — the qwen settings file", () => { + let system: ReturnType; + beforeEach(() => { + system = makeSystem(); + }); + afterEach(async () => { + await system.dispose(); + }); + + it("writes an enabled stdio binding with resolved command/args/env/cwd/timeout + folderTrust off", async () => { + await system.dispatch(projectCreate("p1", "/work/p1", "pc:1")); + await system.dispatch( + addStdio("s-fs", "fs", ["--root", "${PROJECT_CWD}"], [], "add:fs"), + ); + await system.dispatch(bind("p1", "s-fs", {}, "bind:fs")); + + const result = await system.writeOverlay("p1"); + expect(result.allowedServerNames).toEqual(["s-fs"]); + const json = await system.readOverlay(result.overlayPath); + expect(json.security.folderTrust.enabled).toBe(false); + const entry = json.mcpServers["s-fs"]; + expect(entry).toMatchObject({ + command: "uvx", + args: ["--root", "/work/p1"], // ${PROJECT_CWD} expanded + cwd: "/work/p1", + }); + expect(entry.timeout).toBeTypeOf("number"); + }); + + it("materializes a per-project secret value into the entry's env", async () => { + await system.dispatch(projectCreate("p2", "/work/p2", "pc:2")); + await system.dispatch( + addStdio( + "s-sec", + "sec", + [], + [{ name: "TOKEN", secret: true, perProject: true, required: true, value: null }], + "add:sec", + ), + ); + await system.dispatch(bind("p2", "s-sec", { varValues: { TOKEN: "s3cr3t" } }, "bind:sec")); + + const result = await system.writeOverlay("p2"); + const env = (await system.readOverlay(result.overlayPath)).mcpServers["s-sec"].env as Record; + expect(env.TOKEN).toBe("s3cr3t"); // ref → plaintext, isolated in this entry + }); + + it("excludes a DISABLED binding, a CATALOG-disabled server, and an INCOMPLETE binding", async () => { + await system.dispatch(projectCreate("p3", "/work/p3", "pc:3")); + // ru-fork #2: distinct configs (identity is the config) — each server uses different args. + // (a) disabled binding + await system.dispatch(addStdio("s-off", "off", ["off"], [], "add:off")); + await system.dispatch(bind("p3", "s-off", { enabled: false }, "bind:off")); + // (b) catalog-disabled server + await system.dispatch(addStdio("s-cat", "cat", ["cat"], [], "add:cat")); + await system.dispatch(bind("p3", "s-cat", {}, "bind:cat")); + await system.dispatch(setServerEnabled("s-cat", false, "dis:cat")); + // (c) incomplete: required per-project var left unfilled + await system.dispatch( + addStdio("s-inc", "inc", ["inc"], [{ name: "NEED", secret: false, perProject: true, required: true, value: null }], "add:inc"), + ); + await system.dispatch(bind("p3", "s-inc", {}, "bind:inc")); + // (d) a good one, to prove the overlay is otherwise non-empty + await system.dispatch(addStdio("s-ok", "ok", ["ok"], [], "add:ok")); + await system.dispatch(bind("p3", "s-ok", {}, "bind:ok")); + + const result = await system.writeOverlay("p3"); + expect(result.allowedServerNames).toEqual(["s-ok"]); + const json = await system.readOverlay(result.overlayPath); + expect(Object.keys(json.mcpServers)).toEqual(["s-ok"]); + }); + + it("writes an http binding as httpUrl/headers with ${VAR} expanded", async () => { + await system.dispatch(projectCreate("p4", "/work/p4", "pc:4")); + await system.dispatch( + addHttp( + "s-http", + "http", + "https://${HOST}/mcp", + { Authorization: "Bearer ${TOKEN}" }, + [ + { name: "HOST", secret: false, perProject: false, required: true, value: "api.example.com" }, + { name: "TOKEN", secret: true, perProject: true, required: true, value: null }, + ], + "add:http", + ), + ); + await system.dispatch(bind("p4", "s-http", { varValues: { TOKEN: "abc" } }, "bind:http")); + + const result = await system.writeOverlay("p4"); + const entry = (await system.readOverlay(result.overlayPath)).mcpServers["s-http"]; + expect(entry.httpUrl).toBe("https://api.example.com/mcp"); + expect(entry.headers).toEqual({ Authorization: "Bearer abc" }); + expect("env" in entry).toBe(false); + }); + + it("derives include/excludeTools from the tool POLICY (not discovered tools)", async () => { + await system.dispatch(projectCreate("p5", "/work/p5", "pc:5")); + // ru-fork #2: distinct configs (identity is the config) — args differ so both servers can be added. + await system.dispatch(addStdio("s-deny", "deny", ["deny"], [], "add:deny")); + await system.dispatch( + bind("p5", "s-deny", { toolPolicy: { defaultDecision: "deny", exceptions: ["read"] } }, "bind:deny"), + ); + await system.dispatch(addStdio("s-allow", "allow", ["allow"], [], "add:allow")); + await system.dispatch( + bind("p5", "s-allow", { toolPolicy: { defaultDecision: "allow", exceptions: ["danger"] } }, "bind:allow"), + ); + + const json = await system.readOverlay((await system.writeOverlay("p5")).overlayPath); + expect(json.mcpServers["s-deny"].includeTools).toEqual(["read"]); + expect(json.mcpServers["s-deny"].excludeTools).toBeUndefined(); + expect(json.mcpServers["s-allow"].excludeTools).toEqual(["danger"]); + expect(json.mcpServers["s-allow"].includeTools).toBeUndefined(); + // ru-fork #6: every server entry carries qwen's `trust` flag (default true). + expect(json.mcpServers["s-deny"].trust).toBe(true); + }); + + it("a project with no shell (no cwd) writes an empty mcpServers overlay", async () => { + // No project.create for 'ghost' ⇒ getProjectShellById is None ⇒ projectCwd null ⇒ empty overlay. + const result = await system.writeOverlay("ghost"); + expect(result.allowedServerNames).toEqual([]); + const json = await system.readOverlay(result.overlayPath); + expect(json.mcpServers).toEqual({}); + expect(json.security.folderTrust.enabled).toBe(false); + }); + + it("removeOverlay deletes the project's overlay file", async () => { + await system.dispatch(projectCreate("p6", "/work/p6", "pc:6")); + await system.dispatch(addStdio("s-r", "r", [], [], "add:r")); + await system.dispatch(bind("p6", "s-r", {}, "bind:r")); + const result = await system.writeOverlay("p6"); + expect(await system.fileExists(result.overlayPath)).toBe(true); + + await system.removeOverlay("p6"); + expect(await system.fileExists(result.overlayPath)).toBe(false); + }); + + // ── ru-fork #4: ephemeral-overlay leaf ops (used by the spawn finalizer + sweeps) ── + + it("G12 — deleteOverlayFile removes one overlay file and is a no-op when absent", async () => { + await system.dispatch(projectCreate("p7", "/work/p7", "pc:7")); + await system.dispatch(addStdio("s-d", "d", [], [], "add:d")); + await system.dispatch(bind("p7", "s-d", {}, "bind:d")); + const result = await system.writeOverlay("p7"); + expect(await system.fileExists(result.overlayPath)).toBe(true); + + await system.deleteOverlayFile(result.overlayPath); + expect(await system.fileExists(result.overlayPath)).toBe(false); + // Must NOT throw on a missing file (it runs inside Effect.ensuring). + await system.deleteOverlayFile(result.overlayPath); + }); + + it("G10 — removeAllOverlays sweeps EVERY project's overlay file", async () => { + await system.dispatch(projectCreate("pa", "/work/pa", "pc:a")); + await system.dispatch(addStdio("s-a", "a", ["a"], [], "add:a")); + await system.dispatch(bind("pa", "s-a", {}, "bind:a")); + await system.dispatch(projectCreate("pb", "/work/pb", "pc:b")); + await system.dispatch(addStdio("s-b", "b", ["b"], [], "add:b")); + await system.dispatch(bind("pb", "s-b", {}, "bind:b")); + + const a = await system.writeOverlay("pa"); + const b = await system.writeOverlay("pb"); + expect(await system.fileExists(a.overlayPath)).toBe(true); + expect(await system.fileExists(b.overlayPath)).toBe(true); + + await system.removeAllOverlays(); + expect(await system.fileExists(a.overlayPath)).toBe(false); + expect(await system.fileExists(b.overlayPath)).toBe(false); + }); +}); diff --git a/apps/server/tests/ru-fork/mcp/overlayFingerprint.test.ts b/apps/server/tests/ru-fork/mcp/overlayFingerprint.test.ts new file mode 100644 index 00000000000..24af7911aaf --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/overlayFingerprint.test.ts @@ -0,0 +1,179 @@ +// ru-fork: the RESTART-DECISION half of the qwen integration. ProviderCommandReactor re-spawns a live +// session when, at turn-start, the project's overlay FINGERPRINT differs from what the session spawned +// with (qwen only reads the overlay at spawn). So "does editing X restart the CLI?" reduces to "does +// editing X change writeOverlay's fingerprint?". This file pins that down on the REAL McpOverlay: +// - changing a required var's VALUE (referenced in the config) → fingerprint CHANGES → restart; +// - changing ONLY the catalog description → fingerprint UNCHANGED → no restart; +// - changing the tool policy → fingerprint CHANGES → restart; +// - per-project ISOLATION: a per-project var change in project A moves A's fingerprint but NOT B's, +// so a settings change in one project never restarts another project's thread. + +import { + CommandId, + McpServerId, + ProjectId, + type McpToolPolicy, + type OrchestrationCommand, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { OrchestrationCommandReceiptRepositoryLive } from "../../../src/persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../../../src/persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../../../src/persistence/Layers/Sqlite.ts"; +import { RepositoryIdentityResolverLive } from "../../../src/project/Layers/RepositoryIdentityResolver.ts"; +import { OrchestrationEngineLive } from "../../../src/orchestration/Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "../../../src/orchestration/Layers/ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "../../../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; +import { OrchestrationEngineService } from "../../../src/orchestration/Services/OrchestrationEngine.ts"; +import { ServerConfig } from "../../../src/config.ts"; +import { McpOverlay, McpOverlayLive } from "../../../src/ru-fork/mcp/McpOverlay.ts"; + +const ISO = "2026-01-01T00:00:00.000Z"; + +// A server whose required, per-project var is referenced in the spawned config (so its value is part +// of the resolved config that feeds the fingerprint). +const addServerWithVar = (serverId: string): OrchestrationCommand => ({ + type: "mcp.server-add", + commandId: CommandId.make(`add-${serverId}`), + serverId: McpServerId.make(serverId), + draft: { + name: serverId, + config: { transport: "stdio", command: "uvx", args: [serverId, "--token", "${TOKEN}"] }, + vars: [{ name: "TOKEN", secret: false, perProject: true, required: true, value: null }], + timeoutMs: null, + }, + createdAt: ISO, +}); + +const createProject = (projectId: string): OrchestrationCommand => ({ + type: "project.create", + commandId: CommandId.make(`proj-${projectId}`), + projectId: ProjectId.make(projectId), + title: "P", + workspaceRoot: `/tmp/${projectId}`, + createdAt: ISO, +}); + +const bindWithToken = (projectId: string, serverId: string, token: string, commandId: string): OrchestrationCommand => ({ + type: "mcp.binding-set", + commandId: CommandId.make(commandId), + projectId: ProjectId.make(projectId), + serverId: McpServerId.make(serverId), + patch: { enabled: true, varValues: { TOKEN: token } }, +}); + +const setToolPolicy = (projectId: string, serverId: string, toolPolicy: McpToolPolicy, commandId: string): OrchestrationCommand => ({ + type: "mcp.binding-set", + commandId: CommandId.make(commandId), + projectId: ProjectId.make(projectId), + serverId: McpServerId.make(serverId), + patch: { enabled: true, varValues: { TOKEN: "alpha" }, toolPolicy }, +}); + +const setDescription = (serverId: string, description: string, commandId: string): OrchestrationCommand => ({ + type: "mcp.server-update", + commandId: CommandId.make(commandId), + serverId: McpServerId.make(serverId), + patch: { description }, +}); + +function makeSystem() { + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-fp-test-" }); + const base = Layer.mergeAll( + OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(OrchestrationProjectionPipelineLive), + ), + OrchestrationProjectionPipelineLive, + OrchestrationProjectionSnapshotQueryLive, + ).pipe( + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), + ); + const runtime = ManagedRuntime.make(McpOverlayLive.pipe(Layer.provideMerge(base))); + return { + dispatch: (command: OrchestrationCommand) => + runtime.runPromise(Effect.flatMap(Effect.service(OrchestrationEngineService), (e) => e.dispatch(command))), + fingerprint: (projectId: string): Promise => + runtime.runPromise( + Effect.flatMap(Effect.service(McpOverlay), (overlay) => + overlay.writeOverlay(ProjectId.make(projectId)).pipe(Effect.map((result) => result.fingerprint)), + ), + ), + dispose: () => runtime.dispose(), + }; +} + +describe("overlay fingerprint = the restart trigger (what change forces a respawn)", () => { + let system: ReturnType; + beforeEach(() => { + system = makeSystem(); + }); + afterEach(async () => { + await system.dispose(); + }); + + it("changing a required var's VALUE changes the fingerprint (→ CLI restarts next turn)", async () => { + await system.dispatch(createProject("p1")); + await system.dispatch(addServerWithVar("srv")); + await system.dispatch(bindWithToken("p1", "srv", "alpha", "bind-1")); + const before = await system.fingerprint("p1"); + + await system.dispatch(bindWithToken("p1", "srv", "beta", "bind-2")); // same var, new value + const after = await system.fingerprint("p1"); + + expect(after).not.toBe(before); + }); + + it("changing ONLY the catalog description does NOT change the fingerprint (→ no restart)", async () => { + await system.dispatch(createProject("p1")); + await system.dispatch(addServerWithVar("srv")); + await system.dispatch(bindWithToken("p1", "srv", "alpha", "bind-1")); + const before = await system.fingerprint("p1"); + + await system.dispatch(setDescription("srv", "A brand new description that qwen never sees", "desc-1")); + const after = await system.fingerprint("p1"); + + expect(after).toBe(before); // description is catalog metadata, not part of the spawned overlay + }); + + it("changing the tool policy changes the fingerprint (→ restart; allow-list is subsumed)", async () => { + await system.dispatch(createProject("p1")); + await system.dispatch(addServerWithVar("srv")); + await system.dispatch(bindWithToken("p1", "srv", "alpha", "bind-1")); + const before = await system.fingerprint("p1"); + + await system.dispatch(setToolPolicy("p1", "srv", { defaultDecision: "deny", exceptions: ["only_this"] }, "pol-1")); + const after = await system.fingerprint("p1"); + + expect(after).not.toBe(before); + }); + + it("per-project isolation: a var change in project A leaves project B's fingerprint untouched", async () => { + await system.dispatch(createProject("pa")); + await system.dispatch(createProject("pb")); + await system.dispatch(addServerWithVar("srv")); // ONE catalog server, bound per project + await system.dispatch(bindWithToken("pa", "srv", "a-value", "bind-a")); + await system.dispatch(bindWithToken("pb", "srv", "b-value", "bind-b")); + + const aBefore = await system.fingerprint("pa"); + const bBefore = await system.fingerprint("pb"); + + // Change ONLY project A's per-project var value. + await system.dispatch(bindWithToken("pa", "srv", "a-value-2", "bind-a2")); + + const aAfter = await system.fingerprint("pa"); + const bAfter = await system.fingerprint("pb"); + + expect(aAfter).not.toBe(aBefore); // A's thread will restart + expect(bAfter).toBe(bBefore); // B's thread will NOT — its overlay is unchanged + }); +}); diff --git a/apps/server/tests/ru-fork/mcp/overlayRemoveLogging.test.ts b/apps/server/tests/ru-fork/mcp/overlayRemoveLogging.test.ts new file mode 100644 index 00000000000..124db471173 --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/overlayRemoveLogging.test.ts @@ -0,0 +1,129 @@ +// ru-fork: McpOverlay.removeOverlay error-path. It runs `fileSystem.remove(overlayDir, {recursive})` +// and currently `.pipe(Effect.ignore)`s the result — so a failed removal of a project's overlay dir +// (which holds RESOLVED SECRET values, mode 0600) vanishes with zero signal. The intended behavior is +// best-effort but OBSERVABLE: don't fail the project.deleted chain, but logError the failure. We inject +// a guaranteed `remove` failure via a FileSystem override, and a control probe proves the override is +// actually wired (so the "no log" assertion can't be red for the wrong reason). RED until the swallow +// is replaced with a logged catch. + +import { ProjectId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as PlatformError from "effect/PlatformError"; +import * as References from "effect/References"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { OrchestrationCommandReceiptRepositoryLive } from "../../../src/persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../../../src/persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../../../src/persistence/Layers/Sqlite.ts"; +import { RepositoryIdentityResolverLive } from "../../../src/project/Layers/RepositoryIdentityResolver.ts"; +import { OrchestrationEngineLive } from "../../../src/orchestration/Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "../../../src/orchestration/Layers/ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "../../../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; +import { ServerConfig } from "../../../src/config.ts"; +import { McpOverlay, McpOverlayLive } from "../../../src/ru-fork/mcp/McpOverlay.ts"; + +interface CapturedLog { + readonly level: string; + readonly message: string; +} + +// A FileSystem whose `remove` always fails (everything else is the real implementation). +const RemoveFailureFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return { + ...fileSystem, + remove: (path, options) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "remove", + pathOrDescriptor: String(path), + description: `Injected remove failure.${options ? " recursive" : ""}`, + }), + ), + } satisfies FileSystem.FileSystem; + }), +).pipe(Layer.provide(NodeServices.layer)); + +function makeSystem() { + const captured: Array = []; + const captureLogger = Logger.make(({ logLevel, message }) => { + captured.push({ level: String(logLevel), message: String(message) }); + }); + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-rm-test-" }); + const base = Layer.mergeAll( + OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(OrchestrationProjectionPipelineLive), + ), + OrchestrationProjectionPipelineLive, + OrchestrationProjectionSnapshotQueryLive, + ).pipe( + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), + ); + const runtime = ManagedRuntime.make( + McpOverlayLive.pipe( + // RemoveFailureFileSystemLayer must be the INNER provide so McpOverlay binds ITS FileSystem to the + // failing one (a later provideMerge wouldn't win — McpOverlay would bind to base's real NodeServices FS). + Layer.provideMerge(RemoveFailureFileSystemLayer), + Layer.provideMerge(base), + Layer.provideMerge(Logger.layer([captureLogger], { mergeWithExisting: false })), + Layer.provideMerge(Layer.succeed(References.MinimumLogLevel, "Debug")), // capture logDebug + ), + ); + return { + // Control probe: confirms the failing-remove override is actually in effect for this runtime. + removeFails: () => + runtime.runPromise( + Effect.flatMap(Effect.service(FileSystem.FileSystem), (fileSystem) => + Effect.exit(fileSystem.remove("/tmp/t3-control-probe", { recursive: true })), + ), + ), + removeOverlay: (projectId: string) => + runtime.runPromise( + Effect.flatMap(Effect.service(McpOverlay), (overlay) => + overlay.removeOverlay(ProjectId.make(projectId)), + ), + ), + captured, + dispose: () => runtime.dispose(), + }; +} + +describe("McpOverlay.removeOverlay error path", () => { + let system: ReturnType; + beforeEach(() => { + system = makeSystem(); + }); + afterEach(async () => { + await system.dispose(); + }); + + it("does not fail the caller, but LOGS when removing a project's overlay fails", async () => { + // Control: the override is wired ⇒ remove genuinely fails for this runtime. + expect(Exit.isFailure(await system.removeFails())).toBe(true); + + // removeOverlay must NOT fail the project.deleted chain even though remove fails. + await expect(system.removeOverlay("any-project")).resolves.toBeUndefined(); + + // EXPECTED: best-effort but observable at debug. Current `Effect.ignore` emits nothing ⇒ RED until + // the swallow becomes a logDebug. + const overlayLog = system.captured.find((entry) => entry.message.toLowerCase().includes("overlay")); + expect(overlayLog).toBeDefined(); + expect(overlayLog?.level.toUpperCase()).toContain("DEBUG"); + }); +}); diff --git a/apps/server/tests/ru-fork/mcp/probe.test.ts b/apps/server/tests/ru-fork/mcp/probe.test.ts new file mode 100644 index 00000000000..f505666342a --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/probe.test.ts @@ -0,0 +1,58 @@ +// ru-fork: behavior tests for the real probe (probeOnce) — the connect→listTools→close lifecycle. +// We exercise the OFFLINE paths against real child processes (no fake MCP server needed): a command +// that cannot spawn, and a process that never speaks MCP (handshake times out). The online path needs +// a real MCP server and is covered separately by the live mcp-probe harness. + +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { type ResolvedServerConfig, probeOnce } from "@ru-fork/mcp-core"; +import { describe, expect, it } from "vitest"; + +// packages/mcp-core/test-fixtures/fakeMcpStdioServer.mjs (5 dirs up from this test file). +const FAKE_SERVER = path.resolve( + fileURLToPath(import.meta.url), + "../../../../../../packages/mcp-core/test-fixtures/fakeMcpStdioServer.mjs", +); + +const stdio = (command: string, args: ReadonlyArray): ResolvedServerConfig => ({ + transport: "stdio", + command, + args: [...args], + env: {}, + cwd: process.cwd(), +}); + +describe("probeOnce — offline paths", () => { + it("returns offline (not throwing) when the command cannot be spawned", async () => { + const result = await probeOnce(stdio("this-binary-does-not-exist-7f3a", []), 3000); + expect(result.status).toBe("offline"); + expect(result.tools).toEqual([]); + expect(typeof result.message).toBe("string"); + expect(result.message?.length).toBeGreaterThan(0); + }); + + it("returns offline after the connect timeout for a process that never speaks MCP", async () => { + // `sleep` ignores stdin and never answers the MCP handshake ⇒ the SDK connect-timeout fires. + const startedAt = Date.now(); + const result = await probeOnce(stdio("sleep", ["30"]), 700); + expect(result.status).toBe("offline"); + expect(typeof result.message).toBe("string"); + // It actually waited for the timeout (didn't fail instantly like a missing binary). + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(500); + }, 15_000); +}); + +describe("probeOnce — online path (real stdio MCP server)", () => { + it("connects, lists tools, and maps params through the real handshake", async () => { + const result = await probeOnce(stdio("node", [FAKE_SERVER]), 10_000); + expect(result.status).toBe("online"); + expect(typeof result.latencyMs).toBe("number"); + expect(result.tools.map((tool) => tool.name).toSorted()).toEqual(["echo", "ping"]); + const echo = result.tools.find((tool) => tool.name === "echo"); + expect(echo?.params?.map((param) => param.name)).toEqual(["msg"]); + expect(echo?.params?.[0]?.required).toBe(true); + const ping = result.tools.find((tool) => tool.name === "ping"); + expect(ping?.params).toBeUndefined(); // no-arg tool ⇒ params absent + }, 20_000); +}); diff --git a/apps/server/tests/ru-fork/mcp/projectionQuery.test.ts b/apps/server/tests/ru-fork/mcp/projectionQuery.test.ts new file mode 100644 index 00000000000..f485d402b8b --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/projectionQuery.test.ts @@ -0,0 +1,136 @@ +// ru-fork: behavior tests for McpProjectionQuery.getSnapshot — the client-facing catalog+bindings +// snapshot. Asserts it returns the full catalog with all bindings (projectId null) and filters +// bindings to one project when a projectId is given. + +import { + CommandId, + McpServerId, + ProjectId, + type OrchestrationCommand, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Stream from "effect/Stream"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { OrchestrationCommandReceiptRepositoryLive } from "../../../src/persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../../../src/persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../../../src/persistence/Layers/Sqlite.ts"; +import { RepositoryIdentityResolverLive } from "../../../src/project/Layers/RepositoryIdentityResolver.ts"; +import { OrchestrationEngineLive } from "../../../src/orchestration/Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "../../../src/orchestration/Layers/ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "../../../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; +import { OrchestrationEngineService } from "../../../src/orchestration/Services/OrchestrationEngine.ts"; +import { ServerConfig } from "../../../src/config.ts"; +import { McpProjectionQuery } from "../../../src/ru-fork/mcp/McpProjectionQuery.ts"; +import { McpProjectionQueryLive } from "../../../src/ru-fork/mcp/McpProjectionQuery.ts"; + +const ISO = "2026-01-01T00:00:00.000Z"; +const projectCreate = (p: string, c: string): OrchestrationCommand => ({ type: "project.create", commandId: CommandId.make(c), projectId: ProjectId.make(p), title: "P", workspaceRoot: `/w/${p}`, createdAt: ISO }); +const add = (s: string, c: string): OrchestrationCommand => ({ type: "mcp.server-add", commandId: CommandId.make(c), serverId: McpServerId.make(s), draft: { name: s, config: { transport: "stdio", command: "uvx", args: [s] }, vars: [], timeoutMs: null }, createdAt: ISO }); +const bind = (p: string, s: string, c: string): OrchestrationCommand => ({ type: "mcp.binding-set", commandId: CommandId.make(c), projectId: ProjectId.make(p), serverId: McpServerId.make(s), patch: { enabled: true } }); +const unbind = (p: string, s: string, c: string): OrchestrationCommand => ({ type: "mcp.binding-remove", commandId: CommandId.make(c), projectId: ProjectId.make(p), serverId: McpServerId.make(s) }); + +function makeSystem() { + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-pq-test-" }); + const base = Layer.mergeAll( + OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(OrchestrationProjectionPipelineLive), + ), + OrchestrationProjectionPipelineLive, + ).pipe( + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), + ); + const runtime = ManagedRuntime.make(McpProjectionQueryLive.pipe(Layer.provideMerge(base))); + return { + dispatch: (cmd: OrchestrationCommand) => + runtime.runPromise(Effect.flatMap(Effect.service(OrchestrationEngineService), (e) => e.dispatch(cmd))), + snapshot: (projectId: string | null) => + runtime.runPromise( + Effect.flatMap(Effect.service(McpProjectionQuery), (q) => + q.getSnapshot(projectId === null ? null : ProjectId.make(projectId)), + ), + ), + // Subscribe to subscriptionStream, take `count` emissions, dispatch `cmd` once the subscription is + // live, and return the collected snapshot events. Exercises the live `changeSnapshots` stream. + collectAfterDispatch: (count: number, cmd: OrchestrationCommand) => + runtime.runPromise( + Effect.gen(function* () { + const query = yield* Effect.service(McpProjectionQuery); + const engine = yield* Effect.service(OrchestrationEngineService); + const collecting = yield* Effect.forkChild( + Stream.runCollect(Stream.take(query.subscriptionStream, count)), + ); + yield* Effect.sleep("100 millis"); // let the subscription attach before the event fires + yield* engine.dispatch(cmd); + return yield* Fiber.join(collecting); // Stream.runCollect already yields an Array + }), + ), + dispose: () => runtime.dispose(), + }; +} + +describe("McpProjectionQuery.getSnapshot", () => { + let system: ReturnType; + beforeEach(() => { + system = makeSystem(); + }); + afterEach(async () => { + await system.dispose(); + }); + + it("null projectId returns the full catalog and ALL bindings", async () => { + await system.dispatch(projectCreate("pa", "pca")); + await system.dispatch(projectCreate("pb", "pcb")); + await system.dispatch(add("s1", "add:1")); + await system.dispatch(add("s2", "add:2")); + await system.dispatch(bind("pa", "s1", "bind:a1")); + await system.dispatch(bind("pb", "s2", "bind:b2")); + + const snap = await system.snapshot(null); + expect(snap.catalog.map((s) => s.id).toSorted()).toEqual(["s1", "s2"]); + expect(snap.bindings).toHaveLength(2); + }); + + it("a projectId filters bindings to that project (catalog stays full)", async () => { + await system.dispatch(projectCreate("pa", "pca")); + await system.dispatch(projectCreate("pb", "pcb")); + await system.dispatch(add("s1", "add:1")); + await system.dispatch(add("s2", "add:2")); + await system.dispatch(bind("pa", "s1", "bind:a1")); + await system.dispatch(bind("pb", "s2", "bind:b2")); + + const snap = await system.snapshot("pa"); + expect(snap.catalog).toHaveLength(2); // catalog is global + expect(snap.bindings.map((b) => b.projectId)).toEqual(["pa"]); // only pa's binding + expect(snap.bindings[0]?.serverId).toBe("s1"); + }); + + it("subscriptionStream emits a fresh snapshot when a relevant event lands", async () => { + // Element 1 is the initial snapshot (empty); element 2 is re-read by `changeSnapshots` after the add. + const events = await system.collectAfterDispatch(2, add("s-stream", "add:stream")); + expect(events).toHaveLength(2); + expect(events[0]?.type).toBe("snapshot"); + expect(events[0]?.snapshot.catalog.map((s) => s.id)).toEqual([]); // before the add + expect(events[1]?.snapshot.catalog.map((s) => s.id)).toEqual(["s-stream"]); // after the add + }, 15_000); + + it("mcp.binding-remove cascades: the binding disappears from the snapshot", async () => { + await system.dispatch(projectCreate("pr", "pcr")); + await system.dispatch(add("sr", "add:r")); + await system.dispatch(bind("pr", "sr", "bind:r")); + expect((await system.snapshot("pr")).bindings).toHaveLength(1); + + await system.dispatch(unbind("pr", "sr", "unbind:r")); + expect((await system.snapshot("pr")).bindings).toEqual([]); + }); +}); diff --git a/apps/server/tests/ru-fork/mcp/reactorEffects.test.ts b/apps/server/tests/ru-fork/mcp/reactorEffects.test.ts new file mode 100644 index 00000000000..5909748889a --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/reactorEffects.test.ts @@ -0,0 +1,216 @@ +// ru-fork: behavior tests for the reactor's extracted effects driven against the REAL engine — +// computeDesired (which servers get probed: enabled/incomplete gating + dedup-by-resolved-hash + +// refcounting), gcOrphanedSecrets (prune secret .bin files no longer referenced), and autobind +// (bind built-ins to a new project when the setting is on). These cover the runtime decisions that +// decide what qwen/the monitor actually act on. + +import { + CommandId, + McpServerId, + ProjectId, + type McpServerVarDraft, + type OrchestrationCommand, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { ServerSecretStore } from "../../../src/auth/Services/ServerSecretStore.ts"; +import { OrchestrationCommandReceiptRepositoryLive } from "../../../src/persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../../../src/persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../../../src/persistence/Layers/Sqlite.ts"; +import { McpBindingRepository } from "../../../src/persistence/Services/McpBinding.ts"; +import { RepositoryIdentityResolverLive } from "../../../src/project/Layers/RepositoryIdentityResolver.ts"; +import { OrchestrationEngineLive } from "../../../src/orchestration/Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "../../../src/orchestration/Layers/ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "../../../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; +import { OrchestrationEngineService } from "../../../src/orchestration/Services/OrchestrationEngine.ts"; +import { ServerConfig } from "../../../src/config.ts"; +import { ServerSettingsService } from "../../../src/serverSettings.ts"; +import { + builtinConfigForPlatform, + builtinHash, + builtinServerId, + builtinShippedVars, + type McpBuiltinDefinition, +} from "../../../src/ru-fork/mcp/McpBuiltins.ts"; +import { + autobindBuiltinsForProjectWith, + computeDesiredEffect, + gcOrphanedSecretsEffect, +} from "../../../src/ru-fork/mcp/McpReactor.ts"; +import { mcpVarSecretName } from "../../../src/ru-fork/mcp/McpSecretNames.ts"; + +const ISO = "2026-01-01T00:00:00.000Z"; + +const DEMO: McpBuiltinDefinition = { + builtinId: "demo", + name: "demo", + config: { default: { transport: "stdio", command: "uvx", args: ["demo"] } }, + vars: [], +}; + +function projectCreate(projectId: string, cmd: string): OrchestrationCommand { + return { type: "project.create", commandId: CommandId.make(cmd), projectId: ProjectId.make(projectId), title: "P", workspaceRoot: `/w/${projectId}`, createdAt: ISO }; +} +function addStdio(serverId: string, args: ReadonlyArray, vars: ReadonlyArray, cmd: string): OrchestrationCommand { + return { type: "mcp.server-add", commandId: CommandId.make(cmd), serverId: McpServerId.make(serverId), draft: { name: serverId, config: { transport: "stdio", command: "uvx", args: [...args] }, vars, timeoutMs: null }, createdAt: ISO }; +} +function setEnabled(serverId: string, enabled: boolean, cmd: string): OrchestrationCommand { + return { type: "mcp.server-update", commandId: CommandId.make(cmd), serverId: McpServerId.make(serverId), patch: { enabled } }; +} +function remove(serverId: string, cmd: string): OrchestrationCommand { + return { type: "mcp.server-remove", commandId: CommandId.make(cmd), serverId: McpServerId.make(serverId) }; +} +function bind(projectId: string, serverId: string, varValues: Record, cmd: string): OrchestrationCommand { + return { type: "mcp.binding-set", commandId: CommandId.make(cmd), projectId: ProjectId.make(projectId), serverId: McpServerId.make(serverId), patch: { enabled: true, varValues } }; +} +function syncBuiltin(definition: McpBuiltinDefinition, cmd: string): OrchestrationCommand { + const config = builtinConfigForPlatform(definition, process.platform)!; + return { type: "mcp.builtin-sync", commandId: CommandId.make(cmd), serverId: McpServerId.make(builtinServerId(definition.builtinId)), builtinId: definition.builtinId, builtinHash: builtinHash(config, definition), name: definition.name, description: null, websiteUrl: null, config, shippedVars: builtinShippedVars(definition), timeoutMs: null }; +} + +function makeSystem(autobindDefaults: boolean) { + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-reactor-test-" }); + const layer = Layer.mergeAll( + OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(OrchestrationProjectionPipelineLive), + ), + OrchestrationProjectionPipelineLive, + ).pipe( + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettingsService.layerTest({ mcp: { autobindDefaults } })), + ); + const runtime = ManagedRuntime.make(layer); + return { + dispatch: (c: OrchestrationCommand) => + runtime.runPromise(Effect.flatMap(Effect.service(OrchestrationEngineService), (e) => e.dispatch(c))), + computeDesired: () => runtime.runPromise(computeDesiredEffect), + gc: () => runtime.runPromise(gcOrphanedSecretsEffect), + autobind: (projectId: string) => + runtime.runPromise(autobindBuiltinsForProjectWith(ProjectId.make(projectId))), + bindings: () => + runtime.runPromise(Effect.flatMap(Effect.service(McpBindingRepository), (r) => r.listAll())), + secretGet: (ref: string) => + runtime.runPromise(Effect.flatMap(Effect.service(ServerSecretStore), (s) => s.get(ref))), + dispose: () => runtime.dispose(), + }; +} + +describe("computeDesiredEffect — probe-target selection", () => { + let system: ReturnType; + beforeEach(() => { + system = makeSystem(false); + }); + afterEach(async () => { + await system.dispose(); + }); + + it("a no-override binding merges into its catalog instance (one instance, two refs)", async () => { + await system.dispatch(projectCreate("p1", "pc1")); + await system.dispatch(addStdio("s", [], [], "add:s")); + await system.dispatch(bind("p1", "s", {}, "bind:s")); + + const desired = await system.computeDesired(); + const inst = [...desired.values()].find((i) => i.refs.has("catalog:s")); + expect(inst).toBeDefined(); + expect(inst?.refs.has("p1:s")).toBe(true); // same resolved config ⇒ deduped onto the catalog instance + }); + + it("excludes a catalog-disabled server and an incomplete binding", async () => { + await system.dispatch(addStdio("s-dis", [], [], "add:dis")); + await system.dispatch(setEnabled("s-dis", false, "dis")); + await system.dispatch( + addStdio("s-inc", ["${NEED}"], [{ name: "NEED", secret: false, perProject: true, required: true, value: null }], "add:inc"), + ); + const desired = await system.computeDesired(); + const allRefs = [...desired.values()].flatMap((i) => Array.from(i.refs)); + expect(allRefs.some((r) => r.includes("s-dis"))).toBe(false); + expect(allRefs.some((r) => r.includes("s-inc"))).toBe(false); + }); + + it("two bindings with DIFFERENT per-project values are two instances; SAME value collapses to one", async () => { + await system.dispatch(projectCreate("pa", "pca")); + await system.dispatch(projectCreate("pb", "pcb")); + // server whose resolved config depends on a required per-project var ⇒ catalog default is incomplete. + await system.dispatch( + addStdio("s-v", ["${ARG}"], [{ name: "ARG", secret: false, perProject: true, required: true, value: null }], "add:v"), + ); + await system.dispatch(bind("pa", "s-v", { ARG: "x" }, "bind:a")); + await system.dispatch(bind("pb", "s-v", { ARG: "y" }, "bind:b")); + const twoVals = [...(await system.computeDesired()).values()].filter((i) => + [...i.refs].some((r) => r.endsWith(":s-v")), + ); + expect(twoVals).toHaveLength(2); // different resolved configs ⇒ separate instances + + await system.dispatch(bind("pb", "s-v", { ARG: "x" }, "bind:b2")); // now same as pa + const oneVal = [...(await system.computeDesired()).values()].filter((i) => + [...i.refs].some((r) => r.endsWith(":s-v")), + ); + expect(oneVal).toHaveLength(1); + expect(oneVal[0]?.refs.has("pa:s-v")).toBe(true); + expect(oneVal[0]?.refs.has("pb:s-v")).toBe(true); + }); +}); + +describe("gcOrphanedSecretsEffect — secret GC", () => { + let system: ReturnType; + beforeEach(() => { + system = makeSystem(false); + }); + afterEach(async () => { + await system.dispose(); + }); + + it("prunes a secret no longer referenced, keeps a live one", async () => { + const secretVar: McpServerVarDraft = { name: "TOK", secret: true, perProject: false, required: false, value: "v" }; + // ru-fork #2: distinct configs (identity is the config) — args differ so both servers can be added. + await system.dispatch(addStdio("s-live", ["live"], [secretVar], "add:live")); + await system.dispatch(addStdio("s-dead", ["dead"], [secretVar], "add:dead")); + const liveRef = mcpVarSecretName({ serverId: "s-live", varName: "TOK" }); + const deadRef = mcpVarSecretName({ serverId: "s-dead", varName: "TOK" }); + expect(await system.secretGet(liveRef)).not.toBeNull(); + expect(await system.secretGet(deadRef)).not.toBeNull(); + + await system.dispatch(remove("s-dead", "rm:dead")); // s-dead's secret is now orphaned + await system.gc(); + + expect(await system.secretGet(liveRef)).not.toBeNull(); // still referenced ⇒ kept + expect(await system.secretGet(deadRef)).toBeNull(); // orphaned ⇒ pruned + }); +}); + +describe("autobindBuiltinsForProjectWith — default bindings", () => { + let system: ReturnType; + afterEach(async () => { + await system.dispose(); + }); + + it("binds every catalog built-in to the project when autobindDefaults is ON", async () => { + system = makeSystem(true); + await system.dispatch(syncBuiltin(DEMO, "sync")); + await system.dispatch(projectCreate("pab", "pcab")); + await system.autobind("pab"); + expect( + (await system.bindings()).some( + (b) => b.projectId === "pab" && b.serverId === builtinServerId("demo"), + ), + ).toBe(true); + }); + + it("binds NOTHING when autobindDefaults is OFF", async () => { + system = makeSystem(false); + await system.dispatch(syncBuiltin(DEMO, "sync")); + await system.dispatch(projectCreate("pab2", "pcab2")); + await system.autobind("pab2"); + expect((await system.bindings()).filter((b) => b.projectId === "pab2")).toHaveLength(0); + }); +}); diff --git a/apps/server/tests/ru-fork/mcp/reactorErrorArms.test.ts b/apps/server/tests/ru-fork/mcp/reactorErrorArms.test.ts new file mode 100644 index 00000000000..f6d04efb1ba --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/reactorErrorArms.test.ts @@ -0,0 +1,187 @@ +// ru-fork: prove the reactor's `catchCause → logError` hygiene arms are LIVE — i.e. that a failed +// internal dispatch is logged and the loop CONTINUES, and (unlike pruneByPrefix's dead catch) the catch +// can actually fire. We drive the already-exported seams directly with a fake engine whose `dispatch` +// dies and fake repos that force each dispatch, then assert the seam completes AND the arm logged. No +// pipeline, no real engine — so this stays decoupled. These are characterization tests (GREEN): a dead +// or mis-wired arm would make them fail. + +import { + IsoDateTime, + McpServerId, + ProjectId, + type McpCatalogServer, + type McpProbeRecord, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import { describe, expect, it } from "vitest"; + +import { + McpCatalogRepository, + type McpCatalogRepositoryShape, +} from "../../../src/persistence/Services/McpCatalog.ts"; +import { + McpBindingRepository, + type McpBindingRepositoryShape, +} from "../../../src/persistence/Services/McpBinding.ts"; +import { + McpProbeCacheRepository, + type McpProbeCacheRepositoryShape, +} from "../../../src/persistence/Services/McpProbeCache.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../../../src/orchestration/Services/OrchestrationEngine.ts"; +import { buildAddedServer, buildBinding, buildSyncedBuiltin } from "../../../src/ru-fork/mcp/McpCatalogBuilders.ts"; +import { MCP_BUILTINS } from "../../../src/ru-fork/mcp/mcpBuiltinDefinitions.ts"; +import { + backfillServerMetadataEffect, + pruneOrphanedVarValuesEffect, + reconcileBuiltinsWith, +} from "../../../src/ru-fork/mcp/McpReactor.ts"; + +const ISO = "2026-01-01T00:00:00.000Z"; + +// Every internal dispatch dies ⇒ the reactor's `catchCause` arms must absorb it and keep going. +const failingEngine = { + readEvents: () => Effect.die("readEvents is unused in this test"), + dispatch: () => Effect.die("injected dispatch failure"), + streamDomainEvents: Stream.empty, +} satisfies OrchestrationEngineShape; + +const fakeCatalog = (servers: ReadonlyArray) => + ({ + listAll: () => Effect.succeed(servers), + upsert: () => Effect.die("upsert is unused in this test"), + remove: () => Effect.die("remove is unused in this test"), + }) satisfies McpCatalogRepositoryShape; + +const fakeBinding = (bindings: ReadonlyArray>) => + ({ + listAll: () => Effect.succeed(bindings), + upsert: () => Effect.die("upsert is unused in this test"), + remove: () => Effect.die("remove is unused in this test"), + listByProject: () => Effect.die("listByProject is unused in this test"), + removeByProject: () => Effect.die("removeByProject is unused in this test"), + removeByServer: () => Effect.die("removeByServer is unused in this test"), + }) satisfies McpBindingRepositoryShape; + +const fakeProbeCache = (record: McpProbeRecord) => + ({ + getByKey: () => Effect.succeed(Option.some(record)), + upsert: () => Effect.die("upsert is unused in this test"), + deleteKeysNotIn: () => Effect.die("deleteKeysNotIn is unused in this test"), + }) satisfies McpProbeCacheRepositoryShape; + +const stdioConfig = (id: string) => ({ transport: "stdio" as const, command: "uvx", args: [id] }); + +// Run `effect` (already fully provided except logging) and capture every emitted log entry. +async function runCapturingLogs(effect: Effect.Effect) { + const captured: Array<{ readonly level: string; readonly message: string }> = []; + const logger = Logger.make(({ logLevel, message }) => { + captured.push({ level: String(logLevel), message: String(message) }); + }); + await Effect.runPromise( + effect.pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))), + ); + return captured; +} + +const loggedError = (captured: ReadonlyArray<{ level: string; message: string }>, needle: string) => + // The needle messages are only ever emitted via `Effect.logError` in the reactor arms, so matching the + // message proves the arm fired; we also confirm it was at error level (tolerant of "Error"/"ERROR"). + captured.some( + (entry) => entry.message.includes(needle) && entry.level.toUpperCase().includes("ERROR"), + ); + +describe("McpReactor hygiene catch arms are live (log + continue on a failed dispatch)", () => { + it("sync arm: a failed builtin-sync dispatch is logged and the reconcile completes", async () => { + // Empty catalog ⇒ every shipped built-in needs syncing ⇒ a dispatch is attempted (and dies). + const captured = await runCapturingLogs( + reconcileBuiltinsWith(MCP_BUILTINS, process.platform).pipe( + Effect.provideService(OrchestrationEngineService, failingEngine), + Effect.provideService(McpCatalogRepository, fakeCatalog([])), + ), + ); + expect(loggedError(captured, "sync builtin")).toBe(true); // completing AND logging proves the arm is live + }); + + it("remove arm: a failed dropped-builtin remove dispatch is logged and the reconcile completes", async () => { + const ghost = buildSyncedBuiltin({ + serverId: McpServerId.make("server:mcp:ghost"), + builtinId: "ghost", + builtinHash: "h", + name: "ghost", + description: null, + websiteUrl: null, + config: stdioConfig("ghost"), + shippedVars: [], + timeoutMs: null, + existing: undefined, + occurredAt: ISO, + }); + // Nothing shipped ⇒ the installed `ghost` built-in is dropped ⇒ a remove is dispatched (and dies). + const captured = await runCapturingLogs( + reconcileBuiltinsWith([], process.platform).pipe( + Effect.provideService(OrchestrationEngineService, failingEngine), + Effect.provideService(McpCatalogRepository, fakeCatalog([ghost])), + ), + ); + expect(loggedError(captured, "remove dropped builtin")).toBe(true); + }); + + it("backfill arm: a failed metadata-backfill dispatch is logged and the pass completes", async () => { + const server = buildAddedServer( + McpServerId.make("s-bf"), + { name: "s-bf", config: stdioConfig("s-bf"), vars: [], timeoutMs: null }, + [], + ISO, + ); // description + websiteUrl both null ⇒ backfill proceeds + const probe: McpProbeRecord = { + configKey: "ck-bf", + transport: "stdio", + status: "online", + tools: [], + lastError: null, + serverDescription: "Probed description", + serverWebsiteUrl: null, + checkedAt: IsoDateTime.make(ISO), + checkedAtMs: 1, + }; + const captured = await runCapturingLogs( + backfillServerMetadataEffect.pipe( + Effect.provideService(OrchestrationEngineService, failingEngine), + Effect.provideService(McpCatalogRepository, fakeCatalog([server])), + Effect.provideService(McpProbeCacheRepository, fakeProbeCache(probe)), + ), + ); + expect(loggedError(captured, "backfill server metadata")).toBe(true); + }); + + it("prune arm: a failed orphan-var prune dispatch is logged and the scan completes", async () => { + const server = buildAddedServer( + McpServerId.make("s-pr"), + { name: "s-pr", config: stdioConfig("s-pr"), vars: [], timeoutMs: null }, + [], + ISO, + ); // declares NO vars + const binding = buildBinding({ + projectId: ProjectId.make("p"), + serverId: McpServerId.make("s-pr"), + patch: { enabled: true }, + existing: undefined, + varValues: { ORPHAN: "stranded-value" }, // a value for a var the server no longer declares + occurredAt: ISO, + }); + const captured = await runCapturingLogs( + pruneOrphanedVarValuesEffect.pipe( + Effect.provideService(OrchestrationEngineService, failingEngine), + Effect.provideService(McpCatalogRepository, fakeCatalog([server])), + Effect.provideService(McpBindingRepository, fakeBinding([binding])), + ), + ); + expect(loggedError(captured, "prune orphaned var values")).toBe(true); + }); +}); diff --git a/apps/server/tests/ru-fork/mcp/reactorWorker.test.ts b/apps/server/tests/ru-fork/mcp/reactorWorker.test.ts new file mode 100644 index 00000000000..a6d82add8d5 --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/reactorWorker.test.ts @@ -0,0 +1,321 @@ +// ru-fork: integration tests for the McpReactor WORKER + START lifecycle — the live wiring that the +// per-effect seam tests (reactorEffects/reconciliationLifecycle) deliberately bypass. Here we stand up +// the WHOLE reactor (engine → projections → supervisor → overlay → reactor) and drive it through REAL +// domain events, asserting the assembled behaviour: +// - start() seeds built-ins and does the initial reconcile WITHOUT probing on load; +// - a post-startup mcp.* event flows engine → streamDomainEvents → worker → reconcileNow → +// probeHashes and the new instance actually goes ONLINE (the full interaction chain); +// - project.created drives autobind through the worker (gated by settings); +// - reconcileNow's probe-cache GC prunes orphaned rows; +// - project.deleted reconciles the deleted project's ref away. +// Probes use the local fake stdio MCP server; the shipped built-ins are kept un-probed (left unbound, or +// catalog-disabled) so no npx/network probe is ever triggered — the suite stays hermetic and fast. + +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { CommandId, McpServerId, ProjectId, type OrchestrationCommand } from "@t3tools/contracts"; +import type { ServerSettings } from "@t3tools/contracts"; +import type { DeepPartial } from "@t3tools/shared/Struct"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Option from "effect/Option"; +import type * as Scope from "effect/Scope"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { afterEach, describe, expect, it } from "vitest"; + +import { OrchestrationCommandReceiptRepositoryLive } from "../../../src/persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../../../src/persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../../../src/persistence/Layers/Sqlite.ts"; +import { McpBindingRepository } from "../../../src/persistence/Services/McpBinding.ts"; +import { McpProbeCacheRepository } from "../../../src/persistence/Services/McpProbeCache.ts"; +import { RepositoryIdentityResolverLive } from "../../../src/project/Layers/RepositoryIdentityResolver.ts"; +import { OrchestrationEngineLive } from "../../../src/orchestration/Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "../../../src/orchestration/Layers/ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "../../../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; +import { OrchestrationEngineService } from "../../../src/orchestration/Services/OrchestrationEngine.ts"; +import { ServerConfig } from "../../../src/config.ts"; +import { ServerSettingsService } from "../../../src/serverSettings.ts"; +import { builtinServerId } from "../../../src/ru-fork/mcp/McpBuiltins.ts"; +import { McpOverlayLive } from "../../../src/ru-fork/mcp/McpOverlay.ts"; +import { McpReactor, McpReactorLive } from "../../../src/ru-fork/mcp/McpReactor.ts"; +import { McpSupervisor, McpSupervisorLive } from "../../../src/ru-fork/mcp/McpSupervisor.ts"; + +const ISO = "2026-01-01T00:00:00.000Z"; + +// packages/mcp-core/test-fixtures/fakeMcpStdioServer.mjs — real stdio MCP server (echo + ping). +const FAKE_SERVER = path.resolve( + fileURLToPath(import.meta.url), + "../../../../../../packages/mcp-core/test-fixtures/fakeMcpStdioServer.mjs", +); + +const addFakeServer = (serverId: string, extraArgs: ReadonlyArray = []): OrchestrationCommand => ({ + type: "mcp.server-add", + commandId: CommandId.make(`cmd-add-${serverId}-${extraArgs.join("_")}`), + serverId: McpServerId.make(serverId), + draft: { + name: serverId, + config: { transport: "stdio", command: "node", args: [FAKE_SERVER, ...extraArgs] }, + vars: [], + timeoutMs: 10_000, + }, + createdAt: ISO, +}); + +const updateConfig = (serverId: string, extraArgs: ReadonlyArray): OrchestrationCommand => ({ + type: "mcp.server-update", + commandId: CommandId.make(`cmd-cfg-${serverId}-${extraArgs.join("_")}`), + serverId: McpServerId.make(serverId), + patch: { config: { transport: "stdio", command: "node", args: [FAKE_SERVER, ...extraArgs] } }, +}); + +const setEnabled = (serverId: string, enabled: boolean): OrchestrationCommand => ({ + type: "mcp.server-update", + commandId: CommandId.make(`cmd-en-${serverId}-${enabled}`), + serverId: McpServerId.make(serverId), + patch: { enabled }, +}); + +const createProject = (projectId: string): OrchestrationCommand => ({ + type: "project.create", + commandId: CommandId.make(`cmd-proj-${projectId}`), + projectId: ProjectId.make(projectId), + title: "P", + workspaceRoot: `/tmp/${projectId}`, + createdAt: ISO, +}); + +const deleteProject = (projectId: string): OrchestrationCommand => ({ + type: "project.delete", + commandId: CommandId.make(`cmd-projdel-${projectId}`), + projectId: ProjectId.make(projectId), +}); + +const bindServer = (projectId: string, serverId: string): OrchestrationCommand => ({ + type: "mcp.binding-set", + commandId: CommandId.make(`cmd-bind-${projectId}-${serverId}`), + projectId: ProjectId.make(projectId), + serverId: McpServerId.make(serverId), + patch: { enabled: true }, +}); + +// Poll `query` until `predicate` holds (or fail loudly). Used instead of `drain` because the +// engine→stream→worker hop is asynchronous: drain can observe an empty queue before the forked +// subscription has even enqueued the work. +const waitUntil = (query: Effect.Effect, predicate: (value: A) => boolean) => + Effect.gen(function* () { + for (let attempt = 0; attempt < 240; attempt++) { + const value = yield* query; + if (predicate(value)) { + return value; + } + yield* Effect.sleep("25 millis"); + } + return yield* Effect.dieMessage("waitUntil: condition not satisfied within timeout"); + }); + +function makeSystem(settings: DeepPartial = {}) { + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-reactor-test-" }); + const base = Layer.mergeAll( + OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(OrchestrationProjectionPipelineLive), + ), + OrchestrationProjectionPipelineLive, + OrchestrationProjectionSnapshotQueryLive, + ).pipe( + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettingsService.layerTest(settings)), + ); + const withSupervisor = McpSupervisorLive.pipe(Layer.provideMerge(base)); + const withOverlay = McpOverlayLive.pipe(Layer.provideMerge(withSupervisor)); + const runtime = ManagedRuntime.make(McpReactorLive.pipe(Layer.provideMerge(withOverlay))); + return { + // Run a scoped body with the whole reactor in context (start()'s subscription lives for the body). + run: (body: Effect.Effect): Promise => + runtime.runPromise(Effect.scoped(body) as Effect.Effect), + dispose: () => runtime.dispose(), + }; +} + +describe("McpReactor worker + start lifecycle (integration)", () => { + let system: ReturnType; + afterEach(async () => { + await system.dispose(); + }); + + it("start() seeds built-ins + reconciles WITHOUT probing on load (no eager probe at startup)", async () => { + system = makeSystem(); + const result = await system.run( + Effect.gen(function* () { + const reactor = yield* McpReactor; + const engine = yield* OrchestrationEngineService; + const supervisor = yield* McpSupervisor; + + // A complete custom server present BEFORE start: it must be registered by the initial reconcile + // but NOT probed (load-time reconcile is non-eager). + yield* engine.dispatch(addFakeServer("srv-load")); + yield* reactor.start(); + + // Wait until the initial reconcile has registered our server, then assert nothing was probed. + const instances = yield* waitUntil(supervisor.currentInstances, (all) => + all.some((i) => i.refs.has("catalog:srv-load")), + ); + const inFlight = yield* supervisor.currentInFlight; + return { instances, inFlight }; + }), + ); + + // Every registered instance is still «unchecked» — the load-time reconcile probed none of them. + expect(result.instances.length).toBeGreaterThan(0); + expect(result.instances.every((i) => i.status === "unchecked")).toBe(true); + expect(result.inFlight.size).toBe(0); + // The shipped built-ins were seeded into the catalog by start(). + expect(result.instances.some((i) => i.refs.has(`catalog:${builtinServerId("filesystem")}`))).toBe(true); + }, 25_000); + + it("a post-startup server-add flows engine → stream → worker → probe and goes ONLINE", async () => { + system = makeSystem(); + const online = await system.run( + Effect.gen(function* () { + const reactor = yield* McpReactor; + const engine = yield* OrchestrationEngineService; + const supervisor = yield* McpSupervisor; + + yield* reactor.start(); + // Drain the startup reconcile first (so the only eager change below is our new server). + yield* reactor.drain; + + // The crown-jewel path: a genuine post-startup change must auto-probe the NEW instance. + yield* engine.dispatch(addFakeServer("srv-live")); + const instances = yield* waitUntil(supervisor.currentInstances, (all) => + all.some((i) => i.refs.has("catalog:srv-live") && i.status === "online"), + ); + return instances.find((i) => i.refs.has("catalog:srv-live")); + }), + ); + + expect(online?.status).toBe("online"); + expect(online?.discoveredTools.map((tool) => tool.name).toSorted()).toEqual(["echo", "ping"]); + expect(typeof online?.latencyMs).toBe("number"); + }, 25_000); + + it("project.created autobinds every built-in through the worker when autobindDefaults is ON", async () => { + // Disable the two always-complete built-ins so their autobound bindings don't trigger real + // npx/http probes — we're asserting the WIRING (bindings created), not probe outcomes. + system = makeSystem({ mcp: { autobindDefaults: true } }); + const bindings = await system.run( + Effect.gen(function* () { + const reactor = yield* McpReactor; + const engine = yield* OrchestrationEngineService; + const bindingRepository = yield* McpBindingRepository; + + yield* reactor.start(); + yield* reactor.drain; + yield* engine.dispatch(setEnabled(builtinServerId("filesystem"), false)); + yield* engine.dispatch(setEnabled(builtinServerId("context7"), false)); + yield* reactor.drain; + + yield* engine.dispatch(createProject("proj-ab")); + return yield* waitUntil( + bindingRepository.listByProject({ projectId: ProjectId.make("proj-ab") }), + (rows) => rows.length >= 3, + ); + }), + ); + + // All three shipped built-ins (filesystem, context7, atlassian) are autobound, enabled. + expect(bindings.length).toBe(3); + expect(bindings.every((binding) => binding.enabled)).toBe(true); + }, 25_000); + + it("project.created does NOT autobind when autobindDefaults is OFF (default)", async () => { + system = makeSystem(); // autobindDefaults defaults to false + const bindings = await system.run( + Effect.gen(function* () { + const reactor = yield* McpReactor; + const engine = yield* OrchestrationEngineService; + const bindingRepository = yield* McpBindingRepository; + + yield* reactor.start(); + yield* reactor.drain; + yield* engine.dispatch(createProject("proj-noab")); + yield* reactor.drain; + // Give the worker a beat; then assert no bindings ever appear for the project. + yield* Effect.sleep("150 millis"); + return yield* bindingRepository.listByProject({ projectId: ProjectId.make("proj-noab") }); + }), + ); + expect(bindings).toEqual([]); + }, 25_000); + + it("reconcileNow GCs the orphaned probe-cache row after a server's config changes", async () => { + system = makeSystem(); + const result = await system.run( + Effect.gen(function* () { + const reactor = yield* McpReactor; + const engine = yield* OrchestrationEngineService; + const supervisor = yield* McpSupervisor; + const probeCache = yield* McpProbeCacheRepository; + + yield* reactor.start(); + yield* reactor.drain; + + // Probe srv-gc online ⇒ a cache row is written for its current configKey. + yield* engine.dispatch(addFakeServer("srv-gc")); + const before = yield* waitUntil(supervisor.currentInstances, (all) => + all.some((i) => i.refs.has("catalog:srv-gc") && i.status === "online"), + ); + const oldConfigKey = before.find((i) => i.refs.has("catalog:srv-gc"))!.configKey; + const rowBefore = yield* probeCache.getByKey({ configKey: oldConfigKey }); + + // Change the config ⇒ new configKey; the old row is now orphaned and must be GC'd on reconcile + // (desired is still non-empty thanks to the shipped built-ins, so the delete path runs). + yield* engine.dispatch(updateConfig("srv-gc", ["--variant"])); + yield* waitUntil(probeCache.getByKey({ configKey: oldConfigKey }), Option.isNone); + return { rowBefore }; + }), + ); + expect(Option.isSome(result.rowBefore)).toBe(true); // the row existed before the config change + // (the waitUntil above already proved it is gone after — reaching here means GC ran) + }, 25_000); + + it("project.deleted reconciles the deleted project's ref away (binding cascade)", async () => { + system = makeSystem(); + const refsAfter = await system.run( + Effect.gen(function* () { + const reactor = yield* McpReactor; + const engine = yield* OrchestrationEngineService; + const supervisor = yield* McpSupervisor; + + yield* reactor.start(); + yield* reactor.drain; + + yield* engine.dispatch(addFakeServer("srv-del")); + yield* engine.dispatch(createProject("proj-del")); + yield* engine.dispatch(bindServer("proj-del", "srv-del")); + // The binding adds the `proj-del:srv-del` ref onto the shared instance. + yield* waitUntil(supervisor.currentInstances, (all) => + all.some((i) => i.refs.has("proj-del:srv-del")), + ); + + // Delete the project ⇒ binding cascades away ⇒ reconcile drops the project ref. + yield* engine.dispatch(deleteProject("proj-del")); + const instances = yield* waitUntil(supervisor.currentInstances, (all) => + all.every((i) => !i.refs.has("proj-del:srv-del")), + ); + // The instance survives via its catalog ref (the server itself wasn't removed). + return instances.find((i) => i.refs.has("catalog:srv-del"))?.refs; + }), + ); + expect(refsAfter).toBeDefined(); + expect([...(refsAfter ?? [])]).toContain("catalog:srv-del"); + expect([...(refsAfter ?? [])]).not.toContain("proj-del:srv-del"); + }, 25_000); +}); diff --git a/apps/server/tests/ru-fork/mcp/reconciliationLifecycle.test.ts b/apps/server/tests/ru-fork/mcp/reconciliationLifecycle.test.ts new file mode 100644 index 00000000000..e0c2ee18872 --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/reconciliationLifecycle.test.ts @@ -0,0 +1,466 @@ +// ru-fork: characterization tests for the built-in/custom server LIFECYCLE through the REAL engine +// (decider → event → SQL projection → command-receipt store). These exercise the path where the +// reactor's reconciliation meets the engine's commandId-receipt dedup — i.e. where "remove then +// re-add" lives. Some of these are written to the CORRECT expected behaviour and currently FAIL; +// the failure IS the spec for the reconciliation bug (a stable/content commandId is deduped forever). + +import { + CommandId, + IsoDateTime, + McpServerId, + ProjectId, + type McpBinding, + type McpCatalogServer, + type McpProbeRecord, + type OrchestrationCommand, +} from "@t3tools/contracts"; +import { configCacheKey } from "@ru-fork/mcp-core"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { OrchestrationCommandReceiptRepositoryLive } from "../../../src/persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../../../src/persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../../../src/persistence/Layers/Sqlite.ts"; +import { McpBindingRepository } from "../../../src/persistence/Services/McpBinding.ts"; +import { McpCatalogRepository } from "../../../src/persistence/Services/McpCatalog.ts"; +import { McpProbeCacheRepository } from "../../../src/persistence/Services/McpProbeCache.ts"; +import { RepositoryIdentityResolverLive } from "../../../src/project/Layers/RepositoryIdentityResolver.ts"; +import { OrchestrationEngineLive } from "../../../src/orchestration/Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "../../../src/orchestration/Layers/ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "../../../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; +import { OrchestrationEngineService } from "../../../src/orchestration/Services/OrchestrationEngine.ts"; +import { ServerConfig } from "../../../src/config.ts"; +import { + builtinConfigForPlatform, + builtinHash, + builtinServerId, + builtinShippedVars, + type McpBuiltinDefinition, +} from "../../../src/ru-fork/mcp/McpBuiltins.ts"; +import { + backfillServerMetadataEffect, + pruneOrphanedVarValuesEffect, + reconcileBuiltinsWith, +} from "../../../src/ru-fork/mcp/McpReactor.ts"; + +const DEMO: McpBuiltinDefinition = { + builtinId: "demo", + name: "demo", + description: "Demo", + config: { default: { transport: "stdio", command: "uvx", args: ["demo"] } }, + vars: [{ name: "URL", secret: false, perProject: false, required: true, value: "https://demo" }], +}; + +/** The mcp.builtin-sync command the reactor dispatches for `definition` (commandId is the reactor's). */ +function syncCommand(definition: McpBuiltinDefinition, commandId: string): OrchestrationCommand { + const config = builtinConfigForPlatform(definition, process.platform)!; + return { + type: "mcp.builtin-sync", + commandId: CommandId.make(commandId), + serverId: McpServerId.make(builtinServerId(definition.builtinId)), + builtinId: definition.builtinId, + builtinHash: builtinHash(config, definition), + name: definition.name, + description: definition.description ?? null, + websiteUrl: null, + config, + shippedVars: builtinShippedVars(definition), + timeoutMs: null, + }; +} + +function removeCommand(serverId: string, commandId: string): OrchestrationCommand { + return { + type: "mcp.server-remove", + commandId: CommandId.make(commandId), + serverId: McpServerId.make(serverId), + }; +} + +function addCustomCommand(serverId: string, name: string, commandId: string): OrchestrationCommand { + return { + type: "mcp.server-add", + commandId: CommandId.make(commandId), + serverId: McpServerId.make(serverId), + draft: { + name, + config: { transport: "stdio", command: "uvx", args: [name] }, + vars: [], + timeoutMs: null, + }, + createdAt: "2026-01-01T00:00:00.000Z", + }; +} + +function projectCreateCommand(projectId: string, commandId: string): OrchestrationCommand { + return { + type: "project.create", + commandId: CommandId.make(commandId), + projectId: ProjectId.make(projectId), + title: "Test project", + workspaceRoot: `/tmp/${projectId}`, + createdAt: "2026-01-01T00:00:00.000Z", + }; +} + +function bindCommand(projectId: string, serverId: string, commandId: string): OrchestrationCommand { + return { + type: "mcp.binding-set", + commandId: CommandId.make(commandId), + projectId: ProjectId.make(projectId), + serverId: McpServerId.make(serverId), + patch: { enabled: true }, + }; +} + +function updateCustomConfigCommand( + serverId: string, + args: ReadonlyArray, + commandId: string, +): OrchestrationCommand { + return { + type: "mcp.server-update", + commandId: CommandId.make(commandId), + serverId: McpServerId.make(serverId), + patch: { config: { transport: "stdio", command: "uvx", args: [...args] } }, + }; +} + +function addCustomWithVarCommand( + serverId: string, + name: string, + varName: string, + commandId: string, +): OrchestrationCommand { + return { + type: "mcp.server-add", + commandId: CommandId.make(commandId), + serverId: McpServerId.make(serverId), + draft: { + name, + config: { transport: "stdio", command: "uvx", args: [name] }, + vars: [{ name: varName, secret: false, perProject: true, required: false, value: null }], + timeoutMs: null, + }, + createdAt: "2026-01-01T00:00:00.000Z", + }; +} + +function setServerVarsCommand( + serverId: string, + varNames: ReadonlyArray, + commandId: string, +): OrchestrationCommand { + return { + type: "mcp.server-update", + commandId: CommandId.make(commandId), + serverId: McpServerId.make(serverId), + patch: { + vars: varNames.map((name) => ({ + name, + secret: false, + perProject: true, + required: false, + value: null, + })), + }, + }; +} + +function bindVarsCommand( + projectId: string, + serverId: string, + varValues: Record, + commandId: string, +): OrchestrationCommand { + return { + type: "mcp.binding-set", + commandId: CommandId.make(commandId), + projectId: ProjectId.make(projectId), + serverId: McpServerId.make(serverId), + patch: { enabled: true, varValues }, + }; +} + +function makeSystem() { + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-recon-test-" }); + const layer = Layer.mergeAll( + OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(OrchestrationProjectionPipelineLive), + ), + // Merge the pipeline too so its McpCatalogRepository output is queryable from the runtime. + OrchestrationProjectionPipelineLive, + ).pipe( + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), + ); + const runtime = ManagedRuntime.make(layer); + return { + dispatch: (command: OrchestrationCommand) => + runtime.runPromise( + Effect.flatMap(Effect.service(OrchestrationEngineService), (engine) => + engine.dispatch(command), + ), + ), + catalog: (): Promise> => + runtime.runPromise( + Effect.flatMap(Effect.service(McpCatalogRepository), (repo) => repo.listAll()), + ), + bindings: (): Promise> => + runtime.runPromise( + Effect.flatMap(Effect.service(McpBindingRepository), (repo) => repo.listAll()), + ), + // Drive the REAL reactor reconcile loop with a given shipped-definition list (the testability seam). + reconcile: (definitions: ReadonlyArray) => + runtime.runPromise(reconcileBuiltinsWith(definitions, process.platform)), + prune: () => runtime.runPromise(pruneOrphanedVarValuesEffect), + backfill: () => runtime.runPromise(backfillServerMetadataEffect), + probeUpsert: (record: McpProbeRecord) => + runtime.runPromise( + Effect.flatMap(Effect.service(McpProbeCacheRepository), (repo) => repo.upsert(record)), + ), + dispose: () => runtime.dispose(), + }; +} + +describe("server lifecycle through the engine (reconciliation characterization)", () => { + let system: ReturnType; + beforeEach(() => { + system = makeSystem(); + }); + afterEach(async () => { + await system.dispose(); + }); + + it("a no-op re-sync (same commandId) does not duplicate the server", async () => { + await system.dispatch(syncCommand(DEMO, "sync:demo:v1")); + await system.dispatch(syncCommand(DEMO, "sync:demo:v1")); // identical → deduped, correctly + const catalog = await system.catalog(); + expect(catalog.filter((server) => server.builtinId === "demo")).toHaveLength(1); + }); + + it("a CHANGED built-in definition propagates to an existing install (update applies)", async () => { + // v1 installed (commandId carries v1's hash, as the reactor does). + await system.dispatch(syncCommand(DEMO, "sync:demo:hashA")); + // v2 changes the command; the reactor would dispatch with a NEW hash-bearing commandId. + const demoV2: McpBuiltinDefinition = { + ...DEMO, + config: { default: { transport: "stdio", command: "uvx", args: ["demo", "--v2"] } }, + }; + await system.dispatch(syncCommand(demoV2, "sync:demo:hashB")); + const server = (await system.catalog()).find((entry) => entry.builtinId === "demo"); + expect(server?.config).toEqual({ transport: "stdio", command: "uvx", args: ["demo", "--v2"] }); + }); + + it("the reactor re-adds a removed built-in on a later reconcile (real reconcile loop)", async () => { + // Drive the actual reactor reconcile loop, varying the shipped-definition list across "restarts". + await system.reconcile([DEMO]); // ship it → installed + expect((await system.catalog()).some((server) => server.builtinId === "demo")).toBe(true); + + await system.reconcile([]); // dropped from the shipped list → removed + expect((await system.catalog()).some((server) => server.builtinId === "demo")).toBe(false); + + await system.reconcile([DEMO]); // shipped again → MUST reappear + // EXPECTED: it's back. Currently FAILS — the reconcile re-dispatches the SAME content-stable + // commandId from the first install, which the engine's accepted-receipt short-circuits, so the + // re-add never happens. This goes green once the reactor emits a fresh per-reconcile commandId. + expect((await system.catalog()).some((server) => server.builtinId === "demo")).toBe(true); + }); + + it("removing a built-in TWICE removes it both times (remove id is not one-shot)", async () => { + // Re-install between the two removals uses DIFFERENT content (new hash ⇒ new SYNC commandId), so + // this test isolates the REMOVE-side dedup from the re-add bug — the re-install genuinely succeeds. + const demoV2: McpBuiltinDefinition = { + ...DEMO, + config: { default: { transport: "stdio", command: "uvx", args: ["demo", "--v2"] } }, + }; + await system.reconcile([DEMO]); + await system.reconcile([]); // removal #1 (commandId server:mcp-builtin-remove:demo) + expect((await system.catalog()).some((server) => server.builtinId === "demo")).toBe(false); + + await system.reconcile([demoV2]); // re-install with new content ⇒ NOT blocked by the re-add dedup + expect((await system.catalog()).some((server) => server.builtinId === "demo")).toBe(true); + + await system.reconcile([]); // removal #2 — SAME remove commandId ⇒ must still remove + // EXPECTED gone. FAILS today: the second removal re-uses `server:mcp-builtin-remove:demo`, which the + // engine's accepted-receipt short-circuits, so demo is stranded. Needs a per-reconcile id on REMOVE. + expect((await system.catalog()).some((server) => server.builtinId === "demo")).toBe(false); + }); + + it("two custom servers with the SAME name but DIFFERENT config both appear (name is not the identity)", async () => { + // ru-fork #2: identity is the CONFIG, not the name. Same name is fine as long as the config differs; + // a same-CONFIG add is rejected (covered in branch3DeciderGuards). + await system.dispatch(addCustomCommand("srv-a", "foo", "add:a")); // config: uvx foo + await system.dispatch({ + type: "mcp.server-add", + commandId: CommandId.make("add:b"), + serverId: McpServerId.make("srv-b"), + draft: { + name: "foo", + config: { transport: "stdio", command: "uvx", args: ["foo", "--b"] }, + vars: [], + timeoutMs: null, + }, + createdAt: "2026-01-01T00:00:00.000Z", + }); + const named = (await system.catalog()).filter((server) => server.name === "foo"); + expect(named).toHaveLength(2); + }); +}); + +describe("custom (UI-added) servers + project cascade", () => { + let system: ReturnType; + beforeEach(() => { + system = makeSystem(); + }); + afterEach(async () => { + await system.dispose(); + }); + + it("a custom server's command edit is reflected in the catalog (the binding reads it)", async () => { + await system.dispatch(addCustomCommand("srv-c", "myc", "add:c")); + await system.dispatch(updateCustomConfigCommand("srv-c", ["myc", "--new"], "upd:c")); + const server = (await system.catalog()).find((entry) => entry.id === "srv-c"); + expect(server?.config).toEqual({ transport: "stdio", command: "uvx", args: ["myc", "--new"] }); + }); + + it("removing a CUSTOM server used by a project leaves no binding behind", async () => { + await system.dispatch(projectCreateCommand("proj-1", "pc:1")); + await system.dispatch(addCustomCommand("srv-d", "myd", "add:d")); + await system.dispatch(bindCommand("proj-1", "srv-d", "bind:d")); + expect((await system.bindings()).some((binding) => binding.serverId === "srv-d")).toBe(true); + + await system.dispatch(removeCommand("srv-d", "rm:d")); + expect((await system.catalog()).some((entry) => entry.id === "srv-d")).toBe(false); + expect((await system.bindings()).some((binding) => binding.serverId === "srv-d")).toBe(false); + }); + + it("removing a BUILT-IN used by a project leaves no binding behind", async () => { + await system.dispatch(projectCreateCommand("proj-2", "pc:2")); + await system.dispatch(syncCommand(DEMO, "sync:demo:v1")); + const demoId = builtinServerId("demo"); + await system.dispatch(bindCommand("proj-2", demoId, "bind:demo")); + expect((await system.bindings()).some((binding) => binding.serverId === demoId)).toBe(true); + + await system.dispatch(removeCommand(demoId, "rm:demo")); + expect((await system.bindings()).some((binding) => binding.serverId === demoId)).toBe(false); + }); +}); + +describe("reconciler hygiene commands must re-run on later state changes (dedup characterization)", () => { + let system: ReturnType; + beforeEach(() => { + system = makeSystem(); + }); + afterEach(async () => { + await system.dispose(); + }); + + const bindingVarKeys = async (serverId: string): Promise> => { + const binding = (await system.bindings()).find((entry) => entry.serverId === serverId); + return binding ? Object.keys(binding.varValues) : []; + }; + + it("prune-orphaned-var-values runs AGAIN when a new orphan appears for the same binding", async () => { + await system.dispatch(projectCreateCommand("proj-pr", "pc:pr")); + await system.dispatch(addCustomWithVarCommand("srv-pr", "psrv", "V", "add:pr")); + await system.dispatch(bindVarsCommand("proj-pr", "srv-pr", { V: "x" }, "bind:pr1")); + await system.dispatch(setServerVarsCommand("srv-pr", [], "rm-v")); // remove V ⇒ V is orphaned + + await system.prune(); // first prune drops the orphaned V + expect(await bindingVarKeys("srv-pr")).not.toContain("V"); + + // Now create a NEW orphan (W) on the SAME (project, server) binding. + await system.dispatch(setServerVarsCommand("srv-pr", ["W"], "add-w")); // declare W + await system.dispatch(bindVarsCommand("proj-pr", "srv-pr", { W: "y" }, "bind:pr2")); + await system.dispatch(setServerVarsCommand("srv-pr", [], "rm-w")); // remove W ⇒ W is orphaned + + await system.prune(); // second prune re-uses commandId server:mcp-prune-vars:proj-pr:srv-pr + // EXPECTED: W pruned. FAILS today — the stable commandId is short-circuited by the accepted + // receipt from the first prune, so the orphan W is stranded in the binding's varValues. + expect(await bindingVarKeys("srv-pr")).not.toContain("W"); + }); + + it("meta-backfill runs AGAIN after the description is cleared and re-probed", async () => { + const description = (serverId: string) => + system.catalog().then((catalog) => catalog.find((entry) => entry.id === serverId)?.description); + // A custom server "mb" → config args ["mb"]; compute its probe-cache key the same way the reactor does. + const configKey = configCacheKey({ transport: "stdio", command: "uvx", args: ["mb"] }, [], {}, [], {}); + await system.dispatch(addCustomCommand("srv-mb", "mb", "add:mb")); // custom add ⇒ description null + await system.probeUpsert({ + configKey, + transport: "stdio", + status: "online", + tools: [], + lastError: null, + serverDescription: "Probed description", + serverWebsiteUrl: null, + checkedAt: IsoDateTime.make("2026-06-07T10:00:00.000Z"), + checkedAtMs: 1_780_000_000_000, + }); + + await system.backfill(); // fills the empty description from the cached probe + expect(await description("srv-mb")).toBe("Probed description"); + + // User clears the description; the probe still reports one. + await system.dispatch({ + type: "mcp.server-update", + commandId: CommandId.make("clear-desc"), + serverId: McpServerId.make("srv-mb"), + patch: { description: null }, + }); + expect(await description("srv-mb")).toBeNull(); + + await system.backfill(); // second backfill re-uses commandId server:mcp-meta-backfill:srv-mb:description + // EXPECTED: re-filled. FAILS today — the stable commandId is short-circuited by the accepted + // receipt from the first backfill, so the description stays empty. + expect(await description("srv-mb")).toBe("Probed description"); + }); +}); + +describe("decider guards reject invalid commands", () => { + let system: ReturnType; + beforeEach(() => { + system = makeSystem(); + }); + afterEach(async () => { + await system.dispose(); + }); + + it("rejects adding a server with a duplicate id (no second row)", async () => { + await system.dispatch(addCustomCommand("dup", "foo", "add:1")); + await expect(system.dispatch(addCustomCommand("dup", "foo", "add:2"))).rejects.toThrow(); + expect((await system.catalog()).filter((entry) => entry.id === "dup")).toHaveLength(1); + }); + + it("rejects a config patch on a LOCKED built-in template (command unchanged)", async () => { + await system.dispatch(syncCommand(DEMO, "sync:demo:1")); // locked built-in + await expect( + system.dispatch(updateCustomConfigCommand(builtinServerId("demo"), ["evil"], "upd:evil")), + ).rejects.toThrow(); + const demo = (await system.catalog()).find((entry) => entry.builtinId === "demo"); + expect(demo?.config).toEqual({ transport: "stdio", command: "uvx", args: ["demo"] }); + }); + + it("rejects a binding-set for a missing project", async () => { + await system.dispatch(addCustomCommand("srv-mp", "mp", "add:mp")); + await expect(system.dispatch(bindCommand("ghost-project", "srv-mp", "bind:g1"))).rejects.toThrow(); + }); + + it("rejects a binding-set for a missing server", async () => { + await system.dispatch(projectCreateCommand("proj-ms", "pc:ms")); + await expect(system.dispatch(bindCommand("proj-ms", "ghost-server", "bind:g2"))).rejects.toThrow(); + }); + + it("rejects removing a server that does not exist", async () => { + await expect(system.dispatch(removeCommand("nope", "rm:nope"))).rejects.toThrow(); + }); +}); diff --git a/apps/server/tests/ru-fork/mcp/runtimeSnapshot.test.ts b/apps/server/tests/ru-fork/mcp/runtimeSnapshot.test.ts new file mode 100644 index 00000000000..9c259766587 --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/runtimeSnapshot.test.ts @@ -0,0 +1,129 @@ +// ru-fork: behavior test for McpRuntime.currentSnapshot — the UI runtime projection that joins the +// supervisor's live instances to the catalog + bindings. Populates catalog/bindings via the real +// engine, computes the desired instances (configKeys aligned), seeds a probe-cache row so the instance +// hydrates "online", reconciles the supervisor, then reads the runtime snapshot (via the stream head) +// and asserts the per-binding + per-catalog runtime status/tools the UI will show. + +import { + CommandId, + IsoDateTime, + McpServerId, + ProjectId, + type McpProbeRecord, + type OrchestrationCommand, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { OrchestrationCommandReceiptRepositoryLive } from "../../../src/persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../../../src/persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../../../src/persistence/Layers/Sqlite.ts"; +import { McpProbeCacheRepository } from "../../../src/persistence/Services/McpProbeCache.ts"; +import { RepositoryIdentityResolverLive } from "../../../src/project/Layers/RepositoryIdentityResolver.ts"; +import { OrchestrationEngineLive } from "../../../src/orchestration/Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "../../../src/orchestration/Layers/ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "../../../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; +import { OrchestrationEngineService } from "../../../src/orchestration/Services/OrchestrationEngine.ts"; +import { ServerConfig } from "../../../src/config.ts"; +import { ServerSettingsService } from "../../../src/serverSettings.ts"; +import { computeDesiredEffect } from "../../../src/ru-fork/mcp/McpReactor.ts"; +import { McpRuntime, McpRuntimeLive } from "../../../src/ru-fork/mcp/McpRuntime.ts"; +import { McpSupervisor, McpSupervisorLive, type DesiredInstance } from "../../../src/ru-fork/mcp/McpSupervisor.ts"; + +const ISO = "2026-01-01T00:00:00.000Z"; +const projectCreate = (p: string, c: string): OrchestrationCommand => ({ type: "project.create", commandId: CommandId.make(c), projectId: ProjectId.make(p), title: "P", workspaceRoot: `/w/${p}`, createdAt: ISO }); +const add = (s: string, c: string): OrchestrationCommand => ({ type: "mcp.server-add", commandId: CommandId.make(c), serverId: McpServerId.make(s), draft: { name: s, config: { transport: "stdio", command: "uvx", args: [s] }, vars: [], timeoutMs: null }, createdAt: ISO }); +const bind = (p: string, s: string, c: string): OrchestrationCommand => ({ type: "mcp.binding-set", commandId: CommandId.make(c), projectId: ProjectId.make(p), serverId: McpServerId.make(s), patch: { enabled: true } }); + +function makeSystem() { + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-runtime-test-" }); + const base = Layer.mergeAll( + OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(OrchestrationProjectionPipelineLive), + ), + OrchestrationProjectionPipelineLive, + ).pipe( + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolverLive), + Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerSettingsService.layerTest()), + ); + const withSupervisor = McpSupervisorLive.pipe(Layer.provideMerge(base)); + const runtime = ManagedRuntime.make(McpRuntimeLive.pipe(Layer.provideMerge(withSupervisor))); + return { + dispatch: (c: OrchestrationCommand) => + runtime.runPromise(Effect.flatMap(Effect.service(OrchestrationEngineService), (e) => e.dispatch(c))), + computeDesired: (): Promise> => runtime.runPromise(computeDesiredEffect), + reconcileSupervisor: (d: Map) => + runtime.runPromise(Effect.flatMap(Effect.service(McpSupervisor), (s) => s.reconcile(d))), + seed: (record: McpProbeRecord) => + runtime.runPromise(Effect.flatMap(Effect.service(McpProbeCacheRepository), (r) => r.upsert(record))), + snapshot: () => + runtime.runPromise( + Effect.flatMap(Effect.service(McpRuntime), (rt) => + Stream.runHead(rt.subscriptionStream).pipe(Effect.map((o) => Option.getOrThrow(o))), + ), + ), + dispose: () => runtime.dispose(), + }; +} + +describe("McpRuntime.currentSnapshot — UI runtime projection", () => { + let system: ReturnType; + beforeEach(() => { + system = makeSystem(); + }); + afterEach(async () => { + await system.dispose(); + }); + + it("joins a probed instance to its binding + catalog default (status + tools)", async () => { + await system.dispatch(projectCreate("p1", "pc1")); + await system.dispatch(add("s1", "add:s1")); + await system.dispatch(bind("p1", "s1", "bind:1")); + + const desired = await system.computeDesired(); + const configKey = [...desired.values()][0]!.configKey; + + // Seed a cached probe row BEFORE reconcile so the instance hydrates "online". + await system.seed({ + configKey, + transport: "stdio", + status: "online", + tools: [{ name: "do_thing", description: "Does a thing" }], + lastError: null, + serverDescription: null, + serverWebsiteUrl: null, + checkedAt: IsoDateTime.make("2026-06-07T10:00:00.000Z"), + checkedAtMs: 1_780_000_000_000, + }); + await system.reconcileSupervisor(desired); + + const snap = await system.snapshot(); + + // Per-catalog runtime: s1 online with the discovered tool. + const cat = snap.catalogRuntimes.find((c) => c.serverId === "s1"); + expect(cat?.status).toBe("online"); + expect(cat?.discoveredTools.map((t) => t.name)).toEqual(["do_thing"]); + + // Per-binding runtime: p1→s1 online (same deduped instance). + const rt = snap.runtimes.find((r) => r.projectId === "p1" && r.serverId === "s1"); + expect(rt?.status).toBe("online"); + expect(rt?.discoveredTools.map((t) => t.name)).toEqual(["do_thing"]); + }); + + it("shows no runtime for a server that was never probed/reconciled", async () => { + await system.dispatch(add("s-unp", "add:unp")); + const snap = await system.snapshot(); + expect(snap.catalogRuntimes.some((c) => c.serverId === "s-unp")).toBe(false); + }); +}); diff --git a/apps/server/tests/ru-fork/mcp/runtimeSnapshotResilience.test.ts b/apps/server/tests/ru-fork/mcp/runtimeSnapshotResilience.test.ts new file mode 100644 index 00000000000..88204c6933d --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/runtimeSnapshotResilience.test.ts @@ -0,0 +1,104 @@ +// ru-fork: McpRuntime.currentSnapshot swallows a read failure into an EMPTY snapshot +// (`McpRuntime.ts:104` — `Effect.catch(() => Effect.succeed({ runtimes: [], catalogRuntimes: [] }))`). +// Test A pins the intended resilience (a transient repo failure must not crash the UI stream → GREEN). +// Test B pins that the swallow must be OBSERVABLE: right now it is silent, so a debug log of the failure +// is absent ⇒ RED until a `logDebug` is added in that catch. + +import { PersistenceSqlError } from "../../../src/persistence/Errors.ts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import * as Option from "effect/Option"; +import * as References from "effect/References"; +import * as Stream from "effect/Stream"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { McpProbeCacheRepositoryLive } from "../../../src/persistence/Layers/ProjectionMcpProbeCache.ts"; +import { SqlitePersistenceMemory } from "../../../src/persistence/Layers/Sqlite.ts"; +import { + McpCatalogRepository, + type McpCatalogRepositoryShape, +} from "../../../src/persistence/Services/McpCatalog.ts"; +import { + McpBindingRepository, + type McpBindingRepositoryShape, +} from "../../../src/persistence/Services/McpBinding.ts"; +import { ServerSettingsService } from "../../../src/serverSettings.ts"; +import { McpRuntime, McpRuntimeLive } from "../../../src/ru-fork/mcp/McpRuntime.ts"; +import { McpSupervisorLive } from "../../../src/ru-fork/mcp/McpSupervisor.ts"; + +// catalogRepository.listAll fails with a real ProjectionRepositoryError ⇒ the snapshot build fails and +// hits the `Effect.catch` at McpRuntime.ts:104. +const failingCatalog = { + listAll: () => Effect.fail(new PersistenceSqlError({ operation: "listAll", detail: "injected failure" })), + upsert: () => Effect.die("upsert is unused in this test"), + remove: () => Effect.die("remove is unused in this test"), +} satisfies McpCatalogRepositoryShape; + +const emptyBinding = { + listAll: () => Effect.succeed([]), + upsert: () => Effect.die("upsert is unused in this test"), + remove: () => Effect.die("remove is unused in this test"), + listByProject: () => Effect.die("listByProject is unused in this test"), + removeByProject: () => Effect.die("removeByProject is unused in this test"), + removeByServer: () => Effect.die("removeByServer is unused in this test"), +} satisfies McpBindingRepositoryShape; + +function makeSystem() { + const captured: Array<{ readonly level: string; readonly message: string }> = []; + const logger = Logger.make(({ logLevel, message }) => { + captured.push({ level: String(logLevel), message: String(message) }); + }); + const probeCacheLayer = McpProbeCacheRepositoryLive.pipe(Layer.provide(SqlitePersistenceMemory)); + const supervisorLayer = McpSupervisorLive.pipe( + Layer.provide(probeCacheLayer), + Layer.provide(ServerSettingsService.layerTest()), + ); + const runtime = ManagedRuntime.make( + McpRuntimeLive.pipe( + Layer.provideMerge(supervisorLayer), + Layer.provideMerge(Layer.succeed(McpCatalogRepository, failingCatalog)), + Layer.provideMerge(Layer.succeed(McpBindingRepository, emptyBinding)), + Layer.provideMerge(Logger.layer([logger], { mergeWithExisting: false })), + Layer.provideMerge(Layer.succeed(References.MinimumLogLevel, "Debug")), // so logDebug is captured + ), + ); + return { + snapshotHead: () => + runtime.runPromise( + Effect.flatMap(Effect.service(McpRuntime), (rt) => + Stream.runHead(rt.subscriptionStream).pipe(Effect.map(Option.getOrThrow)), + ), + ), + captured, + dispose: () => runtime.dispose(), + }; +} + +describe("McpRuntime.currentSnapshot resilience (read failure → empty snapshot)", () => { + let system: ReturnType; + beforeEach(() => { + system = makeSystem(); + }); + afterEach(async () => { + await system.dispose(); + }); + + it("returns an empty snapshot (does not crash the UI stream) when a repo read fails", async () => { + const event = await system.snapshotHead(); + expect(event.type).toBe("snapshot"); + expect(event.runtimes).toEqual([]); + expect(event.catalogRuntimes).toEqual([]); + }); + + it("logs the swallowed snapshot failure at debug level", async () => { + await system.snapshotHead(); + // EXPECTED: the swallow is observable. Current code catches silently ⇒ RED until a logDebug is added. + const debugLog = system.captured.find( + (entry) => + entry.level.toUpperCase().includes("DEBUG") && entry.message.toLowerCase().includes("snapshot"), + ); + expect(debugLog).toBeDefined(); + }); +}); diff --git a/apps/server/tests/ru-fork/mcp/secretsKeep.test.ts b/apps/server/tests/ru-fork/mcp/secretsKeep.test.ts new file mode 100644 index 00000000000..ccf6a398440 --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/secretsKeep.test.ts @@ -0,0 +1,181 @@ +// ru-fork: unit tests for the secret keep-on-edit machinery — splitServerVars' `keepSecret` +// (catalog edit that leaves a masked secret blank must REUSE the stored ref, not wipe it) and +// splitBindingVarValues' `keepNames` (per-project masked secret left blank is preserved). A fake +// Map-backed ServerSecretStore stands in for the filesystem store. These encode items 13/14. + +import { McpServerId, ProjectId } from "@t3tools/contracts"; +import type { McpServerVar, McpServerVarDraft } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { describe, expect, it } from "vitest"; + +import { ServerSecretStore } from "../../../src/auth/Services/ServerSecretStore.ts"; +import { + collectVarSecretRefs, + materializeSecretValues, + splitBindingVarValues, + splitServerVars, +} from "../../../src/ru-fork/mcp/McpSecrets.ts"; + +const decoder = new TextDecoder(); + +/** A Map-backed ServerSecretStore; `store` is exposed so tests assert what was persisted. */ +function fakeSecretStore() { + const store = new Map(); + const layer = Layer.succeed(ServerSecretStore, { + get: (name: string) => Effect.succeed(store.get(name) ?? null), + set: (name: string, value: Uint8Array) => + Effect.sync(() => { + store.set(name, value); + }), + getOrCreateRandom: (name: string, bytes: number) => + Effect.sync(() => { + const existing = store.get(name); + if (existing) { + return existing; + } + const created = new Uint8Array(bytes); + store.set(name, created); + return created; + }), + remove: (name: string) => + Effect.sync(() => { + store.delete(name); + }), + pruneByPrefix: (prefix: string, keep: ReadonlySet) => + Effect.sync(() => { + for (const key of store.keys()) { + if (key.startsWith(prefix) && !keep.has(key)) { + store.delete(key); + } + } + }), + }); + return { store, layer }; +} + +const SERVER = McpServerId.make("srv-x"); + +describe("splitServerVars — keepSecret reuses the stored ref", () => { + it("keepSecret + a blank value reuses the existing secret ref (no overwrite)", async () => { + const { store, layer } = fakeSecretStore(); + const existing: ReadonlyArray = [ + { name: "TOKEN", secret: true, perProject: false, required: false, value: { secretRef: "mcp-var-srv-x-TOKEN" }, origin: "user" }, + ]; + store.set("mcp-var-srv-x-TOKEN", new TextEncoder().encode("kept-secret")); + const drafts: ReadonlyArray = [ + { name: "TOKEN", secret: true, perProject: false, required: false, value: null, keepSecret: true }, + ]; + const result = await Effect.runPromise( + splitServerVars(SERVER, drafts, existing).pipe(Effect.provide(layer)), + ); + expect(result[0]?.value).toEqual({ secretRef: "mcp-var-srv-x-TOKEN" }); + expect(result[0]?.origin).toBe("user"); + // The stored secret is untouched. + expect(decoder.decode(store.get("mcp-var-srv-x-TOKEN"))).toBe("kept-secret"); + }); + + it("a fresh secret value is stored under a per-server ref and the var holds that ref", async () => { + const { store, layer } = fakeSecretStore(); + const drafts: ReadonlyArray = [ + { name: "TOKEN", secret: true, perProject: false, required: false, value: "new-token" }, + ]; + const result = await Effect.runPromise( + splitServerVars(SERVER, drafts, []).pipe(Effect.provide(layer)), + ); + const value = result[0]?.value; + const ref = value !== null && typeof value === "object" ? value.secretRef : null; + expect(ref).not.toBeNull(); + expect(ref?.startsWith("mcp-var-")).toBe(true); + // The plaintext is persisted under exactly that ref name. + expect(decoder.decode(store.get(ref!))).toBe("new-token"); + }); + + it("keepSecret with no prior ref falls through to treating the value normally (cleared)", async () => { + const { layer } = fakeSecretStore(); + const drafts: ReadonlyArray = [ + { name: "TOKEN", secret: true, perProject: false, required: false, value: null, keepSecret: true }, + ]; + const result = await Effect.runPromise( + splitServerVars(SERVER, drafts, []).pipe(Effect.provide(layer)), + ); + expect(result[0]?.value).toBeNull(); + }); +}); + +describe("splitBindingVarValues — keepNames preserves untouched entries", () => { + const vars: ReadonlyArray = [ + { name: "TOKEN", secret: true, perProject: true, required: true, value: null, origin: "user" }, + { name: "ROOT", secret: false, perProject: true, required: false, value: null, origin: "user" }, + ]; + + it("keeps an untouched secret's existing ref while updating a plain value", async () => { + const { layer } = fakeSecretStore(); + const existing = { TOKEN: { secretRef: "mcp-var-srv-x-proj1-TOKEN" } as const }; + const result = await Effect.runPromise( + splitBindingVarValues({ + projectId: ProjectId.make("proj1"), + serverId: SERVER, + vars, + draftVarValues: { ROOT: "/work" }, + keepNames: ["TOKEN"], + existing, + }).pipe(Effect.provide(layer)), + ); + expect(result.TOKEN).toEqual({ secretRef: "mcp-var-srv-x-proj1-TOKEN" }); + expect(result.ROOT).toBe("/work"); + }); + + it("a kept name with no prior value is simply absent", async () => { + const { layer } = fakeSecretStore(); + const result = await Effect.runPromise( + splitBindingVarValues({ + projectId: ProjectId.make("proj1"), + serverId: SERVER, + vars, + draftVarValues: {}, + keepNames: ["TOKEN"], + existing: {}, + }).pipe(Effect.provide(layer)), + ); + expect("TOKEN" in result).toBe(false); + }); +}); + +describe("collectVarSecretRefs + materializeSecretValues", () => { + it("collects effective secret refs (binding override + catalog default; plain ignored)", () => { + const vars: McpServerVar[] = [ + { name: "A", secret: true, perProject: false, required: true, value: { secretRef: "ref-a" }, origin: "user" }, + { name: "B", secret: true, perProject: true, required: true, value: null, origin: "user" }, + { name: "C", secret: false, perProject: false, required: false, value: "plain", origin: "user" }, + ]; + // A from the catalog default; B filled per-project with a ref; C is plain ⇒ not collected. + const refs = collectVarSecretRefs(vars, { B: { secretRef: "ref-b" } }); + expect([...refs].toSorted()).toEqual(["ref-a", "ref-b"]); + }); + + it("materializes each ref to plaintext from the store (ref-name → value)", async () => { + const { store, layer } = fakeSecretStore(); + store.set("ref-a", new TextEncoder().encode("alpha")); + store.set("ref-b", new TextEncoder().encode("beta")); + const vars: McpServerVar[] = [ + { name: "A", secret: true, perProject: false, required: true, value: { secretRef: "ref-a" }, origin: "user" }, + { name: "B", secret: true, perProject: true, required: true, value: null, origin: "user" }, + ]; + const result = await Effect.runPromise( + materializeSecretValues(vars, { B: { secretRef: "ref-b" } }).pipe(Effect.provide(layer)), + ); + expect(result).toEqual({ "ref-a": "alpha", "ref-b": "beta" }); + }); + + it("a missing ref materializes to empty string (no crash)", async () => { + const { layer } = fakeSecretStore(); + const vars: McpServerVar[] = [ + { name: "A", secret: true, perProject: false, required: true, value: { secretRef: "gone" }, origin: "user" }, + ]; + const result = await Effect.runPromise( + materializeSecretValues(vars, {}).pipe(Effect.provide(layer)), + ); + expect(result).toEqual({ gone: "" }); + }); +}); diff --git a/apps/server/tests/ru-fork/mcp/supervisorDecisions.test.ts b/apps/server/tests/ru-fork/mcp/supervisorDecisions.test.ts new file mode 100644 index 00000000000..b361ec22dce --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/supervisorDecisions.test.ts @@ -0,0 +1,166 @@ +// ru-fork: unit tests for the supervisor's pure probe-decision logic — the rules +// that decide WHICH instances get probed (watch scope, mandatory-first probe, +// per-transport due intervals) and which a manual recheck targets. These encode +// the monitoring redesign's contract, so they are tested in isolation from the +// (network-bound) probe itself. + +import { + instanceInWatched, + instanceMatchesRecheck, + isProbeDue, + isSweepDue, + nextStatus, + type SupervisorInstance, +} from "../../../src/ru-fork/mcp/McpSupervisor.ts"; +import type { ProbeResult } from "@ru-fork/mcp-core"; +import { describe, expect, it } from "vitest"; + +const NOW = 10_000_000; +const MIN = 60_000; + +function makeInstance(overrides: { + refs: ReadonlyArray; + transport?: "stdio" | "http"; + checkedAtMs?: number | null; +}): SupervisorInstance { + const transport = overrides.transport ?? "stdio"; + return { + hash: "hash", + configKey: "configKey", + resolved: + transport === "stdio" + ? { transport: "stdio", command: "npx", args: [], env: {}, cwd: "/probe" } + : { transport: "http", httpUrl: "https://x", headers: {} }, + refs: new Set(overrides.refs), + status: "online", + message: null, + latencyMs: null, + checkedAt: null, + // Honor an explicit `null` (never probed); only default when omitted. + checkedAtMs: "checkedAtMs" in overrides ? overrides.checkedAtMs ?? null : NOW - MIN, + discoveredTools: [], + consecutiveFailures: 0, + }; +} + +describe("instanceInWatched", () => { + it("matches a project ref whose project is watched", () => { + const instance = makeInstance({ refs: ["proj1:srv"] }); + expect(instanceInWatched(instance, new Set(["proj1"]))).toBe(true); + expect(instanceInWatched(instance, new Set(["proj2"]))).toBe(false); + }); + + it("never treats a catalog-only ref as belonging to a watched project", () => { + // Watch sets are real project ids, never the synthetic "catalog" scope. + const instance = makeInstance({ refs: ["catalog:srv"] }); + expect(instanceInWatched(instance, new Set(["proj1", "proj2"]))).toBe(false); + }); + + it("matches when ANY ref's project is watched (shared instance)", () => { + const instance = makeInstance({ refs: ["catalog:srv", "proj1:srv", "proj2:srv"] }); + expect(instanceInWatched(instance, new Set(["proj2"]))).toBe(true); + }); +}); + +describe("isProbeDue", () => { + it("is NOT auto-due when never probed (no probing on load — manual/change only)", () => { + const instance = makeInstance({ refs: ["proj1:srv"], checkedAtMs: null }); + expect(isProbeDue(instance, NOW, 5 * MIN, 10 * MIN)).toBe(false); + }); + + it("never auto-reprobes when the transport interval is 0", () => { + const instance = makeInstance({ refs: ["proj1:srv"], checkedAtMs: NOW - 999 * MIN }); + expect(isProbeDue(instance, NOW, 0, 0)).toBe(false); + }); + + it("uses the LOCAL interval for stdio", () => { + const instance = makeInstance({ refs: ["proj1:srv"], transport: "stdio", checkedAtMs: NOW - 6 * MIN }); + expect(isProbeDue(instance, NOW, 5 * MIN, 30 * MIN)).toBe(true); + expect(isProbeDue(instance, NOW, 30 * MIN, 5 * MIN)).toBe(false); + }); + + it("uses the REMOTE interval for http", () => { + const instance = makeInstance({ refs: ["proj1:srv"], transport: "http", checkedAtMs: NOW - 6 * MIN }); + expect(isProbeDue(instance, NOW, 30 * MIN, 5 * MIN)).toBe(true); + expect(isProbeDue(instance, NOW, 5 * MIN, 30 * MIN)).toBe(false); + }); +}); + +describe("isSweepDue", () => { + it("never auto-probes a never-checked instance (no probing on load)", () => { + const instance = makeInstance({ refs: ["catalog:srv"], checkedAtMs: null }); + expect(isSweepDue(instance, new Set(["other"]), NOW, 5 * MIN, 10 * MIN)).toBe(false); + expect(isSweepDue(instance, null, NOW, 5 * MIN, 10 * MIN)).toBe(false); + }); + + it("skips an already-checked instance outside the watch scope", () => { + const instance = makeInstance({ refs: ["proj1:srv"], checkedAtMs: NOW - 999 * MIN }); + expect(isSweepDue(instance, new Set(["proj2"]), NOW, 5 * MIN, 10 * MIN)).toBe(false); + }); + + it("reprobes a watched, due instance", () => { + const instance = makeInstance({ refs: ["proj1:srv"], checkedAtMs: NOW - 6 * MIN }); + expect(isSweepDue(instance, new Set(["proj1"]), NOW, 5 * MIN, 10 * MIN)).toBe(true); + }); + + it("falls back to plain due-gating when nothing is watched yet (null)", () => { + const fresh = makeInstance({ refs: ["proj1:srv"], checkedAtMs: NOW - 1 * MIN }); + expect(isSweepDue(fresh, null, NOW, 5 * MIN, 10 * MIN)).toBe(false); + const stale = makeInstance({ refs: ["proj1:srv"], checkedAtMs: NOW - 6 * MIN }); + expect(isSweepDue(stale, null, NOW, 5 * MIN, 10 * MIN)).toBe(true); + }); +}); + +describe("instanceMatchesRecheck", () => { + const shared = makeInstance({ refs: ["catalog:fs", "proj1:fs", "proj2:fs"], transport: "stdio" }); + + it("matches a catalog recheck (serverId only) via the catalog ref", () => { + expect(instanceMatchesRecheck(shared, { serverId: "fs" })).toBe(true); + expect(instanceMatchesRecheck(shared, { serverId: "other" })).toBe(false); + }); + + it("matches a project recheck (projectId only)", () => { + expect(instanceMatchesRecheck(shared, { projectId: "proj1" })).toBe(true); + expect(instanceMatchesRecheck(shared, { projectId: "proj3" })).toBe(false); + }); + + it("matches a single binding (projectId + serverId)", () => { + expect(instanceMatchesRecheck(shared, { projectId: "proj2", serverId: "fs" })).toBe(true); + expect(instanceMatchesRecheck(shared, { projectId: "proj2", serverId: "nope" })).toBe(false); + }); + + it("filters by transport", () => { + expect(instanceMatchesRecheck(shared, { transport: "stdio" })).toBe(true); + expect(instanceMatchesRecheck(shared, { transport: "http" })).toBe(false); + }); + + it("an empty filter matches everything", () => { + expect(instanceMatchesRecheck(shared, {})).toBe(true); + }); +}); + +describe("nextStatus", () => { + const online: ProbeResult = { status: "online", tools: [], latencyMs: 1 }; + const hardOffline: ProbeResult = { status: "offline", tools: [], latencyMs: 1, message: "ENOENT" }; + const timeoutOffline: ProbeResult = { + status: "offline", + tools: [], + latencyMs: 1, + message: "timeout", + timedOut: true, + }; + + it("online resets to online + zero failures", () => { + expect(nextStatus(online, 2)).toEqual({ status: "online", consecutiveFailures: 0 }); + }); + + it("a HARD failure is offline (red) immediately — no degraded buffer", () => { + expect(nextStatus(hardOffline, 0)).toEqual({ status: "offline", consecutiveFailures: 1 }); + }); + + it("a TIMEOUT stays degraded (amber) until the 3rd consecutive, then offline", () => { + expect(nextStatus(timeoutOffline, 0)).toEqual({ status: "degraded", consecutiveFailures: 1 }); + expect(nextStatus(timeoutOffline, 1)).toEqual({ status: "degraded", consecutiveFailures: 2 }); + expect(nextStatus(timeoutOffline, 2)).toEqual({ status: "offline", consecutiveFailures: 3 }); + }); +}); diff --git a/apps/server/tests/ru-fork/mcp/supervisorProbe.test.ts b/apps/server/tests/ru-fork/mcp/supervisorProbe.test.ts new file mode 100644 index 00000000000..41662bb81db --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/supervisorProbe.test.ts @@ -0,0 +1,244 @@ +// ru-fork: behavior tests for the McpSupervisor PROBE EXECUTION loop — the live machinery that +// supervisorReconcile/supervisorDecisions don't touch. We drive REAL child-process probes: +// - the fake stdio MCP server (online: connect → listTools → close, write-through to the cache), +// - a missing binary (hard offline), +// - `sleep` (a slow probe held in-flight, to exercise coalescing + the reconciled-away race). +// Covers runProbe / applyProbeResult / probeInstance (claim/release bracket) / probeHashes / recheck. + +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { IsoDateTime, type McpProbeRecord } from "@t3tools/contracts"; +import type { ResolvedServerConfig } from "@ru-fork/mcp-core"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { McpProbeCacheRepositoryLive } from "../../../src/persistence/Layers/ProjectionMcpProbeCache.ts"; +import { McpProbeCacheRepository } from "../../../src/persistence/Services/McpProbeCache.ts"; +import { SqlitePersistenceMemory } from "../../../src/persistence/Layers/Sqlite.ts"; +import { ServerSettingsService } from "../../../src/serverSettings.ts"; +import { + McpSupervisor, + McpSupervisorLive, + type DesiredInstance, + type McpSupervisorShape, +} from "../../../src/ru-fork/mcp/McpSupervisor.ts"; + +// packages/mcp-core/test-fixtures/fakeMcpStdioServer.mjs (real stdio MCP server, advertises echo+ping). +const FAKE_SERVER = path.resolve( + fileURLToPath(import.meta.url), + "../../../../../../packages/mcp-core/test-fixtures/fakeMcpStdioServer.mjs", +); + +const onlineConfig: ResolvedServerConfig = { + transport: "stdio", + command: "node", + args: [FAKE_SERVER], + env: {}, + cwd: process.cwd(), + timeoutMs: 10_000, +}; + +const offlineConfig: ResolvedServerConfig = { + transport: "stdio", + command: "this-binary-does-not-exist-9z3a", + args: [], + env: {}, + cwd: process.cwd(), + timeoutMs: 3_000, +}; + +// `sleep` never answers the MCP handshake — the probe stays in flight until its (short) timeout. +const slowConfig: ResolvedServerConfig = { + transport: "stdio", + command: "sleep", + args: ["30"], + env: {}, + cwd: process.cwd(), + timeoutMs: 1_500, +}; + +const desired = ( + hash: string, + configKey: string, + resolved: ResolvedServerConfig, + refs: ReadonlyArray, +): Map => + new Map([[hash, { hash, configKey, resolved, refs: new Set(refs) }]]); + +function makeSystem() { + const probeCacheLayer = McpProbeCacheRepositoryLive.pipe(Layer.provide(SqlitePersistenceMemory)); + const supervisorLayer = McpSupervisorLive.pipe( + Layer.provide(probeCacheLayer), + Layer.provide(ServerSettingsService.layerTest()), + ); + const runtime = ManagedRuntime.make(Layer.mergeAll(supervisorLayer, probeCacheLayer)); + return { + // Run an arbitrary effect with the supervisor in scope (for multi-step concurrency tests). + withSupervisor: (body: (supervisor: McpSupervisorShape) => Effect.Effect): Promise => + runtime.runPromise(Effect.flatMap(Effect.service(McpSupervisor), body)), + instances: () => + runtime.runPromise(Effect.flatMap(Effect.service(McpSupervisor), (s) => s.currentInstances)), + cacheRow: (configKey: string) => + runtime.runPromise( + Effect.flatMap(Effect.service(McpProbeCacheRepository), (r) => r.getByKey({ configKey })), + ), + seed: (record: McpProbeRecord) => + runtime.runPromise(Effect.flatMap(Effect.service(McpProbeCacheRepository), (r) => r.upsert(record))), + dispose: () => runtime.dispose(), + }; +} + +describe("McpSupervisor probe execution loop", () => { + let system: ReturnType; + beforeEach(() => { + system = makeSystem(); + }); + afterEach(async () => { + await system.dispose(); + }); + + it("probeHashes runs a REAL online probe: registry → online with tools, latency, checkedAt", async () => { + await system.withSupervisor((s) => + Effect.gen(function* () { + yield* s.reconcile(desired("h-on", "ck-on", onlineConfig, ["catalog:s"])); + yield* s.probeHashes(["h-on"]); + }), + ); + const inst = (await system.instances()).find((i) => i.hash === "h-on"); + expect(inst?.status).toBe("online"); + expect(inst?.discoveredTools.map((t) => t.name).toSorted()).toEqual(["echo", "ping"]); + expect(typeof inst?.latencyMs).toBe("number"); + expect(inst?.checkedAt).not.toBeNull(); + expect(inst?.checkedAtMs).not.toBeNull(); + expect(inst?.consecutiveFailures).toBe(0); + }, 20_000); + + it("write-through: a successful probe upserts the config-keyed cache row", async () => { + await system.withSupervisor((s) => + Effect.gen(function* () { + yield* s.reconcile(desired("h-wt", "ck-wt", onlineConfig, ["catalog:s"])); + yield* s.probeHashes(["h-wt"]); + }), + ); + const row = await system.cacheRow("ck-wt"); + expect(row._tag).toBe("Some"); + if (row._tag === "Some") { + expect(row.value.status).toBe("online"); + expect(row.value.tools.map((t) => t.name).toSorted()).toEqual(["echo", "ping"]); + expect(row.value.transport).toBe("stdio"); + } + }, 20_000); + + it("a hard failure (missing binary) → offline, consecutiveFailures = 1, no tools", async () => { + await system.withSupervisor((s) => + Effect.gen(function* () { + yield* s.reconcile(desired("h-off", "ck-off", offlineConfig, ["catalog:s"])); + yield* s.probeHashes(["h-off"]); + }), + ); + const inst = (await system.instances()).find((i) => i.hash === "h-off"); + expect(inst?.status).toBe("offline"); + expect(inst?.consecutiveFailures).toBe(1); + expect(inst?.discoveredTools).toEqual([]); + expect(inst?.message).not.toBeNull(); + }, 20_000); + + it("consecutiveFailures accumulates across repeated hard-offline probes (1 → 2 → 3)", async () => { + const failuresAfterEachProbe: Array = []; + await system.withSupervisor((s) => + Effect.gen(function* () { + yield* s.reconcile(desired("h-acc", "ck-acc", offlineConfig, ["catalog:s"])); + for (let probe = 0; probe < 3; probe++) { + yield* s.probeHashes(["h-acc"]); + const inst = (yield* s.currentInstances).find((i) => i.hash === "h-acc"); + failuresAfterEachProbe.push(inst?.consecutiveFailures ?? -1); + } + }), + ); + expect(failuresAfterEachProbe).toEqual([1, 2, 3]); + const inst = (await system.instances()).find((i) => i.hash === "h-acc"); + expect(inst?.status).toBe("offline"); // a hard (non-timeout) failure is offline at every step + }, 30_000); + + it("coalesces concurrent probes of the SAME config — one in-flight slot, the 2nd is a no-op", async () => { + await system.withSupervisor((s) => + Effect.gen(function* () { + yield* s.reconcile(desired("h-co", "ck-co", slowConfig, ["catalog:s"])); + // Start a slow probe and let it claim the in-flight slot. + const probing = yield* Effect.forkChild(s.probeHashes(["h-co"])); + yield* Effect.sleep("200 millis"); + expect([...(yield* s.currentInFlight)]).toEqual(["h-co"]); + // A second probe of the same hash must coalesce: it returns ~immediately and adds no slot. + yield* s.probeHashes(["h-co"]); + expect((yield* s.currentInFlight).size).toBe(1); + yield* Fiber.await(probing); // let the slow probe settle (timeout) before teardown + expect((yield* s.currentInFlight).size).toBe(0); + }), + ); + }, 20_000); + + it("an instance reconciled away mid-probe is NOT resurrected by the late probe result", async () => { + await system.withSupervisor((s) => + Effect.gen(function* () { + yield* s.reconcile(desired("h-race", "ck-race", slowConfig, ["catalog:s"])); + const probing = yield* Effect.forkChild(s.probeHashes(["h-race"])); + yield* Effect.sleep("200 millis"); + // Drop the instance while its probe is still running. + yield* s.reconcile(new Map()); + yield* Fiber.await(probing); // applyProbeResult sees no `latest` → returns registry unchanged + expect(yield* s.currentInstances).toEqual([]); + }), + ); + }, 20_000); + + it("recheck bypasses the due-gate: it probes a NEVER-probed instance", async () => { + await system.withSupervisor((s) => + Effect.gen(function* () { + yield* s.reconcile(desired("h-rc", "ck-rc", onlineConfig, ["p1:s"])); + // Never probed ⇒ the sweep would skip it; recheck forces it anyway. + yield* s.recheck({ projectId: "p1" }); + }), + ); + const inst = (await system.instances()).find((i) => i.hash === "h-rc"); + expect(inst?.status).toBe("online"); + }, 20_000); + + it("probeHashes is a no-op for an empty list and for unknown hashes", async () => { + await system.withSupervisor((s) => + Effect.gen(function* () { + yield* s.reconcile(desired("h-known", "ck-known", onlineConfig, ["catalog:s"])); + yield* s.probeHashes([]); // empty + yield* s.probeHashes(["does-not-exist"]); // unmatched + }), + ); + const inst = (await system.instances()).find((i) => i.hash === "h-known"); + expect(inst?.status).toBe("unchecked"); // never actually probed + }); + + it("seeds status from the cache on reconcile, then a probe overwrites it with the live result", async () => { + await system.seed({ + configKey: "ck-seed", + transport: "stdio", + status: "offline", + tools: [], + lastError: "stale", + serverDescription: null, + serverWebsiteUrl: null, + checkedAt: IsoDateTime.make("2026-01-01T00:00:00.000Z"), + checkedAtMs: 1_000_000, + }); + await system.withSupervisor((s) => s.reconcile(desired("h-seed", "ck-seed", onlineConfig, ["catalog:s"]))); + const seeded = (await system.instances()).find((i) => i.hash === "h-seed"); + expect(seeded?.status).toBe("offline"); // hydrated from the stale cache row + expect(seeded?.checkedAtMs).toBe(1_000_000); + + await system.withSupervisor((s) => s.probeHashes(["h-seed"])); + const probed = (await system.instances()).find((i) => i.hash === "h-seed"); + expect(probed?.status).toBe("online"); // live probe replaced the stale status + expect(probed?.checkedAtMs).toBeGreaterThan(1_000_000); + }, 20_000); +}); diff --git a/apps/server/tests/ru-fork/mcp/supervisorReconcile.test.ts b/apps/server/tests/ru-fork/mcp/supervisorReconcile.test.ts new file mode 100644 index 00000000000..708b4848d70 --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/supervisorReconcile.test.ts @@ -0,0 +1,96 @@ +// ru-fork: behavior tests for McpSupervisor.reconcile — the in-memory instance registry sync. A +// brand-new desired instance registers as `unchecked`; one whose configKey has a cached probe seeds +// its status from the cache; a re-reconcile preserves an existing instance's status while updating its +// ref set; and reconcile reports which hashes are newly registered. + +import { IsoDateTime, type McpProbeRecord } from "@t3tools/contracts"; +import type { ResolvedServerConfig } from "@ru-fork/mcp-core"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { McpProbeCacheRepositoryLive } from "../../../src/persistence/Layers/ProjectionMcpProbeCache.ts"; +import { McpProbeCacheRepository } from "../../../src/persistence/Services/McpProbeCache.ts"; +import { SqlitePersistenceMemory } from "../../../src/persistence/Layers/Sqlite.ts"; +import { ServerSettingsService } from "../../../src/serverSettings.ts"; +import { McpSupervisor, McpSupervisorLive, type DesiredInstance } from "../../../src/ru-fork/mcp/McpSupervisor.ts"; + +const resolved: ResolvedServerConfig = { + transport: "stdio", + command: "uvx", + args: ["demo"], + env: {}, + cwd: "/probe", +}; + +const desired = (refs: ReadonlyArray): Map => + new Map([["h1", { hash: "h1", configKey: "ck1", resolved, refs: new Set(refs) }]]); + +function makeSystem() { + const probeCacheLayer = McpProbeCacheRepositoryLive.pipe(Layer.provide(SqlitePersistenceMemory)); + const supervisorLayer = McpSupervisorLive.pipe( + Layer.provide(probeCacheLayer), + Layer.provide(ServerSettingsService.layerTest()), + ); + const runtime = ManagedRuntime.make(Layer.mergeAll(supervisorLayer, probeCacheLayer)); + return { + reconcile: (d: Map) => + runtime.runPromise(Effect.flatMap(Effect.service(McpSupervisor), (s) => s.reconcile(d))), + instances: () => + runtime.runPromise(Effect.flatMap(Effect.service(McpSupervisor), (s) => s.currentInstances)), + seed: (record: McpProbeRecord) => + runtime.runPromise(Effect.flatMap(Effect.service(McpProbeCacheRepository), (r) => r.upsert(record))), + dispose: () => runtime.dispose(), + }; +} + +describe("McpSupervisor.reconcile — registry sync", () => { + let system: ReturnType; + beforeEach(() => { + system = makeSystem(); + }); + afterEach(async () => { + await system.dispose(); + }); + + it("registers a brand-new desired instance as unchecked + reports it as newly added", async () => { + const added = await system.reconcile(desired(["catalog:s"])); + expect([...added]).toContain("h1"); + const inst = (await system.instances()).find((i) => i.hash === "h1"); + expect(inst?.status).toBe("unchecked"); + expect(inst?.latencyMs).toBeNull(); + expect([...(inst?.refs ?? [])]).toEqual(["catalog:s"]); + }); + + it("seeds status from a cached probe row matching the configKey", async () => { + await system.seed({ + configKey: "ck1", + transport: "stdio", + status: "online", + tools: [{ name: "echo", description: "Echo" }], + lastError: null, + serverDescription: null, + serverWebsiteUrl: null, + checkedAt: IsoDateTime.make("2026-06-07T10:00:00.000Z"), + checkedAtMs: 1_780_000_000_000, + }); + await system.reconcile(desired(["catalog:s"])); + const inst = (await system.instances()).find((i) => i.hash === "h1"); + expect(inst?.status).toBe("online"); + expect(inst?.discoveredTools.map((t) => t.name)).toEqual(["echo"]); + }); + + it("a re-reconcile updates the ref set and reports the instance as newly-REFERENCED (F1)", async () => { + await system.reconcile(desired(["catalog:s"])); + const added2 = await system.reconcile(desired(["catalog:s", "p1:s"])); + // F1: reconcile returns brand-new OR newly-referenced hashes, so a freshly-bound server is probed. + expect([...added2]).toContain("h1"); + const inst = (await system.instances()).find((i) => i.hash === "h1"); + expect([...(inst?.refs ?? [])].toSorted()).toEqual(["catalog:s", "p1:s"]); + + // A reconcile with NO new ref does not re-report it. + const added3 = await system.reconcile(desired(["catalog:s", "p1:s"])); + expect([...added3]).not.toContain("h1"); + }); +}); diff --git a/apps/server/tests/ru-fork/mcp/supervisorSweep.test.ts b/apps/server/tests/ru-fork/mcp/supervisorSweep.test.ts new file mode 100644 index 00000000000..f987ad88872 --- /dev/null +++ b/apps/server/tests/ru-fork/mcp/supervisorSweep.test.ts @@ -0,0 +1,141 @@ +// ru-fork: behavior tests for the McpSupervisor SWEEP loop orchestration (`runSweep`, exposed for tests +// as `sweepOnce`). The periodic 60s loop only ever runs this body; here we run one tick deterministically +// and assert WHICH instances it re-probes: already-probed + due + watched, and nobody else. This covers +// the live monitoring scheduler that supervisorDecisions (pure isSweepDue) and supervisorProbe don't. + +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { IsoDateTime, type McpProbeRecord, type ServerSettings } from "@t3tools/contracts"; +import type { ResolvedServerConfig } from "@ru-fork/mcp-core"; +import type { DeepPartial } from "@t3tools/shared/Struct"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { McpProbeCacheRepositoryLive } from "../../../src/persistence/Layers/ProjectionMcpProbeCache.ts"; +import { McpProbeCacheRepository } from "../../../src/persistence/Services/McpProbeCache.ts"; +import { SqlitePersistenceMemory } from "../../../src/persistence/Layers/Sqlite.ts"; +import { ServerSettingsService } from "../../../src/serverSettings.ts"; +import { + McpSupervisor, + McpSupervisorLive, + type DesiredInstance, + type McpSupervisorShape, +} from "../../../src/ru-fork/mcp/McpSupervisor.ts"; + +const FAKE_SERVER = path.resolve( + fileURLToPath(import.meta.url), + "../../../../../../packages/mcp-core/test-fixtures/fakeMcpStdioServer.mjs", +); + +const onlineConfig: ResolvedServerConfig = { + transport: "stdio", + command: "node", + args: [FAKE_SERVER], + env: {}, + cwd: process.cwd(), + timeoutMs: 10_000, +}; + +const PAST_MS = 1_000_000; // far enough in the past that any non-zero interval has elapsed + +const desired = ( + hash: string, + configKey: string, + refs: ReadonlyArray, +): Map => + new Map([[hash, { hash, configKey, resolved: onlineConfig, refs: new Set(refs) }]]); + +// A cache row that hydrates an instance as already-probed (online) at PAST_MS, so the sweep treats it +// as due once its transport interval has elapsed. +const probedLongAgo = (configKey: string): McpProbeRecord => ({ + configKey, + transport: "stdio", + status: "online", + tools: [{ name: "echo", description: "Echo" }], + lastError: null, + serverDescription: null, + serverWebsiteUrl: null, + checkedAt: IsoDateTime.make("2026-01-01T00:00:00.000Z"), + checkedAtMs: PAST_MS, +}); + +function makeSystem(settings: DeepPartial = {}) { + const probeCacheLayer = McpProbeCacheRepositoryLive.pipe(Layer.provide(SqlitePersistenceMemory)); + const supervisorLayer = McpSupervisorLive.pipe( + Layer.provide(probeCacheLayer), + Layer.provide(ServerSettingsService.layerTest(settings)), + ); + const runtime = ManagedRuntime.make(Layer.mergeAll(supervisorLayer, probeCacheLayer)); + return { + withSupervisor: (body: (supervisor: McpSupervisorShape) => Effect.Effect): Promise => + runtime.runPromise(Effect.flatMap(Effect.service(McpSupervisor), body)), + instances: () => + runtime.runPromise(Effect.flatMap(Effect.service(McpSupervisor), (s) => s.currentInstances)), + seed: (record: McpProbeRecord) => + runtime.runPromise(Effect.flatMap(Effect.service(McpProbeCacheRepository), (r) => r.upsert(record))), + dispose: () => runtime.dispose(), + }; +} + +describe("McpSupervisor sweep loop orchestration (sweepOnce)", () => { + let system: ReturnType; + afterEach(async () => { + await system.dispose(); + }); + + it("re-probes a watched, already-probed instance whose interval has elapsed", async () => { + system = makeSystem(); // default intervals = 30 min, watched = null (all) + await system.seed(probedLongAgo("ck-due")); + await system.withSupervisor((s) => s.reconcile(desired("h-due", "ck-due", ["catalog:s"]))); + const before = (await system.instances()).find((i) => i.hash === "h-due"); + expect(before?.checkedAtMs).toBe(PAST_MS); // hydrated as probed-long-ago + + await system.withSupervisor((s) => s.sweepOnce); + const after = (await system.instances()).find((i) => i.hash === "h-due"); + expect(after?.status).toBe("online"); + expect(after?.checkedAtMs).toBeGreaterThan(PAST_MS); // the sweep re-probed it (clock advanced) + }, 20_000); + + it("never re-probes a never-probed instance (no probing on load)", async () => { + system = makeSystem(); + // No cache seed ⇒ checkedAtMs null ⇒ unchecked. + await system.withSupervisor((s) => s.reconcile(desired("h-new", "ck-new", ["catalog:s"]))); + await system.withSupervisor((s) => s.sweepOnce); + const inst = (await system.instances()).find((i) => i.hash === "h-new"); + expect(inst?.status).toBe("unchecked"); + expect(inst?.checkedAtMs).toBeNull(); + }); + + it("does nothing when both recheck intervals are 0 (loop off)", async () => { + system = makeSystem({ mcp: { recheckLocalMinutes: 0, recheckRemoteMinutes: 0 } }); + await system.seed(probedLongAgo("ck-off")); + await system.withSupervisor((s) => s.reconcile(desired("h-off", "ck-off", ["catalog:s"]))); + await system.withSupervisor((s) => s.sweepOnce); + const inst = (await system.instances()).find((i) => i.hash === "h-off"); + expect(inst?.checkedAtMs).toBe(PAST_MS); // untouched — the sweep short-circuited + }); + + it("is a no-op on an empty registry", async () => { + system = makeSystem(); + await system.withSupervisor((s) => s.sweepOnce); + expect(await system.instances()).toEqual([]); + }); + + it("skips an instance whose project is not in the watched set", async () => { + system = makeSystem(); + await system.seed(probedLongAgo("ck-unwatched")); + await system.withSupervisor((s) => + Effect.gen(function* () { + yield* s.reconcile(desired("h-unwatched", "ck-unwatched", ["p1:s"])); + yield* s.setWatchedProjects(["some-other-project"]); // p1 is NOT watched + yield* s.sweepOnce; + }), + ); + const inst = (await system.instances()).find((i) => i.hash === "h-unwatched"); + expect(inst?.checkedAtMs).toBe(PAST_MS); // due, but out of the watched scope ⇒ not re-probed + }); +}); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 839fb3b0e3a..43fa924aa94 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -29,6 +29,7 @@ import { TraitsPicker } from "../chat/TraitsPicker"; import { isElectron } from "../../env"; import { DEFAULT_THEME_NAME, THEME_NAMES, THEME_NAME_LABELS, useTheme } from "../../hooks/useTheme"; import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; +import { useServerSettings } from "../../rpc/serverState"; import { useThreadActions } from "../../hooks/useThreadActions"; import { setDesktopUpdateStateQueryData, @@ -49,6 +50,7 @@ import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; +import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; @@ -385,6 +387,17 @@ export function GeneralSettingsPanel() { const settings = useSettings(); const { updateSettings } = useUpdateSettings(); const serverProviders = useServerProviders(); + // ru-fork: MCP auto-recheck intervals (minutes; 0 = only the first probe). + const mcpSettings = useServerSettings().mcp; + const setMcpRecheckMinutes = ( + field: "recheckLocalMinutes" | "recheckRemoteMinutes", + raw: string, + ) => { + const minutes = Number(raw); + if (Number.isFinite(minutes) && minutes >= 0) { + updateSettings({ mcp: { ...mcpSettings, [field]: Math.round(minutes) } }); + } + }; const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); const textGenInstanceId = textGenerationModelSelection.instanceId; @@ -411,6 +424,37 @@ export function GeneralSettingsPanel() { return ( + + setMcpRecheckMinutes("recheckLocalMinutes", event.target.value)} + /> + } + /> + setMcpRecheckMinutes("recheckRemoteMinutes", event.target.value)} + /> + } + /> + + resolveThreadRouteTarget(params), + }); + const routeThreadRef = routeTarget?.kind === "server" ? routeTarget.threadRef : null; + const draftId = routeTarget?.kind === "draft" ? routeTarget.draftId : null; + + const activeThread = useStore( + useMemo(() => createThreadSelectorByRef(routeThreadRef), [routeThreadRef]), + ); + // Select PRIMITIVES from the draft store — returning a fresh object here would + // never be Object.is-equal to the previous render and would loop forever. + const draftEnvironmentId = useComposerDraftStore((store) => + draftId ? store.getDraftSession(draftId)?.environmentId ?? null : null, + ); + const draftProjectId = useComposerDraftStore((store) => + draftId ? store.getDraftSession(draftId)?.projectId ?? null : null, + ); + + if (activeThread) { + return { environmentId: activeThread.environmentId, projectId: activeThread.projectId }; + } + if (draftEnvironmentId !== null && draftProjectId !== null) { + return { environmentId: draftEnvironmentId, projectId: draftProjectId }; + } + return null; +} + +/** The active project's id (draft or thread), or null. */ +export function useActiveProjectRef(): ProjectId | null { + return useActiveProjectScopedRef()?.projectId ?? null; +} + +/** The full active project — including its `cwd` (the working directory) — or null. */ +export function useActiveProject(): Project | null { + const ref = useActiveProjectScopedRef(); + const environmentId = ref?.environmentId ?? null; + const projectId = ref?.projectId ?? null; + const project = useStore( + useMemo( + () => (state: AppState) => + environmentId && projectId + ? selectProjectByRef(state, { environmentId, projectId }) + : undefined, + [environmentId, projectId], + ), + ); + return project ?? null; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 7a8d304d847..f231f678314 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -41,6 +41,7 @@ import { useServerConfigUpdatedSubscription, useServerWelcomeSubscription, } from "../rpc/serverState"; +import { startMcpStateSync } from "../rpc/mcpState"; import { useStore } from "../store"; import { useUiStateStore } from "../uiStateStore"; import { syncBrowserChromeTheme } from "../hooks/useTheme"; @@ -105,6 +106,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} @@ -203,6 +205,19 @@ function ServerStateBootstrap() { return null; } +// ru-fork: keeps the MCP catalog/bindings/runtime atoms in sync with the backend. +function McpStateBootstrap() { + useEffect(() => { + if (!getPrimaryKnownEnvironment()) { + return; + } + + return startMcpStateSync(getPrimaryEnvironmentConnection().client.mcp); + }, []); + + return null; +} + function EnvironmentConnectionManagerBootstrap() { const queryClient = useQueryClient(); diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 94438e91ccd..7e83a328e28 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -23,11 +23,7 @@ import { createThreadSelectorByRef } from "../storeSelectors"; import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; import { RightPanelSheet } from "../components/RightPanelSheet"; import { Sidebar, SidebarInset, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; -import { - McpPanel, - McpPanelInlineSidebar, - useMcpManagerStore, -} from "../ru-fork/mcp-manage"; +import { useMcpManagerStore } from "../ru-fork/mcp-manage"; const DiffPanel = lazy(() => import("../components/DiffPanel")); const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width"; @@ -173,9 +169,9 @@ function ChatThreadRouteView() { const serverThreadStarted = threadHasStarted(serverThread); const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; const diffOpen = search.diff === "1"; - const mcpOpen = useMcpManagerStore((store) => store.panelOpen); + // MCP panel open/close is global (useMcpManagerStore) and rendered once in the + // _chat layout; here we only need the setter to keep diff + MCP mutually exclusive. const setMcpPanelOpen = useMcpManagerStore((store) => store.setPanelOpen); - const closeMcp = useCallback(() => setMcpPanelOpen(false), [setMcpPanelOpen]); const shouldUseDiffSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const currentThreadKey = threadRef ? `${threadRef.environmentId}:${threadRef.threadId}` : null; const [diffPanelMountState, setDiffPanelMountState] = useState(() => ({ @@ -265,7 +261,7 @@ function ChatThreadRouteView() { onOpenDiff={openDiff} renderDiffContent={shouldRenderDiffContent} /> - + {/* ru-fork: the MCP panel is mounted once in the _chat layout (McpPanelMount). */} ); } @@ -283,9 +279,7 @@ function ChatThreadRouteView() { {shouldRenderDiffContent ? : null} - - - + {/* ru-fork: the MCP panel sheet is mounted once in the _chat layout. */} ); } diff --git a/apps/web/src/routes/_chat.draft.$draftId.tsx b/apps/web/src/routes/_chat.draft.$draftId.tsx index 77b9f18f0d7..e8c78abf950 100644 --- a/apps/web/src/routes/_chat.draft.$draftId.tsx +++ b/apps/web/src/routes/_chat.draft.$draftId.tsx @@ -69,6 +69,8 @@ function DraftChatThreadRouteView() { return null; } + // The MCP panel is mounted once in the _chat layout (McpPanelMount), so it is + // available here on drafts too without a per-route mount. return ( { + if (!getPrimaryKnownEnvironment()) { + return; + } + void getPrimaryEnvironmentConnection() + .client.mcp.setActiveProject({ projectId: activeProjectId }) + .catch(() => undefined); + }, [activeProjectId]); + + return null; +} function ChatRouteGlobalShortcuts() { const clearSelection = useThreadSelectionStore((state) => state.clearSelection); @@ -101,7 +122,11 @@ function ChatRouteLayout() { return ( <> + + {/* ru-fork: single MCP panel mount for all chat routes (draft + thread) — + hoisted here so it never remounts on navigation. */} + ); } diff --git a/apps/web/src/rpc/mcpState.ts b/apps/web/src/rpc/mcpState.ts new file mode 100644 index 00000000000..cad15638c04 --- /dev/null +++ b/apps/web/src/rpc/mcpState.ts @@ -0,0 +1,126 @@ +// ru-fork: client-side MCP read state. Mirrors serverState.ts — atoms fed by the +// `mcp` RPC streams (projection = authored catalog/bindings, runtime = live +// status). Mutations go through orchestration.dispatchCommand, not here. + +import { useAtomValue } from "@effect/atom-react"; +import type { + McpBinding, + McpCatalogRuntimeSnapshot, + McpCatalogServer, + McpProjectionStreamEvent, + McpRuntimeSnapshot, + McpRuntimeStreamEvent, +} from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import type { WsRpcClient } from "./wsRpcClient"; +import { appAtomRegistry, resetAppAtomRegistryForTests } from "./atomRegistry"; + +type McpStateClient = Pick< + WsRpcClient["mcp"], + "getSnapshot" | "subscribeProjection" | "subscribeRuntime" +>; + +/** Stable key for one project↔server runtime row. */ +export function mcpRuntimeKey(projectId: string, serverId: string): string { + return `${projectId}:${serverId}`; +} + +function makeStateAtom(label: string, initialValue: A) { + return Atom.make(initialValue).pipe(Atom.keepAlive, Atom.withLabel(label)); +} + +const EMPTY_CATALOG: ReadonlyArray = []; +const EMPTY_BINDINGS: ReadonlyArray = []; +const EMPTY_RUNTIME: Readonly> = {}; +const EMPTY_CATALOG_RUNTIME: Readonly> = {}; + +export const mcpCatalogAtom = makeStateAtom>( + "mcp-catalog", + EMPTY_CATALOG, +); +export const mcpBindingsAtom = makeStateAtom>( + "mcp-bindings", + EMPTY_BINDINGS, +); +export const mcpRuntimeAtom = makeStateAtom>>( + "mcp-runtime", + EMPTY_RUNTIME, +); +// Catalog-level runtime keyed by serverId (status + tools of the default config). +export const mcpCatalogRuntimeAtom = makeStateAtom< + Readonly> +>("mcp-catalog-runtime", EMPTY_CATALOG_RUNTIME); + +export function applyMcpProjectionEvent(event: McpProjectionStreamEvent): void { + // The server only ever emits a full snapshot — a pure replace of the catalog + bindings atoms. + appAtomRegistry.set(mcpCatalogAtom, event.snapshot.catalog); + appAtomRegistry.set(mcpBindingsAtom, event.snapshot.bindings); +} + +export function applyMcpRuntimeEvent(event: McpRuntimeStreamEvent): void { + const runtimeByKey: Record = {}; + for (const runtime of event.runtimes) { + runtimeByKey[mcpRuntimeKey(runtime.projectId, runtime.serverId)] = runtime; + } + appAtomRegistry.set(mcpRuntimeAtom, runtimeByKey); + + const catalogRuntimeByServerId: Record = {}; + for (const catalogRuntime of event.catalogRuntimes) { + catalogRuntimeByServerId[catalogRuntime.serverId] = catalogRuntime; + } + appAtomRegistry.set(mcpCatalogRuntimeAtom, catalogRuntimeByServerId); +} + +export function startMcpStateSync(client: McpStateClient): () => void { + let disposed = false; + const cleanups = [ + client.subscribeProjection((event) => { + applyMcpProjectionEvent(event); + }), + client.subscribeRuntime((event) => { + applyMcpRuntimeEvent(event); + }), + ]; + + // Eager snapshot for the catalog/bindings (runtime arrives via its stream's + // own initial snapshot). projectId null ⇒ all bindings. + if (appAtomRegistry.get(mcpCatalogAtom).length === 0) { + void client + .getSnapshot({ projectId: null }) + .then((snapshot) => { + if (disposed) { + return; + } + applyMcpProjectionEvent({ type: "snapshot", snapshot }); + }) + .catch(() => undefined); + } + + return () => { + disposed = true; + for (const cleanup of cleanups) { + cleanup(); + } + }; +} + +export function useMcpCatalog(): ReadonlyArray { + return useAtomValue(mcpCatalogAtom); +} + +export function useMcpBindings(): ReadonlyArray { + return useAtomValue(mcpBindingsAtom); +} + +export function useMcpRuntimeMap(): Readonly> { + return useAtomValue(mcpRuntimeAtom); +} + +export function useMcpCatalogRuntimeMap(): Readonly> { + return useAtomValue(mcpCatalogRuntimeAtom); +} + +export function resetMcpStateForTests(): void { + resetAppAtomRegistryForTests(); +} diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index 409f9e3b16c..57a190cb807 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -151,6 +151,15 @@ export interface WsRpcClient { readonly subscribeShell: RpcStreamMethod; readonly subscribeThread: RpcInputStreamMethod; }; + // ru-fork: MCP catalog/bindings reads + runtime status. Mutations go through + // `orchestration.dispatchCommand` (the 5 mcp.* commands). + readonly mcp: { + readonly getSnapshot: RpcUnaryMethod; + readonly subscribeProjection: RpcStreamMethod; + readonly subscribeRuntime: RpcStreamMethod; + readonly setActiveProject: RpcUnaryMethod; + readonly recheck: RpcUnaryMethod; + }; } export function createWsRpcClient(transport: WsTransport): WsRpcClient { @@ -309,5 +318,24 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { { ...options, tag: ORCHESTRATION_WS_METHODS.subscribeThread }, ), }, + mcp: { + getSnapshot: (input) => + transport.request((client) => client[WS_METHODS.mcpGetSnapshot](input)), + subscribeProjection: (listener, options) => + transport.subscribe( + (client) => client[WS_METHODS.subscribeMcpProjection]({}), + listener, + { ...options, tag: WS_METHODS.subscribeMcpProjection }, + ), + subscribeRuntime: (listener, options) => + transport.subscribe( + (client) => client[WS_METHODS.subscribeMcpRuntime]({}), + listener, + { ...options, tag: WS_METHODS.subscribeMcpRuntime }, + ), + setActiveProject: (input) => + transport.request((client) => client[WS_METHODS.mcpSetActiveProject](input)), + recheck: (input) => transport.request((client) => client[WS_METHODS.mcpRecheck](input)), + }, }; } diff --git a/apps/web/src/ru-fork/mcp-manage/adapters.ts b/apps/web/src/ru-fork/mcp-manage/adapters.ts new file mode 100644 index 00000000000..9e06be09ce6 --- /dev/null +++ b/apps/web/src/ru-fork/mcp-manage/adapters.ts @@ -0,0 +1,336 @@ +// ru-fork: pure mappers between the wire contracts (@t3tools/contracts) and the +// panel's UI view-types (./types). The UI types are richer/flatter than the +// contracts on purpose — components stay decoupled from the wire shape. +// +// Identity-lock model (mcp-vars-redesign.md): the catalog server is a config +// TEMPLATE + a `vars` block; a binding holds only per-project var VALUES (the +// holes). Secrets never reach the client as plaintext — authored secret vars hold +// a server-side ref, so reads mask their value to "" (write-only): editing a +// secret means re-entering it, and there is no plaintext to leak. + +import type { + McpBinding, + McpCatalogRuntimeSnapshot, + McpCatalogServer, + McpRuntimeSnapshot, + McpServerConfig as ContractServerConfig, + McpServerVar as ContractVar, + McpServerVarDraft, + McpTool as ContractTool, + McpToolPolicy, + McpVarValue, +} from "@t3tools/contracts"; + +import type { + McpHealth, + McpProjectBinding, + McpRegistryServer, + McpServerConfig as UiServerConfig, + McpStatus, + McpTool, + McpVar, +} from "./types"; + +// ── config (pure template — no secrets, no env, no timeout) ────────────────── +export function contractConfigToUi(config: ContractServerConfig): UiServerConfig { + switch (config.transport) { + case "stdio": + return { transport: "stdio", command: config.command, args: [...config.args] }; + case "http": + return { transport: "http", httpUrl: config.httpUrl, headers: { ...config.headers } }; + } +} + +/** UI config (already template-shaped) → command config. Structural pass-through. */ +export function uiConfigToContract(config: UiServerConfig): ContractServerConfig { + return config.transport === "stdio" + ? { transport: "stdio", command: config.command, args: [...config.args] } + : { transport: "http", httpUrl: config.httpUrl, headers: { ...config.headers } }; +} + +// ── vars ───────────────────────────────────────────────────────────────────── +/** A var has a catalog-level value unless it is an empty per-project hole (`value === null`). */ +function varValueToUi(value: McpVarValue | null): string { + if (value === null) { + return ""; // per-project hole — no catalog value + } + // secret → server-side ref → masked (write-only); plain → the literal string. + return typeof value === "string" ? value : ""; +} + +/** Contract var → UI var (masks secret values to ""; flags whether a secret is already stored). */ +function catalogVarToUi(variable: ContractVar): McpVar { + return { + name: variable.name, + value: varValueToUi(variable.value), + secret: variable.secret, + perProject: variable.perProject, + required: variable.required, + // A secret whose catalog value is a stored ref ⇒ there IS a saved secret (write-only here). + hasStoredSecret: variable.secret && variable.value !== null && typeof variable.value === "object", + origin: variable.origin, + // Author-fixed shipped value ⇒ read-only in the editor (the URL the deployer locked). + valueLocked: variable.valueLocked === true, + }; +} + +/** UI var rows → inbound draft vars (plaintext; the decider splits secrets → refs). */ +export function uiVarsToDraft(vars: ReadonlyArray): McpServerVarDraft[] { + return vars.map((variable): McpServerVarDraft => { + // A per-project var has NO catalog value (a pure hole, filled per binding); a catalog var keeps + // its typed value. So an empty catalog var ⇒ null ⇒ «требует настройки»; a per-project var ⇒ null + // ⇒ «шаблон». + const value = variable.perProject ? "" : variable.value.trim(); + // A catalog secret left blank but already stored ⇒ keep the existing ref (don't wipe). Never for a + // per-project var (no catalog secret). + const keepSecret = + !variable.perProject && variable.secret && variable.hasStoredSecret && value.length === 0; + return { + name: variable.name, + secret: variable.secret, + perProject: variable.perProject, + // Every var must resolve to a value (catalog-level here, or per-project in the binding). + required: true, + ...(keepSecret ? { keepSecret: true } : {}), + // Empty stays null; when keepSecret is set the decider reuses the stored ref. + value: value.length > 0 ? value : null, + }; + }); +} + +/** + * Required per-project vars the binding has not filled (no value AND no catalog + * default). Mirrors `missingRequiredVars` in @ru-fork/mcp-core — recomputed here + * so the web stays free of a server dependency. + */ +function computeMissingVars( + vars: ReadonlyArray, + varValues: Readonly>, +): string[] { + return vars + .filter( + (variable) => + variable.perProject && + variable.required && + variable.value === null && + !(variable.name in varValues), + ) + .map((variable) => variable.name); +} + +/** + * Required CATALOG-level vars with no value — the catalog config itself is incomplete and the user + * must fill it HERE (item 7). Per-project required holes are NOT counted (they're filled per project; + * that's the «шаблон» case, see `hasPerProjectHole`). + */ +function catalogMissingVars(vars: ReadonlyArray): string[] { + return vars + .filter((variable) => !variable.perProject && variable.required && variable.value === null) + .map((variable) => variable.name); +} + +/** + * True when the server has a required PER-PROJECT hole (a «для проекта» var, no catalog value) — its + * catalog default can never be probed (the reactor skips it), so it's a «шаблон» usable only once a + * project supplies the values. Distinct from `catalogMissingVars` (fixable at the catalog level). + */ +function hasPerProjectHole(vars: ReadonlyArray): boolean { + return vars.some( + (variable) => variable.perProject && variable.required && variable.value === null, + ); +} + +/** Binding var values → UI map (secret values masked to ""). */ +function bindingVarValuesToUi( + varValues: Readonly>, +): Record { + const masked: Record = {}; + for (const [name, value] of Object.entries(varValues)) { + masked[name] = typeof value === "string" ? value : ""; + } + return masked; +} + +/** Names whose per-project value is a stored secret ref (a saved secret exists for the project). */ +function bindingSecretVarNames(varValues: Readonly>): string[] { + return Object.entries(varValues) + .filter(([, value]) => value !== null && typeof value === "object") + .map(([name]) => name); +} + +/** Contract tools → UI tools. Drops the optional `params` key when absent. */ +function toUiTools(tools: ReadonlyArray): McpTool[] { + return tools.map((tool): McpTool => + tool.params + ? { name: tool.name, description: tool.description, params: [...tool.params] } + : { name: tool.name, description: tool.description }, + ); +} + +export function catalogServerToRegistry( + server: McpCatalogServer, + runtime: McpCatalogRuntimeSnapshot | undefined, +): McpRegistryServer { + const missingVars = catalogMissingVars(server.vars); + const checking = runtime?.checking ?? false; + return { + id: server.id, + name: server.name, + description: server.description ?? "", + source: server.source, + config: contractConfigToUi(server.config), + vars: server.vars.map(catalogVarToUi), + ...(server.timeoutMs !== null ? { timeoutMs: server.timeoutMs } : {}), + // Live tools + status from the probe of the default config (cwd-independent). + tools: toUiTools(runtime?.discoveredTools ?? []), + ...(runtime ? { status: runtimeStatusToUi(true, runtime.status, checking) } : {}), + ...(runtime?.message ? { message: runtime.message } : {}), + checking, + incomplete: missingVars.length > 0, + missingVars, + templateOnly: hasPerProjectHole(server.vars), + locked: server.locked, + enabled: server.enabled, + trust: server.trust, + extraArgs: [...server.extraArgs], + extraHeaders: { ...server.extraHeaders }, + builtinId: server.builtinId, + // Derive a single transport tag so existing filters keep working. + tags: [server.config.transport], + // Docs link: shipped on a built-in or back-filled from the probe (B3 ②). + ...(server.websiteUrl !== null ? { docsUrl: server.websiteUrl } : {}), + }; +} + +export function runtimeStatusToUi( + enabled: boolean, + status: McpRuntimeSnapshot["status"] | undefined, + checking: boolean, +): McpStatus { + if (!enabled) { + return "disabled"; + } + if (checking) { + return "checking"; // a probe is in flight — show «проверка…» over any prior status + } + switch (status) { + case "online": + return "connected"; + case "unchecked": + return "unchecked"; + case undefined: // bound, but the monitor hasn't reported yet + return "connecting"; + case "degraded": + return "degraded"; + case "offline": + return "error"; + } +} + +function runtimeDetail(runtime: McpRuntimeSnapshot): string { + switch (runtime.status) { + case "online": + return `Подключено · ${runtime.discoveredTools.length} инструментов`; + case "unchecked": + return "Не проверено — нажмите «Проверить»"; + // degraded/offline carry the real probe failure (timeout, npm error, + // DNS/401, …) — show it so the user sees exactly what went wrong. + case "degraded": + return runtime.message ?? "Нестабильное соединение, повторная проверка…"; + case "offline": + return runtime.message ?? "Не удалось подключиться"; + } +} + +function runtimeHealth(runtime: McpRuntimeSnapshot | undefined, enabled: boolean): McpHealth { + if (!enabled) { + return { detail: "Отключён в этом проекте" }; + } + if (!runtime) { + return { detail: "Подключение…" }; + } + const detail = runtimeDetail(runtime); + return runtime.latencyMs !== undefined ? { latencyMs: runtime.latencyMs, detail } : { detail }; +} + +/** + * Tool policy + discovered tools → the UI's per-tool override map. A tool is + * "enabled" unless its entry is explicitly `false` (see `isToolEnabled`). + * - default-allow: only the named exceptions are disabled. + * - default-deny: every discovered tool except the exceptions is disabled. + */ +function policyToToolOverrides( + policy: McpToolPolicy, + discoveredToolNames: ReadonlyArray, +): Record { + const overrides: Record = {}; + const exceptions = new Set(policy.exceptions); + if (policy.defaultDecision === "allow") { + for (const name of exceptions) { + overrides[name] = false; + } + } else { + for (const name of discoveredToolNames) { + if (!exceptions.has(name)) { + overrides[name] = false; + } + } + } + return overrides; +} + +export function bindingToUi( + binding: McpBinding, + server: McpCatalogServer | undefined, + runtime: McpRuntimeSnapshot | undefined, +): McpProjectBinding { + const discovered = runtime?.discoveredTools ?? []; + const discoveredNames = discovered.map((tool) => tool.name); + const missingVars = server ? computeMissingVars(server.vars, binding.varValues) : []; + const checking = runtime?.checking ?? false; + return { + projectId: binding.projectId, + serverId: binding.serverId, + enabled: binding.enabled, + // F2: an incomplete binding has no runtime row, so it would otherwise read «Подключение» (blue). + // Show the neutral «Не проверено» dot instead — the amber «требует настройки» marker carries the why. + status: + binding.enabled && missingVars.length > 0 + ? "unchecked" + : runtimeStatusToUi(binding.enabled, runtime?.status, checking), + checking, + health: runtimeHealth(runtime, binding.enabled), + toolOverrides: policyToToolOverrides(binding.toolPolicy, discoveredNames), + // Live tools from the probe — what the UI tool list + counts should reflect. + discoveredTools: toUiTools(discovered), + varValues: bindingVarValuesToUi(binding.varValues), + secretVarNames: bindingSecretVarNames(binding.varValues), + incomplete: missingVars.length > 0, + missingVars, + ...(binding.timeoutMs !== null ? { timeoutMs: binding.timeoutMs } : {}), + }; +} + +/** + * One tool toggle → a new tool policy. Normalizes to default-allow with + * `exceptions` = the disabled tool set, so the on-wire policy is always the + * simplest representation regardless of how it arrived. + */ +export function toggleToolPolicy( + binding: McpBinding, + runtime: McpRuntimeSnapshot | undefined, + toolName: string, + enabled: boolean, +): McpToolPolicy { + const overrides = policyToToolOverrides( + binding.toolPolicy, + (runtime?.discoveredTools ?? []).map((tool) => tool.name), + ); + const disabled = new Set(Object.keys(overrides).filter((name) => overrides[name] === false)); + if (enabled) { + disabled.delete(toolName); + } else { + disabled.add(toolName); + } + return { defaultDecision: "allow", exceptions: [...disabled] }; +} diff --git a/apps/web/src/ru-fork/mcp-manage/components/AddToProjectControl.tsx b/apps/web/src/ru-fork/mcp-manage/components/AddToProjectControl.tsx index a63a6116628..d4c33fb8f6a 100644 --- a/apps/web/src/ru-fork/mcp-manage/components/AddToProjectControl.tsx +++ b/apps/web/src/ru-fork/mcp-manage/components/AddToProjectControl.tsx @@ -1,19 +1,17 @@ import { CheckIcon, FolderPlusIcon, PlusIcon } from "lucide-react"; import { Button } from "~/components/ui/button"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; -import { - selectProjectsForServer, - useMcpManagerStore, -} from "../store"; +import { selectProjectsForServer, useMcpManagerStore } from "../store"; +import { useMcpMutations, useMcpProjectBindings, useMcpProjects } from "../useMcp"; /** * "Add to project ▾" dropdown shown on a catalog server. Lists every project; projects the * server is already bound to are shown with a check and disabled. */ export function AddToProjectControl({ serverId }: { serverId: string }) { - const projects = useMcpManagerStore((state) => state.projects); - const bindings = useMcpManagerStore((state) => state.bindings); - const addBindingToProject = useMcpManagerStore((state) => state.addBindingToProject); + const projects = useMcpProjects(); + const bindings = useMcpProjectBindings(); + const { addBindingToProject } = useMcpMutations(); const selectProject = useMcpManagerStore((state) => state.selectProject); const setActiveTab = useMcpManagerStore((state) => state.setActiveTab); diff --git a/apps/web/src/ru-fork/mcp-manage/components/ConfigSummary.tsx b/apps/web/src/ru-fork/mcp-manage/components/ConfigSummary.tsx index 5a45393901e..702cbbd3a9a 100644 --- a/apps/web/src/ru-fork/mcp-manage/components/ConfigSummary.tsx +++ b/apps/web/src/ru-fork/mcp-manage/components/ConfigSummary.tsx @@ -1,9 +1,15 @@ import { Badge } from "~/components/ui/badge"; -import type { McpServerConfig } from "../types"; +import type { McpServerConfig, McpVar } from "../types"; import { transportDescription } from "../visuals"; -/** Compact, read-only rendering of a server's transport + connection details. */ -export function ConfigSummary({ config }: { config: McpServerConfig }) { +/** Compact, read-only rendering of a server's transport TEMPLATE + declared vars. */ +export function ConfigSummary({ + config, + vars, +}: { + config: McpServerConfig; + vars?: readonly McpVar[]; +}) { return (
@@ -13,16 +19,14 @@ export function ConfigSummary({ config }: { config: McpServerConfig }) { {transportDescription(config.transport)}
{config.transport === "stdio" ? ( - <> - - - + ) : ( <> - + )} + {vars && vars.length > 0 && }
); } @@ -36,29 +40,57 @@ function KeyValue({ label, value }: { label: string; value: string }) { ); } -function EnvBlock({ - label, - entries, - separator = "=", -}: { - label: string; - entries: Readonly>; - separator?: string; -}) { - const pairs = Object.entries(entries); +function HeadersBlock({ headers }: { headers: Readonly> }) { + const pairs = Object.entries(headers); if (pairs.length === 0) return null; return (
- {label} + headers
{pairs.map(([key, value]) => (
- {key} - {separator} - {value} + {key}: {value}
))}
); } + +function VarsBlock({ vars }: { vars: readonly McpVar[] }) { + return ( +
+ vars +
+ {vars.map((variable) => { + // Unfilled CATALOG-level required var ⇒ «требует настройки» (amber). A per-project hole being + // empty at the catalog is normal (it's filled per project) ⇒ not marked. + const needsCatalogValue = + !variable.perProject && variable.required && variable.value.length === 0; + return ( +
+ + {variable.name} + {needsCatalogValue && *} + + {variable.secret && ( + + секрет + + )} + {variable.perProject && ( + + проект{variable.required ? " *" : ""} + + )} +
+ ); + })} +
+
+ ); +} diff --git a/apps/web/src/ru-fork/mcp-manage/components/ExtraArgsField.tsx b/apps/web/src/ru-fork/mcp-manage/components/ExtraArgsField.tsx new file mode 100644 index 00000000000..fc32524d9a7 --- /dev/null +++ b/apps/web/src/ru-fork/mcp-manage/components/ExtraArgsField.tsx @@ -0,0 +1,36 @@ +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; + +/** + * The user's escape hatch on a locked template (and an optional extra for manual stdio servers): + * space-separated args appended AFTER the template's own args. May contain `${NAME}` holes. The + * caller owns the string state and splits it on save (mirrors the args field parsing). + */ +export function ExtraArgsField({ + value, + onChange, + idPrefix, +}: { + value: string; + onChange: (next: string) => void; + idPrefix: string; +}) { + return ( +
+ + onChange(event.target.value)} + placeholder={"--read-only --max-files 100"} + className="mt-1.5 font-mono" + /> + + Через пробел. Добавляются к команде шаблона. Можно подставлять ${"{NAME}"} из + переменных ниже. + +
+ ); +} diff --git a/apps/web/src/ru-fork/mcp-manage/components/ExtraHeadersField.tsx b/apps/web/src/ru-fork/mcp-manage/components/ExtraHeadersField.tsx new file mode 100644 index 00000000000..f2b618fcb37 --- /dev/null +++ b/apps/web/src/ru-fork/mcp-manage/components/ExtraHeadersField.tsx @@ -0,0 +1,37 @@ +import { Label } from "~/components/ui/label"; +import { Textarea } from "~/components/ui/textarea"; + +/** + * The http escape hatch on a locked template (⑲): extra/override request headers, one `Key: Value` + * per line, merged OVER the template's own headers. May contain `${NAME}` holes. The caller owns the + * string state and parses it on save (via `parseHeaderLines`), mirroring `ExtraArgsField`. + */ +export function ExtraHeadersField({ + value, + onChange, + idPrefix, +}: { + value: string; + onChange: (next: string) => void; + idPrefix: string; +}) { + return ( +
+ +