diff --git a/packages/core/src/catalog/facets/process.ts b/packages/core/src/catalog/facets/process.ts index 3ac2ba2..5f7b162 100644 --- a/packages/core/src/catalog/facets/process.ts +++ b/packages/core/src/catalog/facets/process.ts @@ -16,7 +16,16 @@ export const processFacet: Facet = { writer: "workflows", config: { package: "@codemcp/workflows-server@latest", - ref: "workflows" + ref: "workflows", + env: { + VIBE_WORKFLOW_DOMAINS: "skilled" + }, + allowedTools: [ + "whats_next", + "conduct_review", + "list_workflows", + "get_tool_info" + ] } }, { diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts index 51def06..7d3a0ee 100644 --- a/packages/core/src/registry.ts +++ b/packages/core/src/registry.ts @@ -5,6 +5,7 @@ import type { } from "./types.js"; import { instructionWriter } from "./writers/instruction.js"; import { workflowsWriter } from "./writers/workflows.js"; +import { mcpServerWriter } from "./writers/mcp-server.js"; import { skillsWriter } from "./writers/skills.js"; import { knowledgeWriter } from "./writers/knowledge.js"; import { gitHooksWriter } from "./writers/git-hooks.js"; @@ -51,15 +52,15 @@ export function createDefaultRegistry(): WriterRegistry { registerProvisionWriter(registry, instructionWriter); registerProvisionWriter(registry, workflowsWriter); + registerProvisionWriter(registry, mcpServerWriter); registerProvisionWriter(registry, skillsWriter); - registerProvisionWriter(registry, knowledgeWriter); registerProvisionWriter(registry, gitHooksWriter); registerProvisionWriter(registry, setupNoteWriter); registerProvisionWriter(registry, permissionPolicyWriter); // Stub writers for types not yet implemented - for (const id of ["mcp-server", "installable"]) { + for (const id of ["installable"]) { registerProvisionWriter(registry, { id, write: async () => ({}) diff --git a/packages/core/src/resolver.spec.ts b/packages/core/src/resolver.spec.ts index be1775b..b180fcc 100644 --- a/packages/core/src/resolver.spec.ts +++ b/packages/core/src/resolver.spec.ts @@ -120,6 +120,35 @@ describe("resolve", () => { expect(result.mcp_servers.length).toBeGreaterThanOrEqual(1); expect(result.instructions).toContain("Extra instruction"); }); + + it("env set in the catalog option config is forwarded to the resolved mcp_server entry", async () => { + const userConfig: UserConfig = { + choices: { process: "codemcp-workflows" } + }; + + // Patch the catalog option's provision config to include env + const processFacet = catalog.facets.find((f) => f.id === "process")!; + const option = processFacet.options.find( + (o) => o.id === "codemcp-workflows" + )!; + const workflowsProvision = option.recipe.find( + (p) => p.writer === "workflows" + )!; + workflowsProvision.config = { + ...workflowsProvision.config, + env: { VIBE_WORKFLOWS_DOMAIN: "skilled" } + }; + + const result = await resolve(userConfig, catalog, registry); + + const workflowsServer = result.mcp_servers.find( + (s) => s.ref === "workflows" + ); + expect(workflowsServer).toBeDefined(); + expect(workflowsServer!.env).toEqual({ + VIBE_WORKFLOWS_DOMAIN: "skilled" + }); + }); }); describe("unknown facet in choices", () => { diff --git a/packages/core/src/writers/mcp-server.spec.ts b/packages/core/src/writers/mcp-server.spec.ts new file mode 100644 index 0000000..03f47df --- /dev/null +++ b/packages/core/src/writers/mcp-server.spec.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { mcpServerWriter } from "./mcp-server.js"; +import type { ResolutionContext } from "../types.js"; + +describe("mcpServerWriter", () => { + const context: ResolutionContext = { resolved: {} }; + + it("has id 'mcp-server'", () => { + expect(mcpServerWriter.id).toBe("mcp-server"); + }); + + it("returns mcp_servers with correct ref, command, args, and env", async () => { + const result = await mcpServerWriter.write( + { + ref: "my-server", + command: "npx", + args: ["my-mcp-package"], + env: { KEY: "value" } + }, + context + ); + expect(result).toEqual({ + mcp_servers: [ + { + ref: "my-server", + command: "npx", + args: ["my-mcp-package"], + env: { KEY: "value" } + } + ] + }); + }); + + it("defaults env to an empty object when not specified", async () => { + const result = await mcpServerWriter.write( + { ref: "my-server", command: "npx", args: ["my-mcp-package"] }, + context + ); + expect(result.mcp_servers![0].env).toEqual({}); + }); + + it("includes allowedTools when specified", async () => { + const result = await mcpServerWriter.write( + { + ref: "my-server", + command: "npx", + args: ["my-mcp-package"], + allowedTools: ["tool_a", "tool_b"] + }, + context + ); + expect(result.mcp_servers![0].allowedTools).toEqual(["tool_a", "tool_b"]); + }); + + it("omits allowedTools from entry when not specified", async () => { + const result = await mcpServerWriter.write( + { ref: "my-server", command: "npx", args: ["my-mcp-package"] }, + context + ); + expect(result.mcp_servers![0]).not.toHaveProperty("allowedTools"); + }); +}); diff --git a/packages/core/src/writers/mcp-server.ts b/packages/core/src/writers/mcp-server.ts new file mode 100644 index 0000000..b232c01 --- /dev/null +++ b/packages/core/src/writers/mcp-server.ts @@ -0,0 +1,25 @@ +import type { ProvisionWriterDef } from "../types.js"; + +export const mcpServerWriter: ProvisionWriterDef = { + id: "mcp-server", + async write(config) { + const { ref, command, args, env, allowedTools } = config as { + ref: string; + command: string; + args: string[]; + env?: Record; + allowedTools?: string[]; + }; + return { + mcp_servers: [ + { + ref, + command, + args, + env: env ?? {}, + ...(allowedTools !== undefined ? { allowedTools } : {}) + } + ] + }; + } +}; diff --git a/packages/core/src/writers/workflows.spec.ts b/packages/core/src/writers/workflows.spec.ts index b8e51e3..261a23b 100644 --- a/packages/core/src/writers/workflows.spec.ts +++ b/packages/core/src/writers/workflows.spec.ts @@ -59,6 +59,28 @@ describe("workflowsWriter", () => { expect(result.mcp_servers![0].env).toEqual({}); }); + it("includes allowedTools in the entry when specified", async () => { + const result = await workflowsWriter.write( + { + package: "@codemcp/workflows-server", + allowedTools: ["whats_next", "conduct_review"] + }, + context + ); + expect(result.mcp_servers![0].allowedTools).toEqual([ + "whats_next", + "conduct_review" + ]); + }); + + it("omits allowedTools from entry when not specified", async () => { + const result = await workflowsWriter.write( + { package: "@codemcp/workflows-server" }, + context + ); + expect(result.mcp_servers![0]).not.toHaveProperty("allowedTools"); + }); + it("only returns mcp_servers, not other LogicalConfig keys", async () => { const result = await workflowsWriter.write( { package: "@codemcp/workflows-server" }, diff --git a/packages/core/src/writers/workflows.ts b/packages/core/src/writers/workflows.ts index a11ccfd..f8278f8 100644 --- a/packages/core/src/writers/workflows.ts +++ b/packages/core/src/writers/workflows.ts @@ -6,11 +6,13 @@ export const workflowsWriter: ProvisionWriterDef = { const { package: pkg, ref, - env + env, + allowedTools } = config as { package: string; ref?: string; env?: Record; + allowedTools?: string[]; }; return { mcp_servers: [ @@ -18,7 +20,8 @@ export const workflowsWriter: ProvisionWriterDef = { ref: ref ?? pkg, command: "npx", args: [pkg], - env: env ?? {} + env: env ?? {}, + ...(allowedTools !== undefined ? { allowedTools } : {}) } ] }; diff --git a/packages/harnesses/src/writers/copilot.spec.ts b/packages/harnesses/src/writers/copilot.spec.ts index 1c389de..b10ab6c 100644 --- a/packages/harnesses/src/writers/copilot.spec.ts +++ b/packages/harnesses/src/writers/copilot.spec.ts @@ -198,12 +198,8 @@ describe("copilotWriter", () => { expect(sensibleAgent).not.toContain(" - execute"); expect(sensibleAgent).not.toContain(" - todo"); expect(sensibleAgent).not.toContain(" - web"); - expect(sensibleAgent).toContain(" - workflows/whats_next"); - expect(sensibleAgent).toContain(" - workflows/proceed_to_phase"); - expect(sensibleAgent).not.toContain(" - workflows/*"); - expect(sensibleAgent).toContain( - ' tools: ["whats_next","proceed_to_phase"]' - ); + expect(sensibleAgent).toContain(" - workflows/*"); + expect(sensibleAgent).toContain(' tools: ["*"]'); expect(maxAgent).toContain(" - read"); expect(maxAgent).toContain(" - edit"); diff --git a/packages/harnesses/src/writers/copilot.ts b/packages/harnesses/src/writers/copilot.ts index 25ed502..1c43883 100644 --- a/packages/harnesses/src/writers/copilot.ts +++ b/packages/harnesses/src/writers/copilot.ts @@ -56,14 +56,7 @@ function getBuiltInTools(profile: AutonomyProfile | undefined): string[] { } function getForwardedMcpTools(servers: McpServerEntry[]): string[] { - return servers.flatMap((server) => { - const allowedTools = server.allowedTools ?? ["*"]; - if (allowedTools.includes("*")) { - return [`${server.ref}/*`]; - } - - return allowedTools.map((tool) => `${server.ref}/${tool}`); - }); + return servers.map((server) => `${server.ref}/*`); } function renderCopilotAgentMcpServers(servers: McpServerEntry[]): string[] { @@ -78,7 +71,7 @@ function renderCopilotAgentMcpServers(servers: McpServerEntry[]): string[] { lines.push(" type: stdio"); lines.push(` command: ${JSON.stringify(server.command)}`); lines.push(` args: ${JSON.stringify(server.args)}`); - lines.push(` tools: ${JSON.stringify(server.allowedTools ?? ["*"])}`); + lines.push(` tools: ${JSON.stringify(["*"])}`); if (Object.keys(server.env).length > 0) { lines.push(" env:"); diff --git a/packages/harnesses/src/writers/kiro.spec.ts b/packages/harnesses/src/writers/kiro.spec.ts index bbdad48..20bc172 100644 --- a/packages/harnesses/src/writers/kiro.spec.ts +++ b/packages/harnesses/src/writers/kiro.spec.ts @@ -186,4 +186,36 @@ describe("kiroWriter", () => { expect(rigidMcp.mcpServers.workflows.autoApprove).toEqual(["*"]); expect(maxMcp.mcpServers.workflows.autoApprove).toEqual(["*"]); }); + + it("uses wildcard in tools but restricted names in allowedTools when allowedTools is set", async () => { + const config: LogicalConfig = { + mcp_servers: [ + { + ref: "workflows", + command: "npx", + args: ["-y", "@codemcp/workflows"], + env: {}, + allowedTools: ["whats_next", "conduct_review"] + } + ], + instructions: [], + cli_actions: [], + knowledge_sources: [], + skills: [], + git_hooks: [], + setup_notes: [] + }; + + await kiroWriter.install(config, dir); + + const agent = JSON.parse( + await readFile(join(dir, ".kiro", "agents", "ade.json"), "utf-8") + ); + + expect(agent.tools).toContain("@workflows/*"); + expect(agent.tools).not.toContain("@workflows/whats_next"); + expect(agent.allowedTools).toContain("@workflows/whats_next"); + expect(agent.allowedTools).toContain("@workflows/conduct_review"); + expect(agent.allowedTools).not.toContain("@workflows/*"); + }); }); diff --git a/packages/harnesses/src/writers/kiro.ts b/packages/harnesses/src/writers/kiro.ts index 47af6c8..55000f6 100644 --- a/packages/harnesses/src/writers/kiro.ts +++ b/packages/harnesses/src/writers/kiro.ts @@ -27,6 +27,10 @@ export const kiroWriter: HarnessWriter = { }); const tools = getKiroTools(getAutonomyProfile(config), config.mcp_servers); + const allowedTools = getKiroAllowedTools( + getAutonomyProfile(config), + config.mcp_servers + ); await writeJson(join(projectRoot, ".kiro", "agents", "ade.json"), { name: "ade", description: @@ -36,7 +40,7 @@ export const kiroWriter: HarnessWriter = { "ADE — Agentic Development Environment agent.", mcpServers: getKiroAgentMcpServers(config.mcp_servers), tools, - allowedTools: tools, + allowedTools, useLegacyMcpJson: true }); @@ -48,7 +52,7 @@ function getKiroTools( profile: AutonomyProfile | undefined, servers: McpServerEntry[] ): string[] { - const mcpTools = getKiroForwardedMcpTools(servers); + const mcpTools = servers.map((server) => `@${server.ref}/*`); switch (profile) { case "rigid": @@ -62,15 +66,28 @@ function getKiroTools( } } -function getKiroForwardedMcpTools(servers: McpServerEntry[]): string[] { - return servers.flatMap((server) => { +function getKiroAllowedTools( + profile: AutonomyProfile | undefined, + servers: McpServerEntry[] +): string[] { + const mcpAllowedTools = servers.flatMap((server) => { const allowedTools = server.allowedTools ?? ["*"]; if (allowedTools.includes("*")) { return [`@${server.ref}/*`]; } - return allowedTools.map((tool) => `@${server.ref}/${tool}`); }); + + switch (profile) { + case "rigid": + return ["read", "shell", "spec", ...mcpAllowedTools]; + case "sensible-defaults": + return ["read", "write", "shell", "spec", ...mcpAllowedTools]; + case "max-autonomy": + return ["read", "write", "shell(*)", "spec", ...mcpAllowedTools]; + default: + return ["read", "write", "shell", "spec", ...mcpAllowedTools]; + } } function getKiroAgentMcpServers( diff --git a/packages/harnesses/src/writers/opencode.spec.ts b/packages/harnesses/src/writers/opencode.spec.ts index c407d2e..3ee2af3 100644 --- a/packages/harnesses/src/writers/opencode.spec.ts +++ b/packages/harnesses/src/writers/opencode.spec.ts @@ -148,6 +148,72 @@ describe("opencodeWriter", () => { expect(rigidAgent).not.toContain("tools:"); }); + it("writes allowed MCP tools into the permission block of the agent frontmatter", async () => { + const projectRoot = join(dir, "mcp-tools"); + const config: LogicalConfig = { + mcp_servers: [ + { + ref: "workflows", + command: "npx", + args: ["@codemcp/workflows-server@latest"], + env: {}, + allowedTools: ["whats_next", "conduct_review"] + } + ], + instructions: ["Follow project rules."], + cli_actions: [], + knowledge_sources: [], + skills: [], + git_hooks: [], + setup_notes: [] + }; + + await opencodeWriter.install(config, projectRoot); + + const agent = await readFile( + join(projectRoot, ".opencode", "agents", "ade.md"), + "utf-8" + ); + const frontmatter = parseFrontmatter(agent); + const permission = frontmatter.permission as Record; + + expect(permission["workflows*"]).toBe("ask"); + expect(permission["workflows_whats_next"]).toBe("allow"); + expect(permission["workflows_conduct_review"]).toBe("allow"); + expect(agent).not.toContain("tools:"); + }); + + it("writes wildcard MCP permission when allowedTools is not restricted", async () => { + const projectRoot = join(dir, "mcp-wildcard"); + const config: LogicalConfig = { + mcp_servers: [ + { + ref: "workflows", + command: "npx", + args: ["@codemcp/workflows-server@latest"], + env: {} + } + ], + instructions: ["Follow project rules."], + cli_actions: [], + knowledge_sources: [], + skills: [], + git_hooks: [], + setup_notes: [] + }; + + await opencodeWriter.install(config, projectRoot); + + const agent = await readFile( + join(projectRoot, ".opencode", "agents", "ade.md"), + "utf-8" + ); + const frontmatter = parseFrontmatter(agent); + const permission = frontmatter.permission as Record; + + expect(permission["workflows*"]).toBe("allow"); + }); + it("keeps MCP servers in project config and writes documented environment fields", async () => { const projectRoot = join(dir, "mcp"); const config = { diff --git a/packages/harnesses/src/writers/opencode.ts b/packages/harnesses/src/writers/opencode.ts index 584acc3..f55a321 100644 --- a/packages/harnesses/src/writers/opencode.ts +++ b/packages/harnesses/src/writers/opencode.ts @@ -1,5 +1,9 @@ import { join } from "node:path"; -import type { AutonomyProfile, LogicalConfig } from "@codemcp/ade-core"; +import type { + AutonomyProfile, + LogicalConfig, + McpServerEntry +} from "@codemcp/ade-core"; import type { HarnessWriter } from "../types.js"; import { writeAgentMd, @@ -138,6 +142,24 @@ const MAX_AUTONOMY_RULES: Record = { doom_loop: "deny" }; +function getMcpPermissions( + servers: McpServerEntry[] +): Record | undefined { + const entries: [string, PermissionRule][] = servers.flatMap((server) => { + const allowedTools = server.allowedTools ?? ["*"]; + if (allowedTools.includes("*")) { + return [[`${server.ref}*`, "allow"]] as [string, PermissionRule][]; + } + return [ + [`${server.ref}*`, "ask"] as [string, PermissionRule], + ...allowedTools.map( + (tool) => [`${server.ref}_${tool}`, "allow"] as [string, PermissionRule] + ) + ]; + }); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + function getPermissionRules( profile: AutonomyProfile | undefined ): Record | undefined { @@ -170,11 +192,16 @@ export const opencodeWriter: HarnessWriter = { }); const permission = getPermissionRules(getAutonomyProfile(config)); + const mcpPermissions = getMcpPermissions(config.mcp_servers); + const mergedPermission = + permission || mcpPermissions + ? { ...(mcpPermissions ?? {}), ...(permission ?? {}) } + : undefined; await writeAgentMd(config, { path: join(projectRoot, ".opencode", "agents", "ade.md"), - extraFrontmatter: permission - ? renderYamlMapping("permission", permission) + extraFrontmatter: mergedPermission + ? renderYamlMapping("permission", mergedPermission) : undefined, fallbackBody: "ADE — Agentic Development Environment agent with project conventions and tools."