diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index baea4c650..e39f45ddf 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -147,10 +147,13 @@ export class FsProjectManager implements ProjectManager { const existingProjectSpec = await this.json.read(agentCoreSpecPath, ProjectSpecSchema); const existingResources = existingProjectSpec[projectSpecKey]; - if (existingResources.find((r) => r.name === resourceConfig.name)) + if (resourceType === "gateway-target") { + this.assertUniqueGatewayTargetName(existingProjectSpec, resourceConfig.name); + } else if (existingResources.find((resource) => resource.name === resourceConfig.name)) { throw new InputValidationError( `a ${resourceType} with name '${resourceConfig.name}' already exists`, ); + } // Widened: arms push their own shapes; the whole-spec safeParse below validates. const newResources: unknown[] = [...existingResources]; @@ -158,7 +161,7 @@ export class FsProjectManager implements ProjectManager { // Non-file work that a failed spec write must also reverse. let envFile: EnvLocalFile | undefined; - switch (resourceType) { + switch (input.resourceType) { case "harness": { yield { message: `Scaffolding harness in project` }; const outputPath = join(project.rootPath, "app", resourceConfig.name); @@ -195,9 +198,25 @@ export class FsProjectManager implements ProjectManager { case "config-bundle": case "online-eval": case "online-insight": + case "gateway": newResources.push(resourceConfig); break; - + case "gateway-target": { + const gatewayIndex = existingProjectSpec.agentCoreGateways.findIndex( + (gateway) => gateway.name === input.gatewayName, + ); + if (gatewayIndex < 0) { + throw new InputValidationError( + `gateway '${input.gatewayName}' does not exist in agentCoreGateways[]`, + ); + } + const gateway = existingProjectSpec.agentCoreGateways[gatewayIndex]!; + newResources[gatewayIndex] = { + ...gateway, + targets: [...gateway.targets, resourceConfig], + }; + break; + } default: { const unhandled: never = input; throw new NotImplementedError(`unsupported project resource: ${String(unhandled)}`); @@ -283,6 +302,22 @@ export class FsProjectManager implements ProjectManager { }; } + private assertUniqueGatewayTargetName(project: Project["spec"], name: string): void { + const gateway = project.agentCoreGateways.find((candidate) => + candidate.targets.some((target) => target.name === name), + ); + if (gateway) { + throw new InputValidationError( + `a gateway target with name '${name}' already exists in gateway '${gateway.name}'`, + ); + } + if (project.unassignedTargets?.some((target) => target.name === name)) { + throw new InputValidationError( + `an unassigned gateway target with name '${name}' already exists`, + ); + } + } + private async scaffoldHarness( outputPath: string, harnessSpec: z.input, @@ -341,5 +376,8 @@ function toProjectSpecKey(resourceType: ProjectResource) { case "online-eval": case "online-insight": return "onlineEvalConfigs"; + case "gateway": + case "gateway-target": + return "agentCoreGateways"; } } diff --git a/src/handlers/project/add/gateway-connector/index.test.ts b/src/handlers/project/add/gateway-connector/index.test.ts new file mode 100644 index 000000000..dae8be5ff --- /dev/null +++ b/src/handlers/project/add/gateway-connector/index.test.ts @@ -0,0 +1,168 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createGatewayProjectTestHarness } from "../gateway-test-support"; + +const COMPLETE_CONNECTOR = JSON.stringify({ + name: "configured", + targetType: "connector", + connectorId: "web-search", +}); + +const { addGateway, cleanup, inProject, projectSpec, run } = + createGatewayProjectTestHarness("gateway-connector"); + +afterEach(cleanup); + +describe("project add gateway-connector", () => { + test("adds Web Search and external Knowledge Base connectors", async () => { + const projectRoot = await inProject(); + await addGateway(); + + await run([ + "add", + "gateway-connector", + "--gateway", + "tools", + "--name", + "web", + "--connector", + "web-search", + ]); + await run([ + "add", + "gateway-connector", + "--gateway", + "tools", + "--name", + "knowledge", + "--connector", + "bedrock-knowledge-bases", + "--knowledge-base", + "ABCDEFGHIJ", + ]); + + expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets).toEqual([ + { + name: "web", + targetType: "connector", + connectorId: "web-search", + configurations: [{ name: "WebSearch", parameterValues: { maxResults: 10 } }], + }, + { + name: "knowledge", + targetType: "connector", + connectorId: "bedrock-knowledge-bases", + configurations: [{ name: "Retrieve", parameterValues: { knowledgeBaseId: "ABCDEFGHIJ" } }], + }, + ]); + }); + + test("reads a complete connector Target from a file", async () => { + const projectRoot = await inProject(); + await addGateway(); + const target = { + name: "configured", + targetType: "connector", + connectorId: "web-search", + configurations: [{ name: "WebSearch", parameterValues: { maxResults: 3 } }], + }; + const path = join(projectRoot, "connector.json"); + await writeFile(path, JSON.stringify(target)); + + await run([ + "add", + "gateway-connector", + "--gateway", + "tools", + "--connector-configuration", + `file://${path}`, + ]); + + expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0]).toEqual(target); + }); + + test("rejects a non-connector project Target", async () => { + await inProject(); + await addGateway(); + + await expect( + run([ + "add", + "gateway-connector", + "--gateway", + "tools", + "--connector-configuration", + '{"name":"server","targetType":"mcpServer","endpoint":"https://mcp.example.com"}', + ]), + ).rejects.toThrow('targetType: "connector"'); + }); + + test.each([ + [ + "missing parent Gateway", + ["--name", "web", "--connector", "web-search"], + "required option '--gateway", + ], + ["no connector mode", ["--gateway", "tools", "--name", "web"], "specify exactly one"], + [ + "both connector modes", + [ + "--gateway", + "tools", + "--name", + "web", + "--connector", + "web-search", + "--connector-configuration", + COMPLETE_CONNECTOR, + ], + "specify exactly one", + ], + [ + "name with complete connector JSON", + ["--gateway", "tools", "--name", "web", "--connector-configuration", COMPLETE_CONNECTOR], + "--name is part of --connector-configuration", + ], + [ + "knowledge base with complete connector JSON", + [ + "--gateway", + "tools", + "--connector-configuration", + COMPLETE_CONNECTOR, + "--knowledge-base", + "ABCDEFGHIJ", + ], + "--knowledge-base cannot be combined", + ], + [ + "shortcut without name", + ["--gateway", "tools", "--connector", "web-search"], + "required option '--name", + ], + [ + "knowledge base with Web Search", + [ + "--gateway", + "tools", + "--name", + "web", + "--connector", + "web-search", + "--knowledge-base", + "ABCDEFGHIJ", + ], + "--knowledge-base requires --connector bedrock-knowledge-bases", + ], + [ + "Knowledge Base connector without Knowledge Base", + ["--gateway", "tools", "--name", "knowledge", "--connector", "bedrock-knowledge-bases"], + "requires --knowledge-base", + ], + ])("rejects %s", async (_label, flags, message) => { + await inProject(); + await addGateway(); + await expect(run(["add", "gateway-connector", ...flags])).rejects.toThrow(message); + }); +}); diff --git a/src/handlers/project/add/gateway-connector/index.ts b/src/handlers/project/add/gateway-connector/index.ts new file mode 100644 index 000000000..63dae6710 --- /dev/null +++ b/src/handlers/project/add/gateway-connector/index.ts @@ -0,0 +1,127 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { SourceResolver } from "../../../../io"; +import { + AgentCoreGatewayTargetSchema, + type AgentCoreGatewayTarget, + type ConnectorId, +} from "../../../../projectSchemas/gateway"; +import { createHandler, flag, ProjectKey } from "../../../../router"; +import { parseJsonFlagWithSchema } from "../../../utils"; +import type { AddProjectResourceConfig } from "../types"; + +export const createAddGatewayConnectorHandler = (config: AddProjectResourceConfig) => + createHandler({ + name: "gateway-connector", + description: "adds a connector-backed Target to a project Gateway", + flags: [ + flag("gateway", "name of the parent Gateway in this project", z.string().optional()), + flag("name", "the Target name for a connector shortcut", z.string().optional()), + flag( + "connector", + "curated connector", + z.enum(["web-search", "bedrock-knowledge-bases"]).optional(), + ), + flag( + "connector-configuration", + "complete connector agentCoreGateways[].targets[] object (JSON; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "knowledge-base", + "project Knowledge Base name or external ten-character Knowledge Base ID", + z.string().optional(), + ), + ], + handle: async (ctx, flags) => { + if (!flags.gateway) { + throw new InputValidationError("required option '--gateway ' not specified"); + } + if ((flags.connector === undefined) === (flags["connector-configuration"] === undefined)) { + throw new InputValidationError( + "specify exactly one of '--connector' or '--connector-configuration'", + ); + } + + const usesConfiguration = flags["connector-configuration"] !== undefined; + if (usesConfiguration && flags.name !== undefined) { + throw new InputValidationError( + "--name is part of --connector-configuration and cannot be supplied separately", + ); + } + if (usesConfiguration && flags["knowledge-base"] !== undefined) { + throw new InputValidationError( + "--knowledge-base cannot be combined with --connector-configuration", + ); + } + if (!usesConfiguration && !flags.name) { + throw new InputValidationError("required option '--name ' not specified"); + } + if (flags["knowledge-base"] !== undefined && flags.connector !== "bedrock-knowledge-bases") { + throw new InputValidationError( + "--knowledge-base requires --connector bedrock-knowledge-bases", + ); + } + + const project = ctx.require(ProjectKey); + let target: AgentCoreGatewayTarget; + if (usesConfiguration) { + const source = new SourceResolver({ stdin: config.io.stdin }); + target = parseJsonFlagWithSchema( + "connector-configuration", + await source.resolveText("connector-configuration", flags["connector-configuration"]), + AgentCoreGatewayTargetSchema, + )!; + if (target.targetType !== "connector") { + throw new InputValidationError( + '--connector-configuration must have targetType: "connector"', + ); + } + } else { + target = connectorTargetFromShortcut( + flags.name!, + flags.connector!, + flags["knowledge-base"], + ); + } + + for await (const event of config.projectManager.addResource(project, { + resourceType: "gateway-target", + gatewayName: flags.gateway, + resourceConfig: target, + })) { + config.io.stderr.write(`${event.message}\n`); + } + config.io.stderr.write( + `added Connector Target '${target.name}' to Gateway '${flags.gateway}' in '${project.name}'\n`, + ); + }, + }); + +function connectorTargetFromShortcut( + name: string, + connectorId: ConnectorId, + knowledgeBase?: string, +): AgentCoreGatewayTarget { + switch (connectorId) { + case "web-search": + return { + name, + targetType: "connector", + connectorId, + configurations: [{ name: "WebSearch", parameterValues: { maxResults: 10 } }], + }; + case "bedrock-knowledge-bases": + if (!knowledgeBase) { + throw new InputValidationError( + "--connector bedrock-knowledge-bases requires --knowledge-base", + ); + } + return { + name, + targetType: "connector", + connectorId, + configurations: [{ name: "Retrieve", parameterValues: { knowledgeBaseId: knowledgeBase } }], + }; + } +} diff --git a/src/handlers/project/add/gateway-target/index.test.ts b/src/handlers/project/add/gateway-target/index.test.ts new file mode 100644 index 000000000..619f0a583 --- /dev/null +++ b/src/handlers/project/add/gateway-target/index.test.ts @@ -0,0 +1,417 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createGatewayProjectTestHarness } from "../gateway-test-support"; + +const DISCOVERY_URL = "https://idp.example.com/.well-known/openid-configuration"; +const ENDPOINT = "https://mcp.example.com"; +const OAUTH_CREDENTIAL = { + authorizerType: "OAuthCredentialProvider", + name: "oauth", + discoveryUrl: DISCOVERY_URL, +}; +const API_KEY_CREDENTIAL = { + authorizerType: "ApiKeyCredentialProvider", + name: "api-key", +}; + +const { addGateway, cleanup, inProject, projectSpec, run, writeProjectSpec } = + createGatewayProjectTestHarness("gateway-target"); + +afterEach(cleanup); + +function endpointFlags(...extra: string[]): string[] { + return ["--gateway", "tools", "--name", "target", "--endpoint", ENDPOINT, ...extra]; +} + +async function projectWithCredentials() { + const projectRoot = await inProject(); + const spec = await projectSpec(projectRoot); + spec.credentials = [OAUTH_CREDENTIAL, API_KEY_CREDENTIAL]; + await writeProjectSpec(projectRoot, spec); + await addGateway(); + return projectRoot; +} + +describe("project add gateway-target", () => { + test("adds endpoint and project Runtime shortcuts", async () => { + const projectRoot = await inProject(); + await addGateway(); + + await run([ + "add", + "gateway-target", + "--gateway", + "tools", + "--name", + "external", + "--endpoint", + ENDPOINT, + "--outbound-auth", + "none", + ]); + await run([ + "add", + "gateway-target", + "--gateway", + "tools", + "--name", + "runtime", + "--runtime", + "hello_world", + "--runtime-endpoint", + "DEFAULT", + ]); + + expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets).toEqual([ + { + name: "external", + targetType: "mcpServer", + endpoint: ENDPOINT, + outboundAuth: { type: "NONE" }, + }, + { + name: "runtime", + targetType: "httpRuntime", + httpRuntime: { runtime: "hello_world", runtimeEndpoint: "DEFAULT" }, + }, + ]); + }); + + test("persists complete project Target shapes without translation or asset creation", async () => { + const projectRoot = await inProject(); + await addGateway(); + const targets = [ + { + name: "search", + targetType: "lambdaFunctionArn", + lambdaFunctionArn: { + lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", + toolSchemaFile: "schemas/tool-schema.json", + }, + }, + { + name: "local-tool", + targetType: "lambda", + toolDefinitions: [ + { + name: "search", + description: "Search documents", + inputSchema: { type: "object", properties: {} }, + }, + ], + compute: { + host: "Lambda", + implementation: { + language: "Python", + path: "app/search-tool", + handler: "handler.py:handler", + }, + pythonVersion: "PYTHON_3_12", + }, + }, + ]; + + for (const target of targets) { + await run([ + "add", + "gateway-target", + "--gateway", + "tools", + "--target-configuration", + JSON.stringify(target), + ]); + } + + expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets).toEqual(targets); + expect(await Bun.file(join(projectRoot, "agentcore", "assets")).exists()).toBe(false); + }); + + test("reads complete Target JSON from a file", async () => { + const projectRoot = await inProject(); + await addGateway(); + const target = { + name: "source", + targetType: "mcpServer", + endpoint: "https://source.example.com", + }; + const path = join(projectRoot, "target.json"); + await writeFile(path, JSON.stringify(target)); + + await run([ + "add", + "gateway-target", + "--gateway", + "tools", + "--target-configuration", + `file://${path}`, + ]); + + expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0]).toEqual(target); + }); + + test("rejects shortcut flags with complete Target JSON", async () => { + await inProject(); + await addGateway(); + const target = JSON.stringify({ + name: "source", + targetType: "mcpServer", + endpoint: "https://source.example.com", + }); + + await expect( + run([ + "add", + "gateway-target", + "--gateway", + "tools", + "--name", + "duplicate", + "--target-configuration", + target, + ]), + ).rejects.toThrow("--name is part of --target-configuration"); + await expect( + run([ + "add", + "gateway-target", + "--gateway", + "tools", + "--target-configuration", + target, + "--outbound-auth", + "none", + ]), + ).rejects.toThrow("outboundAuth is part of --target-configuration"); + }); + + test("validates direct project credential references", async () => { + const projectRoot = await projectWithCredentials(); + const targets = [ + { + name: "oauth", + targetType: "mcpServer", + endpoint: "https://oauth.example.com", + outboundAuth: { + type: "OAUTH", + credentialName: "oauth", + scopes: ["read", "write"], + }, + }, + { + name: "api-key", + targetType: "openApiSchema", + schemaSource: { inline: { path: "openapi.json" } }, + outboundAuth: { type: "API_KEY", credentialName: "api-key" }, + }, + ]; + + for (const target of targets) { + await run([ + "add", + "gateway-target", + "--gateway", + "tools", + "--target-configuration", + JSON.stringify(target), + ]); + } + + expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets).toEqual(targets); + }); + + test("adds an OAuth-authenticated endpoint shortcut", async () => { + const projectRoot = await projectWithCredentials(); + + await run([ + "add", + "gateway-target", + ...endpointFlags( + "--outbound-auth", + "oauth", + "--credential-name", + "oauth", + "--scope", + "read", + "write", + ), + ]); + + expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0].outboundAuth).toEqual({ + type: "OAUTH", + credentialName: "oauth", + scopes: ["read", "write"], + }); + }); + + test.each([ + [ + "missing parent Gateway", + ["--name", "target", "--endpoint", ENDPOINT], + "required option '--gateway", + ], + ["no Target mode", ["--gateway", "tools", "--name", "target"], "specify exactly one"], + [ + "multiple Target modes", + [...endpointFlags(), "--runtime", "hello_world"], + "specify exactly one", + ], + [ + "runtime endpoint without Runtime mode", + [...endpointFlags(), "--runtime-endpoint", "DEFAULT"], + "--runtime-endpoint requires --runtime", + ], + [ + "shortcut without name", + ["--gateway", "tools", "--endpoint", ENDPOINT], + "required option '--name", + ], + [ + "non-HTTPS endpoint", + ["--gateway", "tools", "--name", "target", "--endpoint", "http://mcp.example.com"], + "must use HTTPS", + ], + [ + "invalid endpoint", + ["--gateway", "tools", "--name", "target", "--endpoint", "not-a-url"], + "must be a valid HTTPS URL", + ], + [ + "credential without auth type", + endpointFlags("--credential-name", "oauth"), + "--credential-name requires --outbound-auth", + ], + [ + "scope without auth type", + endpointFlags("--scope", "read"), + "--scope requires --outbound-auth oauth", + ], + [ + "none auth with credential", + endpointFlags("--outbound-auth", "none", "--credential-name", "oauth"), + "cannot be combined", + ], + [ + "OAuth without credential", + endpointFlags("--outbound-auth", "oauth"), + "requires --credential-name", + ], + [ + "API key with OAuth scope", + endpointFlags( + "--outbound-auth", + "api-key", + "--credential-name", + "api-key", + "--scope", + "read", + ), + "--scope is valid only with --outbound-auth oauth", + ], + [ + "API-key endpoint shortcut unsupported by the project schema", + endpointFlags("--outbound-auth", "api-key", "--credential-name", "api-key"), + "mcpServer targets do not support API_KEY outbound auth", + ], + [ + "unknown credential", + endpointFlags("--outbound-auth", "oauth", "--credential-name", "missing"), + "does not exist in credentials[]", + ], + [ + "credential with wrong type", + endpointFlags("--outbound-auth", "oauth", "--credential-name", "api-key"), + "not a OAuthCredentialProvider", + ], + [ + "unknown Gateway", + ["--gateway", "missing", "--name", "target", "--endpoint", ENDPOINT], + "does not exist in agentCoreGateways[]", + ], + ])("rejects %s", async (_label, flags, message) => { + await projectWithCredentials(); + await expect(run(["add", "gateway-target", ...flags])).rejects.toThrow(message); + }); + + test("rejects a direct API-key reference to an OAuth credential", async () => { + await projectWithCredentials(); + + await expect( + run([ + "add", + "gateway-target", + "--gateway", + "tools", + "--target-configuration", + JSON.stringify({ + name: "target", + targetType: "openApiSchema", + schemaSource: { inline: { path: "openapi.json" } }, + outboundAuth: { type: "API_KEY", credentialName: "oauth" }, + }), + ]), + ).rejects.toThrow("not a ApiKeyCredentialProvider"); + }); + + test("rejects duplicate Target names within or across Gateways", async () => { + const projectRoot = await inProject(); + await addGateway("tools"); + await addGateway("payments"); + await run([ + "add", + "gateway-target", + "--gateway", + "tools", + "--name", + "search", + "--endpoint", + "https://tools.example.com", + ]); + + for (const gateway of ["tools", "payments"]) { + await expect( + run([ + "add", + "gateway-target", + "--gateway", + gateway, + "--name", + "search", + "--endpoint", + `https://${gateway}.example.com`, + ]), + ).rejects.toThrow("already exists in gateway 'tools'"); + } + + const gateways = (await projectSpec(projectRoot)).agentCoreGateways; + expect(gateways[0].targets).toHaveLength(1); + expect(gateways[1].targets).toHaveLength(0); + }); + + test("rejects a Target name already present in unassignedTargets", async () => { + const projectRoot = await inProject(); + const spec = await projectSpec(projectRoot); + spec.unassignedTargets = [ + { + name: "search", + targetType: "mcpServer", + endpoint: "https://unassigned.example.com", + }, + ]; + await writeProjectSpec(projectRoot, spec); + await addGateway(); + + await expect( + run([ + "add", + "gateway-target", + "--gateway", + "tools", + "--name", + "search", + "--endpoint", + "https://tools.example.com", + ]), + ).rejects.toThrow("unassigned gateway target"); + }); +}); diff --git a/src/handlers/project/add/gateway-target/index.ts b/src/handlers/project/add/gateway-target/index.ts new file mode 100644 index 000000000..63a938907 --- /dev/null +++ b/src/handlers/project/add/gateway-target/index.ts @@ -0,0 +1,204 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { SourceResolver } from "../../../../io"; +import type { Credential } from "../../../../projectSchemas/credential"; +import { + AgentCoreGatewayTargetSchema, + type AgentCoreGatewayTarget, + type OutboundAuth, +} from "../../../../projectSchemas/gateway"; +import { createHandler, flag, ProjectKey } from "../../../../router"; +import { parseJsonFlagWithSchema } from "../../../utils"; +import type { Project } from "../../types"; +import type { AddProjectResourceConfig } from "../types"; + +export const createAddGatewayTargetHandler = (config: AddProjectResourceConfig) => + createHandler({ + name: "gateway-target", + description: "adds a Target to a project Gateway", + flags: [ + flag("gateway", "name of the parent Gateway in this project", z.string().optional()), + flag("name", "the Target name for endpoint or Runtime shortcuts", z.string().optional()), + flag("endpoint", "external MCP server HTTPS endpoint", z.string().optional()), + flag("runtime", "name of a Runtime declared in this project", z.string().optional()), + flag("runtime-endpoint", "named endpoint on the selected Runtime", z.string().optional()), + flag( + "target-configuration", + "complete agentCoreGateways[].targets[] object (JSON; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "outbound-auth", + "shortcut Target authentication: none, oauth, or api-key", + z.enum(["none", "oauth", "api-key"]).optional(), + ), + flag( + "credential-name", + "name of a compatible credential declared in this project", + z.string().optional(), + ), + flag("scope", "OAuth scope", z.array(z.string()).optional()), + ], + handle: async (ctx, flags) => { + if (!flags.gateway) { + throw new InputValidationError("required option '--gateway ' not specified"); + } + const modes = [ + ["--endpoint", flags.endpoint], + ["--runtime", flags.runtime], + ["--target-configuration", flags["target-configuration"]], + ].filter(([, value]) => value !== undefined); + if (modes.length !== 1) { + throw new InputValidationError( + "specify exactly one of '--endpoint', '--runtime', or '--target-configuration'", + ); + } + if (flags["runtime-endpoint"] !== undefined && flags.runtime === undefined) { + throw new InputValidationError("--runtime-endpoint requires --runtime"); + } + + const usesConfiguration = flags["target-configuration"] !== undefined; + if (usesConfiguration && flags.name !== undefined) { + throw new InputValidationError( + "--name is part of --target-configuration and cannot be supplied separately", + ); + } + if ( + usesConfiguration && + (flags["outbound-auth"] !== undefined || + flags["credential-name"] !== undefined || + flags.scope !== undefined) + ) { + throw new InputValidationError( + "outboundAuth is part of --target-configuration; shortcut auth flags cannot be combined with it", + ); + } + if (!usesConfiguration && !flags.name) { + throw new InputValidationError("required option '--name ' not specified"); + } + + const project = ctx.require(ProjectKey); + let target: AgentCoreGatewayTarget; + if (usesConfiguration) { + const source = new SourceResolver({ stdin: config.io.stdin }); + target = parseJsonFlagWithSchema( + "target-configuration", + await source.resolveText("target-configuration", flags["target-configuration"]), + AgentCoreGatewayTargetSchema, + )!; + validateTargetCredential(project, target); + } else { + const outboundAuth = projectOutboundAuth(project, { + type: flags["outbound-auth"], + credentialName: flags["credential-name"], + scopes: flags.scope, + }); + target = + flags.endpoint !== undefined + ? { + name: flags.name!, + targetType: "mcpServer", + endpoint: httpsEndpoint(flags.endpoint, "--endpoint"), + outboundAuth, + } + : { + name: flags.name!, + targetType: "httpRuntime", + httpRuntime: { + runtime: flags.runtime!, + runtimeEndpoint: flags["runtime-endpoint"], + }, + outboundAuth, + }; + } + + for await (const event of config.projectManager.addResource(project, { + resourceType: "gateway-target", + gatewayName: flags.gateway, + resourceConfig: target, + })) { + config.io.stderr.write(`${event.message}\n`); + } + config.io.stderr.write( + `added Target '${target.name}' to Gateway '${flags.gateway}' in '${project.name}'\n`, + ); + }, + }); + +type OutboundAuthInput = { + type?: "none" | "oauth" | "api-key"; + credentialName?: string; + scopes?: string[]; +}; + +function projectOutboundAuth(project: Project, input: OutboundAuthInput): OutboundAuth | undefined { + if (!input.type) { + if (input.credentialName) { + throw new InputValidationError("--credential-name requires --outbound-auth oauth or api-key"); + } + if (input.scopes) { + throw new InputValidationError("--scope requires --outbound-auth oauth"); + } + return undefined; + } + if (input.type === "none") { + if (input.credentialName || input.scopes) { + throw new InputValidationError( + "--outbound-auth none cannot be combined with --credential-name or --scope", + ); + } + return { type: "NONE" }; + } + if (!input.credentialName) { + throw new InputValidationError(`--outbound-auth ${input.type} requires --credential-name`); + } + if (input.type === "api-key" && input.scopes) { + throw new InputValidationError("--scope is valid only with --outbound-auth oauth"); + } + + const credential = requireCredential(project, input.credentialName); + assertCredentialType(credential, input.type); + return { + type: input.type === "oauth" ? "OAUTH" : "API_KEY", + credentialName: input.credentialName, + scopes: input.type === "oauth" ? input.scopes : undefined, + }; +} + +function validateTargetCredential(project: Project, target: AgentCoreGatewayTarget): void { + const auth = target.outboundAuth; + if (!auth?.credentialName) return; + + const credential = requireCredential(project, auth.credentialName); + if (auth.type === "OAUTH") assertCredentialType(credential, "oauth"); + if (auth.type === "API_KEY") assertCredentialType(credential, "api-key"); +} + +function requireCredential(project: Project, name: string): Credential { + const credential = project.spec.credentials.find((candidate) => candidate.name === name); + if (!credential) { + throw new InputValidationError(`credential '${name}' does not exist in credentials[]`); + } + return credential; +} + +function assertCredentialType(credential: Credential, auth: "oauth" | "api-key"): void { + const expected = auth === "oauth" ? "OAuthCredentialProvider" : "ApiKeyCredentialProvider"; + if (credential.authorizerType !== expected) { + throw new InputValidationError( + `credential '${credential.name}' is a ${credential.authorizerType}, not a ${expected}`, + ); + } +} + +function httpsEndpoint(value: string, option: string): string { + try { + if (new URL(value).protocol !== "https:") { + throw new InputValidationError(`${option} must use HTTPS`); + } + } catch (error) { + if (error instanceof InputValidationError) throw error; + throw new InputValidationError(`${option} must be a valid HTTPS URL`, { cause: error }); + } + return value; +} diff --git a/src/handlers/project/add/gateway-test-support.ts b/src/handlers/project/add/gateway-test-support.ts new file mode 100644 index 000000000..e373764d6 --- /dev/null +++ b/src/handlers/project/add/gateway-test-support.ts @@ -0,0 +1,61 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createRootHandler } from "../../index"; +import { + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../../testing"; + +export function createGatewayProjectTestHarness(directoryPrefix: string) { + const originalCwd = process.cwd(); + const tempDirectories: string[] = []; + + async function run(args: string[], stdin?: string) { + const io = testIO(); + if (stdin !== undefined) io.io.stdin.end(stdin); + const root = createRootHandler(new TestCoreClient(), { + io: io.io, + globalConfigAccessor: new TestGlobalConfigAccessor(), + logger: createSilentLogger(), + }); + await root.route(["node", "agentcore", "project", ...args]); + return io; + } + + async function inProject(name = "TestProject"): Promise { + const directory = await mkdtemp(join(tmpdir(), `agentcore-${directoryPrefix}-`)); + tempDirectories.push(directory); + process.chdir(directory); + await run(["create", "--name", name, "--skip-install", "--skip-git"]); + const projectRoot = join(directory, name); + process.chdir(projectRoot); + return projectRoot; + } + + async function projectSpec(projectRoot: string) { + return Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + } + + async function writeProjectSpec(projectRoot: string, spec: unknown): Promise { + await Bun.write( + join(projectRoot, "agentcore", "agentcore.json"), + JSON.stringify(spec, undefined, 2), + ); + } + + async function addGateway(name = "tools"): Promise { + await run(["add", "gateway", "--name", name]); + } + + async function cleanup(): Promise { + process.chdir(originalCwd); + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); + } + + return { addGateway, cleanup, inProject, projectSpec, run, writeProjectSpec }; +} diff --git a/src/handlers/project/add/gateway/index.test.ts b/src/handlers/project/add/gateway/index.test.ts new file mode 100644 index 000000000..9958a9273 --- /dev/null +++ b/src/handlers/project/add/gateway/index.test.ts @@ -0,0 +1,210 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createGatewayProjectTestHarness } from "../gateway-test-support"; + +const { cleanup, inProject, projectSpec, run, writeProjectSpec } = + createGatewayProjectTestHarness("gateway-add"); + +afterEach(cleanup); + +describe("project add gateway", () => { + test("adds the default unrestricted Gateway", async () => { + const projectRoot = await inProject(); + const io = await run(["add", "gateway", "--name", "tools"]); + + expect((await projectSpec(projectRoot)).agentCoreGateways).toEqual([ + { + name: "tools", + protocolType: "None", + targets: [], + authorizerType: "NONE", + enableSemanticSearch: false, + exceptionLevel: "NONE", + }, + ]); + expect(io.stderr()).toContain("added Gateway 'tools'"); + }); + + test("maps scalar flags directly to Gateway project fields", async () => { + const projectRoot = await inProject(); + const spec = await projectSpec(projectRoot); + spec.policyEngines = [{ name: "Guardrails", policies: [] }]; + await writeProjectSpec(projectRoot, spec); + + await run([ + "add", + "gateway", + "--name", + "tools", + "--protocol", + "mcp", + "--enable-semantic-search", + "--role-arn", + "arn:aws:iam::123456789012:role/GatewayRole", + "--description", + "Project tools", + "--policy-engine-name", + "Guardrails", + "--policy-engine-mode", + "enforce", + "--exception-level", + "debug", + "--tags", + '{"team":"agents"}', + ]); + + expect((await projectSpec(projectRoot)).agentCoreGateways[0]).toMatchObject({ + name: "tools", + protocolType: "MCP", + description: "Project tools", + authorizerType: "NONE", + enableSemanticSearch: true, + exceptionLevel: "DEBUG", + executionRoleArn: "arn:aws:iam::123456789012:role/GatewayRole", + policyEngineConfiguration: { policyEngineName: "Guardrails", mode: "ENFORCE" }, + tags: { team: "agents" }, + }); + }); + + test("reads project authorizerConfiguration from stdin without translation", async () => { + const projectRoot = await inProject(); + await run( + [ + "add", + "gateway", + "--name", + "secure", + "--authorizer-type", + "CUSTOM_JWT", + "--authorizer-configuration", + "-", + ], + JSON.stringify({ + customJwtAuthorizer: { + discoveryUrl: "https://idp.example.com/.well-known/openid-configuration", + allowedAudience: ["agentcore"], + }, + }), + ); + + expect((await projectSpec(projectRoot)).agentCoreGateways[0]).toMatchObject({ + authorizerType: "CUSTOM_JWT", + authorizerConfiguration: { + customJwtAuthorizer: { + discoveryUrl: "https://idp.example.com/.well-known/openid-configuration", + allowedAudience: ["agentcore"], + }, + }, + }); + }); + + test("rejects the SDK authorizer shape without writing a Gateway", async () => { + const projectRoot = await inProject(); + await expect( + run([ + "add", + "gateway", + "--name", + "secure", + "--authorizer-type", + "CUSTOM_JWT", + "--authorizer-configuration", + '{"customJWTAuthorizer":{"discoveryUrl":"https://idp.example.com/.well-known/openid-configuration"}}', + ]), + ).rejects.toThrow("customJWTAuthorizer"); + + expect((await projectSpec(projectRoot)).agentCoreGateways ?? []).toEqual([]); + }); + + test("maps log-only policy mode", async () => { + const projectRoot = await inProject(); + const spec = await projectSpec(projectRoot); + spec.policyEngines = [{ name: "Guardrails", policies: [] }]; + await writeProjectSpec(projectRoot, spec); + + await run([ + "add", + "gateway", + "--name", + "tools", + "--policy-engine-name", + "Guardrails", + "--policy-engine-mode", + "log-only", + ]); + + expect((await projectSpec(projectRoot)).agentCoreGateways[0].policyEngineConfiguration).toEqual( + { + policyEngineName: "Guardrails", + mode: "LOG_ONLY", + }, + ); + }); + + test("maps repeated key=value tags", async () => { + const projectRoot = await inProject(); + await run(["add", "gateway", "--name", "tools", "--tags", "source=pairs", "team=agents"]); + + expect((await projectSpec(projectRoot)).agentCoreGateways[0].tags).toEqual({ + source: "pairs", + team: "agents", + }); + }); + + test.each([ + ["missing --name", ["add", "gateway"], "required option '--name"], + [ + "service resource name exceeds 48 characters", + ["add", "gateway", "--name", "gateway-name-that-is-far-too-long-for-the-service"], + "exceeds the service limit", + ], + [ + "policy engine name without mode", + ["add", "gateway", "--name", "tools", "--policy-engine-name", "Guardrails"], + "must be supplied together", + ], + [ + "policy engine mode without name", + ["add", "gateway", "--name", "tools", "--policy-engine-mode", "enforce"], + "must be supplied together", + ], + [ + "unknown policy engine", + [ + "add", + "gateway", + "--name", + "tools", + "--policy-engine-name", + "Missing", + "--policy-engine-mode", + "enforce", + ], + "does not exist in policyEngines[]", + ], + [ + "CUSTOM_JWT without configuration", + ["add", "gateway", "--name", "tools", "--authorizer-type", "CUSTOM_JWT"], + "CUSTOM_JWT requires --authorizer-configuration", + ], + [ + "configuration without CUSTOM_JWT", + [ + "add", + "gateway", + "--name", + "tools", + "--authorizer-configuration", + '{"customJwtAuthorizer":{"discoveryUrl":"https://idp.example.com"}}', + ], + "valid only with CUSTOM_JWT", + ], + [ + "semantic search without MCP", + ["add", "gateway", "--name", "tools", "--enable-semantic-search"], + "--protocol mcp", + ], + ])("rejects %s", async (_label, args, message) => { + await inProject(); + await expect(run(args)).rejects.toThrow(message); + }); +}); diff --git a/src/handlers/project/add/gateway/index.ts b/src/handlers/project/add/gateway/index.ts new file mode 100644 index 000000000..15670259f --- /dev/null +++ b/src/handlers/project/add/gateway/index.ts @@ -0,0 +1,154 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { SourceResolver } from "../../../../io"; +import { GatewayAuthorizerConfigSchema } from "../../../../projectSchemas/auth"; +import type { AgentCoreGateway } from "../../../../projectSchemas/gateway"; +import { createHandler, flag, ProjectKey } from "../../../../router"; +import { parseJsonFlagWithSchema, parseTags } from "../../../utils"; +import type { AddProjectResourceConfig } from "../types"; + +const GatewayAuthorizerConfigurationInputSchema = GatewayAuthorizerConfigSchema.strict(); + +export const createAddGatewayHandler = (config: AddProjectResourceConfig) => + createHandler({ + name: "gateway", + description: "adds a Gateway to the current project", + flags: [ + flag("name", "the Gateway name", z.string().optional()), + flag( + "role-arn", + "IAM role the Gateway assumes; a default role is created when omitted", + z.string().optional(), + ), + flag( + "protocol", + "restrict Target protocols to MCP; omitted allows every Target protocol", + z.enum(["mcp"]).optional(), + ), + flag( + "enable-semantic-search", + "enable semantic search for an MCP Gateway", + z.boolean().optional(), + ), + flag( + "authorizer-type", + "inbound authorizer: AWS_IAM, CUSTOM_JWT, or NONE", + z.enum(["AWS_IAM", "CUSTOM_JWT", "NONE"]).optional(), + ), + flag("description", "Gateway description", z.string().optional()), + flag( + "authorizer-configuration", + "project authorizerConfiguration (JSON; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "policy-engine-name", + "name of a Policy Engine declared in this project", + z.string().optional(), + ), + flag( + "policy-engine-mode", + "Policy Engine mode: log-only or enforce", + z.enum(["log-only", "enforce"]).optional(), + ), + flag("exception-level", "exception detail level: debug", z.enum(["debug"]).optional()), + flag( + "tags", + "tags as repeated key=value or a JSON object (inline, file://, or - for stdin)", + z.array(z.string()).optional(), + ), + ], + handle: async (ctx, flags) => { + if (!flags.name) { + throw new InputValidationError("required option '--name ' not specified"); + } + const project = ctx.require(ProjectKey); + const resourceName = `${project.name}-${flags.name}`; + if (resourceName.length > 48) { + throw new InputValidationError( + `Gateway resource name '${resourceName}' exceeds the service limit of 48 characters`, + ); + } + if ( + (flags["policy-engine-name"] === undefined) !== + (flags["policy-engine-mode"] === undefined) + ) { + throw new InputValidationError( + "--policy-engine-name and --policy-engine-mode must be supplied together", + ); + } + if ( + flags["policy-engine-name"] && + !project.spec.policyEngines.some((engine) => engine.name === flags["policy-engine-name"]) + ) { + throw new InputValidationError( + `policy engine '${flags["policy-engine-name"]}' does not exist in policyEngines[]`, + ); + } + + const authorizerType = flags["authorizer-type"] ?? "NONE"; + if (authorizerType === "CUSTOM_JWT" && flags["authorizer-configuration"] === undefined) { + throw new InputValidationError("CUSTOM_JWT requires --authorizer-configuration"); + } + if (authorizerType !== "CUSTOM_JWT" && flags["authorizer-configuration"] !== undefined) { + throw new InputValidationError("--authorizer-configuration is valid only with CUSTOM_JWT"); + } + if (flags["enable-semantic-search"] && !flags.protocol) { + throw new InputValidationError( + "--enable-semantic-search is valid only with --protocol mcp", + ); + } + + const source = new SourceResolver({ stdin: config.io.stdin }); + const authorizerConfiguration = parseJsonFlagWithSchema( + "authorizer-configuration", + await source.resolveText("authorizer-configuration", flags["authorizer-configuration"]), + GatewayAuthorizerConfigurationInputSchema, + ); + const tags = await resolveTags(source, flags.tags); + + const gateway: AgentCoreGateway = { + name: flags.name, + protocolType: flags.protocol ? "MCP" : "None", + authorizerType, + authorizerConfiguration, + description: flags.description, + targets: [], + enableSemanticSearch: flags["enable-semantic-search"] ?? false, + exceptionLevel: flags["exception-level"] ? "DEBUG" : "NONE", + executionRoleArn: flags["role-arn"], + policyEngineConfiguration: + flags["policy-engine-name"] && flags["policy-engine-mode"] + ? { + policyEngineName: flags["policy-engine-name"], + mode: flags["policy-engine-mode"] === "enforce" ? "ENFORCE" : "LOG_ONLY", + } + : undefined, + tags, + }; + + for await (const event of config.projectManager.addResource(project, { + resourceType: "gateway", + resourceConfig: gateway, + })) { + config.io.stderr.write(`${event.message}\n`); + } + config.io.stderr.write(`added Gateway '${flags.name}' to '${project.name}'\n`); + }, + }); + +async function resolveTags( + source: SourceResolver, + values: string[] | undefined, +): Promise | undefined> { + const first = values?.[0]; + if ( + values?.length === 1 && + first && + (first === "-" || first.startsWith("file://") || first.trimStart().startsWith("{")) + ) { + const resolved = await source.resolveText("tags", first); + return parseTags([resolved!]); + } + return parseTags(values); +} diff --git a/src/handlers/project/add/index.ts b/src/handlers/project/add/index.ts index 42f6de230..ee519a81a 100644 --- a/src/handlers/project/add/index.ts +++ b/src/handlers/project/add/index.ts @@ -6,6 +6,9 @@ import { createAddHarnessHandler } from "./harness"; import { createAddRuntimeHandler } from "./runtime"; import { createAddOnlineEvalHandler } from "./online-eval"; import { createAddOnlineInsightHandler } from "./online-insight"; +import { createAddGatewayHandler } from "./gateway"; +import { createAddGatewayTargetHandler } from "./gateway-target"; +import { createAddGatewayConnectorHandler } from "./gateway-connector"; import type { AddProjectResourceConfig } from "./types"; export function createAddProjectResourceHandler(config: AddProjectResourceConfig): Router { @@ -17,5 +20,8 @@ export function createAddProjectResourceHandler(config: AddProjectResourceConfig projectAdd.handler(createAddOnlineEvalHandler(config)); projectAdd.handler(createAddOnlineInsightHandler(config)); projectAdd.handler(createAddCredentialsHandler(config)); + projectAdd.handler(createAddGatewayHandler(config)); + projectAdd.handler(createAddGatewayTargetHandler(config)); + projectAdd.handler(createAddGatewayConnectorHandler(config)); return projectAdd; } diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 625b9ec69..11118bc1a 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -5,6 +5,7 @@ import type { ProjectSpecSchema } from "../../projectSchemas/project"; import z from "zod"; import type { RuntimeResourceConfig } from "./add/runtime/types"; import type { OnlineEvalConfigSchema } from "../../projectSchemas/online-eval-config"; +import type { AgentCoreGateway, AgentCoreGatewayTarget } from "../../projectSchemas/gateway"; /** Available runtime templates for scaffolding agent code. A subset of {@link PROJECT_TEMPLATES} describing runtimes only */ export const RUNTIME_TEMPLATES = { @@ -84,12 +85,21 @@ export type AddResourceInput = | { resourceType: "online-insight"; resourceConfig: z.input; + } + | { + resourceType: "gateway"; + resourceConfig: AgentCoreGateway; + } + | { + resourceType: "gateway-target"; + gatewayName: string; + resourceConfig: AgentCoreGatewayTarget; }; export type ProjectResource = AddResourceInput["resourceType"]; export type RemoveResourceInput = { - resourceType: ProjectResource; + resourceType: Exclude; name: string; };