Skip to content

Commit 1729f8f

Browse files
feat(sdk): honor repo agency.toml MCP servers in worker sessions
Borrow the 1ES Agency CLI's agency.toml -> engine MCP resolution so a PilotSwarm worker running inside a repo checkout (e.g. DsMainDev) surfaces that repo's declared MCP servers to the Copilot SDK createSession({ mcpServers }). - New packages/sdk/src/agency-config.ts: faithful subset port of agency's [mcps] resolution. Reads agency.toml [mcps.servers], plus (per its include_mcps_from_workspace / include_mcps_from_vscode flags) the repo's .mcp.json (default-on) and .vscode/mcp.json (opt-in, templated entries skipped), merged in agency's documented order (later wins). Maps each entry to MCPServerConfig via the same command|url disambiguation as agency's McpServer flat-format deserialize. Flags unsupported sections (plugins/marketplaces/remote_config/builtins/profiles). Never throws. Comments cite the upstream agency source files (https://dev.azure.com/1esgitops/_git/agency) since code may diverge. - session-manager.ts: resolve agency MCP servers at the session working dir and layer them UNDER platform-provisioned servers (platform wins on name conflict, preserving real auth). Add agency.toml details to the GHCP-SDK createSession diagnostics log. - Add smol-toml dependency for TOML parsing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 26f310eb-4680-4102-a8aa-f8d041940ab4
1 parent 0b9a7e7 commit 1729f8f

4 files changed

Lines changed: 353 additions & 2 deletions

File tree

package-lock.json

Lines changed: 14 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/sdk/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@
8585
"@opentelemetry/api": "^1.9.0",
8686
"duroxide": "^0.1.27",
8787
"file-type": "^20.5.0",
88-
"pg": "^8.18.0"
88+
"pg": "^8.18.0",
89+
"smol-toml": "^1.7.1"
8990
},
9091
"peerDependencies": {
9192
"pilotswarm-horizon-store": "0.5.37"

packages/sdk/src/agency-config.ts

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
// agency-config.ts
2+
// -----------------------------------------------------------------------------
3+
// Faithful (subset) port of how the 1ES **Agency** CLI resolves an `agency.toml`
4+
// file found in a target repo into engine (GitHub Copilot) session config. When
5+
// a PilotSwarm worker runs inside a repo checkout (e.g. DsMainDev), it should
6+
// honor that repo's `agency.toml` the same way the Agency pipeline task would —
7+
// primarily by surfacing the repo-declared MCP servers to the Copilot SDK
8+
// `createSession({ mcpServers })` call.
9+
//
10+
// This is intentionally a SUBSET. Agency does far more (remote_config layering,
11+
// profiles, plugin acquisition/marketplaces, builtin MCP machinery, auth
12+
// proxying). First pass covers the piece that is (a) most commonly present in
13+
// target repos and (b) maps cleanly onto the SDK: the `[mcps]` section.
14+
//
15+
// Source-of-truth references (code may diverge — re-check against upstream):
16+
// Repo: https://dev.azure.com/1esgitops/_git/agency (local: C:\src\agency)
17+
// Schema: docs/agency/CLI/agency-config-schema.md
18+
// Struct: client/config/src/agency_config.rs
19+
// - AgencyConfigCore / AgencyConfig (top-level, profiles)
20+
// - McpsConfig (the `[mcps]` table; builtins/servers/include_* flags)
21+
// MCP: client/config/src/custom_agent/mcp.rs
22+
// - McpServer enum (Local / SSE / HTTP) + its custom flat-format
23+
// Deserialize impl (command|url disambiguation, `type` handling)
24+
// - load_workspace_mcp_config (<root>/.mcp.json)
25+
// - load_vscode_mcp_config (<root>/.vscode/mcp.json)
26+
// Merge: client/agency/src/mcp_config.rs
27+
// - merge_all_mcp_sources (source precedence order)
28+
// - write_mcp_config_to_temp_file (final shape handed to the engine
29+
// as `--additional-mcp-config @file`; SDK equivalent = mcpServers)
30+
// -----------------------------------------------------------------------------
31+
32+
import fs from "node:fs";
33+
import path from "node:path";
34+
import { parse as parseToml } from "smol-toml";
35+
import type { MCPServerConfig } from "@github/copilot-sdk";
36+
37+
/** Result of resolving a repo's agency.toml MCP configuration. */
38+
export interface AgencyMcpResolution {
39+
/** True when an agency.toml was found and parsed at the root. */
40+
found: boolean;
41+
/** Absolute path to the agency.toml that was read, when found. */
42+
configPath?: string;
43+
/** Resolved MCP servers, keyed by server name (SDK createSession shape). */
44+
mcpServers: Record<string, MCPServerConfig>;
45+
/** For each resolved server name, which source it came from (for diagnostics). */
46+
sources: Record<string, string>;
47+
/** Non-fatal issues (skipped/invalid entries, both-command-and-url, etc.). */
48+
warnings: string[];
49+
/**
50+
* agency.toml sections that were present but are NOT yet honored by this
51+
* subset port (plugins, marketplaces, remote_config, builtins, profiles…).
52+
* Surfaced so operators can see what was ignored.
53+
*/
54+
unsupported: string[];
55+
}
56+
57+
// Agency defaults (client/config/src/agency_config.rs McpsConfig):
58+
// include_mcps_from_vscode -> false (opt-in)
59+
// include_mcps_from_workspace -> true (opt-out)
60+
const DEFAULT_INCLUDE_MCPS_FROM_VSCODE = false;
61+
const DEFAULT_INCLUDE_MCPS_FROM_WORKSPACE = true;
62+
63+
// Process-lifetime cache keyed by absolute root dir. A PilotSwarm worker is
64+
// bounded to one checkout, so the resolved config is stable for the process;
65+
// caching avoids re-reading/parsing on every per-turn createSession call.
66+
const _cache = new Map<string, AgencyMcpResolution>();
67+
68+
function emptyResolution(): AgencyMcpResolution {
69+
return { found: false, mcpServers: {}, sources: {}, warnings: [], unsupported: [] };
70+
}
71+
72+
/**
73+
* Resolve `<rootDir>/agency.toml` (and, per its flags, the repo's `.mcp.json` /
74+
* `.vscode/mcp.json`) into a set of MCP servers for the Copilot SDK.
75+
*
76+
* Mirrors the repo-relevant slice of agency's `merge_all_mcp_sources`
77+
* (client/agency/src/mcp_config.rs). Precedence (later wins) for the sources we
78+
* cover: `.vscode/mcp.json` (opt-in) -> `.mcp.json` (default-on) ->
79+
* `agency.toml [mcps.servers]`. Platform-provisioned PilotSwarm servers are
80+
* layered on top by the caller and always win on name conflicts (they carry
81+
* real auth) — see session-manager.ts.
82+
*
83+
* Never throws: parse/read failures degrade to warnings and an empty/partial
84+
* result so a malformed repo file can never break session creation.
85+
*/
86+
export function resolveAgencyMcpServers(rootDir: string): AgencyMcpResolution {
87+
const root = path.resolve(rootDir || ".");
88+
const cached = _cache.get(root);
89+
if (cached) return cached;
90+
91+
const result = emptyResolution();
92+
93+
// NOTE: Agency discovers agency.toml globally-first then cwd-last across
94+
// parent dirs (list_agency_config_files in agency_config.rs). For the worker
95+
// we only consider the checkout root — the single repo the session runs
96+
// against — which is the layer that matters for repo-declared MCP servers.
97+
const configPath = path.join(root, "agency.toml");
98+
let rawConfig: string;
99+
try {
100+
if (!fs.existsSync(configPath)) {
101+
_cache.set(root, result);
102+
return result;
103+
}
104+
rawConfig = fs.readFileSync(configPath, "utf8");
105+
} catch (err) {
106+
result.warnings.push(`failed to read ${configPath}: ${errMsg(err)}`);
107+
_cache.set(root, result);
108+
return result;
109+
}
110+
111+
let parsed: Record<string, unknown>;
112+
try {
113+
parsed = parseToml(rawConfig) as Record<string, unknown>;
114+
} catch (err) {
115+
// Agency rejects unknown fields / malformed TOML (deny_unknown_fields).
116+
// We are more lenient: a parse failure yields no servers + a warning
117+
// rather than aborting the session.
118+
result.warnings.push(`failed to parse ${configPath} as TOML: ${errMsg(err)}`);
119+
_cache.set(root, result);
120+
return result;
121+
}
122+
123+
result.found = true;
124+
result.configPath = configPath;
125+
126+
// Flag top-level sections we do not (yet) honor, so nothing is silently lost.
127+
// (agency_config.rs AgencyConfigCore top-level keys.)
128+
for (const key of ["plugins", "marketplaces", "remote_config", "org_config", "profiles", "review", "ring", "telemetry"]) {
129+
if (parsed[key] !== undefined) result.unsupported.push(key);
130+
}
131+
132+
const mcps = (parsed.mcps ?? {}) as Record<string, unknown>;
133+
134+
// Agency also has [mcps.builtins] (BuiltinMcpEntry) — the `ado` builtin, etc.
135+
// Those need agency-specific machinery (spawning the agency binary as an MCP
136+
// proxy, auth). Not portable here; flag and skip.
137+
if (mcps.builtins !== undefined && Object.keys(mcps.builtins as object).length > 0) {
138+
result.unsupported.push("mcps.builtins");
139+
}
140+
141+
const includeVscode = readBoolFlag(mcps.include_mcps_from_vscode, DEFAULT_INCLUDE_MCPS_FROM_VSCODE);
142+
const includeWorkspace = readBoolFlag(mcps.include_mcps_from_workspace, DEFAULT_INCLUDE_MCPS_FROM_WORKSPACE);
143+
144+
// Apply sources in agency's documented order; later sources overwrite
145+
// earlier ones on name collision (matches merge_all_mcp_sources sequencing:
146+
// .vscode -> .mcp.json -> agency mcps.servers).
147+
if (includeVscode) {
148+
mergeExternalMcpFile(path.join(root, ".vscode", "mcp.json"), ".vscode/mcp.json", result, /*skipTemplated*/ true);
149+
}
150+
if (includeWorkspace) {
151+
mergeExternalMcpFile(path.join(root, ".mcp.json"), ".mcp.json", result, /*skipTemplated*/ false);
152+
}
153+
154+
const servers = (mcps.servers ?? {}) as Record<string, unknown>;
155+
for (const [name, entry] of Object.entries(servers)) {
156+
const mapped = mapMcpEntry(name, entry, "agency.toml [mcps.servers]", result);
157+
if (mapped) {
158+
result.mcpServers[name] = mapped;
159+
result.sources[name] = "agency.toml [mcps.servers]";
160+
}
161+
}
162+
163+
_cache.set(root, result);
164+
return result;
165+
}
166+
167+
/**
168+
* Read a Copilot/VS Code MCP JSON file (`{ "mcpServers"|"servers": {...} }`) and
169+
* merge its entries. Mirrors custom_agent::load_workspace_mcp_config /
170+
* load_vscode_mcp_config plus the lenient VS Code handling in
171+
* client/agency/src/mcp_config.rs (load_vscode_mcp_lenient), which skips entries
172+
* that reference un-expandable `${...}` template variables.
173+
*/
174+
function mergeExternalMcpFile(
175+
filePath: string,
176+
label: string,
177+
result: AgencyMcpResolution,
178+
skipTemplated: boolean,
179+
): void {
180+
let text: string;
181+
try {
182+
if (!fs.existsSync(filePath)) return;
183+
text = fs.readFileSync(filePath, "utf8");
184+
} catch (err) {
185+
result.warnings.push(`failed to read ${label}: ${errMsg(err)}`);
186+
return;
187+
}
188+
189+
let obj: Record<string, unknown>;
190+
try {
191+
obj = JSON.parse(stripJsonComments(text)) as Record<string, unknown>;
192+
} catch (err) {
193+
result.warnings.push(`failed to parse ${label} as JSON: ${errMsg(err)}`);
194+
return;
195+
}
196+
197+
// Copilot's `.mcp.json` uses "mcpServers"; VS Code's mcp.json uses "servers".
198+
// Agency's McpConfig serde accepts both (rename/alias in custom_agent/mcp.rs).
199+
const servers = (obj.mcpServers ?? obj.servers ?? {}) as Record<string, unknown>;
200+
for (const [name, entry] of Object.entries(servers)) {
201+
if (skipTemplated && containsTemplate(entry)) {
202+
result.warnings.push(`${label}: skipped server '${name}' (references un-expandable \${...} template)`);
203+
continue;
204+
}
205+
const mapped = mapMcpEntry(name, entry, label, result);
206+
if (mapped) {
207+
result.mcpServers[name] = mapped;
208+
result.sources[name] = label;
209+
}
210+
}
211+
}
212+
213+
/**
214+
* Map one raw MCP entry to the SDK `MCPServerConfig`. Faithfully follows the
215+
* command|url disambiguation in agency's McpServer flat-format Deserialize
216+
* (client/config/src/custom_agent/mcp.rs):
217+
* - has `command`, no `url` -> Local (stdio)
218+
* - has `url`, type == "http" -> HTTP
219+
* - has `url`, type == "sse" | (unset) -> SSE (unset defaults to SSE upstream)
220+
* - both command and url -> error (we warn + skip)
221+
* - neither -> error (we warn + skip)
222+
*/
223+
function mapMcpEntry(
224+
name: string,
225+
entry: unknown,
226+
source: string,
227+
result: AgencyMcpResolution,
228+
): MCPServerConfig | undefined {
229+
if (!entry || typeof entry !== "object") {
230+
result.warnings.push(`${source}: server '${name}' is not a table/object; skipped`);
231+
return undefined;
232+
}
233+
const e = entry as Record<string, unknown>;
234+
const command = typeof e.command === "string" ? e.command : undefined;
235+
const url = typeof e.url === "string" ? e.url : undefined;
236+
const type = typeof e.type === "string" ? (e.type as string).toLowerCase() : undefined;
237+
238+
if (command && url) {
239+
result.warnings.push(`${source}: server '${name}' specifies both 'command' and 'url'; skipped`);
240+
return undefined;
241+
}
242+
243+
// Shared optional fields (agency CommonConfig: tools/timeout/env; local: env).
244+
const tools = Array.isArray(e.tools) ? (e.tools as unknown[]).filter((t) => typeof t === "string") as string[] : undefined;
245+
const timeout = typeof e.timeout === "number" ? e.timeout : undefined;
246+
247+
if (command) {
248+
const args = Array.isArray(e.args) ? (e.args as unknown[]).map(String) : undefined;
249+
const env = isStringMap(e.env) ? (e.env as Record<string, string>) : undefined;
250+
const cwd = typeof e.cwd === "string" ? e.cwd : undefined; // agency LocalConfig.cwd
251+
const cfg: MCPServerConfig = {
252+
type: "stdio",
253+
command,
254+
...(args && { args }),
255+
...(env && { env }),
256+
...(cwd && { workingDirectory: cwd }),
257+
...(tools && { tools }),
258+
...(timeout !== undefined && { timeout }),
259+
};
260+
return cfg;
261+
}
262+
263+
if (url) {
264+
// Unknown non-http/sse `type` is an error upstream; we warn + skip.
265+
if (type && type !== "http" && type !== "sse") {
266+
result.warnings.push(`${source}: server '${name}' has unknown type '${type}'; skipped`);
267+
return undefined;
268+
}
269+
const headers = isStringMap(e.headers) ? (e.headers as Record<string, string>) : undefined;
270+
const cfg: MCPServerConfig = {
271+
type: type === "http" ? "http" : "sse", // unset defaults to SSE (agency)
272+
url,
273+
...(headers && { headers }),
274+
...(tools && { tools }),
275+
...(timeout !== undefined && { timeout }),
276+
};
277+
return cfg;
278+
}
279+
280+
result.warnings.push(`${source}: server '${name}' has neither 'command' nor 'url'; skipped`);
281+
return undefined;
282+
}
283+
284+
/** Deep scan for an un-expandable `${...}` template token in an entry. */
285+
function containsTemplate(entry: unknown): boolean {
286+
try {
287+
return /\$\{[^}]+\}/.test(JSON.stringify(entry));
288+
} catch {
289+
return false;
290+
}
291+
}
292+
293+
function readBoolFlag(v: unknown, dflt: boolean): boolean {
294+
return typeof v === "boolean" ? v : dflt;
295+
}
296+
297+
function isStringMap(v: unknown): v is Record<string, string> {
298+
return !!v && typeof v === "object" && !Array.isArray(v)
299+
&& Object.values(v as object).every((x) => typeof x === "string");
300+
}
301+
302+
/** Tolerate JSONC (// and /* *​/ comments) as VS Code allows in mcp.json. */
303+
function stripJsonComments(text: string): string {
304+
// Remove block comments, then line comments. Conservative: does not attempt
305+
// to preserve comment-like sequences inside strings, which is acceptable for
306+
// MCP config files (they don't contain such literals in practice).
307+
return text
308+
.replace(/\/\*[\s\S]*?\*\//g, "")
309+
.replace(/(^|[^:])\/\/.*$/gm, "$1");
310+
}
311+
312+
function errMsg(err: unknown): string {
313+
return err instanceof Error ? err.message : String(err);
314+
}

packages/sdk/src/session-manager.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { buildKnowledgePromptBlocks, loadKnowledgeIndexFromFactStore, buildEnhan
2525
import { composeStructuredSystemMessage, extractPromptContent, mergePromptSections } from "./prompt-layering.js";
2626
import { buildPromptLayersEventPayload, type PromptLayerDescriptor } from "./prompt-layers.js";
2727
import { approvePermissionForSession } from "./permissions.js";
28+
import { resolveAgencyMcpServers } from "./agency-config.js";
2829
import { readSnapshotMarker, supportsVersionedSnapshots, writeSnapshotMarker } from "./snapshot-protocol.js";
2930
import fs from "node:fs";
3031
import path from "node:path";
@@ -1265,7 +1266,21 @@ export class SessionManager {
12651266
const boundAgentMcpServers = effectiveSerializableConfig.boundAgentName
12661267
? this.workerDefaults.agentMcpServers?.[effectiveSerializableConfig.boundAgentName]
12671268
: undefined;
1269+
1270+
// agency.toml (capability-profiles / repo-native): when the session runs
1271+
// against a repo checkout that ships an `agency.toml`, borrow the MCP
1272+
// servers it declares — the same thing the 1ES Agency pipeline task would
1273+
// surface to the engine. See agency-config.ts for the faithful mapping and
1274+
// the upstream source references (https://dev.azure.com/1esgitops/_git/agency).
1275+
// Root = the per-session workingDirectory when supplied, else the worker
1276+
// process cwd (the serve harness chdir's into the enlistment checkout).
1277+
// Precedence: repo-declared agency servers form the BASE; platform-
1278+
// provisioned servers (base + bound-agent) are layered on top and win on
1279+
// name conflicts because they carry real auth/proxying.
1280+
const agencyRoot = config.workingDirectory ?? process.cwd();
1281+
const agencyMcp = resolveAgencyMcpServers(agencyRoot);
12681282
const effectiveMcpServers = {
1283+
...agencyMcp.mcpServers,
12691284
...(this.workerDefaults.baseMcpServers ?? {}),
12701285
...(boundAgentMcpServers ?? {}),
12711286
};
@@ -1373,6 +1388,14 @@ export class SessionManager {
13731388
skillDirectories: skillDirsProbe,
13741389
customAgentsCount: Array.isArray(sessionConfig.customAgents) ? sessionConfig.customAgents.length : 0,
13751390
mcpServerNames: Object.keys(effectiveMcpServers),
1391+
agencyToml: {
1392+
found: agencyMcp.found,
1393+
configPath: agencyMcp.configPath ?? "(none)",
1394+
serverNames: Object.keys(agencyMcp.mcpServers),
1395+
sources: agencyMcp.sources,
1396+
unsupportedSections: agencyMcp.unsupported,
1397+
warnings: agencyMcp.warnings,
1398+
},
13761399
githubProbeWorkingDir: probeGithub(effWorkingDir),
13771400
githubProbeCwd: probeGithub(process.cwd()),
13781401
}),

0 commit comments

Comments
 (0)