Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions deploy/providers/azure/gitops/worker/overlays/default/.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions deploy/providers/azure/services/worker/deploy.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,11 @@
"name": "copilot-runtime-worker",
"namespace": "pilotswarm",
"verifyImage": true
},
"gitops": {
"optionalEnvKeys": [
"DEFAULT_MCP_JSON",
"MCP_WORKLOAD_IDENTITY_SCOPES"
]
}
}
6 changes: 5 additions & 1 deletion deploy/scripts/lib/worker-env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
32 changes: 32 additions & 0 deletions deploy/scripts/test/stage-manifests.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}
});
58 changes: 29 additions & 29 deletions docs/proposals-impl/default-mcp-servers.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
10 changes: 9 additions & 1 deletion packages/sdk/examples/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -54,6 +56,7 @@ import {
parseExtensionModules,
loadTurnLifecycleHooksFromEnv,
loadWorkerStartupModuleFromEnv,
resolveDeploymentMcpWorkerOptions,
} from "pilotswarm-sdk";

// Sentinel value written to KV by the bicep-deploy `seed-secrets` step
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions packages/sdk/src/deployment-mcp.ts
Original file line number Diff line number Diff line change
@@ -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<WorkerStartupResult["workerOptions"]>;

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 }
: {}),
};
}
1 change: 1 addition & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/test/unit/blob-store-mi-flag.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => [],
Expand Down
84 changes: 84 additions & 0 deletions packages/sdk/test/unit/deployment-mcp.test.mjs
Original file line number Diff line number Diff line change
@@ -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/,
);
});
4 changes: 3 additions & 1 deletion packages/sdk/test/unit/worker-startup-module.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});

Expand Down
Loading