From ad2a52802e404cd692fa1e3d4588428d3046b1f0 Mon Sep 17 00:00:00 2001 From: Andrew Chung <47454279+andrewkcchung@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:42:48 -0400 Subject: [PATCH 1/2] Enable deployment MCP defaults on generic workers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/proposals-impl/default-mcp-servers.md | 58 ++++++------- packages/sdk/examples/worker.js | 10 ++- packages/sdk/src/deployment-mcp.ts | 38 +++++++++ packages/sdk/src/index.ts | 1 + .../sdk/test/unit/blob-store-mi-flag.test.mjs | 1 + .../sdk/test/unit/deployment-mcp.test.mjs | 84 +++++++++++++++++++ .../test/unit/worker-startup-module.test.mjs | 4 +- 7 files changed, 165 insertions(+), 31 deletions(-) create mode 100644 packages/sdk/src/deployment-mcp.ts create mode 100644 packages/sdk/test/unit/deployment-mcp.test.mjs diff --git a/docs/proposals-impl/default-mcp-servers.md b/docs/proposals-impl/default-mcp-servers.md index 92b26e3d..5d904293 100644 --- a/docs/proposals-impl/default-mcp-servers.md +++ b/docs/proposals-impl/default-mcp-servers.md @@ -1,15 +1,15 @@ # Fleet-Default MCP Servers — Design Sketch > **Implemented with a different assembly boundary:** this proposal originally -> targeted the retired specialized repository worker. Fleet-default MCP servers -> are now supplied to generic workers through the constrained deployment startup -> module, while the owning composition repository provides server configuration -> and workload-identity policy. +> targeted the retired specialized repository worker. The standard generic +> worker now consumes deployment-owned MCP configuration directly when no +> startup module supplies MCP worker options. An owning composition repository +> can still use a startup module to override that fallback. -How a repo-pinned worker grants every session a set of **default MCP servers -that are NOT checked into the target repo** — the canonical case being the -Azure DevOps MCP — so a session can (e.g.) look up a work item even when the -repo's `.vscode/mcp.json` never declares an ADO server. +How a generic worker grants every session a set of **default MCP servers that +are NOT checked into a target repo** — including sessions with no repository. +The canonical case is Azure DevOps MCP, so a session can look up a work item +without relying on a repo's `.vscode/mcp.json`. This is the deployment-owned source of MCP servers. Authentication is supplied by the worker through the configured workload-identity scope mapping and is @@ -110,17 +110,18 @@ Add to `packages/sdk/src/mcp-loader.ts`, sharing internals with still carrying unresolved `${input:…}`/`${command:…}`. - **Mark each default server `optional: true`** (see §4). -### 3. Merge + precedence (worker assembly) +### 3. Precedence (worker assembly) -The retired design proposed merging these in the specialized repository worker, -where `mcpServers` was just `repoMcpServers`: +The standard generic worker loads `DEFAULT_MCP_JSON` and +`MCP_WORKLOAD_IDENTITY_SCOPES` from its environment. The Azure deployment +already projects its rendered `.env` through the `worker-env` ConfigMap, so the +same mechanism works for workers with or without a repository. -```js -const defaultMcpServers = loadDefaultMcpConfig(process.env.DEFAULT_MCP_JSON, { trace }); -// repo-declared wins on a name clash (a repo may pin `ado` with a narrower -// toolset); log the override. -const mcpServers = { ...defaultMcpServers, ...repoMcpServers }; -``` +If `PILOTSWARM_WORKER_STARTUP_MODULE` returns `workerOptions`, those options are +authoritative and the generic fallback is not parsed. This preserves +composition-owned startup behavior and prevents a server catalog or headers +provider from being loaded twice. A startup module that only adds plugins and +does not return `workerOptions` still receives the generic deployment fallback. Deployment-owned servers receive only URL-bound worker headers. Repository MCP discovery is disabled on shared workers; a trusted devbox launcher may opt in, @@ -155,19 +156,18 @@ The error is surfaced by the worker rather than silently changing identities. 3. **Identity boundary preserved:** the ADO server is reached with the worker UAMI. No caller token is accepted by the PilotSwarm API or persisted for the worker. -4. **No repo regression:** repo-declared servers and `REPO_MCP_ALLOW` behavior - are unchanged; a repo that declares its own `ado` overrides the default - deterministically (logged). +4. **No composition regression:** startup-module MCP options remain + authoritative and deployment fallback is used only when they are absent. 5. **URL-free source:** the concrete ADO URL/org appear only in the deploy-side - value; the public base manifest carries only the `__DEFAULT_MCP_JSON__` token. + value; public source carries no concrete server or tenant configuration. ## Acceptance test An end-to-end test that exercises the feature against a real fleet: -- Targets a repo whose enlistment declares **no** `ado` server in - `.vscode/mcp.json`, and attaches **no** MCP server of its own — so any `ado` - server that shows up must be the fleet default (not the repo, not the caller). +- Creates a session without repository affinity and attaches **no** MCP server + of its own, so any `ado` server that shows up must be the fleet default rather + than repo or caller configuration. - Supplies no caller credential or MCP server configuration. The deployment provides the server and the worker UAMI authenticates it. - Submits a work-item lookup prompt and asserts an `ado` work-item tool @@ -187,11 +187,11 @@ fast-fail. 1. Land SDK changes (`loadDefaultMcpConfig`, `optional` tag, worker merge, `resolveMcpServerAuth` optional-skip) + unit tests. 2. Build a new uniquely-tagged worker image (worker JS is baked in). -3. Add `__DEFAULT_MCP_JSON__` to the worker base manifest + deploy tooling; set - the ADO default per-fleet on the deploy side (or the cluster `worker-env` - ConfigMap). -4. Roll fleet-by-fleet (DaemonSet `maxUnavailable:1` + truthful readiness); - run the default-MCP acceptance client against each fleet to confirm green. +3. Set the server catalog and scope bindings in the deploy-side worker `.env`; + the existing `worker-env` ConfigMap projects both values to the generic + worker. +4. Roll fleet-by-fleet with truthful readiness and run the default-MCP + acceptance client against each fleet to confirm green. ## Open questions diff --git a/packages/sdk/examples/worker.js b/packages/sdk/examples/worker.js index 97503032..249de33f 100644 --- a/packages/sdk/examples/worker.js +++ b/packages/sdk/examples/worker.js @@ -38,6 +38,8 @@ * PLUGIN_DIRS — Comma-separated plugin directories (default: /app/plugin) * PILOTSWARM_EXTENSION_MODULES — Comma-separated modules; each exports register(worker), called * before start (for example a session workspace provider) + * DEFAULT_MCP_JSON — Deployment-owned remote MCP server catalog + * MCP_WORKLOAD_IDENTITY_SCOPES — Comma-separated server=scope bindings for worker identity * * Usage: * node --env-file=.env.remote examples/worker.js @@ -54,6 +56,7 @@ import { parseExtensionModules, loadTurnLifecycleHooksFromEnv, loadWorkerStartupModuleFromEnv, + resolveDeploymentMcpWorkerOptions, } from "pilotswarm-sdk"; // Sentinel value written to KV by the bicep-deploy `seed-secrets` step @@ -100,6 +103,11 @@ const workerStartup = await loadWorkerStartupModuleFromEnv({ pluginDirs, trace: (message) => console.log(`[worker-startup] ${message}`), }); +const deploymentMcpWorkerOptions = resolveDeploymentMcpWorkerOptions({ + env: process.env, + startupWorkerOptions: workerStartup?.workerOptions, + trace: (message) => console.log(`[deployment-mcp] ${message}`), +}); const effectivePluginDirs = [ ...new Set([ ...pluginDirs, @@ -154,7 +162,7 @@ const worker = new PilotSwarmWorker({ workerNodeId: podName, systemMessage: SYSTEM_MESSAGE, pluginDirs: effectivePluginDirs, - ...(workerStartup?.workerOptions ?? {}), + ...deploymentMcpWorkerOptions, ...turnLifecycleHooks, // Bicep-deploy MI flow (set in worker-env ConfigMap by the overlay // .env). Unset on the legacy `scripts/deploy-aks.sh` path, local diff --git a/packages/sdk/src/deployment-mcp.ts b/packages/sdk/src/deployment-mcp.ts new file mode 100644 index 00000000..de1228bc --- /dev/null +++ b/packages/sdk/src/deployment-mcp.ts @@ -0,0 +1,38 @@ +import { loadDefaultMcpConfig } from "./mcp-loader.js"; +import { createMcpWorkloadIdentityHeadersProvider } from "./mcp-workload-identity.js"; +import type { WorkerStartupResult } from "./worker-startup-module.js"; + +type StartupWorkerOptions = NonNullable; + +export function resolveDeploymentMcpWorkerOptions(options: { + env: NodeJS.ProcessEnv; + startupWorkerOptions?: StartupWorkerOptions; + trace?: (message: string) => void; +}): StartupWorkerOptions { + if (options.startupWorkerOptions !== undefined) { + return options.startupWorkerOptions; + } + + const rawConfig = options.env.DEFAULT_MCP_JSON; + const rawScopeBindings = options.env.MCP_WORKLOAD_IDENTITY_SCOPES; + if (!rawConfig?.trim() && !rawScopeBindings?.trim()) { + return {}; + } + + const mcpServers = loadDefaultMcpConfig(rawConfig, { + trace: options.trace, + }); + const mcpServerHeadersProvider = + createMcpWorkloadIdentityHeadersProvider({ + scopeBindings: rawScopeBindings, + deploymentMcpServers: mcpServers, + trace: options.trace, + }); + + return { + mcpServers, + ...(mcpServerHeadersProvider + ? { mcpServerHeadersProvider } + : {}), + }; +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 790f4fdd..d15c597f 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -64,6 +64,7 @@ export type { WorkerStartupContext, WorkerStartupResult, } from "./worker-startup-module.js"; +export { resolveDeploymentMcpWorkerOptions } from "./deployment-mcp.js"; export { FEATURE_FLAGS, FeatureFlagError, FeatureFlagResolutionError } from "./feature-flags.js"; export type { FeatureKey, FeatureDecision, FeatureDefinition, FeatureSetting, ResolveOptions } from "./feature-flags.js"; export { FeatureFlagCache } from "./feature-flag-cache.js"; diff --git a/packages/sdk/test/unit/blob-store-mi-flag.test.mjs b/packages/sdk/test/unit/blob-store-mi-flag.test.mjs index e6309ba1..1cf0d441 100644 --- a/packages/sdk/test/unit/blob-store-mi-flag.test.mjs +++ b/packages/sdk/test/unit/blob-store-mi-flag.test.mjs @@ -164,6 +164,7 @@ for (const entrypoint of ["packages/sdk/examples/worker.js", "packages/app/tui/s horizonConfigFromEnv: () => ({}), loadTurnLifecycleHooksFromEnv: async () => undefined, loadWorkerStartupModuleFromEnv: async () => undefined, + resolveDeploymentMcpWorkerOptions: () => ({}), // Both entry points load extension modules before start. loadExtensionModules: async () => [], parseExtensionModules: () => [], diff --git a/packages/sdk/test/unit/deployment-mcp.test.mjs b/packages/sdk/test/unit/deployment-mcp.test.mjs new file mode 100644 index 00000000..e0fa5bd7 --- /dev/null +++ b/packages/sdk/test/unit/deployment-mcp.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveDeploymentMcpWorkerOptions } from "../../dist/deployment-mcp.js"; + +const httpsConfig = JSON.stringify({ + servers: { + internal: { + type: "http", + url: "https://mcp.example.invalid/mcp", + }, + }, +}); + +test("deployment MCP options are absent when the environment is unconfigured", () => { + assert.deepEqual(resolveDeploymentMcpWorkerOptions({ env: {} }), {}); +}); + +test("deployment MCP options load repo-independent servers without auth mappings", () => { + const options = resolveDeploymentMcpWorkerOptions({ + env: { DEFAULT_MCP_JSON: httpsConfig }, + }); + + assert.equal( + options.mcpServers.internal.url, + "https://mcp.example.invalid/mcp", + ); + assert.equal(options.mcpServers.internal.optional, true); + assert.equal(options.mcpServerHeadersProvider, undefined); +}); + +test("deployment MCP options add workload identity for configured servers", () => { + const options = resolveDeploymentMcpWorkerOptions({ + env: { + DEFAULT_MCP_JSON: httpsConfig, + MCP_WORKLOAD_IDENTITY_SCOPES: + "internal=api://example/.default", + }, + }); + + assert.equal( + options.mcpServers.internal.url, + "https://mcp.example.invalid/mcp", + ); + assert.equal(options.mcpServers.internal.optional, true); + assert.equal(typeof options.mcpServerHeadersProvider, "function"); +}); + +test("startup module MCP options take precedence over deployment fallback", () => { + const customHeadersProvider = async () => ({}); + const startupWorkerOptions = { + mcpServers: { + custom: { + type: "http", + url: "https://custom.example.invalid/mcp", + }, + }, + mcpServerHeadersProvider: customHeadersProvider, + }; + + const options = resolveDeploymentMcpWorkerOptions({ + env: { + DEFAULT_MCP_JSON: "{ malformed", + MCP_WORKLOAD_IDENTITY_SCOPES: "invalid", + }, + startupWorkerOptions, + }); + + assert.equal(options, startupWorkerOptions); + assert.equal(options.mcpServerHeadersProvider, customHeadersProvider); +}); + +test("deployment MCP workload identity fails closed without a matching server", () => { + assert.throws( + () => resolveDeploymentMcpWorkerOptions({ + env: { + DEFAULT_MCP_JSON: httpsConfig, + MCP_WORKLOAD_IDENTITY_SCOPES: + "missing=api://example/.default", + }, + }), + /is not present in deployment-owned DEFAULT_MCP_JSON/, + ); +}); diff --git a/packages/sdk/test/unit/worker-startup-module.test.mjs b/packages/sdk/test/unit/worker-startup-module.test.mjs index 495524bd..b1c21ab8 100644 --- a/packages/sdk/test/unit/worker-startup-module.test.mjs +++ b/packages/sdk/test/unit/worker-startup-module.test.mjs @@ -186,7 +186,9 @@ test("the standard worker initializes startup additions before construction", as assert.notEqual(loadIndex, -1); assert.notEqual(constructionIndex, -1); assert.ok(loadIndex < constructionIndex); - assert.match(source, /\.\.\.\(workerStartup\?\.workerOptions \?\? \{\}\)/); + assert.match(source, /resolveDeploymentMcpWorkerOptions\(\{/); + assert.match(source, /startupWorkerOptions: workerStartup\?\.workerOptions/); + assert.match(source, /\.\.\.deploymentMcpWorkerOptions/); assert.match(source, /pluginDirs: effectivePluginDirs/); }); From 1bc18e690256fa37068550429a46c3b6007492ec Mon Sep 17 00:00:00 2001 From: Andrew Chung <47454279+andrewkcchung@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:15:25 -0400 Subject: [PATCH 2/2] Project deployment MCP settings to generic workers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure/gitops/worker/overlays/default/.env | 4 +++ .../azure/services/worker/deploy.json | 6 ++++ deploy/scripts/lib/worker-env.mjs | 6 +++- deploy/scripts/test/stage-manifests.test.mjs | 32 +++++++++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/deploy/providers/azure/gitops/worker/overlays/default/.env b/deploy/providers/azure/gitops/worker/overlays/default/.env index 38c718ed..745e71c8 100644 --- a/deploy/providers/azure/gitops/worker/overlays/default/.env +++ b/deploy/providers/azure/gitops/worker/overlays/default/.env @@ -23,6 +23,10 @@ AZURE_TENANT_ID=00000000-0000-0000-0000-000000000000 PILOTSWARM_WORKER_TAGS=generic WORKER_REPLICAS=3 WORKFLOW_GENERATOR_SOURCE_PROVIDERS_JSON=[] +# Optional deployment-owned remote MCP catalog and server=scope mappings. +# These are empty by default and are independent of repository affinity. +DEFAULT_MCP_JSON= +MCP_WORKLOAD_IDENTITY_SCOPES= # Custom LLM endpoint settings (LLM_ENDPOINT / LLM_PROVIDER_TYPE / # LLM_API_VERSION) are intentionally NOT projected here. The default # bicep-deploy path uses the GitHub Copilot SDK + the model providers diff --git a/deploy/providers/azure/services/worker/deploy.json b/deploy/providers/azure/services/worker/deploy.json index 4b76f934..63728633 100644 --- a/deploy/providers/azure/services/worker/deploy.json +++ b/deploy/providers/azure/services/worker/deploy.json @@ -23,5 +23,11 @@ "name": "copilot-runtime-worker", "namespace": "pilotswarm", "verifyImage": true + }, + "gitops": { + "optionalEnvKeys": [ + "DEFAULT_MCP_JSON", + "MCP_WORKLOAD_IDENTITY_SCOPES" + ] } } diff --git a/deploy/scripts/lib/worker-env.mjs b/deploy/scripts/lib/worker-env.mjs index d6826211..4a1d0a36 100644 --- a/deploy/scripts/lib/worker-env.mjs +++ b/deploy/scripts/lib/worker-env.mjs @@ -9,7 +9,11 @@ // // No I/O; no import of common.mjs (it imports this). -export const WORKER_ENV_DEFAULTS = Object.freeze({ PILOTSWARM_NATIVE_SUBAGENTS: "off" }); +export const WORKER_ENV_DEFAULTS = Object.freeze({ + PILOTSWARM_NATIVE_SUBAGENTS: "off", + DEFAULT_MCP_JSON: "", + MCP_WORKLOAD_IDENTITY_SCOPES: "", +}); export function nativeSubagentsSetting(env) { const raw = env?.PILOTSWARM_NATIVE_SUBAGENTS; diff --git a/deploy/scripts/test/stage-manifests.test.mjs b/deploy/scripts/test/stage-manifests.test.mjs index c07ba871..76872120 100644 --- a/deploy/scripts/test/stage-manifests.test.mjs +++ b/deploy/scripts/test/stage-manifests.test.mjs @@ -584,3 +584,35 @@ test("stageManifests(worker): PILOTSWARM_NATIVE_SUBAGENTS reaches the worker-env } } }); + +test("stageManifests(worker): optional deployment MCP settings reach worker-env", () => { + const cases = [ + [{}, "", ""], + [{ + DEFAULT_MCP_JSON: '{"servers":{"internal":{"type":"http","url":"https://mcp.example.invalid/mcp"}}}', + MCP_WORKLOAD_IDENTITY_SCOPES: "internal=api://example/.default", + }, '{"servers":{"internal":{"type":"http","url":"https://mcp.example.invalid/mcp"}}}', "internal=api://example/.default"], + ]; + + for (const [extra, expectedCatalog, expectedScopes] of cases) { + const stagingDir = mkdtempSync(join(tmpdir(), "ps-stage-mcp-")); + try { + const root = stageManifests({ + service: "worker", + envName: "testenv", + env: makePortalEnv({ + AZURE_STORAGE_CONTAINER: "copilot-sessions", + PILOTSWARM_TURN_TIMEOUT_MS: "1", + PILOTSWARM_LIVE_TURN: "0", + ...extra, + }), + stagingDir, + }); + const text = readFileSync(join(root, "overlays", "default", ".env"), "utf8"); + assert.match(text, new RegExp(`^DEFAULT_MCP_JSON=${expectedCatalog.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "m")); + assert.match(text, new RegExp(`^MCP_WORKLOAD_IDENTITY_SCOPES=${expectedScopes.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "m")); + } finally { + rmSync(stagingDir, { recursive: true, force: true }); + } + } +});