From bed23c4087a31dcefa51a363bc8d7e4387ed7366 Mon Sep 17 00:00:00 2001 From: pallakatos Date: Wed, 9 Sep 2026 12:36:21 +0200 Subject: [PATCH 01/14] feat(mcp): checkpoint qualified managed service integration Implement UID-owned managed lifecycle, current protocol readiness, scoped catalogs/invocation, actual socket caller boundaries, runtime bridges and additive image lifecycle. Bounded source review and local Rust/HTTP/runtime qualification are complete; composition and real Kind acceptance remain mandatory before readiness. This local checkpoint does not publish images or change customers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- cli/src/commands/push-apply.ts | 5 + cli/src/commands/push.ts | 7 +- cli/src/lib/image-targets.ts | 13 +- cli/src/lib/managed-mcp-image-apply.test.ts | 71 + cli/src/lib/managed-mcp-image-apply.ts | 111 ++ controller/src/crd_validations.rs | 17 +- controller/src/mcp_server.rs | 56 + controller/src/mcp_server_reconciler.rs | 751 +++------- .../src/mcp_server_reconciler/auxiliary.rs | 128 ++ .../src/mcp_server_reconciler/events.rs | 36 + controller/src/mcp_server_reconciler/jwks.rs | 136 ++ .../src/mcp_server_reconciler/managed/mod.rs | 406 ++++++ .../managed/ownership.rs | 329 +++++ .../src/mcp_server_reconciler/managed/plan.rs | 283 ++++ .../mcp_server_reconciler/managed/probe.rs | 266 ++++ .../managed/probe_tests.rs | 133 ++ .../mcp_server_reconciler/managed/tests.rs | 419 ++++++ .../src/mcp_server_reconciler/source.rs | 106 ++ controller/src/mcp_server_reconciler/tests.rs | 150 ++ controller/src/reconciler/mcp_binding.rs | 108 ++ controller/src/reconciler/mcp_egress.rs | 23 +- controller/src/reconciler/mod.rs | 40 +- .../kars/templates/controller-deployment.yaml | 8 + deploy/helm/kars/templates/crd-mcpserver.yaml | 66 +- .../operator-default-deny-networkpolicy.yaml | 13 + deploy/helm/kars/values.yaml | 8 + docs/mcp.md | 5 + .../security-audits/2026-09-08-managed-mcp.md | 70 + docs/tutorials/managed-mcp.md | 153 ++ inference-router/src/main.rs | 31 +- inference-router/src/mcp/forwarder.rs | 240 ++- inference-router/src/mcp/governed.rs | 78 + inference-router/src/mcp/mod.rs | 2 + inference-router/src/mcp/registry.rs | 4 + inference-router/src/mcp/response_body.rs | 25 + inference-router/src/routes/mcp.rs | 204 ++- inference-router/src/routes/mcp/scoped.rs | 83 ++ inference-router/tests/governed_telemetry.rs | 16 +- inference-router/tests/managed_mcp.rs | 273 ++++ .../kars_runtime_hermes/plugin/__init__.py | 3 + .../kars_runtime_hermes/plugin/mcp_bridge.py | 143 ++ runtimes/hermes/tests/test_mcp_bridge.py | 111 ++ .../src/core/agt-tools/mcp-bridge.test.ts | 94 ++ .../openclaw/src/core/agt-tools/mcp-bridge.ts | 148 ++ runtimes/openclaw/src/index.ts | 2 + sandbox-images/mcp-everything/Dockerfile | 15 + .../mcp-everything/package-lock.json | 1282 +++++++++++++++++ sandbox-images/mcp-everything/package.json | 9 + tests/e2e/managed-mcp.py | 272 ++++ tests/e2e/managed-mcp.sh | 24 + tests/e2e/run.sh | 4 + 51 files changed, 6259 insertions(+), 721 deletions(-) create mode 100644 cli/src/lib/managed-mcp-image-apply.test.ts create mode 100644 cli/src/lib/managed-mcp-image-apply.ts create mode 100644 controller/src/mcp_server_reconciler/auxiliary.rs create mode 100644 controller/src/mcp_server_reconciler/events.rs create mode 100644 controller/src/mcp_server_reconciler/jwks.rs create mode 100644 controller/src/mcp_server_reconciler/managed/mod.rs create mode 100644 controller/src/mcp_server_reconciler/managed/ownership.rs create mode 100644 controller/src/mcp_server_reconciler/managed/plan.rs create mode 100644 controller/src/mcp_server_reconciler/managed/probe.rs create mode 100644 controller/src/mcp_server_reconciler/managed/probe_tests.rs create mode 100644 controller/src/mcp_server_reconciler/managed/tests.rs create mode 100644 controller/src/mcp_server_reconciler/source.rs create mode 100644 controller/src/mcp_server_reconciler/tests.rs create mode 100644 controller/src/reconciler/mcp_binding.rs create mode 100644 docs/security-audits/2026-09-08-managed-mcp.md create mode 100644 docs/tutorials/managed-mcp.md create mode 100644 inference-router/src/mcp/governed.rs create mode 100644 inference-router/src/mcp/response_body.rs create mode 100644 inference-router/src/routes/mcp/scoped.rs create mode 100644 inference-router/tests/managed_mcp.rs create mode 100644 runtimes/hermes/src/kars_runtime_hermes/plugin/mcp_bridge.py create mode 100644 runtimes/hermes/tests/test_mcp_bridge.py create mode 100644 runtimes/openclaw/src/core/agt-tools/mcp-bridge.test.ts create mode 100644 runtimes/openclaw/src/core/agt-tools/mcp-bridge.ts create mode 100644 sandbox-images/mcp-everything/Dockerfile create mode 100644 sandbox-images/mcp-everything/package-lock.json create mode 100644 sandbox-images/mcp-everything/package.json create mode 100644 tests/e2e/managed-mcp.py create mode 100644 tests/e2e/managed-mcp.sh diff --git a/cli/src/commands/push-apply.ts b/cli/src/commands/push-apply.ts index ff47f1405..7a6617999 100644 --- a/cli/src/commands/push-apply.ts +++ b/cli/src/commands/push-apply.ts @@ -11,12 +11,14 @@ import { import { coreImageValues, imageValueArgs, PUSH_COMPONENTS, resolvePushedArtifacts, type PushedImage } from "../lib/image-targets.js"; import { inspectCoreInstallation, recheckCoreOwnership, requireHealthyDeployment, updateLegacyCore, verifyCoreConfiguration } from "../lib/core-image-apply.js"; import { inspectSandboxPlans, refreshSandboxImages } from "../lib/sandbox-image-apply.js"; +import { inspectManagedMcpPlans, refreshManagedMcpImages } from "../lib/managed-mcp-image-apply.js"; export interface PushApplyResult { applied: string[]; buildOnly: string[]; updatedSandboxes: number; preservedOverrides: number; + updatedManagedMcp?: number; } /** Update owning defaults, prove their selected artifact references, then ask @@ -46,6 +48,7 @@ export async function applyPushedImages( const meshImages: MeshImages = {}; for (const item of artifacts) if (item.name === "relay" || item.name === "registry") meshImages[item.name] = item.image; const plans = await inspectSandboxPlans(execute, coreImages); + const managedPlans = await inspectManagedMcpPlans(execute, coreImages); if (core) await recheckCoreOwnership(execute, core); if (mesh && (selectedMesh || core?.kind === "helm")) await recheckMeshOwnership(execute, mesh); @@ -80,6 +83,8 @@ export async function applyPushedImages( requireHealthyDeployment(await verifyCoreConfiguration(execute, core, coreImages)); } const updatedSandboxes = await refreshSandboxImages(execute, plans); + const updatedManagedMcp = await refreshManagedMcpImages(execute, managedPlans, core?.deployment.metadata.namespace ?? "kars-system"); return { applied: artifacts.map(item => item.name), buildOnly, updatedSandboxes, + ...(coreImages.some(image => image.name === "mcp-everything") ? { updatedManagedMcp } : {}), preservedOverrides: plans.filter(plan => plan.pinned).length }; } diff --git a/cli/src/commands/push.ts b/cli/src/commands/push.ts index 3f7ec38a6..acb2023b7 100644 --- a/cli/src/commands/push.ts +++ b/cli/src/commands/push.ts @@ -11,7 +11,7 @@ import { loadContext } from "../config.js"; import { preparePushTarget } from "../lib/deployment-target.js"; import { inspectMeshInstallation } from "../lib/mesh-release.js"; import { applyPushedImages } from "./push-apply.js"; -import { dockerPushDigest, PUSH_COMPONENTS, RUNTIME_IMAGE_TARGETS } from "../lib/image-targets.js"; +import { dockerPushDigest, MANAGED_MCP_IMAGE_TARGET, PUSH_COMPONENTS, RUNTIME_IMAGE_TARGETS } from "../lib/image-targets.js"; import { stageRustBinaries } from "../lib/stage-rust-bin.js"; import { stageMeshPlugin } from "../lib/stage-mesh-plugin.js"; import { ensureAgtRepo, ensureAgtWheels } from "../lib/agt-bootstrap.js"; @@ -25,7 +25,7 @@ export function pushCommand(): Command { .description("Build and push images to ACR (uses cached context from last deploy)") .option("--acr ", "ACR name (default: from last deploy)") .option("--subscription ", "Azure subscription (must match saved deployment when present)") - .option("--only ", "Build one image: controller, router, sandbox, sandbox-base, relay, registry, or runtime-*") + .option("--only ", "Build one image: controller, router, sandbox, sandbox-base, relay, registry, mcp-everything, or runtime-*") .option("--include-base", "Include sandbox-base in a full push (skipped by default — rebuild only when upgrading OpenClaw/Python/Go)") .option("--apply", "Apply selected image configuration and verify the resulting rollouts") .option( @@ -256,6 +256,8 @@ export function pushCommand(): Command { { name: "sandbox", tag: "openclaw-sandbox:latest", dockerfile: "sandbox-images/openclaw/Dockerfile", buildArgs: sandboxBuildArgs }, ...meshImages, + { name: MANAGED_MCP_IMAGE_TARGET.name, tag: `${MANAGED_MCP_IMAGE_TARGET.repo}:latest`, + dockerfile: "sandbox-images/mcp-everything/Dockerfile", context: "sandbox-images/mcp-everything" }, // Shared with release imports, upgrade values, and push application. ...RUNTIME_IMAGE_TARGETS.map(runtime => ({ name: runtime.name, tag: `${runtime.repo}:latest`, @@ -396,6 +398,7 @@ export function pushCommand(): Command { name: image.name, image: pushedArtifacts.get(image.name) ?? `${acrLoginServer}/${image.tag}`, })), path.join(repoRoot, "deploy/helm/kars"), mesh); spin.succeed(`Selected image configuration applied; ${applied.updatedSandboxes} eligible sandbox deployment(s) verified`); + if (applied.updatedManagedMcp !== undefined) console.log(chalk.dim(` ${applied.updatedManagedMcp} managed MCP workload(s) verified; unreferenced defaults updated only.`)); if (applied.preservedOverrides) console.log(chalk.dim(` Preserved ${applied.preservedOverrides} explicit sandbox/overlay image override(s).`)); if (applied.buildOnly.length) console.log(chalk.dim(` Build-only (not deployed): ${applied.buildOnly.join(", ")}.`)); } catch (e: any) { diff --git a/cli/src/lib/image-targets.ts b/cli/src/lib/image-targets.ts index 9d363e2ca..ddbb8bb07 100644 --- a/cli/src/lib/image-targets.ts +++ b/cli/src/lib/image-targets.ts @@ -20,7 +20,12 @@ export const RUNTIME_IMAGE_TARGETS: ReadonlyArray<{ ]; export interface PushedImage { name: string; image: string } -export const PUSH_COMPONENTS = ["controller", "router", "sandbox", "sandbox-base", "relay", "registry", ...RUNTIME_IMAGE_TARGETS.map(item => item.name)]; +export const MANAGED_MCP_IMAGE_TARGET = { + name: "mcp-everything", repo: "mcp-everything", + valueKey: "managedMcp.everythingImage", env: "MCP_EVERYTHING_IMAGE", +} as const; +export const PUSH_COMPONENTS = ["controller", "router", "sandbox", "sandbox-base", "relay", "registry", + MANAGED_MCP_IMAGE_TARGET.name, ...RUNTIME_IMAGE_TARGETS.map(item => item.name)]; export function splitImage(image: string): { repository: string; tag: string } { const withoutDigest = image.split("@")[0]; @@ -43,14 +48,16 @@ export function dockerPushDigest(output: string): string | undefined { export function controllerEnv(name: string): string | undefined { return name === "router" ? "INFERENCE_ROUTER_IMAGE" - : name === "sandbox" ? "SANDBOX_IMAGE" : runtimeTarget(name)?.env; + : name === "sandbox" ? "SANDBOX_IMAGE" + : name === MANAGED_MCP_IMAGE_TARGET.name ? MANAGED_MCP_IMAGE_TARGET.env : runtimeTarget(name)?.env; } export function coreImageValues(images: PushedImage[]): Record { const values: Record = {}; for (const { name, image } of images) { const runtime = runtimeTarget(name); - if (runtime) values[runtime.valueKey] = image; + if (name === MANAGED_MCP_IMAGE_TARGET.name) values[MANAGED_MCP_IMAGE_TARGET.valueKey] = image; + else if (runtime) values[runtime.valueKey] = image; else if (["controller", "router", "sandbox"].includes(name)) { const prefix = name === "router" ? "inferenceRouter" : name; const { repository, tag } = splitImage(image); diff --git a/cli/src/lib/managed-mcp-image-apply.test.ts b/cli/src/lib/managed-mcp-image-apply.test.ts new file mode 100644 index 000000000..de90a4313 --- /dev/null +++ b/cli/src/lib/managed-mcp-image-apply.test.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it, vi } from "vitest"; +import { controllerEnv, coreImageValues } from "./image-targets.js"; +import { inspectManagedMcpPlans, refreshManagedMcpImages } from "./managed-mcp-image-apply.js"; +import type { Execute } from "./deployment-target.js"; + +const image = `registry.azurecr.io/mcp-everything:latest@sha256:${"a".repeat(64)}`; +const source = { + metadata: { name: "tools", namespace: "workspace", uid: "source", resourceVersion: "1", generation: 2 }, + spec: { managed: { preset: "everything" }, allowedTools: ["echo"] }, + status: { phase: "Ready", observedGeneration: 2, workloadImage: image, workloadGeneration: 3, + workloadRef: "kars-mcp/mcp-tools", managedNamespaceUid: "managed-ns" }, +}; +function fixture() { + const annotations = { "kars.azure.com/mcp-source-namespace": "workspace", "kars.azure.com/mcp-source-name": "tools", + "kars.azure.com/mcp-source-uid": "source", "kars.azure.com/mcp-namespace-uid": "managed-ns" }; + const metadata = { name: "mcp-tools", namespace: "kars-mcp", uid: "resource", resourceVersion: "1", generation: 3, + annotations, labels: { "app.kubernetes.io/managed-by": "kars-controller" } }; + const selector = { "kars.azure.com/mcp-source-uid": "source" }; + const resources: Record = { + mcpserver: structuredClone(source), + deployment: { metadata: structuredClone(metadata), spec: { selector: { matchLabels: selector }, + template: { spec: { containers: [{ name: "mcp", image }] } } }, + status: { observedGeneration: 3, availableReplicas: 1, updatedReplicas: 1 } }, + service: { metadata: structuredClone(metadata), spec: { selector: structuredClone(selector) } }, + }; + const execute = vi.fn(async (_file: string, args: readonly string[]) => { + if (args[0] === "get" && args[1] === "mcpservers") return { stdout: JSON.stringify({ items: [source] }) }; + if (args[0] === "get" && args[1] === "namespace") return { stdout: JSON.stringify(args[2] === "kars-system" + ? { metadata: { name: "kars-system", uid: "system", resourceVersion: "1" } } + : { metadata: { name: "kars-mcp", uid: "managed-ns", resourceVersion: "1", annotations: { + "kars.azure.com/mcp-namespace-claim": "v1", "kars.azure.com/mcp-controller-namespace": "kars-system", + "kars.azure.com/mcp-controller-namespace-uid": "system" } } }) }; + if (args[0] === "get" && resources[args[1]]) return { stdout: JSON.stringify(resources[args[1]]) }; + if (["patch", "wait"].includes(args[0])) return { stdout: "" }; + throw new Error("Unexpected command"); + }) as unknown as Execute & ReturnType; + return { execute, resources }; +} + +describe("managed MCP image application", () => { + it("maps the actual artifact to owning Helm values and controller environment", () => { + expect(controllerEnv("mcp-everything")).toBe("MCP_EVERYTHING_IMAGE"); + expect(coreImageValues([{ name: "mcp-everything", image }])).toEqual({ "managedMcp.everythingImage": image }); + }); + it("leaves unrelated component inventories untouched", async () => { + const { execute } = fixture(); + expect(await inspectManagedMcpPlans(execute, [{ name: "controller", image }])).toEqual([]); + expect(execute).not.toHaveBeenCalled(); + }); + it("CAS-triggers only the source and verifies actual artifact, selector and probed generation", async () => { + const { execute } = fixture(); + const plans = await inspectManagedMcpPlans(execute, [{ name: "mcp-everything", image }]); + expect(await refreshManagedMcpImages(execute, plans, "kars-system")).toBe(1); + const calls = execute.mock.calls.map(call => call[1] as string[]); + const patch = calls.find(args => args[0] === "patch")!; + expect(JSON.parse(patch.at(-1)!).metadata).toMatchObject({ uid: "source", resourceVersion: "1" }); + expect(calls.some(args => args[0] === "patch" && args[1] === "deployment")).toBe(false); + }); + it.each(["selector", "image", "probe", "owner"])("rejects %s mismatches instead of reporting applied", async field => { + const { execute, resources } = fixture(); + const plans = await inspectManagedMcpPlans(execute, [{ name: "mcp-everything", image }]); + if (field === "selector") resources.service.spec.selector = { app: "foreign" }; + if (field === "image") resources.deployment.spec.template.spec.containers[0].image = "old:latest"; + if (field === "probe") resources.mcpserver.status.workloadGeneration = 1; + if (field === "owner") resources.deployment.metadata.annotations["kars.azure.com/mcp-source-uid"] = "foreign"; + await expect(refreshManagedMcpImages(execute, plans, "kars-system")).rejects.toThrow("Managed MCP"); + }); +}); diff --git a/cli/src/lib/managed-mcp-image-apply.ts b/cli/src/lib/managed-mcp-image-apply.ts new file mode 100644 index 000000000..3256ce9ef --- /dev/null +++ b/cli/src/lib/managed-mcp-image-apply.ts @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Execute } from "./deployment-target.js"; +import { MANAGED_MCP_IMAGE_TARGET, type PushedImage } from "./image-targets.js"; + +interface ObjectRecord { + metadata: { name: string; namespace?: string; uid: string; resourceVersion: string; generation?: number; + deletionTimestamp?: string; annotations?: Record; labels?: Record; ownerReferences?: unknown[] }; + spec?: Record; + status?: Record; +} +export interface ManagedMcpImagePlan { source: ObjectRecord; image: string } + +function checked(value: unknown): ObjectRecord { + const object = value as ObjectRecord; + if (!object?.metadata?.name || !object.metadata.uid || !object.metadata.resourceVersion + || object.metadata.deletionTimestamp) throw new Error("Managed MCP object lacks a live exact API identity"); + return object; +} + +async function read(execute: Execute, kind: string, name: string, namespace?: string): Promise { + const result = await execute("kubectl", ["get", kind, name, ...(namespace ? ["-n", namespace] : []), "-o", "json"], { stdio: "pipe" }); + const object = checked(JSON.parse(String(result.stdout))); + if (object.metadata.name !== name || (namespace && object.metadata.namespace !== namespace)) { + throw new Error("Managed MCP API returned another object"); + } + return object; +} + +export async function inspectManagedMcpPlans(execute: Execute, images: PushedImage[]): Promise { + const image = images.find(image => image.name === MANAGED_MCP_IMAGE_TARGET.name)?.image; + if (!image) return []; + const result = await execute("kubectl", ["get", "mcpservers", "-A", "-o", "json"], { stdio: "pipe" }); + const list = JSON.parse(String(result.stdout)); + if (!Array.isArray(list.items)) throw new Error("Managed MCP inventory is invalid"); + return list.items.filter((item: ObjectRecord) => item.spec?.managed?.preset === "everything").map((item: unknown) => { + const source = checked(item); + if (!source.metadata.namespace || !source.metadata.generation) throw new Error("Managed MCP source lacks workspace/generation"); + return { source, image }; + }); +} + +function sourceUnchanged(current: ObjectRecord, original: ObjectRecord): void { + if (current.metadata.uid !== original.metadata.uid || current.metadata.generation !== original.metadata.generation + || JSON.stringify(current.spec) !== JSON.stringify(original.spec)) { + throw new Error("Managed MCP source changed during image application; no CR spec was overwritten"); + } +} + +function owned(resource: ObjectRecord, source: ObjectRecord, namespaceUid: string): void { + const annotations = resource.metadata.annotations ?? {}; + if (resource.metadata.ownerReferences?.length + || resource.metadata.labels?.["app.kubernetes.io/managed-by"] !== "kars-controller" + || annotations["kars.azure.com/mcp-source-namespace"] !== source.metadata.namespace + || annotations["kars.azure.com/mcp-source-name"] !== source.metadata.name + || annotations["kars.azure.com/mcp-source-uid"] !== source.metadata.uid + || annotations["kars.azure.com/mcp-namespace-uid"] !== namespaceUid) { + throw new Error("Managed MCP workload ownership does not match its source/namespace UIDs"); + } +} + +export async function refreshManagedMcpImages( + execute: Execute, plans: ManagedMcpImagePlan[], controllerNamespace: string, +): Promise { + if (!plans.length) return 0; + const controller = await read(execute, "namespace", controllerNamespace); + for (const { source, image } of plans) { + const current = await read(execute, "mcpserver", source.metadata.name, source.metadata.namespace); + sourceUnchanged(current, source); + await execute("kubectl", ["patch", "mcpserver", source.metadata.name, "-n", source.metadata.namespace!, + "--type=merge", "-p", JSON.stringify({ metadata: { uid: current.metadata.uid, + resourceVersion: current.metadata.resourceVersion, annotations: { "kars.azure.com/image-refresh": String(Date.now()) } } })], + { stdio: "pipe" }); + await execute("kubectl", ["wait", `mcpserver/${source.metadata.name}`, "-n", source.metadata.namespace!, + `--for=jsonpath={.status.workloadImage}=${image}`, "--timeout=300s"], { stdio: "pipe" }); + const verified = await read(execute, "mcpserver", source.metadata.name, source.metadata.namespace); + sourceUnchanged(verified, source); + const status = verified.status; + if (status?.phase !== "Ready" || status.observedGeneration !== verified.metadata.generation + || status.workloadImage !== image || !status.workloadRef || !status.managedNamespaceUid) { + throw new Error("Managed MCP source did not qualify the selected image"); + } + const parts = String(status.workloadRef).split("/"); + if (parts.length !== 2 || parts.some(part => !/^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/.test(part))) { + throw new Error("Managed MCP workload reference is invalid"); + } + const [namespace, name] = parts; + const ns = await read(execute, "namespace", namespace); + if (ns.metadata.uid !== status.managedNamespaceUid + || ns.metadata.annotations?.["kars.azure.com/mcp-namespace-claim"] !== "v1" + || ns.metadata.annotations?.["kars.azure.com/mcp-controller-namespace"] !== controllerNamespace + || ns.metadata.annotations?.["kars.azure.com/mcp-controller-namespace-uid"] !== controller.metadata.uid) { + throw new Error("Managed MCP namespace ownership changed"); + } + const deployment = await read(execute, "deployment", name, namespace); + const service = await read(execute, "service", name, namespace); + owned(deployment, source, ns.metadata.uid); + owned(service, source, ns.metadata.uid); + const selector = { "kars.azure.com/mcp-source-uid": source.metadata.uid }; + if (JSON.stringify(deployment.spec?.selector?.matchLabels) !== JSON.stringify(selector) + || JSON.stringify(service.spec?.selector) !== JSON.stringify(selector) + || deployment.spec?.template?.spec?.containers?.find((container: { name: string }) => container.name === "mcp")?.image !== image + || deployment.status?.observedGeneration !== deployment.metadata.generation + || deployment.status?.availableReplicas !== 1 || deployment.status?.updatedReplicas !== 1 + || status.workloadGeneration !== deployment.metadata.generation) { + throw new Error("Managed MCP workload/Service did not converge to the qualified artifact and selector"); + } + } + return plans.length; +} diff --git a/controller/src/crd_validations.rs b/controller/src/crd_validations.rs index 67b609d1b..faa028353 100644 --- a/controller/src/crd_validations.rs +++ b/controller/src/crd_validations.rs @@ -107,12 +107,25 @@ pub fn mcp_server_validations() -> Vec { ValidationRule { rule: "!has(self.bundleRef) || (!has(self.url) && !has(self.oauth) && \ !has(self.productionMode) && !has(self.scopes) && \ - !has(self.allowedTools) && !has(self.displayName))" + !has(self.allowedTools) && !has(self.displayName) && !has(self.managed))" .into(), message: Some( "spec.bundleRef is mutually exclusive with spec.url, spec.oauth, \ spec.productionMode, spec.scopes, spec.allowedTools, and \ - spec.displayName" + spec.displayName, and spec.managed" + .into(), + ), + reason: Some("FieldValueInvalid".into()), + ..ValidationRule::default() + }, + ValidationRule { + rule: "!has(self.managed) || (!has(self.url) && !has(self.oauth) && \ + !has(self.productionMode) && !has(self.scopes) && \ + !has(self.bearerFromEnv) && !has(self.bundleRef))" + .into(), + message: Some( + "spec.managed is mutually exclusive with spec.url, spec.oauth, \ + spec.productionMode, spec.scopes, spec.bearerFromEnv, and spec.bundleRef" .into(), ), reason: Some("FieldValueInvalid".into()), diff --git a/controller/src/mcp_server.rs b/controller/src/mcp_server.rs index e86e97692..031902a5d 100644 --- a/controller/src/mcp_server.rs +++ b/controller/src/mcp_server.rs @@ -68,6 +68,12 @@ pub struct McpServerSpec { #[serde(default, skip_serializing_if = "Option::is_none")] pub url: Option, + /// Closed controller-managed workload preset. Mutually exclusive with + /// url, bundleRef and endpoint authentication fields; the controller + /// derives its private endpoint. No arbitrary workload image is accepted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub managed: Option, + /// OAuth 2.1 configuration. Required when `productionMode: true`. #[serde(default, skip_serializing_if = "Option::is_none")] pub oauth: Option, @@ -144,6 +150,21 @@ pub struct McpServerSpec { pub bearer_from_env: Option, } +/// A reviewed managed MCP workload, isolated from the controller namespace. +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ManagedMcpConfig { + pub preset: ManagedMcpPreset, +} + +/// Workloads for which Kars provides concrete deployment and protocol support. +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ManagedMcpPreset { + Playwright, + Everything, +} + #[derive(Debug, Serialize, Deserialize, Default, Clone, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct McpOAuthConfig { @@ -223,6 +244,41 @@ pub struct McpServerStatus { /// Slice 1c.5 of `crd-well-oiled-machine`. #[serde(default, skip_serializing_if = "Option::is_none")] pub bundle_ref_digest: Option, + + /// Managed or External. Managed readiness includes current workload + /// rollout and a successful bounded MCP initialize/tools/list probe. + #[serde(default)] + pub mode: Option, + + /// Effective upstream URL. Absent while a managed workload is unqualified. + #[serde(default)] + pub endpoint: Option, + + /// Display hint for the managed Deployment (namespace/name), never + /// sufficient authority for cleanup. + #[serde(default)] + pub workload_ref: Option, + + /// Exact API-server namespace incarnation holding the managed workload. + #[serde(default)] + pub managed_namespace_uid: Option, + + /// Deployment generation covered by the successful protocol probe. + #[serde(default)] + pub workload_generation: Option, + + /// Exact image reference covered by the successful protocol probe. + #[serde(default)] + pub workload_image: Option, + + /// Last successfully probed, allowlisted upstream tool names. + #[serde(default)] + pub discovered_tools: Option>, + + /// Content digest of the discovered tool definitions, not a signature or + /// supply-chain attestation. + #[serde(default)] + pub tool_schema_digest: Option, } /// Minimal `LocalObjectReference`-shaped struct with `name` only — the diff --git a/controller/src/mcp_server_reconciler.rs b/controller/src/mcp_server_reconciler.rs index 790756484..9e91c8119 100644 --- a/controller/src/mcp_server_reconciler.rs +++ b/controller/src/mcp_server_reconciler.rs @@ -41,7 +41,7 @@ use k8s_openapi::api::core::v1::{ConfigMap, Secret}; use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; use kube::{ Client, ResourceExt, - api::{Api, ListParams, ObjectMeta, Patch, PatchParams}, + api::{Api, ListParams, ObjectMeta, Patch, PatchParams, PostParams}, runtime::controller::{Action, Controller}, }; use rand::RngCore; @@ -52,12 +52,24 @@ use std::time::Duration; use crate::mcp_server::{LocalObjectRef, McpServer, McpServerStatus}; use crate::status::conditions::{self, reason, status as cond_status}; -use crate::status::phase::{PHASE_DEGRADED, PHASE_READY}; +use crate::status::phase::{PHASE_DEGRADED, PHASE_PENDING, PHASE_READY}; + +mod auxiliary; +mod events; +mod jwks; +pub(crate) mod managed; +mod source; +use auxiliary::{ensure_aux_owner, finalize}; +#[cfg(test)] +use jwks::{FetchError, FetchedJwks, parse_jwks_key_count}; +use jwks::{HttpJwksFetcher, JwksFetcher}; +use source::resolve_mcp_source; /// Field manager for SSA patches emitted by this reconciler. A unique /// suffix per reconciler is the §10.4 #1 craftsmanship requirement — /// detects out-of-band tampering. const FIELD_MANAGER: &str = crate::field_managers::MCP_SERVER; +const SOURCE_UID: &str = "kars.azure.com/mcp-source-uid"; /// Finalizer name (DNS subdomain). Mirrors /// `crate::reconciler::FINALIZER` shape. @@ -72,13 +84,6 @@ const SECRET_TYPE: &str = "kars.azure.com/mcp-signing-key"; /// operator-side rotation work and audit-log correlation. const KID_ANNOTATION: &str = "kars.azure.com/mcp-signing-kid"; -/// Maximum size of a JWKS document we will accept. Issuers serve -/// well-formed JWKS responses in the low-kilobytes; anything past 256 KiB -/// is almost certainly an attack or a misconfigured edge that returned -/// HTML. Matches the upper bound used by `mcp/oauth.rs::JwkSet` parsing -/// before deserialization rejects huge inputs anyway. -const MAX_JWKS_BYTES: usize = 256 * 1024; - /// Timeout for the issuer discovery + JWKS HTTP GETs. Bounded — the /// reconciler should never hang on a slow issuer. const HTTP_TIMEOUT_SECS: u64 = 10; @@ -95,153 +100,15 @@ enum ReconcileError { Kube(#[from] kube::Error), #[error("JSON serialization error: {0}")] SerdeJson(#[from] serde_json::Error), + #[error("MCP configuration: {0}")] + Configuration(String), } struct Ctx { client: Client, /// Override hook for tests — swap the JWKS fetcher with a mock. jwks_fetcher: Arc, -} - -/// Pluggable JWKS fetcher — production uses [`HttpJwksFetcher`], tests -/// provide deterministic fixtures. -#[async_trait::async_trait] -trait JwksFetcher: Send + Sync + std::fmt::Debug { - /// Return `(jwks_uri, raw_jwks_bytes)`. `error_class` strings on - /// failure: `"dns" | "tls" | "timeout" | "http_status" | "invalid_jwks_format"`. - async fn fetch(&self, issuer: &str) -> Result; -} - -#[derive(Debug, Clone)] -struct FetchedJwks { - jwks_uri: String, - raw: Vec, - /// Number of keys parsed from `raw`. Audit-event payload only. - key_count: usize, -} - -#[derive(Debug, thiserror::Error)] -enum FetchError { - #[error("issuer discovery: {class}: {detail}")] - Discovery { class: &'static str, detail: String }, - #[error("JWKS fetch: {class}: {detail}")] - Jwks { class: &'static str, detail: String }, - #[error("JWKS payload not a JWKSet: {0}")] - InvalidJwks(String), -} - -impl FetchError { - fn class(&self) -> &'static str { - match self { - FetchError::Discovery { class, .. } => class, - FetchError::Jwks { class, .. } => class, - FetchError::InvalidJwks(_) => "invalid_jwks_format", - } - } -} - -#[derive(Debug)] -struct HttpJwksFetcher { - client: reqwest::Client, -} - -impl HttpJwksFetcher { - fn new() -> Self { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(HTTP_TIMEOUT_SECS)) - .https_only(true) - .build() - .expect("reqwest client builder"); - Self { client } - } -} - -#[async_trait::async_trait] -impl JwksFetcher for HttpJwksFetcher { - async fn fetch(&self, issuer: &str) -> Result { - let trimmed = issuer.trim_end_matches('/'); - let discovery_url = format!("{trimmed}/.well-known/openid-configuration"); - let resp = self.client.get(&discovery_url).send().await.map_err(|e| { - let class = if e.is_timeout() { - "timeout" - } else if e.is_connect() { - "dns" - } else { - "tls" - }; - FetchError::Discovery { - class, - detail: e.to_string(), - } - })?; - if !resp.status().is_success() { - return Err(FetchError::Discovery { - class: "http_status", - detail: resp.status().to_string(), - }); - } - let discovery: serde_json::Value = - resp.json().await.map_err(|e| FetchError::Discovery { - class: "invalid_jwks_format", - detail: e.to_string(), - })?; - let jwks_uri = discovery - .get("jwks_uri") - .and_then(|v| v.as_str()) - .ok_or_else(|| FetchError::Discovery { - class: "invalid_jwks_format", - detail: "discovery document missing jwks_uri".into(), - })? - .to_string(); - - let resp = self.client.get(&jwks_uri).send().await.map_err(|e| { - let class = if e.is_timeout() { - "timeout" - } else if e.is_connect() { - "dns" - } else { - "tls" - }; - FetchError::Jwks { - class, - detail: e.to_string(), - } - })?; - if !resp.status().is_success() { - return Err(FetchError::Jwks { - class: "http_status", - detail: resp.status().to_string(), - }); - } - let bytes = resp.bytes().await.map_err(|e| FetchError::Jwks { - class: "tls", - detail: e.to_string(), - })?; - if bytes.len() > MAX_JWKS_BYTES { - return Err(FetchError::InvalidJwks(format!( - "JWKS exceeds {MAX_JWKS_BYTES} bytes" - ))); - } - let raw = bytes.to_vec(); - let key_count = parse_jwks_key_count(&raw)?; - Ok(FetchedJwks { - jwks_uri, - raw, - key_count, - }) - } -} - -/// Parse `keys` array length from a raw JWKSet payload. Used both by the -/// production fetcher and by the audit-event emitter. -fn parse_jwks_key_count(raw: &[u8]) -> Result { - let v: serde_json::Value = serde_json::from_slice(raw) - .map_err(|e| FetchError::InvalidJwks(format!("not JSON: {e}")))?; - let keys = v - .get("keys") - .and_then(|k| k.as_array()) - .ok_or_else(|| FetchError::InvalidJwks("missing or non-array `keys`".into()))?; - Ok(keys.len()) + probe_client: reqwest::Client, } async fn reconcile(mcp: Arc, ctx: Arc) -> Result { @@ -255,6 +122,12 @@ async fn reconcile(mcp: Arc, ctx: Arc) -> Result, ctx: Arc) -> Result, ctx: Arc) -> Result { + effective_spec.url = outcome.endpoint.clone(); + pending = outcome.pending.clone(); + managed_outcome = Some(outcome); + } + Err(error) => degraded = Some(("ManagedMcpUnqualified", error)), + } + } + if managed_mode { + effective_spec.url = managed_outcome + .as_ref() + .and_then(|outcome| outcome.endpoint.clone()); + effective_spec.oauth = None; + effective_spec.production_mode = None; + effective_spec.scopes = None; + effective_spec.bearer_from_env = None; + } - // 1. Ensure signing keypair Secret. + // Managed private upstreams need no new signer or endpoint credentials. let secret_name = format!("mcp-{name}-signing"); - let signing_kid = ensure_signing_secret(&secrets, &secret_name, &name).await?; + let signing_kid = if managed_mode { + None + } else { + Some(ensure_signing_secret(&secrets, &secret_name, &mcp).await?) + }; // 2. Ensure metadata/JWKS ConfigMap. The CM (`mcp-{name}-jwks`) is // ALWAYS created — its `meta.json` carries the upstream `url` + @@ -305,18 +215,27 @@ async fn reconcile(mcp: Arc, ctx: Arc) -> Result = None; - let mut degraded: Option<(&'static str, String)> = source_degraded; let production = effective_spec.production_mode.unwrap_or(false); - if degraded.is_none() && !production { + if (degraded.is_none() && !production) || managed_mode { // Dev mode: write metadata + empty JWKS default so the // router can discover the upstream URL even without inbound // OAuth. The router's `/mcp` route is mounted in dev mode // (no OAuth) when no `productionMode=true` McpServer is bound. let empty_jwks = b"{\"keys\":[]}"; - ensure_jwks_configmap(&configmaps, &cm_name, &name, empty_jwks, &meta).await?; + ensure_jwks_configmap(&configmaps, &cm_name, &mcp, empty_jwks, &meta).await?; jwks_ref = Some(LocalObjectRef { name: cm_name.clone(), }); @@ -330,14 +249,13 @@ async fn reconcile(mcp: Arc, ctx: Arc) -> Result { let meta = McpServerMeta::from_spec(&effective_spec); - ensure_jwks_configmap(&configmaps, &cm_name, &name, &fetched.raw, &meta) + ensure_jwks_configmap(&configmaps, &cm_name, &mcp, &fetched.raw, &meta) .await?; jwks_ref = Some(LocalObjectRef { name: cm_name.clone(), }); tracing::info!( mcp = %name, - jwks_uri = %fetched.jwks_uri, key_count = fetched.key_count, "McpServerJwksFetched" ); @@ -367,16 +285,24 @@ async fn reconcile(mcp: Arc, ctx: Arc) -> Result, ctx: Arc) -> Result, ctx: Arc) -> Result, secret_name: &str, - owner: &str, + owner: &McpServer, ) -> Result { - if let Ok(existing) = api.get(secret_name).await { + if let Some(existing) = api.get_opt(secret_name).await? { + ensure_aux_owner(&existing.metadata, owner, false)?; if let Some(kid) = existing .metadata .annotations @@ -555,6 +496,12 @@ async fn ensure_signing_secret( data.insert("signing-key.public".into(), ByteString(public_raw.to_vec())); let mut annotations: BTreeMap = BTreeMap::new(); annotations.insert(KID_ANNOTATION.into(), kid.clone()); + annotations.insert( + SOURCE_UID.into(), + owner + .uid() + .ok_or_else(|| ReconcileError::Configuration("McpServer UID is missing".into()))?, + ); let secret = Secret { metadata: ObjectMeta { @@ -565,7 +512,7 @@ async fn ensure_signing_secret( "app.kubernetes.io/managed-by".into(), "kars-controller".into(), ), - ("kars.azure.com/mcp-server".into(), owner.into()), + ("kars.azure.com/mcp-server".into(), owner.name_any()), ])), ..Default::default() }, @@ -573,10 +520,12 @@ async fn ensure_signing_secret( data: Some(data), ..Default::default() }; - api.patch( - secret_name, - &PatchParams::apply(FIELD_MANAGER).force(), - &Patch::Apply(&secret), + api.create( + &PostParams { + field_manager: Some(FIELD_MANAGER.into()), + ..Default::default() + }, + &secret, ) .await?; tracing::info!(secret = secret_name, kid = %kid, "McpServerSigningKeyCreated"); @@ -639,6 +588,12 @@ pub struct McpServerMeta { /// to this server. Empty (default) = no outbound auth. #[serde(default, skip_serializing_if = "String::is_empty")] pub bearer_from_env: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_uid: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_generation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub binding_revision: Option, } impl McpServerMeta { @@ -658,6 +613,9 @@ impl McpServerMeta { url: spec.url.clone().unwrap_or_default(), allowed_tools: spec.allowed_tools.clone().unwrap_or_default(), bearer_from_env: spec.bearer_from_env.clone().unwrap_or_default(), + source_uid: None, + source_generation: None, + binding_revision: None, } } } @@ -665,7 +623,7 @@ impl McpServerMeta { async fn ensure_jwks_configmap( api: &Api, cm_name: &str, - owner: &str, + owner: &McpServer, raw_jwks: &[u8], meta: &McpServerMeta, ) -> Result<(), ReconcileError> { @@ -682,6 +640,10 @@ async fn ensure_jwks_configmap( // mirrored ConfigMaps so each McpServer's tokens are validated // against that server's JWKS + audience. data.insert("meta.json".into(), meta_json); + let existing = api.get_opt(cm_name).await?; + if let Some(existing) = existing.as_ref() { + ensure_aux_owner(&existing.metadata, owner, owner.spec.managed.is_some())?; + } let cm = ConfigMap { metadata: ObjectMeta { name: Some(cm_name.into()), @@ -690,74 +652,45 @@ async fn ensure_jwks_configmap( "app.kubernetes.io/managed-by".into(), "kars-controller".into(), ), - ("kars.azure.com/mcp-server".into(), owner.into()), + ("kars.azure.com/mcp-server".into(), owner.name_any()), ])), ..Default::default() }, data: Some(data), ..Default::default() }; - api.patch( - cm_name, - &PatchParams::apply(FIELD_MANAGER).force(), - &Patch::Apply(&cm), - ) - .await?; + if let Some(existing) = existing { + if existing.data != cm.data { + api.patch(cm_name, &PatchParams::default(), &Patch::Merge(json!({ + "metadata":{"uid":existing.metadata.uid,"resourceVersion":existing.metadata.resource_version}, + "data":cm.data, + }))).await?; + } + } else { + let mut cm = cm; + cm.metadata.annotations = Some(BTreeMap::from([( + SOURCE_UID.into(), + owner + .uid() + .ok_or_else(|| ReconcileError::Configuration("McpServer UID is missing".into()))?, + )])); + api.create( + &PostParams { + field_manager: Some(FIELD_MANAGER.into()), + ..Default::default() + }, + &cm, + ) + .await?; + } Ok(()) } -async fn finalize( - api: &Api, - secrets: &Api, - configmaps: &Api, - mcp: &McpServer, - name: &str, -) -> Result { - let secret_name = format!("mcp-{name}-signing"); - let cm_name = format!("mcp-{name}-jwks"); - let _ = secrets - .delete(&secret_name, &Default::default()) - .await - .map(|_| ()) - .or_else(|e: kube::Error| -> Result<(), kube::Error> { - if matches!(e, kube::Error::Api(ref ae) if ae.code == 404) { - Ok(()) - } else { - Err(e) - } - }); - let _ = configmaps - .delete(&cm_name, &Default::default()) - .await - .map(|_| ()) - .or_else(|e: kube::Error| -> Result<(), kube::Error> { - if matches!(e, kube::Error::Api(ref ae) if ae.code == 404) { - Ok(()) - } else { - Err(e) - } - }); - - let finalizers: Vec = mcp - .metadata - .finalizers - .as_ref() - .map(|v| v.iter().filter(|f| *f != FINALIZER).cloned().collect()) - .unwrap_or_default(); - let patch = json!({"apiVersion":"kars.azure.com/v1alpha1","kind":"McpServer","metadata":{"finalizers": finalizers}}); - api.patch( - name, - &PatchParams::apply(FIELD_MANAGER).force(), - &Patch::Apply(patch), - ) - .await?; - Ok(Action::await_change()) -} - fn error_policy(mcp: Arc, error: &ReconcileError, _ctx: Arc) -> Action { let class = match error { ReconcileError::Kube(_) => "kube_api", ReconcileError::SerdeJson(_) => "serde", + ReconcileError::Configuration(_) => "configuration", }; crate::metrics::record_reconcile_error("McpServer", class); tracing::warn!( @@ -789,8 +722,23 @@ pub async fn run(client: Client) -> Result<()> { let ctx = Arc::new(Ctx { client: client.clone(), jwks_fetcher: Arc::new(HttpJwksFetcher::new()), + probe_client: reqwest::Client::builder() + .timeout(Duration::from_secs(HTTP_TIMEOUT_SECS)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| anyhow::anyhow!("MCP probe client initialization failed"))?, }); Controller::new(mcps, crate::watch_config::bounded()) + .watches( + Api::::all(client.clone()), + crate::watch_config::bounded(), + events::sandbox_references, + ) + .watches( + Api::::all(client), + crate::watch_config::bounded(), + events::workload_reference, + ) .run( |x, ctx| async move { crate::metrics::observe_reconcile("McpServer", reconcile(x, ctx)).await @@ -808,322 +756,5 @@ pub async fn run(client: Client) -> Result<()> { Ok(()) } -/// Resolve the effective spec the reconciler will operate on. -/// -/// Slice 1c.5 of `crd-well-oiled-machine` introduces a signed -/// `bundleRef` authoring path for `McpServer`. This helper closes the -/// inline-vs-bundle authoring choice with a single normalised -/// `McpServerSpec` returned to the reconcile loop: -/// -/// - **Inline (back-compat, no signature)**: any of `url`, `oauth`, -/// `productionMode`, `scopes`, `allowedTools`, `displayName` set; no -/// `bundleRef`. Returns the spec verbatim. `bundle_ref_digest = None`. -/// - **Signed bundle**: `bundleRef` set, content fields all `None`. -/// Fetches + verifies the OCI artifact via -/// [`crate::policy_fetcher::fetch_and_verify_generic`] parameterised -/// by [`crate::policy_canonical::mcp_server::McpServerKind`]. The -/// bundle's content fields are merged onto the CR's -/// `allowedSandboxes` selector. `bundle_ref_digest = Some()`. -/// - **Selector-only**: no `bundleRef` and no content fields set. -/// Acceptable shape (the CR carries only a selector) but -/// `productionMode` defaults to `false` and `url` resolves to empty -/// — the reconciler treats this as a degraded `SpecInvalid` only -/// when `productionMode` would also be `true`; selector-only -/// prod-mode-false is intentionally allowed for in-progress -/// authoring drafts. -/// - **Both inline + bundleRef** *(rejected at runtime as -/// defense-in-depth — admission CEL already rejects)*: returns -/// `(InvalidSpec, msg)` without performing the fetch. -async fn resolve_mcp_source( - mcp: &crate::mcp_server::McpServer, -) -> ( - crate::mcp_server::McpServerSpec, - Option, - Option<(&'static str, String)>, -) { - let spec = &mcp.spec; - let inline_any = spec.url.is_some() - || spec.oauth.is_some() - || spec.production_mode.is_some() - || spec.scopes.is_some() - || spec.allowed_tools.is_some() - || spec.display_name.is_some(); - let bundle_set = spec.bundle_ref.is_some(); - - if inline_any && bundle_set { - return ( - // selector-only synthesis; we won't compile this branch - crate::mcp_server::McpServerSpec { - allowed_sandboxes: spec.allowed_sandboxes.clone(), - ..Default::default() - }, - None, - Some(( - "InvalidSpec", - "spec.bundleRef is mutually exclusive with spec.url, spec.oauth, \ - spec.productionMode, spec.scopes, spec.allowedTools, and \ - spec.displayName" - .into(), - )), - ); - } - - if !bundle_set { - return (spec.clone(), None, None); - } - - let bundle_ref = spec - .bundle_ref - .as_ref() - .expect("bundle_set implies Some") - .clone(); - - let signer_policy_handle = crate::signer_policy::global(); - let verify_result = match signer_policy_handle.snapshot() { - crate::signer_policy::SignerPolicyState::FromConfigMap(p) => { - let cfg: crate::policy_fetcher::SignerPolicyConfig = p.into(); - crate::policy_fetcher::fetch_and_verify_generic::< - crate::policy_canonical::mcp_server::McpServerKind, - >(&bundle_ref, &cfg) - .await - } - crate::signer_policy::SignerPolicyState::Malformed(msg) => Err( - crate::policy_fetcher::FetchError::SignerPolicyMalformed(msg), - ), - crate::signer_policy::SignerPolicyState::Absent => { - let cfg = crate::policy_fetcher::SignerPolicyConfig::from_env(); - crate::policy_fetcher::fetch_and_verify_generic::< - crate::policy_canonical::mcp_server::McpServerKind, - >(&bundle_ref, &cfg) - .await - } - }; - - match verify_result { - Ok(verified) => { - let effective = merge_bundle_with_selector(spec, &verified); - (effective, Some(verified.digest), None) - } - Err(e) => { - let (reason, msg) = fetch_error_to_degraded(&e); - tracing::warn!( - mcpserver = %mcp.name_any(), - registry = %bundle_ref.registry, - repository = %bundle_ref.repository, - digest = %bundle_ref.digest, - reason, - "McpServer bundleRef fetch/verify failed: {msg}" - ); - ( - crate::mcp_server::McpServerSpec { - allowed_sandboxes: spec.allowed_sandboxes.clone(), - ..Default::default() - }, - None, - Some((reason, msg)), - ) - } - } -} - -/// Merge the verified bundle's content fields onto the CR's -/// `allowedSandboxes` selector. The bundle owns the content; the CR -/// owns the selector — same pattern as InferencePolicy + KarsMemory. -fn merge_bundle_with_selector( - cr_spec: &crate::mcp_server::McpServerSpec, - verified: &crate::policy_canonical::mcp_server::VerifiedMcpServerBundle, -) -> crate::mcp_server::McpServerSpec { - use crate::mcp_server::{McpOAuthConfig, McpServerSpec}; - - let oauth = verified.oauth.as_ref().map(|o| McpOAuthConfig { - issuer: o.issuer.clone(), - audience: o.audience.clone(), - resource: o.resource.clone(), - pkce: o.pkce.clone().unwrap_or_else(|| "S256".to_string()), - }); - - McpServerSpec { - url: verified.url.clone(), - oauth, - production_mode: verified.production_mode, - scopes: verified.scopes.clone(), - allowed_tools: verified.allowed_tools.clone(), - allowed_sandboxes: cr_spec.allowed_sandboxes.clone(), - display_name: verified.display_name.clone(), - bundle_ref: None, - // Bundle-sourced spec does not carry outbound bearer config — - // bearer hookup is a CR-level concern (per-deployment), not - // part of the signed policy bundle. - bearer_from_env: cr_spec.bearer_from_env.clone(), - } -} - -/// Map [`crate::policy_fetcher::FetchError`] to the `(reason, message)` -/// degraded pair. Mirrors the same helper in the other 1c.x reconcilers -/// — the controller's class table stays closed. -fn fetch_error_to_degraded(e: &crate::policy_fetcher::FetchError) -> (&'static str, String) { - let reason = crate::policy_fetcher::reason_for_error(e).unwrap_or("Transient"); - (reason, e.to_string()) -} - #[cfg(test)] -mod tests { - use super::*; - - /// Mock fetcher returning a known JWKS. - #[derive(Debug)] - struct MockOk; - #[async_trait::async_trait] - impl JwksFetcher for MockOk { - async fn fetch(&self, _: &str) -> Result { - let raw = br#"{"keys":[{"kty":"OKP","crv":"Ed25519","kid":"k1","x":"AAA"}]}"#.to_vec(); - Ok(FetchedJwks { - jwks_uri: "https://example/.well-known/jwks.json".into(), - raw, - key_count: 1, - }) - } - } - - /// Mock fetcher always failing with a discovery error. - #[derive(Debug)] - struct MockFailDns; - #[async_trait::async_trait] - impl JwksFetcher for MockFailDns { - async fn fetch(&self, _: &str) -> Result { - Err(FetchError::Discovery { - class: "dns", - detail: "name resolution failed".into(), - }) - } - } - - #[test] - fn parse_jwks_key_count_works() { - let raw = br#"{"keys":[{"kid":"a"},{"kid":"b"}]}"#; - assert_eq!(parse_jwks_key_count(raw).unwrap(), 2); - - let bad = br#"{"foo":"bar"}"#; - assert!(parse_jwks_key_count(bad).is_err()); - } - - #[test] - fn kid_from_public_is_deterministic_and_short() { - let pub_bytes = [0u8; 32]; - let kid = kid_from_public_bytes(&pub_bytes); - // 16 bytes -> URL-safe-no-pad b64 is 22 chars - assert_eq!(kid.len(), 22); - assert_eq!(kid, kid_from_public_bytes(&pub_bytes)); - assert!(!kid.contains('=')); - } - - #[test] - fn build_conditions_emits_three_types_on_success() { - let conds = build_conditions(&[], Some(7), None); - assert_eq!(conds.len(), 3); - let ready = conds.iter().find(|c| c.type_ == "Ready").unwrap(); - assert_eq!(ready.status, "True"); - let progressing = conds.iter().find(|c| c.type_ == "Progressing").unwrap(); - assert_eq!(progressing.status, "False"); - let degraded = conds.iter().find(|c| c.type_ == "Degraded").unwrap(); - assert_eq!(degraded.status, "False"); - for c in &conds { - assert_eq!(c.observed_generation, Some(7)); - } - } - - #[test] - fn build_conditions_emits_degraded_true_on_failure() { - let conds = build_conditions(&[], Some(2), Some(("JwksFetchFailed", "boom"))); - let ready = conds.iter().find(|c| c.type_ == "Ready").unwrap(); - assert_eq!(ready.status, "False"); - assert_eq!(ready.reason, "JwksFetchFailed"); - let degraded = conds.iter().find(|c| c.type_ == "Degraded").unwrap(); - assert_eq!(degraded.status, "True"); - assert_eq!(degraded.message, "boom"); - } - - #[test] - fn build_conditions_preserves_transition_time_on_repeat_success() { - let prior = build_conditions(&[], Some(1), None); - std::thread::sleep(std::time::Duration::from_millis(5)); - let next = build_conditions(&prior, Some(1), None); - let p_ready = prior.iter().find(|c| c.type_ == "Ready").unwrap(); - let n_ready = next.iter().find(|c| c.type_ == "Ready").unwrap(); - assert_eq!(p_ready.last_transition_time, n_ready.last_transition_time); - } - - #[test] - fn build_conditions_stamps_new_time_on_status_flip() { - let prior = build_conditions(&[], Some(1), None); - std::thread::sleep(std::time::Duration::from_millis(5)); - let next = build_conditions(&prior, Some(1), Some(("JwksFetchFailed", "x"))); - let p_ready = prior.iter().find(|c| c.type_ == "Ready").unwrap(); - let n_ready = next.iter().find(|c| c.type_ == "Ready").unwrap(); - assert_ne!(p_ready.last_transition_time, n_ready.last_transition_time); - } - - #[test] - fn fetch_error_class_buckets_are_safe_strings() { - // Audit-event policy: error_class is always a fixed bucket, - // never a raw error message. Verify the enum's `class()` method - // only ever yields one of the documented strings. - for class in [ - FetchError::Discovery { - class: "dns", - detail: "x".into(), - } - .class(), - FetchError::Discovery { - class: "tls", - detail: "x".into(), - } - .class(), - FetchError::Discovery { - class: "timeout", - detail: "x".into(), - } - .class(), - FetchError::Discovery { - class: "http_status", - detail: "x".into(), - } - .class(), - FetchError::Jwks { - class: "tls", - detail: "x".into(), - } - .class(), - FetchError::InvalidJwks("x".into()).class(), - ] { - assert!( - matches!( - class, - "dns" | "tls" | "timeout" | "http_status" | "invalid_jwks_format" - ), - "class={class:?}" - ); - } - } - - #[test] - fn mock_fetchers_compile_and_do_not_panic() { - // Tokio-free smoke: the trait object is constructable. - let _ok: Arc = Arc::new(MockOk); - let _fail: Arc = Arc::new(MockFailDns); - } - - #[tokio::test] - async fn mock_ok_returns_one_key() { - let m = MockOk; - let f = m.fetch("https://example").await.unwrap(); - assert_eq!(f.key_count, 1); - } - - #[tokio::test] - async fn mock_fail_dns_classifies() { - let m = MockFailDns; - let e = m.fetch("https://example").await.unwrap_err(); - assert_eq!(e.class(), "dns"); - } -} +mod tests; diff --git a/controller/src/mcp_server_reconciler/auxiliary.rs b/controller/src/mcp_server_reconciler/auxiliary.rs new file mode 100644 index 000000000..32ea63291 --- /dev/null +++ b/controller/src/mcp_server_reconciler/auxiliary.rs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::*; +use kube::api::{DeleteParams, Preconditions}; + +pub(super) fn ensure_aux_owner( + meta: &ObjectMeta, + mcp: &McpServer, + require_uid: bool, +) -> Result<(), ReconcileError> { + let uid = meta + .annotations + .as_ref() + .and_then(|annotations| annotations.get(SOURCE_UID)); + if meta.uid.as_deref().is_none_or(str::is_empty) + || meta.resource_version.as_deref().is_none_or(str::is_empty) + || meta.deletion_timestamp.is_some() + || meta.namespace != mcp.metadata.namespace + || meta + .owner_references + .as_ref() + .is_some_and(|owners| !owners.is_empty()) + || meta + .labels + .as_ref() + .and_then(|labels| labels.get("app.kubernetes.io/managed-by")) + .map(String::as_str) + != Some("kars-controller") + || meta + .labels + .as_ref() + .and_then(|labels| labels.get("kars.azure.com/mcp-server")) + .map(String::as_str) + != Some(mcp.name_any().as_str()) + || uid.is_some_and(|uid| Some(uid) != mcp.metadata.uid.as_ref()) + || (require_uid && uid != mcp.metadata.uid.as_ref()) + { + return Err(ReconcileError::Configuration( + "MCP auxiliary resource ownership conflicts; no adoption permitted".into(), + )); + } + Ok(()) +} + +fn exact_aux_owner(meta: &ObjectMeta, mcp: &McpServer) -> bool { + let mut live = meta.clone(); + live.deletion_timestamp = None; + meta.annotations + .as_ref() + .and_then(|annotations| annotations.get(SOURCE_UID)) + == mcp.metadata.uid.as_ref() + && mcp.uid().is_some() + && ensure_aux_owner(&live, mcp, true).is_ok() +} + +pub(super) async fn finalize( + api: &Api, + secrets: &Api, + configmaps: &Api, + mcp: &McpServer, + name: &str, +) -> Result { + let secret_name = format!("mcp-{name}-signing"); + let cm_name = format!("mcp-{name}-jwks"); + if let Some(secret) = secrets.get_opt(&secret_name).await? { + if exact_aux_owner(&secret.metadata, mcp) { + if secret.metadata.deletion_timestamp.is_none() { + secrets + .delete( + &secret_name, + &DeleteParams { + preconditions: Some(Preconditions { + uid: secret.metadata.uid, + resource_version: secret.metadata.resource_version, + }), + ..Default::default() + }, + ) + .await?; + } + if secrets.get_opt(&secret_name).await?.is_some() { + return Ok(Action::requeue(Duration::from_secs(5))); + } + } else { + tracing::warn!(mcp = %name, "Preserving legacy/unowned MCP signing Secret during cleanup"); + } + } + if let Some(cm) = configmaps.get_opt(&cm_name).await? { + if exact_aux_owner(&cm.metadata, mcp) { + if cm.metadata.deletion_timestamp.is_none() { + configmaps + .delete( + &cm_name, + &DeleteParams { + preconditions: Some(Preconditions { + uid: cm.metadata.uid, + resource_version: cm.metadata.resource_version, + }), + ..Default::default() + }, + ) + .await?; + } + if configmaps.get_opt(&cm_name).await?.is_some() { + return Ok(Action::requeue(Duration::from_secs(5))); + } + } else { + tracing::warn!(mcp = %name, "Preserving legacy/unowned MCP metadata ConfigMap during cleanup"); + } + } + let finalizers: Vec<_> = mcp + .metadata + .finalizers + .as_ref() + .map(|values| { + values + .iter() + .filter(|value| *value != FINALIZER) + .cloned() + .collect() + }) + .unwrap_or_default(); + api.patch(name, &PatchParams::default(), &Patch::Merge(json!({ + "metadata":{"uid":mcp.metadata.uid,"resourceVersion":mcp.metadata.resource_version,"finalizers":finalizers}, + }))).await?; + Ok(Action::await_change()) +} diff --git a/controller/src/mcp_server_reconciler/events.rs b/controller/src/mcp_server_reconciler/events.rs new file mode 100644 index 000000000..59dd33572 --- /dev/null +++ b/controller/src/mcp_server_reconciler/events.rs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::{crd::KarsSandbox, mcp_server::McpServer}; +use k8s_openapi::api::apps::v1::Deployment; +use kube::{ResourceExt, runtime::reflector::ObjectRef}; + +pub(super) fn sandbox_references(sandbox: KarsSandbox) -> Vec> { + let Some(workspace) = sandbox.namespace() else { + return Vec::new(); + }; + sandbox + .spec + .governance + .unwrap_or_default() + .effective_mcp_server_refs() + .iter() + .map(|reference| ObjectRef::new(&reference.name).within(&workspace)) + .collect() +} + +pub(super) fn workload_reference(deployment: Deployment) -> Option> { + let annotations = deployment.metadata.annotations.as_ref()?; + let namespace = annotations.get("kars.azure.com/mcp-source-namespace")?; + let name = annotations.get("kars.azure.com/mcp-source-name")?; + if annotations + .get("kars.azure.com/mcp-source-uid") + .is_none_or(String::is_empty) + || namespace.is_empty() + || name.is_empty() + { + return None; + } + // Watch mapping is a scheduling hint, never resource-mutation authority. + Some(ObjectRef::new(name).within(namespace)) +} diff --git a/controller/src/mcp_server_reconciler/jwks.rs b/controller/src/mcp_server_reconciler/jwks.rs new file mode 100644 index 000000000..83046233c --- /dev/null +++ b/controller/src/mcp_server_reconciler/jwks.rs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::time::Duration; + +const MAX_JWKS_BYTES: usize = 256 * 1024; + +#[async_trait::async_trait] +pub(super) trait JwksFetcher: Send + Sync + std::fmt::Debug { + async fn fetch(&self, issuer: &str) -> Result; +} + +#[derive(Debug, Clone)] +pub(super) struct FetchedJwks { + pub raw: Vec, + pub key_count: usize, +} + +#[derive(Debug, thiserror::Error)] +pub(super) enum FetchError { + #[error("issuer discovery: {class}: {detail}")] + Discovery { class: &'static str, detail: String }, + #[error("JWKS fetch: {class}: {detail}")] + Jwks { class: &'static str, detail: String }, + #[error("JWKS payload not a JWKSet: {0}")] + InvalidJwks(String), +} + +impl FetchError { + pub(super) fn class(&self) -> &'static str { + match self { + Self::Discovery { class, .. } | Self::Jwks { class, .. } => class, + Self::InvalidJwks(_) => "invalid_jwks_format", + } + } +} + +#[derive(Debug)] +pub(super) struct HttpJwksFetcher { + client: reqwest::Client, +} + +impl HttpJwksFetcher { + pub(super) fn new() -> Self { + Self { + client: reqwest::Client::builder() + .timeout(Duration::from_secs(super::HTTP_TIMEOUT_SECS)) + .https_only(true) + .build() + .expect("JWKS client builder"), + } + } +} + +fn transport_class(error: &reqwest::Error) -> &'static str { + if error.is_timeout() { + "timeout" + } else if error.is_connect() { + "dns" + } else { + "tls" + } +} + +#[async_trait::async_trait] +impl JwksFetcher for HttpJwksFetcher { + async fn fetch(&self, issuer: &str) -> Result { + let url = format!( + "{}/.well-known/openid-configuration", + issuer.trim_end_matches('/') + ); + let response = + self.client + .get(url) + .send() + .await + .map_err(|error| FetchError::Discovery { + class: transport_class(&error), + detail: "transport failed".into(), + })?; + if !response.status().is_success() { + return Err(FetchError::Discovery { + class: "http_status", + detail: response.status().to_string(), + }); + } + let discovery: serde_json::Value = + response.json().await.map_err(|_| FetchError::Discovery { + class: "invalid_jwks_format", + detail: "invalid discovery JSON".into(), + })?; + let jwks_uri = discovery["jwks_uri"] + .as_str() + .ok_or_else(|| FetchError::Discovery { + class: "invalid_jwks_format", + detail: "discovery document missing jwks_uri".into(), + })? + .to_string(); + let response = + self.client + .get(&jwks_uri) + .send() + .await + .map_err(|error| FetchError::Jwks { + class: transport_class(&error), + detail: "transport failed".into(), + })?; + if !response.status().is_success() { + return Err(FetchError::Jwks { + class: "http_status", + detail: response.status().to_string(), + }); + } + let bytes = response.bytes().await.map_err(|_| FetchError::Jwks { + class: "tls", + detail: "response transport failed".into(), + })?; + if bytes.len() > MAX_JWKS_BYTES { + return Err(FetchError::InvalidJwks(format!( + "JWKS exceeds {MAX_JWKS_BYTES} bytes" + ))); + } + let raw = bytes.to_vec(); + let key_count = parse_jwks_key_count(&raw)?; + Ok(FetchedJwks { raw, key_count }) + } +} + +pub(super) fn parse_jwks_key_count(raw: &[u8]) -> Result { + let value: serde_json::Value = + serde_json::from_slice(raw).map_err(|_| FetchError::InvalidJwks("not JSON".into()))?; + value["keys"] + .as_array() + .map(Vec::len) + .ok_or_else(|| FetchError::InvalidJwks("missing or non-array keys".into())) +} diff --git a/controller/src/mcp_server_reconciler/managed/mod.rs b/controller/src/mcp_server_reconciler/managed/mod.rs new file mode 100644 index 000000000..7fae74f87 --- /dev/null +++ b/controller/src/mcp_server_reconciler/managed/mod.rs @@ -0,0 +1,406 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod ownership; +mod plan; +mod probe; +#[cfg(test)] +mod probe_tests; +#[cfg(test)] +mod tests; + +use crate::{crd::KarsSandbox, mcp_server::McpServer}; +use k8s_openapi::api::{ + apps::v1::Deployment, + core::v1::{Namespace, Secret, Service}, + networking::v1::NetworkPolicy, +}; +use kube::{Api, Client, ResourceExt, api::ListParams}; +use ownership::api_error; +use plan::{Config, Owner, Plan}; +use serde_json::{Value, json}; + +pub(super) struct Outcome { + pub endpoint: Option, + pub workload_ref: String, + pub namespace_uid: String, + pub tools: Option>, + pub schema_digest: Option, + pub revision: String, + pub pending: Option, + pub workload_generation: Option, + pub workload_image: Option, +} + +pub(super) fn pending_conditions( + prior: &[k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition], + generation: Option, + message: &str, +) -> Vec { + use crate::status::conditions::{find, preserve_transition_time}; + [ + ("Ready", "False"), + ("Progressing", "True"), + ("Degraded", "False"), + ] + .into_iter() + .map(|(kind, status)| { + preserve_transition_time( + find(prior, kind), + kind, + status, + "ManagedMcpPending", + message, + generation, + ) + }) + .collect() +} + +pub(crate) fn selected_for(mcp: &McpServer, sandbox: &KarsSandbox) -> bool { + mcp.namespace() == sandbox.namespace() + && mcp.metadata.deletion_timestamp.is_none() + && mcp.spec.allowed_sandboxes.as_ref().is_none_or(|selector| { + selector.match_labels.iter().all(|(key, expected)| { + sandbox + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get(key)) + == Some(expected) + }) + }) +} + +pub(crate) async fn qualified_endpoint( + client: &Client, + source: &McpServer, +) -> Result, String> { + let Some(status) = source.status.as_ref().filter(|status| { + status.phase.as_deref() == Some("Ready") + && status.observed_generation == source.metadata.generation + }) else { + return Ok(None); + }; + if source.spec.managed.is_none() { + return Ok(source.spec.url.clone().or_else(|| { + source + .spec + .bundle_ref + .as_ref() + .filter(|bundle| { + status.bundle_ref_digest.as_deref() == Some(bundle.digest.as_str()) + }) + .and_then(|_| status.endpoint.clone()) + })); + } + let config = Config::from_env()?; + let plan = Plan::new(source, &config)?; + if status.workload_ref.as_deref() != Some(plan.owner.workload_ref().as_str()) + || status.endpoint.as_deref() != Some(plan.endpoint().as_str()) + || status + .managed_namespace_uid + .as_deref() + .is_none_or(str::is_empty) + { + return Ok(None); + } + let Some(namespace) = ownership::namespace(client, &config, source, false).await? else { + return Ok(None); + }; + let uid = namespace.uid().ok_or("Managed MCP namespace UID missing")?; + let deployments: Api = Api::namespaced(client.clone(), &config.namespace); + let services: Api = Api::namespaced(client.clone(), &config.namespace); + let Some(deployment) = ownership::preflight(&deployments, &plan.owner, &uid).await? else { + return Ok(None); + }; + if ownership::preflight(&services, &plan.owner, &uid) + .await? + .is_none() + || status.workload_generation != deployment.metadata.generation + || status.workload_image.as_deref() != Some(plan.image.as_str()) + || deployment.status.as_ref().is_none_or(|status| { + status.observed_generation != deployment.metadata.generation + || status.updated_replicas != Some(1) + || status.available_replicas != Some(1) + }) + || deployment + .spec + .as_ref() + .and_then(|spec| spec.template.spec.as_ref()) + .is_none_or(|spec| { + spec.containers + .iter() + .find(|container| container.name == "mcp") + .and_then(|container| container.image.as_deref()) + != Some(plan.image.as_str()) + }) + { + return Ok(None); + } + Ok(Some(plan.endpoint())) +} + +async fn peers(client: &Client, source: &McpServer, config: &Config) -> Result, String> { + let mut peers = vec![json!({ + "namespaceSelector":{"matchLabels":{"kubernetes.io/metadata.name":config.controller_namespace}}, + "podSelector":{"matchLabels":{"app.kubernetes.io/component":"controller"}}, + })]; + let sandboxes = Api::::namespaced( + client.clone(), + &source.namespace().ok_or("McpServer workspace missing")?, + ) + .list(&ListParams::default()) + .await + .map_err(|error| api_error("List MCP referring sandboxes", error))?; + let namespaces: Api = Api::all(client.clone()); + for sandbox in sandboxes { + if !selected_for(source, &sandbox) || sandbox.metadata.deletion_timestamp.is_some() { + continue; + } + let governance = sandbox.spec.governance.clone().unwrap_or_default(); + if !governance + .effective_mcp_server_refs() + .iter() + .any(|reference| reference.name == source.name_any()) + { + continue; + } + let namespace_name = format!("kars-{}", sandbox.name_any()); + let Some(namespace) = namespaces + .get_opt(&namespace_name) + .await + .map_err(|error| api_error("Read MCP caller namespace", error))? + else { + continue; + }; + if namespace.metadata.deletion_timestamp.is_some() + || !crate::reconciler::namespace_ownership::claimed(&namespace, &sandbox) + .map_err(|_| "MCP caller namespace ownership conflicts")? + { + continue; + } + peers.push(json!({ + "namespaceSelector":{"matchLabels":{"kubernetes.io/metadata.name":namespace_name}}, + "podSelector":{"matchLabels":{"kars.azure.com/sandbox":sandbox.name_any()}}, + })); + } + Ok(peers) +} + +pub(super) fn validate_source(source: &McpServer) -> Result<(), String> { + let spec = &source.spec; + if spec.managed.is_none() + || spec.url.is_some() + || spec.bundle_ref.is_some() + || spec.oauth.is_some() + || spec.production_mode.is_some() + || spec.scopes.is_some() + || spec.bearer_from_env.is_some() + { + return Err( + "Managed MCP cannot combine workload presets with URL/bundle/auth source fields".into(), + ); + } + Ok(()) +} + +pub(super) async fn reconcile( + client: &Client, + source: &McpServer, + http: &reqwest::Client, +) -> Result { + validate_source(source)?; + ownership::source_current(client, source, false).await?; + let config = Config::from_env()?; + let plan = Plan::new(source, &config)?; + if source + .status + .as_ref() + .and_then(|status| status.workload_ref.as_deref()) + .is_some_and(|reference| reference != plan.owner.workload_ref()) + { + return Err("Managed MCP namespace/name changed; retire the recorded workload before reconfiguration".into()); + } + let namespace = ownership::namespace(client, &config, source, true) + .await? + .ok_or("Managed MCP namespace is absent")?; + let namespace_uid = namespace.uid().ok_or("Managed MCP namespace UID missing")?; + let mut outcome = Outcome { + endpoint: None, + workload_ref: plan.owner.workload_ref(), + namespace_uid: namespace_uid.clone(), + tools: None, + schema_digest: None, + revision: String::new(), + pending: None, + workload_generation: None, + workload_image: None, + }; + if source + .status + .as_ref() + .and_then(|status| status.managed_namespace_uid.as_ref()) + .is_none() + { + outcome.pending = Some( + "Recording the exact managed namespace incarnation before creating workloads".into(), + ); + return Ok(outcome); + } + if let Some(name) = config.pull_secret.as_ref() { + let secret = Api::::namespaced(client.clone(), &config.namespace) + .get_opt(name) + .await + .map_err(|error| { + api_error("Inspect explicitly configured local MCP pull Secret", error) + })?; + let Some(secret) = secret else { + outcome.pending = Some( + "The explicitly configured pull Secret is absent from the managed MCP namespace" + .into(), + ); + return Ok(outcome); + }; + if secret.type_.as_deref() != Some("kubernetes.io/dockerconfigjson") + || secret.metadata.deletion_timestamp.is_some() + || secret + .data + .as_ref() + .is_none_or(|data| !data.contains_key(".dockerconfigjson")) + { + return Err( + "Managed MCP image pull Secret is not a live dockerconfigjson Secret".into(), + ); + } + } + let deployments: Api = Api::namespaced(client.clone(), &config.namespace); + let services: Api = Api::namespaced(client.clone(), &config.namespace); + let policies: Api = Api::namespaced(client.clone(), &config.namespace); + // Complete ownership/selector preflight before modifying any resource. + ownership::preflight(&deployments, &plan.owner, &namespace_uid).await?; + ownership::preflight(&services, &plan.owner, &namespace_uid).await?; + ownership::preflight(&policies, &plan.owner, &namespace_uid).await?; + let peers = peers(client, source, &config).await?; + let policy = ownership::upsert( + client, + source, + &policies, + &plan.owner, + &namespace_uid, + plan.network_policy(&namespace_uid, peers), + ) + .await?; + let service = ownership::upsert( + client, + source, + &services, + &plan.owner, + &namespace_uid, + plan.service(&namespace_uid), + ) + .await?; + let deployment = ownership::upsert( + client, + source, + &deployments, + &plan.owner, + &namespace_uid, + plan.deployment(&namespace_uid, config.pull_secret.as_deref()), + ) + .await?; + let current = deployments + .get(&plan.owner.name) + .await + .map_err(|error| api_error("Read managed MCP rollout", error))?; + if current.uid() != deployment.uid() + || !ownership::owned(¤t.metadata, &plan.owner, &namespace_uid) + { + return Err("Managed MCP Deployment changed during readiness verification".into()); + } + let ready = current.status.as_ref().is_some_and(|status| { + status.observed_generation == current.metadata.generation + && status.updated_replicas == Some(1) + && status.available_replicas == Some(1) + && status.replicas == Some(1) + }) && current + .spec + .as_ref() + .and_then(|spec| spec.template.spec.as_ref()) + .is_some_and(|spec| { + spec.automount_service_account_token == Some(false) + && spec + .containers + .iter() + .find(|container| container.name == "mcp") + .and_then(|container| container.image.as_deref()) + == Some(plan.image.as_str()) + }); + if !ready { + outcome.pending = Some( + "Waiting for the exact managed MCP Deployment generation to become available".into(), + ); + return Ok(outcome); + } + let probe = probe::probe( + http, + &plan.endpoint(), + source.spec.allowed_tools.as_deref().unwrap_or_default(), + ) + .await?; + ownership::source_current(client, source, false).await?; + let revision = json!({"uid":source.uid(),"generation":source.metadata.generation, + "namespace":namespace_uid,"deployment":current.uid(),"deploymentGeneration":current.metadata.generation, + "service":service.uid(),"networkPolicyVersion":policy.metadata.resource_version,"schema":probe.digest}); + outcome.revision = crate::providers::signing::content_digest( + &serde_json::to_vec(&revision).map_err(|_| "Managed MCP revision serialization failed")?, + ); + outcome.endpoint = Some(plan.endpoint()); + outcome.tools = Some(probe.names); + outcome.schema_digest = Some(probe.digest); + outcome.workload_generation = current.metadata.generation; + outcome.workload_image = Some(plan.image.clone()); + Ok(outcome) +} + +pub(super) async fn cleanup(client: &Client, source: &McpServer) -> Result { + let mut config = Config::from_env()?; + if let Some(reference) = source + .status + .as_ref() + .and_then(|status| status.workload_ref.as_deref()) + { + let (namespace, name) = reference + .split_once('/') + .ok_or("Invalid managed MCP workload display reference")?; + config.namespace = namespace.into(); + config.validate()?; + let owner = Owner::new(source, namespace)?; + if name != owner.name { + return Err( + "Managed MCP cleanup reference does not match the source UID-derived resource name" + .into(), + ); + } + } else if source.spec.managed.is_none() { + return Ok(true); + } + ownership::source_current(client, source, source.metadata.deletion_timestamp.is_some()).await?; + let Some(namespace) = ownership::namespace(client, &config, source, false).await? else { + return Ok(true); + }; + let owner = Owner::new(source, &config.namespace)?; + let uid = namespace + .uid() + .ok_or("Managed MCP cleanup namespace UID is missing")?; + let deployments: Api = Api::namespaced(client.clone(), &config.namespace); + let services: Api = Api::namespaced(client.clone(), &config.namespace); + let policies: Api = Api::namespaced(client.clone(), &config.namespace); + if !ownership::delete(&deployments, &owner, &uid).await? { + return Ok(false); + } + if !ownership::delete(&services, &owner, &uid).await? { + return Ok(false); + } + ownership::delete(&policies, &owner, &uid).await +} diff --git a/controller/src/mcp_server_reconciler/managed/ownership.rs b/controller/src/mcp_server_reconciler/managed/ownership.rs new file mode 100644 index 000000000..271a8b052 --- /dev/null +++ b/controller/src/mcp_server_reconciler/managed/ownership.rs @@ -0,0 +1,329 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::plan::*; +use crate::mcp_server::McpServer; +use k8s_openapi::{api::core::v1::Namespace, apimachinery::pkg::apis::meta::v1::ObjectMeta}; +use kube::{ + Api, Client, Resource, ResourceExt, + api::{DeleteParams, Patch, PatchParams, PostParams, Preconditions}, +}; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::{Value, json}; +use std::fmt::Debug; + +pub(super) fn api_error(stage: &str, error: kube::Error) -> String { + match error { + kube::Error::Api(status) => format!("{stage}: Kubernetes status {}", status.code), + _ => format!("{stage}: Kubernetes transport/serialization failure"), + } +} + +pub(super) async fn source_current( + client: &Client, + source: &McpServer, + deleting: bool, +) -> Result<(), String> { + let namespace = source + .namespace() + .ok_or("McpServer source namespace is missing")?; + let current = Api::::namespaced(client.clone(), &namespace) + .get(&source.name_any()) + .await + .map_err(|error| api_error("Verify live McpServer", error))?; + if current.uid() != source.uid() + || source.uid().is_none() + || current.metadata.generation != source.metadata.generation + || current + .metadata + .generation + .is_none_or(|generation| generation <= 0) + || current.metadata.deletion_timestamp.is_some() != deleting + { + return Err( + "McpServer incarnation/generation changed; no resource mutation authorized".into(), + ); + } + Ok(()) +} + +pub(super) fn namespace_owned( + namespace: &Namespace, + config: &Config, + controller_uid: &str, +) -> bool { + let meta = &namespace.metadata; + let annotation = |key: &str| { + meta.annotations + .as_ref() + .and_then(|annotations| annotations.get(key)) + .map(String::as_str) + }; + meta.name.as_deref() == Some(config.namespace.as_str()) + && meta.uid.as_deref().is_some_and(|uid| !uid.is_empty()) + && meta + .resource_version + .as_deref() + .is_some_and(|rv| !rv.is_empty()) + && meta.deletion_timestamp.is_none() + && meta.owner_references.as_ref().is_none_or(Vec::is_empty) + && annotation(CLAIM) == Some("v1") + && annotation(CONTROLLER_NS) == Some(config.controller_namespace.as_str()) + && annotation(CONTROLLER_UID) == Some(controller_uid) +} + +pub(super) async fn namespace( + client: &Client, + config: &Config, + source: &McpServer, + create: bool, +) -> Result, String> { + config.validate()?; + let namespaces: Api = Api::all(client.clone()); + let controller = namespaces + .get(&config.controller_namespace) + .await + .map_err(|error| api_error("Read managed MCP controller namespace", error))?; + let controller_uid = controller + .metadata + .uid + .as_deref() + .filter(|uid| !uid.is_empty()) + .ok_or("Managed MCP controller namespace UID is missing")?; + if controller.metadata.deletion_timestamp.is_some() { + return Err("Managed MCP controller namespace is terminating".into()); + } + let existing = namespaces + .get_opt(&config.namespace) + .await + .map_err(|error| api_error("Read managed MCP namespace", error))?; + let namespace = match existing { + Some(namespace) => namespace, + None if !create => return Ok(None), + None => { + if source + .status + .as_ref() + .and_then(|status| status.managed_namespace_uid.as_ref()) + .is_some() + { + return Err( + "Managed MCP namespace disappeared; explicit incarnation review required" + .into(), + ); + } + source_current(client, source, false).await?; + let body: Namespace = serde_json::from_value(json!({"apiVersion":"v1","kind":"Namespace", + "metadata":{"name":config.namespace,"annotations":{ + CLAIM:"v1",CONTROLLER_NS:config.controller_namespace,CONTROLLER_UID:controller_uid}, + "labels":{"app.kubernetes.io/name":"kars-managed-mcp","app.kubernetes.io/managed-by":"kars-controller", + "pod-security.kubernetes.io/enforce":"restricted","pod-security.kubernetes.io/audit":"restricted", + "pod-security.kubernetes.io/warn":"restricted"}}})) + .map_err(|_| "Managed MCP namespace serialization failed")?; + match namespaces.create(&PostParams::default(), &body).await { + Ok(namespace) => namespace, + Err(kube::Error::Api(status)) if status.code == 409 => namespaces + .get(&config.namespace) + .await + .map_err(|error| api_error("Inspect raced managed MCP namespace", error))?, + Err(error) => return Err(api_error("Create managed MCP namespace", error)), + } + } + }; + if !namespace_owned(&namespace, config, controller_uid) + || source + .status + .as_ref() + .and_then(|status| status.managed_namespace_uid.as_ref()) + .is_some_and(|uid| namespace.metadata.uid.as_ref() != Some(uid)) + { + return Err( + "Managed MCP namespace is unowned, replaced or terminating; no adoption permitted" + .into(), + ); + } + let restricted = namespace + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get("pod-security.kubernetes.io/enforce")); + if restricted.map(String::as_str) != Some("restricted") { + return Err("Managed MCP namespace no longer enforces restricted Pod security".into()); + } + Ok(Some(namespace)) +} + +pub(super) fn owned(meta: &ObjectMeta, plan: &Owner, namespace_uid: &str) -> bool { + let annotation = |key: &str| { + meta.annotations + .as_ref() + .and_then(|annotations| annotations.get(key)) + .map(String::as_str) + }; + meta.name.as_deref() == Some(plan.name.as_str()) + && meta.namespace.as_deref() == Some(plan.namespace.as_str()) + && meta.uid.as_deref().is_some_and(|uid| !uid.is_empty()) + && meta + .resource_version + .as_deref() + .is_some_and(|rv| !rv.is_empty()) + && meta.owner_references.as_ref().is_none_or(Vec::is_empty) + && annotation(SOURCE_NS) == Some(plan.source_namespace.as_str()) + && annotation(SOURCE_NAME) == Some(plan.source_name.as_str()) + && annotation(SOURCE_UID) == Some(plan.source_uid.as_str()) + && annotation(NAMESPACE_UID) == Some(namespace_uid) + && meta + .labels + .as_ref() + .and_then(|labels| labels.get("app.kubernetes.io/managed-by")) + .map(String::as_str) + == Some("kars-controller") +} + +fn selector_matches>(value: &K, plan: &Owner) -> bool { + let Ok(value) = serde_json::to_value(value) else { + return false; + }; + let selector = match K::kind(&()).as_ref() { + "Deployment" => &value["spec"]["selector"]["matchLabels"], + "Service" => &value["spec"]["selector"], + "NetworkPolicy" => &value["spec"]["podSelector"]["matchLabels"], + _ => return false, + }; + *selector == json!({SOURCE_UID:plan.source_uid}) +} + +fn contains_desired(current: &Value, desired: &Value) -> bool { + match desired { + Value::Object(entries) => entries + .iter() + .all(|(key, value)| contains_desired(¤t[key], value)), + Value::Array(items) if items.is_empty() && current.is_null() => true, + Value::Array(items) => current.as_array().is_some_and(|values| { + values.len() == items.len() + && values + .iter() + .zip(items) + .all(|(current, desired)| contains_desired(current, desired)) + }), + _ => current == desired, + } +} + +pub(super) async fn preflight( + api: &Api, + plan: &Owner, + namespace_uid: &str, +) -> Result, String> +where + K: Clone + Debug + DeserializeOwned + Serialize + Resource, +{ + let existing = api + .get_opt(&plan.name) + .await + .map_err(|error| api_error("Inspect managed MCP resource", error))?; + if let Some(value) = existing.as_ref() + && (!owned(value.meta(), plan, namespace_uid) + || value.meta().deletion_timestamp.is_some() + || !selector_matches(value, plan)) + { + return Err( + "Managed MCP resource has conflicting ownership or selector; no mutation permitted" + .into(), + ); + } + Ok(existing) +} + +pub(super) async fn upsert( + client: &Client, + source: &McpServer, + api: &Api, + plan: &Owner, + namespace_uid: &str, + mut desired: Value, +) -> Result +where + K: Clone + Debug + DeserializeOwned + Serialize + Resource, +{ + let existing = preflight(api, plan, namespace_uid).await?; + source_current(client, source, false).await?; + let current_namespace = Api::::all(client.clone()) + .get(&plan.namespace) + .await + .map_err(|error| api_error("Recheck managed MCP namespace incarnation", error))?; + if current_namespace.metadata.uid.as_deref() != Some(namespace_uid) + || current_namespace.metadata.deletion_timestamp.is_some() + { + return Err("Managed MCP namespace changed before resource write".into()); + } + let result = match existing { + Some(existing) => { + if contains_desired( + &serde_json::to_value(&existing) + .map_err(|_| "Managed MCP resource comparison failed")?, + &desired, + ) { + return Ok(existing); + } + desired["metadata"]["uid"] = json!(existing.meta().uid); + desired["metadata"]["resourceVersion"] = json!(existing.meta().resource_version); + api.patch(&plan.name, &PatchParams::default(), &Patch::Merge(desired)) + .await + .map_err(|error| api_error("CAS update managed MCP resource", error))? + } + None => { + let desired: K = serde_json::from_value(desired) + .map_err(|_| "Managed MCP resource serialization failed")?; + api.create(&PostParams::default(), &desired) + .await + .map_err(|error| api_error("Create managed MCP resource", error))? + } + }; + if !owned(result.meta(), plan, namespace_uid) || !selector_matches(&result, plan) { + return Err( + "Managed MCP resource response did not preserve exact ownership/selector".into(), + ); + } + Ok(result) +} + +pub(super) async fn delete( + api: &Api, + plan: &Owner, + namespace_uid: &str, +) -> Result +where + K: Clone + Debug + DeserializeOwned + Serialize + Resource, +{ + let Some(current) = api + .get_opt(&plan.name) + .await + .map_err(|error| api_error("Inspect managed MCP cleanup target", error))? + else { + return Ok(true); + }; + if !owned(current.meta(), plan, namespace_uid) || !selector_matches(¤t, plan) { + return Err("Managed MCP cleanup target is not the exact owned resource; preserved".into()); + } + if current.meta().deletion_timestamp.is_none() { + api.delete( + &plan.name, + &DeleteParams { + preconditions: Some(Preconditions { + uid: current.meta().uid.clone(), + resource_version: current.meta().resource_version.clone(), + }), + propagation_policy: Some(kube::api::PropagationPolicy::Foreground), + ..Default::default() + }, + ) + .await + .map_err(|error| api_error("Delete exact managed MCP resource", error))?; + } + Ok(api + .get_opt(&plan.name) + .await + .map_err(|error| api_error("Wait for managed MCP resource removal", error))? + .is_none()) +} diff --git a/controller/src/mcp_server_reconciler/managed/plan.rs b/controller/src/mcp_server_reconciler/managed/plan.rs new file mode 100644 index 000000000..f03ab9321 --- /dev/null +++ b/controller/src/mcp_server_reconciler/managed/plan.rs @@ -0,0 +1,283 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::mcp_server::{ManagedMcpPreset, McpServer}; +use kube::ResourceExt; +use serde_json::{Value, json}; + +pub(super) const SOURCE_NS: &str = "kars.azure.com/mcp-source-namespace"; +pub(super) const SOURCE_NAME: &str = "kars.azure.com/mcp-source-name"; +pub(super) const SOURCE_UID: &str = "kars.azure.com/mcp-source-uid"; +pub(super) const NAMESPACE_UID: &str = "kars.azure.com/mcp-namespace-uid"; +pub(super) const CONTROLLER_NS: &str = "kars.azure.com/mcp-controller-namespace"; +pub(super) const CONTROLLER_UID: &str = "kars.azure.com/mcp-controller-namespace-uid"; +pub(super) const CLAIM: &str = "kars.azure.com/mcp-namespace-claim"; + +const PLAYWRIGHT_IMAGE: &str = "mcr.microsoft.com/playwright/mcp@sha256:3d871c22ea2d4cca0966e2cfb1860e1cb03eb7353725a3d6cffd133296fb04eb"; +const EVERYTHING_IMAGE: &str = "ghcr.io/azure/kars/mcp-everything:latest"; + +#[derive(Clone, Debug)] +pub(super) struct Config { + pub controller_namespace: String, + pub namespace: String, + pub playwright_image: String, + pub everything_image: String, + pub pull_secret: Option, +} + +pub(super) fn dns_label(value: &str) -> bool { + !value.is_empty() + && value.len() <= 63 + && value + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + && value.as_bytes()[0].is_ascii_alphanumeric() + && value.as_bytes()[value.len() - 1].is_ascii_alphanumeric() +} + +impl Config { + pub(super) fn from_env() -> Result { + let value = |key: &str, default: &str| { + std::env::var(key) + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| default.into()) + }; + let config = Self { + controller_namespace: crate::providers::signing::receipt_namespace(), + namespace: value("MCP_MANAGED_NAMESPACE", "kars-mcp"), + playwright_image: value("MCP_PLAYWRIGHT_IMAGE", PLAYWRIGHT_IMAGE), + everything_image: value("MCP_EVERYTHING_IMAGE", EVERYTHING_IMAGE), + pull_secret: std::env::var("MCP_IMAGE_PULL_SECRET_NAME") + .ok() + .filter(|value| !value.is_empty()), + }; + config.validate()?; + Ok(config) + } + + pub(super) fn validate(&self) -> Result<(), String> { + if !dns_label(&self.controller_namespace) + || !dns_label(&self.namespace) + || self.namespace == self.controller_namespace + || [ + "default", + "kube-system", + "kube-public", + "kube-node-lease", + "kars-sre", + "agentmesh", + ] + .contains(&self.namespace.as_str()) + || self + .pull_secret + .as_ref() + .is_some_and(|name| !dns_label(name)) + { + return Err( + "Managed MCP requires a separate valid namespace and a local pull Secret name" + .into(), + ); + } + for image in [&self.playwright_image, &self.everything_image] { + if image.len() > 512 + || image.chars().any(char::is_whitespace) + || (!image.contains("@sha256:") + && image + .rfind(':') + .is_none_or(|index| image.rfind('/').is_some_and(|slash| index < slash))) + { + return Err("Managed MCP operator images require an explicit tag or digest".into()); + } + } + Ok(()) + } +} + +#[derive(Clone, Debug)] +pub(super) struct Owner { + pub namespace: String, + pub name: String, + pub source_namespace: String, + pub source_name: String, + pub source_uid: String, +} + +impl Owner { + pub(super) fn new(mcp: &McpServer, namespace: &str) -> Result { + let source_namespace = mcp + .namespace() + .filter(|ns| dns_label(ns)) + .ok_or("McpServer namespace is invalid")?; + let source_uid = mcp + .uid() + .filter(|uid| { + !uid.is_empty() + && uid.len() <= 63 + && uid.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') + }) + .ok_or("Managed MCP requires a live source UID")?; + let source_name = mcp + .metadata + .name + .clone() + .filter(|name| !name.is_empty() && name.len() <= 253 && name.split('.').all(dns_label)) + .ok_or("McpServer source name is invalid")?; + if !dns_label(namespace) { + return Err("Managed MCP namespace is invalid".into()); + } + let identity = format!("{source_namespace}/{source_uid}"); + let digest = crate::providers::signing::content_digest(identity.as_bytes()); + let suffix = &digest.trim_start_matches("sha256:")[..20]; + let display: String = source_name + .chars() + .take(38) + .map(|ch| if ch == '.' { '-' } else { ch }) + .collect(); + let name = format!("mcp-{}-{suffix}", display.trim_matches('-')); + if !dns_label(&name) { + return Err("Managed MCP workload name is invalid".into()); + } + Ok(Self { + namespace: namespace.into(), + name, + source_namespace, + source_name, + source_uid, + }) + } + + pub(super) fn workload_ref(&self) -> String { + format!("{}/{}", self.namespace, self.name) + } + + pub(super) fn labels(&self) -> Value { + json!({"app.kubernetes.io/name":self.name, "app.kubernetes.io/component":"mcp-server", + "app.kubernetes.io/managed-by":"kars-controller",SOURCE_UID:self.source_uid}) + } + + pub(super) fn annotations(&self, namespace_uid: &str) -> Value { + json!({SOURCE_NS:self.source_namespace,SOURCE_NAME:self.source_name,SOURCE_UID:self.source_uid, + NAMESPACE_UID:namespace_uid}) + } +} + +#[derive(Clone, Debug)] +pub(super) struct Plan { + pub owner: Owner, + pub image: String, + pub port: u16, + pub args: Vec, + pub cpu_request: &'static str, + pub memory_request: &'static str, + pub cpu_limit: &'static str, + pub memory_limit: &'static str, +} + +impl Plan { + pub(super) fn new(mcp: &McpServer, config: &Config) -> Result { + config.validate()?; + let owner = Owner::new(mcp, &config.namespace)?; + if !dns_label(&owner.source_name) { + return Err("Managed MCP names must be DNS labels for namespaced tool dispatch".into()); + } + let managed = mcp + .spec + .managed + .as_ref() + .ok_or("McpServer has no managed preset")?; + let (image, port, args, cpu_request, memory_request, cpu_limit, memory_limit) = + match managed.preset { + ManagedMcpPreset::Playwright => { + let host = + format!("{}.{}.svc.cluster.local:8931", owner.name, config.namespace); + ( + config.playwright_image.clone(), + 8931, + vec![ + "--port=8931".into(), + "--host=0.0.0.0".into(), + "--headless".into(), + "--browser=chromium".into(), + "--no-sandbox".into(), + "--isolated".into(), + format!("--allowed-hosts={host},localhost:8931,localhost"), + ], + "250m", + "512Mi", + "2", + "2Gi", + ) + } + ManagedMcpPreset::Everything => ( + config.everything_image.clone(), + 3001, + vec!["streamableHttp".into()], + "50m", + "128Mi", + "500m", + "512Mi", + ), + }; + Ok(Self { + owner, + image, + port, + args, + cpu_request, + memory_request, + cpu_limit, + memory_limit, + }) + } + + pub(super) fn endpoint(&self) -> String { + format!( + "http://{}.{}.svc.cluster.local:{}/mcp", + self.owner.name, self.owner.namespace, self.port + ) + } + + pub(super) fn deployment(&self, namespace_uid: &str, pull_secret: Option<&str>) -> Value { + json!({"apiVersion":"apps/v1","kind":"Deployment", + "metadata":{"name":self.owner.name,"namespace":self.owner.namespace,"labels":self.owner.labels(),"annotations":self.owner.annotations(namespace_uid)}, + "spec":{"replicas":1,"strategy":{"type":"Recreate"}, + "selector":{"matchLabels":{SOURCE_UID:self.owner.source_uid}}, + "template":{"metadata":{"labels":self.owner.labels(),"annotations":self.owner.annotations(namespace_uid)}, + "spec":{"automountServiceAccountToken":false, + "securityContext":{"runAsNonRoot":true,"runAsUser":1000,"runAsGroup":1000,"fsGroup":1000, + "seccompProfile":{"type":"RuntimeDefault"}}, + "imagePullSecrets":pull_secret.map(|name|vec![json!({"name":name})]).unwrap_or_default(), + "containers":[{"name":"mcp","image":self.image, + "imagePullPolicy":if self.image.ends_with(":latest") {"Always"} else {"IfNotPresent"}, + "args":self.args,"env":[{"name":"PORT","value":self.port.to_string()},{"name":"HOME","value":"/tmp"},{"name":"TMPDIR","value":"/tmp"}], + "ports":[{"name":"mcp","containerPort":self.port}], + "readinessProbe":{"tcpSocket":{"port":"mcp"},"initialDelaySeconds":3,"periodSeconds":5}, + "securityContext":{"allowPrivilegeEscalation":false,"readOnlyRootFilesystem":true,"capabilities":{"drop":["ALL"]}}, + "resources":{"requests":{"cpu":self.cpu_request,"memory":self.memory_request}, + "limits":{"cpu":self.cpu_limit,"memory":self.memory_limit}}, + "volumeMounts":[{"name":"scratch","mountPath":"/tmp"}]}], + "volumes":[{"name":"scratch","emptyDir":{"sizeLimit":"1Gi"}}]}}}}) + } + + pub(super) fn service(&self, namespace_uid: &str) -> Value { + json!({"apiVersion":"v1","kind":"Service", + "metadata":{"name":self.owner.name,"namespace":self.owner.namespace,"labels":self.owner.labels(),"annotations":self.owner.annotations(namespace_uid)}, + "spec":{"type":"ClusterIP","selector":{SOURCE_UID:self.owner.source_uid}, + "ports":[{"name":"mcp","port":self.port,"targetPort":"mcp"}]}}) + } + + pub(super) fn network_policy(&self, namespace_uid: &str, peers: Vec) -> Value { + json!({"apiVersion":"networking.k8s.io/v1","kind":"NetworkPolicy", + "metadata":{"name":self.owner.name,"namespace":self.owner.namespace,"labels":self.owner.labels(),"annotations":self.owner.annotations(namespace_uid)}, + "spec":{"podSelector":{"matchLabels":{SOURCE_UID:self.owner.source_uid}},"policyTypes":["Ingress","Egress"], + "ingress":[{"from":peers,"ports":[{"protocol":"TCP","port":self.port}]}], + "egress":[ + {"to":[{"namespaceSelector":{"matchLabels":{"kubernetes.io/metadata.name":"kube-system"}}}], + "ports":[{"protocol":"UDP","port":53},{"protocol":"TCP","port":53}]}, + {"to":[{"ipBlock":{"cidr":"0.0.0.0/0","except":["0.0.0.0/8","10.0.0.0/8","100.64.0.0/10", + "127.0.0.0/8","169.254.0.0/16","172.16.0.0/12","192.168.0.0/16","224.0.0.0/4","240.0.0.0/4"]}}], + "ports":[{"protocol":"TCP","port":80},{"protocol":"TCP","port":443}]} + ]}}) + } +} diff --git a/controller/src/mcp_server_reconciler/managed/probe.rs b/controller/src/mcp_server_reconciler/managed/probe.rs new file mode 100644 index 000000000..225698983 --- /dev/null +++ b/controller/src/mcp_server_reconciler/managed/probe.rs @@ -0,0 +1,266 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use futures::StreamExt; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, BTreeSet}; +use std::time::Duration; + +const MAX_RESPONSE: usize = 256 * 1024; +const MAX_TOOLS: usize = 256; +const PROTOCOL: &str = "2025-06-18"; + +#[derive(Clone, Debug)] +pub(super) struct Probe { + pub names: Vec, + pub digest: String, +} + +async fn payload(response: reqwest::Response, id: u64) -> Result { + if !response.status().is_success() { + return Err(format!( + "Managed MCP probe HTTP {}", + response.status().as_u16() + )); + } + if response + .content_length() + .is_some_and(|length| length > MAX_RESPONSE as u64) + { + return Err("Managed MCP probe response exceeds its byte limit".into()); + } + let sse = response + .headers() + .get("content-type") + .and_then(|header| header.to_str().ok()) + .is_some_and(|header| header.starts_with("text/event-stream")); + let mut stream = response.bytes_stream(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| "Managed MCP probe response transport failure")?; + if bytes.len().saturating_add(chunk.len()) > MAX_RESPONSE { + return Err("Managed MCP probe response exceeds its byte limit".into()); + } + bytes.extend_from_slice(&chunk); + } + let value = if sse { + let text = std::str::from_utf8(&bytes).map_err(|_| "Managed MCP probe SSE is not UTF-8")?; + let mut result = None; + let mut data = String::new(); + for line in text.lines().chain(std::iter::once("")) { + if line.is_empty() { + if !data.is_empty() { + let event: Value = serde_json::from_str(&data) + .map_err(|_| "Managed MCP probe SSE is invalid JSON")?; + if event["jsonrpc"] == "2.0" + && event["id"].as_u64() == Some(id) + && result.replace(event).is_some() + { + return Err("Managed MCP probe returned duplicate RPC responses".into()); + } + data.clear(); + } + } else if let Some(value) = line.strip_prefix("data:") { + if !data.is_empty() { + data.push('\n'); + } + data.push_str(value.trim_start()); + } + } + result.ok_or("Managed MCP probe SSE omitted the matching RPC response")? + } else { + serde_json::from_slice(&bytes).map_err(|_| "Managed MCP probe is not valid JSON")? + }; + if value["jsonrpc"] != "2.0" + || value["id"].as_u64() != Some(id) + || value.get("error").is_some() + || !value["result"].is_object() + { + return Err("Managed MCP probe returned an invalid or failed RPC response".into()); + } + Ok(value["result"].clone()) +} + +fn headers(session: Option<&str>, protocol: &str) -> Result { + use reqwest::header::{ACCEPT, CONTENT_TYPE, HeaderMap, HeaderValue}; + let mut headers = HeaderMap::new(); + headers.insert( + ACCEPT, + HeaderValue::from_static("application/json, text/event-stream"), + ); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + headers.insert( + "mcp-protocol-version", + HeaderValue::from_str(protocol).map_err(|_| "Invalid MCP protocol header")?, + ); + if let Some(session) = session { + if session.is_empty() + || session.len() > 1024 + || !session.bytes().all(|b| b.is_ascii_graphic()) + { + return Err("Managed MCP probe received an invalid session identifier".into()); + } + headers.insert( + "mcp-session-id", + HeaderValue::from_str(session).map_err(|_| "Invalid MCP session header")?, + ); + } + Ok(headers) +} + +async fn list( + http: &reqwest::Client, + endpoint: &str, + session: Option<&str>, + protocol: &str, + allowed: &[String], +) -> Result { + let headers = headers(session, protocol)?; + let response = http + .post(endpoint) + .headers(headers.clone()) + .json(&json!({"jsonrpc":"2.0","method":"notifications/initialized"})) + .send() + .await + .map_err(|_| "Managed MCP initialization notification failed")?; + if !response.status().is_success() { + return Err(format!( + "Managed MCP initialization notification HTTP {}", + response.status().as_u16() + )); + } + let mut cursor = None; + let mut cursors = BTreeSet::new(); + let mut tools = BTreeMap::new(); + for page in 0..8 { + let id = page + 2; + let params = cursor + .as_ref() + .map(|cursor: &String| json!({"cursor":cursor})) + .unwrap_or_else(|| json!({})); + let response = http + .post(endpoint) + .headers(headers.clone()) + .json(&json!({"jsonrpc":"2.0","id":id,"method":"tools/list","params":params})) + .send() + .await + .map_err(|_| "Managed MCP tools/list transport failure")?; + let result = payload(response, id).await?; + let definitions = result["tools"] + .as_array() + .ok_or("Managed MCP tools/list omitted its tool array")?; + for tool in definitions { + let name = tool["name"] + .as_str() + .filter(|name| { + !name.is_empty() + && name.len() <= 128 + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_.:-".contains(&b)) + }) + .ok_or("Managed MCP tool name is invalid")?; + if !tool["inputSchema"].is_object() + || tool["inputSchema"]["type"] != "object" + || tools.insert(name.to_string(), tool.clone()).is_some() + || tools.len() > MAX_TOOLS + { + return Err( + "Managed MCP catalog is invalid, duplicated or exceeds its tool limit".into(), + ); + } + } + match result.get("nextCursor").filter(|value| !value.is_null()) { + None => { + let selected: BTreeMap<_, _> = tools + .into_iter() + .filter(|(name, _)| { + allowed + .iter() + .any(|allowed| allowed == "*" || allowed == name) + }) + .collect(); + let canonical = serde_json::to_vec(&selected) + .map_err(|_| "MCP catalog serialization failed")?; + return Ok(Probe { + names: selected.keys().cloned().collect(), + digest: crate::providers::signing::content_digest(&canonical), + }); + } + Some(value) => { + let next = value + .as_str() + .filter(|cursor| !cursor.is_empty() && cursor.len() <= 1024) + .ok_or("Managed MCP catalog cursor is invalid")?; + if !cursors.insert(next.to_string()) { + return Err("Managed MCP catalog pagination repeated a cursor".into()); + } + cursor = Some(next.to_string()); + } + } + } + Err("Managed MCP catalog exceeds its page limit".into()) +} + +pub(super) async fn probe( + http: &reqwest::Client, + endpoint: &str, + allowed: &[String], +) -> Result { + tokio::time::timeout(Duration::from_secs(20), async { + let response = http + .post(endpoint) + .headers(headers(None, PROTOCOL)?) + .json( + &json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{ + "protocolVersion":PROTOCOL,"capabilities":{}, + "clientInfo":{"name":"kars-controller","version":env!("CARGO_PKG_VERSION")}}}), + ) + .send() + .await + .map_err(|_| "Managed MCP initialize transport failure")?; + let session = response + .headers() + .get("mcp-session-id") + .map(|header| { + header + .to_str() + .map(str::to_string) + .map_err(|_| "Invalid MCP session header") + }) + .transpose()?; + let initialized = payload(response, 1).await; + let negotiated = initialized + .as_ref() + .ok() + .and_then(|result| result["protocolVersion"].as_str()) + .filter(|version| { + ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"].contains(version) + }); + let protocol = negotiated.unwrap_or(PROTOCOL); + let outcome = match initialized.as_ref() { + Ok(_) if negotiated.is_some() => { + list(http, endpoint, session.as_deref(), protocol, allowed).await + } + Ok(_) => Err("Managed MCP initialize negotiated an unsupported protocol".into()), + Err(error) => Err(error.clone()), + }; + if session.is_some() { + let closed = tokio::time::timeout( + Duration::from_secs(2), + http.delete(endpoint) + .headers(headers(session.as_deref(), protocol)?) + .send(), + ) + .await + .map_err(|_| "Managed MCP probe session cleanup timed out")? + .map_err(|_| "Managed MCP probe session cleanup transport failure")?; + if !closed.status().is_success() && closed.status().as_u16() != 404 { + return Err("Managed MCP probe session could not be closed".into()); + } + } + outcome + }) + .await + .map_err(|_| "Managed MCP probe exceeded its total deadline")? +} diff --git a/controller/src/mcp_server_reconciler/managed/probe_tests.rs b/controller/src/mcp_server_reconciler/managed/probe_tests.rs new file mode 100644 index 000000000..24b585686 --- /dev/null +++ b/controller/src/mcp_server_reconciler/managed/probe_tests.rs @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::probe; +use serde_json::{Value, json}; +use std::sync::{Arc, Mutex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +async fn fixture(mode: &'static str) -> (MockServer, Arc>>) { + let server = MockServer::start().await; + let calls = Arc::new(Mutex::new(Vec::new())); + let observed = calls.clone(); + Mock::given(|_: &wiremock::Request| true).respond_with(move |request: &wiremock::Request| { + let body: Value = request.body_json().unwrap_or(Value::Null); + let method = body["method"].as_str().unwrap_or(request.method.as_str()); + observed.lock().unwrap().push(method.into()); + if request.method == "DELETE" { + assert_eq!(request.headers.get("mcp-session-id").unwrap(), "probe-session"); + return ResponseTemplate::new(204); + } + if method == "initialize" { + if mode == "redirect" { + return ResponseTemplate::new(302).insert_header("location", "/not-followed"); + } + return ResponseTemplate::new(200).insert_header("mcp-session-id", "probe-session") + .set_body_json(if mode == "initialize-error" { + json!({"jsonrpc":"2.0","id":body["id"],"error":{"code":-32603,"message":"PRIVATE_SENTINEL"}}) + } else { + json!({"jsonrpc":"2.0","id":body["id"],"result":{ + "protocolVersion":if mode=="version" {"unknown"} else {"2025-06-18"},"capabilities":{"tools":{}}}}) + }); + } + if method == "notifications/initialized" { + return ResponseTemplate::new(202); + } + assert_eq!(method, "tools/list"); + assert_eq!(request.headers.get("mcp-session-id").unwrap(), "probe-session"); + assert_eq!(request.headers.get("mcp-protocol-version").unwrap(), "2025-06-18"); + if mode == "list-error" { + return ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc":"2.0","id":body["id"],"error":{"code":-32000,"message":"PRIVATE_SENTINEL"}})); + } + let second = body["params"]["cursor"] == "second"; + let mut result = json!({"tools":[{"name":if second {"second"} else {"first"},"inputSchema":{"type":"object"}}]}); + if mode == "pages" && !second { result["nextCursor"] = "second".into(); } + if mode == "repeated" { result["nextCursor"] = "second".into(); } + if mode == "oversize" { result["description"] = "x".repeat(300_000).into(); } + let response = json!({"jsonrpc":"2.0","id":body["id"],"result":result}); + if mode == "sse" { + ResponseTemplate::new(200) + .set_body_raw(format!("event: message\ndata: {response}\n\n"), "text/event-stream") + } else { + ResponseTemplate::new(200).set_body_json(response) + } + }).mount(&server).await; + (server, calls) +} + +fn client() -> reqwest::Client { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(3)) + .build() + .unwrap() +} + +#[tokio::test] +async fn real_http_probe_negotiates_pages_and_closes_its_session_without_invoking_tools() { + let (server, calls) = fixture("pages").await; + let result = probe::probe(&client(), &server.uri(), &["*".into()]) + .await + .unwrap(); + assert_eq!(result.names, ["first", "second"]); + assert!(result.digest.starts_with("sha256:")); + assert_eq!( + *calls.lock().unwrap(), + [ + "initialize", + "notifications/initialized", + "tools/list", + "tools/list", + "DELETE" + ] + ); +} + +#[tokio::test] +async fn sse_probe_honors_the_allowlist_and_closes_session() { + let (server, calls) = fixture("sse").await; + let result = probe::probe(&client(), &server.uri(), &["different".into()]) + .await + .unwrap(); + assert!(result.names.is_empty()); + assert_eq!(calls.lock().unwrap().last().unwrap(), "DELETE"); +} + +#[tokio::test] +async fn semantic_failures_invalid_versions_and_size_limits_are_unqualified_and_private() { + for mode in [ + "initialize-error", + "list-error", + "version", + "repeated", + "oversize", + ] { + let (server, calls) = fixture(mode).await; + let error = probe::probe(&client(), &server.uri(), &["*".into()]) + .await + .err() + .unwrap(); + assert!(!error.contains("PRIVATE_SENTINEL")); + assert_eq!(calls.lock().unwrap().last().unwrap(), "DELETE", "{mode}"); + assert!( + !calls + .lock() + .unwrap() + .iter() + .any(|method| method == "tools/call") + ); + } +} + +#[tokio::test] +async fn probe_does_not_follow_redirects() { + let (server, calls) = fixture("redirect").await; + assert!( + probe::probe(&client(), &server.uri(), &["*".into()]) + .await + .is_err() + ); + assert_eq!(*calls.lock().unwrap(), ["initialize"]); +} diff --git a/controller/src/mcp_server_reconciler/managed/tests.rs b/controller/src/mcp_server_reconciler/managed/tests.rs new file mode 100644 index 000000000..58b483b7d --- /dev/null +++ b/controller/src/mcp_server_reconciler/managed/tests.rs @@ -0,0 +1,419 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::plan::*; +use super::*; +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn source(uid: &str) -> McpServer { + serde_json::from_value(json!({"apiVersion":"kars.azure.com/v1alpha1","kind":"McpServer", + "metadata":{"name":"browser","namespace":"workspace","uid":uid,"resourceVersion":"1","generation":1}, + "spec":{"managed":{"preset":"playwright"},"allowedTools":["browser_navigate"]}})).unwrap() +} + +fn config() -> Config { + Config { + controller_namespace: "kars-system".into(), + namespace: "kars-mcp".into(), + playwright_image: "mcr.microsoft.com/playwright/mcp:latest".into(), + everything_image: "ghcr.io/azure/kars/mcp-everything:latest".into(), + pull_secret: None, + } +} + +fn namespace() -> Value { + json!({"apiVersion":"v1","kind":"Namespace","metadata":{"name":"kars-mcp","uid":"managed-ns", + "resourceVersion":"1","annotations":{CLAIM:"v1",CONTROLLER_NS:"kars-system",CONTROLLER_UID:"controller-ns"}, + "labels":{"pod-security.kubernetes.io/enforce":"restricted"}}}) +} + +#[derive(Default)] +struct ApiState { + objects: BTreeMap, + calls: Vec<(String, String, Value)>, + error: Option, + conflict: bool, +} + +fn failure(code: u16) -> ResponseTemplate { + ResponseTemplate::new(code).set_body_json(json!({"apiVersion":"v1","kind":"Status", + "code":code,"status":"Failure","reason":if code==404 {"NotFound"} else if code==409 {"Conflict"} else {"Forbidden"}, + "message":"PRIVATE_API_RESPONSE"})) +} + +fn merge(value: &mut Value, patch: &Value) { + if let Some(object) = patch.as_object() { + if !value.is_object() { + *value = json!({}); + } + for (key, item) in object { + if item.is_null() { + value.as_object_mut().unwrap().remove(key); + } else { + merge(&mut value[key], item); + } + } + } else { + *value = patch.clone(); + } +} + +async fn fixture() -> (MockServer, Client, Arc>) { + let server = MockServer::start().await; + let state = Arc::new(Mutex::new(ApiState::default())); + { + let mut state = state.lock().unwrap(); + state.objects.insert( + "/api/v1/namespaces/kars-system".into(), + json!({"metadata":{"name":"kars-system","uid":"controller-ns","resourceVersion":"1"}}), + ); + state.objects.insert( + "/apis/kars.azure.com/v1alpha1/namespaces/workspace/mcpservers/browser".into(), + serde_json::to_value(source("source")).unwrap(), + ); + } + let handler = state.clone(); + Mock::given(|_: &wiremock::Request| true) + .respond_with(move |request: &wiremock::Request| { + let mut state = handler.lock().unwrap(); + let method = request.method.as_str(); + let path = request.url.path(); + let body: Value = request.body_json().unwrap_or(Value::Null); + state.calls.push((method.into(), path.into(), body.clone())); + if let Some(code) = state.error { + return failure(code); + } + match method { + "GET" => state + .objects + .get(path) + .map(|value| ResponseTemplate::new(200).set_body_json(value)) + .unwrap_or_else(|| failure(404)), + "POST" => { + let key = format!("{path}/{}", body["metadata"]["name"].as_str().unwrap()); + if state.objects.contains_key(&key) || state.conflict { + return failure(409); + } + let mut body = body; + body["metadata"]["uid"] = "created-resource".into(); + body["metadata"]["resourceVersion"] = "1".into(); + state.objects.insert(key, body.clone()); + ResponseTemplate::new(201).set_body_json(body) + } + "PATCH" => { + if state.conflict { + return failure(409); + } + let Some(value) = state.objects.get_mut(path) else { + return failure(404); + }; + assert_eq!(body["metadata"]["uid"], value["metadata"]["uid"]); + assert_eq!( + body["metadata"]["resourceVersion"], + value["metadata"]["resourceVersion"] + ); + merge(value, &body); + value["metadata"]["resourceVersion"] = "2".into(); + ResponseTemplate::new(200).set_body_json(value.clone()) + } + "DELETE" => { + let Some(value) = state.objects.get(path) else { + return failure(404); + }; + assert_eq!(body["preconditions"]["uid"], value["metadata"]["uid"]); + assert_eq!( + body["preconditions"]["resourceVersion"], + value["metadata"]["resourceVersion"] + ); + if state.conflict { + return failure(409); + } + state.objects.remove(path); + ResponseTemplate::new(200).set_body_json( + json!({"apiVersion":"v1","kind":"Status","status":"Success"}), + ) + } + _ => failure(405), + } + }) + .mount(&server) + .await; + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let client = Client::try_from(kube::Config::new(server.uri().parse().unwrap())).unwrap(); + (server, client, state) +} + +fn writes(state: &ApiState) -> usize { + state + .calls + .iter() + .filter(|(method, _, _)| method != "GET") + .count() +} + +#[test] +fn managed_plans_have_uid_unique_selectors_rootless_posture_and_no_credentials() { + let first = Plan::new(&source("first"), &config()).unwrap(); + let second = Plan::new(&source("second"), &config()).unwrap(); + assert_ne!(first.owner.name, second.owner.name); + let pod = first.deployment("namespace-uid", None); + assert_eq!( + pod["spec"]["selector"]["matchLabels"], + json!({SOURCE_UID:"first"}) + ); + assert_eq!( + first.service("namespace-uid")["spec"]["selector"], + json!({SOURCE_UID:"first"}) + ); + assert_eq!( + pod["spec"]["template"]["spec"]["automountServiceAccountToken"], + false + ); + assert_eq!( + pod["spec"]["template"]["spec"]["containers"][0]["securityContext"]["readOnlyRootFilesystem"], + true + ); + assert_eq!( + pod["spec"]["template"]["spec"]["containers"][0]["imagePullPolicy"], + "Always" + ); + assert!(first.args.iter().any(|argument| argument == "--isolated")); + let policy = first.network_policy("namespace-uid", vec![]); + assert!( + policy["spec"]["egress"][1]["to"][0]["ipBlock"]["except"] + .as_array() + .unwrap() + .contains(&json!("169.254.0.0/16")) + ); + let mut invalid = source("valid"); + invalid.metadata.uid = None; + assert!(Plan::new(&invalid, &config()).is_err()); + invalid.metadata.uid = Some("valid".into()); + invalid.metadata.name = Some("browser.team".into()); + assert!(Plan::new(&invalid, &config()).is_err()); +} + +#[test] +fn managed_schema_is_closed_and_endpoint_auth_paths_cannot_be_combined() { + for extra in [ + json!({"url":"https://example/mcp"}), + json!({"productionMode":false}), + json!({"bearerFromEnv":"TOKEN"}), + json!({"oauth":{"issuer":"https://example"}}), + ] { + let mut value = serde_json::to_value(source("source")).unwrap(); + merge(&mut value["spec"], &extra); + let value: McpServer = serde_json::from_value(value).unwrap(); + assert!(validate_source(&value).is_err()); + } + assert!( + serde_json::from_value::( + json!({"preset":"playwright","image":"unreviewed:latest"}) + ) + .is_err() + ); + assert!( + serde_json::from_value::( + json!({"preset":"unreviewed"}) + ) + .is_err() + ); +} + +#[tokio::test] +async fn namespace_creation_is_claimed_without_adopting_existing_occupants() { + let (_server, client, state) = fixture().await; + let namespace = ownership::namespace(&client, &config(), &source("source"), true) + .await + .unwrap() + .unwrap(); + assert!(ownership::namespace_owned( + &namespace, + &config(), + "controller-ns" + )); + { + let mut guard = state.lock().unwrap(); + let ns = guard + .objects + .get_mut("/api/v1/namespaces/kars-mcp") + .unwrap(); + ns["metadata"]["annotations"][CONTROLLER_UID] = "foreign".into(); + guard.calls.clear(); + } + assert!( + ownership::namespace(&client, &config(), &source("source"), true) + .await + .is_err() + ); + assert_eq!(writes(&state.lock().unwrap()), 0); +} + +#[tokio::test] +async fn namespace_replacement_api_errors_and_stale_sources_never_create_workloads() { + for kind in ["replacement", "api-error", "source"] { + let (_server, client, state) = fixture().await; + let mut source = source("source"); + match kind { + "replacement" => { + source.status = + Some(serde_json::from_value(json!({"managedNamespaceUid":"old"})).unwrap()); + state + .lock() + .unwrap() + .objects + .insert("/api/v1/namespaces/kars-mcp".into(), namespace()); + } + "api-error" => state.lock().unwrap().error = Some(403), + _ => source.metadata.uid = Some("stale".into()), + } + let result = ownership::namespace(&client, &config(), &source, true).await; + assert!(result.is_err(), "{kind}"); + assert_eq!(writes(&state.lock().unwrap()), 0); + assert!(!result.err().unwrap().contains("PRIVATE_API_RESPONSE")); + } +} + +#[tokio::test] +async fn exact_owned_resource_updates_use_cas_and_preserve_unrelated_metadata() { + let (_server, client, state) = fixture().await; + state + .lock() + .unwrap() + .objects + .insert("/api/v1/namespaces/kars-mcp".into(), namespace()); + let source = source("source"); + let plan = Plan::new(&source, &config()).unwrap(); + let api: Api = Api::namespaced(client.clone(), "kars-mcp"); + let service = ownership::upsert( + &client, + &source, + &api, + &plan.owner, + "managed-ns", + plan.service("managed-ns"), + ) + .await + .unwrap(); + assert!(ownership::owned( + &service.metadata, + &plan.owner, + "managed-ns" + )); + let path = format!("/api/v1/namespaces/kars-mcp/services/{}", plan.owner.name); + { + let mut state = state.lock().unwrap(); + state.objects.get_mut(&path).unwrap()["metadata"]["annotations"]["customer"] = + "preserve".into(); + state.calls.clear(); + } + ownership::upsert( + &client, + &source, + &api, + &plan.owner, + "managed-ns", + plan.service("managed-ns"), + ) + .await + .unwrap(); + assert_eq!( + writes(&state.lock().unwrap()), + 0, + "unchanged specs must not cause rollout loops" + ); + let mut changed = plan.service("managed-ns"); + changed["spec"]["ports"][0]["port"] = 3001.into(); + ownership::upsert(&client, &source, &api, &plan.owner, "managed-ns", changed) + .await + .unwrap(); + assert_eq!( + state.lock().unwrap().objects[&path]["metadata"]["annotations"]["customer"], + "preserve" + ); +} + +#[tokio::test] +async fn selector_or_uid_conflicts_are_preserved_and_cleanup_is_cas_fenced() { + let (_server, client, state) = fixture().await; + state + .lock() + .unwrap() + .objects + .insert("/api/v1/namespaces/kars-mcp".into(), namespace()); + let source = source("source"); + let plan = Plan::new(&source, &config()).unwrap(); + let api: Api = Api::namespaced(client.clone(), "kars-mcp"); + ownership::upsert( + &client, + &source, + &api, + &plan.owner, + "managed-ns", + plan.service("managed-ns"), + ) + .await + .unwrap(); + let path = format!("/api/v1/namespaces/kars-mcp/services/{}", plan.owner.name); + { + let mut state = state.lock().unwrap(); + state.objects.get_mut(&path).unwrap()["spec"]["selector"][SOURCE_UID] = "foreign".into(); + state.calls.clear(); + } + assert!( + ownership::delete(&api, &plan.owner, "managed-ns") + .await + .is_err() + ); + assert_eq!(writes(&state.lock().unwrap()), 0); + { + let mut state = state.lock().unwrap(); + state.objects.get_mut(&path).unwrap()["spec"]["selector"][SOURCE_UID] = "source".into(); + state.conflict = true; + } + assert!( + ownership::delete(&api, &plan.owner, "managed-ns") + .await + .is_err() + ); + assert!(state.lock().unwrap().objects.contains_key(&path)); + state.lock().unwrap().conflict = false; + assert!( + ownership::delete(&api, &plan.owner, "managed-ns") + .await + .unwrap() + ); +} + +#[tokio::test] +async fn cleanup_never_uses_an_arbitrary_status_reference_as_delete_authority() { + let (_server, client, state) = fixture().await; + let mut source = source("source"); + source.status = + Some(serde_json::from_value(json!({"workloadRef":"kars-mcp/foreign"})).unwrap()); + assert!(cleanup(&client, &source).await.is_err()); + assert_eq!(writes(&state.lock().unwrap()), 0); +} + +#[test] +fn pending_conditions_never_claim_readiness() { + let conditions = pending_conditions(&[], Some(2), "Waiting for rollout"); + assert_eq!( + conditions + .iter() + .find(|condition| condition.type_ == "Ready") + .unwrap() + .status, + "False" + ); + assert_eq!( + conditions + .iter() + .find(|condition| condition.type_ == "Progressing") + .unwrap() + .status, + "True" + ); +} diff --git a/controller/src/mcp_server_reconciler/source.rs b/controller/src/mcp_server_reconciler/source.rs new file mode 100644 index 000000000..63748aed6 --- /dev/null +++ b/controller/src/mcp_server_reconciler/source.rs @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::mcp_server::{McpOAuthConfig, McpServer, McpServerSpec}; +use kube::ResourceExt; + +pub(super) async fn resolve_mcp_source( + mcp: &McpServer, +) -> ( + McpServerSpec, + Option, + Option<(&'static str, String)>, +) { + let spec = &mcp.spec; + if spec.managed.is_some() { + return match super::managed::validate_source(mcp) { + Ok(()) => (spec.clone(), None, None), + Err(error) => (spec.clone(), None, Some(("InvalidSpec", error))), + }; + } + let inline_any = spec.url.is_some() + || spec.oauth.is_some() + || spec.production_mode.is_some() + || spec.scopes.is_some() + || spec.allowed_tools.is_some() + || spec.display_name.is_some(); + if inline_any && spec.bundle_ref.is_some() { + return ( + McpServerSpec { + allowed_sandboxes: spec.allowed_sandboxes.clone(), + ..Default::default() + }, + None, + Some(( + "InvalidSpec", + "spec.bundleRef is mutually exclusive with inline content fields".into(), + )), + ); + } + let Some(bundle_ref) = spec.bundle_ref.as_ref() else { + return (spec.clone(), None, None); + }; + let signer_policy_handle = crate::signer_policy::global(); + let verify_result = match signer_policy_handle.snapshot() { + crate::signer_policy::SignerPolicyState::FromConfigMap(p) => { + let cfg: crate::policy_fetcher::SignerPolicyConfig = p.into(); + crate::policy_fetcher::fetch_and_verify_generic::< + crate::policy_canonical::mcp_server::McpServerKind, + >(bundle_ref, &cfg) + .await + } + crate::signer_policy::SignerPolicyState::Malformed(message) => Err( + crate::policy_fetcher::FetchError::SignerPolicyMalformed(message), + ), + crate::signer_policy::SignerPolicyState::Absent => { + let cfg = crate::policy_fetcher::SignerPolicyConfig::from_env(); + crate::policy_fetcher::fetch_and_verify_generic::< + crate::policy_canonical::mcp_server::McpServerKind, + >(bundle_ref, &cfg) + .await + } + }; + match verify_result { + Ok(verified) => ( + merge_bundle_with_selector(spec, &verified), + Some(verified.digest), + None, + ), + Err(error) => { + let reason = crate::policy_fetcher::reason_for_error(&error).unwrap_or("Transient"); + tracing::warn!(mcpserver = %mcp.name_any(), reason, "McpServer bundle verification failed"); + ( + McpServerSpec { + allowed_sandboxes: spec.allowed_sandboxes.clone(), + ..Default::default() + }, + None, + Some((reason, error.to_string())), + ) + } + } +} + +fn merge_bundle_with_selector( + cr_spec: &McpServerSpec, + verified: &crate::policy_canonical::mcp_server::VerifiedMcpServerBundle, +) -> McpServerSpec { + let oauth = verified.oauth.as_ref().map(|oauth| McpOAuthConfig { + issuer: oauth.issuer.clone(), + audience: oauth.audience.clone(), + resource: oauth.resource.clone(), + pkce: oauth.pkce.clone().unwrap_or_else(|| "S256".into()), + }); + McpServerSpec { + url: verified.url.clone(), + managed: None, + oauth, + production_mode: verified.production_mode, + scopes: verified.scopes.clone(), + allowed_tools: verified.allowed_tools.clone(), + allowed_sandboxes: cr_spec.allowed_sandboxes.clone(), + display_name: verified.display_name.clone(), + bundle_ref: None, + bearer_from_env: cr_spec.bearer_from_env.clone(), + } +} diff --git a/controller/src/mcp_server_reconciler/tests.rs b/controller/src/mcp_server_reconciler/tests.rs new file mode 100644 index 000000000..bf9ef7378 --- /dev/null +++ b/controller/src/mcp_server_reconciler/tests.rs @@ -0,0 +1,150 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::*; + +#[derive(Debug)] +struct MockOk; +#[async_trait::async_trait] +impl JwksFetcher for MockOk { + async fn fetch(&self, _: &str) -> Result { + let raw = br#"{"keys":[{"kty":"OKP","crv":"Ed25519","kid":"k1","x":"AAA"}]}"#.to_vec(); + Ok(FetchedJwks { raw, key_count: 1 }) + } +} + +#[derive(Debug)] +struct MockFailDns; +#[async_trait::async_trait] +impl JwksFetcher for MockFailDns { + async fn fetch(&self, _: &str) -> Result { + Err(FetchError::Discovery { + class: "dns", + detail: "name resolution failed".into(), + }) + } +} + +#[test] +fn parse_jwks_key_count_works() { + assert_eq!( + parse_jwks_key_count(br#"{"keys":[{"kid":"a"},{"kid":"b"}]}"#).unwrap(), + 2 + ); + assert!(parse_jwks_key_count(br#"{"foo":"bar"}"#).is_err()); +} + +#[test] +fn kid_from_public_is_deterministic_and_short() { + let public = [0u8; 32]; + let kid = kid_from_public_bytes(&public); + assert_eq!(kid.len(), 22); + assert_eq!(kid, kid_from_public_bytes(&public)); + assert!(!kid.contains('=')); +} + +#[test] +fn build_conditions_emits_three_types_on_success() { + let conditions = build_conditions(&[], Some(7), None); + assert_eq!(conditions.len(), 3); + for (name, expected) in [ + ("Ready", "True"), + ("Progressing", "False"), + ("Degraded", "False"), + ] { + let condition = conditions + .iter() + .find(|condition| condition.type_ == name) + .unwrap(); + assert_eq!(condition.status, expected); + assert_eq!(condition.observed_generation, Some(7)); + } +} + +#[test] +fn build_conditions_emits_degraded_true_on_failure() { + let conditions = build_conditions(&[], Some(2), Some(("JwksFetchFailed", "boom"))); + let ready = conditions + .iter() + .find(|condition| condition.type_ == "Ready") + .unwrap(); + assert_eq!(ready.status, "False"); + assert_eq!(ready.reason, "JwksFetchFailed"); + let degraded = conditions + .iter() + .find(|condition| condition.type_ == "Degraded") + .unwrap(); + assert_eq!(degraded.status, "True"); + assert_eq!(degraded.message, "boom"); +} + +#[test] +fn build_conditions_preserves_transition_time_on_repeat_success() { + let prior = build_conditions(&[], Some(1), None); + std::thread::sleep(Duration::from_millis(5)); + let next = build_conditions(&prior, Some(1), None); + assert_eq!(prior[0].last_transition_time, next[0].last_transition_time); +} + +#[test] +fn build_conditions_stamps_new_time_on_status_flip() { + let prior = build_conditions(&[], Some(1), None); + std::thread::sleep(Duration::from_millis(5)); + let next = build_conditions(&prior, Some(1), Some(("JwksFetchFailed", "x"))); + assert_ne!(prior[0].last_transition_time, next[0].last_transition_time); +} + +#[test] +fn fetch_error_class_buckets_are_safe_strings() { + for error in [ + FetchError::Discovery { + class: "dns", + detail: "x".into(), + }, + FetchError::Discovery { + class: "tls", + detail: "x".into(), + }, + FetchError::Discovery { + class: "timeout", + detail: "x".into(), + }, + FetchError::Discovery { + class: "http_status", + detail: "x".into(), + }, + FetchError::Jwks { + class: "tls", + detail: "x".into(), + }, + FetchError::InvalidJwks("x".into()), + ] { + assert!(matches!( + error.class(), + "dns" | "tls" | "timeout" | "http_status" | "invalid_jwks_format" + )); + } +} + +#[test] +fn mock_fetchers_compile_and_do_not_panic() { + let _ok: Arc = Arc::new(MockOk); + let _fail: Arc = Arc::new(MockFailDns); +} + +#[tokio::test] +async fn mock_ok_returns_one_key() { + assert_eq!(MockOk.fetch("https://example").await.unwrap().key_count, 1); +} + +#[tokio::test] +async fn mock_fail_dns_classifies() { + assert_eq!( + MockFailDns + .fetch("https://example") + .await + .unwrap_err() + .class(), + "dns" + ); +} diff --git a/controller/src/reconciler/mcp_binding.rs b/controller/src/reconciler/mcp_binding.rs new file mode 100644 index 000000000..69ca6489e --- /dev/null +++ b/controller/src/reconciler/mcp_binding.rs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::{crd::KarsSandbox, mcp_server::McpServer}; +use kube::{Api, Client, ResourceExt}; + +pub(super) struct Binding { + pub endpoint: Option, + pub signing: bool, + pub revision: String, +} + +pub(super) fn api_error(error: kube::Error) -> String { + match error { + kube::Error::Api(status) => format!("MCP binding API status {}", status.code), + _ => "MCP binding API/transport failure".into(), + } +} + +pub(super) async fn resolve( + client: &Client, + sandbox: &KarsSandbox, + name: &str, +) -> Result, String> { + let workspace = sandbox + .namespace() + .ok_or("MCP caller workspace is absent")?; + let source = Api::::namespaced(client.clone(), &workspace) + .get_opt(name) + .await + .map_err(api_error)?; + let Some(source) = source + .filter(|source| crate::mcp_server_reconciler::managed::selected_for(source, sandbox)) + else { + return Ok(None); + }; + if source.uid().is_none() + || source + .metadata + .generation + .is_none_or(|generation| generation <= 0) + || source.status.as_ref().is_none_or(|status| { + status.phase.as_deref() != Some("Ready") + || status.observed_generation != source.metadata.generation + }) + { + return Ok(None); + } + let endpoint = + crate::mcp_server_reconciler::managed::qualified_endpoint(client, &source).await?; + if source.spec.managed.is_some() && endpoint.is_none() { + return Ok(None); + } + let cm_name = format!("mcp-{name}-jwks"); + let Some(cm) = + Api::::namespaced(client.clone(), &workspace) + .get_opt(&cm_name) + .await + .map_err(api_error)? + else { + return Ok(None); + }; + let owner = cm + .metadata + .annotations + .as_ref() + .and_then(|annotations| annotations.get("kars.azure.com/mcp-source-uid")); + if owner.is_some_and(|uid| source.metadata.uid.as_ref() != Some(uid)) + || (source.spec.managed.is_some() && owner != source.metadata.uid.as_ref()) + || cm.metadata.deletion_timestamp.is_some() + || cm.metadata.uid.as_deref().is_none_or(str::is_empty) + || cm + .metadata + .resource_version + .as_deref() + .is_none_or(str::is_empty) + || cm + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get("app.kubernetes.io/managed-by")) + .map(String::as_str) + != Some("kars-controller") + || cm + .metadata + .labels + .as_ref() + .and_then(|labels| labels.get("kars.azure.com/mcp-server")) + .map(String::as_str) + != Some(name) + { + return Ok(None); + } + let revision = crate::providers::signing::content_digest( + &serde_json::to_vec(&( + &source.metadata.uid, + source.metadata.generation, + &cm.metadata.uid, + &cm.metadata.resource_version, + )) + .map_err(|_| "MCP binding revision serialization failed")?, + ); + Ok(Some(Binding { + endpoint, + signing: source.spec.managed.is_none(), + revision, + })) +} diff --git a/controller/src/reconciler/mcp_egress.rs b/controller/src/reconciler/mcp_egress.rs index fe6368a91..934e05b0a 100644 --- a/controller/src/reconciler/mcp_egress.rs +++ b/controller/src/reconciler/mcp_egress.rs @@ -27,28 +27,22 @@ pub(crate) async fn derive_mcp_egress_rules( refs: &[&crate::mcp_server::LocalObjectRef], sandbox_name: &str, existing: &[serde_json::Value], -) -> Result, kube::Error> { +) -> Result, String> { use kube::api::Api; - let mcp_api: Api = - Api::namespaced(client.clone(), sandbox_self_ns); + let sandbox = Api::::namespaced(client.clone(), sandbox_self_ns) + .get(sandbox_name) + .await + .map_err(super::mcp_binding::api_error)?; let mut out: Vec = Vec::new(); for mcp_ref in refs { let ref_name = mcp_ref.name.trim(); if ref_name.is_empty() { continue; } - let url = match mcp_api.get(ref_name).await { - Ok(mcp) => mcp.spec.url.unwrap_or_default(), - Err(kube::Error::Api(ae)) if ae.code == 404 => { - tracing::warn!( - sandbox = %sandbox_name, - mcp = %ref_name, - "referenced McpServer not found; egress not auto-derived", - ); - continue; - } - Err(e) => return Err(e), + let Some(binding) = super::mcp_binding::resolve(client, &sandbox, ref_name).await? else { + continue; }; + let url = binding.endpoint.unwrap_or_default(); if url.is_empty() { continue; } @@ -56,7 +50,6 @@ pub(crate) async fn derive_mcp_egress_rules( tracing::warn!( sandbox = %sandbox_name, mcp = %ref_name, - url = %url, "McpServer url not parseable for egress derivation; skipping", ); continue; diff --git a/controller/src/reconciler/mod.rs b/controller/src/reconciler/mod.rs index d7dabe3cc..2489ae3b0 100644 --- a/controller/src/reconciler/mod.rs +++ b/controller/src/reconciler/mod.rs @@ -40,6 +40,7 @@ mod dev_env; pub(crate) mod governance_mounts; mod governed_services; mod inference; +mod mcp_binding; mod mcp_egress; pub(crate) mod namespace_ownership; mod sre_writer; @@ -2382,11 +2383,21 @@ async fn reconcile(sandbox: Arc, ctx: Arc) -> Result = Vec::with_capacity(mcp_refs.len()); - for (idx, mcp_ref) in mcp_refs.iter().enumerate() { + let mut mcp_revisions = Vec::new(); + for mcp_ref in &mcp_refs { let mcp_name = mcp_ref.name.trim(); if mcp_name.is_empty() { continue; } + let Some(binding) = mcp_binding::resolve(client, &sandbox, mcp_name) + .await + .map_err(ReconcileError::Configuration)? + else { + tracing::warn!(sandbox = %name, mcp = %mcp_name, "MCP reference is not currently authorized and qualified"); + continue; + }; + let first_mcp = mirrored_mcp_names.is_empty(); + mcp_revisions.push(binding.revision); let jwks_cm = format!("mcp-{mcp_name}-jwks"); let signing_secret = format!("mcp-{mcp_name}-signing"); let jwks_volume = format!("mcp-jwks-{mcp_name}"); @@ -2409,7 +2420,7 @@ async fn reconcile(sandbox: Arc, ctx: Arc) -> Result, ctx: Arc) -> Result, ctx: Arc) -> Result { - let legacy_env = if idx == 0 { + let legacy_env = if first_mcp { Some(("MCP_SIGNING_KEY_DIR", signing_mount.clone())) } else { None @@ -2692,6 +2706,22 @@ async fn reconcile(sandbox: Arc, ctx: Arc) -> Result, ctx: Arc) -> Result:` string consumed by controller/src/reconciler/runtime.rs # via the `*_RUNTIME_IMAGE` env vars on the controller Deployment. diff --git a/docs/mcp.md b/docs/mcp.md index 6c05388ea..77b0f6371 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1,5 +1,10 @@ # MCP servers in kars +For controller-owned Playwright/Everything workloads, see +[Managed MCP workloads](tutorials/managed-mcp.md). That path requires the +qualified controller/chart, exact UID ownership and a successful protocol probe; +it does not turn a capability request into an automatic resource grant. + The [Model Context Protocol](https://modelcontextprotocol.io) (MCP) is how a kars agent reaches tools it doesn't ship with — a hosted search API, a wiki reader, a headless browser, your internal services. kars treats every MCP diff --git a/docs/security-audits/2026-09-08-managed-mcp.md b/docs/security-audits/2026-09-08-managed-mcp.md new file mode 100644 index 000000000..83ab0c765 --- /dev/null +++ b/docs/security-audits/2026-09-08-managed-mcp.md @@ -0,0 +1,70 @@ +# Managed MCP capability audit — 2026-09-08 + +Status: **PENDING — not qualified for deployment or merge** + +## Scope + +Closed managed Playwright/Everything presets; UID-bound namespace and resource +lifecycle; current rollout/protocol readiness; scoped router catalogs and +invocations; live AGT decisions; bounded session handling; OpenClaw/Hermes +bridges; optional image-build/apply assets. + +Canonical source `ce9044077` is an extraction reference, not approval evidence. +Its forced ownership, name-only selectors, status-directed deletion, inline +hashing, argument/result logging and accepted-error retry behavior are not +qualification shortcuts. + +## Required evidence + +- Actual API rejection of stale/replaced source and namespace identities. +- No foreign-resource adoption, selector replacement or name-only cleanup. +- Exact owned Deployment/Service/NetworkPolicy creation and deletion. +- Ready binds a successful protocol probe to the current image/generation. +- Direct HTTP calls obey policy denial; scoped calls cannot cross servers. +- Accepted RPC failures and semantic `isError` are not false successes/replays. +- Bounded discovery, bodies, keepalive and bridge queues. +- Both runtime handlers execute the actual router protocol independently of + Foundry-specific tool registration. +- Existing standalone/external MCP and publication guardrails remain intact. +- Qualified SRE-authority/governed-services ancestry, not merely copied source. + +## Qualification state + +Bounded automated review closed the guard type mismatch and incoming-caller +authorization findings. The unauthenticated lane requires actual loopback +socket identity; verified remote OAuth callers do not inherit managed Sandbox +catalogs or sessions. Automated review is not a human sign-off. + +Local paired controller/router qualification passed 311 MCP-selected unit cases, +five managed-MCP HTTP cases and twelve existing governed-telemetry cases, plus +all-target type checking, strict Clippy and formatting. Two test fixtures were +corrected to send their claimed MIME/Accept headers; production protocol checks +were not relaxed. Minor Clippy simplifications preserve behavior. + +Full OpenClaw type checking and five real MCP bridge HTTP tests passed using +the existing dependency cache after a missing-compiler failure. Its package +manifests, lockfile and mesh source match the previously qualified GitHub +candidate; no dependency installation, lockfile rewrite or placeholder +declaration was used. Earlier focused CLI and Hermes evidence is retained. + +The candidate still needs forward-integrated prerequisite qualification and +real Kind lifecycle/protocol acceptance. Image availability and publishing are +separate operator actions; no image release or full deployment readiness is +asserted by these local results. + +## Explicit limitations + +No skill-package or memory acquisition, task delivery, aggregate budget broker, +new provider, outbound OAuth acquisition, attestation verification by reference +equality, or autonomous grant worker is included. Reconciliation-driven +revocation is not instantaneous cancellation of already accepted upstream work. + +## Signatures + +| Role | Sign-off | +|---|---| +| Maintainer / owner | **PENDING** | +| Independent reviewer | **PENDING** | + +The limited human-review waivers for earlier qualified publication slices do +not apply to this candidate. diff --git a/docs/tutorials/managed-mcp.md b/docs/tutorials/managed-mcp.md new file mode 100644 index 000000000..bee4115ce --- /dev/null +++ b/docs/tutorials/managed-mcp.md @@ -0,0 +1,153 @@ +# Managed MCP workloads + +Kars can deploy the reviewed `playwright` and `everything` MCP presets. An +operator authors the `McpServer`; an access request alone never creates or +grants a server. This infrastructure does not deliver assignments, acquire skill +packages or memory, or implement aggregate task budgets. + +**Publication gate:** this slice depends on the qualified SRE-authority and +governed-services prerequisites. Their local source integration is not a +substitute for completed real-API acceptance or the required reviews. No image +release or deployment is performed by publishing this source. + +## Register a managed server + +After installing the matching controller/chart, create a server in the same +workspace as its authorized Sandboxes: + +```yaml +apiVersion: kars.azure.com/v1alpha1 +kind: McpServer +metadata: + name: browser + namespace: kars-system +spec: + managed: + preset: playwright + allowedTools: + - browser_navigate + - browser_snapshot + allowedSandboxes: + matchLabels: + team: research +``` + +Reference `browser` in the Sandbox's `spec.governance.mcpServerRefs` and ensure +its labels match the selector. Keep the existing `inferenceRef` and runtime +configuration. The legacy singular `mcpServerRef` remains supported. + +Managed server names must be DNS labels because their names become tool-name +prefixes. The `managed` object accepts only a reviewed `preset`; it cannot select +arbitrary images, commands, volumes, privileges, URLs or authentication sources. +It is mutually exclusive with `url`, `bundleRef`, `oauth`, `productionMode`, +`scopes` and `bearerFromEnv`. External URL and genuinely signed bundle authoring +paths remain separate; a managed image is not presented as a signed policy +bundle. + +Wait for `Ready` and inspect `status.endpoint`, `workloadRef`, +`managedNamespaceUid`, `workloadGeneration`, `workloadImage`, +`discoveredTools` and `toolSchemaDigest`. A TCP socket alone is insufficient: +the exact Deployment generation/image must become available and pass bounded +MCP initialization and `tools/list` discovery. The probe closes its session and +never invokes a tool. Empty `allowedTools` grants no tools. + +Unavailable images, invalid ownership, failed protocol negotiation and stale +rollouts remain Pending/Degraded; they are not advertised as working resources. + +## Invoke from either runtime + +OpenClaw and Hermes register two provider-independent tools: + +- `kars_mcp_list`, optionally with `server: "browser"`. +- `kars_mcp_call` with the exact returned tool name, optional server scope, and + an `arguments` object. + +The namespaced name is `browser.browser_navigate`; hyphens in server names become +underscores. The router re-evaluates its live AGT policy and per-tool rate limit +for `tool:browser.browser_navigate` before forwarding. Configure the bounding +ToolPolicy accordingly. A bridge cannot grant a denied capability, and direct +HTTP calls do not bypass the same router policy check. + +The optional `X-Kars-Mcp-Server` header narrows discovery and invocation to one +mounted server; it never selects a workspace or an arbitrary upstream URL. +The unauthenticated lane requires an actual loopback socket peer; missing +connection context, network peers and spoofed forwarding/Sandbox/session headers +are rejected before policy evaluation or invocation. Configured Sandbox identity +is not evidence about an incoming caller. Legacy OAuth routes retain their +verified caller identity for policy/audit and do not expose managed Sandbox +catalogs or browser sessions to remote OAuth clients. +Unqualified or unknown scopes do not invoke a tool. Bridge calls are not +automatically replayed, and HTTP-accepted upstream RPC errors—including errors +mentioning a session—are not retried as tool calls. Semantic `isError` survives +the bridge. Protocol errors and diagnostic logs do not copy API bodies. + +Registry changes are applied by Sandbox reconciliation and rolling restart, +not by an instant in-process revocation mechanism. Sandboxes referencing MCP +servers reconcile periodically as well as on normal resource events. A +credential/configuration revision change restarts their cached catalogs. + +## Isolation and ownership + +The default managed namespace is `kars-mcp`, separate from the controller. +Kars CREATEs and UID-claims it; it does not force-adopt an existing unclaimed +namespace. Namespace replacement is rejected rather than silently rebound. +Use a fresh namespace through `managedMcp.namespace` when necessary; retire +existing managed resources before changing their namespace. + +Each Deployment, Service and NetworkPolicy is bound to the full source CR UID +and namespace UID. UID-derived names and selectors prevent same-named servers +in different workspaces from sharing pods. Writes and deletion use API UID/RV +preconditions; status `workloadRef` is only a display hint. + +Pods run non-root with RuntimeDefault seccomp, dropped capabilities, a read-only +root filesystem, bounded resources and scratch storage, and no automounted +Kubernetes token. The managed path creates no signing key or endpoint credential. +NetworkPolicy permits incoming controller probes and authorized referring +Sandbox namespaces. Browser egress is restricted to public HTTP(S) plus DNS; +private, loopback and link-local IPv4 destinations are excluded. This is not a +claim that a browser's internal network activity inherits the agent's signed +hostname allowlist: granting a browser tool grants that preset's separately +documented network capability. ToolPolicy must bound the tool surface. + +Deleting a server waits for its owned Deployment to disappear, then removes its +owned Service and NetworkPolicy. The shared namespace and operator-managed pull +Secrets remain. Unbound legacy auxiliary Secrets/ConfigMaps are retained for +explicit operator cleanup instead of being deleted by name. + +## Image acquisition + +Playwright defaults to the pinned official MCR image. Image overrides are +operator-level Helm values: + +- `managedMcp.playwrightImage` +- `managedMcp.everythingImage` +- `managedMcp.imagePullSecret`: an existing dockerconfigjson Secret **in the + claimed managed namespace**, not a credential copied from another namespace. + +The Everything image has a checked-in Dockerfile and complete npm lockfile. +Its default GHCR reference is not evidence that a new release was published. +Build and publish it through an explicitly authorized operator workflow: + +```sh +kars push --only mcp-everything --apply +``` + +That command binds the owning controller configuration to the pushed manifest +and waits for eligible Everything servers to qualify the actual image. It does +not overwrite MCP CR specs or force-recreate Services/selectors. With no eligible +servers, only the owning default is updated. No release automation is added by +this slice. + +## Bounds and limitations + +Discovery is bounded to eight pages and 256 tools per server. Router response +bodies are bounded to 2 MiB; readiness probes use a smaller 256 KiB response +limit and a total deadline. Each bridge admits at most eight pending calls. +Concurrent calls that could race one upstream session are rejected rather than +mutating that session concurrently. Long-running calls can time out; a timeout +does not prove that an upstream side effect was cancelled. + +There is no new outbound OAuth acquisition flow. Existing explicitly configured +external bearer authentication and inbound OAuth verification retain their +separate contracts. The router does not claim durable receipts, shared budget +enforcement, workload attestation or autonomous resource approval. diff --git a/inference-router/src/main.rs b/inference-router/src/main.rs index 282e2a855..9252d4246 100644 --- a/inference-router/src/main.rs +++ b/inference-router/src/main.rs @@ -451,6 +451,11 @@ async fn main() -> Result<()> { let memory_binding_for_platform = state.memory_binding.clone(); let policy_status_for_platform = state.policy_status.clone(); let telemetry = state.services.telemetry.clone(); + let mcp_guard = ( + state.governance.clone(), + state.services.clone(), + state.sandbox_name.as_ref().clone(), + ); let services = routes::governed_service_routes(state.clone()).with_state(state.clone()); let merged = public .merge(protected) @@ -458,7 +463,7 @@ async fn main() -> Result<()> { .merge(handoff_mutations) .merge(handoff_status) .with_state(state) - .merge(build_mcp_router(Some(telemetry.clone())).await) + .merge(build_mcp_router(Some(telemetry.clone()), Some(mcp_guard)).await) .merge(build_platform_mcp_router( Some(memory_binding_for_platform), Some(policy_status_for_platform), @@ -633,6 +638,11 @@ async fn main() -> Result<()> { /// unauthenticated MCP traffic. async fn build_mcp_router( telemetry: Option>, + guard: Option<( + Arc, + Arc, + String, + )>, ) -> Router { use kars_inference_router::mcp::forwarder::RouterToolDispatcher; use kars_inference_router::mcp::oauth::OAuthVerifierConfig; @@ -686,8 +696,27 @@ async fn build_mcp_router( let mut state = routes::McpRouteState::standard(); state.task_telemetry = telemetry; + state.managed_prefixes = registry_arc + .servers + .values() + .filter(|server| { + server + .meta + .as_ref() + .is_some_and(|meta| meta.source_uid.is_some()) + }) + .map(|server| { + format!( + "{}.", + kars_inference_router::mcp::forwarder::server_name_to_prefix(&server.name) + ) + }) + .collect(); if let Some(d) = dispatcher_arc { state = state.with_tools(d); + if let Some((governance, services, principal)) = guard { + state = state.with_governance(governance, services, principal); + } } // Slice 4d.3 — prefer the multi-issuer path when MCP_JWKS_DIR is diff --git a/inference-router/src/mcp/forwarder.rs b/inference-router/src/mcp/forwarder.rs index be929a3a3..5c1c117bf 100644 --- a/inference-router/src/mcp/forwarder.rs +++ b/inference-router/src/mcp/forwarder.rs @@ -114,6 +114,7 @@ struct ForwarderEntry { /// the task's [`AbortHandle`] so it can be cancelled when the session is /// re-initialized (replaced) or the dispatcher is dropped. keepalive: Arc>>, + call_gate: Arc>, } /// Namespaced MCP forwarder — the production-mode `AsyncToolDispatcher` @@ -178,6 +179,7 @@ impl RouterToolDispatcher { ) -> Result { let http = reqwest::Client::builder() .timeout(per_call_timeout) + .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|e| format!("reqwest client init: {e}"))?; Self::discover_with_client(registry, http).await @@ -235,6 +237,9 @@ impl AsyncToolDispatcher for RouterToolDispatcher { if !entry.tools.contains_key(suffix) { return Err(DispatchError::UnknownTool(name.to_string())); } + let _call = entry.call_gate.try_lock().map_err(|_| DispatchError::ExecutionFailed { + tool: name.into(), reason: "MCP server has an active invocation; no concurrent session mutation was attempted".into(), + })?; forward_tools_call(&self.http, entry, suffix, arguments).await } } @@ -373,6 +378,7 @@ async fn build_entry_for( bearer_token, session: Arc::new(Mutex::new(session)), keepalive, + call_gate: Arc::new(Mutex::new(())), }, namespaced_defs, )) @@ -467,8 +473,8 @@ async fn initialize_session(http: &reqwest::Client, url: &str, bearer: Option<&s let resp = match req.send().await { Ok(r) => r, - Err(e) => { - tracing::debug!(url = %url, error = %e, "MCP initialize POST failed; treating upstream as stateless"); + Err(_) => { + tracing::debug!("MCP initialize transport failed; trying the legacy stateless path"); return McpSession::stateless(); } }; @@ -489,7 +495,7 @@ async fn initialize_session(http: &reqwest::Client, url: &str, bearer: Option<&s // body may be plain JSON or an SSE event stream (official TS-SDK servers // reply with SSE); `extract_jsonrpc_payload` handles both. If anything is // missing we fall back to the version we proposed. - let protocol_version = match resp.text().await { + let protocol_version = match super::response_body::text(resp).await { Ok(body) => extract_jsonrpc_payload(&content_type, &body) .ok() .and_then(|v| { @@ -529,11 +535,11 @@ async fn initialize_session(http: &reqwest::Client, url: &str, bearer: Option<&s if let Some(token) = bearer { nreq = nreq.bearer_auth(token); } - if let Err(e) = nreq.send().await { - tracing::debug!(url = %url, error = %e, "MCP notifications/initialized POST failed"); + if nreq.send().await.is_err() { + tracing::debug!("MCP notifications/initialized transport failed"); } - tracing::debug!(url = %url, protocol = %session.protocol_version, "Established MCP session with stateful upstream"); + tracing::debug!(protocol = %session.protocol_version, "Established MCP session with stateful upstream"); session } @@ -613,8 +619,8 @@ async fn run_session_keepalive( let resp = match req.send().await { Ok(r) => r, - Err(e) => { - tracing::debug!(url = %url, error = %e, "MCP keepalive GET failed; retrying"); + Err(_) => { + tracing::debug!("MCP keepalive transport failed; retrying"); tokio::time::sleep(KEEPALIVE_RECONNECT_BACKOFF).await; continue; } @@ -625,7 +631,6 @@ async fn run_session_keepalive( // stream (so it can't be heartbeating us either). Either way, stop: // there is nothing this task can keep alive. tracing::debug!( - url = %url, status = %resp.status(), "MCP keepalive GET not available; stopping keepalive for this session" ); @@ -637,11 +642,15 @@ async fn run_session_keepalive( while let Some(chunk) = stream.next().await { let bytes = match chunk { Ok(b) => b, - Err(e) => { - tracing::debug!(url = %url, error = %e, "MCP keepalive SSE read error; reconnecting"); + Err(_) => { + tracing::debug!("MCP keepalive stream transport failed; reconnecting"); break; } }; + if buf.len().saturating_add(bytes.len()) > 64 * 1024 { + tracing::warn!("MCP keepalive frame exceeded its byte limit"); + return; + } buf.push_str(&String::from_utf8_lossy(&bytes)); // SSE frames are newline-delimited; pull complete lines and act on // `data:` lines that carry a server→client `ping` request. @@ -661,6 +670,9 @@ async fn run_session_keepalive( // A ping *notification* (no id) needs no response. continue; }; + if !id.is_number() && id.as_str().is_none_or(|id| id.len() > 128) { + continue; + } let pong = json!({ "jsonrpc": "2.0", "id": id, "result": {} }); let mut preq = http .post(&url) @@ -671,8 +683,8 @@ async fn run_session_keepalive( if let Some(token) = bearer.as_deref() { preq = preq.bearer_auth(token); } - if let Err(e) = preq.send().await { - tracing::debug!(url = %url, error = %e, "MCP keepalive pong POST failed"); + if preq.send().await.is_err() { + tracing::debug!("MCP keepalive pong transport failed"); } } } @@ -683,81 +695,67 @@ async fn run_session_keepalive( } } -/// pagination — Slice 4d.4 caps at one page; multi-page upstreams are -/// truncated with a recorded warning. Multi-page support lands when -/// we have a real consumer that hits the cap (principles §5). +/// Bounded upstream catalog response. async fn fetch_upstream_tools( http: &reqwest::Client, url: &str, bearer: Option<&str>, session: &McpSession, ) -> Result, String> { - let body = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/list", - }); - - let mut req = http - .post(url) - .header("content-type", "application/json") - .header("accept", "application/json, text/event-stream") - .json(&body); - if let Some(token) = bearer { - req = req.bearer_auth(token); - } - req = session.apply(req); - let resp = req - .send() - .await - .map_err(|e| format!("tools/list POST failed: {e}"))?; - - let status = resp.status(); - let content_type = resp - .headers() - .get("content-type") - .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(); - let body_text = resp - .text() - .await - .map_err(|e| format!("tools/list body read failed: {e}"))?; - - if !status.is_success() { - return Err(format!( - "tools/list non-2xx: {} (body trimmed: {})", - status, - body_text.chars().take(120).collect::() - )); - } - - let json_payload = extract_jsonrpc_payload(&content_type, &body_text) - .map_err(|e| format!("tools/list response decode failed: {e}"))?; - let parsed: ToolsListResponse = serde_json::from_value(json_payload) - .map_err(|e| format!("tools/list parse failed: {e}"))?; - - if let Some(err) = parsed.error { - return Err(format!( - "tools/list returned JSON-RPC error: code={} message={}", - err.code, err.message - )); - } - - let result = parsed - .result - .ok_or_else(|| "tools/list missing result".to_string())?; - - if result.next_cursor.is_some() { - tracing::warn!( - url = %url, - "Upstream advertised tools/list pagination (nextCursor present); \ - Slice 4d.4 only consumes the first page — additional tools will \ - not be advertised until pagination support lands" - ); + let mut cursor = None; + let mut cursors = std::collections::BTreeSet::new(); + let mut tools = BTreeMap::new(); + for _ in 0..8 { + let body = json!({"jsonrpc":"2.0","id":1,"method":"tools/list", + "params":cursor.as_ref().map(|cursor: &String| json!({"cursor":cursor})).unwrap_or_else(||json!({}))}); + let mut req = http + .post(url) + .header("content-type", "application/json") + .header("accept", "application/json, text/event-stream") + .json(&body); + if let Some(token) = bearer { + req = req.bearer_auth(token); + } + let resp = session + .apply(req) + .send() + .await + .map_err(|_| "tools/list transport failed")?; + let status = resp.status(); + let content_type = resp + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .to_string(); + let body = super::response_body::text(resp).await?; + if !status.is_success() { + return Err(format!("tools/list HTTP {}", status.as_u16())); + } + let value = extract_jsonrpc_payload(&content_type, &body) + .map_err(|_| "tools/list response decode failed")?; + let parsed: ToolsListResponse = + serde_json::from_value(value).map_err(|_| "tools/list response is invalid")?; + if let Some(error) = parsed.error { + return Err(format!("tools/list RPC error {}", error.code)); + } + let result = parsed.result.ok_or("tools/list missing result")?; + for tool in result.tools { + if tools.insert(tool.name.clone(), tool).is_some() || tools.len() > 256 { + return Err("tools/list contains duplicate tools or exceeds its tool limit".into()); + } + } + match result.next_cursor { + None => return Ok(tools.into_values().collect()), + Some(next) => { + if next.is_empty() || next.len() > 1024 || !cursors.insert(next.clone()) { + return Err("tools/list cursor is invalid or repeated".into()); + } + cursor = Some(next); + } + } } - - Ok(result.tools) + Err("tools/list exceeds its page limit".into()) } fn filter_by_allowlist(upstream: &[ToolDefinition], allow: &[String]) -> Vec { @@ -874,7 +872,12 @@ async fn post_tools_call( Err(e) => { return CallAttempt::Fatal(DispatchError::ExecutionFailed { tool: tool_label, - reason: format!("upstream POST failed: {e}"), + reason: if e.is_timeout() { + "upstream POST timed out" + } else { + "upstream POST transport failed" + } + .into(), }); } }; @@ -886,12 +889,12 @@ async fn post_tools_call( .and_then(|v| v.to_str().ok()) .unwrap_or("") .to_string(); - let body_text = match resp.text().await { + let body_text = match super::response_body::text(resp).await { Ok(t) => t, Err(e) => { return CallAttempt::Fatal(DispatchError::ExecutionFailed { tool: tool_label, - reason: format!("upstream body read failed: {e}"), + reason: e.into(), }); } }; @@ -900,19 +903,14 @@ async fn post_tools_call( if is_session_lost(status, &body_text) { return CallAttempt::SessionLost { reason: format!( - "http {} body: {}", - status, - body_text.chars().take(160).collect::() + "MCP session rejected before HTTP acceptance ({})", + status.as_u16() ), }; } return CallAttempt::Fatal(DispatchError::ExecutionFailed { tool: tool_label, - reason: format!( - "upstream non-2xx: {} (body trimmed: {})", - status, - body_text.chars().take(120).collect::() - ), + reason: format!("upstream HTTP {}", status.as_u16()), }); } @@ -936,16 +934,8 @@ async fn post_tools_call( }; if let Some(err) = parsed.error { - // A JSON-RPC session error (code -32000/-32001 with an explicit - // session-loss message) also means the session is gone — retry once. - // We reuse the same conservative body classifier so an ordinary - // tool-level error whose message merely mentions "session" is NOT - // mistaken for a lost session. - if (err.code == -32000 || err.code == -32001) && body_signals_session_loss(&err.message) { - return CallAttempt::SessionLost { - reason: format!("jsonrpc error code={} message={}", err.code, err.message), - }; - } + // HTTP acceptance is not proof that no side effect occurred. Even an + // RPC error mentioning a session must not cause tools/call replay. // Other upstream protocol error → surface as an isError content // entry, not a DispatchError. Per MCP spec, JSON-RPC errors from // `tools/call` indicate the *protocol* failed; the semantic "tool @@ -1173,6 +1163,7 @@ mod tests { url: url.to_string(), allowed_tools: allowed.into_iter().map(String::from).collect(), bearer_from_env: String::new(), + source_uid: None, }), } } @@ -1581,25 +1572,32 @@ mod tests { #[tokio::test] async fn invoke_surfaces_upstream_json_rpc_error_as_is_error() { - let state = MockState { - tools: vec![tool_def("flaky", "")], - force_call_error: Some((-32000, "boom".to_string())), - ..Default::default() - }; - let url = mock_upstream(state).await; - let registry = registry_with(vec![discovered("svc", &url, vec!["*"])]); - let dispatcher = RouterToolDispatcher::discover(registry, Duration::from_secs(5)) - .await - .unwrap(); - - let out = dispatcher - .invoke("svc.flaky", &serde_json::json!({})) - .await - .expect("invoke"); - assert!(out.is_error); - let ToolContent::Text { text } = &out.content[0]; - assert!(text.contains("code=-32000")); - assert!(text.contains("boom")); + for message in ["boom", "session expired", "Server not initialized"] { + let state = MockState { + tools: vec![tool_def("flaky", "")], + force_call_error: Some((-32000, message.to_string())), + ..Default::default() + }; + let calls = state.call_count.clone(); + let url = mock_upstream(state).await; + let registry = registry_with(vec![discovered("svc", &url, vec!["*"])]); + let dispatcher = RouterToolDispatcher::discover(registry, Duration::from_secs(5)) + .await + .unwrap(); + let out = dispatcher + .invoke("svc.flaky", &serde_json::json!({})) + .await + .expect("invoke"); + assert!(out.is_error); + let ToolContent::Text { text } = &out.content[0]; + assert!(text.contains("code=-32000")); + assert!(text.contains(message)); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "accepted errors must not replay tools/call" + ); + } } #[tokio::test] diff --git a/inference-router/src/mcp/governed.rs b/inference-router/src/mcp/governed.rs new file mode 100644 index 000000000..d426445f8 --- /dev/null +++ b/inference-router/src/mcp/governed.rs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::tools::{AsyncToolDispatcher, DispatchError, ToolCallOutput, ToolCatalog}; +use crate::{access_request::Request, governance::Governance, governed_services::GovernedServices}; +use async_trait::async_trait; +use serde_json::{Value, json}; +use std::sync::Arc; + +/// Constructed per request after the route authenticates its socket/OAuth +/// caller. Configuration identity alone is not caller authentication. +/// A capability request records denial, never a grant. +pub struct GovernedDispatcher { + inner: Arc, + governance: Arc, + services: Arc, + principal: String, +} + +impl GovernedDispatcher { + pub fn new( + inner: Arc, + governance: Arc, + services: Arc, + principal: String, + ) -> Self { + Self { + inner, + governance, + services, + principal, + } + } +} + +#[async_trait] +impl AsyncToolDispatcher for GovernedDispatcher { + fn catalog(&self) -> &ToolCatalog { + self.inner.catalog() + } + + async fn invoke(&self, name: &str, arguments: &Value) -> Result { + if !self.services.identity_valid { + return Err(DispatchError::ExecutionFailed { + tool: name.into(), + reason: "MCP caller identity is unavailable".into(), + }); + } + let scope = self.services.telemetry.cursor().0; + let action = format!("tool:{name}"); + let allowed = self.governance.check_tool_rate(name).0 + && self.governance.evaluate( + &self.principal, + &action, + Some(&json!({"mcp":true,"tool_name":name,"arguments":arguments})), + )["allowed"] + .as_bool() + == Some(true); + self.services + .telemetry + .record_policy(&scope, &action, allowed); + if !allowed { + let _ = self.services.requests.record(Request { + scope_id: scope, + kind: "tool".into(), + target: name.into(), + reason: "Governance denied the requested MCP tool".into(), + tier: None, + port: None, + }); + return Err(DispatchError::ExecutionFailed { + tool: name.into(), + reason: "MCP tool is not allowed by the current governance policy".into(), + }); + } + self.inner.invoke(name, arguments).await + } +} diff --git a/inference-router/src/mcp/mod.rs b/inference-router/src/mcp/mod.rs index 9d5bf1469..e30e9cc3e 100644 --- a/inference-router/src/mcp/mod.rs +++ b/inference-router/src/mcp/mod.rs @@ -47,6 +47,7 @@ pub mod error; pub mod forwarder; +pub mod governed; pub mod initialize; pub mod jsonrpc; pub mod oauth; @@ -54,6 +55,7 @@ pub mod oauth_layer; pub mod pipeline; pub mod platform; pub mod registry; +mod response_body; pub mod streamable_http; pub mod tools; diff --git a/inference-router/src/mcp/registry.rs b/inference-router/src/mcp/registry.rs index 8df9f9208..5a7af3f6d 100644 --- a/inference-router/src/mcp/registry.rs +++ b/inference-router/src/mcp/registry.rs @@ -70,6 +70,10 @@ pub struct DiscoveredMcpServerMeta { /// this at discovery time. Empty (the default) = no outbound auth. #[serde(default, skip_serializing_if = "String::is_empty")] pub bearer_from_env: String, + /// Controller-qualified managed workloads are local Sandbox capabilities, + /// not a remote OAuth publication surface. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_uid: Option, } /// A single McpServer discovered under `MCP_JWKS_DIR`. diff --git a/inference-router/src/mcp/response_body.rs b/inference-router/src/mcp/response_body.rs new file mode 100644 index 000000000..811c0484b --- /dev/null +++ b/inference-router/src/mcp/response_body.rs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use futures::StreamExt; + +pub(super) const MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024; + +pub(super) async fn text(response: reqwest::Response) -> Result { + if response + .content_length() + .is_some_and(|size| size > MAX_RESPONSE_BYTES as u64) + { + return Err("MCP response exceeds its byte limit"); + } + let mut bytes = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| "MCP response transport failed")?; + if bytes.len().saturating_add(chunk.len()) > MAX_RESPONSE_BYTES { + return Err("MCP response exceeds its byte limit"); + } + bytes.extend_from_slice(&chunk); + } + String::from_utf8(bytes).map_err(|_| "MCP response is not UTF-8") +} diff --git a/inference-router/src/routes/mcp.rs b/inference-router/src/routes/mcp.rs index 7066d1230..f61b99867 100644 --- a/inference-router/src/routes/mcp.rs +++ b/inference-router/src/routes/mcp.rs @@ -42,19 +42,21 @@ use axum::{ Router, body::Bytes, - extract::State, + extract::{ConnectInfo, Extension, State}, http::{HeaderMap, HeaderValue, StatusCode, header}, response::{IntoResponse, Response}, routing::post, }; -use std::sync::Arc; +use std::{collections::BTreeSet, net::SocketAddr, sync::Arc}; use crate::mcp::initialize::{InitializeConfig, OsRngSessionMinter, SessionMinter}; -use crate::mcp::oauth::OAuthVerifierConfig; +use crate::mcp::oauth::{OAuthVerifierConfig, VerifiedToken}; use crate::mcp::oauth_layer::OAuthLayer; use crate::mcp::pipeline::{ProcessOutcome, process_request_async}; use crate::mcp::tools::{AsyncToolDispatcher, EchoDispatcher, SyncToAsync}; +mod scoped; + /// HTTP header name carrying the MCP session id on a successful /// `initialize` response and on subsequent client requests. pub const MCP_SESSION_HEADER: &str = "Mcp-Session-Id"; @@ -66,6 +68,15 @@ pub struct McpRouteState { pub config: Arc, pub minter: Arc, pub tools: Arc, + pub caller_policy: Option>, + pub oauth_required: bool, + pub managed_prefixes: BTreeSet, +} + +pub struct McpCallerPolicy { + governance: Arc, + services: Arc, + sandbox: String, } impl McpRouteState { @@ -81,6 +92,9 @@ impl McpRouteState { config: Arc::new(InitializeConfig::default()), minter: Arc::new(OsRngSessionMinter), tools: Arc::new(SyncToAsync::new(EchoDispatcher::standard())), + caller_policy: None, + oauth_required: false, + managed_prefixes: BTreeSet::new(), } } @@ -91,6 +105,20 @@ impl McpRouteState { self } + pub fn with_governance( + mut self, + governance: Arc, + services: Arc, + sandbox: String, + ) -> Self { + self.caller_policy = Some(Arc::new(McpCallerPolicy { + governance, + services, + sandbox, + })); + self + } + /// State for the **platform MCP server** mounted at `/platform/mcp`. /// /// Publishes the runtime-agnostic Foundry-shim catalog @@ -126,6 +154,9 @@ impl McpRouteState { minter: Arc::new(OsRngSessionMinter), tools: Arc::new(dispatcher), task_telemetry: None, + caller_policy: None, + oauth_required: false, + managed_prefixes: BTreeSet::new(), } } } @@ -178,7 +209,8 @@ pub fn platform_mcp_route() -> Router { /// `request.extensions_mut()`, available to downstream handlers via an /// `axum::Extension` extractor (consumed by the /// upcoming per-tool scope check in `pipeline::process_request`). -pub fn protected_mcp_route(state: McpRouteState, oauth: Arc) -> Router { +pub fn protected_mcp_route(mut state: McpRouteState, oauth: Arc) -> Router { + state.oauth_required = true; mcp_route().with_state(state).layer(OAuthLayer::new(oauth)) } @@ -190,7 +222,65 @@ async fn method_not_allowed() -> impl IntoResponse { ) } -async fn post_mcp(State(state): State, headers: HeaderMap, body: Bytes) -> Response { +async fn post_mcp( + State(state): State, + peer: Option>>, + verified: Option>, + headers: HeaderMap, + body: Bytes, +) -> Response { + let local = peer + .as_ref() + .is_some_and(|peer| peer.0.0.ip().is_loopback()); + let principal = if state.oauth_required { + let Some(Extension(token)) = verified else { + return (StatusCode::UNAUTHORIZED, "Verified MCP caller required").into_response(); + }; + serde_json::json!(["oauth", token.issuer, token.subject]).to_string() + } else { + if !local { + return ( + StatusCode::FORBIDDEN, + "Same-router socket peer required for local MCP", + ) + .into_response(); + } + state + .caller_policy + .as_ref() + .map(|policy| policy.sandbox.clone()) + .unwrap_or_default() + }; + let base_tools: Arc = if state.oauth_required && !local { + match scoped::ScopedDispatcher::exclude(state.tools.clone(), &state.managed_prefixes) { + Ok(tools) => Arc::new(tools), + Err(error) => return (StatusCode::SERVICE_UNAVAILABLE, error).into_response(), + } + } else { + state.tools.clone() + }; + let tools: Arc = if let Some(server) = headers.get("x-kars-mcp-server") + { + let Ok(server) = server.to_str() else { + return (StatusCode::BAD_REQUEST, "Invalid MCP server scope").into_response(); + }; + match scoped::ScopedDispatcher::new(base_tools, server) { + Ok(scoped) => Arc::new(scoped), + Err(error) => return (StatusCode::NOT_FOUND, error).into_response(), + } + } else { + base_tools + }; + let tools = if let Some(policy) = state.caller_policy.as_ref() { + Arc::new(crate::mcp::governed::GovernedDispatcher::new( + tools, + policy.governance.clone(), + policy.services.clone(), + principal, + )) as Arc + } else { + tools + }; let telemetry_scope = state .task_telemetry .as_ref() @@ -208,7 +298,7 @@ async fn post_mcp(State(state): State, headers: HeaderMap, body: accept.as_deref(), state.config.as_ref(), state.minter.as_ref(), - Some(state.tools.as_ref()), + Some(tools.as_ref()), ) .await; if let (Some(telemetry), Some(scope)) = (&state.task_telemetry, telemetry_scope) { @@ -324,6 +414,7 @@ mod tests { config: Arc::new(InitializeConfig::default()), minter: Arc::new(FixedMinter("test-session-001")), tools: Arc::new(SyncToAsync::new(EchoDispatcher::standard())), + ..McpRouteState::standard() } } @@ -336,7 +427,11 @@ mod tests { if let Some(a) = accept { req = req.header("accept", a); } - req.body(Body::from(body.to_vec())).unwrap() + let mut request = req.body(Body::from(body.to_vec())).unwrap(); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345)))); + request } async fn body_text(resp: Response) -> (StatusCode, HeaderMap, String) { @@ -514,6 +609,7 @@ mod tests { tools: Arc::new(crate::mcp::PlatformDispatcher::with_base_url( "http://127.0.0.1:1", )), + ..McpRouteState::standard() } } @@ -526,7 +622,11 @@ mod tests { if let Some(a) = accept { req = req.header("accept", a); } - req.body(Body::from(body.to_vec())).unwrap() + let mut request = req.body(Body::from(body.to_vec())).unwrap(); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345)))); + request } #[tokio::test] @@ -703,6 +803,7 @@ mod tests { tools: Arc::new(crate::mcp::PlatformDispatcher::with_base_url( upstream.uri(), )), + ..McpRouteState::standard() }; let app = platform_mcp_route().with_state(state); @@ -969,6 +1070,93 @@ mod tests { assert!(v["result"]["protocolVersion"].is_string()); } + #[tokio::test] + async fn remote_oauth_uses_verified_caller_and_cannot_borrow_managed_sessions() { + use crate::mcp::tools::{ + DispatchError, ToolCallOutput, ToolCatalog, ToolContent, ToolDefinition, + }; + struct Tools(ToolCatalog, Arc); + #[async_trait::async_trait] + impl AsyncToolDispatcher for Tools { + fn catalog(&self) -> &ToolCatalog { + &self.0 + } + async fn invoke(&self, _: &str, _: &Value) -> Result { + self.1.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(ToolCallOutput { + content: vec![ToolContent::Text { text: "ok".into() }], + is_error: false, + }) + } + } + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let tools = ToolCatalog::new( + ["legacy.echo", "managed.echo"] + .into_iter() + .map(|name| ToolDefinition { + name: name.into(), + description: "test".into(), + input_schema: json!({"type":"object"}), + }) + .collect(), + ) + .unwrap(); + let governance = Arc::new(crate::governance::Governance::new("victim")); + let policy = tempfile::tempdir_in(".").unwrap(); + let path = policy.path().join("allow.yaml"); + std::fs::write(&path,"version: '1.0'\nagent: test\npolicies:\n - name: allow\n type: capability\n allowed_actions: ['tool:*']\n priority: 100\n").unwrap(); + governance + .policy + .load_from_file(path.to_str().unwrap()) + .unwrap(); + let services = Arc::new(crate::governed_services::GovernedServices::new( + crate::access_request::Identity::standalone("victim"), + None, + )); + let mut state = McpRouteState::standard() + .with_tools(Arc::new(Tools(tools, calls.clone()))) + .with_governance(governance.clone(), services, "victim".into()); + state.managed_prefixes.insert("managed.".into()); + let (sk, vk) = route_keypair_seeded(15); + let app = protected_mcp_route(state, route_oauth_cfg(route_jwks_with(&vk, ROUTE_TEST_KID))); + let token = route_issue_token(&sk, ROUTE_TEST_KID); + for (id, name) in [(1, "legacy.echo"), (2, "managed.echo")] { + let mut request = Request::post("/mcp") + .header("accept", "application/json, text/event-stream") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header("x-forwarded-for", "127.0.0.1") + .header("mcp-session-id", "victim-session") + .body(Body::from( + json!({"jsonrpc":"2.0","id":id,"method":"tools/call", + "params":{"name":name,"arguments":{}}}) + .to_string(), + )) + .unwrap(); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([10, 2, 3, 4], 12345)))); + let (_, _, body) = body_text(app.clone().oneshot(request).await.unwrap()).await; + let body: Value = serde_json::from_str(&body).unwrap(); + if id == 1 { + assert_eq!(body["result"]["content"][0]["text"], "ok"); + } else { + assert!(body.get("error").is_some() || body["result"]["isError"] == true); + } + } + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); + let entries = governance.audit_json()["entries"].clone(); + let actor = entries + .as_array() + .unwrap() + .iter() + .find(|entry| entry["action"] == "tool:legacy.echo") + .unwrap(); + assert_eq!( + actor["agent_id"], + json!(["oauth", ROUTE_TEST_ISS, "route-sub"]).to_string() + ); + } + #[tokio::test] async fn protected_route_rejects_get_with_401_before_method_check() { // The OAuth layer runs before the per-route method matcher; diff --git a/inference-router/src/routes/mcp/scoped.rs b/inference-router/src/routes/mcp/scoped.rs new file mode 100644 index 000000000..ad69314a6 --- /dev/null +++ b/inference-router/src/routes/mcp/scoped.rs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::mcp::{ + forwarder::server_name_to_prefix, + tools::{AsyncToolDispatcher, DispatchError, ToolCallOutput, ToolCatalog}, +}; +use async_trait::async_trait; +use serde_json::Value; +use std::sync::Arc; + +pub(super) struct ScopedDispatcher { + inner: Arc, + catalog: ToolCatalog, + prefix: String, +} + +impl ScopedDispatcher { + pub(super) fn exclude( + inner: Arc, + excluded: &std::collections::BTreeSet, + ) -> Result { + let tools = inner + .catalog() + .tools() + .iter() + .filter(|tool| !excluded.iter().any(|prefix| tool.name.starts_with(prefix))) + .cloned() + .collect(); + let catalog = ToolCatalog::new(tools).map_err(|_| "Invalid remote MCP catalog")?; + Ok(Self { + inner, + catalog, + prefix: String::new(), + }) + } + pub(super) fn new( + inner: Arc, + server: &str, + ) -> Result { + if server.is_empty() + || server.len() > 253 + || !server.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"-.".contains(&byte) + }) + { + return Err("Invalid MCP server scope"); + } + let prefix = format!("{}.", server_name_to_prefix(server)); + let tools = inner + .catalog() + .tools() + .iter() + .filter(|tool| tool.name.starts_with(&prefix)) + .cloned() + .collect::>(); + if tools.is_empty() { + return Err("Unknown or unqualified MCP server scope"); + } + let catalog = ToolCatalog::new(tools).map_err(|_| "Invalid scoped MCP catalog")?; + Ok(Self { + inner, + catalog, + prefix, + }) + } +} + +#[async_trait] +impl AsyncToolDispatcher for ScopedDispatcher { + fn catalog(&self) -> &ToolCatalog { + &self.catalog + } + + async fn invoke(&self, name: &str, arguments: &Value) -> Result { + if !name.starts_with(&self.prefix) + || !self.catalog.tools().iter().any(|tool| tool.name == name) + { + return Err(DispatchError::UnknownTool(name.into())); + } + self.inner.invoke(name, arguments).await + } +} diff --git a/inference-router/tests/governed_telemetry.rs b/inference-router/tests/governed_telemetry.rs index 6fb55ec7a..43288798b 100644 --- a/inference-router/tests/governed_telemetry.rs +++ b/inference-router/tests/governed_telemetry.rs @@ -477,7 +477,7 @@ async fn actual_mcp_http_errors_are_not_mistaken_for_success_and_bodies_are_not_ state.task_telemetry = Some(telemetry.clone()); let app = routes::mcp_route().with_state(state); for (id, name) in [(1, "echo"), (2, "missing-tool")] { - let request = Request::post("/mcp") + let mut request = Request::post("/mcp") .header("content-type", "application/json") .header("accept", "application/json, text/event-stream") .body(Body::from( @@ -486,6 +486,12 @@ async fn actual_mcp_http_errors_are_not_mistaken_for_success_and_bodies_are_not_ .to_string(), )) .unwrap(); + request + .extensions_mut() + .insert(axum::extract::ConnectInfo(std::net::SocketAddr::from(( + [127, 0, 0, 1], + 12345, + )))); assert_eq!( app.clone().oneshot(request).await.unwrap().status(), StatusCode::OK @@ -543,8 +549,14 @@ async fn mcp_is_error_and_dispatch_transport_failure_are_both_failure_observatio state.task_telemetry = Some(telemetry.clone()); let app = routes::mcp_route().with_state(state); for (id, name) in [(1, "tool_error"), (2, "transport_error")] { - let request=Request::post("/mcp").header("content-type","application/json").header("accept","application/json, text/event-stream") + let mut request=Request::post("/mcp").header("content-type","application/json").header("accept","application/json, text/event-stream") .body(Body::from(json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":{}}}).to_string())).unwrap(); + request + .extensions_mut() + .insert(axum::extract::ConnectInfo(std::net::SocketAddr::from(( + [127, 0, 0, 1], + 12345, + )))); assert_eq!( app.clone().oneshot(request).await.unwrap().status(), StatusCode::OK diff --git a/inference-router/tests/managed_mcp.rs b/inference-router/tests/managed_mcp.rs new file mode 100644 index 000000000..a0ae74daf --- /dev/null +++ b/inference-router/tests/managed_mcp.rs @@ -0,0 +1,273 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use async_trait::async_trait; +use axum::{ + body::{Body, to_bytes}, + extract::ConnectInfo, + http::Request, +}; +use kars_inference_router::{ + access_request::Identity, + governance::Governance, + governed_services::GovernedServices, + mcp::tools::{ + AsyncToolDispatcher, DispatchError, ToolCallOutput, ToolCatalog, ToolContent, + ToolDefinition, + }, + routes::{self, McpRouteState}, +}; +use serde_json::{Value, json}; +use std::net::SocketAddr; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; +use tower::ServiceExt; + +struct Dispatcher { + catalog: ToolCatalog, + calls: Arc, +} +#[async_trait] +impl AsyncToolDispatcher for Dispatcher { + fn catalog(&self) -> &ToolCatalog { + &self.catalog + } + async fn invoke( + &self, + _name: &str, + _arguments: &Value, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(ToolCallOutput { + content: vec![ToolContent::Text { text: "ok".into() }], + is_error: false, + }) + } +} +fn dispatcher() -> (Arc, Arc) { + let calls = Arc::new(AtomicUsize::new(0)); + let catalog = ToolCatalog::new( + ["one.echo", "two.echo"] + .into_iter() + .map(|name| ToolDefinition { + name: name.into(), + description: "echo".into(), + input_schema: json!({"type":"object"}), + }) + .collect(), + ) + .unwrap(); + ( + Arc::new(Dispatcher { + catalog, + calls: calls.clone(), + }), + calls, + ) +} +async fn request(state: McpRouteState, server: &str, method: &str, params: Value) -> (u16, Value) { + let mut request = Request::builder() + .method("POST") + .uri("/mcp") + .header("accept", "application/json, text/event-stream") + .header("content-type", "application/json") + .header("x-kars-mcp-server", server) + .body(Body::from( + json!({"jsonrpc":"2.0","id":1,"method":method,"params":params}).to_string(), + )) + .unwrap(); + request + .extensions_mut() + .insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 12345)))); + let response = routes::mcp_route() + .with_state(state) + .oneshot(request) + .await + .unwrap(); + let status = response.status().as_u16(); + let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + ( + status, + serde_json::from_slice(&bytes).unwrap_or(Value::Null), + ) +} + +#[tokio::test] +async fn scoped_catalog_and_invocation_never_cross_server_boundaries() { + let (dispatcher, calls) = dispatcher(); + let state = McpRouteState::standard().with_tools(dispatcher); + let (status, body) = request(state.clone(), "one", "tools/list", json!({})).await; + assert_eq!(status, 200); + assert_eq!(body["result"]["tools"].as_array().unwrap().len(), 1); + assert_eq!(body["result"]["tools"][0]["name"], "one.echo"); + let (_, body) = request( + state.clone(), + "one", + "tools/call", + json!({"name":"two.echo","arguments":{}}), + ) + .await; + assert!(body.get("error").is_some() || body["result"]["isError"] == true); + assert_eq!(calls.load(Ordering::SeqCst), 0); + let (_, body) = request( + state, + "one", + "tools/call", + json!({"name":"one.echo","arguments":{}}), + ) + .await; + assert_eq!(body["result"]["content"][0]["text"], "ok"); + assert_eq!(calls.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn unknown_server_scope_never_dispatches() { + let (dispatcher, calls) = dispatcher(); + let (status, _) = request( + McpRouteState::standard().with_tools(dispatcher), + "unmounted", + "tools/call", + json!({"name":"one.echo"}), + ) + .await; + assert_eq!(status, 404); + assert_eq!(calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn direct_mcp_http_calls_obey_live_agt_denials_and_record_no_argument_bodies() { + let (inner, calls) = dispatcher(); + let governance = Arc::new(Governance::new("test")); + let policy = tempfile::tempdir_in(".").unwrap(); + let path = policy.path().join("deny.yaml"); + std::fs::write(&path, "version: '1.0'\nagent: test\npolicies:\n - name: deny-mcp\n type: capability\n denied_actions: ['tool:*']\n priority: 100\n").unwrap(); + governance + .policy + .load_from_file(path.to_str().unwrap()) + .unwrap(); + let services = Arc::new(GovernedServices::new(Identity::standalone("test"), None)); + let (_, body) = request( + McpRouteState::standard().with_tools(inner).with_governance( + governance, + services, + "test".into(), + ), + "one", + "tools/call", + json!({"name":"one.echo","arguments":{"secret":"PRIVATE_SENTINEL"}}), + ) + .await; + assert!(body.get("error").is_some() || body["result"]["isError"] == true); + assert_eq!(calls.load(Ordering::SeqCst), 0); + assert!(!body.to_string().contains("PRIVATE_SENTINEL")); +} + +#[tokio::test] +async fn missing_peer_network_peer_and_forged_local_headers_never_evaluate_or_invoke() { + for peer in [ + None, + Some(SocketAddr::from(([10, 2, 3, 4], 32100))), + Some("[fd00::1]:32100".parse().unwrap()), + ] { + let (inner, calls) = dispatcher(); + let governance = Arc::new(Governance::new("victim")); + let services = Arc::new(GovernedServices::new(Identity::standalone("victim"), None)); + let state = McpRouteState::standard().with_tools(inner).with_governance( + governance.clone(), + services, + "victim".into(), + ); + let mut request = Request::post("/mcp") + .header("accept", "application/json, text/event-stream") + .header("content-type", "application/json") + .header("forwarded", "for=127.0.0.1") + .header("x-forwarded-for", "127.0.0.1") + .header("x-real-ip", "127.0.0.1") + .header("x-kars-sandbox", "victim") + .header("mcp-session-id", "victim-session") + .body(Body::from( + json!({"jsonrpc":"2.0","id":1,"method":"tools/call", + "params":{"name":"one.echo","arguments":{}}}) + .to_string(), + )) + .unwrap(); + if let Some(peer) = peer { + request.extensions_mut().insert(ConnectInfo(peer)); + } + let response = routes::mcp_route() + .with_state(state) + .oneshot(request) + .await + .unwrap(); + assert_eq!(response.status().as_u16(), 403); + assert_eq!(calls.load(Ordering::SeqCst), 0); + assert_eq!(governance.metrics.evaluations.load(Ordering::Relaxed), 0); + } +} + +#[tokio::test] +async fn real_http_loopback_can_initialize_scope_catalog_and_invoke_only_its_server() { + let (inner, calls) = dispatcher(); + let state = McpRouteState::standard().with_tools(inner); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve( + listener, + routes::mcp_route() + .with_state(state) + .into_make_service_with_connect_info::(), + ) + .await + .unwrap(); + }); + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let http = reqwest::Client::builder() + .no_proxy() + .timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap(); + let url = format!("http://{address}/mcp"); + let response = http.post(&url).header("accept","application/json, text/event-stream") + .json(&json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{ + "protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"loopback","version":"1"}}})) + .send().await.unwrap(); + assert!(response.status().is_success()); + let session = response + .headers() + .get("mcp-session-id") + .unwrap() + .to_str() + .unwrap() + .to_string(); + let catalog: Value = http + .post(&url) + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", &session) + .header("x-kars-mcp-server", "one") + .json(&json!({"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}})) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(catalog["result"]["tools"].as_array().unwrap().len(), 1); + assert_eq!(catalog["result"]["tools"][0]["name"], "one.echo"); + let forbidden: Value = http.post(&url).header("accept","application/json, text/event-stream") + .header("mcp-session-id",&session).header("x-kars-mcp-server","one") + .json(&json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"two.echo","arguments":{}}})) + .send().await.unwrap().json().await.unwrap(); + assert!(forbidden.get("error").is_some() || forbidden["result"]["isError"] == true); + assert_eq!(calls.load(Ordering::SeqCst), 0); + let result: Value = http.post(&url).header("accept","application/json, text/event-stream") + .header("mcp-session-id",session).header("x-kars-mcp-server","one") + .json(&json!({"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"one.echo","arguments":{}}})) + .send().await.unwrap().json().await.unwrap(); + assert_eq!(result["result"]["content"][0]["text"], "ok"); + assert_eq!(calls.load(Ordering::SeqCst), 1); + server.abort(); + let _ = server.await; +} diff --git a/runtimes/hermes/src/kars_runtime_hermes/plugin/__init__.py b/runtimes/hermes/src/kars_runtime_hermes/plugin/__init__.py index 86243e931..7f4fe6a0d 100644 --- a/runtimes/hermes/src/kars_runtime_hermes/plugin/__init__.py +++ b/runtimes/hermes/src/kars_runtime_hermes/plugin/__init__.py @@ -91,6 +91,9 @@ def register(ctx: Any) -> None: # noqa: ANN401 — Hermes' ctx is dynamic from . import http_fetch # noqa: PLC0415 http_fetch.register(ctx) + from . import mcp_bridge # noqa: PLC0415 + + mcp_bridge.register(ctx) # Phase A2.1 — real AGT MeshClient (replaces mesh_stubs). # SKIPPED in SRE mode per §7.8.6 — the SRE agent is not on the mesh diff --git a/runtimes/hermes/src/kars_runtime_hermes/plugin/mcp_bridge.py b/runtimes/hermes/src/kars_runtime_hermes/plugin/mcp_bridge.py new file mode 100644 index 000000000..3eadee7c8 --- /dev/null +++ b/runtimes/hermes/src/kars_runtime_hermes/plugin/mcp_bridge.py @@ -0,0 +1,143 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Bounded MCP access through the existing loopback router client.""" + +from __future__ import annotations + +import json +import re +import threading +from typing import Any + +from . import router_client + +_LOCK = threading.Lock() +_SLOTS = threading.BoundedSemaphore(8) +_SESSION: str | None = None +_INITIALIZED = False +_NEXT_ID = 1 +_PROTOCOL = "2025-06-18" +_MAX_BYTES = 2 * 1024 * 1024 + + +def _headers(server: str | None) -> dict[str, str]: + headers = {"Accept": "application/json, text/event-stream", "MCP-Protocol-Version": _PROTOCOL} + if _SESSION: + headers["Mcp-Session-Id"] = _SESSION + if server: + headers["X-Kars-Mcp-Server"] = server + return headers + + +def _request(method: str, params: dict[str, Any], server: str | None, notification: bool = False) -> tuple[Any, dict[str, Any]]: + global _NEXT_ID, _INITIALIZED + request_id = _NEXT_ID + _NEXT_ID += 1 + body = {"jsonrpc": "2.0", "method": method, "params": params} + if not notification: + body["id"] = request_id + if len(json.dumps(body).encode()) > _MAX_BYTES: + raise RuntimeError("MCP request exceeds its byte limit") + response = router_client.call("POST", "/mcp", json=body, headers=_headers(server)) + if response.status_code in (400, 404): + _INITIALIZED = False + if not 200 <= response.status_code < 300: + raise RuntimeError(f"MCP HTTP {response.status_code}") + if len(response.content) > _MAX_BYTES: + raise RuntimeError("MCP response exceeds its byte limit") + if notification: + return response, {} + try: + value = response.json() + except Exception: + raise RuntimeError("MCP router response is not valid JSON") from None + if not isinstance(value, dict) or value.get("jsonrpc") != "2.0" or value.get("id") != request_id \ + or value.get("error") or not isinstance(value.get("result"), dict): + raise RuntimeError("MCP RPC failed or omitted its matching result") + return response, value["result"] + + +def _initialize(server: str | None) -> None: + global _SESSION, _INITIALIZED, _PROTOCOL + _SESSION = None + _PROTOCOL = "2025-06-18" + response, result = _request("initialize", { + "protocolVersion": _PROTOCOL, "capabilities": {}, + "clientInfo": {"name": "kars-runtime-hermes", "version": "0.1.0"}, + }, server) + protocol = result.get("protocolVersion") + if protocol not in ("2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"): + raise RuntimeError("MCP negotiated an unsupported protocol") + session = response.headers.get("mcp-session-id") + if session is not None and (not session or len(session) > 1024 or not re.fullmatch(r"[\x21-\x7e]+", session)): + raise RuntimeError("MCP session identifier is invalid") + _SESSION, _PROTOCOL = session, protocol + _request("notifications/initialized", {}, server, notification=True) + _INITIALIZED = True + + +def _invoke(method: str, params: dict[str, Any], server: str | None = None) -> dict[str, Any]: + if server is not None and (not isinstance(server, str) or len(server) > 253 + or not re.fullmatch(r"[a-z0-9.-]+", server)): + raise RuntimeError("Invalid MCP server scope") + if not _SLOTS.acquire(blocking=False): + raise RuntimeError("MCP bridge queue is full") + locked = False + try: + locked = _LOCK.acquire(timeout=60) + if not locked: + raise RuntimeError("MCP bridge queue timed out") + if not _INITIALIZED: + _initialize(server) + # No tools/call retry, including accepted JSON-RPC errors. + _, result = _request(method, params, server) + return result + finally: + if locked: + _LOCK.release() + _SLOTS.release() + + +def _failure(message: str) -> str: + return json.dumps({"error": message, "isError": True}) + + +def _list_tools(args: dict[str, Any], **_kwargs: Any) -> str: + try: + result = _invoke("tools/list", {}, args.get("server")) + if not isinstance(result.get("tools"), list): + return _failure("MCP catalog omitted tools") + return json.dumps(result, separators=(",", ":")) + except Exception: + return _failure("MCP discovery failed; inspect the router's scoped status") + + +def _call_tool(args: dict[str, Any], **_kwargs: Any) -> str: + name, arguments = args.get("name"), args.get("arguments", {}) + if not isinstance(name, str) or not name.strip(): + return _failure("name is required") + if not isinstance(arguments, dict): + return _failure("arguments must be an object") + try: + return json.dumps(_invoke("tools/call", {"name": name, "arguments": arguments}, args.get("server")), separators=(",", ":")) + except Exception: + return _failure("MCP invocation failed; calls are never automatically replayed") + + +def register(ctx: Any) -> None: + for name, description, parameters, handler in ( + ("kars_mcp_list", "List qualified MCP tools mounted in this sandbox, optionally scoped to one server.", + {"type": "object", "properties": {"server": {"type": "string"}}}, _list_tools), + ("kars_mcp_call", "Call an exact MCP tool name returned by kars_mcp_list. No automatic call replay.", + {"type": "object", "properties": {"name": {"type": "string"}, "arguments": {"type": "object"}, + "server": {"type": "string"}}, "required": ["name"]}, _call_tool), + ): + ctx.register_tool(name=name, toolset="kars_mcp", + schema={"name": name, "description": description, "parameters": parameters}, + handler=handler, description=description) + + +def _reset_for_tests() -> None: + global _SESSION, _INITIALIZED, _NEXT_ID, _PROTOCOL + _SESSION, _INITIALIZED, _NEXT_ID, _PROTOCOL = None, False, 1, "2025-06-18" diff --git a/runtimes/hermes/tests/test_mcp_bridge.py b/runtimes/hermes/tests/test_mcp_bridge.py new file mode 100644 index 000000000..ff64ff201 --- /dev/null +++ b/runtimes/hermes/tests/test_mcp_bridge.py @@ -0,0 +1,111 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from unittest.mock import patch + +import httpx +import pytest + +from kars_runtime_hermes.plugin import mcp_bridge, router_client + + +@pytest.fixture +def server(): + state = {"calls": [], "result": {"content": [{"type": "text", "text": "ok"}]}, "error": None, "status": 200} + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers["content-length"]))) + state["calls"].append((body, dict(self.headers))) + if body["method"] == "initialize": + self.send_response(200) + self.send_header("mcp-session-id", "session-one") + value = {"jsonrpc": "2.0", "id": body["id"], "result": {"protocolVersion": "2025-06-18", "capabilities": {}}} + elif body["method"] == "notifications/initialized": + self.send_response(202) + self.end_headers() + return + else: + self.send_response(state["status"]) + value = {"jsonrpc": "2.0", "id": body["id"]} + if state["error"]: + value["error"] = state["error"] + else: + value["result"] = {"tools": [{"name": "everything.echo"}]} if body["method"] == "tools/list" else state["result"] + data = json.dumps(value).encode() + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + http = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=http.serve_forever, daemon=True) + thread.start() + mcp_bridge._reset_for_tests() + try: + with httpx.Client(base_url=f"http://127.0.0.1:{http.server_port}", trust_env=False) as client: + with patch.object(router_client, "_client", return_value=client): + yield state + finally: + http.shutdown() + http.server_close() + thread.join(timeout=5) + + +def test_real_http_negotiation_scope_headers_and_one_session(server): + result = mcp_bridge._invoke("tools/list", {}, "everything") + assert result["tools"][0]["name"] == "everything.echo" + mcp_bridge._invoke("tools/call", {"name": "everything.echo", "arguments": {}}, "everything") + assert [body["method"] for body, _ in server["calls"]] == [ + "initialize", "notifications/initialized", "tools/list", "tools/call"] + headers = server["calls"][-1][1] + assert headers["Mcp-Session-Id"] == "session-one" + assert headers["MCP-Protocol-Version"] == "2025-06-18" + assert headers["X-Kars-Mcp-Server"] == "everything" + + +def test_accepted_session_error_never_replays_or_copies_error_body(server): + server["error"] = {"code": -32000, "message": "session expired PRIVATE_SENTINEL"} + result = json.loads(mcp_bridge._call_tool({"name": "everything.echo"})) + assert result["isError"] is True + assert "PRIVATE_SENTINEL" not in json.dumps(result) + assert sum(body["method"] == "tools/call" for body, _ in server["calls"]) == 1 + assert sum(body["method"] == "initialize" for body, _ in server["calls"]) == 1 + + +def test_semantic_error_result_is_preserved(server): + server["result"]["isError"] = True + result = json.loads(mcp_bridge._call_tool({"name": "everything.echo", "arguments": {}})) + assert result["isError"] is True + assert result["content"] == server["result"]["content"] + + +def test_bad_arguments_and_scope_never_send_requests(server): + assert json.loads(mcp_bridge._call_tool({"name": "everything.echo", "arguments": []}))["isError"] is True + assert json.loads(mcp_bridge._list_tools({"server": "../other"}))["isError"] is True + assert server["calls"] == [] + + +def test_http_failure_is_not_replayed(server): + server["status"] = 400 + result = json.loads(mcp_bridge._call_tool({"name": "everything.echo"})) + assert result["isError"] is True + assert sum(body["method"] == "tools/call" for body, _ in server["calls"]) == 1 + + +def test_registers_both_real_handlers(): + tools = [] + + class Context: + def register_tool(self, **kwargs): + tools.append(kwargs) + + mcp_bridge.register(Context()) + assert {tool["name"] for tool in tools} == {"kars_mcp_list", "kars_mcp_call"} + assert all(callable(tool["handler"]) for tool in tools) diff --git a/runtimes/openclaw/src/core/agt-tools/mcp-bridge.test.ts b/runtimes/openclaw/src/core/agt-tools/mcp-bridge.test.ts new file mode 100644 index 000000000..338b36bca --- /dev/null +++ b/runtimes/openclaw/src/core/agt-tools/mcp-bridge.test.ts @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as http from "node:http"; +import { once } from "node:events"; + +const target = vi.hoisted(() => ({ port: 0 })); +vi.mock("node:http", async () => { + const actual = await vi.importActual("node:http"); + return { ...actual, request: (options: http.RequestOptions, callback: (response: http.IncomingMessage) => void) => { + expect(options.hostname).toBe("127.0.0.1"); + expect(options.port).toBe(8443); + expect(options.path).toBe("/mcp"); + return actual.request({ ...options, port: target.port }, callback); + } }; +}); +import { invokeMcp, registerMcpBridgeTools, resetMcpBridgeForTests } from "./mcp-bridge.js"; + +describe("real HTTP MCP bridge", () => { + let server: http.Server; + let calls: Array<{ body: any; headers: http.IncomingHttpHeaders }>; + let result: Record; + let rpcError: unknown; + let status: number; + beforeEach(async () => { + resetMcpBridgeForTests(); + calls = []; + result = { content: [{ type: "text", text: "ok" }] }; + rpcError = undefined; + status = 200; + server = http.createServer(async (request, response) => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const body = JSON.parse(Buffer.concat(chunks).toString()); + calls.push({ body, headers: request.headers }); + response.setHeader("content-type", "application/json"); + if (body.method === "initialize") { + response.setHeader("mcp-session-id", "session-one"); + response.end(JSON.stringify({ jsonrpc: "2.0", id: body.id, result: { protocolVersion: "2025-06-18", capabilities: {} } })); + } else if (body.method === "notifications/initialized") { + response.writeHead(202).end(); + } else { + response.writeHead(status); + response.end(JSON.stringify({ jsonrpc: "2.0", id: body.id, + ...(rpcError ? { error: rpcError } : { result: body.method === "tools/list" ? { tools: [{ name: "everything.echo" }] } : result }) })); + } + }).listen(0, "127.0.0.1"); + await once(server, "listening"); + target.port = (server.address() as { port: number }).port; + }); + afterEach(async () => { await new Promise(resolve => server.close(() => resolve())); }); + + it("initializes once, scopes calls, and sends negotiated session headers without credentials", async () => { + await invokeMcp("tools/list", {}, "everything"); + await invokeMcp("tools/call", { name: "everything.echo", arguments: { value: 1 } }, "everything"); + expect(calls.map(call => call.body.method)).toEqual(["initialize", "notifications/initialized", "tools/list", "tools/call"]); + expect(calls.at(-1)?.headers["mcp-session-id"]).toBe("session-one"); + expect(calls.at(-1)?.headers["mcp-protocol-version"]).toBe("2025-06-18"); + expect(calls.every(call => call.headers["x-kars-mcp-server"] === "everything")).toBe(true); + expect(calls.every(call => call.headers.authorization === undefined)).toBe(true); + }); + + it("never replays accepted RPC failures or leaks their error bodies", async () => { + rpcError = { code: -32000, message: "session expired PRIVATE_SENTINEL" }; + await expect(invokeMcp("tools/call", { name: "everything.echo" })).rejects.toThrow("MCP RPC failed"); + expect(calls.filter(call => call.body.method === "tools/call")).toHaveLength(1); + expect(calls.filter(call => call.body.method === "initialize")).toHaveLength(1); + }); + + it("preserves semantic isError and rejects malformed arguments without invocation", async () => { + const tools: any[] = []; + registerMcpBridgeTools({ registerTool: tool => tools.push(tool) }); + result = { content: [{ type: "text", text: "tool failed" }], isError: true }; + const output = await tools[1].execute("id", { name: "everything.echo", arguments: {} }); + expect(output.isError).toBe(true); + expect(JSON.parse(output.content[0].text).isError).toBe(true); + const count = calls.length; + expect((await tools[1].execute("id", { name: "everything.echo", arguments: [] })).isError).toBe(true); + expect(calls).toHaveLength(count); + }); + + it("does not follow redirects or retry a failed HTTP call", async () => { + status = 302; + await expect(invokeMcp("tools/call", { name: "everything.echo" })).rejects.toThrow("MCP HTTP 302"); + expect(calls.filter(call => call.body.method === "tools/call")).toHaveLength(1); + }); + + it("rejects non-string or malformed scopes before any request", async () => { + await expect(invokeMcp("tools/list", {}, ["everything"] as unknown as string)).rejects.toThrow("Invalid MCP"); + await expect(invokeMcp("tools/list", {}, "../other")).rejects.toThrow("Invalid MCP"); + expect(calls).toHaveLength(0); + }); +}); diff --git a/runtimes/openclaw/src/core/agt-tools/mcp-bridge.ts b/runtimes/openclaw/src/core/agt-tools/mcp-bridge.ts new file mode 100644 index 000000000..2f5733937 --- /dev/null +++ b/runtimes/openclaw/src/core/agt-tools/mcp-bridge.ts @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as http from "node:http"; + +const MAX_BYTES = 2 * 1024 * 1024; +const MAX_QUEUED = 8; +const PROTOCOL = "2025-06-18"; +interface State { initialized: boolean; session?: string; protocol: string; nextId: number; queued: number; tail: Promise } +const KEY = Symbol.for("kars-mcp-bridge"); +const globals = globalThis as typeof globalThis & { [KEY]?: State }; +const state = globals[KEY] ??= { initialized: false, protocol: PROTOCOL, nextId: 1, queued: 0, tail: Promise.resolve() }; + +interface Response { status: number; headers: http.IncomingHttpHeaders; body: unknown } +interface Result { content: Array<{ type: "text"; text: string }>; isError?: boolean } +interface Tool { name: string; label: string; description: string; parameters: Record; + execute(id: string, args?: Record): Promise } + +function request(payload: Record, server?: string): Promise { + const body = JSON.stringify(payload); + if (Buffer.byteLength(body) > MAX_BYTES) return Promise.reject(new Error("MCP request exceeds its byte limit")); + return new Promise((resolve, reject) => { + const req = http.request({ + hostname: "127.0.0.1", port: 8443, path: "/mcp", method: "POST", + headers: { accept: "application/json, text/event-stream", "content-type": "application/json", + "content-length": Buffer.byteLength(body), "mcp-protocol-version": state.protocol, + ...(state.session ? { "mcp-session-id": state.session } : {}), + ...(server ? { "x-kars-mcp-server": server } : {}) }, + }, response => { + const chunks: Buffer[] = []; + let size = 0; + response.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BYTES) { req.destroy(new Error("MCP response exceeds its byte limit")); return; } + chunks.push(chunk); + }); + response.on("error", () => reject(new Error("MCP response transport failed"))); + response.on("end", () => { + try { + const bytes = Buffer.concat(chunks).toString("utf8"); + resolve({ status: response.statusCode ?? 500, headers: response.headers, + body: bytes ? JSON.parse(bytes) : undefined }); + } catch { reject(new Error("MCP router response is not valid JSON")); } + }); + }); + req.on("error", error => reject(new Error(error.message.startsWith("MCP ") ? error.message : "MCP transport failed"))); + req.setTimeout(30_000, () => req.destroy(new Error("MCP request timed out"))); + req.end(body); + }); +} + +function rpc(response: Response, id: number): Record { + if (response.status < 200 || response.status >= 300) throw new Error(`MCP HTTP ${response.status}`); + const body = response.body as { jsonrpc?: unknown; id?: unknown; error?: unknown; result?: unknown } | undefined; + if (body?.jsonrpc !== "2.0" || body.id !== id || body.error + || !body.result || typeof body.result !== "object" || Array.isArray(body.result)) { + throw new Error("MCP RPC failed or omitted its matching result"); + } + return body.result as Record; +} + +async function initialize(server?: string): Promise { + state.session = undefined; + state.protocol = PROTOCOL; + const id = state.nextId++; + const response = await request({ jsonrpc: "2.0", id, method: "initialize", + params: { protocolVersion: PROTOCOL, capabilities: {}, clientInfo: { name: "kars-runtime-openclaw", version: "0.1.0" } } }, server); + const result = rpc(response, id); + if (typeof result.protocolVersion !== "string" + || !["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"].includes(result.protocolVersion)) { + throw new Error("MCP negotiated an unsupported protocol"); + } + const session = response.headers["mcp-session-id"]; + if (session !== undefined && (typeof session !== "string" || !session.length + || session.length > 1024 || /[^\x21-\x7e]/.test(session))) throw new Error("MCP session identifier is invalid"); + state.session = session; + state.protocol = result.protocolVersion; + const notification = await request({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }, server); + if (notification.status < 200 || notification.status >= 300) throw new Error(`MCP initialization HTTP ${notification.status}`); + state.initialized = true; +} + +export async function invokeMcp(method: "tools/list" | "tools/call", params: Record, server?: string): Promise> { + if (server !== undefined && (typeof server !== "string" || !server.length || server.length > 253 || !/^[a-z0-9.-]+$/.test(server))) { + throw new Error("Invalid MCP server scope"); + } + if (state.queued >= MAX_QUEUED) throw new Error("MCP bridge queue is full"); + state.queued++; + const deadline = Date.now() + 60_000; + const run = async () => { + try { + if (Date.now() >= deadline) throw new Error("MCP bridge queue timed out"); + if (!state.initialized) await initialize(server); + const id = state.nextId++; + const response = await request({ jsonrpc: "2.0", id, method, params }, server); + // A failed call is never replayed. A later explicit caller may open a + // new session after a definite transport-level session rejection. + if (response.status === 404 || response.status === 400) state.initialized = false; + return rpc(response, id); + } finally { state.queued--; } + }; + const result = state.tail.then(run, run); + state.tail = result.then(() => undefined, () => undefined); + return result; +} + +function text(value: unknown, failed = false): Result { + return { content: [{ type: "text", text: JSON.stringify(value) }], + ...(failed || (value as { isError?: unknown } | undefined)?.isError === true ? { isError: true } : {}) }; +} + +export function registerMcpBridgeTools(api: { registerTool(tool: Tool): void }): void { + api.registerTool({ + name: "kars_mcp_list", label: "Kars MCP Tool List", + description: "List qualified MCP tools mounted in this sandbox. Optional server scope narrows the catalog.", + parameters: { type: "object", properties: { server: { type: "string" } } }, + async execute(_id, args = {}) { + try { + const result = await invokeMcp("tools/list", {}, args.server as string | undefined); + if (!Array.isArray(result.tools)) throw new Error("MCP catalog omitted tools"); + return text(result); + } catch (error) { return text({ error: error instanceof Error ? error.message : "MCP discovery failed" }, true); } + }, + }); + api.registerTool({ + name: "kars_mcp_call", label: "Kars MCP Tool Call", + description: "Invoke the exact MCP tool name returned by kars_mcp_list. Calls are never automatically replayed.", + parameters: { type: "object", properties: { name: { type: "string" }, arguments: { type: "object" }, server: { type: "string" } }, required: ["name"] }, + async execute(_id, args = {}) { + if (typeof args.name !== "string" || !args.name.trim()) return text({ error: "name is required" }, true); + const argumentsValue = args.arguments ?? {}; + if (typeof argumentsValue !== "object" || argumentsValue === null || Array.isArray(argumentsValue)) { + return text({ error: "arguments must be an object" }, true); + } + try { return text(await invokeMcp("tools/call", { name: args.name, arguments: argumentsValue }, args.server as string | undefined)); } + catch (error) { return text({ error: error instanceof Error ? error.message : "MCP invocation failed" }, true); } + }, + }); +} + +export function resetMcpBridgeForTests(): void { + state.initialized = false; + state.session = undefined; + state.protocol = PROTOCOL; + state.nextId = 1; + state.queued = 0; + state.tail = Promise.resolve(); +} diff --git a/runtimes/openclaw/src/index.ts b/runtimes/openclaw/src/index.ts index 43fd228d7..321095736 100644 --- a/runtimes/openclaw/src/index.ts +++ b/runtimes/openclaw/src/index.ts @@ -369,6 +369,7 @@ import { runOffloadTask as _runOffloadTask, startProactiveOffloadIfNeeded as _st import { processTaskWithTools as _processTaskWithTools } from "./core/agt-task-loop.js"; import { runHandoffOrchestration as _runHandoffOrchestrationCore } from "./core/agt-handoff.js"; import { registerHttpFetchTool } from "./core/agt-tools/http-fetch.js"; +import { registerMcpBridgeTools } from "./core/agt-tools/mcp-bridge.js"; import { registerFoundryTools } from "./core/agt-tools/foundry.js"; import { registerAgtTools } from "./core/agt-tools/agt.js"; import { registerOpenClawCommands } from "./core/commands/openclaw.js"; @@ -2937,6 +2938,7 @@ const azureClawPlugin = definePluginEntry({ // unchanged; the registration helpers receive a Deps bag for late-bound // foundryProject + log + config access. registerHttpFetchTool(api); + registerMcpBridgeTools(api); // Skip Foundry tool catalog when running against GH-token providers // (`github-models` or `github-copilot`). Foundry tools require an Azure // project the GH-token paths don't have, so registering them is pure dead diff --git a/sandbox-images/mcp-everything/Dockerfile b/sandbox-images/mcp-everything/Dockerfile new file mode 100644 index 000000000..a3a4d60cb --- /dev/null +++ b/sandbox-images/mcp-everything/Dockerfile @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 + +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --ignore-scripts \ + && npm cache clean --force + +ENV PORT=3001 +EXPOSE 3001 +USER 1000 +ENTRYPOINT ["./node_modules/.bin/mcp-server-everything"] +CMD ["streamableHttp"] diff --git a/sandbox-images/mcp-everything/package-lock.json b/sandbox-images/mcp-everything/package-lock.json new file mode 100644 index 000000000..dab104026 --- /dev/null +++ b/sandbox-images/mcp-everything/package-lock.json @@ -0,0 +1,1282 @@ +{ + "name": "@kars-runtime/mcp-everything-image", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@kars-runtime/mcp-everything-image", + "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/server-everything": "2026.7.4" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/server-everything": { + "version": "2026.7.4", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-everything/-/server-everything-2026.7.4.tgz", + "integrity": "sha512-ydMW/M6rk9tK23b+U38trsNLHhd5eF+ntiv2Vr+RPMDhbiKY/IKrZU25ukvSXVPUBvy7TxTPWpeV4KcYcXg72w==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "cors": "^2.8.5", + "express": "^5.2.1", + "jszip": "^3.10.1", + "zod": "^4.0.0" + }, + "bin": { + "mcp-server-everything": "dist/index.js" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.29", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.29.tgz", + "integrity": "sha512-1hNiRjawYrLq/4m3DQQjPGFg0VZkk4RjQJDff/excI6Dm9BiL75qxGrd7/c6YOxPdq6AscP3LiXhQ6fKFC1Waw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/sandbox-images/mcp-everything/package.json b/sandbox-images/mcp-everything/package.json new file mode 100644 index 000000000..5550a3439 --- /dev/null +++ b/sandbox-images/mcp-everything/package.json @@ -0,0 +1,9 @@ +{ + "name": "@kars-runtime/mcp-everything-image", + "version": "0.1.0", + "private": true, + "description": "Hermetic image wrapper for the MCP reference everything server", + "dependencies": { + "@modelcontextprotocol/server-everything": "2026.7.4" + } +} diff --git a/tests/e2e/managed-mcp.py b/tests/e2e/managed-mcp.py new file mode 100644 index 000000000..9e2439975 --- /dev/null +++ b/tests/e2e/managed-mcp.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Disposable Kind acceptance; real MCP workload/protocol, no model execution.""" + +import argparse +import json +import os +from pathlib import Path +import signal +import socket +import subprocess +import time +import urllib.error +import urllib.request + +ROOT = Path(__file__).resolve().parents[2] +CONTEXT = "kind-kars-e2e" +WORKSPACE = "kars-system" +OTHER = "e2e-mcp-workspace" +SANDBOX = "e2e-managed-mcp" +SERVER = "e2e-tools" +IMAGE = "kars-mcp-everything:e2e" +PROCESSES = [] +DEADLINE = time.monotonic() + 900 + + +def require(condition, message): + if not condition: + raise AssertionError(message) + + +def run(args, body=None, seconds=45): + seconds = min(seconds, DEADLINE - time.monotonic()) + require(seconds > 0, "Managed MCP acceptance exceeded its total deadline") + process = subprocess.Popen(args, cwd=ROOT, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, start_new_session=True) + try: + output, _ = process.communicate(body, timeout=seconds) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGTERM) + try: + process.communicate(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.communicate(timeout=5) + raise AssertionError(f"Bounded {Path(args[0]).name} operation timed out") from None + require(process.returncode == 0, f"{Path(args[0]).name} operation failed (exit {process.returncode}); no command output/body logged") + return output + + +def kube(*args, body=None, seconds=45): + return run(["kubectl", "--context", CONTEXT, "--request-timeout=20s", *args], body, seconds) + + +def get(kind, name, namespace=None): + value = kube("get", kind, name, *(["-n", namespace] if namespace else []), "--ignore-not-found", "-o", "json") + return json.loads(value) if value.strip() else None + + +def create(value): + return json.loads(kube("create", "-f", "-", "-o", "json", body=json.dumps(value))) + + +def wait(label, predicate, seconds=180): + end = min(DEADLINE, time.monotonic() + seconds) + while time.monotonic() < end: + value = predicate() + if value: + return value + time.sleep(1) + raise AssertionError(f"{label} did not converge") + + +def delete(kind, name, namespace, uid, wait_for_removal=True): + obj = get(kind, name, namespace) + require(obj and obj["metadata"]["uid"] == uid, "Cleanup object incarnation changed") + resource = {"mcpserver": "mcpservers", "karssandbox": "karssandboxes", + "inferencepolicy": "inferencepolicies"}.get(kind) + path = (f"/apis/kars.azure.com/v1alpha1/namespaces/{namespace}/{resource}/{name}" + if resource else f"/api/v1/namespaces/{name}") + kube("delete", "--raw", path, "-f", "-", body=json.dumps({ + "apiVersion": "v1", "kind": "DeleteOptions", + "preconditions": {"uid": uid, "resourceVersion": obj["metadata"]["resourceVersion"]}, + })) + if wait_for_removal: + wait(f"{kind} cleanup", lambda: get(kind, name, namespace) is None) + + +def ready(namespace): + obj = get("mcpserver", SERVER, namespace) + status = obj.get("status", {}) if obj else {} + return obj if (status.get("phase") == "Ready" + and status.get("observedGeneration") == obj["metadata"]["generation"] + and status.get("workloadImage") == IMAGE + and "echo" in status.get("discoveredTools", [])) else False + + +def rpc(port, method, params, server=None): + headers = {"Content-Type": "application/json", "Accept": "application/json"} + if server: + headers["X-Kars-Mcp-Server"] = server + request = urllib.request.Request(f"http://127.0.0.1:{port}/mcp", + data=json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).encode(), + headers=headers) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + try: + with opener.open(request, timeout=15) as response: + return response.status, json.loads(response.read(2 * 1024 * 1024)) + except urllib.error.HTTPError as error: + return error.code, None + + +def build_image(): + run(["docker", "build", "-t", IMAGE, "-f", "sandbox-images/mcp-everything/Dockerfile", + "sandbox-images/mcp-everything"], seconds=360) + run(["kind", "load", "docker-image", IMAGE, "--name", "kars-e2e"], seconds=120) + print("MCP-PASS Built/loaded the actual locked Everything server image; no image published", flush=True) + + +def test(): + require(get("namespace", WORKSPACE), "Expected disposable Kind controller namespace is absent") + other = create({"apiVersion": "v1", "kind": "Namespace", "metadata": {"name": OTHER}}) + sources = [] + for namespace in (WORKSPACE, OTHER): + sources.append(create({"apiVersion": "kars.azure.com/v1alpha1", "kind": "McpServer", + "metadata": {"name": SERVER, "namespace": namespace}, + "spec": {"managed": {"preset": "everything"}, "allowedTools": ["echo"], + "allowedSandboxes": {"matchLabels": {"e2e-mcp": "allowed"}}}})) + first = wait("first managed MCP protocol readiness", lambda: ready(WORKSPACE)) + second = wait("second managed MCP protocol readiness", lambda: ready(OTHER)) + require(first["status"]["workloadRef"] != second["status"]["workloadRef"], + "Same-named servers across workspaces shared a workload") + references = [] + for source in (first, second): + namespace, name = source["status"]["workloadRef"].split("/") + ns = get("namespace", namespace) + deployment = get("deployment", name, namespace) + service = get("service", name, namespace) + policy = get("networkpolicy", name, namespace) + uid = source["metadata"]["uid"] + selector = {"kars.azure.com/mcp-source-uid": uid} + require(ns["metadata"]["uid"] == source["status"]["managedNamespaceUid"], "Namespace UID proof differs") + require(deployment["spec"]["selector"]["matchLabels"] == selector and service["spec"]["selector"] == selector, + "Managed Deployment/Service selectors are not source-UID bound") + require(policy["spec"]["podSelector"]["matchLabels"] == selector, "Managed NetworkPolicy selector differs") + require(deployment["spec"]["template"]["spec"].get("automountServiceAccountToken") is False, + "Managed workload acquired a Kubernetes token") + require(source["status"]["workloadGeneration"] == deployment["metadata"]["generation"], "Probe covers a stale Deployment generation") + references.append((namespace, name, deployment["metadata"]["uid"])) + print("MCP-PASS Two real same-named MCP servers qualify independently with UID-scoped selectors and protocol probes", flush=True) + + inference = create({"apiVersion": "kars.azure.com/v1alpha1", "kind": "InferencePolicy", + "metadata": {"name": SANDBOX, "namespace": WORKSPACE}, + "spec": {"appliesTo": {"sandboxName": SANDBOX}, + "modelPreference": {"primary": {"provider": "azure-openai", "deployment": "gpt-4.1"}}}}) + sandbox = create({"apiVersion": "kars.azure.com/v1alpha1", "kind": "KarsSandbox", + "metadata": {"name": SANDBOX, "namespace": WORKSPACE, "labels": {"e2e-mcp": "allowed"}}, + "spec": {"runtime": {"kind": "BYO", "byo": {"image": "kars-sandbox-e2e:dev", + "contractVersion": "v1", "command": ["/bin/sh"], "args": ["-c", "sleep infinity"]}}, + "sandbox": {"isolation": "standard"}, "inferenceRef": {"name": SANDBOX}, + "governance": {"mcpServerRefs": [{"name": SERVER}]}}}) + runtime_namespace = f"kars-{SANDBOX}" + wait("MCP Sandbox Deployment", lambda: get("deployment", SANDBOX, runtime_namespace)) + kube("rollout", "status", f"deployment/{SANDBOX}", "-n", runtime_namespace, "--timeout=180s", seconds=190) + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + process = subprocess.Popen(["kubectl", "--context", CONTEXT, "--request-timeout=20s", + "-n", runtime_namespace, "port-forward", f"deployment/{SANDBOX}", f"{port}:8443", "--address=127.0.0.1"], + cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + PROCESSES.append(process) + try: + def catalog(): + require(process.poll() is None, "MCP port-forward exited") + try: + code, body = rpc(port, "tools/list", {}, SERVER) + return body if code == 200 and any(tool.get("name") == "e2e_tools.echo" + for tool in (body or {}).get("result", {}).get("tools", [])) else False + except (OSError, ValueError): + return False + wait("actual routed MCP catalog after reconciliation", catalog, seconds=120) + code, body = rpc(port, "tools/call", {"name": "e2e_tools.echo", "arguments": {"message": "mcp-kind-proof"}}, SERVER) + require(code == 200 and "error" not in body and not body["result"].get("isError") + and "mcp-kind-proof" in json.dumps(body["result"]), "Real MCP echo did not execute through the router") + code, _ = rpc(port, "tools/list", {}, "unmounted") + require(code == 404, "Unknown MCP server scope was not rejected") + print("MCP-PASS Real router-scoped discovery and Everything echo work from an alive BYO fixture; no model run claimed", flush=True) + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + PROCESSES.remove(process) + + delete("karssandbox", SANDBOX, WORKSPACE, sandbox["metadata"]["uid"]) + wait("MCP Sandbox namespace cleanup", lambda: get("namespace", runtime_namespace) is None) + delete("inferencepolicy", SANDBOX, WORKSPACE, inference["metadata"]["uid"]) + delete("mcpserver", SERVER, WORKSPACE, sources[0]["metadata"]["uid"]) + namespace, name, _ = references[0] + for kind in ("deployment", "service", "networkpolicy"): + require(get(kind, name, namespace) is None, "Owned managed MCP resource survived finalization") + other_namespace, other_name, other_uid = references[1] + require(get("deployment", other_name, other_namespace)["metadata"]["uid"] == other_uid, + "Deleting one source changed the same-named server in another workspace") + delete("mcpserver", SERVER, OTHER, sources[1]["metadata"]["uid"]) + delete("namespace", OTHER, None, other["metadata"]["uid"]) + require(get("namespace", namespace), "Shared managed namespace was deleted") + print("MCP-PASS Exact owned cleanup preserves the other workspace and shared managed namespace", flush=True) + + # Simulate a namespace whose ownership has been explicitly withdrawn. + # No server resources remain; the sentinel belongs solely to this fixture. + ns = get("namespace", namespace) + kube("patch", "namespace", namespace, "--type=merge", "-p", json.dumps({"metadata": { + "uid": ns["metadata"]["uid"], "resourceVersion": ns["metadata"]["resourceVersion"], + "annotations": {"kars.azure.com/mcp-namespace-claim": None}, + }})) + create({"apiVersion": "v1", "kind": "ConfigMap", + "metadata": {"name": "mcp-foreign-sentinel", "namespace": namespace}, "data": {"value": "preserve"}}) + blocked = create({"apiVersion": "kars.azure.com/v1alpha1", "kind": "McpServer", + "metadata": {"name": SERVER, "namespace": WORKSPACE}, + "spec": {"managed": {"preset": "everything"}, "allowedTools": ["echo"]}}) + def blocked_on_owner(): + current = get("mcpserver", SERVER, WORKSPACE) + status = current.get("status", {}) + return (status.get("phase") == "Degraded" + and status.get("observedGeneration") == current["metadata"]["generation"] + and any("unowned" in condition.get("message", "") for condition in status.get("conditions", []))) + wait("actual unowned namespace refusal", blocked_on_owner) + current = get("namespace", namespace) + require(current["metadata"]["uid"] == ns["metadata"]["uid"] + and "kars.azure.com/mcp-namespace-claim" not in current["metadata"].get("annotations", {}), + "Controller adopted or replaced the unowned namespace") + require(get("configmap", "mcp-foreign-sentinel", namespace)["data"]["value"] == "preserve", + "Unowned namespace contents were modified") + require(json.loads(kube("get", "deployments", "-n", namespace, "-o", "json"))["items"] == [], + "A workload was launched into an unowned namespace") + delete("mcpserver", SERVER, WORKSPACE, blocked["metadata"]["uid"], wait_for_removal=False) + delete("namespace", namespace, None, ns["metadata"]["uid"]) + wait("blocked MCP finalizer after explicit fixture removal", lambda: get("mcpserver", SERVER, WORKSPACE) is None) + print("MCP-PASS Actual API namespace-ownership refusal preserves the occupant and sentinel; explicit UID/RV fixture cleanup only", flush=True) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("phase", choices=("prepare", "test")) + args = parser.parse_args() + try: + build_image() if args.phase == "prepare" else test() + return 0 + except Exception as error: + print(f"MCP-FAIL {args.phase}: {str(error) if isinstance(error, AssertionError) else type(error).__name__}", flush=True) + for namespace in (WORKSPACE, OTHER): + try: + obj = get("mcpserver", SERVER, namespace) + if obj: + print("MCP-DIAG", json.dumps({"namespace": namespace, "uid": obj["metadata"].get("uid"), + "phase": obj.get("status", {}).get("phase"), "generation": obj["metadata"].get("generation"), + "observedGeneration": obj.get("status", {}).get("observedGeneration")}), flush=True) + except Exception: + pass + return 1 + finally: + for process in PROCESSES: + process.terminate() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/e2e/managed-mcp.sh b/tests/e2e/managed-mcp.sh new file mode 100644 index 000000000..85698fde5 --- /dev/null +++ b/tests/e2e/managed-mcp.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +managed_mcp_phase() { + local phase="$1" output result=0 line + output=$(python3 "$SCRIPT_DIR/managed-mcp.py" "$phase") || result=$? + while IFS= read -r line; do + case "$line" in + "MCP-PASS "*) pass "${line#MCP-PASS }" ;; + "MCP-FAIL "*) fail "${line#MCP-FAIL }" ;; + *) [ -z "$line" ] || printf '%s\n' "$line" ;; + esac + done <<< "$output" + return "$result" +} + +prepare_managed_mcp() { + managed_mcp_phase prepare +} + +test_managed_mcp() { + managed_mcp_phase test +} diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index d57b47b47..6fe6c34d1 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -152,6 +152,7 @@ install_crds() { local replicas="${KARS_E2E_CONTROLLER_REPLICAS:-1}" local disable_le="${KARS_E2E_DISABLE_LEADER_ELECTION:-1}" local extra_set_args=( + --set-string "managedMcp.everythingImage=kars-mcp-everything:e2e" --set "controller.replicas=${replicas}" --set "inferenceRouter.replicas=${replicas}" # Without a fake Foundry endpoint, the KarsSandbox reconciler @@ -3016,6 +3017,7 @@ EOF # ─── Main ───────────────────────────────────────────────────────────────────── source "$SCRIPT_DIR/namespace-ownership.sh" +source "$SCRIPT_DIR/managed-mcp.sh" source "$SCRIPT_DIR/credential-sources.sh" source "$SCRIPT_DIR/governed-services.sh" @@ -3030,6 +3032,7 @@ main() { setup_cluster build_images + prepare_managed_mcp install_crds echo "" @@ -3122,6 +3125,7 @@ main() { ;; esac + test_managed_mcp || fail "Managed MCP lifecycle/protocol gate failed" test_sre_namespace_ownership || fail "SRE namespace lifecycle gate failed" test_credential_sources || fail "Credential-source lifecycle gate failed" From f876b40306cc2da45265338863a26b982d44750a Mon Sep 17 00:00:00 2001 From: pallakatos Date: Wed, 9 Sep 2026 12:53:38 +0200 Subject: [PATCH 02/14] build(mcp): prepare explicit patched URI dependency lock Pin fast-uri 3.1.6 to cover the six blocking advisory ranges. The local verified cache lacks this patch and registry access is unavailable; generate only the lockfile on a read-only hosted runner with scripts disabled, upload for review, and keep normal security gates unchanged. This temporary preparation job is not application qualification or a release. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/mcp-lock-preparation.yml | 31 ++++++++++++++++++++++ sandbox-images/mcp-everything/package.json | 3 +++ 2 files changed, 34 insertions(+) create mode 100644 .github/workflows/mcp-lock-preparation.yml diff --git a/.github/workflows/mcp-lock-preparation.yml b/.github/workflows/mcp-lock-preparation.yml new file mode 100644 index 000000000..824c2e42b --- /dev/null +++ b/.github/workflows/mcp-lock-preparation.yml @@ -0,0 +1,31 @@ +name: Prepare MCP dependency lock + +on: + pull_request: + branches: ["public/pr7-governed-services"] + paths: + - "sandbox-images/mcp-everything/package.json" + - ".github/workflows/mcp-lock-preparation.yml" + +permissions: + contents: read + +jobs: + prepare: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: "22" + - name: Generate lockfile for explicit patched dependency + working-directory: sandbox-images/mcp-everything + run: npm install --package-lock-only --ignore-scripts --no-audit --no-fund + - name: Upload generated lockfile for review, not release + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: mcp-patched-lock + path: sandbox-images/mcp-everything/package-lock.json + if-no-files-found: error + retention-days: 1 diff --git a/sandbox-images/mcp-everything/package.json b/sandbox-images/mcp-everything/package.json index 5550a3439..1cc417786 100644 --- a/sandbox-images/mcp-everything/package.json +++ b/sandbox-images/mcp-everything/package.json @@ -5,5 +5,8 @@ "description": "Hermetic image wrapper for the MCP reference everything server", "dependencies": { "@modelcontextprotocol/server-everything": "2026.7.4" + }, + "overrides": { + "fast-uri": "3.1.6" } } From fdcbc431711434a7441b720505f310001853b5a6 Mon Sep 17 00:00:00 2001 From: pallakatos Date: Wed, 9 Sep 2026 12:55:14 +0200 Subject: [PATCH 03/14] build(mcp): apply reviewed patched URI lockfile Apply the exact npm-generated hosted artifact; only fast-uri changes from 3.1.3 to 3.1.6, with matching registry integrity. No other package metadata changes. Remove the temporary lock-preparation workflow and retain every normal dependency/security gate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/mcp-lock-preparation.yml | 31 ------------------- .../mcp-everything/package-lock.json | 6 ++-- 2 files changed, 3 insertions(+), 34 deletions(-) delete mode 100644 .github/workflows/mcp-lock-preparation.yml diff --git a/.github/workflows/mcp-lock-preparation.yml b/.github/workflows/mcp-lock-preparation.yml deleted file mode 100644 index 824c2e42b..000000000 --- a/.github/workflows/mcp-lock-preparation.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Prepare MCP dependency lock - -on: - pull_request: - branches: ["public/pr7-governed-services"] - paths: - - "sandbox-images/mcp-everything/package.json" - - ".github/workflows/mcp-lock-preparation.yml" - -permissions: - contents: read - -jobs: - prepare: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - name: Generate lockfile for explicit patched dependency - working-directory: sandbox-images/mcp-everything - run: npm install --package-lock-only --ignore-scripts --no-audit --no-fund - - name: Upload generated lockfile for review, not release - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - with: - name: mcp-patched-lock - path: sandbox-images/mcp-everything/package-lock.json - if-no-files-found: error - retention-days: 1 diff --git a/sandbox-images/mcp-everything/package-lock.json b/sandbox-images/mcp-everything/package-lock.json index dab104026..cf9fa54cb 100644 --- a/sandbox-images/mcp-everything/package-lock.json +++ b/sandbox-images/mcp-everything/package-lock.json @@ -466,9 +466,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", "funding": [ { "type": "github", From 7b11446055bcfaf95833bc85b100cc12685d4625 Mon Sep 17 00:00:00 2001 From: pallakatos Date: Wed, 9 Sep 2026 13:12:05 +0200 Subject: [PATCH 04/14] fix(mcp): close composed source gates and prepare remaining patched dependency Extract MCP binding resolution/rollout annotation logic under the existing reconciler cap without a waiver, retain both SRE/MCP authority paths, and fix Hermes formatting. Paired Rust/Clippy and affected CLI/Python cases pass. Pin the remaining reported ip-address fix and audit the complete generated hosted lock before reviewing it; no vulnerability or approval gate is bypassed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/mcp-lock-preparation.yml | 34 +++++++++++ controller/src/reconciler/mcp_binding.rs | 58 ++++++++++++++++++- controller/src/reconciler/mod.rs | 44 ++------------ .../kars_runtime_hermes/plugin/mcp_bridge.py | 7 ++- runtimes/hermes/tests/test_mcp_bridge.py | 9 ++- sandbox-images/mcp-everything/package.json | 3 +- 6 files changed, 111 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/mcp-lock-preparation.yml diff --git a/.github/workflows/mcp-lock-preparation.yml b/.github/workflows/mcp-lock-preparation.yml new file mode 100644 index 000000000..45dd6fa20 --- /dev/null +++ b/.github/workflows/mcp-lock-preparation.yml @@ -0,0 +1,34 @@ +name: Prepare MCP dependency lock + +on: + pull_request: + branches: ["public/pr7-governed-services"] + paths: + - "sandbox-images/mcp-everything/package.json" + - ".github/workflows/mcp-lock-preparation.yml" + +permissions: + contents: read + +jobs: + prepare: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: "22" + - name: Generate lockfile for explicitly patched dependencies + working-directory: sandbox-images/mcp-everything + run: npm install --package-lock-only --ignore-scripts --no-audit --no-fund + - name: Audit every generated package with the existing blocking gate + run: node ci/npm-audit-bulk.mjs sandbox-images/mcp-everything/package-lock.json + - name: Upload generated lockfile for review, not release + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: mcp-patched-lock + path: sandbox-images/mcp-everything/package-lock.json + if-no-files-found: error + retention-days: 1 diff --git a/controller/src/reconciler/mcp_binding.rs b/controller/src/reconciler/mcp_binding.rs index 69ca6489e..43514d197 100644 --- a/controller/src/reconciler/mcp_binding.rs +++ b/controller/src/reconciler/mcp_binding.rs @@ -1,7 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use crate::{crd::KarsSandbox, mcp_server::McpServer}; +use crate::{ + crd::{GovernanceConfig, KarsSandbox}, + mcp_server::McpServer, +}; +use k8s_openapi::api::apps::v1::Deployment; use kube::{Api, Client, ResourceExt}; pub(super) struct Binding { @@ -10,6 +14,58 @@ pub(super) struct Binding { pub revision: String, } +pub(super) async fn resolve_all<'a>( + client: &Client, + sandbox: &KarsSandbox, + governance: &'a GovernanceConfig, +) -> Result, String> { + let name = sandbox.name_any(); + if governance.uses_singular_mcp_server_ref() { + tracing::warn!( + sandbox = %name, + reason = crate::status::conditions::reason::MCP_SINGULAR_DEPRECATED, + "spec.governance.mcpServerRef is deprecated; migrate to \ + spec.governance.mcpServerRefs (Slice 4d.1)", + ); + } + let mut bindings = Vec::new(); + for reference in governance.effective_mcp_server_refs() { + let mcp_name = reference.name.trim(); + if mcp_name.is_empty() { + continue; + } + match resolve(client, sandbox, mcp_name).await? { + Some(binding) => bindings.push((mcp_name, binding)), + None => tracing::warn!(sandbox = %name, mcp = %mcp_name, + "MCP reference is not currently authorized and qualified"), + } + } + Ok(bindings) +} + +pub(super) fn decorate( + deployment: &mut Deployment, + revisions: &[String], +) -> Result<(), serde_json::Error> { + if !revisions.is_empty() { + deployment + .spec + .as_mut() + .unwrap() + .template + .metadata + .as_mut() + .unwrap() + .annotations + .get_or_insert_with(Default::default) + .insert( + "kars.azure.com/mcp-bindings-version".into(), + crate::providers::signing::content_digest(&serde_json::to_vec(revisions)?), + ); + } + Ok(()) +} + pub(super) fn api_error(error: kube::Error) -> String { match error { kube::Error::Api(status) => format!("MCP binding API status {}", status.code), diff --git a/controller/src/reconciler/mod.rs b/controller/src/reconciler/mod.rs index 0ea7e0ed7..989d8c8e5 100644 --- a/controller/src/reconciler/mod.rs +++ b/controller/src/reconciler/mod.rs @@ -2351,29 +2351,12 @@ async fn reconcile(sandbox: Arc, ctx: Arc) -> Result = Vec::with_capacity(mcp_refs.len()); + let bindings = mcp_binding::resolve_all(client, &sandbox, &governance_config) + .await + .map_err(ReconcileError::Configuration)?; + let mut mirrored_mcp_names: Vec = Vec::with_capacity(bindings.len()); let mut mcp_revisions = Vec::new(); - for mcp_ref in &mcp_refs { - let mcp_name = mcp_ref.name.trim(); - if mcp_name.is_empty() { - continue; - } - let Some(binding) = mcp_binding::resolve(client, &sandbox, mcp_name) - .await - .map_err(ReconcileError::Configuration)? - else { - tracing::warn!(sandbox = %name, mcp = %mcp_name, "MCP reference is not currently authorized and qualified"); - continue; - }; + for (mcp_name, binding) in bindings { let first_mcp = mirrored_mcp_names.is_empty(); mcp_revisions.push(binding.revision); let jwks_cm = format!("mcp-{mcp_name}-jwks"); @@ -2687,22 +2670,7 @@ async fn reconcile(sandbox: Arc, ctx: Arc) -> Result dict[str, str]: return headers -def _request(method: str, params: dict[str, Any], server: str | None, notification: bool = False) -> tuple[Any, dict[str, Any]]: +def _request( + method: str, params: dict[str, Any], server: str | None, notification: bool = False +) -> tuple[Any, dict[str, Any]]: global _NEXT_ID, _INITIALIZED request_id = _NEXT_ID _NEXT_ID += 1 @@ -120,7 +122,8 @@ def _call_tool(args: dict[str, Any], **_kwargs: Any) -> str: if not isinstance(arguments, dict): return _failure("arguments must be an object") try: - return json.dumps(_invoke("tools/call", {"name": name, "arguments": arguments}, args.get("server")), separators=(",", ":")) + result = _invoke("tools/call", {"name": name, "arguments": arguments}, args.get("server")) + return json.dumps(result, separators=(",", ":")) except Exception: return _failure("MCP invocation failed; calls are never automatically replayed") diff --git a/runtimes/hermes/tests/test_mcp_bridge.py b/runtimes/hermes/tests/test_mcp_bridge.py index ff64ff201..adf23b1aa 100644 --- a/runtimes/hermes/tests/test_mcp_bridge.py +++ b/runtimes/hermes/tests/test_mcp_bridge.py @@ -26,7 +26,10 @@ def do_POST(self): if body["method"] == "initialize": self.send_response(200) self.send_header("mcp-session-id", "session-one") - value = {"jsonrpc": "2.0", "id": body["id"], "result": {"protocolVersion": "2025-06-18", "capabilities": {}}} + value = { + "jsonrpc": "2.0", "id": body["id"], + "result": {"protocolVersion": "2025-06-18", "capabilities": {}}, + } elif body["method"] == "notifications/initialized": self.send_response(202) self.end_headers() @@ -37,7 +40,9 @@ def do_POST(self): if state["error"]: value["error"] = state["error"] else: - value["result"] = {"tools": [{"name": "everything.echo"}]} if body["method"] == "tools/list" else state["result"] + value["result"] = ( + {"tools": [{"name": "everything.echo"}]} if body["method"] == "tools/list" else state["result"] + ) data = json.dumps(value).encode() self.send_header("content-type", "application/json") self.send_header("content-length", str(len(data))) diff --git a/sandbox-images/mcp-everything/package.json b/sandbox-images/mcp-everything/package.json index 1cc417786..b2fc50ad0 100644 --- a/sandbox-images/mcp-everything/package.json +++ b/sandbox-images/mcp-everything/package.json @@ -7,6 +7,7 @@ "@modelcontextprotocol/server-everything": "2026.7.4" }, "overrides": { - "fast-uri": "3.1.6" + "fast-uri": "3.1.6", + "ip-address": "10.3.1" } } From 394ca12318e3877e58f07759606a54c00f411080 Mon Sep 17 00:00:00 2001 From: pallakatos Date: Wed, 9 Sep 2026 13:16:15 +0200 Subject: [PATCH 05/14] build(mcp): apply fully audited IP parser lock repair Only ip-address changes to the patched 10.3.1 release; the applied lock is byte-identical to the npm-generated artifact, whose full blocking bulk audit passed. Keep fast-uri pinned to its patched release and remove temporary lock generation. All normal security and dependency review remains enabled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/mcp-lock-preparation.yml | 34 ------------------- .../mcp-everything/package-lock.json | 6 ++-- 2 files changed, 3 insertions(+), 37 deletions(-) delete mode 100644 .github/workflows/mcp-lock-preparation.yml diff --git a/.github/workflows/mcp-lock-preparation.yml b/.github/workflows/mcp-lock-preparation.yml deleted file mode 100644 index 45dd6fa20..000000000 --- a/.github/workflows/mcp-lock-preparation.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Prepare MCP dependency lock - -on: - pull_request: - branches: ["public/pr7-governed-services"] - paths: - - "sandbox-images/mcp-everything/package.json" - - ".github/workflows/mcp-lock-preparation.yml" - -permissions: - contents: read - -jobs: - prepare: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version: "22" - - name: Generate lockfile for explicitly patched dependencies - working-directory: sandbox-images/mcp-everything - run: npm install --package-lock-only --ignore-scripts --no-audit --no-fund - - name: Audit every generated package with the existing blocking gate - run: node ci/npm-audit-bulk.mjs sandbox-images/mcp-everything/package-lock.json - - name: Upload generated lockfile for review, not release - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a - with: - name: mcp-patched-lock - path: sandbox-images/mcp-everything/package-lock.json - if-no-files-found: error - retention-days: 1 diff --git a/sandbox-images/mcp-everything/package-lock.json b/sandbox-images/mcp-everything/package-lock.json index cf9fa54cb..8346c3397 100644 --- a/sandbox-images/mcp-everything/package-lock.json +++ b/sandbox-images/mcp-everything/package-lock.json @@ -660,9 +660,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", + "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", "license": "MIT", "engines": { "node": ">= 12" From 13a26819b3369b254c8355c52ec4bae3dd15b075 Mon Sep 17 00:00:00 2001 From: pallakatos Date: Wed, 9 Sep 2026 15:23:46 +0200 Subject: [PATCH 06/14] fix(mcp): align managed schema with the generated Kubernetes contract The full hosted drift gate found one mismatch: Kubernetes CRD generation removes the boolean additionalProperties:false keyword. Match that generated managed-object schema while retaining the required closed preset enum, nullability and all CEL constraints. The rendered changed block matches the actual hosted canonical Rust output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- deploy/helm/kars/templates/crd-mcpserver.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/deploy/helm/kars/templates/crd-mcpserver.yaml b/deploy/helm/kars/templates/crd-mcpserver.yaml index d99e1e7f0..b54d9de11 100644 --- a/deploy/helm/kars/templates/crd-mcpserver.yaml +++ b/deploy/helm/kars/templates/crd-mcpserver.yaml @@ -160,7 +160,6 @@ spec: nullable: true type: string managed: - additionalProperties: false description: |- Closed controller-managed workload preset. Mutually exclusive with url, bundleRef and endpoint authentication fields; the controller From 84617e737911235e98759efdd7b3761be1725db2 Mon Sep 17 00:00:00 2001 From: pallakatos Date: Wed, 9 Sep 2026 17:13:27 +0200 Subject: [PATCH 07/14] fix(mcp): keep bounded probes buildable with controller-only dependencies Use reqwest's always-available chunk reader rather than bytes_stream, which the controller-only benchmark build does not enable. Preserve every response-size check, transport failure and JSON/SSE invariant without adding dependency features. Isolated controller check, probe/schema cases and paired strict Clippy pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- controller/src/mcp_server_reconciler/managed/probe.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/controller/src/mcp_server_reconciler/managed/probe.rs b/controller/src/mcp_server_reconciler/managed/probe.rs index 225698983..27aad25d5 100644 --- a/controller/src/mcp_server_reconciler/managed/probe.rs +++ b/controller/src/mcp_server_reconciler/managed/probe.rs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use futures::StreamExt; use serde_json::{Value, json}; use std::collections::{BTreeMap, BTreeSet}; use std::time::Duration; @@ -16,7 +15,7 @@ pub(super) struct Probe { pub digest: String, } -async fn payload(response: reqwest::Response, id: u64) -> Result { +async fn payload(mut response: reqwest::Response, id: u64) -> Result { if !response.status().is_success() { return Err(format!( "Managed MCP probe HTTP {}", @@ -34,10 +33,12 @@ async fn payload(response: reqwest::Response, id: u64) -> Result .get("content-type") .and_then(|header| header.to_str().ok()) .is_some_and(|header| header.starts_with("text/event-stream")); - let mut stream = response.bytes_stream(); let mut bytes = Vec::new(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|_| "Managed MCP probe response transport failure")?; + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| "Managed MCP probe response transport failure")? + { if bytes.len().saturating_add(chunk.len()) > MAX_RESPONSE { return Err("Managed MCP probe response exceeds its byte limit".into()); } From 04bd43f1508be38621618bf2eaa21b1c37406abe Mon Sep 17 00:00:00 2001 From: pallakatos Date: Thu, 10 Sep 2026 15:22:29 +0200 Subject: [PATCH 08/14] fix(mcp): preserve typed tool result content when forwarding Support the standard MCP text, image, audio, resource-link, and embedded resource content blocks without fetching resource URIs. Preserve annotations, metadata, structured content, extension fields, and tool error semantics. Reject malformed known fields and unknown content kinds without an untyped content fallback. Add JSON/SSE forwarding, roundtrip, bounds, non-fetching, and response scanner regressions. Static rustfmt, JSON syntax, and whitespace checks passed; Cargo qualification remains pending the shared-target lease. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- docs/mcp.md | 16 + inference-router/src/mcp/content.rs | 284 ++++++++++++++++++ inference-router/src/mcp/content/tests.rs | 127 ++++++++ inference-router/src/mcp/forwarder.rs | 216 +++++++++++-- inference-router/src/mcp/mod.rs | 1 + inference-router/src/mcp/platform.rs | 55 ++-- inference-router/src/mcp/tools.rs | 34 ++- inference-router/src/routes/mcp.rs | 3 +- .../tests/fixtures/mcp-tool-content.json | 96 ++++++ inference-router/tests/governed_telemetry.rs | 1 + inference-router/tests/managed_mcp.rs | 3 +- 11 files changed, 774 insertions(+), 62 deletions(-) create mode 100644 inference-router/src/mcp/content.rs create mode 100644 inference-router/src/mcp/content/tests.rs create mode 100644 inference-router/tests/fixtures/mcp-tool-content.json diff --git a/docs/mcp.md b/docs/mcp.md index 77b0f6371..4ae756e7b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -107,6 +107,22 @@ The agent calls a **namespaced** tool (`.`, e.g. to the MCP, and returns the result. The agent has no ambient network reach and no credentials. +### Tool result compatibility + +The forwarder preserves the typed +[MCP content blocks](https://modelcontextprotocol.io/specification/2025-11-25/schema#contentblock): +text, images, audio, resource links, and embedded text or base64 blob resources. +MIME types, annotations, metadata, extension fields, `structuredContent`, and +`isError` survive forwarding. Ordinary text results retain their existing wire +format. Unknown content kinds and malformed required fields are protocol errors, +not silently discarded content. + +Resource and icon URIs are references only: the router does not fetch them. +The existing response byte limit, authentication, governance, and session rules +are unchanged. Text in embedded resources and structured results remains present +in serialized output for structured response inspection; binary data is not +decoded into model text or treated as a successful content-safety scan. + ## Out-of-the-box egress Because the router is the only path to the MCP, the sandbox's default-deny diff --git a/inference-router/src/mcp/content.rs b/inference-router/src/mcp/content.rs new file mode 100644 index 000000000..1053a3a8f --- /dev/null +++ b/inference-router/src/mcp/content.rs @@ -0,0 +1,284 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Typed MCP content blocks, including embedded resources (never dereferenced). +//! Schema: + +use base64::Engine; +use serde::{Deserialize, Deserializer, Serialize, de::Error}; +use serde_json::{Map, Number, Value}; + +/// All five content kinds in MCP 2025-06-18 and 2025-11-25. +/// Extension fields are retained only inside a validated, known content kind. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ToolContent { + Text { + text: String, + #[serde(flatten)] + attributes: ContentAttributes, + }, + Image { + #[serde(deserialize_with = "base64_data")] + data: String, + #[serde(rename = "mimeType")] + mime_type: String, + #[serde(flatten)] + attributes: ContentAttributes, + }, + Audio { + #[serde(deserialize_with = "base64_data")] + data: String, + #[serde(rename = "mimeType")] + mime_type: String, + #[serde(flatten)] + attributes: ContentAttributes, + }, + Resource { + resource: ResourceContents, + #[serde(flatten)] + attributes: ContentAttributes, + }, + ResourceLink { + #[serde(flatten)] + resource: ResourceLink, + }, +} + +impl ToolContent { + pub fn text(text: impl Into) -> Self { + Self::Text { + text: text.into(), + attributes: ContentAttributes::default(), + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContentAttributes { + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "present" + )] + pub annotations: Option, + #[serde( + default, + rename = "_meta", + skip_serializing_if = "Option::is_none", + deserialize_with = "present" + )] + pub meta: Option>, + #[serde(flatten)] + pub extensions: Map, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Annotations { + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "present" + )] + pub audience: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "priority" + )] + pub priority: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "present" + )] + pub last_modified: Option, + #[serde(flatten)] + pub extensions: Map, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Audience { + User, + Assistant, +} + +/// The schema uses anyOf, not oneOf: a resource may contain both text and blob. +/// At least one must be present, and neither may mask an invalid sibling field. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(try_from = "ResourceContentsWire", rename_all = "camelCase")] +pub struct ResourceContents { + pub uri: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub blob: Option, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option>, + #[serde(flatten)] + pub extensions: Map, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ResourceContentsWire { + #[serde(deserialize_with = "uri")] + uri: String, + #[serde(default, deserialize_with = "present")] + mime_type: Option, + #[serde(default, deserialize_with = "present")] + text: Option, + #[serde(default, deserialize_with = "present")] + blob: Option, + #[serde(default, rename = "_meta", deserialize_with = "present")] + meta: Option>, + #[serde(flatten)] + extensions: Map, +} + +impl TryFrom for ResourceContents { + type Error = &'static str; + + fn try_from(value: ResourceContentsWire) -> Result { + if value.text.is_none() && value.blob.is_none() { + return Err("embedded resource requires text or blob"); + } + if let Some(blob) = &value.blob { + validate_base64(blob)?; + } + Ok(Self { + uri: value.uri, + mime_type: value.mime_type, + text: value.text, + blob: value.blob, + meta: value.meta, + extensions: value.extensions, + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ResourceLink { + pub name: String, + #[serde(deserialize_with = "uri")] + pub uri: String, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "present" + )] + pub title: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "present" + )] + pub description: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "present" + )] + pub mime_type: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "present" + )] + pub size: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "present" + )] + pub icons: Option>, + #[serde(flatten)] + pub attributes: ContentAttributes, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Icon { + #[serde(deserialize_with = "uri")] + pub src: String, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "present" + )] + pub mime_type: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "present" + )] + pub sizes: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "present" + )] + pub theme: Option, + #[serde(flatten)] + pub extensions: Map, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum IconTheme { + Light, + Dark, +} + +/// Optional schema fields may be absent, but explicit null is not their type. +pub(super) fn present<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + T::deserialize(deserializer).map(Some) +} + +fn priority<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + let value = Number::deserialize(deserializer)?; + if !value + .as_f64() + .is_some_and(|value| (0.0..=1.0).contains(&value)) + { + return Err(D::Error::custom( + "annotation priority must be between 0 and 1", + )); + } + Ok(Some(value)) +} + +fn uri<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + if value.chars().any(|c| c.is_whitespace() || c.is_control()) + || reqwest::Url::parse(&value).is_err() + { + return Err(D::Error::custom("resource URI must be an absolute URI")); + } + Ok(value) +} + +fn validate_base64(value: &str) -> Result<(), &'static str> { + base64::engine::general_purpose::STANDARD + .decode(value) + .map(|_| ()) + .map_err(|_| "content data must be base64") +} + +fn base64_data<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + validate_base64(&value).map_err(D::Error::custom)?; + Ok(value) +} + +#[cfg(test)] +mod tests; diff --git a/inference-router/src/mcp/content/tests.rs b/inference-router/src/mcp/content/tests.rs new file mode 100644 index 000000000..6f17644e0 --- /dev/null +++ b/inference-router/src/mcp/content/tests.rs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use super::*; +use crate::mcp::tools::ToolCallOutput; +use serde_json::json; + +#[test] +fn protocol_content_real_json_roundtrips_losslessly() { + let fixtures: Vec = serde_json::from_str(include_str!( + "../../../tests/fixtures/mcp-tool-content.json" + )) + .unwrap(); + for fixture in fixtures { + let output: ToolCallOutput = serde_json::from_str(&fixture.to_string()).unwrap(); + let encoded = serde_json::to_value(&output).unwrap(); + assert_eq!(encoded, fixture); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + output + ); + } +} + +#[test] +fn protocol_content_preserves_existing_text_wire_bytes_and_error_default() { + let output = ToolCallOutput { + content: vec![ToolContent::text("hello")], + ..Default::default() + }; + assert_eq!( + serde_json::to_string(&output).unwrap(), + r#"{"content":[{"type":"text","text":"hello"}],"isError":false}"# + ); + let decoded: ToolCallOutput = + serde_json::from_str(r#"{"content":[{"type":"text","text":"hello"}]}"#).unwrap(); + assert_eq!(decoded, output); +} + +#[test] +fn protocol_content_rejects_invalid_known_fields_and_unknown_kinds() { + let invalid = [ + json!({"type":"video","data":"AA==","mimeType":"video/mp4"}), + json!({"type":"text"}), + json!({"type":"text","text":42}), + json!({"text":"missing discriminator"}), + json!({"type":"image","data":"AA=="}), + json!({"type":"image","data":"not base64!","mimeType":"image/png"}), + json!({"type":"audio","data":null,"mimeType":"audio/wav"}), + json!({"type":"audio","data":"AA==","mimeType":5}), + json!({"type":"resource","resource":{"text":"missing URI"}}), + json!({"type":"resource","resource":{"uri":"file:///example"}}), + json!({"type":"resource","resource":{"uri":"relative/path","text":"invalid URI"}}), + json!({"type":"resource","resource":{"uri":"file:///example","text":null}}), + json!({"type":"resource","resource":{"uri":"file:///example","blob":"?"}}), + json!({"type":"resource","resource":{"uri":"file:///example","text":"valid","blob":12}}), + json!({"type":"resource","resource":{"uri":"file:///example","text":12,"blob":"AA=="}}), + json!({"type":"resource","resource":{"uri":"file:///example","text":"valid","blob":"?"}}), + json!({"type":"resource","resource":{"uri":"file:///example","text":"ok","mimeType":12}}), + json!({"type":"resource_link","uri":"https://example.invalid"}), + json!({"type":"resource_link","name":"link","uri":" https://example.invalid"}), + json!({"type":"resource_link","name":"link","uri":"urn:test","size":0.5}), + json!({"type":"resource_link","name":"link","uri":"urn:test","icons":[{"src":"urn:icon","theme":"blue"}]}), + json!({"type":"text","text":"ok","annotations":{"audience":["system"]}}), + json!({"type":"text","text":"ok","annotations":{"priority":1.1}}), + json!({"type":"text","text":"ok","annotations":{"priority":-0.1}}), + json!({"type":"text","text":"ok","annotations":{"priority":"1"}}), + json!({"type":"text","text":"ok","annotations":null}), + json!({"type":"text","text":"ok","_meta":[]}), + ]; + for content in invalid { + assert!( + serde_json::from_value::(content.clone()).is_err(), + "accepted invalid content: {content}" + ); + } + for result in [ + json!({}), + json!({"content":null}), + json!({"content":[],"isError":null}), + json!({"content":[],"isError":"false"}), + json!({"content":[],"structuredContent":[]}), + json!({"content":[],"structuredContent":null}), + json!({"content":[],"_meta":42}), + ] { + assert!( + serde_json::from_value::(result.clone()).is_err(), + "accepted invalid result: {result}" + ); + } +} + +#[test] +fn protocol_content_remains_visible_to_existing_structured_response_scanner() { + use agentmesh_mcp::{ + InMemoryAuditSink, McpMetricsCollector, McpResponseScanner, SystemClock, + redactor::CredentialRedactor, + }; + use std::sync::Arc; + + let scanner = McpResponseScanner::new( + CredentialRedactor::new(), + Arc::new(InMemoryAuditSink::new(CredentialRedactor::new())), + McpMetricsCollector::default(), + Arc::new(SystemClock), + ) + .unwrap(); + let marker = "inspection regression"; + let fixture = json!({ + "content": [ + {"type":"text","text":marker}, + {"type":"resource","resource":{"uri":"urn:example:text","text":marker}}, + {"type":"resource_link","uri":"urn:example:link","name":marker,"title":marker,"description":marker}, + {"type":"image","data":"AA==","mimeType":"image/png","_meta":{"alt":marker}}, + {"type":"audio","data":"AA==","mimeType":"audio/wav","_meta":{"transcript":marker}} + ], + "structuredContent":{"nested":[{"text":marker}]}, + "_meta":{"note":marker} + }); + let output: ToolCallOutput = serde_json::from_value(fixture).unwrap(); + let serialized = serde_json::to_value(&output).unwrap(); + let inspected = scanner.scan_value(&serialized).unwrap(); + assert_eq!(inspected.findings.len(), 9); + assert!(!inspected.sanitized.to_string().contains(marker)); + assert_eq!(inspected.sanitized["content"][3]["data"], "AA=="); + assert_eq!(serialized["content"][1]["resource"]["text"], marker); +} diff --git a/inference-router/src/mcp/forwarder.rs b/inference-router/src/mcp/forwarder.rs index 5c1c117bf..959c83d1d 100644 --- a/inference-router/src/mcp/forwarder.rs +++ b/inference-router/src/mcp/forwarder.rs @@ -943,13 +943,12 @@ async fn post_tools_call( // because the agent-facing surface shouldn't distinguish (the audit // layer can see both). return CallAttempt::Done(ToolCallOutput { - content: vec![ToolContent::Text { - text: format!( - "upstream JSON-RPC error code={} message={}", - err.code, err.message - ), - }], + content: vec![ToolContent::text(format!( + "upstream JSON-RPC error code={} message={}", + err.code, err.message + ))], is_error: true, + ..Default::default() }); } @@ -963,10 +962,7 @@ async fn post_tools_call( } }; - CallAttempt::Done(ToolCallOutput { - content: result.content, - is_error: result.is_error.unwrap_or(false), - }) + CallAttempt::Done(result) } async fn forward_tools_call( @@ -1105,19 +1101,11 @@ struct ToolsListResult { #[derive(Debug, Deserialize)] struct ToolsCallResponse { #[serde(default)] - result: Option, + result: Option, #[serde(default)] error: Option, } -#[derive(Debug, Deserialize)] -struct ToolsCallResult { - #[serde(default)] - content: Vec, - #[serde(default, rename = "isError")] - is_error: Option, -} - #[derive(Debug, Deserialize)] struct JsonRpcWireError { code: i64, @@ -1188,14 +1176,39 @@ mod tests { force_call_error: Option<(i64, String)>, /// If set, the upstream returns this HTTP status for tools/call. force_call_http_status: Option, + force_call_result: Option, + call_sse: bool, /// Last `Authorization` header value seen by the mock (used by /// the bearer-attach test to confirm outbound auth wiring). last_auth_header: StdArc>>, } async fn mock_upstream(state: MockState) -> String { + use axum::response::IntoResponse; + let app = Router::new() - .route("/", post(mock_handler)) + .route( + "/", + post( + |State(state): State, + headers: HeaderMap, + Json(body): Json| async move { + let sse = state.call_sse && body["method"] == "tools/call"; + let (status, Json(payload)) = + mock_handler(State(state), headers, Json(body)).await; + if sse { + ( + status, + [("content-type", "text/event-stream")], + format!("data: {payload}\n\n"), + ) + .into_response() + } else { + (status, Json(payload)).into_response() + } + }, + ), + ) .route("/", any(method_block)) .with_state(state); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -1250,6 +1263,12 @@ mod tests { })), ); } + if let Some(result) = state.force_call_result { + return ( + StatusCode::OK, + Json(json!({"jsonrpc":"2.0", "id":id, "result":result})), + ); + } let upstream_tool = body .pointer("/params/name") .and_then(|v| v.as_str()) @@ -1531,11 +1550,138 @@ mod tests { .expect("invoke"); assert!(!out.is_error); assert_eq!(out.content.len(), 1); - let ToolContent::Text { text } = &out.content[0]; + let ToolContent::Text { text, .. } = &out.content[0] else { + panic!("expected text content"); + }; assert!(text.contains("called search")); assert!(text.contains("azure")); } + #[tokio::test] + async fn protocol_content_forwards_json_and_sse_results_losslessly() { + let fixtures: Vec = + serde_json::from_str(include_str!("../../tests/fixtures/mcp-tool-content.json")) + .unwrap(); + for call_sse in [false, true] { + for result in &fixtures { + let state = MockState { + tools: vec![tool_def("get_file_contents", "")], + force_call_result: Some(result.clone()), + call_sse, + ..Default::default() + }; + let calls = state.call_count.clone(); + let url = mock_upstream(state).await; + let registry = registry_with(vec![discovered("github-mcp", &url, vec!["*"])]); + let dispatcher = RouterToolDispatcher::discover(registry, Duration::from_secs(5)) + .await + .unwrap(); + let request = crate::mcp::jsonrpc::Request { + jsonrpc: "2.0".into(), + id: crate::mcp::jsonrpc::Id::Number(7), + method: "tools/call".into(), + params: Some( + json!({"name":"github_mcp.get_file_contents","arguments":{"path":"README.md"}}), + ), + }; + let response = + crate::mcp::tools::handle_tools_call_async(&request, &dispatcher).await; + assert!(response.error.is_none(), "{response:?}"); + assert_eq!(response.result.as_ref(), Some(result), "SSE={call_sse}"); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + } + } + + #[tokio::test] + async fn protocol_content_rejects_invalid_forwarded_results_without_replay() { + for result in [ + json!({"content":[{"type":"unknown","text":"not a supported block"}]}), + json!({"content":[{"type":"resource","resource":{"uri":"file:///example"}}]}), + json!({"content":[{"type":"resource","resource":{"uri":"file:///example","blob":"?"}}]}), + json!({"content":[],"structuredContent":[]}), + json!({"structuredContent":{}}), + ] { + let state = MockState { + tools: vec![tool_def("get_file_contents", "")], + force_call_result: Some(result), + ..Default::default() + }; + let calls = state.call_count.clone(); + let url = mock_upstream(state).await; + let registry = registry_with(vec![discovered("github-mcp", &url, vec!["*"])]); + let dispatcher = RouterToolDispatcher::discover(registry, Duration::from_secs(5)) + .await + .unwrap(); + let error = dispatcher + .invoke("github_mcp.get_file_contents", &json!({})) + .await + .unwrap_err(); + assert!( + matches!(error, DispatchError::ExecutionFailed { ref reason, .. } if reason.contains("parse failed")), + "{error}" + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + } + + #[tokio::test] + async fn protocol_content_does_not_dereference_resource_or_icon_uris() { + let resource_server = wiremock::MockServer::start().await; + let uri = resource_server.uri(); + let result = json!({"content":[ + {"type":"resource","resource":{"uri":uri,"text":"embedded text"}}, + {"type":"resource_link","uri":uri,"name":"reference","icons":[{"src":uri}]} + ],"isError":false}); + let state = MockState { + tools: vec![tool_def("get_file_contents", "")], + force_call_result: Some(result.clone()), + ..Default::default() + }; + let url = mock_upstream(state).await; + let registry = registry_with(vec![discovered("github-mcp", &url, vec!["*"])]); + let dispatcher = RouterToolDispatcher::discover(registry, Duration::from_secs(5)) + .await + .unwrap(); + let output = dispatcher + .invoke("github_mcp.get_file_contents", &json!({})) + .await + .unwrap(); + assert_eq!(serde_json::to_value(output).unwrap(), result); + assert!( + resource_server + .received_requests() + .await + .unwrap() + .is_empty() + ); + } + + #[tokio::test] + async fn protocol_content_embedded_resources_still_obey_response_byte_limit() { + let state = MockState { + tools: vec![tool_def("get_file_contents", "")], + force_call_result: Some(json!({"content":[{"type":"resource","resource":{ + "uri":"file:///large.txt", + "text":"x".repeat(super::super::response_body::MAX_RESPONSE_BYTES) + }}]})), + ..Default::default() + }; + let url = mock_upstream(state).await; + let registry = registry_with(vec![discovered("github-mcp", &url, vec!["*"])]); + let dispatcher = RouterToolDispatcher::discover(registry, Duration::from_secs(5)) + .await + .unwrap(); + let error = dispatcher + .invoke("github_mcp.get_file_contents", &json!({})) + .await + .unwrap_err(); + assert!( + matches!(error, DispatchError::ExecutionFailed { ref reason, .. } if reason.contains("byte limit")), + "{error}" + ); + } + #[tokio::test] async fn invoke_unknown_tool_returns_unknown_tool() { let state = MockState { @@ -1589,7 +1735,9 @@ mod tests { .await .expect("invoke"); assert!(out.is_error); - let ToolContent::Text { text } = &out.content[0]; + let ToolContent::Text { text, .. } = &out.content[0] else { + panic!("expected text content"); + }; assert!(text.contains("code=-32000")); assert!(text.contains(message)); assert_eq!( @@ -1652,14 +1800,18 @@ mod tests { .invoke("github_mcp.search", &serde_json::json!({"q": "a"})) .await .unwrap(); - let ToolContent::Text { text: t1 } = &out1.content[0]; + let ToolContent::Text { text: t1, .. } = &out1.content[0] else { + panic!("expected text content"); + }; assert!(t1.contains("called search")); let out2 = dispatcher .invoke("kb_search.query", &serde_json::json!({"q": "b"})) .await .unwrap(); - let ToolContent::Text { text: t2 } = &out2.content[0]; + let ToolContent::Text { text: t2, .. } = &out2.content[0] else { + panic!("expected text content"); + }; assert!(t2.contains("called query")); } @@ -1862,7 +2014,9 @@ mod tests { ) .await .expect("invoke"); - let ToolContent::Text { text } = &out.content[0]; + let ToolContent::Text { text, .. } = &out.content[0] else { + panic!("expected text content"); + }; assert!(text.contains("called browser_navigate")); assert!(!out.is_error); @@ -1901,7 +2055,9 @@ mod tests { .invoke("github_mcp.search_repos", &serde_json::json!({"q": "kars"})) .await .expect("invoke"); - let ToolContent::Text { text } = &out.content[0]; + let ToolContent::Text { text, .. } = &out.content[0] else { + panic!("expected text content"); + }; assert!(text.contains("called search_repos")); } @@ -1930,7 +2086,9 @@ mod tests { .invoke("svc.do_thing", &serde_json::json!({})) .await .expect("invoke should succeed after transparent re-init + retry"); - let ToolContent::Text { text } = &out.content[0]; + let ToolContent::Text { text, .. } = &out.content[0] else { + panic!("expected text content"); + }; assert!(text.contains("called do_thing")); // The retry path re-established the session exactly once more. @@ -1974,7 +2132,9 @@ mod tests { ) .await .expect("invoke"); - let ToolContent::Text { text } = &out.content[0]; + let ToolContent::Text { text, .. } = &out.content[0] else { + panic!("expected text content"); + }; assert!(text.contains("sessionStorage"), "result text preserved"); assert!(!out.is_error); } diff --git a/inference-router/src/mcp/mod.rs b/inference-router/src/mcp/mod.rs index e30e9cc3e..64cc7d27d 100644 --- a/inference-router/src/mcp/mod.rs +++ b/inference-router/src/mcp/mod.rs @@ -45,6 +45,7 @@ //! oversize frame, missing `Accept` content types, `alg=none`, `alg` //! confusion, kid mismatch, expired token, replayed token. +pub mod content; pub mod error; pub mod forwarder; pub mod governed; diff --git a/inference-router/src/mcp/platform.rs b/inference-router/src/mcp/platform.rs index 69e817057..abe1d706d 100644 --- a/inference-router/src/mcp/platform.rs +++ b/inference-router/src/mcp/platform.rs @@ -1030,10 +1030,9 @@ impl PlatformDispatcher { return ( None, ToolCallOutput { - content: vec![ToolContent::Text { - text: format!("{tool} body serialise: {e}"), - }], + content: vec![ToolContent::text(format!("{tool} body serialise: {e}"))], is_error: true, + ..Default::default() }, ); } @@ -1106,18 +1105,20 @@ impl PlatformDispatcher { ( Some(code), ToolCallOutput { - content: vec![ToolContent::Text { text }], + content: vec![ToolContent::text(text)], is_error: false, + ..Default::default() }, ) } else { ( Some(code), ToolCallOutput { - content: vec![ToolContent::Text { - text: format!("{tool} upstream returned HTTP {code}: {text}"), - }], + content: vec![ToolContent::text(format!( + "{tool} upstream returned HTTP {code}: {text}" + ))], is_error: true, + ..Default::default() }, ) } @@ -1125,10 +1126,9 @@ impl PlatformDispatcher { Err(e) => ( None, ToolCallOutput { - content: vec![ToolContent::Text { - text: format!("{tool} transport error: {e}"), - }], + content: vec![ToolContent::text(format!("{tool} transport error: {e}"))], is_error: true, + ..Default::default() }, ), } @@ -1151,10 +1151,11 @@ impl ToolDispatcher for PlatformDispatcher { return Err(DispatchError::UnknownTool(name.to_string())); } Ok(ToolCallOutput { - content: vec![ToolContent::Text { - text: format!("{SYNC_PATH_NOT_SUPPORTED}\n\nTool: {name}"), - }], + content: vec![ToolContent::text(format!( + "{SYNC_PATH_NOT_SUPPORTED}\n\nTool: {name}" + ))], is_error: true, + ..Default::default() }) } } @@ -1220,7 +1221,9 @@ mod tests { let out = ToolDispatcher::invoke(&d, "foundry.web_search", &json!({"query": "x"})) .expect("known tool dispatches"); assert!(out.is_error, "sync path must surface is_error=true"); - let ToolContent::Text { text } = &out.content[0]; + let ToolContent::Text { text, .. } = &out.content[0] else { + panic!("expected text content"); + }; assert!( text.contains("does not support synchronous"), "sync error must mention async path requirement, got: {text}" @@ -1254,7 +1257,9 @@ mod tests { async fn assert_ok_text(out: ToolCallOutput, expected_substr: &str) { assert!(!out.is_error, "expected success, got error: {out:?}"); - let ToolContent::Text { text } = &out.content[0]; + let ToolContent::Text { text, .. } = &out.content[0] else { + panic!("expected text content"); + }; assert!( text.contains(expected_substr), "missing {expected_substr:?} in {text:?}" @@ -1835,7 +1840,9 @@ mod tests { .await .unwrap(); assert!(out.is_error); - let ToolContent::Text { text } = &out.content[0]; + let ToolContent::Text { text, .. } = &out.content[0] else { + panic!("expected text content"); + }; assert!(text.contains("403"), "expected status 403 in: {text}"); assert!(text.contains("forbidden")); } @@ -1853,7 +1860,9 @@ mod tests { .await .unwrap(); assert!(out.is_error); - let ToolContent::Text { text } = &out.content[0]; + let ToolContent::Text { text, .. } = &out.content[0] else { + panic!("expected text content"); + }; assert!(text.contains("503")); } @@ -1866,7 +1875,9 @@ mod tests { .await .unwrap(); assert!(out.is_error); - let ToolContent::Text { text } = &out.content[0]; + let ToolContent::Text { text, .. } = &out.content[0] else { + panic!("expected text content"); + }; assert!( text.contains("transport error"), "expected transport error in: {text}" @@ -2046,7 +2057,9 @@ mod tests { .await .unwrap(); assert!(out.is_error); - let ToolContent::Text { text } = &out.content[0]; + let ToolContent::Text { text, .. } = &out.content[0] else { + panic!("expected text content"); + }; assert!(text.contains("HTTP 403")); } @@ -2205,7 +2218,9 @@ mod tests { .await .unwrap(); assert!(out.is_error); - let ToolContent::Text { text } = &out.content[0]; + let ToolContent::Text { text, .. } = &out.content[0] else { + panic!("expected text content"); + }; assert!(text.contains("HTTP 404")); } diff --git a/inference-router/src/mcp/tools.rs b/inference-router/src/mcp/tools.rs index 291eeee96..2439dce2b 100644 --- a/inference-router/src/mcp/tools.rs +++ b/inference-router/src/mcp/tools.rs @@ -33,6 +33,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +pub use super::content::ToolContent; use super::error::{ErrorCode, JsonRpcError}; use super::jsonrpc::{Request, Response}; @@ -113,21 +114,30 @@ pub enum CatalogError { DuplicateName(String), } -/// One content item returned by a tool call. Spec §tools/call result. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "type", rename_all = "lowercase")] -pub enum ToolContent { - Text { text: String }, -} - /// Successful tool-call result. `is_error: true` indicates the tool /// ran but returned an error result (per spec — this is distinct from /// the JSON-RPC error path which is for protocol-level failures). -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ToolCallOutput { pub content: Vec, + #[serde(default)] pub is_error: bool, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "super::content::present" + )] + pub structured_content: Option>, + #[serde( + default, + rename = "_meta", + skip_serializing_if = "Option::is_none", + deserialize_with = "super::content::present" + )] + pub meta: Option>, + #[serde(flatten)] + pub extensions: serde_json::Map, } /// Errors a dispatcher can raise. These map to JSON-RPC errors — @@ -272,10 +282,9 @@ impl ToolDispatcher for EchoDispatcher { reason: "missing required string property `text`".to_string(), })?; Ok(ToolCallOutput { - content: vec![ToolContent::Text { - text: text.to_string(), - }], + content: vec![ToolContent::text(text)], is_error: false, + ..Default::default() }) } } @@ -550,7 +559,8 @@ mod tests { assert!(!out.is_error); assert_eq!(out.content.len(), 1); match &out.content[0] { - ToolContent::Text { text } => assert_eq!(text, "hello"), + ToolContent::Text { text, .. } => assert_eq!(text, "hello"), + other => panic!("expected text content, got {other:?}"), } } diff --git a/inference-router/src/routes/mcp.rs b/inference-router/src/routes/mcp.rs index f61b99867..bfb695caf 100644 --- a/inference-router/src/routes/mcp.rs +++ b/inference-router/src/routes/mcp.rs @@ -1084,8 +1084,9 @@ mod tests { async fn invoke(&self, _: &str, _: &Value) -> Result { self.1.fetch_add(1, std::sync::atomic::Ordering::SeqCst); Ok(ToolCallOutput { - content: vec![ToolContent::Text { text: "ok".into() }], + content: vec![ToolContent::text("ok")], is_error: false, + ..Default::default() }) } } diff --git a/inference-router/tests/fixtures/mcp-tool-content.json b/inference-router/tests/fixtures/mcp-tool-content.json new file mode 100644 index 000000000..a4fa653fb --- /dev/null +++ b/inference-router/tests/fixtures/mcp-tool-content.json @@ -0,0 +1,96 @@ +[ + { + "content": [{"type": "text", "text": "ordinary tool result"}], + "isError": false + }, + { + "content": [ + {"type": "text", "text": "successfully downloaded text file (SHA: example)"}, + { + "type": "resource", + "resource": { + "uri": "repo://example/project/refs/heads/main/README.md", + "mimeType": "text/plain; charset=utf-8", + "text": "# Example\n\nRepository contents: café 🦀\n" + } + } + ], + "isError": false + }, + { + "content": [ + { + "type": "resource", + "resource": { + "uri": "file:///assets/logo.bin", + "mimeType": "application/octet-stream", + "blob": "AAECAwQ=", + "_meta": {"sha": "example", "nested": {"retained": true}} + }, + "annotations": {"audience": ["user", "assistant"], "priority": 0.75, "lastModified": "2025-06-18T10:00:00Z"}, + "_meta": {"source": "fixture"} + } + ], + "isError": false + }, + { + "content": [ + { + "type": "text", + "text": "annotated text", + "annotations": {"audience": [], "priority": 0}, + "_meta": {"retained": [null, 42, "text"]}, + "example.com/extension": {"note": "retained"} + }, + { + "type": "image", + "data": "AAECAwQ=", + "mimeType": "image/png", + "annotations": {"audience": ["user"], "priority": 1}, + "_meta": {"alt": "image description"} + }, + { + "type": "audio", + "data": "UklGRg==", + "mimeType": "audio/wav", + "annotations": {"priority": 0.5}, + "_meta": {"transcript": "audio description"} + }, + { + "type": "resource_link", + "uri": "https://example.invalid/guide", + "name": "guide", + "title": "Guide", + "description": "Reference documentation", + "mimeType": "text/markdown", + "size": 123, + "icons": [ + {"src": "https://example.invalid/icon.png", "mimeType": "image/png", "sizes": ["48x48"], "theme": "light"}, + {"src": "data:image/svg+xml;base64,PHN2Zy8+", "sizes": ["any"], "theme": "dark"} + ], + "annotations": {"audience": ["assistant"]}, + "_meta": {"retained": true} + } + ], + "isError": false + }, + { + "content": [{"type": "text", "text": "Not found"}], + "isError": true, + "structuredContent": {"status": "missing", "details": [{"path": "README.md", "retry": false}]}, + "_meta": {"requestId": "fixture"}, + "example.com/extension": {"retained": true} + }, + { + "content": [], + "isError": false, + "structuredContent": {"items": [], "count": 0} + }, + { + "content": [ + {"type": "resource", "resource": {"uri": "urn:example:empty", "text": "", "blob": "", "_meta": {}}}, + {"type": "resource", "resource": {"uri": "custom:example", "blob": ""}} + ], + "isError": false + } +] diff --git a/inference-router/tests/governed_telemetry.rs b/inference-router/tests/governed_telemetry.rs index 43288798b..4492ad7c4 100644 --- a/inference-router/tests/governed_telemetry.rs +++ b/inference-router/tests/governed_telemetry.rs @@ -524,6 +524,7 @@ async fn mcp_is_error_and_dispatch_transport_failure_are_both_failure_observatio Ok(ToolCallOutput { content: vec![], is_error: true, + ..Default::default() }) } else { Err(DispatchError::ExecutionFailed { diff --git a/inference-router/tests/managed_mcp.rs b/inference-router/tests/managed_mcp.rs index a0ae74daf..f1ed4ed01 100644 --- a/inference-router/tests/managed_mcp.rs +++ b/inference-router/tests/managed_mcp.rs @@ -41,8 +41,9 @@ impl AsyncToolDispatcher for Dispatcher { ) -> Result { self.calls.fetch_add(1, Ordering::SeqCst); Ok(ToolCallOutput { - content: vec![ToolContent::Text { text: "ok".into() }], + content: vec![ToolContent::text("ok")], is_error: false, + ..Default::default() }) } } From 211d0d4e209924b7cec72cdbfd00b24c96dfeb60 Mon Sep 17 00:00:00 2001 From: pallakatos Date: Thu, 10 Sep 2026 16:12:21 +0200 Subject: [PATCH 09/14] docs(security): record delegated MCP compatibility audit Record the maintainer-authorized AI source review and actual 249 MCP plus 17 integration test results at fbd01597. Disclose scanner and binary-content limits, cached-tooling scope, and native qualification still required. Do not imply independent human review, waive gates, or approve a deployment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../security-audits/2026-09-08-managed-mcp.md | 49 ++++++++++++++++--- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/docs/security-audits/2026-09-08-managed-mcp.md b/docs/security-audits/2026-09-08-managed-mcp.md index 4d1b343b3..2221abc5b 100644 --- a/docs/security-audits/2026-09-08-managed-mcp.md +++ b/docs/security-audits/2026-09-08-managed-mcp.md @@ -1,6 +1,43 @@ # Managed MCP capability audit — 2026-09-08 -Status: **PENDING — not qualified for deployment or merge** +Status: **Source audit approved under explicit maintainer delegation**. +Exact-head hosted and native qualification remain required before merge. + +## Current delegated review and qualification + +The maintainer authorized publication sign-offs after additional focused +reviews, recorded in +[comment 5615522306](https://github.com/Azure/kars/pull/551#issuecomment-5615522306). +The second attestation below is delegated AI review, not an assertion that a +second human signed this audit. + +The current source is `fbd01597ce1cb86fff6df71d21dc2bf07abaf528`, based on +signed governed-services source `6b34d5ea`. A focused AI security review of +that comparison reported no vulnerabilities in its reviewed scope. The +earlier caller-identity and lifecycle findings remain repaired as described +below. No source branch containing private SDK/security probes is included. + +The additional tool-result compatibility fix preserves all five standard MCP +content kinds, embedded text/blob resources, metadata, annotations, structured +results and `isError`. Resource/icon URIs are not fetched. Unknown kinds and +malformed required fields remain protocol errors, and existing byte limits, +authentication, session isolation and policy checks are unchanged. + +Actual local qualification at `fbd01597` passed 249 router MCP library cases, +17 managed-MCP/governed-telemetry integration cases, strict paired all-target +Clippy and formatting. The guarded shared target observed at least 10.01 GiB +free. Controller runtime tests were not rerun in this content-focused batch. +Earlier restack checks passed Helm, 108 Python cases, 22 CLI cases and type +checking with compatible cached tooling; these are not exact-lockfile or +native H100 deployment claims. + +This branch has no MCP output Content Safety hook, and none is introduced or +claimed by the fix. Scanner regressions exercise existing AGT `scan_value` +visibility of serialized text, metadata and structured fields. Base64 +validation establishes encoding only, not the safety of binary contents. +The focused reviewer did not independently rerun tests. Current-base hosted +Rust and native lifecycle/protocol acceptance remain mandatory, with no +technical gate, finding, identity fence or policy denial waived. ## Scope @@ -68,10 +105,10 @@ revocation is not instantaneous cancellation of already accepted upstream work. ## Signatures -| Role | Sign-off | -|---|---| -| Maintainer / owner | **PENDING** | -| Independent reviewer | **PENDING** | +Signed-off-by: pallakatos (maintainer delegation recorded above) +Signed-off-by: GitHub Copilot (delegated AI audit, not an independent human) <223556219+Copilot@users.noreply.github.com> The limited human-review waivers for earlier qualified publication slices do -not apply to this candidate. +not apply to this candidate. The separate, explicit delegation above supplies +the current source approval; it does not approve an unqualified integration +merge, customer/H100 deployment, image release or `main` promotion. From c56b90d4963f85df7442e3e6eafb8b99808f443e Mon Sep 17 00:00:00 2001 From: pallakatos Date: Thu, 10 Sep 2026 17:17:52 +0200 Subject: [PATCH 10/14] fix(e2e): negotiate both MCP response media types The actual Kind RPC fixture sent JSON-only Accept, which the scoped router correctly rejects with HTTP 406. Advertise both required media types without changing readiness, UID, namespace, authentication, or deadline checks. Preserve bounded numeric catalog-stage diagnostics before teardown rather than discarding the rejection status. Add fixture/privacy unit regressions and a bounded integration test invoking the actual Python RPC helper against the real loopback router, including the JSON-only 406 negative control. The new header unit regression failed before the fix; six Python tests and static checks now pass. Rust qualification remains pending the explicit shared-target lease. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- docs/tutorials/managed-mcp.md | 6 ++ inference-router/tests/managed_mcp.rs | 78 +++++++++++++++ tests/e2e/managed-mcp.py | 52 ++++++++-- tests/e2e/managed_mcp_test.py | 139 ++++++++++++++++++++++++++ 4 files changed, 265 insertions(+), 10 deletions(-) create mode 100644 tests/e2e/managed_mcp_test.py diff --git a/docs/tutorials/managed-mcp.md b/docs/tutorials/managed-mcp.md index bee4115ce..4c35ad0f7 100644 --- a/docs/tutorials/managed-mcp.md +++ b/docs/tutorials/managed-mcp.md @@ -70,6 +70,12 @@ HTTP calls do not bypass the same router policy check. The optional `X-Kars-Mcp-Server` header narrows discovery and invocation to one mounted server; it never selects a workspace or an arbitrary upstream URL. +Direct MCP POST clients must advertise +`Accept: application/json, text/event-stream`; JSON-only requests are rejected +with HTTP 406 even when the managed server is Ready. The Kind acceptance fixture +uses this same contract. Catalog timeouts report only the last HTTP status, +numeric RPC error code, tool count, expected-tool presence and transport state, +before teardown—not response bodies, headers or credentials. The unauthenticated lane requires an actual loopback socket peer; missing connection context, network peers and spoofed forwarding/Sandbox/session headers are rejected before policy evaluation or invocation. Configured Sandbox identity diff --git a/inference-router/tests/managed_mcp.rs b/inference-router/tests/managed_mcp.rs index f1ed4ed01..b8ccd0696 100644 --- a/inference-router/tests/managed_mcp.rs +++ b/inference-router/tests/managed_mcp.rs @@ -208,6 +208,84 @@ async fn missing_peer_network_peer_and_forged_local_headers_never_evaluate_or_in } } +#[tokio::test] +async fn actual_kind_rpc_client_negotiates_the_scoped_router_without_weakening_accept_checks() { + let (inner, calls) = dispatcher(); + let state = McpRouteState::standard().with_tools(inner); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve( + listener, + routes::mcp_route() + .with_state(state) + .into_make_service_with_connect_info::(), + ) + .await + .unwrap(); + }); + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let http = reqwest::Client::builder() + .no_proxy() + .timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap(); + let rejected = http + .post(format!("http://{address}/mcp")) + .header("accept", "application/json") + .header("x-kars-mcp-server", "one") + .json(&json!({"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}})) + .send() + .await + .unwrap(); + assert_eq!(rejected.status().as_u16(), 406); + assert_eq!(calls.load(Ordering::SeqCst), 0); + + // Execute the actual native fixture helper, not a second client with + // independently correct headers that could leave the Kind fixture broken. + let script = r#" +import importlib.util +import sys + +spec = importlib.util.spec_from_file_location("managed_mcp_fixture", sys.argv[1]) +fixture = importlib.util.module_from_spec(spec) +spec.loader.exec_module(fixture) +port = int(sys.argv[2]) +status, body = fixture.rpc(port, "tools/list", {}, "one") +assert status == 200, "actual Kind catalog client was rejected" +assert [tool["name"] for tool in body["result"]["tools"]] == ["one.echo"] +status, body = fixture.rpc(port, "tools/call", {"name":"one.echo","arguments":{}}, "one") +assert status == 200 and body["result"]["content"][0]["text"] == "ok" +status, body = fixture.rpc(port, "tools/call", {"name":"two.echo","arguments":{}}, "one") +assert status == 200 and ("error" in body or body["result"].get("isError")) +status, _ = fixture.rpc(port, "tools/list", {}, "unmounted") +assert status == 404, "unknown scope must remain rejected" +"#; + let fixture = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/e2e/managed-mcp.py"); + let output = tokio::time::timeout( + std::time::Duration::from_secs(20), + tokio::process::Command::new("python3") + .args(["-B", "-c", script]) + .arg(fixture) + .arg(address.port().to_string()) + .kill_on_drop(true) + .output(), + ) + .await; + server.abort(); + let _ = server.await; + let output = output + .expect("actual Kind RPC client exceeded its bounded local test deadline") + .expect("python3 must be available for the native fixture regression"); + assert!( + output.status.success(), + "actual Kind RPC client failed; status {}", + output.status + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); +} + #[tokio::test] async fn real_http_loopback_can_initialize_scope_catalog_and_invoke_only_its_server() { let (inner, calls) = dispatcher(); diff --git a/tests/e2e/managed-mcp.py b/tests/e2e/managed-mcp.py index 9e2439975..9abeb9e0c 100644 --- a/tests/e2e/managed-mcp.py +++ b/tests/e2e/managed-mcp.py @@ -98,7 +98,7 @@ def ready(namespace): def rpc(port, method, params, server=None): - headers = {"Content-Type": "application/json", "Accept": "application/json"} + headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"} if server: headers["X-Kars-Mcp-Server"] = server request = urllib.request.Request(f"http://127.0.0.1:{port}/mcp", @@ -112,6 +112,46 @@ def rpc(port, method, params, server=None): return error.code, None +def catalog_facts(code, body): + envelope = body if isinstance(body, dict) else {} + result = envelope.get("result") + tools = result.get("tools") if isinstance(result, dict) else None + error = envelope.get("error") + error_code = error.get("code") if isinstance(error, dict) else None + return { + "stage": "router_catalog", + "httpStatus": code if type(code) is int and 100 <= code <= 599 else None, + "rpcError": "error" in envelope, + "rpcErrorCode": error_code if type(error_code) is int else None, + "toolCount": len(tools) if isinstance(tools, list) else None, + "expectedToolPresent": isinstance(tools, list) and any( + isinstance(tool, dict) and tool.get("name") == "e2e_tools.echo" for tool in tools), + } + + +def wait_for_catalog(port, process): + last = catalog_facts(None, None) + + def catalog(): + nonlocal last + require(process.poll() is None, "MCP port-forward exited") + try: + code, body = rpc(port, "tools/list", {}, SERVER) + except (OSError, ValueError) as error: + last = catalog_facts(None, None) + last["transportError"] = "io" if isinstance(error, OSError) else "invalid_json" + return False + last = catalog_facts(code, body) + return body if code == 200 and last["expectedToolPresent"] and not last["rpcError"] else False + + try: + return wait("actual routed MCP catalog after reconciliation", catalog, seconds=120) + except AssertionError: + last["portForwardAlive"] = process.poll() is None + print("MCP-DIAG", json.dumps(last), flush=True) + raise + + def build_image(): run(["docker", "build", "-t", IMAGE, "-f", "sandbox-images/mcp-everything/Dockerfile", "sandbox-images/mcp-everything"], seconds=360) @@ -172,15 +212,7 @@ def test(): cwd=ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) PROCESSES.append(process) try: - def catalog(): - require(process.poll() is None, "MCP port-forward exited") - try: - code, body = rpc(port, "tools/list", {}, SERVER) - return body if code == 200 and any(tool.get("name") == "e2e_tools.echo" - for tool in (body or {}).get("result", {}).get("tools", [])) else False - except (OSError, ValueError): - return False - wait("actual routed MCP catalog after reconciliation", catalog, seconds=120) + wait_for_catalog(port, process) code, body = rpc(port, "tools/call", {"name": "e2e_tools.echo", "arguments": {"message": "mcp-kind-proof"}}, SERVER) require(code == 200 and "error" not in body and not body["result"].get("isError") and "mcp-kind-proof" in json.dumps(body["result"]), "Real MCP echo did not execute through the router") diff --git a/tests/e2e/managed_mcp_test.py b/tests/e2e/managed_mcp_test.py new file mode 100644 index 000000000..1046d3e99 --- /dev/null +++ b/tests/e2e/managed_mcp_test.py @@ -0,0 +1,139 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Managed MCP fixture checks, not native Kubernetes acceptance.""" + +import importlib.util +import contextlib +import io +import json +from pathlib import Path +import unittest +from unittest.mock import MagicMock, patch + +SPEC = importlib.util.spec_from_file_location( + "managed_mcp_fixture", Path(__file__).with_name("managed-mcp.py")) +MCP = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MCP) + + +class ManagedMcpTests(unittest.TestCase): + def test_rpc_advertises_both_required_media_types_without_changing_scope(self): + response = MagicMock() + response.status = 200 + response.read.return_value = b'{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}' + opener = MagicMock() + opener.open.return_value.__enter__.return_value = response + with patch.object(MCP.urllib.request, "build_opener", return_value=opener): + status, body = MCP.rpc(12345, "tools/list", {}, MCP.SERVER) + request = opener.open.call_args.args[0] + headers = {key.lower(): value for key, value in request.header_items()} + self.assertEqual(headers, { + "content-type": "application/json", + "accept": "application/json, text/event-stream", + "x-kars-mcp-server": MCP.SERVER, + }) + self.assertEqual(request.full_url, "http://127.0.0.1:12345/mcp") + self.assertEqual(json.loads(request.data), { + "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}, + }) + self.assertEqual(opener.open.call_args.kwargs, {"timeout": 15}) + response.read.assert_called_once_with(2 * 1024 * 1024) + self.assertEqual(status, 200) + self.assertEqual(body["result"]["tools"], []) + + def test_catalog_facts_never_copy_payloads_headers_or_tool_names(self): + secret = "PRIVATE_DIAGNOSTIC_SENTINEL" + body = {"result": {"tools": [ + {"name": secret, "description": secret}, + {"name": "e2e_tools.echo"}, + ]}, "error": {"code": -32000, "message": secret, "data": {"token": secret}}, + "headers": {"Authorization": secret}} + facts = MCP.catalog_facts(200, body) + self.assertEqual(facts, {"stage": "router_catalog", "httpStatus": 200, + "rpcError": True, "rpcErrorCode": -32000, "toolCount": 2, + "expectedToolPresent": True}) + self.assertNotIn(secret, json.dumps(facts)) + self.assertNotIn("Authorization", json.dumps(facts)) + + def test_catalog_facts_handle_malformed_envelopes_without_logging_values(self): + for body in (None, [], "PRIVATE", {"result": []}, {"result": {"tools": "PRIVATE"}}, + {"result": {"tools": [None, "PRIVATE", {}]}}, + {"error": {"code": "PRIVATE", "message": "PRIVATE"}}): + with self.subTest(body=type(body).__name__): + facts = MCP.catalog_facts(None, body) + self.assertIsNone(facts["httpStatus"]) + self.assertFalse(facts["expectedToolPresent"]) + self.assertIsNone(facts["rpcErrorCode"]) + self.assertNotIn("PRIVATE", json.dumps(facts)) + + def test_catalog_timeout_retains_status_and_keeps_the_native_deadline(self): + process = MagicMock() + process.poll.return_value = None + + def exhausted(label, predicate, seconds): + self.assertEqual(label, "actual routed MCP catalog after reconciliation") + self.assertEqual(seconds, 120) + self.assertFalse(predicate()) + raise AssertionError(f"{label} did not converge") + + for status in (403, 404, 406, 503): + output = io.StringIO() + with self.subTest(status=status), \ + patch.object(MCP, "rpc", return_value=(status, None)), \ + patch.object(MCP, "wait", side_effect=exhausted), \ + contextlib.redirect_stdout(output): + with self.assertRaisesRegex(AssertionError, "did not converge"): + MCP.wait_for_catalog(12345, process) + facts = json.loads(output.getvalue().removeprefix("MCP-DIAG ")) + self.assertEqual(facts["httpStatus"], status) + self.assertEqual(facts["stage"], "router_catalog") + self.assertTrue(facts["portForwardAlive"]) + + def test_catalog_transport_failure_diagnostics_never_copy_exception_text(self): + process = MagicMock() + process.poll.return_value = None + + def exhausted(_label, predicate, seconds): + self.assertEqual(seconds, 120) + self.assertFalse(predicate()) + raise AssertionError("catalog did not converge") + + for error, classification in ((OSError("PRIVATE"), "io"), (ValueError("PRIVATE"), "invalid_json")): + output = io.StringIO() + with patch.object(MCP, "rpc", side_effect=error), \ + patch.object(MCP, "wait", side_effect=exhausted), \ + contextlib.redirect_stdout(output): + with self.assertRaisesRegex(AssertionError, "did not converge"): + MCP.wait_for_catalog(12345, process) + facts = json.loads(output.getvalue().removeprefix("MCP-DIAG ")) + self.assertEqual(facts["transportError"], classification) + self.assertNotIn("PRIVATE", output.getvalue()) + + def test_catalog_success_still_requires_exact_scoped_tool_and_no_rpc_error(self): + process = MagicMock() + process.poll.return_value = None + good = {"result": {"tools": [{"name": "e2e_tools.echo"}]}} + responses = [ + OSError("connection not ready"), + (200, {"result": {"tools": [{"name": "other.echo"}]}}), + (200, {**good, "error": {"code": -32000}}), + (200, good), + ] + + def converge(_label, predicate, seconds): + self.assertEqual(seconds, 120) + for _ in range(3): + self.assertFalse(predicate()) + return predicate() + + output = io.StringIO() + with patch.object(MCP, "rpc", side_effect=responses), \ + patch.object(MCP, "wait", side_effect=converge), \ + contextlib.redirect_stdout(output): + self.assertEqual(MCP.wait_for_catalog(12345, process), good) + self.assertEqual(output.getvalue(), "") + + +if __name__ == "__main__": + unittest.main() From 0f2dd75ad943275c9a8f749fe21bf413db80dc0d Mon Sep 17 00:00:00 2001 From: pallakatos Date: Thu, 10 Sep 2026 17:26:03 +0200 Subject: [PATCH 11/14] docs(mcp): record native client repair and real-router qualification Retain the failed hosted catalog result and its diagnostic limits. Record the reproduced JSON-only Accept defect, test-only correction, real Python-to-router regression and unchanged production source; fresh complete Kind acceptance remains mandatory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .../security-audits/2026-09-08-managed-mcp.md | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/docs/security-audits/2026-09-08-managed-mcp.md b/docs/security-audits/2026-09-08-managed-mcp.md index 2221abc5b..82df86386 100644 --- a/docs/security-audits/2026-09-08-managed-mcp.md +++ b/docs/security-audits/2026-09-08-managed-mcp.md @@ -11,7 +11,8 @@ reviews, recorded in The second attestation below is delegated AI review, not an assertion that a second human signed this audit. -The current source is `fbd01597ce1cb86fff6df71d21dc2bf07abaf528`, based on +The approved production source was qualified at +`fbd01597ce1cb86fff6df71d21dc2bf07abaf528`, based on signed governed-services source `6b34d5ea`. A focused AI security review of that comparison reported no vulnerabilities in its reviewed scope. The earlier caller-identity and lifecycle findings remain repaired as described @@ -39,6 +40,41 @@ The focused reviewer did not independently rerun tests. Current-base hosted Rust and native lifecycle/protocol acceptance remain mandatory, with no technical gate, finding, identity fence or policy denial waived. +## Native client correction and current qualification (2026-09-10) + +Published `211d0d4e209924b7cec72cdbfd00b24c96dfeb60` passed its hosted Rust +and other checks, but native job +[102915586372](https://github.com/Azure/kars/actions/runs/34487526141/job/102915586372) +failed with 168 passes and two reports of the same MCP gate failure. Both +same-named servers reached Ready with distinct UIDs and current generations; +the actual routed catalog did not converge. Legacy and fresh SRE acceptance +passed. That native result remains a failure. + +The Python fixture advertised only `application/json`, whereas the real +Streamable HTTP router requires both JSON and `text/event-stream`. The +JSON-only request is deterministically rejected with 406. The old native +collector discarded HTTP status, so that original run itself did not retain +the 406; the exact fixture/header defect was subsequently reproduced. + +Test-only repair `c56b90d4963f85df7442e3e6eafb8b99808f443e` corrects the +client header, retains bounded catalog-stage diagnostics and adds regression +coverage. Actual guarded execution passed 249 MCP library cases, 18 integration +cases, six Python cases, strict paired all-target Clippy and formatting. +The additional integration case executes the actual Python fixture client +against the real router and retains rejection of the unsupported Accept +header. Minimum free space was 9.04 GiB above the unchanged 8.5 GiB floor. + +Current source `81ee4fe02f0a99f069ce636710e7308d30881a73` also incorporates +services `920f9f0e` and the actually landed SRE ancestry. That merge changes +only three CLI test-harness files; the 39 forwarded cases and typecheck pass +with the existing compatible cache. All production bytes remain identical +to source-approved `211d0d4e`. No authentication, transport guard, readiness +requirement, UID fence or native deadline was changed. + +Fresh exact-head hosted/native lifecycle convergence is still required. +The passing Python-to-router regression is not substituted for the complete +Kind lifecycle, policy, image and cleanup acceptance. + ## Scope Closed managed Playwright/Everything presets; UID-bound namespace and resource From 3b479c95c8fc4d592bc8cc53f8209ea69aabccc6 Mon Sep 17 00:00:00 2001 From: pallakatos Date: Thu, 10 Sep 2026 18:54:09 +0200 Subject: [PATCH 12/14] chore(mcp): prepare reviewed hosted dependency lock repair Pin compatible qs 6.16.0, hono 4.13.5, and @hono/node-server 1.19.15 overrides without changing the Everything release or existing fast-uri/ip-address pins. Offline npm lock generation is blocked because cached metadata lacks hono 4.13.5; the current lock remains unchanged and vulnerable pending the separately authorized hosted run. Prepare a temporary contents-read-only Node 22 generation workflow for the existing MCP publication branch. Generate lock metadata with npm, reject unrelated graph drift, verify tarball integrity with script-disabled npm ci, require zero audit findings, and exercise the real Everything HTTP initialization/catalog/echo/session-close before uploading only the manifest, lock, and bounded provenance. No workflow push or dispatch is performed by this commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/mcp-everything-lock.yml | 80 +++++++++++++++++++ sandbox-images/mcp-everything/package.json | 5 +- .../mcp-everything/protocol-smoke.mjs | 80 +++++++++++++++++++ sandbox-images/mcp-everything/verify-lock.mjs | 73 +++++++++++++++++ 4 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/mcp-everything-lock.yml create mode 100644 sandbox-images/mcp-everything/protocol-smoke.mjs create mode 100644 sandbox-images/mcp-everything/verify-lock.mjs diff --git a/.github/workflows/mcp-everything-lock.yml b/.github/workflows/mcp-everything-lock.yml new file mode 100644 index 000000000..190998c19 --- /dev/null +++ b/.github/workflows/mcp-everything-lock.yml @@ -0,0 +1,80 @@ +name: Reviewed Everything dependency lock + +# Temporary, artifact-only generation. Remove after importing the verified lock. +"on": + workflow_dispatch: + push: + branches: [public/pr9-managed-mcp] + paths: + - .github/workflows/mcp-everything-lock.yml + - sandbox-images/mcp-everything/package.json + - sandbox-images/mcp-everything/verify-lock.mjs + - sandbox-images/mcp-everything/protocol-smoke.mjs + +permissions: + contents: read + +jobs: + generate: + if: github.repository == 'Azure/kars' && github.ref == 'refs/heads/public/pr9-managed-mcp' + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: sandbox-images/mcp-everything + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + package-manager-cache: false + - name: Generate only the reviewed lock + run: npm install --package-lock-only --ignore-scripts --no-audit --no-fund + - name: Verify exact replacements and preserve unrelated lock entries + run: node verify-lock.mjs + - name: Verify tarball integrity through a clean script-disabled install + run: npm ci --omit=dev --ignore-scripts --no-audit --no-fund + - name: Reject remaining advisories at every severity + run: npm audit --omit=dev --audit-level=low --json > dependency-audit.json + - name: Exercise the actual Everything Streamable HTTP server + run: node protocol-smoke.mjs > protocol-smoke.json + - name: Record bounded artifact provenance + run: | + node --input-type=module <<'JS' + import assert from "node:assert/strict"; + import { execFileSync } from "node:child_process"; + import { createHash } from "node:crypto"; + import { readFileSync, writeFileSync } from "node:fs"; + import { verifyLock } from "./verify-lock.mjs"; + const checked = verifyLock(); + const sourceSha = execFileSync("git", ["rev-parse", "HEAD"], {encoding:"utf8"}).trim(); + assert.match(sourceSha, /^[0-9a-f]{40}$/); + assert.equal(sourceSha, process.env.GITHUB_SHA); + assert.match(process.env.GITHUB_RUN_ID, /^\d+$/); + const audit = JSON.parse(readFileSync("dependency-audit.json", "utf8")); + assert.equal(audit.metadata.vulnerabilities.total, 0); + const smoke = JSON.parse(readFileSync("protocol-smoke.json", "utf8")); + for (const key of ["initialized", "toolsListed", "echoPassed", "sessionClosed"]) { + assert.equal(smoke[key], true); + } + assert.ok(Number.isSafeInteger(smoke.toolCount) && smoke.toolCount > 0); + const hashes = Object.fromEntries(["package.json", "package-lock.json"].map(name => + [name, createHash("sha256").update(readFileSync(name)).digest("hex")])); + const provenance = {sourceSha, runId:process.env.GITHUB_RUN_ID, + node:process.versions.node, npm:execFileSync("npm", ["--version"], {encoding:"utf8"}).trim(), + ...checked, hashes, npmCiIntegrityPassed:true, auditVulnerabilities:0, + protocolSmoke:{initialized:true,toolsListed:true,echoPassed:true,sessionClosed:true, + toolCount:smoke.toolCount}}; + writeFileSync("lock-provenance.json", JSON.stringify(provenance, null, 2) + "\n"); + JS + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + with: + name: mcp-everything-reviewed-lock + if-no-files-found: error + retention-days: 7 + path: | + sandbox-images/mcp-everything/package.json + sandbox-images/mcp-everything/package-lock.json + sandbox-images/mcp-everything/lock-provenance.json diff --git a/sandbox-images/mcp-everything/package.json b/sandbox-images/mcp-everything/package.json index b2fc50ad0..d4a20b1b6 100644 --- a/sandbox-images/mcp-everything/package.json +++ b/sandbox-images/mcp-everything/package.json @@ -7,7 +7,10 @@ "@modelcontextprotocol/server-everything": "2026.7.4" }, "overrides": { + "@hono/node-server": "1.19.15", "fast-uri": "3.1.6", - "ip-address": "10.3.1" + "hono": "4.13.5", + "ip-address": "10.3.1", + "qs": "6.16.0" } } diff --git a/sandbox-images/mcp-everything/protocol-smoke.mjs b/sandbox-images/mcp-everything/protocol-smoke.mjs new file mode 100644 index 000000000..1be9e0c58 --- /dev/null +++ b/sandbox-images/mcp-everything/protocol-smoke.mjs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { createConnection, createServer } from "node:net"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +const directory = fileURLToPath(new URL(".", import.meta.url)); +const reservation = createServer(); +reservation.listen(0, "127.0.0.1"); +await once(reservation, "listening"); +const port = reservation.address().port; +await new Promise((resolve) => reservation.close(resolve)); +const child = spawn(process.execPath, [ + "node_modules/.bin/mcp-server-everything", "streamableHttp", +], { cwd: directory, env: { ...process.env, PORT: String(port) }, stdio: "ignore" }); +let childFailed = false; +child.on("error", () => { childFailed = true; }); +const exited = new Promise((resolve) => { + child.once("exit", resolve); + child.once("error", resolve); +}); +const client = new Client({ name: "kars-dependency-smoke", version: "1.0.0" }); +const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`)); +let stage = "startup"; +let timer; + +async function exercise() { + while (true) { + assert.ok(!childFailed && child.exitCode === null, "Everything process exited"); + const listening = await new Promise((resolve) => { + const socket = createConnection({ host: "127.0.0.1", port }); + const done = (ready) => { socket.destroy(); resolve(ready); }; + socket.once("connect", () => done(true)); + socket.once("error", () => done(false)); + socket.setTimeout(500, () => done(false)); + }); + if (listening) break; + await delay(100); + } + stage = "initialize"; + await client.connect(transport); + stage = "tools-list"; + const catalog = await client.listTools(); + assert.ok(catalog.tools.some((tool) => tool.name === "echo")); + stage = "echo"; + const marker = "kars-dependency-smoke"; + const result = await client.callTool({ name: "echo", arguments: { message: marker } }); + assert.notEqual(result.isError, true); + assert.ok(result.content.some((item) => item.type === "text" && item.text.includes(marker))); + stage = "session-close"; + await transport.terminateSession(); + await client.close(); + return { initialized: true, toolsListed: true, echoPassed: true, sessionClosed: true, + toolCount: catalog.tools.length }; +} + +try { + const result = await Promise.race([ + exercise(), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("Protocol smoke deadline exceeded")), 45_000); + }), + ]); + console.log(JSON.stringify(result)); +} catch { + console.error(`Everything protocol smoke failed at ${stage}; no server output/body logged`); + process.exitCode = 1; +} finally { + clearTimeout(timer); + child.kill("SIGTERM"); + await Promise.race([exited, delay(2_000)]); + if (!childFailed && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + await Promise.race([client.close(), delay(1_000)]).catch(() => {}); +} diff --git a/sandbox-images/mcp-everything/verify-lock.mjs b/sandbox-images/mcp-everything/verify-lock.mjs new file mode 100644 index 000000000..0928b7f8e --- /dev/null +++ b/sandbox-images/mcp-everything/verify-lock.mjs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const directory = fileURLToPath(new URL(".", import.meta.url)); +const read = (name) => JSON.parse(readFileSync(new URL(name, import.meta.url), "utf8")); +const baseline = (name) => JSON.parse(execFileSync("git", [ + "show", `HEAD:sandbox-images/mcp-everything/${name}`, +], { cwd: directory, encoding: "utf8" })); + +const fixed = { + "@hono/node-server": "1.19.15", + hono: "4.13.5", + qs: "6.16.0", +}; +const preserved = { + "@modelcontextprotocol/server-everything": "2026.7.4", + "fast-uri": "3.1.6", + "ip-address": "10.3.1", +}; + +export function verifyLock() { + const manifest = read("package.json"); + const lock = read("package-lock.json"); + const previous = baseline("package-lock.json"); + assert.deepEqual(manifest, baseline("package.json"), "Generation changed the reviewed manifest"); + assert.deepEqual(manifest.dependencies, { + "@modelcontextprotocol/server-everything": preserved["@modelcontextprotocol/server-everything"], + }); + assert.deepEqual(manifest.overrides, { + ...fixed, "fast-uri": preserved["fast-uri"], "ip-address": preserved["ip-address"], + }); + assert.equal(lock.lockfileVersion, 3); + assert.deepEqual(lock.packages[""].dependencies, manifest.dependencies); + assert.deepEqual(Object.keys(lock.packages).sort(), Object.keys(previous.packages).sort(), + "Dependency graph changed beyond the reviewed replacements"); + + const versions = { ...fixed, ...preserved }; + const counts = Object.fromEntries(Object.keys(versions).map((name) => [name, 0])); + for (const [path, entry] of Object.entries(lock.packages)) { + const name = path.slice(path.lastIndexOf("node_modules/") + "node_modules/".length); + if (!Object.hasOwn(fixed, name)) { + assert.deepEqual(entry, previous.packages[path], `Unrelated lock entry changed: ${path}`); + } + if (Object.hasOwn(versions, name)) { + assert.equal(entry.version, versions[name], `Unexpected locked version: ${path}`); + counts[name]++; + } + if (path) { + const url = new URL(entry.resolved); + assert.equal(url.protocol, "https:"); + assert.equal(url.hostname, "registry.npmjs.org"); + assert.equal(url.username + url.password + url.search + url.hash, ""); + assert.match(entry.integrity, /^sha512-[A-Za-z0-9+/]+={0,2}$/); + const encoded = entry.integrity.slice("sha512-".length); + const digest = Buffer.from(encoded, "base64"); + assert.equal(digest.length, 64); + assert.equal(digest.toString("base64"), encoded); + } + } + for (const name of Object.keys(versions)) { + assert.ok(counts[name] > 0, `Required package missing: ${name}`); + } + return { versions, packageCount: Object.keys(lock.packages).length - 1 }; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + console.log(JSON.stringify(verifyLock())); +} From 30d89dc23ddadf2afc9805251e7f74593ea92814 Mon Sep 17 00:00:00 2001 From: pallakatos Date: Thu, 10 Sep 2026 19:06:47 +0200 Subject: [PATCH 13/14] test(mcp): retain fatal lock-generation diagnostics and bounded cleanup Keep approved artifacts success-only, retain failed lock attempts under an explicitly unverified name, expose bounded audit counts without bodies, and print protocol success only after owned-process cleanup. Verify exact reviewed package tarball paths. The existing lock is still vulnerable until hosted generation succeeds; this is not a qualified dependency candidate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/mcp-everything-lock.yml | 30 +++++++++++++++- .../mcp-everything/protocol-smoke.mjs | 36 ++++++++++++++++--- sandbox-images/mcp-everything/verify-lock.mjs | 4 +++ 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/.github/workflows/mcp-everything-lock.yml b/.github/workflows/mcp-everything-lock.yml index 190998c19..733ee93a1 100644 --- a/.github/workflows/mcp-everything-lock.yml +++ b/.github/workflows/mcp-everything-lock.yml @@ -37,7 +37,25 @@ jobs: - name: Verify tarball integrity through a clean script-disabled install run: npm ci --omit=dev --ignore-scripts --no-audit --no-fund - name: Reject remaining advisories at every severity - run: npm audit --omit=dev --audit-level=low --json > dependency-audit.json + run: | + set +e + npm audit --omit=dev --audit-level=low --json > dependency-audit.json + status=$? + set -e + node --input-type=module <<'JS' + import assert from "node:assert/strict"; + import { readFileSync } from "node:fs"; + const audit = JSON.parse(readFileSync("dependency-audit.json", "utf8")); + assert.ok(audit.metadata?.vulnerabilities, "Audit vulnerability metadata unavailable"); + const counts = {}; + for (const key of ["info", "low", "moderate", "high", "critical", "total"]) { + const value = audit.metadata.vulnerabilities[key]; + assert.ok(Number.isSafeInteger(value) && value >= 0); + counts[key] = value; + } + console.log(JSON.stringify({auditVulnerabilities: counts})); + JS + exit "$status" - name: Exercise the actual Everything Streamable HTTP server run: node protocol-smoke.mjs > protocol-smoke.json - name: Record bounded artifact provenance @@ -78,3 +96,13 @@ jobs: sandbox-images/mcp-everything/package.json sandbox-images/mcp-everything/package-lock.json sandbox-images/mcp-everything/lock-provenance.json + - name: Retain unverified inputs only when generation fails + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 + with: + name: mcp-everything-unverified-lock + if-no-files-found: error + retention-days: 7 + path: | + sandbox-images/mcp-everything/package.json + sandbox-images/mcp-everything/package-lock.json diff --git a/sandbox-images/mcp-everything/protocol-smoke.mjs b/sandbox-images/mcp-everything/protocol-smoke.mjs index 1be9e0c58..015704594 100644 --- a/sandbox-images/mcp-everything/protocol-smoke.mjs +++ b/sandbox-images/mcp-everything/protocol-smoke.mjs @@ -29,10 +29,13 @@ const client = new Client({ name: "kars-dependency-smoke", version: "1.0.0" }); const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`)); let stage = "startup"; let timer; +let stopped = false; +let clientClosed = false; async function exercise() { while (true) { - assert.ok(!childFailed && child.exitCode === null, "Everything process exited"); + assert.ok(!stopped && !childFailed && child.exitCode === null && child.signalCode === null, + "Everything process stopped"); const listening = await new Promise((resolve) => { const socket = createConnection({ host: "127.0.0.1", port }); const done = (ready) => { socket.destroy(); resolve(ready); }; @@ -56,25 +59,48 @@ async function exercise() { stage = "session-close"; await transport.terminateSession(); await client.close(); + clientClosed = true; + assert.ok(!childFailed && child.exitCode === null && child.signalCode === null, + "Everything process exited during the protocol check"); return { initialized: true, toolsListed: true, echoPassed: true, sessionClosed: true, toolCount: catalog.tools.length }; } +let result; try { - const result = await Promise.race([ + result = await Promise.race([ exercise(), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Protocol smoke deadline exceeded")), 45_000); }), ]); - console.log(JSON.stringify(result)); } catch { console.error(`Everything protocol smoke failed at ${stage}; no server output/body logged`); process.exitCode = 1; } finally { + stopped = true; clearTimeout(timer); + if (!clientClosed) { + try { + const closed = await Promise.race([ + client.close().then(() => true), + delay(1_000).then(() => false), + ]); + assert.ok(closed, "Client cleanup deadline exceeded"); + } catch { + console.error("Everything protocol smoke client cleanup failed; no response/body logged"); + process.exitCode = 1; + } + } child.kill("SIGTERM"); await Promise.race([exited, delay(2_000)]); - if (!childFailed && child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); - await Promise.race([client.close(), delay(1_000)]).catch(() => {}); + if (!childFailed && child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + await Promise.race([exited, delay(1_000)]); + } + if (!childFailed && child.exitCode === null && child.signalCode === null) { + console.error("Everything protocol smoke child did not stop"); + process.exitCode = 1; + } } +if (!process.exitCode) console.log(JSON.stringify(result)); diff --git a/sandbox-images/mcp-everything/verify-lock.mjs b/sandbox-images/mcp-everything/verify-lock.mjs index 0928b7f8e..961af7a37 100644 --- a/sandbox-images/mcp-everything/verify-lock.mjs +++ b/sandbox-images/mcp-everything/verify-lock.mjs @@ -55,6 +55,10 @@ export function verifyLock() { assert.equal(url.protocol, "https:"); assert.equal(url.hostname, "registry.npmjs.org"); assert.equal(url.username + url.password + url.search + url.hash, ""); + if (Object.hasOwn(fixed, name)) { + assert.equal(url.href, + `https://registry.npmjs.org/${name}/-/${name.split("/").at(-1)}-${fixed[name]}.tgz`); + } assert.match(entry.integrity, /^sha512-[A-Za-z0-9+/]+={0,2}$/); const encoded = entry.integrity.slice("sha512-".length); const digest = Buffer.from(encoded, "base64"); From 0dd1e8cb40ed344c434f862caf30a26e52563fb6 Mon Sep 17 00:00:00 2001 From: pallakatos Date: Thu, 10 Sep 2026 19:15:57 +0200 Subject: [PATCH 14/14] fix(mcp): close reviewed Everything dependency advisories Import only npm-generated qs6.16.0, hono4.13.5 and node-server1.19.15 replacements, preserving every other lock entry and dependency edge. Exact read-only hosted source30d89/run34506209770 passed clean script-disabled npm integrity installation, all-severity audit with zero findings, and actual Everything initialize/list13tools/echo/session-close/cleanup. Independently verify GitHub ZIP/file hashes and manifest/source/run provenance. Remove temporary generator/workflow scripts and retain immutable evidence in the audit. Rust/protocol production source is unchanged from qualified6f513; final-head scans/native qualification and truthful review resolution remain required. No advisory waiver, local npm network workaround, image release or deployment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3654cd-f1e0-445a-8734-430800af1903 --- .github/workflows/mcp-everything-lock.yml | 108 ------------------ .../security-audits/2026-09-08-managed-mcp.md | 47 ++++++++ .../mcp-everything/package-lock.json | 18 +-- .../mcp-everything/protocol-smoke.mjs | 106 ----------------- sandbox-images/mcp-everything/verify-lock.mjs | 77 ------------- 5 files changed, 56 insertions(+), 300 deletions(-) delete mode 100644 .github/workflows/mcp-everything-lock.yml delete mode 100644 sandbox-images/mcp-everything/protocol-smoke.mjs delete mode 100644 sandbox-images/mcp-everything/verify-lock.mjs diff --git a/.github/workflows/mcp-everything-lock.yml b/.github/workflows/mcp-everything-lock.yml deleted file mode 100644 index 733ee93a1..000000000 --- a/.github/workflows/mcp-everything-lock.yml +++ /dev/null @@ -1,108 +0,0 @@ -name: Reviewed Everything dependency lock - -# Temporary, artifact-only generation. Remove after importing the verified lock. -"on": - workflow_dispatch: - push: - branches: [public/pr9-managed-mcp] - paths: - - .github/workflows/mcp-everything-lock.yml - - sandbox-images/mcp-everything/package.json - - sandbox-images/mcp-everything/verify-lock.mjs - - sandbox-images/mcp-everything/protocol-smoke.mjs - -permissions: - contents: read - -jobs: - generate: - if: github.repository == 'Azure/kars' && github.ref == 'refs/heads/public/pr9-managed-mcp' - runs-on: ubuntu-latest - timeout-minutes: 10 - defaults: - run: - working-directory: sandbox-images/mcp-everything - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: "22" - package-manager-cache: false - - name: Generate only the reviewed lock - run: npm install --package-lock-only --ignore-scripts --no-audit --no-fund - - name: Verify exact replacements and preserve unrelated lock entries - run: node verify-lock.mjs - - name: Verify tarball integrity through a clean script-disabled install - run: npm ci --omit=dev --ignore-scripts --no-audit --no-fund - - name: Reject remaining advisories at every severity - run: | - set +e - npm audit --omit=dev --audit-level=low --json > dependency-audit.json - status=$? - set -e - node --input-type=module <<'JS' - import assert from "node:assert/strict"; - import { readFileSync } from "node:fs"; - const audit = JSON.parse(readFileSync("dependency-audit.json", "utf8")); - assert.ok(audit.metadata?.vulnerabilities, "Audit vulnerability metadata unavailable"); - const counts = {}; - for (const key of ["info", "low", "moderate", "high", "critical", "total"]) { - const value = audit.metadata.vulnerabilities[key]; - assert.ok(Number.isSafeInteger(value) && value >= 0); - counts[key] = value; - } - console.log(JSON.stringify({auditVulnerabilities: counts})); - JS - exit "$status" - - name: Exercise the actual Everything Streamable HTTP server - run: node protocol-smoke.mjs > protocol-smoke.json - - name: Record bounded artifact provenance - run: | - node --input-type=module <<'JS' - import assert from "node:assert/strict"; - import { execFileSync } from "node:child_process"; - import { createHash } from "node:crypto"; - import { readFileSync, writeFileSync } from "node:fs"; - import { verifyLock } from "./verify-lock.mjs"; - const checked = verifyLock(); - const sourceSha = execFileSync("git", ["rev-parse", "HEAD"], {encoding:"utf8"}).trim(); - assert.match(sourceSha, /^[0-9a-f]{40}$/); - assert.equal(sourceSha, process.env.GITHUB_SHA); - assert.match(process.env.GITHUB_RUN_ID, /^\d+$/); - const audit = JSON.parse(readFileSync("dependency-audit.json", "utf8")); - assert.equal(audit.metadata.vulnerabilities.total, 0); - const smoke = JSON.parse(readFileSync("protocol-smoke.json", "utf8")); - for (const key of ["initialized", "toolsListed", "echoPassed", "sessionClosed"]) { - assert.equal(smoke[key], true); - } - assert.ok(Number.isSafeInteger(smoke.toolCount) && smoke.toolCount > 0); - const hashes = Object.fromEntries(["package.json", "package-lock.json"].map(name => - [name, createHash("sha256").update(readFileSync(name)).digest("hex")])); - const provenance = {sourceSha, runId:process.env.GITHUB_RUN_ID, - node:process.versions.node, npm:execFileSync("npm", ["--version"], {encoding:"utf8"}).trim(), - ...checked, hashes, npmCiIntegrityPassed:true, auditVulnerabilities:0, - protocolSmoke:{initialized:true,toolsListed:true,echoPassed:true,sessionClosed:true, - toolCount:smoke.toolCount}}; - writeFileSync("lock-provenance.json", JSON.stringify(provenance, null, 2) + "\n"); - JS - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 - with: - name: mcp-everything-reviewed-lock - if-no-files-found: error - retention-days: 7 - path: | - sandbox-images/mcp-everything/package.json - sandbox-images/mcp-everything/package-lock.json - sandbox-images/mcp-everything/lock-provenance.json - - name: Retain unverified inputs only when generation fails - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4 - with: - name: mcp-everything-unverified-lock - if-no-files-found: error - retention-days: 7 - path: | - sandbox-images/mcp-everything/package.json - sandbox-images/mcp-everything/package-lock.json diff --git a/docs/security-audits/2026-09-08-managed-mcp.md b/docs/security-audits/2026-09-08-managed-mcp.md index 82df86386..69dc5f618 100644 --- a/docs/security-audits/2026-09-08-managed-mcp.md +++ b/docs/security-audits/2026-09-08-managed-mcp.md @@ -75,6 +75,53 @@ Fresh exact-head hosted/native lifecycle convergence is still required. The passing Python-to-router regression is not substituted for the complete Kind lifecycle, policy, image and cleanup acceptance. +## Transitive dependency repair after the guarded landing check + +Current-base source `6f513c49d0be1b38b6464dbc6f5959b561d65a41` subsequently +passed full CI `34497050687`, including native lifecycle/protocol acceptance. +The landing guard still stopped before any temporary review allowance or +merge: ten unresolved GHAS/Trivy conversations identified genuinely unpatched +Everything image dependencies. Green high/critical gates were not treated as +permission to waive medium/low findings. + +The compatible exact replacements are: + +| Package | Previous | Patched | +|---|---|---| +| `@hono/node-server` | 1.19.14 | 1.19.15 | +| `hono` | 4.12.29 | 4.13.5 | +| `qs` | 6.15.3 | 6.16.0 | + +These address alert743, alerts791-797 and alerts801-802. The server package +remains2026.7.4, fast-uri3.1.6 and ip-address10.3.1 remain pinned, and all +unrelated lock entries and dependency edges are byte-for-byte unchanged. +Only the version, canonical registry URL and integrity value changed in each +of the three reviewed lock entries. + +The local offline cache lacked the required Hono metadata. No local network +workaround or manually invented integrity hash was used. A temporary, +read-only, fixed-branch workflow generated the lock normally on GitHub: +[run34506209770](https://github.com/Azure/kars/actions/runs/34506209770), +exact source `30d89dc23ddadf2afc9805251e7f74593ea92814`. +Node22.23.2/npm10.9.8 generated it, a clean script-disabled npm install +verified package integrity, npm audit reported zero vulnerabilities across +all severities, and the actual Everything Streamable HTTP server passed +initialization, listing13tools, echo and session-close/owned-process cleanup. + +The importer independently verified source/run identity, the unchanged +reviewed manifest, all version pins, the three-entry-only diff and both file +hashes. Artifact10163921178's ZIP SHA256 is +`867fcda1f48c929a97f18a4977adad3009547717884c8b1947beb26c2d1394f9`; +the imported lock SHA256 is +`9f5fe73ea91d98215b8377ae4bd9ba0d30fc87d42b8f42b760a832718ba83f8c`. +Only manifest, lock and bounded provenance were present in the artifact. + +The temporary generator and its one-off scripts are removed from the final +candidate. Their immutable generation commit and run retain the evidence. +No private source, SDK probe, credentials, deployment or image release was +involved. A fresh final-head pipeline and truthful resolution of the specific +review conversations remain required before the guarded integration merge. + ## Scope Closed managed Playwright/Everything presets; UID-bound namespace and resource diff --git a/sandbox-images/mcp-everything/package-lock.json b/sandbox-images/mcp-everything/package-lock.json index 8346c3397..655711a8a 100644 --- a/sandbox-images/mcp-everything/package-lock.json +++ b/sandbox-images/mcp-everything/package-lock.json @@ -12,9 +12,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "1.19.15", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.15.tgz", + "integrity": "sha512-Za2ai6TLdKjUvnur+eenO6nuYYipVAEhyCAdaV8IRvmU9kK8crOZUSYvIXn72E4f8fJqyAbpcJuTsYYmZp9Deg==", "license": "MIT", "engines": { "node": ">=18.14.1" @@ -603,9 +603,9 @@ } }, "node_modules/hono": { - "version": "4.12.29", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.29.tgz", - "integrity": "sha512-1hNiRjawYrLq/4m3DQQjPGFg0VZkk4RjQJDff/excI6Dm9BiL75qxGrd7/c6YOxPdq6AscP3LiXhQ6fKFC1Waw==", + "version": "4.13.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz", + "integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -912,9 +912,9 @@ } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", diff --git a/sandbox-images/mcp-everything/protocol-smoke.mjs b/sandbox-images/mcp-everything/protocol-smoke.mjs deleted file mode 100644 index 015704594..000000000 --- a/sandbox-images/mcp-everything/protocol-smoke.mjs +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import { once } from "node:events"; -import { createConnection, createServer } from "node:net"; -import { setTimeout as delay } from "node:timers/promises"; -import { fileURLToPath } from "node:url"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; - -const directory = fileURLToPath(new URL(".", import.meta.url)); -const reservation = createServer(); -reservation.listen(0, "127.0.0.1"); -await once(reservation, "listening"); -const port = reservation.address().port; -await new Promise((resolve) => reservation.close(resolve)); -const child = spawn(process.execPath, [ - "node_modules/.bin/mcp-server-everything", "streamableHttp", -], { cwd: directory, env: { ...process.env, PORT: String(port) }, stdio: "ignore" }); -let childFailed = false; -child.on("error", () => { childFailed = true; }); -const exited = new Promise((resolve) => { - child.once("exit", resolve); - child.once("error", resolve); -}); -const client = new Client({ name: "kars-dependency-smoke", version: "1.0.0" }); -const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`)); -let stage = "startup"; -let timer; -let stopped = false; -let clientClosed = false; - -async function exercise() { - while (true) { - assert.ok(!stopped && !childFailed && child.exitCode === null && child.signalCode === null, - "Everything process stopped"); - const listening = await new Promise((resolve) => { - const socket = createConnection({ host: "127.0.0.1", port }); - const done = (ready) => { socket.destroy(); resolve(ready); }; - socket.once("connect", () => done(true)); - socket.once("error", () => done(false)); - socket.setTimeout(500, () => done(false)); - }); - if (listening) break; - await delay(100); - } - stage = "initialize"; - await client.connect(transport); - stage = "tools-list"; - const catalog = await client.listTools(); - assert.ok(catalog.tools.some((tool) => tool.name === "echo")); - stage = "echo"; - const marker = "kars-dependency-smoke"; - const result = await client.callTool({ name: "echo", arguments: { message: marker } }); - assert.notEqual(result.isError, true); - assert.ok(result.content.some((item) => item.type === "text" && item.text.includes(marker))); - stage = "session-close"; - await transport.terminateSession(); - await client.close(); - clientClosed = true; - assert.ok(!childFailed && child.exitCode === null && child.signalCode === null, - "Everything process exited during the protocol check"); - return { initialized: true, toolsListed: true, echoPassed: true, sessionClosed: true, - toolCount: catalog.tools.length }; -} - -let result; -try { - result = await Promise.race([ - exercise(), - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error("Protocol smoke deadline exceeded")), 45_000); - }), - ]); -} catch { - console.error(`Everything protocol smoke failed at ${stage}; no server output/body logged`); - process.exitCode = 1; -} finally { - stopped = true; - clearTimeout(timer); - if (!clientClosed) { - try { - const closed = await Promise.race([ - client.close().then(() => true), - delay(1_000).then(() => false), - ]); - assert.ok(closed, "Client cleanup deadline exceeded"); - } catch { - console.error("Everything protocol smoke client cleanup failed; no response/body logged"); - process.exitCode = 1; - } - } - child.kill("SIGTERM"); - await Promise.race([exited, delay(2_000)]); - if (!childFailed && child.exitCode === null && child.signalCode === null) { - child.kill("SIGKILL"); - await Promise.race([exited, delay(1_000)]); - } - if (!childFailed && child.exitCode === null && child.signalCode === null) { - console.error("Everything protocol smoke child did not stop"); - process.exitCode = 1; - } -} -if (!process.exitCode) console.log(JSON.stringify(result)); diff --git a/sandbox-images/mcp-everything/verify-lock.mjs b/sandbox-images/mcp-everything/verify-lock.mjs deleted file mode 100644 index 961af7a37..000000000 --- a/sandbox-images/mcp-everything/verify-lock.mjs +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; - -const directory = fileURLToPath(new URL(".", import.meta.url)); -const read = (name) => JSON.parse(readFileSync(new URL(name, import.meta.url), "utf8")); -const baseline = (name) => JSON.parse(execFileSync("git", [ - "show", `HEAD:sandbox-images/mcp-everything/${name}`, -], { cwd: directory, encoding: "utf8" })); - -const fixed = { - "@hono/node-server": "1.19.15", - hono: "4.13.5", - qs: "6.16.0", -}; -const preserved = { - "@modelcontextprotocol/server-everything": "2026.7.4", - "fast-uri": "3.1.6", - "ip-address": "10.3.1", -}; - -export function verifyLock() { - const manifest = read("package.json"); - const lock = read("package-lock.json"); - const previous = baseline("package-lock.json"); - assert.deepEqual(manifest, baseline("package.json"), "Generation changed the reviewed manifest"); - assert.deepEqual(manifest.dependencies, { - "@modelcontextprotocol/server-everything": preserved["@modelcontextprotocol/server-everything"], - }); - assert.deepEqual(manifest.overrides, { - ...fixed, "fast-uri": preserved["fast-uri"], "ip-address": preserved["ip-address"], - }); - assert.equal(lock.lockfileVersion, 3); - assert.deepEqual(lock.packages[""].dependencies, manifest.dependencies); - assert.deepEqual(Object.keys(lock.packages).sort(), Object.keys(previous.packages).sort(), - "Dependency graph changed beyond the reviewed replacements"); - - const versions = { ...fixed, ...preserved }; - const counts = Object.fromEntries(Object.keys(versions).map((name) => [name, 0])); - for (const [path, entry] of Object.entries(lock.packages)) { - const name = path.slice(path.lastIndexOf("node_modules/") + "node_modules/".length); - if (!Object.hasOwn(fixed, name)) { - assert.deepEqual(entry, previous.packages[path], `Unrelated lock entry changed: ${path}`); - } - if (Object.hasOwn(versions, name)) { - assert.equal(entry.version, versions[name], `Unexpected locked version: ${path}`); - counts[name]++; - } - if (path) { - const url = new URL(entry.resolved); - assert.equal(url.protocol, "https:"); - assert.equal(url.hostname, "registry.npmjs.org"); - assert.equal(url.username + url.password + url.search + url.hash, ""); - if (Object.hasOwn(fixed, name)) { - assert.equal(url.href, - `https://registry.npmjs.org/${name}/-/${name.split("/").at(-1)}-${fixed[name]}.tgz`); - } - assert.match(entry.integrity, /^sha512-[A-Za-z0-9+/]+={0,2}$/); - const encoded = entry.integrity.slice("sha512-".length); - const digest = Buffer.from(encoded, "base64"); - assert.equal(digest.length, 64); - assert.equal(digest.toString("base64"), encoded); - } - } - for (const name of Object.keys(versions)) { - assert.ok(counts[name] > 0, `Required package missing: ${name}`); - } - return { versions, packageCount: Object.keys(lock.packages).length - 1 }; -} - -if (process.argv[1] === fileURLToPath(import.meta.url)) { - console.log(JSON.stringify(verifyLock())); -}