From db6a0779642d538a351da351ce0719a4b5bcad57 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 18 Aug 2026 20:17:32 +0000 Subject: [PATCH 1/7] feat(project): add gateway resources --- src/core/project/manager.test.ts | 195 ++++++++ src/core/project/manager.tsx | 44 +- src/handlers/project/add/gateway-add.test.ts | 467 ++++++++++++++++++ .../project/add/gateway-connector/index.ts | 84 ++++ .../add/gateway-target/configuration.test.ts | 353 +++++++++++++ .../add/gateway-target/configuration.ts | 428 ++++++++++++++++ .../project/add/gateway-target/index.ts | 170 +++++++ src/handlers/project/add/gateway/index.ts | 264 ++++++++++ src/handlers/project/add/index.ts | 6 + src/handlers/project/types.ts | 26 +- 10 files changed, 2033 insertions(+), 4 deletions(-) create mode 100644 src/handlers/project/add/gateway-add.test.ts create mode 100644 src/handlers/project/add/gateway-connector/index.ts create mode 100644 src/handlers/project/add/gateway-target/configuration.test.ts create mode 100644 src/handlers/project/add/gateway-target/configuration.ts create mode 100644 src/handlers/project/add/gateway-target/index.ts create mode 100644 src/handlers/project/add/gateway/index.ts diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index d9254ca07..599b996e4 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -6,11 +6,13 @@ import { DeserializationError, ProjectStateError } from "../../errors/errors"; import { FsProjectManager } from "./manager"; import { PROJECT_TEMPLATES, + type AddResourceInput, type CreateProjectInput, type Project, type ProjectEvent, } from "../../handlers/project/types"; import { createSilentLogger } from "../../testing"; +import { FsReadWriteJson, type ReadWriteJson } from "../../io"; const originalCwd = process.cwd(); const tempDirectories: string[] = []; @@ -62,6 +64,21 @@ async function runCreate( } } +async function runAdd( + subject: FsProjectManager, + project: Project, + input: AddResourceInput, +): Promise<{ events: ProjectEvent[]; project: Project }> { + const iterator = subject.addResource(project, input); + const events: ProjectEvent[] = []; + + while (true) { + const next = await iterator.next(); + if (next.done) return { events, project: next.value }; + events.push(next.value); + } +} + describe("FsProjectManager.create", () => { test("scaffolds the expected file tree into a fresh directory", async () => { const directory = await inTempDirectory(); @@ -359,3 +376,181 @@ describe("FsProjectManager.resolve", () => { ); }); }); + +describe("FsProjectManager.addResource", () => { + async function projectWithGateway(subject: FsProjectManager): Promise { + const { project } = await runCreate(subject, { + name: "example", + template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, + skipInstall: true, + skipGit: true, + }); + return ( + await runAdd(subject, project, { + resourceType: "gateway", + resourceConfig: { + name: "tools", + protocolType: "None", + authorizerType: "NONE", + targets: [], + enableSemanticSearch: false, + exceptionLevel: "NONE", + }, + }) + ).project; + } + + test("writes managed schema assets and stores a portable project-relative path", async () => { + const directory = await inTempDirectory(); + const subject = manager().manager; + const project = await projectWithGateway(subject); + + const result = await runAdd(subject, project, { + resourceType: "gateway-target", + gatewayName: "tools", + resourceConfig: { + name: "search", + targetType: "lambdaFunctionArn", + lambdaFunctionArn: { + lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", + toolSchemaFile: "tool-schema.json", + }, + }, + inlineSchema: { kind: "lambda", content: "[]" }, + }); + + const assetDirectory = join( + directory, + "example", + "agentcore", + "assets", + "gateways", + "tools", + "targets", + "search", + ); + expect(await Bun.file(join(assetDirectory, "tool-schema.json")).text()).toBe("[]"); + expect( + result.project.spec.agentCoreGateways[0]?.targets[0]?.lambdaFunctionArn?.toolSchemaFile, + ).toBe("agentcore/assets/gateways/tools/targets/search/tool-schema.json"); + }); + + test("rolls back a managed asset when candidate validation fails", async () => { + const directory = await inTempDirectory(); + const subject = manager().manager; + const project = await projectWithGateway(subject); + + await expect( + runAdd(subject, project, { + resourceType: "gateway-target", + gatewayName: "tools", + resourceConfig: { + name: "search", + targetType: "openApiSchema", + schemaSource: { inline: { path: "openapi.json" } }, + }, + inlineSchema: { kind: "openapi", content: '{"openapi":"3.0.0"}' }, + }), + ).rejects.toBeInstanceOf(InputValidationError); + + const assetDirectory = join( + directory, + "example", + "agentcore", + "assets", + "gateways", + "tools", + "targets", + "search", + ); + expect(await Bun.file(assetDirectory).exists()).toBe(false); + }); + + test("rolls back a managed asset when the project write fails", async () => { + const directory = await inTempDirectory(); + const logger = createSilentLogger(); + const realJson = new FsReadWriteJson({ logger }); + const subject = manager().manager; + const project = await projectWithGateway(subject); + const failingJson: ReadWriteJson = { + read: (path, schema) => realJson.read(path, schema), + write: async () => { + throw new Error("write exploded"); + }, + }; + const failing = new FsProjectManager({ + logger, + runner: async () => {}, + checkTool: async () => {}, + json: failingJson, + }); + + await expect( + runAdd(failing, project, { + resourceType: "gateway-target", + gatewayName: "tools", + resourceConfig: { + name: "search", + targetType: "lambdaFunctionArn", + lambdaFunctionArn: { + lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", + toolSchemaFile: "tool-schema.json", + }, + }, + inlineSchema: { kind: "lambda", content: "[]" }, + }), + ).rejects.toThrow("write exploded"); + + const assetDirectory = join( + directory, + "example", + "agentcore", + "assets", + "gateways", + "tools", + "targets", + "search", + ); + expect(await Bun.file(assetDirectory).exists()).toBe(false); + const persisted = await Bun.file( + join(directory, "example", "agentcore", "agentcore.json"), + ).json(); + expect(persisted.agentCoreGateways[0].targets).toEqual([]); + }); + + test("refuses to overwrite an existing Target asset directory", async () => { + const directory = await inTempDirectory(); + const subject = manager().manager; + const project = await projectWithGateway(subject); + const assetDirectory = join( + directory, + "example", + "agentcore", + "assets", + "gateways", + "tools", + "targets", + "search", + ); + await mkdir(assetDirectory, { recursive: true }); + await writeFile(join(assetDirectory, "keep.txt"), "keep"); + + await expect( + runAdd(subject, project, { + resourceType: "gateway-target", + gatewayName: "tools", + resourceConfig: { + name: "search", + targetType: "lambdaFunctionArn", + lambdaFunctionArn: { + lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", + toolSchemaFile: "tool-schema.json", + }, + }, + inlineSchema: { kind: "lambda", content: "[]" }, + }), + ).rejects.toThrow("asset directory already exists"); + + expect(await Bun.file(join(assetDirectory, "keep.txt")).text()).toBe("keep"); + }); +}); 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-add.test.ts b/src/handlers/project/add/gateway-add.test.ts new file mode 100644 index 000000000..9db10a4e1 --- /dev/null +++ b/src/handlers/project/add/gateway-add.test.ts @@ -0,0 +1,467 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } 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"; + +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-gateway-add-")); + 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]); +} + +afterEach(async () => { + process.chdir(originalCwd); + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +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 MCP, policy, role, exception, description, and tags", async () => { + const projectRoot = await inProject(); + const spec = await projectSpec(projectRoot); + spec.policyEngines = [{ name: "Guardrails", policies: [] }]; + await writeProjectSpec(projectRoot, spec); + const protocolFile = join(projectRoot, "protocol.json"); + await writeFile(protocolFile, '{"mcp":{"searchType":"SEMANTIC"}}'); + + await run([ + "add", + "gateway", + "--name", + "tools", + "--protocol", + "mcp", + "--protocol-configuration", + `file://${protocolFile}`, + "--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 CUSTOM_JWT configuration from stdin", 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 unsupported protocol fields without writing a Gateway", async () => { + const projectRoot = await inProject(); + await expect( + run([ + "add", + "gateway", + "--name", + "tools", + "--protocol", + "mcp", + "--protocol-configuration", + '{"mcp":{"instructions":"not persistable"}}', + ]), + ).rejects.toThrow("mcp.instructions"); + + expect((await projectSpec(projectRoot)).agentCoreGateways ?? []).toEqual([]); + }); +}); + +describe("project add gateway-target", () => { + test("adds endpoint and project Runtime modes", async () => { + const projectRoot = await inProject(); + await addGateway(); + await run([ + "add", + "gateway-target", + "--gateway", + "tools", + "--name", + "external", + "--endpoint", + "https://mcp.example.com", + ]); + 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: "https://mcp.example.com", + }, + { + name: "runtime", + targetType: "httpRuntime", + httpRuntime: { runtime: "hello_world", runtimeEndpoint: "DEFAULT" }, + }, + ]); + }); + + test("materializes an inline Lambda tool schema", async () => { + const projectRoot = await inProject(); + await addGateway(); + await run([ + "add", + "gateway-target", + "--gateway", + "tools", + "--name", + "search", + "--target-configuration", + JSON.stringify({ + mcp: { + lambda: { + lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", + toolSchema: { + inlinePayload: [ + { + name: "search", + description: "Search", + inputSchema: { type: "object", properties: {} }, + }, + ], + }, + }, + }, + }), + ]); + + const managedPath = "agentcore/assets/gateways/tools/targets/search/tool-schema.json"; + expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0]).toEqual({ + name: "search", + targetType: "lambdaFunctionArn", + lambdaFunctionArn: { + lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", + toolSchemaFile: managedPath, + }, + }); + expect(await Bun.file(join(projectRoot, managedPath)).json()).toEqual([ + { + name: "search", + description: "Search", + inputSchema: { type: "object", properties: {} }, + }, + ]); + }); + + test("rejects an external MCP endpoint that does not use HTTPS", async () => { + const projectRoot = await inProject(); + await addGateway(); + await expect( + run([ + "add", + "gateway-target", + "--gateway", + "tools", + "--name", + "insecure", + "--endpoint", + "http://mcp.example.com", + ]), + ).rejects.toThrow("must use HTTPS"); + + expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets).toEqual([]); + }); + + test.each(["file", "stdin"] as const)( + "reads Target configuration from %s", + async (sourceKind) => { + const projectRoot = await inProject(); + await addGateway(); + const configuration = JSON.stringify({ + mcp: { mcpServer: { endpoint: "https://source.example.com" } }, + }); + const path = join(projectRoot, "target.json"); + await writeFile(path, configuration); + const source = sourceKind === "file" ? `file://${path}` : "-"; + + await run( + [ + "add", + "gateway-target", + "--gateway", + "tools", + "--name", + "source", + "--target-configuration", + source, + ], + sourceKind === "stdin" ? configuration : undefined, + ); + + expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0]).toMatchObject({ + name: "source", + targetType: "mcpServer", + endpoint: "https://source.example.com", + }); + }, + ); + + test("resolves compatible project credentials", async () => { + const projectRoot = await inProject(); + const spec = await projectSpec(projectRoot); + spec.credentials = [ + { authorizerType: "OAuthCredentialProvider", name: "search-oauth" }, + { authorizerType: "ApiKeyCredentialProvider", name: "search-key" }, + ]; + await writeProjectSpec(projectRoot, spec); + await addGateway(); + await run([ + "add", + "gateway-target", + "--gateway", + "tools", + "--name", + "oauth", + "--endpoint", + "https://oauth.example.com", + "--outbound-auth", + "oauth", + "--credential-name", + "search-oauth", + "--scope", + "read", + "write", + ]); + + expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0].outboundAuth).toEqual({ + type: "OAUTH", + credentialName: "search-oauth", + scopes: ["read", "write"], + }); + }); + + test("allows equal Target names in different Gateways but not the same Gateway", async () => { + const projectRoot = await inProject(); + await addGateway("tools"); + await addGateway("payments"); + for (const gateway of ["tools", "payments"]) { + await run([ + "add", + "gateway-target", + "--gateway", + gateway, + "--name", + "search", + "--endpoint", + `https://${gateway}.example.com`, + ]); + } + await expect( + run([ + "add", + "gateway-target", + "--gateway", + "tools", + "--name", + "search", + "--endpoint", + "https://duplicate.example.com", + ]), + ).rejects.toThrow("already exists"); + + const gateways = (await projectSpec(projectRoot)).agentCoreGateways; + expect(gateways.map((gateway: { targets: unknown[] }) => gateway.targets)).toHaveLength(2); + expect(gateways[0].targets).toHaveLength(1); + expect(gateways[1].targets).toHaveLength(1); + }); +}); + +describe("project add gateway-connector", () => { + test("adds curated 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.each(["inline", "file", "stdin"] as const)( + "reads Connector configuration from %s JSON", + async (sourceKind) => { + const projectRoot = await inProject(); + await addGateway(); + const configuration = JSON.stringify({ + mcp: { + connector: { + source: { connectorId: "web-search" }, + configurations: [{ name: "WebSearch", parameterValues: { maxResults: 3 } }], + }, + }, + }); + const path = join(projectRoot, "connector.json"); + await writeFile(path, configuration); + const source = + sourceKind === "inline" ? configuration : sourceKind === "file" ? `file://${path}` : "-"; + + await run( + [ + "add", + "gateway-connector", + "--gateway", + "tools", + "--name", + "configured", + "--connector-configuration", + source, + ], + sourceKind === "stdin" ? configuration : undefined, + ); + + expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0]).toMatchObject({ + name: "configured", + targetType: "connector", + connectorId: "web-search", + configurations: [{ name: "WebSearch", parameterValues: { maxResults: 3 } }], + }); + }, + ); +}); 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..dcad9b7f2 --- /dev/null +++ b/src/handlers/project/add/gateway-connector/index.ts @@ -0,0 +1,84 @@ +import type { TargetConfiguration } from "@aws-sdk/client-bedrock-agentcore-control"; +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { SourceResolver } from "../../../../io"; +import { createHandler, flag, ProjectKey } from "../../../../router"; +import { parseJsonObjectFlag } from "../../../utils"; +import type { AddProjectResourceConfig } from "../types"; +import { + connectorTargetFromShortcut, + translateTargetConfiguration, +} from "../gateway-target/configuration"; + +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 connector Target name", z.string().optional()), + flag( + "connector", + "curated connector", + z.enum(["web-search", "bedrock-knowledge-bases"]).optional(), + ), + flag( + "connector-configuration", + "connector-backed Target configuration (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.name) { + throw new InputValidationError("required option '--name ' not specified"); + } + if ((flags.connector === undefined) === (flags["connector-configuration"] === undefined)) { + throw new InputValidationError( + "specify exactly one of '--connector' or '--connector-configuration'", + ); + } + 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; + if (flags.connector) { + target = connectorTargetFromShortcut(flags.name, flags.connector, flags["knowledge-base"]); + } else { + const source = new SourceResolver({ stdin: config.io.stdin }); + const connectorConfiguration = parseJsonObjectFlag( + "connector-configuration", + await source.resolveText("connector-configuration", flags["connector-configuration"]), + )!; + const translated = translateTargetConfiguration(flags.name, connectorConfiguration); + if (translated.target.targetType !== "connector") { + throw new InputValidationError( + "--connector-configuration must contain an MCP connector Target", + ); + } + target = translated.target; + } + + 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 '${flags.name}' to Gateway '${flags.gateway}' in '${project.name}'\n`, + ); + }, + }); diff --git a/src/handlers/project/add/gateway-target/configuration.test.ts b/src/handlers/project/add/gateway-target/configuration.test.ts new file mode 100644 index 000000000..ec0602078 --- /dev/null +++ b/src/handlers/project/add/gateway-target/configuration.test.ts @@ -0,0 +1,353 @@ +import { describe, expect, test } from "bun:test"; +import type { TargetConfiguration } from "@aws-sdk/client-bedrock-agentcore-control"; +import { connectorTargetFromShortcut, translateTargetConfiguration } from "./configuration"; + +describe("translateTargetConfiguration", () => { + test("materializes an inline Lambda tool schema", () => { + const result = translateTargetConfiguration("search", { + mcp: { + lambda: { + lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", + toolSchema: { + inlinePayload: [ + { + name: "search", + description: "Search documents", + inputSchema: { type: "object", properties: {} }, + }, + ], + }, + }, + }, + }); + + expect(result.target).toEqual({ + name: "search", + targetType: "lambdaFunctionArn", + lambdaFunctionArn: { + lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", + toolSchemaFile: "tool-schema.json", + }, + }); + expect(result.inlineSchema).toEqual({ + kind: "lambda", + content: JSON.stringify( + [ + { + name: "search", + description: "Search documents", + inputSchema: { type: "object", properties: {} }, + }, + ], + undefined, + 2, + ), + }); + }); + + test("preserves a Lambda S3 tool schema", () => { + const result = translateTargetConfiguration("search", { + mcp: { + lambda: { + lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", + toolSchema: { s3: { uri: "s3://schemas/search.json" } }, + }, + }, + }); + + expect(result).toEqual({ + target: { + name: "search", + targetType: "lambdaFunctionArn", + lambdaFunctionArn: { + lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", + toolSchemaFile: "s3://schemas/search.json", + }, + }, + }); + }); + + test("materializes inline OpenAPI and Smithy schemas", () => { + expect( + translateTargetConfiguration("openapi", { + mcp: { openApiSchema: { inlinePayload: '{"openapi":"3.0.0"}' } }, + }), + ).toEqual({ + target: { + name: "openapi", + targetType: "openApiSchema", + schemaSource: { inline: { path: "openapi.json" } }, + }, + inlineSchema: { kind: "openapi", content: '{"openapi":"3.0.0"}' }, + }); + + expect( + translateTargetConfiguration("smithy", { + mcp: { smithyModel: { inlinePayload: '{"smithy":"2.0"}' } }, + }), + ).toEqual({ + target: { + name: "smithy", + targetType: "smithyModel", + schemaSource: { inline: { path: "smithy.json" } }, + }, + inlineSchema: { kind: "smithy", content: '{"smithy":"2.0"}' }, + }); + }); + + test("preserves S3 schema configuration", () => { + expect( + translateTargetConfiguration("openapi", { + mcp: { + openApiSchema: { + s3: { uri: "s3://schemas/openapi.json", bucketOwnerAccountId: "123456789012" }, + }, + }, + }), + ).toEqual({ + target: { + name: "openapi", + targetType: "openApiSchema", + schemaSource: { + s3: { uri: "s3://schemas/openapi.json", bucketOwnerAccountId: "123456789012" }, + }, + }, + }); + }); + + test("maps external MCP, API Gateway, and HTTP passthrough targets", () => { + expect( + translateTargetConfiguration("external", { + mcp: { mcpServer: { endpoint: "https://mcp.example.com" } }, + }), + ).toEqual({ + target: { + name: "external", + targetType: "mcpServer", + endpoint: "https://mcp.example.com", + }, + }); + + expect( + translateTargetConfiguration("api", { + mcp: { + apiGateway: { + restApiId: "abc123", + stage: "prod", + apiGatewayToolConfiguration: { + toolFilters: [{ filterPath: "/pets", methods: ["GET", "POST"] }], + toolOverrides: [ + { + name: "getPet", + path: "/pets/{id}", + method: "GET", + description: "Get one pet", + }, + ], + }, + }, + }, + }), + ).toEqual({ + target: { + name: "api", + targetType: "apiGateway", + apiGateway: { + restApiId: "abc123", + stage: "prod", + apiGatewayToolConfiguration: { + toolFilters: [{ filterPath: "/pets", methods: ["GET", "POST"] }], + toolOverrides: [ + { + name: "getPet", + path: "/pets/{id}", + method: "GET", + description: "Get one pet", + }, + ], + }, + }, + }, + }); + + expect( + translateTargetConfiguration("http", { + http: { + passthrough: { + endpoint: "https://api.example.com", + protocolType: "CUSTOM", + stickinessConfiguration: { identifier: "$context.header.x-session", timeout: 900 }, + }, + }, + }), + ).toEqual({ + target: { + name: "http", + targetType: "passthrough", + passthrough: { + endpoint: "https://api.example.com", + protocolType: "CUSTOM", + stickinessConfiguration: { identifier: "$context.header.x-session", timeout: 900 }, + }, + }, + }); + }); + + test("maps supported connector configuration without dropping fields", () => { + expect( + translateTargetConfiguration("search", { + mcp: { + connector: { + source: { connectorId: "web-search" }, + configurations: [ + { + name: "WebSearch", + description: "Search selected sites", + parameterValues: { maxResults: 5 }, + parameterOverrides: [ + { path: "/query", description: "Search query", visible: true }, + ], + }, + ], + }, + }, + }), + ).toEqual({ + target: { + name: "search", + targetType: "connector", + connectorId: "web-search", + configurations: [ + { + name: "WebSearch", + description: "Search selected sites", + parameterValues: { maxResults: 5 }, + parameterOverrides: [{ path: "/query", description: "Search query", visible: true }], + }, + ], + }, + }); + }); + + test.each([ + [ + "MCP tool schema", + { + mcp: { + mcpServer: { + endpoint: "https://mcp.example.com", + mcpToolSchema: { inlinePayload: "[]" }, + }, + }, + }, + "mcp.mcpServer.mcpToolSchema", + ], + [ + "connector version", + { + mcp: { + connector: { source: { connectorId: "web-search", version: "1.1.0" } }, + }, + }, + "mcp.connector.source.version", + ], + [ + "connector enabled tools", + { + mcp: { + connector: { + source: { connectorId: "web-search" }, + enabled: ["WebSearch"], + }, + }, + }, + "mcp.connector.enabled", + ], + [ + "HTTP schema", + { + http: { + passthrough: { + endpoint: "https://api.example.com", + protocolType: "CUSTOM", + schema: { source: { inlinePayload: "{}" } }, + }, + }, + }, + "http.passthrough.schema", + ], + [ + "Runtime ARN", + { + http: { + agentcoreRuntime: { + arn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-id", + }, + }, + }, + "--runtime", + ], + [ + "inference", + { + inference: { + connector: { source: { connectorId: "bedrock-mantle" } }, + }, + }, + "inference", + ], + [ + "unknown field", + { + mcp: { + mcpServer: { + endpoint: "https://mcp.example.com", + futureField: true, + }, + }, + } as unknown as TargetConfiguration, + "mcp.mcpServer.futureField", + ], + [ + "non-HTTPS MCP endpoint", + { + mcp: { + mcpServer: { + endpoint: "http://mcp.example.com", + }, + }, + }, + "must use HTTPS", + ], + ] satisfies [string, TargetConfiguration, string][])( + "rejects unsupported %s input", + (_label, configuration, expected) => { + expect(() => translateTargetConfiguration("target", configuration)).toThrow(expected); + }, + ); +}); + +describe("connectorTargetFromShortcut", () => { + test("builds web search and knowledge base connector targets", () => { + expect(connectorTargetFromShortcut("search", "web-search")).toEqual({ + name: "search", + targetType: "connector", + connectorId: "web-search", + configurations: [{ name: "WebSearch", parameterValues: { maxResults: 10 } }], + }); + expect( + connectorTargetFromShortcut("knowledge", "bedrock-knowledge-bases", "ProductDocs"), + ).toEqual({ + name: "knowledge", + targetType: "connector", + connectorId: "bedrock-knowledge-bases", + configurations: [{ name: "Retrieve", parameterValues: { knowledgeBaseId: "ProductDocs" } }], + }); + }); + + test("requires a knowledge base reference", () => { + expect(() => connectorTargetFromShortcut("knowledge", "bedrock-knowledge-bases")).toThrow( + "--knowledge-base", + ); + }); +}); diff --git a/src/handlers/project/add/gateway-target/configuration.ts b/src/handlers/project/add/gateway-target/configuration.ts new file mode 100644 index 000000000..ad1402d50 --- /dev/null +++ b/src/handlers/project/add/gateway-target/configuration.ts @@ -0,0 +1,428 @@ +import type { TargetConfiguration } from "@aws-sdk/client-bedrock-agentcore-control"; +import { InputValidationError } from "../../../../errors"; +import type { + AgentCoreGatewayTarget, + ConnectorId, + OutboundAuth, + SchemaSource, +} from "../../../../projectSchemas/gateway"; +import { + GATEWAY_TARGET_INLINE_SCHEMA_FILENAMES, + type GatewayTargetInlineSchema, +} from "../../types"; + +type Translation = { + target: AgentCoreGatewayTarget; + inlineSchema?: GatewayTargetInlineSchema; +}; + +type JsonObject = Record; + +export function translateTargetConfiguration( + name: string, + configuration: TargetConfiguration, + outboundAuth?: OutboundAuth, +): Translation { + const root = object(configuration, "targetConfiguration"); + exactKeys(root, ["mcp", "http", "inference"], "targetConfiguration"); + const variant = exactlyOne(root, ["mcp", "http", "inference"], "targetConfiguration"); + + let translation: Translation; + switch (variant) { + case "mcp": + translation = translateMcp(name, object(root.mcp, "mcp")); + break; + case "http": + translation = translateHttp(name, object(root.http, "http")); + break; + case "inference": + throw unsupported( + "inference", + "Inference Gateway Targets are not represented by the current project schema.", + ); + } + + if (!outboundAuth) return translation; + if ( + !["mcpServer", "openApiSchema", "apiGateway", "httpRuntime", "passthrough"].includes( + translation.target.targetType, + ) + ) { + throw unsupported( + "outboundAuth", + `${translation.target.targetType} Targets cannot preserve outbound authentication in the current project schema.`, + ); + } + return { + ...translation, + target: { ...translation.target, outboundAuth }, + }; +} + +export 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 } }], + }; + } +} + +function translateMcp(name: string, mcp: JsonObject): Translation { + const variants = [ + "openApiSchema", + "smithyModel", + "lambda", + "mcpServer", + "apiGateway", + "connector", + ] as const; + exactKeys(mcp, variants, "mcp"); + const variant = exactlyOne(mcp, variants, "mcp"); + + switch (variant) { + case "openApiSchema": + return translateApiSchema(name, "openApiSchema", "openapi", mcp.openApiSchema); + case "smithyModel": + return translateApiSchema(name, "smithyModel", "smithy", mcp.smithyModel); + case "lambda": + return translateLambda(name, object(mcp.lambda, "mcp.lambda")); + case "mcpServer": + return translateMcpServer(name, object(mcp.mcpServer, "mcp.mcpServer")); + case "apiGateway": + return translateApiGateway(name, object(mcp.apiGateway, "mcp.apiGateway")); + case "connector": + return translateConnector(name, object(mcp.connector, "mcp.connector")); + } +} + +function translateApiSchema( + name: string, + targetType: "openApiSchema" | "smithyModel", + kind: "openapi" | "smithy", + raw: unknown, +): Translation { + const schema = object(raw, `mcp.${targetType}`); + exactKeys(schema, ["inlinePayload", "s3"], `mcp.${targetType}`); + const source = exactlyOne(schema, ["inlinePayload", "s3"], `mcp.${targetType}`); + + if (source === "inlinePayload") { + const content = string(schema.inlinePayload, `mcp.${targetType}.inlinePayload`); + return { + target: { + name, + targetType, + schemaSource: { + inline: { path: GATEWAY_TARGET_INLINE_SCHEMA_FILENAMES[kind] }, + }, + }, + inlineSchema: { kind, content }, + }; + } + + return { + target: { + name, + targetType, + schemaSource: translateS3Source(schema.s3, `mcp.${targetType}.s3`), + }, + }; +} + +function translateLambda(name: string, lambda: JsonObject): Translation { + exactKeys(lambda, ["lambdaArn", "toolSchema"], "mcp.lambda"); + const lambdaArn = string(lambda.lambdaArn, "mcp.lambda.lambdaArn"); + const toolSchema = object(lambda.toolSchema, "mcp.lambda.toolSchema"); + exactKeys(toolSchema, ["inlinePayload", "s3"], "mcp.lambda.toolSchema"); + const source = exactlyOne(toolSchema, ["inlinePayload", "s3"], "mcp.lambda.toolSchema"); + + if (source === "inlinePayload") { + if (!Array.isArray(toolSchema.inlinePayload)) { + throw invalid("mcp.lambda.toolSchema.inlinePayload", "must be a JSON array"); + } + return { + target: { + name, + targetType: "lambdaFunctionArn", + lambdaFunctionArn: { + lambdaArn, + toolSchemaFile: GATEWAY_TARGET_INLINE_SCHEMA_FILENAMES.lambda, + }, + }, + inlineSchema: { + kind: "lambda", + content: JSON.stringify(toolSchema.inlinePayload, undefined, 2), + }, + }; + } + + const s3 = object(toolSchema.s3, "mcp.lambda.toolSchema.s3"); + exactKeys(s3, ["uri", "bucketOwnerAccountId"], "mcp.lambda.toolSchema.s3"); + if (s3.bucketOwnerAccountId !== undefined) { + throw unsupported( + "mcp.lambda.toolSchema.s3.bucketOwnerAccountId", + "Lambda tool schemas in the project schema preserve only the S3 URI.", + ); + } + return { + target: { + name, + targetType: "lambdaFunctionArn", + lambdaFunctionArn: { + lambdaArn, + toolSchemaFile: string(s3.uri, "mcp.lambda.toolSchema.s3.uri"), + }, + }, + }; +} + +function translateMcpServer(name: string, server: JsonObject): Translation { + exactKeys( + server, + ["endpoint", "mcpToolSchema", "listingMode", "resourcePriority"], + "mcp.mcpServer", + ); + for (const field of ["mcpToolSchema", "listingMode", "resourcePriority"] as const) { + if (server[field] !== undefined) { + throw unsupported( + `mcp.mcpServer.${field}`, + "Static MCP Server discovery settings are not represented by the current project schema.", + ); + } + } + return { + target: { + name, + targetType: "mcpServer", + endpoint: httpsEndpoint(server.endpoint, "mcp.mcpServer.endpoint"), + }, + }; +} + +function translateApiGateway(name: string, raw: JsonObject): Translation { + exactKeys(raw, ["restApiId", "stage", "apiGatewayToolConfiguration"], "mcp.apiGateway"); + const toolConfiguration = object( + raw.apiGatewayToolConfiguration, + "mcp.apiGateway.apiGatewayToolConfiguration", + ); + exactKeys( + toolConfiguration, + ["toolFilters", "toolOverrides"], + "mcp.apiGateway.apiGatewayToolConfiguration", + ); + array( + toolConfiguration.toolFilters, + "mcp.apiGateway.apiGatewayToolConfiguration.toolFilters", + ).forEach((filter, index) => + exactKeys( + object(filter, `mcp.apiGateway.apiGatewayToolConfiguration.toolFilters[${index}]`), + ["filterPath", "methods"], + `mcp.apiGateway.apiGatewayToolConfiguration.toolFilters[${index}]`, + ), + ); + if (toolConfiguration.toolOverrides !== undefined) { + array( + toolConfiguration.toolOverrides, + "mcp.apiGateway.apiGatewayToolConfiguration.toolOverrides", + ).forEach((override, index) => + exactKeys( + object(override, `mcp.apiGateway.apiGatewayToolConfiguration.toolOverrides[${index}]`), + ["name", "description", "path", "method"], + `mcp.apiGateway.apiGatewayToolConfiguration.toolOverrides[${index}]`, + ), + ); + } + + return { + target: { + name, + targetType: "apiGateway", + apiGateway: raw as AgentCoreGatewayTarget["apiGateway"], + }, + }; +} + +function translateConnector(name: string, raw: JsonObject): Translation { + exactKeys(raw, ["source", "enabled", "configurations"], "mcp.connector"); + if (raw.enabled !== undefined) { + throw unsupported( + "mcp.connector.enabled", + "Connector enabled-tool selection is not represented by the current project schema.", + ); + } + const source = object(raw.source, "mcp.connector.source"); + exactKeys(source, ["connectorId", "version"], "mcp.connector.source"); + if (source.version !== undefined) { + throw unsupported( + "mcp.connector.source.version", + "Connector version selection is not represented by the current project schema.", + ); + } + const connectorId = string(source.connectorId, "mcp.connector.source.connectorId"); + if (connectorId !== "web-search" && connectorId !== "bedrock-knowledge-bases") { + throw unsupported( + "mcp.connector.source.connectorId", + `Connector '${connectorId}' is not supported by the current project schema.`, + ); + } + + let configurations: AgentCoreGatewayTarget["configurations"]; + if (raw.configurations !== undefined) { + configurations = array(raw.configurations, "mcp.connector.configurations").map( + (configuration, index) => { + const path = `mcp.connector.configurations[${index}]`; + const item = object(configuration, path); + exactKeys(item, ["name", "description", "parameterValues", "parameterOverrides"], path); + if (item.parameterOverrides !== undefined) { + array(item.parameterOverrides, `${path}.parameterOverrides`).forEach( + (override, overrideIndex) => + exactKeys( + object(override, `${path}.parameterOverrides[${overrideIndex}]`), + ["path", "description", "visible"], + `${path}.parameterOverrides[${overrideIndex}]`, + ), + ); + } + return item as NonNullable[number]; + }, + ); + } + + return { + target: { + name, + targetType: "connector", + connectorId, + configurations, + }, + }; +} + +function translateHttp(name: string, http: JsonObject): Translation { + exactKeys(http, ["agentcoreRuntime", "passthrough"], "http"); + const variant = exactlyOne(http, ["agentcoreRuntime", "passthrough"], "http"); + if (variant === "agentcoreRuntime") { + throw unsupported( + "http.agentcoreRuntime", + "Use --runtime with the name of a Runtime declared in this project.", + ); + } + + const passthrough = object(http.passthrough, "http.passthrough"); + exactKeys( + passthrough, + ["endpoint", "protocolType", "schema", "stickinessConfiguration"], + "http.passthrough", + ); + if (passthrough.schema !== undefined) { + throw unsupported( + "http.passthrough.schema", + "HTTP API schemas are not represented by the current project schema.", + ); + } + if (passthrough.stickinessConfiguration !== undefined) { + exactKeys( + object(passthrough.stickinessConfiguration, "http.passthrough.stickinessConfiguration"), + ["identifier", "timeout"], + "http.passthrough.stickinessConfiguration", + ); + } + + return { + target: { + name, + targetType: "passthrough", + passthrough: passthrough as AgentCoreGatewayTarget["passthrough"], + }, + }; +} + +function translateS3Source(raw: unknown, path: string): SchemaSource { + const s3 = object(raw, path); + exactKeys(s3, ["uri", "bucketOwnerAccountId"], path); + return { + s3: { + uri: string(s3.uri, `${path}.uri`), + ...(s3.bucketOwnerAccountId === undefined + ? {} + : { + bucketOwnerAccountId: string(s3.bucketOwnerAccountId, `${path}.bucketOwnerAccountId`), + }), + }, + }; +} + +function object(value: unknown, path: string): JsonObject { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw invalid(path, "must be a JSON object"); + } + return value as JsonObject; +} + +function array(value: unknown, path: string): unknown[] { + if (!Array.isArray(value)) throw invalid(path, "must be a JSON array"); + return value; +} + +function string(value: unknown, path: string): string { + if (typeof value !== "string" || value.length === 0) { + throw invalid(path, "must be a non-empty string"); + } + return value; +} + +export function httpsEndpoint(value: unknown, path: string): string { + const endpoint = string(value, path); + try { + if (new URL(endpoint).protocol !== "https:") { + throw invalid(path, "must use HTTPS"); + } + } catch (error) { + if (error instanceof InputValidationError) throw error; + throw invalid(path, "must be a valid HTTPS URL"); + } + return endpoint; +} + +function exactKeys(value: JsonObject, allowed: readonly string[], path: string): void { + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) { + throw unsupported(`${path}.${key}`, "This field cannot be persisted without data loss."); + } + } +} + +function exactlyOne(value: JsonObject, keys: readonly T[], path: string): T { + const present = keys.filter((key) => value[key] !== undefined); + if (present.length !== 1) { + throw invalid(path, `must contain exactly one of ${keys.join(", ")}`); + } + return present[0]!; +} + +function invalid(path: string, message: string): InputValidationError { + return new InputValidationError(`Invalid Target configuration field '${path}': ${message}`); +} + +function unsupported(path: string, guidance: string): InputValidationError { + return new InputValidationError(`Unsupported Target configuration field '${path}'. ${guidance}`); +} 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..9b9c6735a --- /dev/null +++ b/src/handlers/project/add/gateway-target/index.ts @@ -0,0 +1,170 @@ +import type { TargetConfiguration } from "@aws-sdk/client-bedrock-agentcore-control"; +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { SourceResolver } from "../../../../io"; +import type { AgentCoreGatewayTarget, OutboundAuth } from "../../../../projectSchemas/gateway"; +import type { Credential } from "../../../../projectSchemas/credential"; +import { createHandler, flag, ProjectKey } from "../../../../router"; +import { parseJsonObjectFlag } from "../../../utils"; +import type { Project } from "../../types"; +import type { AddProjectResourceConfig } from "../types"; +import { httpsEndpoint, translateTargetConfiguration } from "./configuration"; + +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", 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 Target configuration (JSON; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "outbound-auth", + "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"); + } + if (!flags.name) { + throw new InputValidationError("required option '--name ' 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 project = ctx.require(ProjectKey); + const outboundAuth = projectOutboundAuth(project, { + type: flags["outbound-auth"], + credentialName: flags["credential-name"], + scopes: flags.scope, + }); + + let target: AgentCoreGatewayTarget; + let inlineSchema; + if (flags.endpoint !== undefined) { + target = { + name: flags.name, + targetType: "mcpServer", + endpoint: httpsEndpoint(flags.endpoint, "--endpoint"), + outboundAuth, + }; + } else if (flags.runtime !== undefined) { + target = { + name: flags.name, + targetType: "httpRuntime", + httpRuntime: { + runtime: flags.runtime, + runtimeEndpoint: flags["runtime-endpoint"], + }, + outboundAuth, + }; + } else { + const source = new SourceResolver({ stdin: config.io.stdin }); + const targetConfiguration = parseJsonObjectFlag( + "target-configuration", + await source.resolveText("target-configuration", flags["target-configuration"]), + )!; + const translated = translateTargetConfiguration( + flags.name, + targetConfiguration, + outboundAuth, + ); + target = translated.target; + inlineSchema = translated.inlineSchema; + } + + for await (const event of config.projectManager.addResource(project, { + resourceType: "gateway-target", + gatewayName: flags.gateway, + resourceConfig: target, + inlineSchema, + })) { + config.io.stderr.write(`${event.message}\n`); + } + config.io.stderr.write( + `added Target '${flags.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 = project.spec.credentials.find( + (candidate) => candidate.name === input.credentialName, + ); + if (!credential) { + throw new InputValidationError( + `credential '${input.credentialName}' does not exist in credentials[]`, + ); + } + assertCredentialType(credential, input.type); + return { + type: input.type === "oauth" ? "OAUTH" : "API_KEY", + credentialName: input.credentialName, + scopes: input.type === "oauth" ? input.scopes : undefined, + }; +} + +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}`, + ); + } +} diff --git a/src/handlers/project/add/gateway/index.ts b/src/handlers/project/add/gateway/index.ts new file mode 100644 index 000000000..65cda3710 --- /dev/null +++ b/src/handlers/project/add/gateway/index.ts @@ -0,0 +1,264 @@ +import type { + AuthorizerConfiguration as SdkAuthorizerConfiguration, + GatewayProtocolConfiguration, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { SourceResolver } from "../../../../io"; +import type { AuthorizerConfig } from "../../../../projectSchemas/auth"; +import type { AgentCoreGateway } from "../../../../projectSchemas/gateway"; +import { createHandler, flag, ProjectKey } from "../../../../router"; +import { parseJsonObjectFlag, parseTags } from "../../../utils"; +import type { AddProjectResourceConfig } from "../types"; + +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( + "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( + "protocol-configuration", + "MCP protocol configuration (JSON; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "authorizer-configuration", + "CUSTOM_JWT configuration (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.protocol && flags["protocol-configuration"] !== undefined) { + throw new InputValidationError( + "--protocol-configuration is valid only with --protocol mcp", + ); + } + + const source = new SourceResolver({ stdin: config.io.stdin }); + const protocolConfiguration = parseJsonObjectFlag( + "protocol-configuration", + await source.resolveText("protocol-configuration", flags["protocol-configuration"]), + ); + const authorizerConfiguration = parseJsonObjectFlag( + "authorizer-configuration", + await source.resolveText("authorizer-configuration", flags["authorizer-configuration"]), + ); + const tags = await resolveTags(source, flags.tags); + + const gateway: AgentCoreGateway = { + name: flags.name, + protocolType: flags.protocol ? "MCP" : "None", + authorizerType, + authorizerConfiguration: authorizerConfiguration + ? toAuthorizerConfiguration(authorizerConfiguration) + : undefined, + description: flags.description, + targets: [], + enableSemanticSearch: protocolConfiguration + ? semanticSearchEnabled(protocolConfiguration) + : 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`); + }, + }); + +function semanticSearchEnabled(configuration: GatewayProtocolConfiguration): boolean { + const root = configuration as unknown as Record; + exactKeys(root, ["mcp"], "protocol-configuration"); + if (!root.mcp) { + throw new InputValidationError("--protocol-configuration must contain mcp"); + } + const mcp = object(root.mcp, "protocol-configuration.mcp"); + exactKeys( + mcp, + [ + "searchType", + "supportedVersions", + "instructions", + "sessionConfiguration", + "streamingConfiguration", + ], + "protocol-configuration.mcp", + ); + for (const field of [ + "supportedVersions", + "instructions", + "sessionConfiguration", + "streamingConfiguration", + ]) { + if (mcp[field] !== undefined) { + throw new InputValidationError( + `Unsupported --protocol-configuration field 'mcp.${field}'. ` + + "The current project schema cannot persist it.", + ); + } + } + if (mcp.searchType !== undefined && mcp.searchType !== "SEMANTIC") { + throw new InputValidationError( + "Unsupported --protocol-configuration field 'mcp.searchType'. " + + "The current project schema supports only SEMANTIC.", + ); + } + return mcp.searchType === "SEMANTIC"; +} + +function toAuthorizerConfiguration(configuration: SdkAuthorizerConfiguration): AuthorizerConfig { + const root = configuration as unknown as Record; + exactKeys(root, ["customJWTAuthorizer"], "authorizer-configuration"); + const custom = object(root.customJWTAuthorizer, "authorizer-configuration.customJWTAuthorizer"); + exactKeys( + custom, + [ + "discoveryUrl", + "allowedAudience", + "allowedClients", + "allowedScopes", + "advertisedScopeMapping", + "customClaims", + "privateEndpoint", + "privateEndpointOverrides", + "allowedWorkloadConfiguration", + ], + "authorizer-configuration.customJWTAuthorizer", + ); + for (const field of ["advertisedScopeMapping", "allowedWorkloadConfiguration"]) { + if (custom[field] !== undefined) { + throw new InputValidationError( + `Unsupported --authorizer-configuration field 'customJWTAuthorizer.${field}'. ` + + "The current project schema cannot persist it.", + ); + } + } + return { + customJwtAuthorizer: { + discoveryUrl: custom.discoveryUrl as string, + allowedAudience: custom.allowedAudience as string[] | undefined, + allowedClients: custom.allowedClients as string[] | undefined, + allowedScopes: custom.allowedScopes as string[] | undefined, + customClaims: custom.customClaims as + NonNullable["customClaims"] | undefined, + privateEndpoint: custom.privateEndpoint as + NonNullable["privateEndpoint"] | undefined, + privateEndpointOverrides: custom.privateEndpointOverrides as + | NonNullable["privateEndpointOverrides"] + | undefined, + }, + }; +} + +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); +} + +function object(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new InputValidationError(`${path} must be a JSON object`); + } + return value as Record; +} + +function exactKeys(value: Record, allowed: readonly string[], path: string): void { + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) { + throw new InputValidationError( + `Unsupported --${path} field '${key}'. This field cannot be persisted without data loss.`, + ); + } + } +} 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..01bf992b2 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 = { @@ -58,6 +59,19 @@ export type EnvLocalEntry = { comment: string; }; +export const GATEWAY_TARGET_INLINE_SCHEMA_FILENAMES = { + lambda: "tool-schema.json", + openapi: "openapi.json", + smithy: "smithy.json", +} as const; + +export type GatewayTargetInlineSchemaKind = keyof typeof GATEWAY_TARGET_INLINE_SCHEMA_FILENAMES; + +export type GatewayTargetInlineSchema = { + kind: GatewayTargetInlineSchemaKind; + content: string; +}; + /** Discriminated union input for {@link ProjectManager.addResource}. */ export type AddResourceInput = | { @@ -84,12 +98,22 @@ export type AddResourceInput = | { resourceType: "online-insight"; resourceConfig: z.input; + } + | { + resourceType: "gateway"; + resourceConfig: AgentCoreGateway; + } + | { + resourceType: "gateway-target"; + gatewayName: string; + resourceConfig: AgentCoreGatewayTarget; + inlineSchema?: GatewayTargetInlineSchema; }; export type ProjectResource = AddResourceInput["resourceType"]; export type RemoveResourceInput = { - resourceType: ProjectResource; + resourceType: Exclude; name: string; }; From 820945b2c8ae43be35ebe70793337560167302c2 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 19 Aug 2026 18:46:44 +0000 Subject: [PATCH 2/7] refactor(project): use project schema for gateway JSON --- src/core/project/manager.test.ts | 130 +----- src/handlers/project/add/gateway-add.test.ts | 278 ++++++------ .../project/add/gateway-connector/index.ts | 57 ++- .../add/gateway-target/configuration.test.ts | 353 ---------------- .../add/gateway-target/configuration.ts | 398 +----------------- .../project/add/gateway-target/index.ts | 129 +++--- src/handlers/project/add/gateway/index.ts | 142 +------ src/handlers/project/types.ts | 14 - 8 files changed, 295 insertions(+), 1206 deletions(-) delete mode 100644 src/handlers/project/add/gateway-target/configuration.test.ts diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 599b996e4..f4f817692 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -12,7 +12,6 @@ import { type ProjectEvent, } from "../../handlers/project/types"; import { createSilentLogger } from "../../testing"; -import { FsReadWriteJson, type ReadWriteJson } from "../../io"; const originalCwd = process.cwd(); const tempDirectories: string[] = []; @@ -400,7 +399,7 @@ describe("FsProjectManager.addResource", () => { ).project; } - test("writes managed schema assets and stores a portable project-relative path", async () => { + test("adds a Target using its project-schema shape without creating assets", async () => { const directory = await inTempDirectory(); const subject = manager().manager; const project = await projectWithGateway(subject); @@ -413,29 +412,23 @@ describe("FsProjectManager.addResource", () => { targetType: "lambdaFunctionArn", lambdaFunctionArn: { lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", - toolSchemaFile: "tool-schema.json", + toolSchemaFile: "schemas/tool-schema.json", }, }, - inlineSchema: { kind: "lambda", content: "[]" }, }); - const assetDirectory = join( - directory, - "example", - "agentcore", - "assets", - "gateways", - "tools", - "targets", - "search", - ); - expect(await Bun.file(join(assetDirectory, "tool-schema.json")).text()).toBe("[]"); - expect( - result.project.spec.agentCoreGateways[0]?.targets[0]?.lambdaFunctionArn?.toolSchemaFile, - ).toBe("agentcore/assets/gateways/tools/targets/search/tool-schema.json"); + expect(result.project.spec.agentCoreGateways[0]?.targets[0]).toEqual({ + name: "search", + targetType: "lambdaFunctionArn", + lambdaFunctionArn: { + lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", + toolSchemaFile: "schemas/tool-schema.json", + }, + }); + expect(await Bun.file(join(directory, "example", "agentcore", "assets")).exists()).toBe(false); }); - test("rolls back a managed asset when candidate validation fails", async () => { + test("rejects an invalid project Target before writing the project", async () => { const directory = await inTempDirectory(); const subject = manager().manager; const project = await projectWithGateway(subject); @@ -447,110 +440,13 @@ describe("FsProjectManager.addResource", () => { resourceConfig: { name: "search", targetType: "openApiSchema", - schemaSource: { inline: { path: "openapi.json" } }, - }, - inlineSchema: { kind: "openapi", content: '{"openapi":"3.0.0"}' }, + } as never, }), ).rejects.toBeInstanceOf(InputValidationError); - const assetDirectory = join( - directory, - "example", - "agentcore", - "assets", - "gateways", - "tools", - "targets", - "search", - ); - expect(await Bun.file(assetDirectory).exists()).toBe(false); - }); - - test("rolls back a managed asset when the project write fails", async () => { - const directory = await inTempDirectory(); - const logger = createSilentLogger(); - const realJson = new FsReadWriteJson({ logger }); - const subject = manager().manager; - const project = await projectWithGateway(subject); - const failingJson: ReadWriteJson = { - read: (path, schema) => realJson.read(path, schema), - write: async () => { - throw new Error("write exploded"); - }, - }; - const failing = new FsProjectManager({ - logger, - runner: async () => {}, - checkTool: async () => {}, - json: failingJson, - }); - - await expect( - runAdd(failing, project, { - resourceType: "gateway-target", - gatewayName: "tools", - resourceConfig: { - name: "search", - targetType: "lambdaFunctionArn", - lambdaFunctionArn: { - lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", - toolSchemaFile: "tool-schema.json", - }, - }, - inlineSchema: { kind: "lambda", content: "[]" }, - }), - ).rejects.toThrow("write exploded"); - - const assetDirectory = join( - directory, - "example", - "agentcore", - "assets", - "gateways", - "tools", - "targets", - "search", - ); - expect(await Bun.file(assetDirectory).exists()).toBe(false); const persisted = await Bun.file( join(directory, "example", "agentcore", "agentcore.json"), ).json(); expect(persisted.agentCoreGateways[0].targets).toEqual([]); }); - - test("refuses to overwrite an existing Target asset directory", async () => { - const directory = await inTempDirectory(); - const subject = manager().manager; - const project = await projectWithGateway(subject); - const assetDirectory = join( - directory, - "example", - "agentcore", - "assets", - "gateways", - "tools", - "targets", - "search", - ); - await mkdir(assetDirectory, { recursive: true }); - await writeFile(join(assetDirectory, "keep.txt"), "keep"); - - await expect( - runAdd(subject, project, { - resourceType: "gateway-target", - gatewayName: "tools", - resourceConfig: { - name: "search", - targetType: "lambdaFunctionArn", - lambdaFunctionArn: { - lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", - toolSchemaFile: "tool-schema.json", - }, - }, - inlineSchema: { kind: "lambda", content: "[]" }, - }), - ).rejects.toThrow("asset directory already exists"); - - expect(await Bun.file(join(assetDirectory, "keep.txt")).text()).toBe("keep"); - }); }); diff --git a/src/handlers/project/add/gateway-add.test.ts b/src/handlers/project/add/gateway-add.test.ts index 9db10a4e1..4d3aa9332 100644 --- a/src/handlers/project/add/gateway-add.test.ts +++ b/src/handlers/project/add/gateway-add.test.ts @@ -75,13 +75,11 @@ describe("project add gateway", () => { expect(io.stderr()).toContain("added Gateway 'tools'"); }); - test("maps MCP, policy, role, exception, description, and tags", async () => { + 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); - const protocolFile = join(projectRoot, "protocol.json"); - await writeFile(protocolFile, '{"mcp":{"searchType":"SEMANTIC"}}'); await run([ "add", @@ -90,8 +88,7 @@ describe("project add gateway", () => { "tools", "--protocol", "mcp", - "--protocol-configuration", - `file://${protocolFile}`, + "--enable-semantic-search", "--role-arn", "arn:aws:iam::123456789012:role/GatewayRole", "--description", @@ -119,7 +116,7 @@ describe("project add gateway", () => { }); }); - test("reads CUSTOM_JWT configuration from stdin", async () => { + test("reads project authorizerConfiguration from stdin without translation", async () => { const projectRoot = await inProject(); await run( [ @@ -133,7 +130,7 @@ describe("project add gateway", () => { "-", ], JSON.stringify({ - customJWTAuthorizer: { + customJwtAuthorizer: { discoveryUrl: "https://idp.example.com/.well-known/openid-configuration", allowedAudience: ["agentcore"], }, @@ -151,27 +148,34 @@ describe("project add gateway", () => { }); }); - test("rejects unsupported protocol fields without writing a Gateway", async () => { + test("rejects the SDK authorizer shape", async () => { const projectRoot = await inProject(); await expect( run([ "add", "gateway", "--name", - "tools", - "--protocol", - "mcp", - "--protocol-configuration", - '{"mcp":{"instructions":"not persistable"}}', + "secure", + "--authorizer-type", + "CUSTOM_JWT", + "--authorizer-configuration", + '{"customJWTAuthorizer":{"discoveryUrl":"https://idp.example.com/.well-known/openid-configuration"}}', ]), - ).rejects.toThrow("mcp.instructions"); + ).rejects.toThrow("customJWTAuthorizer"); expect((await projectSpec(projectRoot)).agentCoreGateways ?? []).toEqual([]); }); + + test("semantic search requires an MCP Gateway", async () => { + await inProject(); + await expect( + run(["add", "gateway", "--name", "tools", "--enable-semantic-search"]), + ).rejects.toThrow("--protocol mcp"); + }); }); describe("project add gateway-target", () => { - test("adds endpoint and project Runtime modes", async () => { + test("adds endpoint and project Runtime shortcuts", async () => { const projectRoot = await inProject(); await addGateway(); await run([ @@ -211,138 +215,153 @@ describe("project add gateway-target", () => { ]); }); - test("materializes an inline Lambda tool schema", async () => { + test("persists a complete project Target object without translation or asset creation", async () => { const projectRoot = await inProject(); await addGateway(); + const target = { + name: "search", + targetType: "lambdaFunctionArn", + lambdaFunctionArn: { + lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", + toolSchemaFile: "schemas/tool-schema.json", + }, + }; + await run([ "add", "gateway-target", "--gateway", "tools", - "--name", - "search", "--target-configuration", - JSON.stringify({ - mcp: { - lambda: { - lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", - toolSchema: { - inlinePayload: [ - { - name: "search", - description: "Search", - inputSchema: { type: "object", properties: {} }, - }, - ], - }, - }, - }, - }), + JSON.stringify(target), ]); - const managedPath = "agentcore/assets/gateways/tools/targets/search/tool-schema.json"; - expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0]).toEqual({ - name: "search", - targetType: "lambdaFunctionArn", - lambdaFunctionArn: { - lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", - toolSchemaFile: managedPath, - }, - }); - expect(await Bun.file(join(projectRoot, managedPath)).json()).toEqual([ - { - name: "search", - description: "Search", - inputSchema: { type: "object", properties: {} }, - }, - ]); + expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0]).toEqual(target); + expect(await Bun.file(join(projectRoot, "agentcore", "assets")).exists()).toBe(false); }); - test("rejects an external MCP endpoint that does not use HTTPS", async () => { + test("accepts a project-owned compute Target represented by the project schema", async () => { const projectRoot = await inProject(); await addGateway(); - await expect( - run([ - "add", - "gateway-target", - "--gateway", - "tools", - "--name", - "insecure", - "--endpoint", - "http://mcp.example.com", - ]), - ).rejects.toThrow("must use HTTPS"); + const target = { + 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", + }, + }; + + await run([ + "add", + "gateway-target", + "--gateway", + "tools", + "--target-configuration", + JSON.stringify(target), + ]); - expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets).toEqual([]); + expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0]).toEqual(target); }); test.each(["file", "stdin"] as const)( - "reads Target configuration from %s", + "reads a complete project Target from %s", async (sourceKind) => { const projectRoot = await inProject(); await addGateway(); - const configuration = JSON.stringify({ - mcp: { mcpServer: { endpoint: "https://source.example.com" } }, - }); + const target = { + name: "source", + targetType: "mcpServer", + endpoint: "https://source.example.com", + }; + const configuration = JSON.stringify(target); const path = join(projectRoot, "target.json"); await writeFile(path, configuration); const source = sourceKind === "file" ? `file://${path}` : "-"; await run( - [ - "add", - "gateway-target", - "--gateway", - "tools", - "--name", - "source", - "--target-configuration", - source, - ], + ["add", "gateway-target", "--gateway", "tools", "--target-configuration", source], sourceKind === "stdin" ? configuration : undefined, ); - expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0]).toMatchObject({ - name: "source", - targetType: "mcpServer", - endpoint: "https://source.example.com", - }); + expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0]).toEqual(target); }, ); - test("resolves compatible project credentials", async () => { + test("rejects separate name and auth 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 inProject(); const spec = await projectSpec(projectRoot); - spec.credentials = [ - { authorizerType: "OAuthCredentialProvider", name: "search-oauth" }, - { authorizerType: "ApiKeyCredentialProvider", name: "search-key" }, - ]; + spec.credentials = [{ authorizerType: "OAuthCredentialProvider", name: "search-oauth" }]; await writeProjectSpec(projectRoot, spec); await addGateway(); + const target = { + name: "oauth", + targetType: "mcpServer", + endpoint: "https://oauth.example.com", + outboundAuth: { + type: "OAUTH", + credentialName: "search-oauth", + scopes: ["read", "write"], + }, + }; + await run([ "add", "gateway-target", "--gateway", "tools", - "--name", - "oauth", - "--endpoint", - "https://oauth.example.com", - "--outbound-auth", - "oauth", - "--credential-name", - "search-oauth", - "--scope", - "read", - "write", + "--target-configuration", + JSON.stringify(target), ]); - expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0].outboundAuth).toEqual({ - type: "OAUTH", - credentialName: "search-oauth", - scopes: ["read", "write"], - }); + expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0]).toEqual(target); }); test("allows equal Target names in different Gateways but not the same Gateway", async () => { @@ -375,7 +394,6 @@ describe("project add gateway-target", () => { ).rejects.toThrow("already exists"); const gateways = (await projectSpec(projectRoot)).agentCoreGateways; - expect(gateways.map((gateway: { targets: unknown[] }) => gateway.targets)).toHaveLength(2); expect(gateways[0].targets).toHaveLength(1); expect(gateways[1].targets).toHaveLength(1); }); @@ -425,43 +443,43 @@ describe("project add gateway-connector", () => { }); test.each(["inline", "file", "stdin"] as const)( - "reads Connector configuration from %s JSON", + "reads a complete connector project Target from %s JSON", async (sourceKind) => { const projectRoot = await inProject(); await addGateway(); - const configuration = JSON.stringify({ - mcp: { - connector: { - source: { connectorId: "web-search" }, - configurations: [{ name: "WebSearch", parameterValues: { maxResults: 3 } }], - }, - }, - }); + const target = { + name: "configured", + targetType: "connector", + connectorId: "web-search", + configurations: [{ name: "WebSearch", parameterValues: { maxResults: 3 } }], + }; + const configuration = JSON.stringify(target); const path = join(projectRoot, "connector.json"); await writeFile(path, configuration); const source = sourceKind === "inline" ? configuration : sourceKind === "file" ? `file://${path}` : "-"; await run( - [ - "add", - "gateway-connector", - "--gateway", - "tools", - "--name", - "configured", - "--connector-configuration", - source, - ], + ["add", "gateway-connector", "--gateway", "tools", "--connector-configuration", source], sourceKind === "stdin" ? configuration : undefined, ); - expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0]).toMatchObject({ - name: "configured", - targetType: "connector", - connectorId: "web-search", - configurations: [{ name: "WebSearch", parameterValues: { maxResults: 3 } }], - }); + 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"'); + }); }); diff --git a/src/handlers/project/add/gateway-connector/index.ts b/src/handlers/project/add/gateway-connector/index.ts index dcad9b7f2..fbaec8eb3 100644 --- a/src/handlers/project/add/gateway-connector/index.ts +++ b/src/handlers/project/add/gateway-connector/index.ts @@ -1,14 +1,14 @@ -import type { TargetConfiguration } from "@aws-sdk/client-bedrock-agentcore-control"; import z from "zod"; import { InputValidationError } from "../../../../errors"; import { SourceResolver } from "../../../../io"; +import { + AgentCoreGatewayTargetSchema, + type AgentCoreGatewayTarget, +} from "../../../../projectSchemas/gateway"; import { createHandler, flag, ProjectKey } from "../../../../router"; -import { parseJsonObjectFlag } from "../../../utils"; +import { parseJsonFlagWithSchema } from "../../../utils"; import type { AddProjectResourceConfig } from "../types"; -import { - connectorTargetFromShortcut, - translateTargetConfiguration, -} from "../gateway-target/configuration"; +import { connectorTargetFromShortcut } from "../gateway-target/configuration"; export const createAddGatewayConnectorHandler = (config: AddProjectResourceConfig) => createHandler({ @@ -16,7 +16,7 @@ export const createAddGatewayConnectorHandler = (config: AddProjectResourceConfi 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 connector Target name", z.string().optional()), + flag("name", "the Target name for a connector shortcut", z.string().optional()), flag( "connector", "curated connector", @@ -24,7 +24,7 @@ export const createAddGatewayConnectorHandler = (config: AddProjectResourceConfi ), flag( "connector-configuration", - "connector-backed Target configuration (JSON; inline, file://, or - for stdin)", + "complete connector agentCoreGateways[].targets[] object (JSON; inline, file://, or - for stdin)", z.string().optional(), ), flag( @@ -37,14 +37,26 @@ export const createAddGatewayConnectorHandler = (config: AddProjectResourceConfi if (!flags.gateway) { throw new InputValidationError("required option '--gateway ' not specified"); } - if (!flags.name) { - throw new InputValidationError("required option '--name ' 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", @@ -52,22 +64,25 @@ export const createAddGatewayConnectorHandler = (config: AddProjectResourceConfi } const project = ctx.require(ProjectKey); - let target; - if (flags.connector) { - target = connectorTargetFromShortcut(flags.name, flags.connector, flags["knowledge-base"]); - } else { + let target: AgentCoreGatewayTarget; + if (usesConfiguration) { const source = new SourceResolver({ stdin: config.io.stdin }); - const connectorConfiguration = parseJsonObjectFlag( + target = parseJsonFlagWithSchema( "connector-configuration", await source.resolveText("connector-configuration", flags["connector-configuration"]), + AgentCoreGatewayTargetSchema, )!; - const translated = translateTargetConfiguration(flags.name, connectorConfiguration); - if (translated.target.targetType !== "connector") { + if (target.targetType !== "connector") { throw new InputValidationError( - "--connector-configuration must contain an MCP connector Target", + '--connector-configuration must have targetType: "connector"', ); } - target = translated.target; + } else { + target = connectorTargetFromShortcut( + flags.name!, + flags.connector!, + flags["knowledge-base"], + ); } for await (const event of config.projectManager.addResource(project, { @@ -78,7 +93,7 @@ export const createAddGatewayConnectorHandler = (config: AddProjectResourceConfi config.io.stderr.write(`${event.message}\n`); } config.io.stderr.write( - `added Connector Target '${flags.name}' to Gateway '${flags.gateway}' in '${project.name}'\n`, + `added Connector Target '${target.name}' to Gateway '${flags.gateway}' in '${project.name}'\n`, ); }, }); diff --git a/src/handlers/project/add/gateway-target/configuration.test.ts b/src/handlers/project/add/gateway-target/configuration.test.ts deleted file mode 100644 index ec0602078..000000000 --- a/src/handlers/project/add/gateway-target/configuration.test.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { TargetConfiguration } from "@aws-sdk/client-bedrock-agentcore-control"; -import { connectorTargetFromShortcut, translateTargetConfiguration } from "./configuration"; - -describe("translateTargetConfiguration", () => { - test("materializes an inline Lambda tool schema", () => { - const result = translateTargetConfiguration("search", { - mcp: { - lambda: { - lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", - toolSchema: { - inlinePayload: [ - { - name: "search", - description: "Search documents", - inputSchema: { type: "object", properties: {} }, - }, - ], - }, - }, - }, - }); - - expect(result.target).toEqual({ - name: "search", - targetType: "lambdaFunctionArn", - lambdaFunctionArn: { - lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", - toolSchemaFile: "tool-schema.json", - }, - }); - expect(result.inlineSchema).toEqual({ - kind: "lambda", - content: JSON.stringify( - [ - { - name: "search", - description: "Search documents", - inputSchema: { type: "object", properties: {} }, - }, - ], - undefined, - 2, - ), - }); - }); - - test("preserves a Lambda S3 tool schema", () => { - const result = translateTargetConfiguration("search", { - mcp: { - lambda: { - lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", - toolSchema: { s3: { uri: "s3://schemas/search.json" } }, - }, - }, - }); - - expect(result).toEqual({ - target: { - name: "search", - targetType: "lambdaFunctionArn", - lambdaFunctionArn: { - lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", - toolSchemaFile: "s3://schemas/search.json", - }, - }, - }); - }); - - test("materializes inline OpenAPI and Smithy schemas", () => { - expect( - translateTargetConfiguration("openapi", { - mcp: { openApiSchema: { inlinePayload: '{"openapi":"3.0.0"}' } }, - }), - ).toEqual({ - target: { - name: "openapi", - targetType: "openApiSchema", - schemaSource: { inline: { path: "openapi.json" } }, - }, - inlineSchema: { kind: "openapi", content: '{"openapi":"3.0.0"}' }, - }); - - expect( - translateTargetConfiguration("smithy", { - mcp: { smithyModel: { inlinePayload: '{"smithy":"2.0"}' } }, - }), - ).toEqual({ - target: { - name: "smithy", - targetType: "smithyModel", - schemaSource: { inline: { path: "smithy.json" } }, - }, - inlineSchema: { kind: "smithy", content: '{"smithy":"2.0"}' }, - }); - }); - - test("preserves S3 schema configuration", () => { - expect( - translateTargetConfiguration("openapi", { - mcp: { - openApiSchema: { - s3: { uri: "s3://schemas/openapi.json", bucketOwnerAccountId: "123456789012" }, - }, - }, - }), - ).toEqual({ - target: { - name: "openapi", - targetType: "openApiSchema", - schemaSource: { - s3: { uri: "s3://schemas/openapi.json", bucketOwnerAccountId: "123456789012" }, - }, - }, - }); - }); - - test("maps external MCP, API Gateway, and HTTP passthrough targets", () => { - expect( - translateTargetConfiguration("external", { - mcp: { mcpServer: { endpoint: "https://mcp.example.com" } }, - }), - ).toEqual({ - target: { - name: "external", - targetType: "mcpServer", - endpoint: "https://mcp.example.com", - }, - }); - - expect( - translateTargetConfiguration("api", { - mcp: { - apiGateway: { - restApiId: "abc123", - stage: "prod", - apiGatewayToolConfiguration: { - toolFilters: [{ filterPath: "/pets", methods: ["GET", "POST"] }], - toolOverrides: [ - { - name: "getPet", - path: "/pets/{id}", - method: "GET", - description: "Get one pet", - }, - ], - }, - }, - }, - }), - ).toEqual({ - target: { - name: "api", - targetType: "apiGateway", - apiGateway: { - restApiId: "abc123", - stage: "prod", - apiGatewayToolConfiguration: { - toolFilters: [{ filterPath: "/pets", methods: ["GET", "POST"] }], - toolOverrides: [ - { - name: "getPet", - path: "/pets/{id}", - method: "GET", - description: "Get one pet", - }, - ], - }, - }, - }, - }); - - expect( - translateTargetConfiguration("http", { - http: { - passthrough: { - endpoint: "https://api.example.com", - protocolType: "CUSTOM", - stickinessConfiguration: { identifier: "$context.header.x-session", timeout: 900 }, - }, - }, - }), - ).toEqual({ - target: { - name: "http", - targetType: "passthrough", - passthrough: { - endpoint: "https://api.example.com", - protocolType: "CUSTOM", - stickinessConfiguration: { identifier: "$context.header.x-session", timeout: 900 }, - }, - }, - }); - }); - - test("maps supported connector configuration without dropping fields", () => { - expect( - translateTargetConfiguration("search", { - mcp: { - connector: { - source: { connectorId: "web-search" }, - configurations: [ - { - name: "WebSearch", - description: "Search selected sites", - parameterValues: { maxResults: 5 }, - parameterOverrides: [ - { path: "/query", description: "Search query", visible: true }, - ], - }, - ], - }, - }, - }), - ).toEqual({ - target: { - name: "search", - targetType: "connector", - connectorId: "web-search", - configurations: [ - { - name: "WebSearch", - description: "Search selected sites", - parameterValues: { maxResults: 5 }, - parameterOverrides: [{ path: "/query", description: "Search query", visible: true }], - }, - ], - }, - }); - }); - - test.each([ - [ - "MCP tool schema", - { - mcp: { - mcpServer: { - endpoint: "https://mcp.example.com", - mcpToolSchema: { inlinePayload: "[]" }, - }, - }, - }, - "mcp.mcpServer.mcpToolSchema", - ], - [ - "connector version", - { - mcp: { - connector: { source: { connectorId: "web-search", version: "1.1.0" } }, - }, - }, - "mcp.connector.source.version", - ], - [ - "connector enabled tools", - { - mcp: { - connector: { - source: { connectorId: "web-search" }, - enabled: ["WebSearch"], - }, - }, - }, - "mcp.connector.enabled", - ], - [ - "HTTP schema", - { - http: { - passthrough: { - endpoint: "https://api.example.com", - protocolType: "CUSTOM", - schema: { source: { inlinePayload: "{}" } }, - }, - }, - }, - "http.passthrough.schema", - ], - [ - "Runtime ARN", - { - http: { - agentcoreRuntime: { - arn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/runtime-id", - }, - }, - }, - "--runtime", - ], - [ - "inference", - { - inference: { - connector: { source: { connectorId: "bedrock-mantle" } }, - }, - }, - "inference", - ], - [ - "unknown field", - { - mcp: { - mcpServer: { - endpoint: "https://mcp.example.com", - futureField: true, - }, - }, - } as unknown as TargetConfiguration, - "mcp.mcpServer.futureField", - ], - [ - "non-HTTPS MCP endpoint", - { - mcp: { - mcpServer: { - endpoint: "http://mcp.example.com", - }, - }, - }, - "must use HTTPS", - ], - ] satisfies [string, TargetConfiguration, string][])( - "rejects unsupported %s input", - (_label, configuration, expected) => { - expect(() => translateTargetConfiguration("target", configuration)).toThrow(expected); - }, - ); -}); - -describe("connectorTargetFromShortcut", () => { - test("builds web search and knowledge base connector targets", () => { - expect(connectorTargetFromShortcut("search", "web-search")).toEqual({ - name: "search", - targetType: "connector", - connectorId: "web-search", - configurations: [{ name: "WebSearch", parameterValues: { maxResults: 10 } }], - }); - expect( - connectorTargetFromShortcut("knowledge", "bedrock-knowledge-bases", "ProductDocs"), - ).toEqual({ - name: "knowledge", - targetType: "connector", - connectorId: "bedrock-knowledge-bases", - configurations: [{ name: "Retrieve", parameterValues: { knowledgeBaseId: "ProductDocs" } }], - }); - }); - - test("requires a knowledge base reference", () => { - expect(() => connectorTargetFromShortcut("knowledge", "bedrock-knowledge-bases")).toThrow( - "--knowledge-base", - ); - }); -}); diff --git a/src/handlers/project/add/gateway-target/configuration.ts b/src/handlers/project/add/gateway-target/configuration.ts index ad1402d50..42477c2d0 100644 --- a/src/handlers/project/add/gateway-target/configuration.ts +++ b/src/handlers/project/add/gateway-target/configuration.ts @@ -1,63 +1,5 @@ -import type { TargetConfiguration } from "@aws-sdk/client-bedrock-agentcore-control"; import { InputValidationError } from "../../../../errors"; -import type { - AgentCoreGatewayTarget, - ConnectorId, - OutboundAuth, - SchemaSource, -} from "../../../../projectSchemas/gateway"; -import { - GATEWAY_TARGET_INLINE_SCHEMA_FILENAMES, - type GatewayTargetInlineSchema, -} from "../../types"; - -type Translation = { - target: AgentCoreGatewayTarget; - inlineSchema?: GatewayTargetInlineSchema; -}; - -type JsonObject = Record; - -export function translateTargetConfiguration( - name: string, - configuration: TargetConfiguration, - outboundAuth?: OutboundAuth, -): Translation { - const root = object(configuration, "targetConfiguration"); - exactKeys(root, ["mcp", "http", "inference"], "targetConfiguration"); - const variant = exactlyOne(root, ["mcp", "http", "inference"], "targetConfiguration"); - - let translation: Translation; - switch (variant) { - case "mcp": - translation = translateMcp(name, object(root.mcp, "mcp")); - break; - case "http": - translation = translateHttp(name, object(root.http, "http")); - break; - case "inference": - throw unsupported( - "inference", - "Inference Gateway Targets are not represented by the current project schema.", - ); - } - - if (!outboundAuth) return translation; - if ( - !["mcpServer", "openApiSchema", "apiGateway", "httpRuntime", "passthrough"].includes( - translation.target.targetType, - ) - ) { - throw unsupported( - "outboundAuth", - `${translation.target.targetType} Targets cannot preserve outbound authentication in the current project schema.`, - ); - } - return { - ...translation, - target: { ...translation.target, outboundAuth }, - }; -} +import type { AgentCoreGatewayTarget, ConnectorId } from "../../../../projectSchemas/gateway"; export function connectorTargetFromShortcut( name: string, @@ -87,342 +29,14 @@ export function connectorTargetFromShortcut( } } -function translateMcp(name: string, mcp: JsonObject): Translation { - const variants = [ - "openApiSchema", - "smithyModel", - "lambda", - "mcpServer", - "apiGateway", - "connector", - ] as const; - exactKeys(mcp, variants, "mcp"); - const variant = exactlyOne(mcp, variants, "mcp"); - - switch (variant) { - case "openApiSchema": - return translateApiSchema(name, "openApiSchema", "openapi", mcp.openApiSchema); - case "smithyModel": - return translateApiSchema(name, "smithyModel", "smithy", mcp.smithyModel); - case "lambda": - return translateLambda(name, object(mcp.lambda, "mcp.lambda")); - case "mcpServer": - return translateMcpServer(name, object(mcp.mcpServer, "mcp.mcpServer")); - case "apiGateway": - return translateApiGateway(name, object(mcp.apiGateway, "mcp.apiGateway")); - case "connector": - return translateConnector(name, object(mcp.connector, "mcp.connector")); - } -} - -function translateApiSchema( - name: string, - targetType: "openApiSchema" | "smithyModel", - kind: "openapi" | "smithy", - raw: unknown, -): Translation { - const schema = object(raw, `mcp.${targetType}`); - exactKeys(schema, ["inlinePayload", "s3"], `mcp.${targetType}`); - const source = exactlyOne(schema, ["inlinePayload", "s3"], `mcp.${targetType}`); - - if (source === "inlinePayload") { - const content = string(schema.inlinePayload, `mcp.${targetType}.inlinePayload`); - return { - target: { - name, - targetType, - schemaSource: { - inline: { path: GATEWAY_TARGET_INLINE_SCHEMA_FILENAMES[kind] }, - }, - }, - inlineSchema: { kind, content }, - }; - } - - return { - target: { - name, - targetType, - schemaSource: translateS3Source(schema.s3, `mcp.${targetType}.s3`), - }, - }; -} - -function translateLambda(name: string, lambda: JsonObject): Translation { - exactKeys(lambda, ["lambdaArn", "toolSchema"], "mcp.lambda"); - const lambdaArn = string(lambda.lambdaArn, "mcp.lambda.lambdaArn"); - const toolSchema = object(lambda.toolSchema, "mcp.lambda.toolSchema"); - exactKeys(toolSchema, ["inlinePayload", "s3"], "mcp.lambda.toolSchema"); - const source = exactlyOne(toolSchema, ["inlinePayload", "s3"], "mcp.lambda.toolSchema"); - - if (source === "inlinePayload") { - if (!Array.isArray(toolSchema.inlinePayload)) { - throw invalid("mcp.lambda.toolSchema.inlinePayload", "must be a JSON array"); - } - return { - target: { - name, - targetType: "lambdaFunctionArn", - lambdaFunctionArn: { - lambdaArn, - toolSchemaFile: GATEWAY_TARGET_INLINE_SCHEMA_FILENAMES.lambda, - }, - }, - inlineSchema: { - kind: "lambda", - content: JSON.stringify(toolSchema.inlinePayload, undefined, 2), - }, - }; - } - - const s3 = object(toolSchema.s3, "mcp.lambda.toolSchema.s3"); - exactKeys(s3, ["uri", "bucketOwnerAccountId"], "mcp.lambda.toolSchema.s3"); - if (s3.bucketOwnerAccountId !== undefined) { - throw unsupported( - "mcp.lambda.toolSchema.s3.bucketOwnerAccountId", - "Lambda tool schemas in the project schema preserve only the S3 URI.", - ); - } - return { - target: { - name, - targetType: "lambdaFunctionArn", - lambdaFunctionArn: { - lambdaArn, - toolSchemaFile: string(s3.uri, "mcp.lambda.toolSchema.s3.uri"), - }, - }, - }; -} - -function translateMcpServer(name: string, server: JsonObject): Translation { - exactKeys( - server, - ["endpoint", "mcpToolSchema", "listingMode", "resourcePriority"], - "mcp.mcpServer", - ); - for (const field of ["mcpToolSchema", "listingMode", "resourcePriority"] as const) { - if (server[field] !== undefined) { - throw unsupported( - `mcp.mcpServer.${field}`, - "Static MCP Server discovery settings are not represented by the current project schema.", - ); - } - } - return { - target: { - name, - targetType: "mcpServer", - endpoint: httpsEndpoint(server.endpoint, "mcp.mcpServer.endpoint"), - }, - }; -} - -function translateApiGateway(name: string, raw: JsonObject): Translation { - exactKeys(raw, ["restApiId", "stage", "apiGatewayToolConfiguration"], "mcp.apiGateway"); - const toolConfiguration = object( - raw.apiGatewayToolConfiguration, - "mcp.apiGateway.apiGatewayToolConfiguration", - ); - exactKeys( - toolConfiguration, - ["toolFilters", "toolOverrides"], - "mcp.apiGateway.apiGatewayToolConfiguration", - ); - array( - toolConfiguration.toolFilters, - "mcp.apiGateway.apiGatewayToolConfiguration.toolFilters", - ).forEach((filter, index) => - exactKeys( - object(filter, `mcp.apiGateway.apiGatewayToolConfiguration.toolFilters[${index}]`), - ["filterPath", "methods"], - `mcp.apiGateway.apiGatewayToolConfiguration.toolFilters[${index}]`, - ), - ); - if (toolConfiguration.toolOverrides !== undefined) { - array( - toolConfiguration.toolOverrides, - "mcp.apiGateway.apiGatewayToolConfiguration.toolOverrides", - ).forEach((override, index) => - exactKeys( - object(override, `mcp.apiGateway.apiGatewayToolConfiguration.toolOverrides[${index}]`), - ["name", "description", "path", "method"], - `mcp.apiGateway.apiGatewayToolConfiguration.toolOverrides[${index}]`, - ), - ); - } - - return { - target: { - name, - targetType: "apiGateway", - apiGateway: raw as AgentCoreGatewayTarget["apiGateway"], - }, - }; -} - -function translateConnector(name: string, raw: JsonObject): Translation { - exactKeys(raw, ["source", "enabled", "configurations"], "mcp.connector"); - if (raw.enabled !== undefined) { - throw unsupported( - "mcp.connector.enabled", - "Connector enabled-tool selection is not represented by the current project schema.", - ); - } - const source = object(raw.source, "mcp.connector.source"); - exactKeys(source, ["connectorId", "version"], "mcp.connector.source"); - if (source.version !== undefined) { - throw unsupported( - "mcp.connector.source.version", - "Connector version selection is not represented by the current project schema.", - ); - } - const connectorId = string(source.connectorId, "mcp.connector.source.connectorId"); - if (connectorId !== "web-search" && connectorId !== "bedrock-knowledge-bases") { - throw unsupported( - "mcp.connector.source.connectorId", - `Connector '${connectorId}' is not supported by the current project schema.`, - ); - } - - let configurations: AgentCoreGatewayTarget["configurations"]; - if (raw.configurations !== undefined) { - configurations = array(raw.configurations, "mcp.connector.configurations").map( - (configuration, index) => { - const path = `mcp.connector.configurations[${index}]`; - const item = object(configuration, path); - exactKeys(item, ["name", "description", "parameterValues", "parameterOverrides"], path); - if (item.parameterOverrides !== undefined) { - array(item.parameterOverrides, `${path}.parameterOverrides`).forEach( - (override, overrideIndex) => - exactKeys( - object(override, `${path}.parameterOverrides[${overrideIndex}]`), - ["path", "description", "visible"], - `${path}.parameterOverrides[${overrideIndex}]`, - ), - ); - } - return item as NonNullable[number]; - }, - ); - } - - return { - target: { - name, - targetType: "connector", - connectorId, - configurations, - }, - }; -} - -function translateHttp(name: string, http: JsonObject): Translation { - exactKeys(http, ["agentcoreRuntime", "passthrough"], "http"); - const variant = exactlyOne(http, ["agentcoreRuntime", "passthrough"], "http"); - if (variant === "agentcoreRuntime") { - throw unsupported( - "http.agentcoreRuntime", - "Use --runtime with the name of a Runtime declared in this project.", - ); - } - - const passthrough = object(http.passthrough, "http.passthrough"); - exactKeys( - passthrough, - ["endpoint", "protocolType", "schema", "stickinessConfiguration"], - "http.passthrough", - ); - if (passthrough.schema !== undefined) { - throw unsupported( - "http.passthrough.schema", - "HTTP API schemas are not represented by the current project schema.", - ); - } - if (passthrough.stickinessConfiguration !== undefined) { - exactKeys( - object(passthrough.stickinessConfiguration, "http.passthrough.stickinessConfiguration"), - ["identifier", "timeout"], - "http.passthrough.stickinessConfiguration", - ); - } - - return { - target: { - name, - targetType: "passthrough", - passthrough: passthrough as AgentCoreGatewayTarget["passthrough"], - }, - }; -} - -function translateS3Source(raw: unknown, path: string): SchemaSource { - const s3 = object(raw, path); - exactKeys(s3, ["uri", "bucketOwnerAccountId"], path); - return { - s3: { - uri: string(s3.uri, `${path}.uri`), - ...(s3.bucketOwnerAccountId === undefined - ? {} - : { - bucketOwnerAccountId: string(s3.bucketOwnerAccountId, `${path}.bucketOwnerAccountId`), - }), - }, - }; -} - -function object(value: unknown, path: string): JsonObject { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw invalid(path, "must be a JSON object"); - } - return value as JsonObject; -} - -function array(value: unknown, path: string): unknown[] { - if (!Array.isArray(value)) throw invalid(path, "must be a JSON array"); - return value; -} - -function string(value: unknown, path: string): string { - if (typeof value !== "string" || value.length === 0) { - throw invalid(path, "must be a non-empty string"); - } - return value; -} - -export function httpsEndpoint(value: unknown, path: string): string { - const endpoint = string(value, path); +export function httpsEndpoint(value: string, option: string): string { try { - if (new URL(endpoint).protocol !== "https:") { - throw invalid(path, "must use HTTPS"); + if (new URL(value).protocol !== "https:") { + throw new InputValidationError(`${option} must use HTTPS`); } } catch (error) { if (error instanceof InputValidationError) throw error; - throw invalid(path, "must be a valid HTTPS URL"); - } - return endpoint; -} - -function exactKeys(value: JsonObject, allowed: readonly string[], path: string): void { - for (const key of Object.keys(value)) { - if (!allowed.includes(key)) { - throw unsupported(`${path}.${key}`, "This field cannot be persisted without data loss."); - } + throw new InputValidationError(`${option} must be a valid HTTPS URL`, { cause: error }); } -} - -function exactlyOne(value: JsonObject, keys: readonly T[], path: string): T { - const present = keys.filter((key) => value[key] !== undefined); - if (present.length !== 1) { - throw invalid(path, `must contain exactly one of ${keys.join(", ")}`); - } - return present[0]!; -} - -function invalid(path: string, message: string): InputValidationError { - return new InputValidationError(`Invalid Target configuration field '${path}': ${message}`); -} - -function unsupported(path: string, guidance: string): InputValidationError { - return new InputValidationError(`Unsupported Target configuration field '${path}'. ${guidance}`); + return value; } diff --git a/src/handlers/project/add/gateway-target/index.ts b/src/handlers/project/add/gateway-target/index.ts index 9b9c6735a..88211004b 100644 --- a/src/handlers/project/add/gateway-target/index.ts +++ b/src/handlers/project/add/gateway-target/index.ts @@ -1,14 +1,17 @@ -import type { TargetConfiguration } from "@aws-sdk/client-bedrock-agentcore-control"; import z from "zod"; import { InputValidationError } from "../../../../errors"; import { SourceResolver } from "../../../../io"; -import type { AgentCoreGatewayTarget, OutboundAuth } from "../../../../projectSchemas/gateway"; import type { Credential } from "../../../../projectSchemas/credential"; +import { + AgentCoreGatewayTargetSchema, + type AgentCoreGatewayTarget, + type OutboundAuth, +} from "../../../../projectSchemas/gateway"; import { createHandler, flag, ProjectKey } from "../../../../router"; -import { parseJsonObjectFlag } from "../../../utils"; +import { parseJsonFlagWithSchema } from "../../../utils"; import type { Project } from "../../types"; import type { AddProjectResourceConfig } from "../types"; -import { httpsEndpoint, translateTargetConfiguration } from "./configuration"; +import { httpsEndpoint } from "./configuration"; export const createAddGatewayTargetHandler = (config: AddProjectResourceConfig) => createHandler({ @@ -16,18 +19,18 @@ export const createAddGatewayTargetHandler = (config: AddProjectResourceConfig) 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", 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 Target configuration (JSON; inline, file://, or - for stdin)", + "complete agentCoreGateways[].targets[] object (JSON; inline, file://, or - for stdin)", z.string().optional(), ), flag( "outbound-auth", - "Target authentication: none, oauth, or api-key", + "shortcut Target authentication: none, oauth, or api-key", z.enum(["none", "oauth", "api-key"]).optional(), ), flag( @@ -41,9 +44,6 @@ export const createAddGatewayTargetHandler = (config: AddProjectResourceConfig) if (!flags.gateway) { throw new InputValidationError("required option '--gateway ' not specified"); } - if (!flags.name) { - throw new InputValidationError("required option '--name ' not specified"); - } const modes = [ ["--endpoint", flags.endpoint], ["--runtime", flags.runtime], @@ -58,57 +58,70 @@ export const createAddGatewayTargetHandler = (config: AddProjectResourceConfig) throw new InputValidationError("--runtime-endpoint requires --runtime"); } - const project = ctx.require(ProjectKey); - const outboundAuth = projectOutboundAuth(project, { - type: flags["outbound-auth"], - credentialName: flags["credential-name"], - scopes: flags.scope, - }); + 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; - let inlineSchema; - if (flags.endpoint !== undefined) { - target = { - name: flags.name, - targetType: "mcpServer", - endpoint: httpsEndpoint(flags.endpoint, "--endpoint"), - outboundAuth, - }; - } else if (flags.runtime !== undefined) { - target = { - name: flags.name, - targetType: "httpRuntime", - httpRuntime: { - runtime: flags.runtime, - runtimeEndpoint: flags["runtime-endpoint"], - }, - outboundAuth, - }; - } else { + if (usesConfiguration) { const source = new SourceResolver({ stdin: config.io.stdin }); - const targetConfiguration = parseJsonObjectFlag( + target = parseJsonFlagWithSchema( "target-configuration", await source.resolveText("target-configuration", flags["target-configuration"]), + AgentCoreGatewayTargetSchema, )!; - const translated = translateTargetConfiguration( - flags.name, - targetConfiguration, - outboundAuth, - ); - target = translated.target; - inlineSchema = translated.inlineSchema; + 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, - inlineSchema, })) { config.io.stderr.write(`${event.message}\n`); } config.io.stderr.write( - `added Target '${flags.name}' to Gateway '${flags.gateway}' in '${project.name}'\n`, + `added Target '${target.name}' to Gateway '${flags.gateway}' in '${project.name}'\n`, ); }, }); @@ -144,14 +157,7 @@ function projectOutboundAuth(project: Project, input: OutboundAuthInput): Outbou throw new InputValidationError("--scope is valid only with --outbound-auth oauth"); } - const credential = project.spec.credentials.find( - (candidate) => candidate.name === input.credentialName, - ); - if (!credential) { - throw new InputValidationError( - `credential '${input.credentialName}' does not exist in credentials[]`, - ); - } + const credential = requireCredential(project, input.credentialName); assertCredentialType(credential, input.type); return { type: input.type === "oauth" ? "OAUTH" : "API_KEY", @@ -160,6 +166,23 @@ function projectOutboundAuth(project: Project, input: OutboundAuthInput): Outbou }; } +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) { diff --git a/src/handlers/project/add/gateway/index.ts b/src/handlers/project/add/gateway/index.ts index 65cda3710..15670259f 100644 --- a/src/handlers/project/add/gateway/index.ts +++ b/src/handlers/project/add/gateway/index.ts @@ -1,16 +1,14 @@ -import type { - AuthorizerConfiguration as SdkAuthorizerConfiguration, - GatewayProtocolConfiguration, -} from "@aws-sdk/client-bedrock-agentcore-control"; import z from "zod"; import { InputValidationError } from "../../../../errors"; import { SourceResolver } from "../../../../io"; -import type { AuthorizerConfig } from "../../../../projectSchemas/auth"; +import { GatewayAuthorizerConfigSchema } from "../../../../projectSchemas/auth"; import type { AgentCoreGateway } from "../../../../projectSchemas/gateway"; import { createHandler, flag, ProjectKey } from "../../../../router"; -import { parseJsonObjectFlag, parseTags } from "../../../utils"; +import { parseJsonFlagWithSchema, parseTags } from "../../../utils"; import type { AddProjectResourceConfig } from "../types"; +const GatewayAuthorizerConfigurationInputSchema = GatewayAuthorizerConfigSchema.strict(); + export const createAddGatewayHandler = (config: AddProjectResourceConfig) => createHandler({ name: "gateway", @@ -27,20 +25,20 @@ export const createAddGatewayHandler = (config: AddProjectResourceConfig) => "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( - "protocol-configuration", - "MCP protocol configuration (JSON; inline, file://, or - for stdin)", - z.string().optional(), - ), flag( "authorizer-configuration", - "CUSTOM_JWT configuration (JSON; inline, file://, or - for stdin)", + "project authorizerConfiguration (JSON; inline, file://, or - for stdin)", z.string().optional(), ), flag( @@ -95,20 +93,17 @@ export const createAddGatewayHandler = (config: AddProjectResourceConfig) => if (authorizerType !== "CUSTOM_JWT" && flags["authorizer-configuration"] !== undefined) { throw new InputValidationError("--authorizer-configuration is valid only with CUSTOM_JWT"); } - if (!flags.protocol && flags["protocol-configuration"] !== undefined) { + if (flags["enable-semantic-search"] && !flags.protocol) { throw new InputValidationError( - "--protocol-configuration is valid only with --protocol mcp", + "--enable-semantic-search is valid only with --protocol mcp", ); } const source = new SourceResolver({ stdin: config.io.stdin }); - const protocolConfiguration = parseJsonObjectFlag( - "protocol-configuration", - await source.resolveText("protocol-configuration", flags["protocol-configuration"]), - ); - const authorizerConfiguration = parseJsonObjectFlag( + const authorizerConfiguration = parseJsonFlagWithSchema( "authorizer-configuration", await source.resolveText("authorizer-configuration", flags["authorizer-configuration"]), + GatewayAuthorizerConfigurationInputSchema, ); const tags = await resolveTags(source, flags.tags); @@ -116,14 +111,10 @@ export const createAddGatewayHandler = (config: AddProjectResourceConfig) => name: flags.name, protocolType: flags.protocol ? "MCP" : "None", authorizerType, - authorizerConfiguration: authorizerConfiguration - ? toAuthorizerConfiguration(authorizerConfiguration) - : undefined, + authorizerConfiguration, description: flags.description, targets: [], - enableSemanticSearch: protocolConfiguration - ? semanticSearchEnabled(protocolConfiguration) - : false, + enableSemanticSearch: flags["enable-semantic-search"] ?? false, exceptionLevel: flags["exception-level"] ? "DEBUG" : "NONE", executionRoleArn: flags["role-arn"], policyEngineConfiguration: @@ -146,90 +137,6 @@ export const createAddGatewayHandler = (config: AddProjectResourceConfig) => }, }); -function semanticSearchEnabled(configuration: GatewayProtocolConfiguration): boolean { - const root = configuration as unknown as Record; - exactKeys(root, ["mcp"], "protocol-configuration"); - if (!root.mcp) { - throw new InputValidationError("--protocol-configuration must contain mcp"); - } - const mcp = object(root.mcp, "protocol-configuration.mcp"); - exactKeys( - mcp, - [ - "searchType", - "supportedVersions", - "instructions", - "sessionConfiguration", - "streamingConfiguration", - ], - "protocol-configuration.mcp", - ); - for (const field of [ - "supportedVersions", - "instructions", - "sessionConfiguration", - "streamingConfiguration", - ]) { - if (mcp[field] !== undefined) { - throw new InputValidationError( - `Unsupported --protocol-configuration field 'mcp.${field}'. ` + - "The current project schema cannot persist it.", - ); - } - } - if (mcp.searchType !== undefined && mcp.searchType !== "SEMANTIC") { - throw new InputValidationError( - "Unsupported --protocol-configuration field 'mcp.searchType'. " + - "The current project schema supports only SEMANTIC.", - ); - } - return mcp.searchType === "SEMANTIC"; -} - -function toAuthorizerConfiguration(configuration: SdkAuthorizerConfiguration): AuthorizerConfig { - const root = configuration as unknown as Record; - exactKeys(root, ["customJWTAuthorizer"], "authorizer-configuration"); - const custom = object(root.customJWTAuthorizer, "authorizer-configuration.customJWTAuthorizer"); - exactKeys( - custom, - [ - "discoveryUrl", - "allowedAudience", - "allowedClients", - "allowedScopes", - "advertisedScopeMapping", - "customClaims", - "privateEndpoint", - "privateEndpointOverrides", - "allowedWorkloadConfiguration", - ], - "authorizer-configuration.customJWTAuthorizer", - ); - for (const field of ["advertisedScopeMapping", "allowedWorkloadConfiguration"]) { - if (custom[field] !== undefined) { - throw new InputValidationError( - `Unsupported --authorizer-configuration field 'customJWTAuthorizer.${field}'. ` + - "The current project schema cannot persist it.", - ); - } - } - return { - customJwtAuthorizer: { - discoveryUrl: custom.discoveryUrl as string, - allowedAudience: custom.allowedAudience as string[] | undefined, - allowedClients: custom.allowedClients as string[] | undefined, - allowedScopes: custom.allowedScopes as string[] | undefined, - customClaims: custom.customClaims as - NonNullable["customClaims"] | undefined, - privateEndpoint: custom.privateEndpoint as - NonNullable["privateEndpoint"] | undefined, - privateEndpointOverrides: custom.privateEndpointOverrides as - | NonNullable["privateEndpointOverrides"] - | undefined, - }, - }; -} - async function resolveTags( source: SourceResolver, values: string[] | undefined, @@ -245,20 +152,3 @@ async function resolveTags( } return parseTags(values); } - -function object(value: unknown, path: string): Record { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new InputValidationError(`${path} must be a JSON object`); - } - return value as Record; -} - -function exactKeys(value: Record, allowed: readonly string[], path: string): void { - for (const key of Object.keys(value)) { - if (!allowed.includes(key)) { - throw new InputValidationError( - `Unsupported --${path} field '${key}'. This field cannot be persisted without data loss.`, - ); - } - } -} diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 01bf992b2..11118bc1a 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -59,19 +59,6 @@ export type EnvLocalEntry = { comment: string; }; -export const GATEWAY_TARGET_INLINE_SCHEMA_FILENAMES = { - lambda: "tool-schema.json", - openapi: "openapi.json", - smithy: "smithy.json", -} as const; - -export type GatewayTargetInlineSchemaKind = keyof typeof GATEWAY_TARGET_INLINE_SCHEMA_FILENAMES; - -export type GatewayTargetInlineSchema = { - kind: GatewayTargetInlineSchemaKind; - content: string; -}; - /** Discriminated union input for {@link ProjectManager.addResource}. */ export type AddResourceInput = | { @@ -107,7 +94,6 @@ export type AddResourceInput = resourceType: "gateway-target"; gatewayName: string; resourceConfig: AgentCoreGatewayTarget; - inlineSchema?: GatewayTargetInlineSchema; }; export type ProjectResource = AddResourceInput["resourceType"]; From 51adfd5854eb581e647caaeeb54e90cb137cff89 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 19 Aug 2026 18:53:07 +0000 Subject: [PATCH 3/7] test(project): restore gateway manager assertion --- src/core/project/manager.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index f4f817692..7cf5c8856 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { join, relative } from "node:path"; import { tmpdir } from "node:os"; -import { DeserializationError, ProjectStateError } from "../../errors/errors"; +import { DeserializationError, InputValidationError, ProjectStateError } from "../../errors/errors"; import { FsProjectManager } from "./manager"; import { PROJECT_TEMPLATES, From c9423e05388b167353952ff1814b691f042e8271 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 20 Aug 2026 16:06:12 +0000 Subject: [PATCH 4/7] fix(project): enforce global gateway target names --- src/handlers/project/add/gateway-add.test.ts | 62 ++++++++++++++------ 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/src/handlers/project/add/gateway-add.test.ts b/src/handlers/project/add/gateway-add.test.ts index 4d3aa9332..94a4258af 100644 --- a/src/handlers/project/add/gateway-add.test.ts +++ b/src/handlers/project/add/gateway-add.test.ts @@ -364,22 +364,54 @@ describe("project add gateway-target", () => { expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0]).toEqual(target); }); - test("allows equal Target names in different Gateways but not the same Gateway", async () => { + test("rejects duplicate Target names across every Gateway in the project", 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 run([ - "add", - "gateway-target", - "--gateway", - gateway, - "--name", - "search", - "--endpoint", - `https://${gateway}.example.com`, - ]); + 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("tools"); + await expect( run([ "add", @@ -389,13 +421,9 @@ describe("project add gateway-target", () => { "--name", "search", "--endpoint", - "https://duplicate.example.com", + "https://tools.example.com", ]), - ).rejects.toThrow("already exists"); - - const gateways = (await projectSpec(projectRoot)).agentCoreGateways; - expect(gateways[0].targets).toHaveLength(1); - expect(gateways[1].targets).toHaveLength(1); + ).rejects.toThrow("unassigned gateway target"); }); }); From a5df42801a1a6fc6e09e5a2e910485a7cec8740d Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 21 Aug 2026 20:23:35 +0000 Subject: [PATCH 5/7] refactor(project): simplify gateway resource mutation --- src/core/project/manager.test.ts | 93 +--- src/handlers/project/add/gateway-add.test.ts | 445 +++++++++++++++++- .../project/add/gateway-connector/index.ts | 30 +- .../add/gateway-target/configuration.ts | 42 -- .../project/add/gateway-target/index.ts | 13 +- 5 files changed, 485 insertions(+), 138 deletions(-) delete mode 100644 src/handlers/project/add/gateway-target/configuration.ts diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 7cf5c8856..d9254ca07 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -2,11 +2,10 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { join, relative } from "node:path"; import { tmpdir } from "node:os"; -import { DeserializationError, InputValidationError, ProjectStateError } from "../../errors/errors"; +import { DeserializationError, ProjectStateError } from "../../errors/errors"; import { FsProjectManager } from "./manager"; import { PROJECT_TEMPLATES, - type AddResourceInput, type CreateProjectInput, type Project, type ProjectEvent, @@ -63,21 +62,6 @@ async function runCreate( } } -async function runAdd( - subject: FsProjectManager, - project: Project, - input: AddResourceInput, -): Promise<{ events: ProjectEvent[]; project: Project }> { - const iterator = subject.addResource(project, input); - const events: ProjectEvent[] = []; - - while (true) { - const next = await iterator.next(); - if (next.done) return { events, project: next.value }; - events.push(next.value); - } -} - describe("FsProjectManager.create", () => { test("scaffolds the expected file tree into a fresh directory", async () => { const directory = await inTempDirectory(); @@ -375,78 +359,3 @@ describe("FsProjectManager.resolve", () => { ); }); }); - -describe("FsProjectManager.addResource", () => { - async function projectWithGateway(subject: FsProjectManager): Promise { - const { project } = await runCreate(subject, { - name: "example", - template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON, - skipInstall: true, - skipGit: true, - }); - return ( - await runAdd(subject, project, { - resourceType: "gateway", - resourceConfig: { - name: "tools", - protocolType: "None", - authorizerType: "NONE", - targets: [], - enableSemanticSearch: false, - exceptionLevel: "NONE", - }, - }) - ).project; - } - - test("adds a Target using its project-schema shape without creating assets", async () => { - const directory = await inTempDirectory(); - const subject = manager().manager; - const project = await projectWithGateway(subject); - - const result = await runAdd(subject, project, { - resourceType: "gateway-target", - gatewayName: "tools", - resourceConfig: { - name: "search", - targetType: "lambdaFunctionArn", - lambdaFunctionArn: { - lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", - toolSchemaFile: "schemas/tool-schema.json", - }, - }, - }); - - expect(result.project.spec.agentCoreGateways[0]?.targets[0]).toEqual({ - name: "search", - targetType: "lambdaFunctionArn", - lambdaFunctionArn: { - lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", - toolSchemaFile: "schemas/tool-schema.json", - }, - }); - expect(await Bun.file(join(directory, "example", "agentcore", "assets")).exists()).toBe(false); - }); - - test("rejects an invalid project Target before writing the project", async () => { - const directory = await inTempDirectory(); - const subject = manager().manager; - const project = await projectWithGateway(subject); - - await expect( - runAdd(subject, project, { - resourceType: "gateway-target", - gatewayName: "tools", - resourceConfig: { - name: "search", - targetType: "openApiSchema", - } as never, - }), - ).rejects.toBeInstanceOf(InputValidationError); - - const persisted = await Bun.file( - join(directory, "example", "agentcore", "agentcore.json"), - ).json(); - expect(persisted.agentCoreGateways[0].targets).toEqual([]); - }); -}); diff --git a/src/handlers/project/add/gateway-add.test.ts b/src/handlers/project/add/gateway-add.test.ts index 94a4258af..9e4b89406 100644 --- a/src/handlers/project/add/gateway-add.test.ts +++ b/src/handlers/project/add/gateway-add.test.ts @@ -172,6 +172,99 @@ describe("project add gateway", () => { run(["add", "gateway", "--name", "tools", "--enable-semantic-search"]), ).rejects.toThrow("--protocol mcp"); }); + + test("reads tags from file, stdin, and repeated key=value flags", async () => { + const projectRoot = await inProject(); + const tagsPath = join(projectRoot, "tags.json"); + await writeFile(tagsPath, '{"source":"file"}'); + + await run(["add", "gateway", "--name", "from-file", "--tags", `file://${tagsPath}`]); + await run(["add", "gateway", "--name", "from-stdin", "--tags", "-"], '{"source":"stdin"}'); + await run(["add", "gateway", "--name", "from-pairs", "--tags", "source=pairs", "team=agents"]); + + const gateways = (await projectSpec(projectRoot)).agentCoreGateways; + expect(gateways[0].tags).toEqual({ source: "file" }); + expect(gateways[1].tags).toEqual({ source: "stdin" }); + expect(gateways[2].tags).toEqual({ source: "pairs", team: "agents" }); + }); + + 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.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", + ], + ])("rejects %s", async (_label, args, message) => { + await inProject(); + await expect(run(args)).rejects.toThrow(message); + }); }); describe("project add gateway-target", () => { @@ -187,6 +280,8 @@ describe("project add gateway-target", () => { "external", "--endpoint", "https://mcp.example.com", + "--outbound-auth", + "none", ]); await run([ "add", @@ -206,6 +301,7 @@ describe("project add gateway-target", () => { name: "external", targetType: "mcpServer", endpoint: "https://mcp.example.com", + outboundAuth: { type: "NONE" }, }, { name: "runtime", @@ -338,7 +434,10 @@ describe("project add gateway-target", () => { test("validates direct project credential references", async () => { const projectRoot = await inProject(); const spec = await projectSpec(projectRoot); - spec.credentials = [{ authorizerType: "OAuthCredentialProvider", name: "search-oauth" }]; + spec.credentials = [ + { authorizerType: "OAuthCredentialProvider", name: "search-oauth" }, + { authorizerType: "ApiKeyCredentialProvider", name: "search-api-key" }, + ]; await writeProjectSpec(projectRoot, spec); await addGateway(); const target = { @@ -360,8 +459,275 @@ describe("project add gateway-target", () => { "--target-configuration", JSON.stringify(target), ]); + await run([ + "add", + "gateway-target", + "--gateway", + "tools", + "--target-configuration", + JSON.stringify({ + name: "api-key", + targetType: "openApiSchema", + schemaSource: { inline: { path: "openapi.json" } }, + outboundAuth: { type: "API_KEY", credentialName: "search-api-key" }, + }), + ]); - expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0]).toEqual(target); + const targets = (await projectSpec(projectRoot)).agentCoreGateways[0].targets; + expect(targets[0]).toEqual(target); + expect(targets[1].outboundAuth).toEqual({ + type: "API_KEY", + credentialName: "search-api-key", + }); + }); + + test("adds an OAuth-authenticated endpoint shortcut", async () => { + const projectRoot = await inProject(); + const spec = await projectSpec(projectRoot); + spec.credentials = [{ authorizerType: "OAuthCredentialProvider", name: "oauth" }]; + await writeProjectSpec(projectRoot, spec); + await addGateway(); + + await run([ + "add", + "gateway-target", + "--gateway", + "tools", + "--name", + "oauth-target", + "--endpoint", + "https://oauth.example.com", + "--outbound-auth", + "oauth", + "--credential-name", + "oauth", + "--scope", + "read", + "write", + ]); + + const targets = (await projectSpec(projectRoot)).agentCoreGateways[0].targets; + expect(targets[0].outboundAuth).toEqual({ + type: "OAUTH", + credentialName: "oauth", + scopes: ["read", "write"], + }); + }); + + test.each([ + [ + "missing parent Gateway", + ["--name", "target", "--endpoint", "https://mcp.example.com"], + "required option '--gateway", + ], + ["no Target mode", ["--gateway", "tools", "--name", "target"], "specify exactly one"], + [ + "multiple Target modes", + [ + "--gateway", + "tools", + "--name", + "target", + "--endpoint", + "https://mcp.example.com", + "--runtime", + "hello_world", + ], + "specify exactly one", + ], + [ + "runtime endpoint without Runtime mode", + [ + "--gateway", + "tools", + "--name", + "target", + "--endpoint", + "https://mcp.example.com", + "--runtime-endpoint", + "DEFAULT", + ], + "--runtime-endpoint requires --runtime", + ], + [ + "shortcut without name", + ["--gateway", "tools", "--endpoint", "https://mcp.example.com"], + "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", + [ + "--gateway", + "tools", + "--name", + "target", + "--endpoint", + "https://mcp.example.com", + "--credential-name", + "oauth", + ], + "--credential-name requires --outbound-auth", + ], + [ + "scope without auth type", + [ + "--gateway", + "tools", + "--name", + "target", + "--endpoint", + "https://mcp.example.com", + "--scope", + "read", + ], + "--scope requires --outbound-auth oauth", + ], + [ + "none auth with credential", + [ + "--gateway", + "tools", + "--name", + "target", + "--endpoint", + "https://mcp.example.com", + "--outbound-auth", + "none", + "--credential-name", + "oauth", + ], + "cannot be combined", + ], + [ + "OAuth without credential", + [ + "--gateway", + "tools", + "--name", + "target", + "--endpoint", + "https://mcp.example.com", + "--outbound-auth", + "oauth", + ], + "requires --credential-name", + ], + [ + "API key with OAuth scope", + [ + "--gateway", + "tools", + "--name", + "target", + "--endpoint", + "https://mcp.example.com", + "--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", + [ + "--gateway", + "tools", + "--name", + "target", + "--endpoint", + "https://mcp.example.com", + "--outbound-auth", + "api-key", + "--credential-name", + "api-key", + ], + "mcpServer targets do not support API_KEY outbound auth", + ], + [ + "unknown credential", + [ + "--gateway", + "tools", + "--name", + "target", + "--endpoint", + "https://mcp.example.com", + "--outbound-auth", + "oauth", + "--credential-name", + "missing", + ], + "does not exist in credentials[]", + ], + [ + "credential with wrong type", + [ + "--gateway", + "tools", + "--name", + "target", + "--endpoint", + "https://mcp.example.com", + "--outbound-auth", + "oauth", + "--credential-name", + "api-key", + ], + "not a OAuthCredentialProvider", + ], + [ + "unknown Gateway", + ["--gateway", "missing", "--name", "target", "--endpoint", "https://mcp.example.com"], + "does not exist in agentCoreGateways[]", + ], + ])("rejects %s", async (_label, flags, message) => { + const projectRoot = await inProject(); + const spec = await projectSpec(projectRoot); + spec.credentials = [ + { authorizerType: "OAuthCredentialProvider", name: "oauth" }, + { authorizerType: "ApiKeyCredentialProvider", name: "api-key" }, + ]; + await writeProjectSpec(projectRoot, spec); + await addGateway(); + + await expect(run(["add", "gateway-target", ...flags])).rejects.toThrow(message); + }); + + test("rejects a direct API-key credential with the wrong project credential type", async () => { + const projectRoot = await inProject(); + const spec = await projectSpec(projectRoot); + spec.credentials = [{ authorizerType: "OAuthCredentialProvider", name: "oauth" }]; + await writeProjectSpec(projectRoot, spec); + await addGateway(); + + 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 across every Gateway in the project", async () => { @@ -510,4 +876,79 @@ describe("project add gateway-connector", () => { ]), ).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", + '{"name":"configured","targetType":"connector","connectorId":"web-search"}', + ], + "specify exactly one", + ], + [ + "name with complete connector JSON", + [ + "--gateway", + "tools", + "--name", + "web", + "--connector-configuration", + '{"name":"configured","targetType":"connector","connectorId":"web-search"}', + ], + "--name is part of --connector-configuration", + ], + [ + "knowledge base with complete connector JSON", + [ + "--gateway", + "tools", + "--connector-configuration", + '{"name":"configured","targetType":"connector","connectorId":"web-search"}', + "--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 index fbaec8eb3..63dae6710 100644 --- a/src/handlers/project/add/gateway-connector/index.ts +++ b/src/handlers/project/add/gateway-connector/index.ts @@ -4,11 +4,11 @@ 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"; -import { connectorTargetFromShortcut } from "../gateway-target/configuration"; export const createAddGatewayConnectorHandler = (config: AddProjectResourceConfig) => createHandler({ @@ -97,3 +97,31 @@ export const createAddGatewayConnectorHandler = (config: AddProjectResourceConfi ); }, }); + +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/configuration.ts b/src/handlers/project/add/gateway-target/configuration.ts deleted file mode 100644 index 42477c2d0..000000000 --- a/src/handlers/project/add/gateway-target/configuration.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { InputValidationError } from "../../../../errors"; -import type { AgentCoreGatewayTarget, ConnectorId } from "../../../../projectSchemas/gateway"; - -export 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 } }], - }; - } -} - -export 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-target/index.ts b/src/handlers/project/add/gateway-target/index.ts index 88211004b..63a938907 100644 --- a/src/handlers/project/add/gateway-target/index.ts +++ b/src/handlers/project/add/gateway-target/index.ts @@ -11,7 +11,6 @@ import { createHandler, flag, ProjectKey } from "../../../../router"; import { parseJsonFlagWithSchema } from "../../../utils"; import type { Project } from "../../types"; import type { AddProjectResourceConfig } from "../types"; -import { httpsEndpoint } from "./configuration"; export const createAddGatewayTargetHandler = (config: AddProjectResourceConfig) => createHandler({ @@ -191,3 +190,15 @@ function assertCredentialType(credential: Credential, auth: "oauth" | "api-key") ); } } + +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; +} From 7753af0ebc0c985908477b548084e64c274a7351 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 21 Aug 2026 22:45:33 +0000 Subject: [PATCH 6/7] test(project): update gateway credential fixtures --- src/handlers/project/add/gateway-add.test.ts | 28 +++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/handlers/project/add/gateway-add.test.ts b/src/handlers/project/add/gateway-add.test.ts index 9e4b89406..3999cc0ac 100644 --- a/src/handlers/project/add/gateway-add.test.ts +++ b/src/handlers/project/add/gateway-add.test.ts @@ -435,7 +435,11 @@ describe("project add gateway-target", () => { const projectRoot = await inProject(); const spec = await projectSpec(projectRoot); spec.credentials = [ - { authorizerType: "OAuthCredentialProvider", name: "search-oauth" }, + { + authorizerType: "OAuthCredentialProvider", + name: "search-oauth", + discoveryUrl: "https://idp.example.com/.well-known/openid-configuration", + }, { authorizerType: "ApiKeyCredentialProvider", name: "search-api-key" }, ]; await writeProjectSpec(projectRoot, spec); @@ -484,7 +488,13 @@ describe("project add gateway-target", () => { test("adds an OAuth-authenticated endpoint shortcut", async () => { const projectRoot = await inProject(); const spec = await projectSpec(projectRoot); - spec.credentials = [{ authorizerType: "OAuthCredentialProvider", name: "oauth" }]; + spec.credentials = [ + { + authorizerType: "OAuthCredentialProvider", + name: "oauth", + discoveryUrl: "https://idp.example.com/.well-known/openid-configuration", + }, + ]; await writeProjectSpec(projectRoot, spec); await addGateway(); @@ -697,7 +707,11 @@ describe("project add gateway-target", () => { const projectRoot = await inProject(); const spec = await projectSpec(projectRoot); spec.credentials = [ - { authorizerType: "OAuthCredentialProvider", name: "oauth" }, + { + authorizerType: "OAuthCredentialProvider", + name: "oauth", + discoveryUrl: "https://idp.example.com/.well-known/openid-configuration", + }, { authorizerType: "ApiKeyCredentialProvider", name: "api-key" }, ]; await writeProjectSpec(projectRoot, spec); @@ -709,7 +723,13 @@ describe("project add gateway-target", () => { test("rejects a direct API-key credential with the wrong project credential type", async () => { const projectRoot = await inProject(); const spec = await projectSpec(projectRoot); - spec.credentials = [{ authorizerType: "OAuthCredentialProvider", name: "oauth" }]; + spec.credentials = [ + { + authorizerType: "OAuthCredentialProvider", + name: "oauth", + discoveryUrl: "https://idp.example.com/.well-known/openid-configuration", + }, + ]; await writeProjectSpec(projectRoot, spec); await addGateway(); From 854514eb2566ecbb456d127b463adf6d5e95062b Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 21 Aug 2026 23:00:17 +0000 Subject: [PATCH 7/7] test(project): refactor gateway add coverage --- src/handlers/project/add/gateway-add.test.ts | 974 ------------------ .../add/gateway-connector/index.test.ts | 168 +++ .../project/add/gateway-target/index.test.ts | 417 ++++++++ .../project/add/gateway-test-support.ts | 61 ++ .../project/add/gateway/index.test.ts | 210 ++++ 5 files changed, 856 insertions(+), 974 deletions(-) delete mode 100644 src/handlers/project/add/gateway-add.test.ts create mode 100644 src/handlers/project/add/gateway-connector/index.test.ts create mode 100644 src/handlers/project/add/gateway-target/index.test.ts create mode 100644 src/handlers/project/add/gateway-test-support.ts create mode 100644 src/handlers/project/add/gateway/index.test.ts diff --git a/src/handlers/project/add/gateway-add.test.ts b/src/handlers/project/add/gateway-add.test.ts deleted file mode 100644 index 3999cc0ac..000000000 --- a/src/handlers/project/add/gateway-add.test.ts +++ /dev/null @@ -1,974 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm, writeFile } 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"; - -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-gateway-add-")); - 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]); -} - -afterEach(async () => { - process.chdir(originalCwd); - await Promise.all( - tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); - -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", 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("semantic search requires an MCP Gateway", async () => { - await inProject(); - await expect( - run(["add", "gateway", "--name", "tools", "--enable-semantic-search"]), - ).rejects.toThrow("--protocol mcp"); - }); - - test("reads tags from file, stdin, and repeated key=value flags", async () => { - const projectRoot = await inProject(); - const tagsPath = join(projectRoot, "tags.json"); - await writeFile(tagsPath, '{"source":"file"}'); - - await run(["add", "gateway", "--name", "from-file", "--tags", `file://${tagsPath}`]); - await run(["add", "gateway", "--name", "from-stdin", "--tags", "-"], '{"source":"stdin"}'); - await run(["add", "gateway", "--name", "from-pairs", "--tags", "source=pairs", "team=agents"]); - - const gateways = (await projectSpec(projectRoot)).agentCoreGateways; - expect(gateways[0].tags).toEqual({ source: "file" }); - expect(gateways[1].tags).toEqual({ source: "stdin" }); - expect(gateways[2].tags).toEqual({ source: "pairs", team: "agents" }); - }); - - 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.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", - ], - ])("rejects %s", async (_label, args, message) => { - await inProject(); - await expect(run(args)).rejects.toThrow(message); - }); -}); - -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", - "https://mcp.example.com", - "--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: "https://mcp.example.com", - outboundAuth: { type: "NONE" }, - }, - { - name: "runtime", - targetType: "httpRuntime", - httpRuntime: { runtime: "hello_world", runtimeEndpoint: "DEFAULT" }, - }, - ]); - }); - - test("persists a complete project Target object without translation or asset creation", async () => { - const projectRoot = await inProject(); - await addGateway(); - const target = { - name: "search", - targetType: "lambdaFunctionArn", - lambdaFunctionArn: { - lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:search", - toolSchemaFile: "schemas/tool-schema.json", - }, - }; - - await run([ - "add", - "gateway-target", - "--gateway", - "tools", - "--target-configuration", - JSON.stringify(target), - ]); - - expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0]).toEqual(target); - expect(await Bun.file(join(projectRoot, "agentcore", "assets")).exists()).toBe(false); - }); - - test("accepts a project-owned compute Target represented by the project schema", async () => { - const projectRoot = await inProject(); - await addGateway(); - const target = { - 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", - }, - }; - - await run([ - "add", - "gateway-target", - "--gateway", - "tools", - "--target-configuration", - JSON.stringify(target), - ]); - - expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0]).toEqual(target); - }); - - test.each(["file", "stdin"] as const)( - "reads a complete project Target from %s", - async (sourceKind) => { - const projectRoot = await inProject(); - await addGateway(); - const target = { - name: "source", - targetType: "mcpServer", - endpoint: "https://source.example.com", - }; - const configuration = JSON.stringify(target); - const path = join(projectRoot, "target.json"); - await writeFile(path, configuration); - const source = sourceKind === "file" ? `file://${path}` : "-"; - - await run( - ["add", "gateway-target", "--gateway", "tools", "--target-configuration", source], - sourceKind === "stdin" ? configuration : undefined, - ); - - expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0]).toEqual(target); - }, - ); - - test("rejects separate name and auth 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 inProject(); - const spec = await projectSpec(projectRoot); - spec.credentials = [ - { - authorizerType: "OAuthCredentialProvider", - name: "search-oauth", - discoveryUrl: "https://idp.example.com/.well-known/openid-configuration", - }, - { authorizerType: "ApiKeyCredentialProvider", name: "search-api-key" }, - ]; - await writeProjectSpec(projectRoot, spec); - await addGateway(); - const target = { - name: "oauth", - targetType: "mcpServer", - endpoint: "https://oauth.example.com", - outboundAuth: { - type: "OAUTH", - credentialName: "search-oauth", - scopes: ["read", "write"], - }, - }; - - await run([ - "add", - "gateway-target", - "--gateway", - "tools", - "--target-configuration", - JSON.stringify(target), - ]); - await run([ - "add", - "gateway-target", - "--gateway", - "tools", - "--target-configuration", - JSON.stringify({ - name: "api-key", - targetType: "openApiSchema", - schemaSource: { inline: { path: "openapi.json" } }, - outboundAuth: { type: "API_KEY", credentialName: "search-api-key" }, - }), - ]); - - const targets = (await projectSpec(projectRoot)).agentCoreGateways[0].targets; - expect(targets[0]).toEqual(target); - expect(targets[1].outboundAuth).toEqual({ - type: "API_KEY", - credentialName: "search-api-key", - }); - }); - - test("adds an OAuth-authenticated endpoint shortcut", async () => { - const projectRoot = await inProject(); - const spec = await projectSpec(projectRoot); - spec.credentials = [ - { - authorizerType: "OAuthCredentialProvider", - name: "oauth", - discoveryUrl: "https://idp.example.com/.well-known/openid-configuration", - }, - ]; - await writeProjectSpec(projectRoot, spec); - await addGateway(); - - await run([ - "add", - "gateway-target", - "--gateway", - "tools", - "--name", - "oauth-target", - "--endpoint", - "https://oauth.example.com", - "--outbound-auth", - "oauth", - "--credential-name", - "oauth", - "--scope", - "read", - "write", - ]); - - const targets = (await projectSpec(projectRoot)).agentCoreGateways[0].targets; - expect(targets[0].outboundAuth).toEqual({ - type: "OAUTH", - credentialName: "oauth", - scopes: ["read", "write"], - }); - }); - - test.each([ - [ - "missing parent Gateway", - ["--name", "target", "--endpoint", "https://mcp.example.com"], - "required option '--gateway", - ], - ["no Target mode", ["--gateway", "tools", "--name", "target"], "specify exactly one"], - [ - "multiple Target modes", - [ - "--gateway", - "tools", - "--name", - "target", - "--endpoint", - "https://mcp.example.com", - "--runtime", - "hello_world", - ], - "specify exactly one", - ], - [ - "runtime endpoint without Runtime mode", - [ - "--gateway", - "tools", - "--name", - "target", - "--endpoint", - "https://mcp.example.com", - "--runtime-endpoint", - "DEFAULT", - ], - "--runtime-endpoint requires --runtime", - ], - [ - "shortcut without name", - ["--gateway", "tools", "--endpoint", "https://mcp.example.com"], - "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", - [ - "--gateway", - "tools", - "--name", - "target", - "--endpoint", - "https://mcp.example.com", - "--credential-name", - "oauth", - ], - "--credential-name requires --outbound-auth", - ], - [ - "scope without auth type", - [ - "--gateway", - "tools", - "--name", - "target", - "--endpoint", - "https://mcp.example.com", - "--scope", - "read", - ], - "--scope requires --outbound-auth oauth", - ], - [ - "none auth with credential", - [ - "--gateway", - "tools", - "--name", - "target", - "--endpoint", - "https://mcp.example.com", - "--outbound-auth", - "none", - "--credential-name", - "oauth", - ], - "cannot be combined", - ], - [ - "OAuth without credential", - [ - "--gateway", - "tools", - "--name", - "target", - "--endpoint", - "https://mcp.example.com", - "--outbound-auth", - "oauth", - ], - "requires --credential-name", - ], - [ - "API key with OAuth scope", - [ - "--gateway", - "tools", - "--name", - "target", - "--endpoint", - "https://mcp.example.com", - "--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", - [ - "--gateway", - "tools", - "--name", - "target", - "--endpoint", - "https://mcp.example.com", - "--outbound-auth", - "api-key", - "--credential-name", - "api-key", - ], - "mcpServer targets do not support API_KEY outbound auth", - ], - [ - "unknown credential", - [ - "--gateway", - "tools", - "--name", - "target", - "--endpoint", - "https://mcp.example.com", - "--outbound-auth", - "oauth", - "--credential-name", - "missing", - ], - "does not exist in credentials[]", - ], - [ - "credential with wrong type", - [ - "--gateway", - "tools", - "--name", - "target", - "--endpoint", - "https://mcp.example.com", - "--outbound-auth", - "oauth", - "--credential-name", - "api-key", - ], - "not a OAuthCredentialProvider", - ], - [ - "unknown Gateway", - ["--gateway", "missing", "--name", "target", "--endpoint", "https://mcp.example.com"], - "does not exist in agentCoreGateways[]", - ], - ])("rejects %s", async (_label, flags, message) => { - const projectRoot = await inProject(); - const spec = await projectSpec(projectRoot); - spec.credentials = [ - { - authorizerType: "OAuthCredentialProvider", - name: "oauth", - discoveryUrl: "https://idp.example.com/.well-known/openid-configuration", - }, - { authorizerType: "ApiKeyCredentialProvider", name: "api-key" }, - ]; - await writeProjectSpec(projectRoot, spec); - await addGateway(); - - await expect(run(["add", "gateway-target", ...flags])).rejects.toThrow(message); - }); - - test("rejects a direct API-key credential with the wrong project credential type", async () => { - const projectRoot = await inProject(); - const spec = await projectSpec(projectRoot); - spec.credentials = [ - { - authorizerType: "OAuthCredentialProvider", - name: "oauth", - discoveryUrl: "https://idp.example.com/.well-known/openid-configuration", - }, - ]; - await writeProjectSpec(projectRoot, spec); - await addGateway(); - - 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 across every Gateway in the project", 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("tools"); - - await expect( - run([ - "add", - "gateway-target", - "--gateway", - "tools", - "--name", - "search", - "--endpoint", - "https://tools.example.com", - ]), - ).rejects.toThrow("unassigned gateway target"); - }); -}); - -describe("project add gateway-connector", () => { - test("adds curated 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.each(["inline", "file", "stdin"] as const)( - "reads a complete connector project Target from %s JSON", - async (sourceKind) => { - const projectRoot = await inProject(); - await addGateway(); - const target = { - name: "configured", - targetType: "connector", - connectorId: "web-search", - configurations: [{ name: "WebSearch", parameterValues: { maxResults: 3 } }], - }; - const configuration = JSON.stringify(target); - const path = join(projectRoot, "connector.json"); - await writeFile(path, configuration); - const source = - sourceKind === "inline" ? configuration : sourceKind === "file" ? `file://${path}` : "-"; - - await run( - ["add", "gateway-connector", "--gateway", "tools", "--connector-configuration", source], - sourceKind === "stdin" ? configuration : undefined, - ); - - 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", - '{"name":"configured","targetType":"connector","connectorId":"web-search"}', - ], - "specify exactly one", - ], - [ - "name with complete connector JSON", - [ - "--gateway", - "tools", - "--name", - "web", - "--connector-configuration", - '{"name":"configured","targetType":"connector","connectorId":"web-search"}', - ], - "--name is part of --connector-configuration", - ], - [ - "knowledge base with complete connector JSON", - [ - "--gateway", - "tools", - "--connector-configuration", - '{"name":"configured","targetType":"connector","connectorId":"web-search"}', - "--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.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-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-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); + }); +});