|
| 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 | +} |
0 commit comments