diff --git a/apps/docs/docs.json b/apps/docs/docs.json index 563d264029..2e4d08f55c 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -35,6 +35,10 @@ "group": "Hosted", "pages": ["hosted/cloud", "hosted/docker", "hosted/cloudflare"] }, + { + "group": "Integrations", + "pages": ["integrations/aws-mcp"] + }, { "group": "Concepts", "pages": ["concepts/integrations", "concepts/connections", "concepts/policies"] diff --git a/apps/docs/integrations/aws-mcp.mdx b/apps/docs/integrations/aws-mcp.mdx new file mode 100644 index 0000000000..ae38a69826 --- /dev/null +++ b/apps/docs/integrations/aws-mcp.mdx @@ -0,0 +1,114 @@ +--- +title: AWS MCP +description: "Connect Executor to the managed AWS MCP Server with an IAM role and short-lived OAuth tokens." +--- + +Executor connects directly to the managed AWS MCP Server over Streamable HTTP. An Executor +connection stores bootstrap AWS credentials and a target role ARN. At runtime Executor: + +1. assumes the target role with AWS STS; +2. verifies the resulting account and role identity; +3. exchanges the temporary role credentials for a short-lived AWS MCP bearer token; and +4. uses that bearer through Executor's normal MCP discovery and execution path. + +Temporary role credentials and bearer tokens are cached in memory until shortly before expiry. +They are never written to the connection store. + +## Create the bootstrap identity + +Create an IAM user dedicated to Executor and generate an access key. Its only permission should be +assuming the target role: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": "arn:aws:iam::123456789012:role/ExecutorAwsMcp" + } + ] +} +``` + +For two AWS accounts, add one target role ARN per account to `Resource`, or use a separate +bootstrap identity for each account. + +## Create the target role + +Create `ExecutorAwsMcp` in the account Executor should access. Its trust policy must name the +bootstrap identity. Add an external ID condition if you use one in the Executor account form. +Remove the `Condition` block from this example when you do not use an external ID. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::111122223333:user/executor-aws-mcp" + }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "sts:ExternalId": "replace-with-a-random-value" + } + } + } + ] +} +``` + +The target role needs the AWS permissions its MCP tools may use, plus permission to mint the AWS +MCP bearer: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "signin:CreateOAuth2Token", + "Resource": "arn:aws:signin:us-east-1:123456789012:service-principal/aws-mcp.amazonaws.com", + "Condition": { + "StringEquals": { + "signin:OAuthClientId": "arn:aws:signin:::client-credentials/sigv4", + "signin:OAuthGrantType": "client_credentials", + "aws:RequestedRegion": "us-east-1" + } + } + } + ] +} +``` + +Attach your AWS service permissions separately. The OAuth token does not grant permissions the +role does not already have. A broad managed policy can be useful for an initial private proof, but +the target role is the security boundary for generic AWS MCP tools such as `call_aws` and +`run_script`. + +## Add AWS MCP in Executor + +1. Select **Add Integration**, choose **AWS MCP**, and add the managed server. +2. On the AWS MCP integration page, select **Add Account**. +3. Choose the normal Executor owner and account name. +4. Enter the bootstrap access key ID and secret, target role ARN, and optional session token and + external ID. Executor derives the authentication region from the managed endpoint. +5. Run the account check. A healthy result includes the AWS account ID and assumed-role ARN. + +Repeat **Add Account** for another AWS role or account. Each connection gets its own native tool +address, search entries, health status, credential provider, and policies. + +Executor includes managed presets for the AWS MCP endpoints in `us-east-1` and `eu-central-1`. +Choose the endpoint whose region should handle the MCP connection; its region must also match the +`signin:CreateOAuth2Token` resource and `aws:RequestedRegion` condition in the target-role policy. + + + AWS IAM authentication is accepted only for the managed AWS MCP endpoints. Executor will not + forward an IAM-derived bearer to a custom MCP URL. + + +For the AWS-side behavior and available regions, see the +[AWS MCP authentication guide](https://docs.aws.amazon.com/signin/latest/userguide/aws-mcp-server.html). diff --git a/bun.lock b/bun.lock index 290cb98d62..36d5978650 100644 --- a/bun.lock +++ b/bun.lock @@ -1080,6 +1080,8 @@ "@executor-js/config": "workspace:*", "@executor-js/sdk": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", + "aws4fetch": "1.0.20", + "fast-xml-parser": "5.10.1", "zod": "4.3.6", }, "devDependencies": { @@ -2357,6 +2359,8 @@ "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], + "@nodable/entities": ["@nodable/entities@3.0.0", "", {}, "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -3535,6 +3539,8 @@ "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + "anynum": ["anynum@1.0.1", "", {}, "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A=="], + "app-builder-bin": ["app-builder-bin@5.0.0-alpha.12", "", {}, "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w=="], "app-builder-lib": ["app-builder-lib@26.8.1", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1" } }, "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw=="], @@ -3597,6 +3603,8 @@ "aws4": ["aws4@1.13.2", "", {}, "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="], + "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], + "axios": ["axios@1.15.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], @@ -4217,6 +4225,10 @@ "fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="], + "fast-xml-builder": ["fast-xml-builder@1.3.0", "", { "dependencies": { "path-expression-matcher": "^1.6.2", "xml-naming": "^0.3.0" } }, "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ=="], + + "fast-xml-parser": ["fast-xml-parser@5.10.1", "", { "dependencies": { "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", "is-unsafe": "^2.0.0", "path-expression-matcher": "^1.6.2", "strnum": "^2.4.1", "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw=="], + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fd-package-json": ["fd-package-json@2.0.0", "", { "dependencies": { "walk-up-path": "^4.0.0" } }, "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ=="], @@ -4569,6 +4581,8 @@ "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + "is-unsafe": ["is-unsafe@2.0.0", "", {}, "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA=="], + "is-wayland": ["is-wayland@0.1.0", "", {}, "sha512-QkbMsWkIfkrzOPxenwye0h56iAXirZYHG9eHVPb22fO9y+wPbaX/CHacOWBa/I++4ohTcByimhM1/nyCsH8KNA=="], "is-windows": ["is-windows@1.0.2", "", {}, "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA=="], @@ -5135,6 +5149,8 @@ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "path-expression-matcher": ["path-expression-matcher@1.6.2", "", {}, "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ=="], + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -5697,6 +5713,8 @@ "stripe": ["stripe@22.3.0", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-ypO6xjVrMWs9SmIMeHr8naCx3dAQ0clxMdUTxn7Ejd7hmY9meBGfE+N4pVHkf9sUNebAHp6uJo6mV3GxDIc2cA=="], + "strnum": ["strnum@2.4.1", "", { "dependencies": { "anynum": "^1.0.1" } }, "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg=="], + "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], "stubborn-fs": ["stubborn-fs@2.0.0", "", { "dependencies": { "stubborn-utils": "^1.0.1" } }, "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA=="], @@ -6023,6 +6041,8 @@ "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="], + "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index e779c56ded..9919dfff61 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -82,6 +82,7 @@ export { // Integration / connection / tool domain contracts. export type { AuthMethodDescriptor, + AuthMethodCredentialInputDescriptor, AuthMethodOAuthDescriptor, AuthPlacementDescriptor, Integration, diff --git a/packages/core/sdk/src/integration.ts b/packages/core/sdk/src/integration.ts index df1ea50876..846ddc4817 100644 --- a/packages/core/sdk/src/integration.ts +++ b/packages/core/sdk/src/integration.ts @@ -66,6 +66,22 @@ export interface AuthMethodOAuthDescriptor { readonly supportsClientIdMetadataDocument?: boolean; } +/** One value collected when an authentication strategy needs credential + * material that is not itself an HTTP placement. Provider-backed strategies + * such as AWS IAM use these inputs to derive the request credential at runtime + * (for example, exchanging an assumed role for a short-lived bearer token). */ +export interface AuthMethodCredentialInputDescriptor { + /** Stable key used in the connection's encrypted values map. */ + readonly variable: string; + readonly label: string; + readonly description?: string; + readonly placeholder?: string; + /** Secret inputs are masked in the account form. Defaults to true. */ + readonly secret?: boolean; + /** Optional inputs may be omitted when creating or validating a connection. */ + readonly optional?: boolean; +} + /** A single declared auth method on an integration's catalog response. */ export interface AuthMethodDescriptor { /** Stable id within the integration (e.g. the auth template slug). */ @@ -75,6 +91,10 @@ export interface AuthMethodDescriptor { /** The auth-template slug a connection binds against. */ readonly template: string; readonly placements?: readonly AuthPlacementDescriptor[]; + /** Named credential values consumed by the owning plugin rather than + * rendered directly onto an HTTP request. Mutually exclusive with + * placement-derived inputs for built-in methods. */ + readonly credentialInputs?: readonly AuthMethodCredentialInputDescriptor[]; readonly oauth?: AuthMethodOAuthDescriptor; } diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index 760bcc6dce..78bbdf4e1d 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -32,6 +32,7 @@ export { connectionIdentifier, isConnectionIdentifier } from "./connection-name- // Domain projections (types only — no runtime cost). export type { AuthMethodDescriptor, + AuthMethodCredentialInputDescriptor, AuthMethodOAuthDescriptor, AuthPlacementDescriptor, Integration, diff --git a/packages/plugins/mcp/package.json b/packages/plugins/mcp/package.json index b76c724733..4ba6c3fd92 100644 --- a/packages/plugins/mcp/package.json +++ b/packages/plugins/mcp/package.json @@ -66,6 +66,8 @@ "@executor-js/config": "workspace:*", "@executor-js/sdk": "workspace:*", "@modelcontextprotocol/sdk": "^1.29.0", + "aws4fetch": "1.0.20", + "fast-xml-parser": "5.10.1", "zod": "4.3.6" }, "devDependencies": { diff --git a/packages/plugins/mcp/src/react/AddMcpIntegration.tsx b/packages/plugins/mcp/src/react/AddMcpIntegration.tsx index 6e1d0de26d..bd17559539 100644 --- a/packages/plugins/mcp/src/react/AddMcpIntegration.tsx +++ b/packages/plugins/mcp/src/react/AddMcpIntegration.tsx @@ -238,10 +238,18 @@ export default function AddMcpIntegration(props: { return [{ value: { kind: "none" }, label: "Detected" }]; }, [probe]); const authMethodList = useAuthMethodList(authMethodSeeds); + const presetAuthenticationTemplate = + preset && preset.transport === undefined && state.url.trim() === preset.endpoint + ? preset.authenticationTemplate + : undefined; const remoteIdentity = useIntegrationIdentity({ fallbackName: - integrationDisplayNameFromUrl(state.url, "MCP") ?? probe?.serverName ?? probe?.name ?? "", + (!isStdioPreset && preset?.transport === undefined ? preset.name : undefined) ?? + integrationDisplayNameFromUrl(state.url, "MCP") ?? + probe?.serverName ?? + probe?.name ?? + "", }); // Agent-visible description: prefilled from the server's `instructions` // until the user types (null = untouched, keep deriving from the probe). @@ -337,15 +345,17 @@ export default function AddMcpIntegration(props: { dispatch({ type: "add-start" }); // Every row registers as a declared method (a lone no-auth row registers // the open-server method). Slugs are assigned server-side by kind. - const methods = authMethodList.rows.map((row: AuthMethodRow) => - mcpWireAuthInput(mcpAuthMethodInputFromEditorValue(row.value)), - ); + const methods = presetAuthenticationTemplate + ? [...presetAuthenticationTemplate] + : authMethodList.rows.map((row: AuthMethodRow) => + mcpWireAuthInput(mcpAuthMethodInputFromEditorValue(row.value)), + ); const slug = await registerIntegration( methods.length > 0 ? methods : [{ kind: "none" as const }], ); if (slug === null) return; props.onComplete(slug); - }, [probe, authMethodList.rows, registerIntegration, props]); + }, [probe, presetAuthenticationTemplate, authMethodList.rows, registerIntegration, props]); // ---- Stdio actions ---- @@ -434,7 +444,18 @@ export default function AddMcpIntegration(props: { shared list editor. The credentials themselves (API key value / OAuth sign-in) are added from the integration's detail hub after adding. */} - {probe && ( + {probe && presetAuthenticationTemplate ? ( + + + +

AWS IAM role

+
+
+
+ ) : probe ? ( - )} + ) : null} {/* Error (add server). Probe errors show inline on the field. */} {otherError && ( diff --git a/packages/plugins/mcp/src/react/EditMcpIntegration.tsx b/packages/plugins/mcp/src/react/EditMcpIntegration.tsx index e1cb718a82..6ba1101060 100644 --- a/packages/plugins/mcp/src/react/EditMcpIntegration.tsx +++ b/packages/plugins/mcp/src/react/EditMcpIntegration.tsx @@ -42,6 +42,7 @@ type McpRemoteConfig = Extract; const methodSeedLabel = (method: McpAuthMethod): string => { if (method.kind === "oauth2") return "OAuth"; if (method.kind === "apikey") return apiKeyMethodLabel(method); + if (method.kind === "aws_iam") return "AWS IAM role"; return "No authentication"; }; @@ -202,6 +203,25 @@ function StdioReadOnly(props: { ); } +function AwsIamReadOnly() { + return ( +
+
+

Authentication method

+

+ AWS IAM credentials and the target role are configured independently on each account. +

+
+
+

AWS IAM role

+ + managed + +
+
+ ); +} + // --------------------------------------------------------------------------- // Main component — the mcp plugin's section of the integration Edit sheet. // `integrationId` is the integration slug (v2). @@ -227,6 +247,10 @@ export default function EditMcpIntegration({ ); } + if (server.config.authenticationTemplate.some((method) => method.kind === "aws_iam")) { + return ; + } + return ( wireAuthInputFromShared(method) as McpAuthMethodInput; +): McpAuthMethodInput => + method.kind === "aws_iam" ? method : (wireAuthInputFromShared(method) as McpAuthMethodInput); const oauthAuthMethod = (slug: string, endpoint: string): AuthMethod => ({ id: slug, @@ -58,6 +60,16 @@ const oauthAuthMethod = (slug: string, endpoint: string): AuthMethod => ({ oauth: { discoveryUrl: endpoint, supportsDynamicRegistration: true }, }); +const awsIamAuthMethod = (slug: string): AuthMethod => ({ + id: slug, + label: "AWS IAM role", + kind: "apikey", + source: "spec", + template: AuthTemplateSlug.make(slug), + placements: [], + credentialInputs: awsIamCredentialInputs, +}); + /** Convert a generic editor value into one MCP auth-method input (no slug — * the backend assigns carrier-derived slugs). An apikey value keeps every * named placement (headers and query params mix freely); one with no usable @@ -77,6 +89,7 @@ export function editorValueFromMcpAuthMethod(method: McpAuthMethod): AuthTemplat return { kind: "oauth", authorizationUrl: "", tokenUrl: "", scopes: [] }; } if (method.kind === "stdio_env") return stdioEnvEditorValue(method); + if (method.kind === "aws_iam") return { kind: "none" }; return editorValueFromSharedMethod(method); } @@ -91,6 +104,7 @@ export function authMethodsFromConfig( return methods.map((method: McpAuthMethod): AuthMethod => { if (method.kind === "oauth2") return oauthAuthMethod(method.slug, endpoint); if (method.kind === "stdio_env") return stdioEnvAuthMethod(method); + if (method.kind === "aws_iam") return awsIamAuthMethod(method.slug); return authMethodFromSharedTemplate(method); }); } diff --git a/packages/plugins/mcp/src/sdk/aws-iam.test.ts b/packages/plugins/mcp/src/sdk/aws-iam.test.ts new file mode 100644 index 0000000000..12d2fec20c --- /dev/null +++ b/packages/plugins/mcp/src/sdk/aws-iam.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Predicate } from "effect"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; + +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + ToolAddress, + createExecutor, +} from "@executor-js/sdk"; +import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; + +import { createAwsIamTokenManager, isManagedAwsMcpEndpoint, parseAwsIamInput } from "./aws-iam"; +import { mcpPlugin } from "./plugin"; +import { mcpPresets } from "./presets"; +import { makeEchoMcpServer, serveMcpServer } from "../testing"; + +const values = { + access_key_id: "AKIABOOTSTRAP", + secret_access_key: "bootstrap-secret", + role_arn: "arn:aws:iam::123456789012:role/ExecutorAwsMcp", + external_id: "executor-test", +}; + +const endpoint = "https://aws-mcp.us-east-1.api.aws/mcp"; + +const requestBody = (request: HttpClientRequest.HttpClientRequest): string => { + if (!Predicate.isTagged(request.body, "Uint8Array")) return ""; + return new TextDecoder().decode(request.body.body); +}; + +const assumeRoleXml = ` + + + + ASIATEMPORARY + temporary-secret + temporary-session + 2099-01-01T00:00:00Z + + +`; + +const identityXml = ` + + + 123456789012 + arn:aws:sts::123456789012:assumed-role/ExecutorAwsMcp/executor-aws-mcp + +`; + +const makeAwsClient = ( + seen: HttpClientRequest.HttpClientRequest[], + options?: { readonly expiresIn?: number; readonly nextToken?: () => string }, +) => + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { + seen.push(request); + const body = requestBody(request); + if (body.includes("Action=AssumeRole")) { + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(assumeRoleXml, { status: 200 })), + ); + } + if (body.includes("Action=GetCallerIdentity")) { + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(identityXml, { status: 200 })), + ); + } + if (request.url.includes("oauth.signin.aws")) { + const accessToken = options?.nextToken?.() ?? "aws-mcp-bearer"; + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + `{"access_token":"${accessToken}","expires_in":${options?.expiresIn ?? 3600},"token_type":"Bearer"}`, + { status: 200, headers: { "content-type": "application/json" } }, + ), + ), + ); + } + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("not found", { status: 404 })), + ); + }); + +const makeAwsLayer = (seen: HttpClientRequest.HttpClientRequest[]) => + Layer.succeed(HttpClient.HttpClient)(makeAwsClient(seen)); + +const makeAwsForwardingLayer = ( + seen: HttpClientRequest.HttpClientRequest[], + mcpEndpoint: string, + options?: { readonly expiresIn?: number; readonly nextToken?: () => string }, +) => + Layer.effect( + HttpClient.HttpClient, + Effect.gen(function* () { + const network = yield* HttpClient.HttpClient; + const aws = makeAwsClient(seen, options); + return HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { + if (request.url.startsWith("https://aws-mcp.us-east-1.api.aws/mcp")) { + return network.execute(HttpClientRequest.setUrl(request, mcpEndpoint)); + } + return request.url.includes("amazonaws.com") || request.url.includes("oauth.signin.aws") + ? aws.execute(request) + : network.execute(request); + }); + }), + ).pipe(Layer.provide(FetchHttpClient.layer)); + +describe("AWS IAM MCP token manager", () => { + it("ships AWS MCP as an IAM-authenticated managed preset", () => { + expect(mcpPresets.find((preset) => preset.id === "aws-mcp")).toMatchObject({ + endpoint: "https://aws-mcp.us-east-1.api.aws/mcp", + authenticationTemplate: [{ kind: "aws_iam" }], + }); + }); + + it("only permits exact managed AWS MCP endpoints", () => { + expect(isManagedAwsMcpEndpoint("https://aws-mcp.us-east-1.api.aws/mcp")).toBe(true); + expect(isManagedAwsMcpEndpoint("https://aws-mcp.eu-central-1.api.aws/mcp")).toBe(true); + expect(isManagedAwsMcpEndpoint("https://attacker.example/mcp")).toBe(false); + expect(isManagedAwsMcpEndpoint("https://aws-mcp.us-east-1.api.aws:444/mcp")).toBe(false); + expect(isManagedAwsMcpEndpoint("https://aws-mcp.us-east-1.api.aws/mcp?redirect=attacker")).toBe( + false, + ); + }); + + it.effect("assumes the configured role, verifies identity, and mints a cached bearer", () => + Effect.gen(function* () { + const seen: HttpClientRequest.HttpClientRequest[] = []; + const layer = makeAwsLayer(seen); + const manager = createAwsIamTokenManager(); + + const first = yield* manager + .resolve("org:aws:production", endpoint, values) + .pipe(Effect.provide(layer)); + const second = yield* manager + .resolve("org:aws:production", endpoint, values) + .pipe(Effect.provide(layer)); + + expect(first).toMatchObject({ + accessToken: "aws-mcp-bearer", + accountId: "123456789012", + arn: "arn:aws:sts::123456789012:assumed-role/ExecutorAwsMcp/executor-aws-mcp", + }); + expect(second).toEqual(first); + expect(seen).toHaveLength(3); + + const [assume, identity, token] = seen; + expect(requestBody(assume!).includes("RoleArn=arn%3Aaws%3Aiam%3A%3A123456789012")).toBe(true); + expect(assume!.headers.authorization).toContain("Credential=AKIABOOTSTRAP/"); + expect(identity!.headers.authorization).toContain("Credential=ASIATEMPORARY/"); + expect(token!.headers.authorization).toContain("Credential=ASIATEMPORARY/"); + expect(token!.headers["x-amz-security-token"]).toBe("temporary-session"); + expect(requestBody(token!)).toContain('"resource":"aws-mcp.amazonaws.com"'); + }), + ); + + it.effect("deduplicates concurrent token mints for one Executor connection", () => + Effect.gen(function* () { + const seen: HttpClientRequest.HttpClientRequest[] = []; + const layer = makeAwsLayer(seen); + const manager = createAwsIamTokenManager(); + const [left, right] = yield* Effect.all( + [ + manager.resolve("org:aws:shared", endpoint, values), + manager.resolve("org:aws:shared", endpoint, values), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.provide(layer)); + expect(left.accessToken).toBe("aws-mcp-bearer"); + expect(right).toEqual(left); + expect(seen).toHaveLength(3); + }), + ); + + it.effect("rejects a role ARN that cannot establish the expected account", () => + Effect.gen(function* () { + const error = yield* parseAwsIamInput({ + ...values, + role_arn: "not-an-arn", + }).pipe(Effect.flip); + expect(error.stage).toBe("credentials"); + expect(error).toMatchObject({ message: expect.stringContaining("Role ARN") }); + }), + ); + + it.effect("uses the AWS bearer through native discovery, execution, and health", () => + Effect.gen(function* () { + const seen: HttpClientRequest.HttpClientRequest[] = []; + let tokenGeneration = 0; + const server = yield* serveMcpServer( + () => + makeEchoMcpServer({ + name: "aws-mcp-test", + toolName: "aws_docs", + toolDescription: "Search AWS documentation", + inputName: "value", + text: (value) => `AWS:${value}`, + }), + { + path: "/mcp", + auth: { + validateAuthorization: (authorization) => + Effect.succeed(authorization?.startsWith("Bearer aws-mcp-bearer-") === true), + }, + }, + ); + const executor = yield* createExecutor({ + ...makeTestConfig({ + plugins: [memoryCredentialsPlugin(), mcpPlugin()] as const, + }), + httpClientLayer: makeAwsForwardingLayer(seen, server.endpoint, { + // Shorter than the refresh skew: each operation rotates the bearer, + // proving the connection pool cannot retain an older AWS session. + expiresIn: 1, + nextToken: () => `aws-mcp-bearer-${++tokenGeneration}`, + }), + }); + const integration = IntegrationSlug.make("aws_mcp_test"); + const connection = ConnectionName.make("chosenaccount"); + yield* executor.mcp.addServer({ + name: "AWS MCP Test", + slug: String(integration), + endpoint: "https://aws-mcp.us-east-1.api.aws/mcp", + authenticationTemplate: [{ kind: "aws_iam" }], + }); + yield* executor.connections.create({ + owner: "org", + name: connection, + integration, + template: AuthTemplateSlug.make("aws_iam"), + values, + }); + + expect(seen).toHaveLength(3); + expect((yield* server.requests).length).toBeGreaterThan(0); + const discovered = yield* executor.tools.list(); + expect(discovered.map((tool) => String(tool.address))).toContain( + "tools.aws_mcp_test.org.chosenaccount.aws_docs", + ); + + const result = yield* executor.execute( + ToolAddress.make("tools.aws_mcp_test.org.chosenaccount.aws_docs"), + { value: "S3" }, + ); + expect(result).toMatchObject({ + ok: true, + data: { content: [{ type: "text", text: "AWS:S3" }] }, + }); + const health = yield* executor.connections.checkHealth({ + owner: "org", + integration, + name: connection, + }); + expect(health).toMatchObject({ + status: "healthy", + identity: + "123456789012 · arn:aws:sts::123456789012:assumed-role/ExecutorAwsMcp/executor-aws-mcp", + }); + const requests = yield* server.requests; + const authorizations = new Set(requests.map((request) => request.authorization)); + expect(authorizations.has("Bearer aws-mcp-bearer-1")).toBe(true); + expect(authorizations.has("Bearer aws-mcp-bearer-2")).toBe(true); + expect(authorizations.has("Bearer aws-mcp-bearer-4")).toBe(true); + expect(seen).toHaveLength(12); + }), + ); +}); diff --git a/packages/plugins/mcp/src/sdk/aws-iam.ts b/packages/plugins/mcp/src/sdk/aws-iam.ts new file mode 100644 index 0000000000..e30c0fa752 --- /dev/null +++ b/packages/plugins/mcp/src/sdk/aws-iam.ts @@ -0,0 +1,448 @@ +import { AwsV4Signer } from "aws4fetch"; +import { XMLParser } from "fast-xml-parser"; +import { Effect, Option, Schema } from "effect"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; + +export const AWS_IAM_AUTH_KIND = "aws_iam" as const; + +const ACCESS_KEY_ID = "access_key_id"; +const SECRET_ACCESS_KEY = "secret_access_key"; +const SESSION_TOKEN = "session_token"; +const ROLE_ARN = "role_arn"; +const EXTERNAL_ID = "external_id"; +const REFRESH_SKEW_MS = 60_000; + +export class AwsIamAuthError extends Schema.TaggedErrorClass()("AwsIamAuthError", { + stage: Schema.Literals(["credentials", "assume_role", "identity", "token"]), + message: Schema.String, + httpStatus: Schema.optional(Schema.Number), + awsCode: Schema.optional(Schema.String), +}) {} + +type AwsCredentials = { + readonly accessKeyId: string; + readonly secretAccessKey: string; + readonly sessionToken?: string; +}; + +type AwsIamInput = { + readonly bootstrap: AwsCredentials; + readonly roleArn: string; + readonly externalId?: string; + readonly region: string; + readonly expectedAccountId: string; +}; + +export type AwsIamResolvedToken = { + readonly accessToken: string; + readonly expiresAt: number; + readonly accountId: string; + readonly arn: string; + /** Non-secret process-local generation used to rotate pooled MCP sessions. */ + readonly version: number; +}; + +const AwsRoleArn = /^arn:aws:iam::(\d{12}):role\/(.+)$/; +const AWS_MCP_REGIONS = new Map([ + ["aws-mcp.us-east-1.api.aws", "us-east-1"], + ["aws-mcp.eu-central-1.api.aws", "eu-central-1"], +]); + +/** The IAM-derived bearer is scoped to AWS MCP and must never be forwarded to + * a user-controlled server. Keep this allowlist at the credential boundary. */ +export const isManagedAwsMcpEndpoint = (endpoint: string): boolean => { + if (!URL.canParse(endpoint)) return false; + const url = new URL(endpoint); + return ( + url.protocol === "https:" && + AWS_MCP_REGIONS.has(url.hostname) && + url.port === "" && + url.pathname === "/mcp" && + url.username === "" && + url.password === "" && + url.search === "" && + url.hash === "" + ); +}; + +const managedAwsMcpRegion = (endpoint: string): string | undefined => { + if (!isManagedAwsMcpEndpoint(endpoint)) return undefined; + return AWS_MCP_REGIONS.get(new URL(endpoint).hostname); +}; + +const requiredValue = ( + values: Record, + variable: string, + label: string, +): Effect.Effect => { + const value = values[variable]?.trim(); + return value + ? Effect.succeed(value) + : Effect.fail( + new AwsIamAuthError({ + stage: "credentials", + message: `${label} is required for AWS IAM authentication`, + }), + ); +}; + +export const parseAwsIamInput = Effect.fn("McpAwsIam.parseInput")(function* ( + values: Record, + region = "us-east-1", +) { + const accessKeyId = yield* requiredValue(values, ACCESS_KEY_ID, "Access key ID"); + const secretAccessKey = yield* requiredValue(values, SECRET_ACCESS_KEY, "Secret access key"); + const roleArn = yield* requiredValue(values, ROLE_ARN, "Role ARN"); + const roleMatch = AwsRoleArn.exec(roleArn); + if (!roleMatch) { + return yield* new AwsIamAuthError({ + stage: "credentials", + message: "Role ARN must identify an IAM role in an AWS account", + }); + } + const sessionToken = values[SESSION_TOKEN]?.trim() || undefined; + const externalId = values[EXTERNAL_ID]?.trim() || undefined; + return { + bootstrap: { + accessKeyId, + secretAccessKey, + ...(sessionToken ? { sessionToken } : {}), + }, + roleArn, + ...(externalId ? { externalId } : {}), + region, + expectedAccountId: roleMatch[1]!, + } satisfies AwsIamInput; +}); + +const xmlParser = new XMLParser({ + ignoreAttributes: false, + parseTagValue: false, + trimValues: true, +}); + +const StsCredentialsResponse = Schema.Struct({ + AssumeRoleResponse: Schema.Struct({ + AssumeRoleResult: Schema.Struct({ + Credentials: Schema.Struct({ + AccessKeyId: Schema.String, + SecretAccessKey: Schema.String, + SessionToken: Schema.String, + Expiration: Schema.String, + }), + }), + }), +}); + +const StsIdentityResponse = Schema.Struct({ + GetCallerIdentityResponse: Schema.Struct({ + GetCallerIdentityResult: Schema.Struct({ + Account: Schema.String, + Arn: Schema.String, + }), + }), +}); + +const AwsErrorResponse = Schema.Struct({ + ErrorResponse: Schema.Struct({ + Error: Schema.Struct({ + Code: Schema.optional(Schema.String), + }), + }), +}); + +const TokenResponse = Schema.Struct({ + access_token: Schema.String, + expires_in: Schema.Number, + token_type: Schema.String, +}); + +const decodeStsCredentials = Schema.decodeUnknownEffect(StsCredentialsResponse); +const decodeStsIdentity = Schema.decodeUnknownEffect(StsIdentityResponse); +const decodeAwsError = Schema.decodeUnknownOption(AwsErrorResponse); +const decodeTokenResponse = Schema.decodeUnknownEffect(Schema.fromJsonString(TokenResponse)); +const encodeJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); + +const parseXml = ( + text: string, + stage: AwsIamAuthError["stage"], +): Effect.Effect => + Effect.try({ + try: () => xmlParser.parse(text) as unknown, + catch: () => + new AwsIamAuthError({ + stage, + message: "AWS STS returned an unreadable response", + }), + }); + +const signedRequest = Effect.fn("McpAwsIam.signRequest")(function* (input: { + readonly url: string; + readonly service: string; + readonly region: string; + readonly credentials: AwsCredentials; + readonly body: string; + readonly contentType: string; +}) { + const signed = yield* Effect.tryPromise({ + try: () => + new AwsV4Signer({ + method: "POST", + url: input.url, + headers: { "content-type": input.contentType }, + body: input.body, + service: input.service, + region: input.region, + ...input.credentials, + }).sign(), + catch: () => + new AwsIamAuthError({ + stage: "credentials", + message: "Could not sign the AWS authentication request", + }), + }); + const headers: Record = {}; + signed.headers.forEach((value, name) => { + headers[name] = value; + }); + return HttpClientRequest.post(signed.url.toString()).pipe( + HttpClientRequest.setHeaders(headers), + HttpClientRequest.bodyText(input.body, input.contentType), + ); +}); + +const executeSigned = Effect.fn("McpAwsIam.executeSigned")(function* (input: { + readonly stage: "assume_role" | "identity" | "token"; + readonly url: string; + readonly service: string; + readonly region: string; + readonly credentials: AwsCredentials; + readonly body: string; + readonly contentType: string; +}) { + const client = yield* HttpClient.HttpClient; + const request = yield* signedRequest(input); + const response = yield* client.execute(request).pipe( + Effect.mapError( + () => + new AwsIamAuthError({ + stage: input.stage, + message: `Could not reach AWS during ${input.stage.replace("_", " ")}`, + }), + ), + ); + const text = yield* response.text.pipe( + Effect.mapError( + () => + new AwsIamAuthError({ + stage: input.stage, + message: `Could not read the AWS ${input.stage.replace("_", " ")} response`, + httpStatus: response.status, + }), + ), + ); + if (response.status >= 200 && response.status < 300) return text; + + const parsed = yield* parseXml(text, input.stage).pipe(Effect.option); + const decoded = Option.flatMap(parsed, decodeAwsError); + const awsCode = Option.getOrUndefined( + Option.map(decoded, (value) => value.ErrorResponse.Error.Code), + ); + return yield* new AwsIamAuthError({ + stage: input.stage, + message: `AWS rejected ${input.stage.replace("_", " ")}`, + httpStatus: response.status, + ...(awsCode ? { awsCode } : {}), + }); +}); + +const stsRequest = ( + action: "AssumeRole" | "GetCallerIdentity", + input: AwsIamInput, + credentials: AwsCredentials, + fields: Record = {}, +) => + executeSigned({ + stage: action === "AssumeRole" ? "assume_role" : "identity", + url: `https://sts.${input.region}.amazonaws.com/`, + service: "sts", + region: input.region, + credentials, + body: new URLSearchParams({ Action: action, Version: "2011-06-15", ...fields }).toString(), + contentType: "application/x-www-form-urlencoded; charset=utf-8", + }); + +const assumeRole = Effect.fn("McpAwsIam.assumeRole")(function* (input: AwsIamInput) { + const body = yield* stsRequest("AssumeRole", input, input.bootstrap, { + RoleArn: input.roleArn, + RoleSessionName: "executor-aws-mcp", + DurationSeconds: "3600", + ...(input.externalId ? { ExternalId: input.externalId } : {}), + }); + const parsed = yield* parseXml(body, "assume_role"); + const decoded = yield* decodeStsCredentials(parsed).pipe( + Effect.mapError( + () => + new AwsIamAuthError({ + stage: "assume_role", + message: "AWS STS returned an incomplete AssumeRole response", + }), + ), + ); + const credentials = decoded.AssumeRoleResponse.AssumeRoleResult.Credentials; + const expiration = Date.parse(credentials.Expiration); + if (!Number.isFinite(expiration)) { + return yield* new AwsIamAuthError({ + stage: "assume_role", + message: "AWS STS returned an invalid role expiration", + }); + } + return { + credentials: { + accessKeyId: credentials.AccessKeyId, + secretAccessKey: credentials.SecretAccessKey, + sessionToken: credentials.SessionToken, + } satisfies AwsCredentials, + expiration, + }; +}); + +const getCallerIdentity = Effect.fn("McpAwsIam.getCallerIdentity")(function* ( + input: AwsIamInput, + credentials: AwsCredentials, +) { + const body = yield* stsRequest("GetCallerIdentity", input, credentials); + const parsed = yield* parseXml(body, "identity"); + const decoded = yield* decodeStsIdentity(parsed).pipe( + Effect.mapError( + () => + new AwsIamAuthError({ + stage: "identity", + message: "AWS STS returned an incomplete caller identity", + }), + ), + ); + const identity = decoded.GetCallerIdentityResponse.GetCallerIdentityResult; + if (identity.Account !== input.expectedAccountId) { + return yield* new AwsIamAuthError({ + stage: "identity", + message: `Assumed role resolved to AWS account ${identity.Account}, expected ${input.expectedAccountId}`, + }); + } + return { accountId: identity.Account, arn: identity.Arn }; +}); + +const createMcpToken = Effect.fn("McpAwsIam.createToken")(function* ( + input: AwsIamInput, + credentials: AwsCredentials, +) { + const body = encodeJson({ + grant_type: "client_credentials", + resource: "aws-mcp.amazonaws.com", + }); + const response = yield* executeSigned({ + stage: "token", + url: `https://${input.region}.oauth.signin.aws/v1/token?x-amz-client-auth-method=iam`, + service: "signin", + region: input.region, + credentials, + body, + contentType: "application/json", + }); + const decoded = yield* decodeTokenResponse(response).pipe( + Effect.mapError( + () => + new AwsIamAuthError({ + stage: "token", + message: "AWS Sign-In returned an incomplete token response", + }), + ), + ); + if (decoded.token_type.toLowerCase() !== "bearer" || decoded.expires_in <= 0) { + return yield* new AwsIamAuthError({ + stage: "token", + message: "AWS Sign-In returned an invalid bearer token", + }); + } + return { + accessToken: decoded.access_token, + expiresAt: Date.now() + decoded.expires_in * 1_000, + }; +}); + +const credentialFingerprint = (input: AwsIamInput): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const material = encodeJson({ + ...input, + bootstrap: input.bootstrap, + }); + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(material)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join( + "", + ); + }, + catch: () => + new AwsIamAuthError({ + stage: "credentials", + message: "Could not fingerprint the AWS credential configuration", + }), + }); + +export interface AwsIamTokenManager { + readonly resolve: ( + connectionKey: string, + endpoint: string, + values: Record, + ) => Effect.Effect; + readonly clear: () => void; +} + +export const createAwsIamTokenManager = (): AwsIamTokenManager => { + const cache = new Map(); + const inFlight = new Map< + string, + Effect.Effect + >(); + + let generation = 0; + const resolve: AwsIamTokenManager["resolve"] = (connectionKey, endpoint, values) => + Effect.gen(function* () { + const region = managedAwsMcpRegion(endpoint); + if (!region) { + return yield* new AwsIamAuthError({ + stage: "credentials", + message: "AWS IAM authentication can only be used with a managed AWS MCP endpoint", + }); + } + const input = yield* parseAwsIamInput(values, region); + const fingerprint = yield* credentialFingerprint(input); + const key = `${connectionKey}:${fingerprint}`; + const cached = cache.get(key); + if (cached && cached.expiresAt - REFRESH_SKEW_MS > Date.now()) return cached; + + const existing = inFlight.get(key); + if (existing) return yield* existing; + + const mint = Effect.gen(function* () { + const assumed = yield* assumeRole(input); + const identity = yield* getCallerIdentity(input, assumed.credentials); + const token = yield* createMcpToken(input, assumed.credentials); + const resolved = { + ...token, + expiresAt: Math.min(token.expiresAt, assumed.expiration), + ...identity, + version: ++generation, + } satisfies AwsIamResolvedToken; + cache.set(key, resolved); + return resolved; + }); + const memoized = yield* Effect.cached(mint); + const gated = memoized.pipe(Effect.ensuring(Effect.sync(() => inFlight.delete(key)))); + const winner = inFlight.get(key) ?? gated; + if (winner === gated) inFlight.set(key, gated); + return yield* winner; + }); + + return { resolve, clear: () => cache.clear() }; +}; diff --git a/packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts b/packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts index e8a9fe5a9f..2d488052b2 100644 --- a/packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts +++ b/packages/plugins/mcp/src/sdk/describe-auth-methods.test.ts @@ -73,6 +73,31 @@ describe("describeMcpAuthMethods", () => { ]); }); + it("projects AWS IAM as named strategy inputs rather than HTTP placements", () => { + const methods = describeMcpAuthMethods( + recordWith({ + transport: "remote", + endpoint: "https://aws-mcp.us-east-1.api.aws/mcp", + authenticationTemplate: [{ slug: "aws_iam", kind: "aws_iam" }], + }), + ); + + expect(methods[0]).toMatchObject({ + id: "aws_iam", + label: "AWS IAM role", + kind: "apikey", + template: "aws_iam", + credentialInputs: [ + { variable: "access_key_id", secret: false }, + { variable: "secret_access_key" }, + { variable: "session_token", optional: true }, + { variable: "role_arn", secret: false }, + { variable: "external_id", optional: true }, + ], + }); + expect(methods[0]?.placements).toBeUndefined(); + }); + it("projects an apikey query method (the ui.sh '?token=' shape)", () => { const methods = describeMcpAuthMethods( recordWith({ diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 99e5c6dda5..f3b3fe67e1 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -42,6 +42,11 @@ import { import { createMcpConnector, type ConnectorInput, type McpConnector } from "./connection"; import { createMcpConnectionPool } from "./connection-pool"; +import { + createAwsIamTokenManager, + isManagedAwsMcpEndpoint, + type AwsIamTokenManager, +} from "./aws-iam"; import { discoverTools } from "./discover"; import { McpConnectionError, @@ -64,6 +69,7 @@ import { type McpIntegrationConfig as McpIntegrationConfigType, type McpStdioEnvMethod, type McpStdioIntegrationConfig, + awsIamCredentialInputs, } from "./types"; const MCP_PLUGIN_ID = "mcp" as const; @@ -548,13 +554,19 @@ const selectAuthMethod = ( return methods.length === 1 ? methods[0] : undefined; }; +type BuiltConnectorInput = ConnectorInput & { readonly credentialVersion?: number }; + const buildConnectorInput = ( config: McpIntegrationConfigType, values: Record, templateSlug: string | null, allowStdio: boolean, httpClientLayer?: Layer.Layer, -): Effect.Effect => { + awsIam?: { + readonly manager: AwsIamTokenManager; + readonly connectionKey: string; + }, +): Effect.Effect => { if (config.transport === "stdio") { if (!allowStdio) { return Effect.fail( @@ -600,6 +612,44 @@ const buildConnectorInput = ( } else if (auth?.kind === "oauth2") { const token = values[TOKEN_VARIABLE]; if (token != null) authProvider = makeOAuthProvider(token); + } else if (auth?.kind === "aws_iam") { + if (!awsIam || !httpClientLayer) { + return Effect.fail( + new McpConnectionError({ + transport: "streamable-http", + message: "AWS IAM authentication is unavailable in this host", + }), + ); + } + if (!isManagedAwsMcpEndpoint(config.endpoint)) { + return Effect.fail( + new McpConnectionError({ + transport: "streamable-http", + message: "AWS IAM authentication can only be used with a managed AWS MCP endpoint", + }), + ); + } + return awsIam.manager.resolve(awsIam.connectionKey, config.endpoint, values).pipe( + Effect.provide(httpClientLayer), + Effect.map((resolved) => ({ + transport: "remote" as const, + endpoint: config.endpoint, + queryParams: Object.keys(queryParams).length > 0 ? queryParams : undefined, + headers: Object.keys(headers).length > 0 ? headers : undefined, + authProvider: makeOAuthProvider(resolved.accessToken), + credentialVersion: resolved.version, + httpClientLayer, + })), + Effect.mapError( + (error) => + new McpConnectionError({ + transport: "streamable-http", + // oxlint-disable-next-line executor/no-unknown-error-message -- typed AwsIamAuthError from the strategy resolver + message: error.message, + ...(error.httpStatus !== undefined ? { httpStatus: error.httpStatus } : {}), + }), + ), + ); } return Effect.succeed({ @@ -625,7 +675,7 @@ const sortedRecord = ( ); const connectionPoolKey = ( - input: Extract, + input: Extract, template: string, values: Record, ): string => @@ -636,6 +686,7 @@ const connectionPoolKey = ( queryParams: sortedRecord(input.queryParams), template, values: sortedRecord(values), + credentialVersion: input.credentialVersion, }); // --------------------------------------------------------------------------- @@ -666,6 +717,14 @@ const describeStdioEnvAuthMethod = (method: McpStdioEnvMethod): AuthMethodDescri placements: method.vars.map((name) => ({ carrier: "env", name, prefix: "", variable: name })), }); +const describeAwsIamAuthMethod = (slug: string): AuthMethodDescriptor => ({ + id: slug, + label: "AWS IAM role", + kind: "apikey", + template: slug, + credentialInputs: awsIamCredentialInputs, +}); + export const describeMcpAuthMethods = ( record: IntegrationRecord, ): readonly AuthMethodDescriptor[] => { @@ -678,6 +737,7 @@ export const describeMcpAuthMethods = ( const methods = config.authenticationTemplate ?? []; return methods.map((method: McpAuthMethod): AuthMethodDescriptor => { if (method.kind === "stdio_env") return describeStdioEnvAuthMethod(method); + if (method.kind === "aws_iam") return describeAwsIamAuthMethod(method.slug); if (method.kind === "apikey") return describeApiKeyAuthMethod(method); if (method.kind === "oauth2") { return { @@ -723,6 +783,7 @@ export interface McpPluginOptions { export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { const allowStdio = options?.dangerouslyAllowStdioMCP ?? false; const connectionPool = createMcpConnectionPool(); + const awsIamTokenManager = createAwsIamTokenManager(); const presetEntries = ( allowStdio @@ -755,7 +816,8 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { // factory reads `allowStdio` and gates the stdio tab + presets. clientConfig: { allowStdio }, storage: () => ({}), - close: () => connectionPool.close(), + close: () => + connectionPool.close().pipe(Effect.tap(() => Effect.sync(() => awsIamTokenManager.clear()))), extension: (ctx: PluginCtx) => { const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; @@ -1223,6 +1285,10 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { template === null ? null : String(template), allowStdio, httpClientLayer, + { + manager: awsIamTokenManager, + connectionKey: `${connection.owner}:${connection.integration}:${connection.name}`, + }, ).pipe( Effect.map((ci) => createMcpConnector(ci)), Effect.result, @@ -1296,6 +1362,10 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { String(credential.template), allowStdio, options?.httpClientLayer ?? ctx.httpClientLayer, + { + manager: awsIamTokenManager, + connectionKey: `${credential.owner}:${credential.integration}:${credential.connection}`, + }, ); const connector: McpConnector = createMcpConnector(connectorInput); const poolKey = @@ -1517,18 +1587,54 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { if (!parsed) { return { status: "unknown" as const, checkedAt: Date.now() } satisfies HealthCheckResult; } + const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; + const method = selectAuthMethod(parsed, String(credential.template)); + const awsIdentity = + method?.kind === "aws_iam" + ? yield* awsIamTokenManager + .resolve( + `${credential.owner}:${credential.integration}:${credential.connection}`, + parsed.transport === "remote" ? parsed.endpoint : "", + credential.values, + ) + .pipe( + Effect.provide(httpClientLayer), + Effect.mapError( + (error) => + new McpConnectionError({ + transport: "streamable-http", + // oxlint-disable-next-line executor/no-unknown-error-message -- typed AwsIamAuthError from the strategy resolver + message: error.message, + ...(error.httpStatus !== undefined ? { httpStatus: error.httpStatus } : {}), + }), + ), + ) + : undefined; const connector = yield* buildConnectorInput( parsed, credential.values, credential.template === null ? null : String(credential.template), allowStdio, - options?.httpClientLayer ?? ctx.httpClientLayer, + httpClientLayer, + { + manager: awsIamTokenManager, + connectionKey: `${credential.owner}:${credential.integration}:${credential.connection}`, + }, ).pipe(Effect.map((ci) => createMcpConnector(ci))); return yield* discoverTools(connector).pipe( Effect.map( () => - ({ status: "healthy" as const, checkedAt: Date.now() }) satisfies HealthCheckResult, + ({ + status: "healthy" as const, + checkedAt: Date.now(), + ...(awsIdentity + ? { + identity: `${awsIdentity.accountId} · ${awsIdentity.arn}`, + detail: `Authenticated as ${awsIdentity.arn}`, + } + : {}), + }) satisfies HealthCheckResult, ), Effect.catchTag("McpToolDiscoveryError", (error) => Effect.succeed({ diff --git a/packages/plugins/mcp/src/sdk/presets.ts b/packages/plugins/mcp/src/sdk/presets.ts index d63052d3f5..5a5a9516e8 100644 --- a/packages/plugins/mcp/src/sdk/presets.ts +++ b/packages/plugins/mcp/src/sdk/presets.ts @@ -1,3 +1,5 @@ +import type { McpAuthMethodInput } from "./types"; + export interface McpRemotePreset { readonly id: string; readonly name: string; @@ -6,6 +8,9 @@ export interface McpRemotePreset { readonly endpoint: string; readonly logoDomain?: string; readonly featured?: boolean; + /** Built-in authentication strategy for providers whose credentials are + * resolved by the MCP plugin rather than authored as request placements. */ + readonly authenticationTemplate?: readonly McpAuthMethodInput[]; readonly transport?: undefined; } @@ -24,6 +29,25 @@ export interface McpStdioPreset { export type McpPreset = McpRemotePreset | McpStdioPreset; export const mcpPresets: readonly McpPreset[] = [ + { + id: "aws-mcp", + name: "AWS MCP", + summary: "AWS documentation, APIs, and automation through the managed AWS MCP Server.", + url: "https://aws-mcp.us-east-1.api.aws/mcp", + endpoint: "https://aws-mcp.us-east-1.api.aws/mcp", + logoDomain: "aws.amazon.com", + featured: true, + authenticationTemplate: [{ kind: "aws_iam" }], + }, + { + id: "aws-mcp-eu-central-1", + name: "AWS MCP (Europe)", + summary: "AWS documentation, APIs, and automation through the Frankfurt AWS MCP Server.", + url: "https://aws-mcp.eu-central-1.api.aws/mcp", + endpoint: "https://aws-mcp.eu-central-1.api.aws/mcp", + logoDomain: "aws.amazon.com", + authenticationTemplate: [{ kind: "aws_iam" }], + }, { id: "emulate-mcp", name: "Emulate MCP", diff --git a/packages/plugins/mcp/src/sdk/types.ts b/packages/plugins/mcp/src/sdk/types.ts index 5287dd85e7..6c7224796e 100644 --- a/packages/plugins/mcp/src/sdk/types.ts +++ b/packages/plugins/mcp/src/sdk/types.ts @@ -7,6 +7,7 @@ import { isApiKeyAuthTemplate, normalizeAuthMethodSlugs, } from "@executor-js/sdk/http-auth"; +import type { AuthMethodCredentialInputDescriptor } from "@executor-js/sdk/core"; // --------------------------------------------------------------------------- // MCP plugin v2 data model. @@ -51,6 +52,45 @@ export const McpOAuthMethod = Schema.Struct({ }); export type McpOAuthMethod = typeof McpOAuthMethod.Type; +/** AWS IAM credentials are exchanged at runtime for a short-lived bearer for + * the managed AWS MCP Server. The connection stores the source values; the + * integration config stores only this strategy tag. */ +export const McpAwsIamMethod = Schema.Struct({ + slug: Schema.String, + kind: Schema.Literal("aws_iam"), +}); +export type McpAwsIamMethod = typeof McpAwsIamMethod.Type; + +export const awsIamCredentialInputs: readonly AuthMethodCredentialInputDescriptor[] = [ + { + variable: "access_key_id", + label: "Access key ID", + placeholder: "AKIA…", + description: "Bootstrap IAM identity permitted to assume the target role.", + secret: false, + }, + { variable: "secret_access_key", label: "Secret access key" }, + { + variable: "session_token", + label: "Session token", + description: "Only required when the bootstrap credentials are temporary.", + optional: true, + }, + { + variable: "role_arn", + label: "Role ARN", + placeholder: "arn:aws:iam::123456789012:role/ExecutorAwsMcp", + description: "Role Executor assumes for AWS MCP access.", + secret: false, + }, + { + variable: "external_id", + label: "External ID", + description: "Optional value required by the role trust policy.", + optional: true, + }, +]; + /** Stdio env credential: the named environment variables a stdio server needs * (often API keys / tokens). A connection supplies one secret value per `var`, * keyed by the var name; at launch the connector injects them into the @@ -69,6 +109,7 @@ export const McpAuthMethod = Schema.Union([ NoneAuthMethod, ApiKeyAuthMethod, McpOAuthMethod, + McpAwsIamMethod, McpStdioEnvMethod, ]); export type McpAuthMethod = typeof McpAuthMethod.Type; @@ -112,6 +153,7 @@ export const mcpAuthMethodFromShorthand = (auth: McpAuthShorthand): McpAuthMetho export const McpAuthMethodInput = Schema.Union([ Schema.Struct({ slug: Schema.optional(Schema.String), kind: Schema.Literal("none") }), Schema.Struct({ slug: Schema.optional(Schema.String), kind: Schema.Literal("oauth2") }), + Schema.Struct({ slug: Schema.optional(Schema.String), kind: Schema.Literal("aws_iam") }), // Credential methods are authored request-shaped — the ONE apikey input // dialect: `{ type: "apiKey", headers: { Authorization: ["Bearer ", // variable("token")] }, queryParams: { … } }`. Stored configs and the diff --git a/packages/react/src/components/add-account-modal.test.ts b/packages/react/src/components/add-account-modal.test.ts index d0d5ba0827..5bb5203f37 100644 --- a/packages/react/src/components/add-account-modal.test.ts +++ b/packages/react/src/components/add-account-modal.test.ts @@ -301,6 +301,36 @@ describe("createCredentialPayloadOrigin", () => { }), ).toBeNull(); }); + + it("omits blank optional strategy inputs", () => { + expect( + createCredentialPayloadOrigin({ + origin: "paste", + inputs: [ + { variable: "role_arn", label: "Role ARN" }, + { variable: "external_id", label: "External ID", optional: true }, + ], + values: { role_arn: " arn:aws:iam::123456789012:role/Executor ", external_id: " " }, + onePasswordItemId: "", + singleInput: false, + }), + ).toEqual({ values: { role_arn: "arn:aws:iam::123456789012:role/Executor" } }); + }); + + it("still rejects a blank required strategy input", () => { + expect( + createCredentialPayloadOrigin({ + origin: "paste", + inputs: [ + { variable: "role_arn", label: "Role ARN" }, + { variable: "external_id", label: "External ID", optional: true }, + ], + values: { role_arn: "", external_id: "tenant" }, + onePasswordItemId: "", + singleInput: false, + }), + ).toBeNull(); + }); }); describe("runCimdConnect", () => { diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index 376b5c2c8f..3ffd5f6b21 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -125,7 +125,14 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs"; const ONEPASSWORD_PROVIDER = ProviderKey.make("onepassword"); type CredentialOrigin = "paste" | "onepassword"; -type CredentialInput = { readonly variable: string; readonly label: string }; +type CredentialInput = { + readonly variable: string; + readonly label: string; + readonly description?: string; + readonly placeholder?: string; + readonly secret?: boolean; + readonly optional?: boolean; +}; type CredentialPayloadOrigin = | { readonly values: Record } @@ -152,10 +159,17 @@ export function createCredentialPayloadOrigin(args: { }; } - const values = Object.fromEntries( - args.inputs.map((input) => [input.variable, (args.values[input.variable] ?? "").trim()]), + const entries = args.inputs.map( + (input) => [input, (args.values[input.variable] ?? "").trim()] as const, ); - return Object.values(values).every((value) => value.length > 0) ? { values } : null; + if (entries.some(([input, value]) => input.optional !== true && value.length === 0)) return null; + return { + values: Object.fromEntries( + entries + .filter(([, value]) => value.length > 0) + .map(([input, value]) => [input.variable, value]), + ), + }; } const numberBadge = (n: number) => ( @@ -211,6 +225,7 @@ function PasteCredentialInputs(props: { // to see all of it, not toggle per field. const [revealed, setRevealed] = useState(false); const inputType = revealed ? "text" : "password"; + const hasSecretInput = props.inputs.some((input) => input.secret !== false); if (!props.singleInput) { return (
@@ -222,12 +237,17 @@ function PasteCredentialInputs(props: { className="min-w-0 truncate font-mono text-xs font-medium text-muted-foreground" > {input.label} + {input.optional === true ? " (optional)" : ""}
+ {input.description ? ( +

{input.description}

+ ) : null}
- setRevealed((v) => !v)} /> + {input.secret === false ? null : ( + setRevealed((v) => !v)} /> + )}
))} @@ -259,8 +281,14 @@ function PasteCredentialInputs(props: { {props.inputs.map((input) => (
{labelled && ( - + )} + {input.description ? ( +

{input.description}

+ ) : null} {props.affix ? ( // Merged field: the placement's lead + prefix is a FIXED addon (its // own muted segment, divider, non-selectable), and the user types @@ -277,7 +305,7 @@ function PasteCredentialInputs(props: { {/* oxlint-disable-next-line react/forbid-elements */} - setRevealed((v) => !v)} /> + {input.secret === false ? null : ( + setRevealed((v) => !v)} /> + )}
) : (
- setRevealed((v) => !v)} /> + {input.secret === false || !hasSecretInput ? null : ( + setRevealed((v) => !v)} /> + )}
)} @@ -1442,6 +1475,9 @@ function AddAccountModalView(props: AddAccountModalProps) { // a variable collapse to one input. const credentialInputs = useMemo(() => { if (!method || method.kind === "oauth" || method.kind === "none") return []; + if (method.credentialInputs && method.credentialInputs.length > 0) { + return method.credentialInputs; + } const byVar = new Map(); for (const placement of method.placements) { const variable = placement.variable ?? "token"; diff --git a/packages/react/src/lib/auth-placements.test.ts b/packages/react/src/lib/auth-placements.test.ts index f31ca38ade..77540f2b12 100644 --- a/packages/react/src/lib/auth-placements.test.ts +++ b/packages/react/src/lib/auth-placements.test.ts @@ -81,6 +81,26 @@ describe("authMethodsFromDescriptors", () => { ]); }); + it("preserves strategy credential inputs without inventing HTTP placements", () => { + const methods = authMethodsFromDescriptors([ + { + id: "aws_iam", + label: "AWS IAM role", + kind: "apikey", + template: "aws_iam", + credentialInputs: [ + { variable: "access_key_id", label: "Access key ID", secret: false }, + { variable: "session_token", label: "Session token", optional: true }, + ], + }, + ]); + expect(methods[0]?.placements).toEqual([]); + expect(methods[0]?.credentialInputs).toEqual([ + { variable: "access_key_id", label: "Access key ID", secret: false }, + { variable: "session_token", label: "Session token", optional: true }, + ]); + }); + it("keeps `none` methods as no-input connection methods", () => { const methods = authMethodsFromDescriptors([ { id: "none", label: "No auth", kind: "none", template: "none" }, diff --git a/packages/react/src/lib/auth-placements.tsx b/packages/react/src/lib/auth-placements.tsx index e61c307af4..4cbbadabda 100644 --- a/packages/react/src/lib/auth-placements.tsx +++ b/packages/react/src/lib/auth-placements.tsx @@ -16,7 +16,10 @@ // --------------------------------------------------------------------------- import { AuthTemplateSlug } from "@executor-js/sdk/shared"; -import type { AuthMethodDescriptor } from "@executor-js/sdk/shared"; +import type { + AuthMethodCredentialInputDescriptor, + AuthMethodDescriptor, +} from "@executor-js/sdk/shared"; export type Carrier = "header" | "query" | "env"; @@ -83,6 +86,7 @@ export interface AuthMethod { readonly source: "spec" | "custom"; readonly template: AuthTemplateSlug; readonly placements: readonly Placement[]; + readonly credentialInputs?: readonly AuthMethodCredentialInputDescriptor[]; /** Declared OAuth endpoints/scopes (only for `kind === "oauth"`). */ readonly oauth?: AuthMethodOAuth; } @@ -194,7 +198,9 @@ function authMethodFromDescriptor(descriptor: AuthMethodDescriptor): AuthMethod ...(placement.literal !== undefined ? { literal: placement.literal } : {}), }), ) - : DEFAULT_PLACEMENTS; + : descriptor.credentialInputs && descriptor.credentialInputs.length > 0 + ? [] + : DEFAULT_PLACEMENTS; return { id: descriptor.id, label: descriptor.label, @@ -202,6 +208,7 @@ function authMethodFromDescriptor(descriptor: AuthMethodDescriptor): AuthMethod source: "spec", template, placements, + ...(descriptor.credentialInputs ? { credentialInputs: descriptor.credentialInputs } : {}), }; }