diff --git a/cli/src/commands/push-apply.ts b/cli/src/commands/push-apply.ts index 81202b82c..dee8b27f3 100644 --- a/cli/src/commands/push-apply.ts +++ b/cli/src/commands/push-apply.ts @@ -11,6 +11,7 @@ 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"; import { assertSafeMutation } from "../lib/sre-authority.js"; export interface PushApplyResult { @@ -18,6 +19,7 @@ export interface PushApplyResult { buildOnly: string[]; updatedSandboxes: number; preservedOverrides: number; + updatedManagedMcp?: number; } /** Update owning defaults, prove their selected artifact references, then ask @@ -48,6 +50,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); @@ -82,6 +85,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..27aad25d5 --- /dev/null +++ b/controller/src/mcp_server_reconciler/managed/probe.rs @@ -0,0 +1,267 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +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(mut 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 bytes = Vec::new(); + 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()); + } + 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..43514d197 --- /dev/null +++ b/controller/src/reconciler/mcp_binding.rs @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::{ + crd::{GovernanceConfig, KarsSandbox}, + mcp_server::McpServer, +}; +use k8s_openapi::api::apps::v1::Deployment; +use kube::{Api, Client, ResourceExt}; + +pub(super) struct Binding { + pub endpoint: Option, + pub signing: bool, + 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), + _ => "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 a56d50ce6..417adad6e 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; @@ -2352,21 +2353,14 @@ async fn reconcile(sandbox: Arc, ctx: Arc) -> Result = Vec::with_capacity(mcp_refs.len()); - for (idx, mcp_ref) in mcp_refs.iter().enumerate() { - let mcp_name = mcp_ref.name.trim(); - if mcp_name.is_empty() { - continue; - } + 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_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"); let signing_secret = format!("mcp-{mcp_name}-signing"); let jwks_volume = format!("mcp-jwks-{mcp_name}"); @@ -2389,7 +2383,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 @@ -2675,6 +2672,7 @@ 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..4ae756e7b 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 @@ -102,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/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..69dc5f618 --- /dev/null +++ b/docs/security-audits/2026-09-08-managed-mcp.md @@ -0,0 +1,197 @@ +# Managed MCP capability audit — 2026-09-08 + +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 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 +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. + +## 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. + +## 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 +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 now includes the exact governed-service prerequisite `068ae160`. +Composition preserves both SRE privacy and MCP rollout annotations, runs SRE +mutation preflight before image application, and retains mandatory SRE migration +before the managed-resource acceptance phase. The composed source passed 315 +selected unit cases, 17 HTTP cases, strict paired Clippy, CLI type checking and +53 image/application CLI cases. + +The candidate still needs hosted 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 + +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. 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. diff --git a/docs/tutorials/managed-mcp.md b/docs/tutorials/managed-mcp.md new file mode 100644 index 000000000..4c35ad0f7 --- /dev/null +++ b/docs/tutorials/managed-mcp.md @@ -0,0 +1,159 @@ +# 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. +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 +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 0bec3fcae..936648067 100644 --- a/inference-router/src/main.rs +++ b/inference-router/src/main.rs @@ -459,6 +459,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) @@ -466,7 +471,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), @@ -641,6 +646,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; @@ -694,8 +704,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/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 be929a3a3..959c83d1d 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 @@ -953,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() }); } @@ -973,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( @@ -1115,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, @@ -1173,6 +1151,7 @@ mod tests { url: url.to_string(), allowed_tools: allowed.into_iter().map(String::from).collect(), bearer_from_env: String::new(), + source_uid: None, }), } } @@ -1197,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(); @@ -1259,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()) @@ -1540,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 { @@ -1581,25 +1718,34 @@ 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] else { + panic!("expected text content"); + }; + 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] @@ -1654,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")); } @@ -1864,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); @@ -1903,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")); } @@ -1932,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. @@ -1976,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/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..64cc7d27d 100644 --- a/inference-router/src/mcp/mod.rs +++ b/inference-router/src/mcp/mod.rs @@ -45,8 +45,10 @@ //! 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; pub mod initialize; pub mod jsonrpc; pub mod oauth; @@ -54,6 +56,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/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/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/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 7066d1230..bfb695caf 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,94 @@ 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("ok")], + is_error: false, + ..Default::default() + }) + } + } + 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/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 6fb55ec7a..4492ad7c4 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 @@ -518,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 { @@ -543,8 +550,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..b8ccd0696 --- /dev/null +++ b/inference-router/tests/managed_mcp.rs @@ -0,0 +1,352 @@ +// 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("ok")], + is_error: false, + ..Default::default() + }) + } +} +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 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(); + 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..93c613159 --- /dev/null +++ b/runtimes/hermes/src/kars_runtime_hermes/plugin/mcp_bridge.py @@ -0,0 +1,146 @@ +# 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: + 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") + + +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..adf23b1aa --- /dev/null +++ b/runtimes/hermes/tests/test_mcp_bridge.py @@ -0,0 +1,116 @@ +# 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..655711a8a --- /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.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" + }, + "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.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "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.13.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.5.tgz", + "integrity": "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==", + "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.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" + } + }, + "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.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", + "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..d4a20b1b6 --- /dev/null +++ b/sandbox-images/mcp-everything/package.json @@ -0,0 +1,16 @@ +{ + "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" + }, + "overrides": { + "@hono/node-server": "1.19.15", + "fast-uri": "3.1.6", + "hono": "4.13.5", + "ip-address": "10.3.1", + "qs": "6.16.0" + } +} diff --git a/tests/e2e/managed-mcp.py b/tests/e2e/managed-mcp.py new file mode 100644 index 000000000..9abeb9e0c --- /dev/null +++ b/tests/e2e/managed-mcp.py @@ -0,0 +1,304 @@ +#!/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, text/event-stream"} + 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 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) + 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: + 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") + 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/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() diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index 6fd621411..9fefa1321 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -161,6 +161,7 @@ install_crds() { local disable_le="${KARS_E2E_DISABLE_LEADER_ELECTION:-1}" local helm_wait_arg=--wait 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 @@ -3050,6 +3051,7 @@ EOF source "$SCRIPT_DIR/sre-authority.sh" source "$SCRIPT_DIR/namespace-ownership.sh" +source "$SCRIPT_DIR/managed-mcp.sh" source "$SCRIPT_DIR/credential-sources.sh" source "$SCRIPT_DIR/governed-services.sh" @@ -3069,6 +3071,7 @@ main() { # Validate the public cluster API before any Rust images or private fixtures. PYTHONDONTWRITEBYTECODE=1 python3 "$SCRIPT_DIR/sre_authority/registration_schema.py" build_images + prepare_managed_mcp prepare_sre_authority_legacy install_crds # Finish real legacy retirement before unrelated tests can create private @@ -3165,6 +3168,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"