diff --git a/README.md b/README.md index f2aeef2c..496ebe75 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,117 @@ await test.cleanupItems(); await test.removeUser('test@example.com'); ``` +## MCP Auth + +CDK Serverless includes built-in support for adding [Model Context Protocol (MCP)](https://spec.modelcontextprotocol.io) OAuth authentication to your API. This enables MCP clients like Claude and ChatGPT to authenticate with your API using standard OAuth 2.0 flows. + +When activated, the following endpoints are automatically added to your OpenAPI spec: + +| Endpoint | Method | RFC | Purpose | +|----------|--------|-----|---------| +| `/.well-known/oauth-protected-resource` | GET | RFC 9728 | Resource metadata discovery | +| `/.well-known/oauth-authorization-server` | GET | RFC 8414 | Authorization server metadata | +| `/oauth/authorize` | GET | — | Authorize proxy (redirect to upstream) | +| `/oauth/token` | POST | — | Token proxy (forward to upstream) | +| `/oauth/register` | POST | RFC 7591 | Dynamic client registration | + +All MCP auth endpoints are anonymous (no API authorizer applied). + +### With Cognito + +MCP clients like Claude enforce that all OAuth endpoints (discovery, authorize, token, register) live on the **same origin** as the API itself. Cognito's hosted UI lives on a different domain (`auth.example.com`), so MCP clients cannot talk to it directly. Additionally: + +- Cognito Managed Login v2 requires the RFC 8707 `resource` parameter for custom scopes in authorize requests — MCP clients do not send this parameter. +- MCP clients construct the token endpoint URL from the OAuth metadata `issuer` field rather than using the explicit `token_endpoint` — so the token exchange must be proxied through the API domain. +- Dynamic Client Registration (RFC 7591) is not natively supported by Cognito. + +The MCP auth proxy solves all three problems: it serves discovery metadata on the API domain, strips unsupported parameters before forwarding to Cognito, and implements a simplified registration endpoint that returns a pre-provisioned client ID. + +If your API already uses `CognitoAuthentication`, add MCP auth with minimal config — a dedicated user pool client is created automatically: + +```typescript +const api = new TestApiRestApi(this, 'Api', { + stageName: 'dev', + domainName: 'example.com', + apiHostname: 'api', + authentication: cognitoAuth, + cors: true, + mcpAuth: { + cognito: { + auth: cognitoAuth, // your CognitoAuthentication construct + authDomain: 'auth.example.com', // Cognito custom/hosted domain (required) + }, + serverInfo: { name: 'my-mcp-server', version: '1.0.0' }, + }, +}); +``` + +The `apiDomain` is derived from the RestApi's own domain config (`apiHostname` + `domainName`), and `stageName` is reused — no duplication needed. + +### With any OAuth2 provider + +For non-Cognito providers, use `generic` mode with explicit endpoint URLs: + +```typescript +const api = new TestApiRestApi(this, 'Api', { + // ... + mcpAuth: { + generic: { + authorizeEndpoint: 'https://auth.example.com/authorize', + tokenEndpoint: 'https://auth.example.com/token', + clientId: 'my-pre-provisioned-client-id', + }, + serverInfo: { name: 'my-mcp-server', version: '1.0.0' }, + }, +}); +``` + +### Configuration options + +| Option | Default | Description | +|--------|---------|-------------| +| `serverInfo` | (required) | `{ name, version }` returned in MCP initialize | +| `allowedRedirectUris` | Claude + ChatGPT callbacks | Allowlist for dynamic registration | +| `protocolVersions` | `['2025-11-25', '2025-03-26', '2024-11-05']` | Supported MCP versions | +| `scopes` | `['openid', 'email', 'profile']` | Advertised OAuth scopes | +| `stripParameters` | `['resource']` | Params stripped from authorize/token proxying | +| `lambdaOptions` | — | Lambda config for MCP auth handlers | + +### MCP Server runtime + +The `cdk-serverless/mcp-auth` module also exports a JSON-RPC server for implementing the MCP tool endpoint itself: + +```typescript +import { createMcpServer } from 'cdk-serverless/mcp-auth'; + +const server = createMcpServer({ + serverInfo: { name: 'my-server', version: '1.0.0' }, + protocolVersions: ['2025-11-25'], + resolver: { + async resolve(headers) { + // Validate Bearer token, return principal or throw McpUnauthorizedError + const token = headers.authorization?.replace('Bearer ', ''); + if (!token) throw new McpUnauthorizedError('Bearer'); + return verifyToken(token); + }, + }, + tools: [ + { + name: 'search', + description: 'Search documents', + inputSchema: { type: 'object', properties: { query: { type: 'string' } } }, + async invoke(principal, args) { + const results = await search(principal, (args as any).query); + return { content: [{ type: 'text', text: JSON.stringify(results) }] }; + }, + }, + ], +}); + +// In your Lambda handler: +const response = await server.handle(parsedBody, event.headers); +``` + ## Breaking Change: `axios` Removed CDK Serverless no longer depends on `axios`. The library now uses the diff --git a/docs/constructs/assets/hierarchy.js b/docs/constructs/assets/hierarchy.js index 907e3636..e76642bb 100644 --- a/docs/constructs/assets/hierarchy.js +++ b/docs/constructs/assets/hierarchy.js @@ -1 +1 @@ -window.hierarchyData = "eJyVkztvwyAYRf/LN5MmNoZgtqSVqm59bVEG6pAYlUAEZIr83ytat6WN5cfiwb4f5x4MF3DWBg98w0q0xCjPEcXZFoGTey2roKzxwC+Q5/FpxFECh4dbezAq2NU51NIEVYmYAwTvyuyA54QiODsNHJQJ0u1FJf28e+imDkcNCCotvAcOwe9mcZXZz2T8WCu9c9IA3xTltkFQlEmb/jJZzr7LfDKkn08r8vWiQbDECXQtvFyd1KOzJz8gnkan6mJCEVmw6MzKa3yfZRsZ8PpLYxQRXEQaJjTB3Ttxqp/0SOF/6RHOEci6gX2Kv6nhv0cWLFn/Wfow0iaNjlMhuLhG9Xm0kWEJirP0Gr4oc9DyVbxpeSeC8ME6OXQLu2amnkpakHhGaEGSNr1lOqQnFWl3oGk+AKzSjMo=" \ No newline at end of file +window.hierarchyData = "eJyVk0tzwiAUhf/LXWNVEglhZ9uZTnd97RwXNF4NUwQHcOXkv3ewtqU1k8eGBZx7z/ngcgJnbfAgVrwkRUYoJUWWrwk43GqsgrLGgzgBpXE1co8g4PHO7owKdnkMNZqgKhl1QOBDmQ0IumAEjk6DAGUCuq2s0E/bi27qsNdAoNLSexAQ/GYSu0x+KuNhrfTGoQGxyst1QyAvkzTdYeaUf4c5e6CfjgvytdEQKLLE9FZ6XB7Uk7MH3wOeSsfiZgtG2GwemXl5bd9FeZH0cP1144wwyqNbtmCJ3YOTh/pZDwT+px7AHA15u2EX4q+q//XYbJ70f0EfBtKk0mEojPJrqy6Oi2TICObpN3xVZqfxTb5rvJdB+mAd9v3CtpqxU1nk5xkp8pSzM0wL9Kgglxtomk+59IzW" \ No newline at end of file diff --git a/docs/constructs/assets/navigation.js b/docs/constructs/assets/navigation.js index e34f79f2..5233b221 100644 --- a/docs/constructs/assets/navigation.js +++ b/docs/constructs/assets/navigation.js @@ -1 +1 @@ -window.navigationData = "eJyNlF1PwjAUhv9Lr4kKCX5whxiNxERFoheGi7IdtobSLu1BNIb/bjQb2+zpGdfnOc/ete/2/i0QPlGMxNh7wElqRE8UEnMxEomW3oM/rSYnOW606Im1MqkY9QeX+95h+1p6GBcqXC4H3O7EZkahHW8xB4MqkagsEYPEOO+dk0X+rMlY9YwzPMjNMpW3W5PQkdpzzjQDj2SQcsDtviiTaZjLpYYbidKjdRCKKIqzvlm3Xmm7C03VhNuuOvHkbOFrhTIIbiWTRmv+iLZqMDwPqxM3NQFORBYkro3j3EPq4sTN/xhOd99R/oaVRln5dIfHiAOMlfJ1bHq7K9lWT/0MvNUf4B6L3xj06QYUp2x/ofErI7hu7dzJRJmMC0uBnLj8HcSDNgFORJ193Bqlj30EeJXR7QooTvmK+pgKhBgnrf5n8ddvEd33HkTDr+Jw13Sgs6uL/nCwX/wACnuUNw==" \ No newline at end of file +window.navigationData = "eJyNlU1PAjEQQP9Lz0SFBD+4IUYi0ahI9GA8lKXsNpR20w6iMfx3o9mFLp3O9jxvXqftTPv+w0B8ARuwoXMCRgvNOqzkULAByxR3TrjTOnJSwFqxDltJvWCDbu9y19lnX3MnhqUMk6sAlTsyuZZghhsohAaZcZAGKQPFKO/Y8rJ4VmhZhxhluOfr+YLfbnSGl9SMU6aHrPwrPFRUgZZcb++owotTpqlwgB5HFaByX6TOlZjxuRI3HLgDY0UowijK+mbsaqnMNjTVESq77swna0p3UEgNwi555vXuP9FU9frnYQPHTT5AidA2jWvjOLXIoX3j5iOG0t21jKBnxVFSPtlCijjASCndjr63vSWb6ombCmfUp7CP5V8Z+OkGFKVsvhPxK0O4du3M8kzqnCoWAylx9ShVV02ZUTJBXe+RtO6hBOFYaGFlllBrk0xQJzjTZfHL94EWkTeDpO+Yo7TVFxD3+QAlwuYtbo3SqUsIJ3P8RQkoSvkKKmXsQ4yS1n9YfPsNon3Wg9Lgu9zPN17Q2dVFt9/bffwCDG1v9A==" \ No newline at end of file diff --git a/docs/constructs/assets/search.js b/docs/constructs/assets/search.js index 6f621f2c..7a03f716 100644 --- a/docs/constructs/assets/search.js +++ b/docs/constructs/assets/search.js @@ -1 +1 @@ -window.searchData = "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"; \ No newline at end of file +window.searchData = "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"; \ No newline at end of file diff --git a/docs/constructs/classes/McpAuth.html b/docs/constructs/classes/McpAuth.html new file mode 100644 index 00000000..8e075568 --- /dev/null +++ b/docs/constructs/classes/McpAuth.html @@ -0,0 +1,43 @@ +McpAuth | cdk-serverless
cdk-serverless
    Preparing search index...

    Class McpAuth

    CDK construct that creates Lambda functions for the MCP OAuth façade. +Service-agnostic — proxies authorize/token requests to any upstream +OAuth2/OIDC provider.

    +

    Use with RestApi by passing mcpAuth prop, which auto-injects the +required paths into the OpenAPI spec and wires Lambda integrations.

    +
    const mcpAuth = new McpAuth(this, 'McpAuth', {
    apiDomain: 'api.example.com',
    authorizeEndpoint: 'https://auth.example.com/oauth2/authorize',
    tokenEndpoint: 'https://auth.example.com/oauth2/token',
    allowedRedirectUris: ['https://claude.ai/oauth/callback'],
    clientId: 'my-client-id',
    serverInfo: { name: 'my-mcp-server', version: '1.0.0' },
    stageName: 'dev',
    }); +
    + +

    Hierarchy

    • Construct
      • McpAuth
    Index

    Constructors

    Properties

    Methods

    Constructors

    Properties

    functions: McpAuthFunctions

    The Lambda functions created for the OAuth endpoints.

    +
    mcpEnvVars: Record<string, string>

    The environment variables map that should be injected into the MCP RPC +handler Lambda (if you have one) so it can read MCP config at runtime.

    +
    node: Node

    The tree node.

    +

    Methods

    • Returns a string representation of this construct.

      +

      Returns string

    • Applies one or more mixins to this construct.

      +

      Mixins are applied in order. The list of constructs is captured at the +start of the call, so constructs added by a mixin will not be visited. +Use multiple with() calls if subsequent mixins should apply to added +constructs.

      +

      Parameters

      • ...mixins: IMixin[]

        The mixins to apply

        +

      Returns IConstruct

      This construct for chaining

      +
    • Checks if x is a construct.

      +

      Use this method instead of instanceof to properly detect Construct +instances, even when the construct library is symlinked.

      +

      Explanation: in JavaScript, multiple copies of the constructs library on +disk are seen as independent, completely different libraries. As a +consequence, the class Construct in each copy of the constructs library +is seen as a different class, and an instance of one class will not test as +instanceof the other class. npm install will not create installations +like this, but users may manually symlink construct libraries together or +use a monorepo tool: in those cases, multiple copies of the constructs +library can be accidentally installed, and instanceof will behave +unpredictably. It is safest to avoid using instanceof, and using +this type-testing method instead.

      +

      Parameters

      • x: any

        Any object

        +

      Returns x is Construct

      true if x is an object created from a class which extends Construct.

      +
    diff --git a/docs/constructs/classes/McpCognitoAuth.html b/docs/constructs/classes/McpCognitoAuth.html new file mode 100644 index 00000000..86b87b65 --- /dev/null +++ b/docs/constructs/classes/McpCognitoAuth.html @@ -0,0 +1,43 @@ +McpCognitoAuth | cdk-serverless
    cdk-serverless
      Preparing search index...

      Class McpCognitoAuth

      Convenience construct that creates an McpAuth backed by Amazon Cognito.

      +

      It creates a dedicated Cognito user pool client configured for the +authorization code + PKCE flow and wires it into the generic McpAuth +construct. Use this when your authentication is managed by Cognito; +use McpAuth directly for other OAuth2/OIDC providers.

      +
      const mcpAuth = new McpCognitoAuth(this, 'McpAuth', {
      auth: cognitoAuthentication,
      apiDomain: 'api.example.com',
      authDomain: 'auth.example.com',
      serverInfo: { name: 'my-mcp-server', version: '1.0.0' },
      stageName: 'dev',
      });

      const api = new MyApiRestApi(this, 'Api', {
      mcpAuth: mcpAuth.mcpAuth,
      // ...
      }); +
      + +

      Hierarchy

      • Construct
        • McpCognitoAuth
      Index

      Constructors

      Properties

      Methods

      Constructors

      Properties

      mcpAuth: McpAuth

      The underlying service-agnostic McpAuth construct. +Pass this to RestApi's mcpAuth prop.

      +
      node: Node

      The tree node.

      +
      userPoolClient: UserPoolClient

      The Cognito user pool client created for MCP connectors.

      +

      Methods

      • Returns a string representation of this construct.

        +

        Returns string

      • Applies one or more mixins to this construct.

        +

        Mixins are applied in order. The list of constructs is captured at the +start of the call, so constructs added by a mixin will not be visited. +Use multiple with() calls if subsequent mixins should apply to added +constructs.

        +

        Parameters

        • ...mixins: IMixin[]

          The mixins to apply

          +

        Returns IConstruct

        This construct for chaining

        +
      • Checks if x is a construct.

        +

        Use this method instead of instanceof to properly detect Construct +instances, even when the construct library is symlinked.

        +

        Explanation: in JavaScript, multiple copies of the constructs library on +disk are seen as independent, completely different libraries. As a +consequence, the class Construct in each copy of the constructs library +is seen as a different class, and an instance of one class will not test as +instanceof the other class. npm install will not create installations +like this, but users may manually symlink construct libraries together or +use a monorepo tool: in those cases, multiple copies of the constructs +library can be accidentally installed, and instanceof will behave +unpredictably. It is safest to avoid using instanceof, and using +this type-testing method instead.

        +

        Parameters

        • x: any

          Any object

          +

        Returns x is Construct

        true if x is an object created from a class which extends Construct.

        +
      diff --git a/docs/constructs/classes/RestApi.html b/docs/constructs/classes/RestApi.html index d45338eb..d7fadacb 100644 --- a/docs/constructs/classes/RestApi.html +++ b/docs/constructs/classes/RestApi.html @@ -6,7 +6,7 @@

      Type Parameters

      Hierarchy (View Summary)

      Index

      Constructors

      Hierarchy (View Summary)

      Index

      Constructors

      Properties

      Parameters

      • operationIds: (keyof OPS)[]

        The operation IDs to add to the anonymous set.

      Returns void

      • Parameters

        • operation: OperationObject
        • method: string
        • description: string
        • additionalLambdaOptions: LambdaOptions = {}

        Returns LambdaFunction | undefined

      • Type Parameters

        • P extends string | number | symbol

        Parameters

        Returns LambdaFunction | undefined

      • Parameters

        • spec: { [key: string]: any }

        Returns { [key: string]: any }

      • return the generated Lambda function for the specified API operation

        Parameters

        • operationId: keyof OPS

        Returns LambdaFunction

      • Visitor method to modify the given functions

        +

        Returns LambdaFunction[]

      • Returns the MCP auth construct if mcpAuth was configured. +Useful for accessing the created Lambda functions or env vars.

        +

        Returns McpAuth | undefined

      • Visitor method to modify the given functions

        Parameters

        • operationIds: (keyof OPS)[]

          the list of functions to visit

        • op: (fn: LambdaFunction) => void

          the function to call for every function

        Returns void

      • AWS does not properly apply the 'security' option to every single path-method. @@ -88,4 +91,4 @@ this type-testing method instead.

        Parameters

        • x: any

          Any object

        Returns x is Construct

        true if x is an object created from a class which extends Construct.

        -
      +
      diff --git a/docs/constructs/classes/SingleTableDatastore.html b/docs/constructs/classes/SingleTableDatastore.html index d60762a5..8c6e2ea1 100644 --- a/docs/constructs/classes/SingleTableDatastore.html +++ b/docs/constructs/classes/SingleTableDatastore.html @@ -4,7 +4,7 @@
      const datastore = new SingleTableDatastore(this, 'MyDatastore', {
      design: {
      primaryKey: {
      partitionKey: 'PK',
      sortKey: 'SK',
      },
      globalIndexes: [
      {
      indexName: 'GSI1',
      partitionKey: { name: 'GSI1PK', type: dynamodb.AttributeType.STRING },
      sortKey: { name: 'GSI1SK', type: dynamodb.AttributeType.STRING },
      },
      ],
      localIndexes: [
      {
      indexName: 'LSI1',
      sortKey: { name: 'LSI1SK', type: dynamodb.AttributeType.STRING },
      },
      ],
      timeToLiveAttribute: 'TTL',
      },
      encryption: dynamodb.TableEncryption.AWS_MANAGED,
      });
      -

      Hierarchy

      Implements

      Index

      Constructors

      Hierarchy

      Implements

      Index

      Constructors

      Properties

      Methods

      toString diff --git a/docs/constructs/classes/Workflow.html b/docs/constructs/classes/Workflow.html index 8a1c8c0d..41c375de 100644 --- a/docs/constructs/classes/Workflow.html +++ b/docs/constructs/classes/Workflow.html @@ -4,7 +4,7 @@
      const workflow = new Workflow(this, 'MyWorkflow', {
      definitionFileName: 'path/to/definition.asl.json',
      stateMachineType: sfn.StateMachineType.STANDARD,
      loggingConfiguration: {
      level: sfn.LogLevel.ALL,
      includeExecutionData: true,
      destinations: [new logs.LogGroup(this, 'LogGroup')],
      },
      tracingConfiguration: {
      enabled: true,
      },
      definitionSubstitutions: {
      '${MyVariable}': 'MyValue',
      },
      });

      const lambdaFunction = new lambda.Function(this, 'MyFunction', {
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: 'index.handler',
      code: lambda.Code.fromAsset('lambda'),
      });

      workflow.grantPrincipal.grantInvoke(lambdaFunction);
      -

      Hierarchy

      • Construct
        • Workflow

      Implements

      • IGrantable
      Index

      Constructors

      Hierarchy

      • Construct
        • Workflow

      Implements

      • IGrantable
      Index

      Constructors

      Properties

      grantPrincipal node role diff --git a/docs/constructs/hierarchy.html b/docs/constructs/hierarchy.html index f6919896..85e90312 100644 --- a/docs/constructs/hierarchy.html +++ b/docs/constructs/hierarchy.html @@ -1 +1 @@ -cdk-serverless
      cdk-serverless
        Preparing search index...
        +cdk-serverless
        cdk-serverless
          Preparing search index...
          diff --git a/docs/constructs/index.html b/docs/constructs/index.html index 56722cd2..86359c08 100644 --- a/docs/constructs/index.html +++ b/docs/constructs/index.html @@ -55,6 +55,117 @@ +

          MCP clients like Claude enforce that all OAuth endpoints (discovery, authorize, token, register) live on the same origin as the API itself. Cognito's hosted UI lives on a different domain (auth.example.com), so MCP clients cannot talk to it directly. Additionally:

          +
            +
          • Cognito Managed Login v2 requires the RFC 8707 resource parameter for custom scopes in authorize requests — MCP clients do not send this parameter.
          • +
          • MCP clients construct the token endpoint URL from the OAuth metadata issuer field rather than using the explicit token_endpoint — so the token exchange must be proxied through the API domain.
          • +
          • Dynamic Client Registration (RFC 7591) is not natively supported by Cognito.
          • +
          +

          The MCP auth proxy solves all three problems: it serves discovery metadata on the API domain, strips unsupported parameters before forwarding to Cognito, and implements a simplified registration endpoint that returns a pre-provisioned client ID.

          +

          If your API already uses CognitoAuthentication, add MCP auth with minimal config — a dedicated user pool client is created automatically:

          +
          const api = new TestApiRestApi(this, 'Api', {
          stageName: 'dev',
          domainName: 'example.com',
          apiHostname: 'api',
          authentication: cognitoAuth,
          cors: true,
          mcpAuth: {
          cognito: {
          auth: cognitoAuth, // your CognitoAuthentication construct
          authDomain: 'auth.example.com', // Cognito custom/hosted domain (required)
          },
          serverInfo: { name: 'my-mcp-server', version: '1.0.0' },
          },
          }); +
          + +

          The apiDomain is derived from the RestApi's own domain config (apiHostname + domainName), and stageName is reused — no duplication needed.

          + +

          For non-Cognito providers, use generic mode with explicit endpoint URLs:

          +
          const api = new TestApiRestApi(this, 'Api', {
          // ...
          mcpAuth: {
          generic: {
          authorizeEndpoint: 'https://auth.example.com/authorize',
          tokenEndpoint: 'https://auth.example.com/token',
          clientId: 'my-pre-provisioned-client-id',
          },
          serverInfo: { name: 'my-mcp-server', version: '1.0.0' },
          },
          }); +
          + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
          OptionDefaultDescription
          serverInfo(required){ name, version } returned in MCP initialize
          allowedRedirectUrisClaude + ChatGPT callbacksAllowlist for dynamic registration
          protocolVersions['2025-11-25', '2025-03-26', '2024-11-05']Supported MCP versions
          scopes['openid', 'email', 'profile']Advertised OAuth scopes
          stripParameters['resource']Params stripped from authorize/token proxying
          lambdaOptions—Lambda config for MCP auth handlers
          + +

          The cdk-serverless/mcp-auth module also exports a JSON-RPC server for implementing the MCP tool endpoint itself:

          +
          import { createMcpServer } from 'cdk-serverless/mcp-auth';

          const server = createMcpServer({
          serverInfo: { name: 'my-server', version: '1.0.0' },
          protocolVersions: ['2025-11-25'],
          resolver: {
          async resolve(headers) {
          // Validate Bearer token, return principal or throw McpUnauthorizedError
          const token = headers.authorization?.replace('Bearer ', '');
          if (!token) throw new McpUnauthorizedError('Bearer');
          return verifyToken(token);
          },
          },
          tools: [
          {
          name: 'search',
          description: 'Search documents',
          inputSchema: { type: 'object', properties: { query: { type: 'string' } } },
          async invoke(principal, args) {
          const results = await search(principal, (args as any).query);
          return { content: [{ type: 'text', text: JSON.stringify(results) }] };
          },
          },
          ],
          });

          // In your Lambda handler:
          const response = await server.handle(parsedBody, event.headers); +
          +

          CDK Serverless no longer depends on axios. The library now uses the platform-native fetch API (stable in Node.js 18+; the Lambda functions @@ -136,4 +247,4 @@

          Authors

          Brought to you by Taimos

          -
          +
          diff --git a/docs/constructs/interfaces/McpAuthCognitoOptions.html b/docs/constructs/interfaces/McpAuthCognitoOptions.html new file mode 100644 index 00000000..974a47b8 --- /dev/null +++ b/docs/constructs/interfaces/McpAuthCognitoOptions.html @@ -0,0 +1,17 @@ +McpAuthCognitoOptions | cdk-serverless
          cdk-serverless
            Preparing search index...

            Interface McpAuthCognitoOptions

            Cognito-specific MCP auth configuration. +Creates a dedicated user pool client for MCP connectors automatically.

            +
            interface McpAuthCognitoOptions {
                additionalCallbackUrls?: string[];
                auth: CognitoAuthentication;
                authDomain: string;
                clientConstructId?: string;
            }
            Index

            Properties

            additionalCallbackUrls?: string[]

            Additional OAuth callback URLs for the Cognito client beyond the allowedRedirectUris.

            +

            The CognitoAuthentication construct managing the user pool. +A dedicated client with auth code + PKCE will be created.

            +
            authDomain: string

            The Cognito auth domain (custom or Cognito-hosted). +Required — never inferred from apiDomain. +Example: 'auth.example.com'

            +
            clientConstructId?: string

            Construct ID for the user pool client (affects CloudFormation logical ID).

            +
            'mcpConnector'
            +
            + +
            diff --git a/docs/constructs/interfaces/McpAuthFunctions.html b/docs/constructs/interfaces/McpAuthFunctions.html new file mode 100644 index 00000000..4f24b06e --- /dev/null +++ b/docs/constructs/interfaces/McpAuthFunctions.html @@ -0,0 +1,14 @@ +McpAuthFunctions | cdk-serverless
            cdk-serverless
              Preparing search index...

              Interface McpAuthFunctions

              Provides the Lambda function references for MCP OAuth endpoints. +These can be wired into API Gateway (via RestApi integration) +or used standalone.

              +
              Index

              Properties

              authorizationServer: LambdaFunction

              GET /.well-known/oauth-authorization-server

              +
              authorize: LambdaFunction

              GET /oauth/authorize

              +
              protectedResource: LambdaFunction

              GET /.well-known/oauth-protected-resource

              +
              register: LambdaFunction

              POST /oauth/register

              +

              POST /oauth/token

              +
              diff --git a/docs/constructs/interfaces/McpAuthGenericOptions.html b/docs/constructs/interfaces/McpAuthGenericOptions.html new file mode 100644 index 00000000..17b72ae6 --- /dev/null +++ b/docs/constructs/interfaces/McpAuthGenericOptions.html @@ -0,0 +1,11 @@ +McpAuthGenericOptions | cdk-serverless
              cdk-serverless
                Preparing search index...

                Interface McpAuthGenericOptions

                Generic (non-Cognito) MCP auth configuration. +Requires explicit authorize and token endpoint URLs.

                +
                interface McpAuthGenericOptions {
                    authorizeEndpoint: string;
                    clientId: string;
                    tokenEndpoint: string;
                }
                Index

                Properties

                authorizeEndpoint: string

                The upstream OAuth authorize endpoint URL. +Example: 'https://auth.example.com/oauth2/authorize'

                +
                clientId: string

                The pre-provisioned OAuth client ID returned by the register endpoint.

                +
                tokenEndpoint: string

                The upstream OAuth token endpoint URL. +Example: 'https://auth.example.com/oauth2/token'

                +
                diff --git a/docs/constructs/interfaces/McpAuthOptions.html b/docs/constructs/interfaces/McpAuthOptions.html new file mode 100644 index 00000000..f444c5a6 --- /dev/null +++ b/docs/constructs/interfaces/McpAuthOptions.html @@ -0,0 +1,33 @@ +McpAuthOptions | cdk-serverless
                cdk-serverless
                  Preparing search index...

                  Interface McpAuthOptions

                  MCP auth configuration for the RestApi. +Provide either cognito (for Cognito-backed auth) or generic (for any OAuth2 provider).

                  +
                  interface McpAuthOptions {
                      allowedRedirectUris?: string[];
                      cognito?: McpAuthCognitoOptions;
                      generic?: McpAuthGenericOptions;
                      lambdaOptions?: LambdaOptions;
                      protocolVersions?: string[];
                      scopes?: string[];
                      serverInfo: { name: string; version: string };
                      stripParameters?: string[];
                  }
                  Index

                  Properties

                  allowedRedirectUris?: string[]

                  Allowed redirect URIs for dynamic client registration.

                  +
                  ['https://claude.ai/oauth/callback', 'https://claude.ai/api/mcp/auth_callback', 'https://chatgpt.com/oauth/callback']
                  +
                  + +

                  Cognito-specific configuration. Mutually exclusive with generic. +Creates a user pool client and derives endpoints automatically.

                  +

                  Generic OAuth2 provider configuration. Mutually exclusive with cognito. +Requires explicit endpoint URLs and client ID.

                  +
                  lambdaOptions?: LambdaOptions

                  Lambda function options for MCP auth handlers.

                  +
                  protocolVersions?: string[]

                  Supported MCP protocol versions (newest first).

                  +
                  ['2025-11-25', '2025-03-26', '2024-11-05']
                  +
                  + +
                  scopes?: string[]

                  OAuth scopes to advertise in discovery metadata.

                  +
                  ['openid', 'email', 'profile']
                  +
                  + +
                  serverInfo: { name: string; version: string }

                  MCP server info returned in the 'initialize' response.

                  +
                  stripParameters?: string[]

                  Parameters to strip from authorize/token proxy requests.

                  +
                  ['resource']
                  +
                  + +
                  diff --git a/docs/constructs/interfaces/McpAuthProps.html b/docs/constructs/interfaces/McpAuthProps.html new file mode 100644 index 00000000..d808c7ae --- /dev/null +++ b/docs/constructs/interfaces/McpAuthProps.html @@ -0,0 +1,43 @@ +McpAuthProps | cdk-serverless
                  cdk-serverless
                    Preparing search index...

                    Interface McpAuthProps

                    Configuration for the MCP Auth CDK construct. +Service-agnostic: works with any OAuth2/OIDC provider.

                    +
                    interface McpAuthProps {
                        additionalEnv?: Record<string, string>;
                        allowedRedirectUris: string[];
                        apiDomain: string;
                        authorizeEndpoint: string;
                        clientId: string;
                        lambdaOptions?: LambdaOptions;
                        protocolVersions?: string[];
                        scopes?: string[];
                        serverInfo: { name: string; version: string };
                        stageName: string;
                        stripParameters?: string[];
                        tokenEndpoint: string;
                    }
                    Index

                    Properties

                    additionalEnv?: Record<string, string>

                    Additional environment variables to inject into all MCP auth Lambda functions.

                    +
                    allowedRedirectUris: string[]

                    Allowed redirect URIs for dynamic client registration. +Clients registering with URIs not in this list are rejected.

                    +
                    apiDomain: string

                    The API domain that acts as the MCP OAuth issuer. +All OAuth discovery endpoints will reference this domain. +Example: 'api.example.com'

                    +
                    authorizeEndpoint: string

                    The upstream OAuth authorize endpoint URL. +The authorize Lambda proxies (302 redirects) to this URL. +Example: 'https://auth.example.com/oauth2/authorize'

                    +
                    clientId: string

                    The pre-provisioned OAuth client ID returned by the register endpoint.

                    +
                    lambdaOptions?: LambdaOptions

                    Lambda function options for MCP auth handlers.

                    +
                    protocolVersions?: string[]

                    Supported MCP protocol versions (newest first).

                    +
                    ['2025-11-25', '2025-03-26', '2024-11-05']
                    +
                    + +
                    scopes?: string[]

                    OAuth scopes to advertise in discovery metadata.

                    +
                    ['openid', 'email', 'profile']
                    +
                    + +
                    serverInfo: { name: string; version: string }

                    MCP server info returned in the 'initialize' response.

                    +
                    stageName: string

                    Stage name for function naming and tagging.

                    +
                    stripParameters?: string[]

                    Parameters to strip from authorize/token proxy requests.

                    +
                    ['resource']
                    +
                    + +
                    tokenEndpoint: string

                    The upstream OAuth token endpoint URL. +The token Lambda proxies POST requests to this URL. +Example: 'https://auth.example.com/oauth2/token'

                    +
                    diff --git a/docs/constructs/interfaces/McpCognitoAuthProps.html b/docs/constructs/interfaces/McpCognitoAuthProps.html new file mode 100644 index 00000000..16502e69 --- /dev/null +++ b/docs/constructs/interfaces/McpCognitoAuthProps.html @@ -0,0 +1,49 @@ +McpCognitoAuthProps | cdk-serverless
                    cdk-serverless
                      Preparing search index...

                      Interface McpCognitoAuthProps

                      Props for creating an McpAuth backed by a Cognito User Pool. +This is a convenience wrapper — use McpAuth directly for non-Cognito providers.

                      +
                      interface McpCognitoAuthProps {
                          additionalCallbackUrls?: string[];
                          additionalEnv?: Record<string, string>;
                          allowedRedirectUris?: string[];
                          apiDomain: string;
                          auth: CognitoAuthentication;
                          authDomain: string;
                          clientConstructId?: string;
                          lambdaOptions?: LambdaOptions;
                          protocolVersions?: string[];
                          scopes?: string[];
                          serverInfo: { name: string; version: string };
                          stageName: string;
                      }
                      Index

                      Properties

                      additionalCallbackUrls?: string[]

                      Additional OAuth callback URLs to configure on the Cognito client +beyond the allowedRedirectUris. Useful if the Cognito client needs +URLs that aren't in the MCP registration allowlist.

                      +
                      additionalEnv?: Record<string, string>

                      Additional environment variables for MCP auth Lambda functions.

                      +
                      allowedRedirectUris?: string[]

                      Allowed redirect URIs for dynamic client registration.

                      +
                      ['https://claude.ai/oauth/callback', 'https://claude.ai/api/mcp/auth_callback', 'https://chatgpt.com/oauth/callback']
                      +
                      + +
                      apiDomain: string

                      The API domain that acts as the MCP OAuth issuer. +All discovery endpoints will reference this domain. +Example: 'api.example.com'

                      +

                      The CognitoAuthentication construct that manages the user pool. +A dedicated user pool client will be created for MCP connectors.

                      +
                      authDomain: string

                      The Cognito auth domain (custom domain or Cognito-hosted domain). +Authorize and token requests are proxied here. +Example: 'auth.example.com' or 'myapp.auth.eu-central-1.amazoncognito.com'

                      +
                      clientConstructId?: string

                      Construct ID for the user pool client (affects CloudFormation logical ID).

                      +
                      'mcpConnector'
                      +
                      + +
                      lambdaOptions?: LambdaOptions

                      Lambda function options for MCP auth handlers.

                      +
                      protocolVersions?: string[]

                      Supported MCP protocol versions (newest first).

                      +
                      ['2025-11-25', '2025-03-26', '2024-11-05']
                      +
                      + +
                      scopes?: string[]

                      OAuth scopes to advertise in discovery metadata. +Note: Do NOT include custom resource server scopes here — +Cognito Managed Login v2 requires RFC 8707 resource parameter +for custom scopes which MCP clients cannot supply.

                      +
                      ['openid', 'email', 'profile']
                      +
                      + +
                      serverInfo: { name: string; version: string }

                      MCP server info returned in the 'initialize' response.

                      +
                      stageName: string

                      Stage name for function naming and tagging.

                      +
                      diff --git a/docs/constructs/interfaces/RestApiProps.html b/docs/constructs/interfaces/RestApiProps.html index 4e9df348..114c72c5 100644 --- a/docs/constructs/interfaces/RestApiProps.html +++ b/docs/constructs/interfaces/RestApiProps.html @@ -1,4 +1,4 @@ -RestApiProps | cdk-serverless
                      cdk-serverless
                        Preparing search index...

                        Interface RestApiProps<OPS>

                        interface RestApiProps<OPS> {
                            additionalEnv?: { [key: string]: string };
                            anonymousOperations?: (keyof OPS)[];
                            apiHostname?: string;
                            apiName: string;
                            assetCdn?: AssetCdn;
                            authentication?: IJwtAuthentication | ICognitoAuthentication;
                            authorizationScopes?: string[];
                            authorizationScopesByOperation?: {
                                [operationId in string | number | symbol]?: string[]
                            };
                            autoGenerateRoutes?: boolean;
                            cors: boolean;
                            definitionFileName: string;
                            domainName?: string;
                            hostedZone?: IHostedZone;
                            jwtAuthorizerType?: "token"
                            | "request";
                            lambdaOptions?: LambdaOptions;
                            lambdaOptionsByOperation?: {
                                [operationId in string | number | symbol]?: LambdaOptions
                            };
                            lambdaTracing?: LambdaTracingOptions;
                            monitoring?: boolean;
                            restApiProps?: RestApiBaseProps;
                            singleTableDatastore?: ISingleTableDatastore;
                            stageName: string;
                        }

                        Type Parameters

                        • OPS

                        Hierarchy (View Summary)

                        Index

                        Properties

                        additionalEnv? +RestApiProps | cdk-serverless
                        cdk-serverless
                          Preparing search index...

                          Interface RestApiProps<OPS>

                          interface RestApiProps<OPS> {
                              additionalEnv?: { [key: string]: string };
                              anonymousOperations?: (keyof OPS)[];
                              apiHostname?: string;
                              apiName: string;
                              assetCdn?: AssetCdn;
                              authentication?: IJwtAuthentication | ICognitoAuthentication;
                              authorizationScopes?: string[];
                              authorizationScopesByOperation?: {
                                  [operationId in string | number | symbol]?: string[]
                              };
                              autoGenerateRoutes?: boolean;
                              cors: boolean;
                              definitionFileName: string;
                              domainName?: string;
                              hostedZone?: IHostedZone;
                              jwtAuthorizerType?: "token"
                              | "request";
                              lambdaOptions?: LambdaOptions;
                              lambdaOptionsByOperation?: {
                                  [operationId in string | number | symbol]?: LambdaOptions
                              };
                              lambdaTracing?: LambdaTracingOptions;
                              mcpAuth?: McpAuthOptions;
                              monitoring?: boolean;
                              restApiProps?: RestApiBaseProps;
                              singleTableDatastore?: ISingleTableDatastore;
                              stageName: string;
                          }

                          Type Parameters

                          • OPS

                          Hierarchy (View Summary)

                          Index
                          lambdaTracing?: LambdaTracingOptions

                          Tracing config for the generated Lambda functions

                          -
                          monitoring?: boolean

                          Configure CloudWatch Dashboard for the API and the Lambda functions

                          -
                          true
                          +
                          mcpAuth?: McpAuthOptions

                          MCP Auth configuration to wire into this API. +When provided, the following paths are automatically added to the OpenAPI spec +with Lambda proxy integrations:

                          +
                            +
                          • GET /.well-known/oauth-protected-resource
                          • +
                          • GET /.well-known/oauth-authorization-server
                          • +
                          • GET /oauth/authorize
                          • +
                          • POST /oauth/token
                          • +
                          • POST /oauth/register
                          • +
                          +

                          All MCP auth endpoints are anonymous (no authorizer). +The API domain is derived from the RestApi's own domain configuration.

                          +

                          Provide either cognito (creates a Cognito client automatically) or +generic (for any OAuth2 provider with explicit endpoint URLs).

                          +
                          - no MCP auth
                          +
                          + +
                          monitoring?: boolean

                          Configure CloudWatch Dashboard for the API and the Lambda functions

                          +
                          true
                           
                          restApiProps?: RestApiBaseProps

                          custom options for the created HttpApi

                          -
                          -
                          +
                          -
                           
                          -
                          singleTableDatastore?: ISingleTableDatastore
                          none
                          +
                          singleTableDatastore?: ISingleTableDatastore
                          none
                           
                          stageName: string

                          Deployment stage (e.g. dev)

                          -
                          +
                          diff --git a/docs/constructs/modules.html b/docs/constructs/modules.html index 10f24438..9bba2558 100644 --- a/docs/constructs/modules.html +++ b/docs/constructs/modules.html @@ -1 +1 @@ -cdk-serverless
                          cdk-serverless
                            Preparing search index...
                            +cdk-serverless
                            cdk-serverless
                              Preparing search index...
                              diff --git a/docs/lambda/index.html b/docs/lambda/index.html index 56722cd2..86359c08 100644 --- a/docs/lambda/index.html +++ b/docs/lambda/index.html @@ -55,6 +55,117 @@
                              +

                              MCP clients like Claude enforce that all OAuth endpoints (discovery, authorize, token, register) live on the same origin as the API itself. Cognito's hosted UI lives on a different domain (auth.example.com), so MCP clients cannot talk to it directly. Additionally:

                              + +

                              The MCP auth proxy solves all three problems: it serves discovery metadata on the API domain, strips unsupported parameters before forwarding to Cognito, and implements a simplified registration endpoint that returns a pre-provisioned client ID.

                              +

                              If your API already uses CognitoAuthentication, add MCP auth with minimal config — a dedicated user pool client is created automatically:

                              +
                              const api = new TestApiRestApi(this, 'Api', {
                              stageName: 'dev',
                              domainName: 'example.com',
                              apiHostname: 'api',
                              authentication: cognitoAuth,
                              cors: true,
                              mcpAuth: {
                              cognito: {
                              auth: cognitoAuth, // your CognitoAuthentication construct
                              authDomain: 'auth.example.com', // Cognito custom/hosted domain (required)
                              },
                              serverInfo: { name: 'my-mcp-server', version: '1.0.0' },
                              },
                              }); +
                              + +

                              The apiDomain is derived from the RestApi's own domain config (apiHostname + domainName), and stageName is reused — no duplication needed.

                              + +

                              For non-Cognito providers, use generic mode with explicit endpoint URLs:

                              +
                              const api = new TestApiRestApi(this, 'Api', {
                              // ...
                              mcpAuth: {
                              generic: {
                              authorizeEndpoint: 'https://auth.example.com/authorize',
                              tokenEndpoint: 'https://auth.example.com/token',
                              clientId: 'my-pre-provisioned-client-id',
                              },
                              serverInfo: { name: 'my-mcp-server', version: '1.0.0' },
                              },
                              }); +
                              + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
                              OptionDefaultDescription
                              serverInfo(required){ name, version } returned in MCP initialize
                              allowedRedirectUrisClaude + ChatGPT callbacksAllowlist for dynamic registration
                              protocolVersions['2025-11-25', '2025-03-26', '2024-11-05']Supported MCP versions
                              scopes['openid', 'email', 'profile']Advertised OAuth scopes
                              stripParameters['resource']Params stripped from authorize/token proxying
                              lambdaOptions—Lambda config for MCP auth handlers
                              + +

                              The cdk-serverless/mcp-auth module also exports a JSON-RPC server for implementing the MCP tool endpoint itself:

                              +
                              import { createMcpServer } from 'cdk-serverless/mcp-auth';

                              const server = createMcpServer({
                              serverInfo: { name: 'my-server', version: '1.0.0' },
                              protocolVersions: ['2025-11-25'],
                              resolver: {
                              async resolve(headers) {
                              // Validate Bearer token, return principal or throw McpUnauthorizedError
                              const token = headers.authorization?.replace('Bearer ', '');
                              if (!token) throw new McpUnauthorizedError('Bearer');
                              return verifyToken(token);
                              },
                              },
                              tools: [
                              {
                              name: 'search',
                              description: 'Search documents',
                              inputSchema: { type: 'object', properties: { query: { type: 'string' } } },
                              async invoke(principal, args) {
                              const results = await search(principal, (args as any).query);
                              return { content: [{ type: 'text', text: JSON.stringify(results) }] };
                              },
                              },
                              ],
                              });

                              // In your Lambda handler:
                              const response = await server.handle(parsedBody, event.headers); +
                              +

                              CDK Serverless no longer depends on axios. The library now uses the platform-native fetch API (stable in Node.js 18+; the Lambda functions @@ -136,4 +247,4 @@

                              Authors

                              Brought to you by Taimos

                              -
                              +
                              diff --git a/docs/projen/index.html b/docs/projen/index.html index baafa6fe..681b2e30 100644 --- a/docs/projen/index.html +++ b/docs/projen/index.html @@ -55,6 +55,117 @@
                              +

                              MCP clients like Claude enforce that all OAuth endpoints (discovery, authorize, token, register) live on the same origin as the API itself. Cognito's hosted UI lives on a different domain (auth.example.com), so MCP clients cannot talk to it directly. Additionally:

                              + +

                              The MCP auth proxy solves all three problems: it serves discovery metadata on the API domain, strips unsupported parameters before forwarding to Cognito, and implements a simplified registration endpoint that returns a pre-provisioned client ID.

                              +

                              If your API already uses CognitoAuthentication, add MCP auth with minimal config — a dedicated user pool client is created automatically:

                              +
                              const api = new TestApiRestApi(this, 'Api', {
                              stageName: 'dev',
                              domainName: 'example.com',
                              apiHostname: 'api',
                              authentication: cognitoAuth,
                              cors: true,
                              mcpAuth: {
                              cognito: {
                              auth: cognitoAuth, // your CognitoAuthentication construct
                              authDomain: 'auth.example.com', // Cognito custom/hosted domain (required)
                              },
                              serverInfo: { name: 'my-mcp-server', version: '1.0.0' },
                              },
                              }); +
                              + +

                              The apiDomain is derived from the RestApi's own domain config (apiHostname + domainName), and stageName is reused — no duplication needed.

                              + +

                              For non-Cognito providers, use generic mode with explicit endpoint URLs:

                              +
                              const api = new TestApiRestApi(this, 'Api', {
                              // ...
                              mcpAuth: {
                              generic: {
                              authorizeEndpoint: 'https://auth.example.com/authorize',
                              tokenEndpoint: 'https://auth.example.com/token',
                              clientId: 'my-pre-provisioned-client-id',
                              },
                              serverInfo: { name: 'my-mcp-server', version: '1.0.0' },
                              },
                              }); +
                              + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
                              OptionDefaultDescription
                              serverInfo(required){ name, version } returned in MCP initialize
                              allowedRedirectUrisClaude + ChatGPT callbacksAllowlist for dynamic registration
                              protocolVersions['2025-11-25', '2025-03-26', '2024-11-05']Supported MCP versions
                              scopes['openid', 'email', 'profile']Advertised OAuth scopes
                              stripParameters['resource']Params stripped from authorize/token proxying
                              lambdaOptions—Lambda config for MCP auth handlers
                              + +

                              The cdk-serverless/mcp-auth module also exports a JSON-RPC server for implementing the MCP tool endpoint itself:

                              +
                              import { createMcpServer } from 'cdk-serverless/mcp-auth';

                              const server = createMcpServer({
                              serverInfo: { name: 'my-server', version: '1.0.0' },
                              protocolVersions: ['2025-11-25'],
                              resolver: {
                              async resolve(headers) {
                              // Validate Bearer token, return principal or throw McpUnauthorizedError
                              const token = headers.authorization?.replace('Bearer ', '');
                              if (!token) throw new McpUnauthorizedError('Bearer');
                              return verifyToken(token);
                              },
                              },
                              tools: [
                              {
                              name: 'search',
                              description: 'Search documents',
                              inputSchema: { type: 'object', properties: { query: { type: 'string' } } },
                              async invoke(principal, args) {
                              const results = await search(principal, (args as any).query);
                              return { content: [{ type: 'text', text: JSON.stringify(results) }] };
                              },
                              },
                              ],
                              });

                              // In your Lambda handler:
                              const response = await server.handle(parsedBody, event.headers); +
                              +

                              CDK Serverless no longer depends on axios. The library now uses the platform-native fetch API (stable in Node.js 18+; the Lambda functions @@ -136,4 +247,4 @@

                              Authors

                              Brought to you by Taimos

                              -
                              +
                              diff --git a/llm.md b/llm.md index 197f6959..cc8fa8bc 100644 --- a/llm.md +++ b/llm.md @@ -343,6 +343,220 @@ describe('Integration Tests', () => { }); ``` +## MCP Auth + +MCP Auth is a service-agnostic OAuth façade for the [Model Context Protocol](https://spec.modelcontextprotocol.io). It adds OAuth 2.0 discovery, authorization, token exchange, and dynamic client registration endpoints to your API so MCP clients (Claude, ChatGPT, etc.) can authenticate via standard OAuth flows. + +### Using with RestApi + +The easiest way is via the `mcpAuth` prop on `RestApiProps`. It supports two mutually exclusive modes: + +#### Cognito Mode + +```typescript +const api = new RestApi(this, 'Api', { + apiName: 'MyApi', + stageName: 'dev', + definitionFileName: 'openapi.yaml', + authentication: auth, + domainName: 'example.com', + apiHostname: 'api', + cors: true, + mcpAuth: { + cognito: { + auth: cognitoAuthentication, // CognitoAuthentication construct + authDomain: 'auth.example.com', // Cognito hosted/custom domain + }, + serverInfo: { name: 'my-mcp-server', version: '1.0.0' }, + }, +}); +``` + +A dedicated user pool client (auth code + PKCE) is created automatically with callback URLs for known MCP clients. + +#### Generic Mode + +```typescript +const api = new RestApi(this, 'Api', { + // ... + mcpAuth: { + generic: { + authorizeEndpoint: 'https://auth.example.com/oauth2/authorize', + tokenEndpoint: 'https://auth.example.com/oauth2/token', + clientId: 'my-pre-provisioned-client-id', + }, + serverInfo: { name: 'my-mcp-server', version: '1.0.0' }, + }, +}); +``` + +#### McpAuthOptions + +| Property | Default | Description | +|----------|---------|-------------| +| `cognito` | — | Cognito-specific config (mutually exclusive with `generic`) | +| `generic` | — | Any OAuth2 provider config (mutually exclusive with `cognito`) | +| `serverInfo` | *required* | `{ name, version }` returned in MCP `initialize` response | +| `allowedRedirectUris` | Claude + ChatGPT callbacks | URIs allowed for dynamic client registration | +| `protocolVersions` | `['2025-11-25', '2025-03-26', '2024-11-05']` | Supported MCP protocol versions | +| `scopes` | `['openid', 'email', 'profile']` | OAuth scopes advertised in discovery | +| `stripParameters` | `['resource']` | Params stripped before proxying to upstream | +| `lambdaOptions` | — | Lambda config for MCP auth handlers | + +### Auto-Injected Endpoints + +When `mcpAuth` is configured, 5 anonymous paths are added to the OpenAPI spec: + +| Method | Path | RFC | Purpose | +|--------|------|-----|---------| +| GET | `/.well-known/oauth-protected-resource` | RFC 9728 | Resource metadata discovery | +| GET | `/.well-known/oauth-authorization-server` | RFC 8414 | Authorization server metadata | +| GET | `/oauth/authorize` | — | Proxies to upstream authorize endpoint | +| POST | `/oauth/token` | — | Proxies to upstream token endpoint | +| POST | `/oauth/register` | RFC 7591 | Dynamic client registration | + +All endpoints bypass the API authorizer (`security: []`). The API domain is derived from the RestApi's own domain configuration. + +### Standalone Constructs + +For use outside `RestApi` (e.g., with HTTP API or custom integration): + +#### McpCognitoAuth + +Convenience wrapper that creates a Cognito client + the generic `McpAuth`: + +```typescript +import { McpCognitoAuth } from 'cdk-serverless/constructs'; + +const mcpAuth = new McpCognitoAuth(this, 'McpAuth', { + auth: cognitoAuthentication, + apiDomain: 'api.example.com', + authDomain: 'auth.example.com', + serverInfo: { name: 'my-mcp-server', version: '1.0.0' }, + stageName: 'dev', +}); + +// Access the underlying McpAuth and user pool client +mcpAuth.mcpAuth.functions; // Lambda functions for each endpoint +mcpAuth.mcpAuth.mcpEnvVars; // Env vars to inject into your MCP handler +mcpAuth.userPoolClient; // The created Cognito client +``` + +#### McpAuth + +Service-agnostic construct for any OAuth2 provider: + +```typescript +import { McpAuth } from 'cdk-serverless/constructs'; + +const mcpAuth = new McpAuth(this, 'McpAuth', { + apiDomain: 'api.example.com', + authorizeEndpoint: 'https://auth.example.com/oauth2/authorize', + tokenEndpoint: 'https://auth.example.com/oauth2/token', + allowedRedirectUris: ['https://claude.ai/oauth/callback'], + clientId: 'my-client-id', + serverInfo: { name: 'my-mcp-server', version: '1.0.0' }, + stageName: 'dev', +}); + +// Wire functions into your own API Gateway +mcpAuth.functions.protectedResource; // GET /.well-known/oauth-protected-resource +mcpAuth.functions.authorizationServer; // GET /.well-known/oauth-authorization-server +mcpAuth.functions.authorize; // GET /oauth/authorize +mcpAuth.functions.token; // POST /oauth/token +mcpAuth.functions.register; // POST /oauth/register + +// Inject env vars into your MCP RPC handler Lambda +mcpAuth.mcpEnvVars; // { MCP_API_DOMAIN, MCP_AUTHORIZE_ENDPOINT, ... } +``` + +### Runtime Module (`src/mcp-auth/`) + +The `mcp-auth` package provides runtime utilities for building MCP tool servers: + +#### MCP JSON-RPC Server + +```typescript +import { createMcpServer } from 'cdk-serverless/mcp-auth'; + +interface MyPrincipal { + userId: string; + email: string; +} + +const server = createMcpServer({ + serverInfo: { name: 'my-server', version: '1.0.0' }, + protocolVersions: ['2025-11-25', '2025-03-26', '2024-11-05'], + resolver: { + async resolve(headers) { + // Validate Bearer token, return principal or throw McpUnauthorizedError + const token = headers.authorization?.replace('Bearer ', ''); + if (!token) throw new McpUnauthorizedError('Missing token', 'Bearer'); + return verifyToken(token); + }, + }, + tools: [ + { + name: 'get_items', + description: 'List items for the authenticated user', + inputSchema: { type: 'object', properties: { limit: { type: 'number' } } }, + async invoke(principal, args) { + const items = await getItemsForUser(principal.userId); + return { content: [{ type: 'text', text: JSON.stringify(items) }] }; + }, + }, + ], +}); + +// In your Lambda handler: +export const handler = async (event: APIGatewayProxyEvent) => { + const body = JSON.parse(event.body ?? '{}'); + const headers = event.headers as Record; + const response = await server.handle(body, headers); + return response; +}; +``` + +#### Key Types + +```typescript +// Tool definition — register tools with the server +interface McpToolDefinition { + name: string; + description: string | ((principal: TPrincipal) => Promise); + inputSchema: object; // JSON Schema + invoke(principal: TPrincipal, args: unknown): Promise; +} + +// Credential resolver — authenticate incoming requests +interface McpCredentialResolver { + resolve(headers: Record): Promise; +} + +// Server options +interface McpServerOptions { + serverInfo: { name: string; version: string }; + protocolVersions: string[]; + resolver: McpCredentialResolver; + tools: Array>; +} + +// Runtime config (read from Lambda env vars) +interface McpAuthConfig { + apiDomain: string; + authorizeEndpoint: string; + tokenEndpoint: string; + allowedRedirectUris: string[]; + clientId: string; + serverInfo: { name: string; version: string }; + protocolVersions: string[]; + scopes?: string[]; + stripParameters?: string[]; +} +``` + +The server handles the JSON-RPC protocol (`initialize`, `notifications/initialized`, `tools/list`, `tools/call`), credential resolution via the `McpCredentialResolver`, and per-tool dispatch with typed principals. + ## Common Workflows ### Creating a New Serverless Project diff --git a/src/constructs/index.ts b/src/constructs/index.ts index 4af14b55..18c39869 100644 --- a/src/constructs/index.ts +++ b/src/constructs/index.ts @@ -3,6 +3,8 @@ export * from './authentication'; export * from './base-api'; export * from './func'; export * from './graphql'; +export * from './mcp-auth'; +export * from './mcp-cognito-auth'; export * from './rest-api'; export * from './table'; -export * from './workflow'; \ No newline at end of file +export * from './workflow'; diff --git a/src/constructs/mcp-auth.ts b/src/constructs/mcp-auth.ts new file mode 100644 index 00000000..51f78b86 --- /dev/null +++ b/src/constructs/mcp-auth.ts @@ -0,0 +1,206 @@ +import * as path from 'node:path'; +import { Duration } from 'aws-cdk-lib'; +import { Construct } from 'constructs'; +import { LambdaFunction, LambdaOptions } from './func'; + +/** + * Configuration for the MCP Auth CDK construct. + * Service-agnostic: works with any OAuth2/OIDC provider. + */ +export interface McpAuthProps { + /** + * The API domain that acts as the MCP OAuth issuer. + * All OAuth discovery endpoints will reference this domain. + * Example: 'api.example.com' + */ + readonly apiDomain: string; + + /** + * The upstream OAuth authorize endpoint URL. + * The authorize Lambda proxies (302 redirects) to this URL. + * Example: 'https://auth.example.com/oauth2/authorize' + */ + readonly authorizeEndpoint: string; + + /** + * The upstream OAuth token endpoint URL. + * The token Lambda proxies POST requests to this URL. + * Example: 'https://auth.example.com/oauth2/token' + */ + readonly tokenEndpoint: string; + + /** + * Allowed redirect URIs for dynamic client registration. + * Clients registering with URIs not in this list are rejected. + */ + readonly allowedRedirectUris: string[]; + + /** + * The pre-provisioned OAuth client ID returned by the register endpoint. + */ + readonly clientId: string; + + /** + * MCP server info returned in the 'initialize' response. + */ + readonly serverInfo: { readonly name: string; readonly version: string }; + + /** + * Supported MCP protocol versions (newest first). + * @default ['2025-11-25', '2025-03-26', '2024-11-05'] + */ + readonly protocolVersions?: string[]; + + /** + * OAuth scopes to advertise in discovery metadata. + * @default ['openid', 'email', 'profile'] + */ + readonly scopes?: string[]; + + /** + * Parameters to strip from authorize/token proxy requests. + * @default ['resource'] + */ + readonly stripParameters?: string[]; + + /** + * Additional environment variables to inject into all MCP auth Lambda functions. + */ + readonly additionalEnv?: Record; + + /** + * Lambda function options for MCP auth handlers. + */ + readonly lambdaOptions?: LambdaOptions; + + /** + * Stage name for function naming and tagging. + */ + readonly stageName: string; +} + +/** + * Provides the Lambda function references for MCP OAuth endpoints. + * These can be wired into API Gateway (via RestApi integration) + * or used standalone. + */ +export interface McpAuthFunctions { + /** GET /.well-known/oauth-protected-resource */ + readonly protectedResource: LambdaFunction; + /** GET /.well-known/oauth-authorization-server */ + readonly authorizationServer: LambdaFunction; + /** GET /oauth/authorize */ + readonly authorize: LambdaFunction; + /** POST /oauth/token */ + readonly token: LambdaFunction; + /** POST /oauth/register */ + readonly register: LambdaFunction; +} + +/** + * CDK construct that creates Lambda functions for the MCP OAuth façade. + * Service-agnostic — proxies authorize/token requests to any upstream + * OAuth2/OIDC provider. + * + * Use with `RestApi` by passing `mcpAuth` prop, which auto-injects the + * required paths into the OpenAPI spec and wires Lambda integrations. + * + * @example + * const mcpAuth = new McpAuth(this, 'McpAuth', { + * apiDomain: 'api.example.com', + * authorizeEndpoint: 'https://auth.example.com/oauth2/authorize', + * tokenEndpoint: 'https://auth.example.com/oauth2/token', + * allowedRedirectUris: ['https://claude.ai/oauth/callback'], + * clientId: 'my-client-id', + * serverInfo: { name: 'my-mcp-server', version: '1.0.0' }, + * stageName: 'dev', + * }); + */ +export class McpAuth extends Construct { + + /** + * The Lambda functions created for the OAuth endpoints. + */ + public readonly functions: McpAuthFunctions; + + /** + * The environment variables map that should be injected into the MCP RPC + * handler Lambda (if you have one) so it can read MCP config at runtime. + */ + public readonly mcpEnvVars: Record; + + constructor(scope: Construct, id: string, props: McpAuthProps) { + super(scope, id); + + const env: Record = { + MCP_API_DOMAIN: props.apiDomain, + MCP_AUTHORIZE_ENDPOINT: props.authorizeEndpoint, + MCP_TOKEN_ENDPOINT: props.tokenEndpoint, + MCP_CLIENT_ID: props.clientId, + MCP_SERVER_NAME: props.serverInfo.name, + MCP_SERVER_VERSION: props.serverInfo.version, + MCP_PROTOCOL_VERSIONS: (props.protocolVersions ?? ['2025-11-25', '2025-03-26', '2024-11-05']).join(','), + MCP_SCOPES: (props.scopes ?? ['openid', 'email', 'profile']).join(','), + MCP_ALLOWED_REDIRECT_URIS: props.allowedRedirectUris.join(','), + MCP_STRIP_PARAMETERS: (props.stripParameters ?? ['resource']).join(','), + ...props.additionalEnv, + }; + + this.mcpEnvVars = env; + + const handlersDir = path.join(__dirname, '..', 'mcp-auth', 'lambda-handlers'); + + const baseLambdaOptions: LambdaOptions = { + timeout: Duration.seconds(15), + ...props.lambdaOptions, + }; + + const protectedResource = new LambdaFunction(this, 'ProtectedResourceFn', { + stageName: props.stageName, + entry: path.join(handlersDir, 'protected-resource.handler.ts'), + description: `[${props.stageName}] MCP OAuth Protected Resource`, + additionalEnv: env, + lambdaOptions: baseLambdaOptions, + }); + + const authorizationServer = new LambdaFunction(this, 'AuthorizationServerFn', { + stageName: props.stageName, + entry: path.join(handlersDir, 'authorization-server.handler.ts'), + description: `[${props.stageName}] MCP OAuth Authorization Server`, + additionalEnv: env, + lambdaOptions: baseLambdaOptions, + }); + + const authorize = new LambdaFunction(this, 'AuthorizeFn', { + stageName: props.stageName, + entry: path.join(handlersDir, 'authorize.handler.ts'), + description: `[${props.stageName}] MCP OAuth Authorize Proxy`, + additionalEnv: env, + lambdaOptions: baseLambdaOptions, + }); + + const token = new LambdaFunction(this, 'TokenFn', { + stageName: props.stageName, + entry: path.join(handlersDir, 'token.handler.ts'), + description: `[${props.stageName}] MCP OAuth Token Proxy`, + additionalEnv: env, + lambdaOptions: baseLambdaOptions, + }); + + const register = new LambdaFunction(this, 'RegisterFn', { + stageName: props.stageName, + entry: path.join(handlersDir, 'register.handler.ts'), + description: `[${props.stageName}] MCP OAuth Register`, + additionalEnv: env, + lambdaOptions: baseLambdaOptions, + }); + + this.functions = { + protectedResource, + authorizationServer, + authorize, + token, + register, + }; + } +} diff --git a/src/constructs/mcp-cognito-auth.ts b/src/constructs/mcp-cognito-auth.ts new file mode 100644 index 00000000..a17ef3a8 --- /dev/null +++ b/src/constructs/mcp-cognito-auth.ts @@ -0,0 +1,170 @@ +import { aws_cognito } from 'aws-cdk-lib'; +import { Construct } from 'constructs'; +import { CognitoAuthentication } from './authentication'; +import { LambdaOptions } from './func'; +import { McpAuth } from './mcp-auth'; + +/** + * Props for creating an McpAuth backed by a Cognito User Pool. + * This is a convenience wrapper — use McpAuth directly for non-Cognito providers. + */ +export interface McpCognitoAuthProps { + /** + * The CognitoAuthentication construct that manages the user pool. + * A dedicated user pool client will be created for MCP connectors. + */ + readonly auth: CognitoAuthentication; + + /** + * The API domain that acts as the MCP OAuth issuer. + * All discovery endpoints will reference this domain. + * Example: 'api.example.com' + */ + readonly apiDomain: string; + + /** + * The Cognito auth domain (custom domain or Cognito-hosted domain). + * Authorize and token requests are proxied here. + * Example: 'auth.example.com' or 'myapp.auth.eu-central-1.amazoncognito.com' + */ + readonly authDomain: string; + + /** + * Allowed redirect URIs for dynamic client registration. + * @default ['https://claude.ai/oauth/callback', 'https://claude.ai/api/mcp/auth_callback', 'https://chatgpt.com/oauth/callback'] + */ + readonly allowedRedirectUris?: string[]; + + /** + * MCP server info returned in the 'initialize' response. + */ + readonly serverInfo: { readonly name: string; readonly version: string }; + + /** + * Supported MCP protocol versions (newest first). + * @default ['2025-11-25', '2025-03-26', '2024-11-05'] + */ + readonly protocolVersions?: string[]; + + /** + * OAuth scopes to advertise in discovery metadata. + * Note: Do NOT include custom resource server scopes here — + * Cognito Managed Login v2 requires RFC 8707 resource parameter + * for custom scopes which MCP clients cannot supply. + * @default ['openid', 'email', 'profile'] + */ + readonly scopes?: string[]; + + /** + * Additional environment variables for MCP auth Lambda functions. + */ + readonly additionalEnv?: Record; + + /** + * Lambda function options for MCP auth handlers. + */ + readonly lambdaOptions?: LambdaOptions; + + /** + * Stage name for function naming and tagging. + */ + readonly stageName: string; + + /** + * Construct ID for the user pool client (affects CloudFormation logical ID). + * @default 'mcpConnector' + */ + readonly clientConstructId?: string; + + /** + * Additional OAuth callback URLs to configure on the Cognito client + * beyond the allowedRedirectUris. Useful if the Cognito client needs + * URLs that aren't in the MCP registration allowlist. + */ + readonly additionalCallbackUrls?: string[]; +} + +/** + * Convenience construct that creates an McpAuth backed by Amazon Cognito. + * + * It creates a dedicated Cognito user pool client configured for the + * authorization code + PKCE flow and wires it into the generic McpAuth + * construct. Use this when your authentication is managed by Cognito; + * use McpAuth directly for other OAuth2/OIDC providers. + * + * @example + * const mcpAuth = new McpCognitoAuth(this, 'McpAuth', { + * auth: cognitoAuthentication, + * apiDomain: 'api.example.com', + * authDomain: 'auth.example.com', + * serverInfo: { name: 'my-mcp-server', version: '1.0.0' }, + * stageName: 'dev', + * }); + * + * const api = new MyApiRestApi(this, 'Api', { + * mcpAuth: mcpAuth.mcpAuth, + * // ... + * }); + */ +export class McpCognitoAuth extends Construct { + + /** + * The underlying service-agnostic McpAuth construct. + * Pass this to RestApi's `mcpAuth` prop. + */ + public readonly mcpAuth: McpAuth; + + /** + * The Cognito user pool client created for MCP connectors. + */ + public readonly userPoolClient: aws_cognito.UserPoolClient; + + constructor(scope: Construct, id: string, props: McpCognitoAuthProps) { + super(scope, id); + + const clientId = props.clientConstructId ?? 'mcpConnector'; + + const allowedRedirectUris = props.allowedRedirectUris ?? [ + 'https://claude.ai/oauth/callback', + 'https://claude.ai/api/mcp/auth_callback', + 'https://chatgpt.com/oauth/callback', + ]; + + // Create a Cognito user pool client with auth code grant + PKCE + this.userPoolClient = props.auth.addUserPoolClient(clientId, { + generateSecret: false, + oAuth: { + flows: { + authorizationCodeGrant: true, + }, + scopes: [ + aws_cognito.OAuthScope.OPENID, + aws_cognito.OAuthScope.EMAIL, + aws_cognito.OAuthScope.PROFILE, + ], + callbackUrls: [ + ...allowedRedirectUris, + ...(props.additionalCallbackUrls ?? []), + ], + }, + authFlows: { + userSrp: true, + }, + }); + + // Create the generic McpAuth, deriving endpoints from authDomain + this.mcpAuth = new McpAuth(this, 'McpAuth', { + apiDomain: props.apiDomain, + authorizeEndpoint: `https://${props.authDomain}/oauth2/authorize`, + tokenEndpoint: `https://${props.authDomain}/oauth2/token`, + allowedRedirectUris, + clientId: this.userPoolClient.userPoolClientId, + serverInfo: props.serverInfo, + protocolVersions: props.protocolVersions, + scopes: props.scopes, + additionalEnv: props.additionalEnv, + lambdaOptions: props.lambdaOptions, + stageName: props.stageName, + }); + } +} diff --git a/src/constructs/rest-api.ts b/src/constructs/rest-api.ts index 9f4d5099..633dda1f 100644 --- a/src/constructs/rest-api.ts +++ b/src/constructs/rest-api.ts @@ -10,11 +10,118 @@ import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; import * as yaml from 'js-yaml'; import { OpenAPI3, OperationObject, PathItemObject } from 'openapi-typescript'; -import { ICognitoAuthentication, IJwtAuthentication } from './authentication'; +import { CognitoAuthentication, ICognitoAuthentication, IJwtAuthentication } from './authentication'; import { BaseApi, BaseApiProps } from './base-api'; import { LambdaFunction, LambdaOptions } from './func'; +import { McpAuth } from './mcp-auth'; +import { McpCognitoAuth } from './mcp-cognito-auth'; import { CFN_OUTPUT_SUFFIX_RESTAPI_DOMAINNAME, CFN_OUTPUT_SUFFIX_RESTAPI_URL } from '../shared/outputs'; +/** + * Cognito-specific MCP auth configuration. + * Creates a dedicated user pool client for MCP connectors automatically. + */ +export interface McpAuthCognitoOptions { + /** + * The CognitoAuthentication construct managing the user pool. + * A dedicated client with auth code + PKCE will be created. + */ + readonly auth: CognitoAuthentication; + + /** + * The Cognito auth domain (custom or Cognito-hosted). + * Required — never inferred from apiDomain. + * Example: 'auth.example.com' + */ + readonly authDomain: string; + + /** + * Construct ID for the user pool client (affects CloudFormation logical ID). + * @default 'mcpConnector' + */ + readonly clientConstructId?: string; + + /** + * Additional OAuth callback URLs for the Cognito client beyond the allowedRedirectUris. + */ + readonly additionalCallbackUrls?: string[]; +} + +/** + * Generic (non-Cognito) MCP auth configuration. + * Requires explicit authorize and token endpoint URLs. + */ +export interface McpAuthGenericOptions { + /** + * The upstream OAuth authorize endpoint URL. + * Example: 'https://auth.example.com/oauth2/authorize' + */ + readonly authorizeEndpoint: string; + + /** + * The upstream OAuth token endpoint URL. + * Example: 'https://auth.example.com/oauth2/token' + */ + readonly tokenEndpoint: string; + + /** + * The pre-provisioned OAuth client ID returned by the register endpoint. + */ + readonly clientId: string; +} + +/** + * MCP auth configuration for the RestApi. + * Provide either `cognito` (for Cognito-backed auth) or `generic` (for any OAuth2 provider). + */ +export interface McpAuthOptions { + /** + * Cognito-specific configuration. Mutually exclusive with `generic`. + * Creates a user pool client and derives endpoints automatically. + */ + readonly cognito?: McpAuthCognitoOptions; + + /** + * Generic OAuth2 provider configuration. Mutually exclusive with `cognito`. + * Requires explicit endpoint URLs and client ID. + */ + readonly generic?: McpAuthGenericOptions; + + /** + * MCP server info returned in the 'initialize' response. + */ + readonly serverInfo: { readonly name: string; readonly version: string }; + + /** + * Allowed redirect URIs for dynamic client registration. + * @default ['https://claude.ai/oauth/callback', 'https://claude.ai/api/mcp/auth_callback', 'https://chatgpt.com/oauth/callback'] + */ + readonly allowedRedirectUris?: string[]; + + /** + * Supported MCP protocol versions (newest first). + * @default ['2025-11-25', '2025-03-26', '2024-11-05'] + */ + readonly protocolVersions?: string[]; + + /** + * OAuth scopes to advertise in discovery metadata. + * @default ['openid', 'email', 'profile'] + */ + readonly scopes?: string[]; + + /** + * Parameters to strip from authorize/token proxy requests. + * @default ['resource'] + */ + readonly stripParameters?: string[]; + + /** + * Lambda function options for MCP auth handlers. + */ + readonly lambdaOptions?: LambdaOptions; +} + export interface RestApiProps extends BaseApiProps { /** @@ -74,6 +181,27 @@ export interface RestApiProps extends BaseApiProps { */ jwtAuthorizerType?: 'token' | 'request'; + /** + * MCP Auth configuration to wire into this API. + * When provided, the following paths are automatically added to the OpenAPI spec + * with Lambda proxy integrations: + * + * - GET /.well-known/oauth-protected-resource + * - GET /.well-known/oauth-authorization-server + * - GET /oauth/authorize + * - POST /oauth/token + * - POST /oauth/register + * + * All MCP auth endpoints are anonymous (no authorizer). + * The API domain is derived from the RestApi's own domain configuration. + * + * Provide either `cognito` (creates a Cognito client automatically) or + * `generic` (for any OAuth2 provider with explicit endpoint URLs). + * + * @default - no MCP auth + */ + mcpAuth?: McpAuthOptions; + /** * Global OAuth2 scopes required by the Cognito authorizer. * @@ -158,6 +286,12 @@ export class RestApi extends BaseApi { */ private _authorizerFn?: LambdaFunction; + /** + * The MCP auth construct, if mcpAuth options were provided. + * @private + */ + private _mcpAuth?: McpAuth; + /** * Set of operationIds that should have security disabled (security: []). * @private @@ -326,6 +460,14 @@ export class RestApi extends BaseApi { } } + // Inject MCP auth paths into the OpenAPI spec. + // IMPORTANT: This MUST happen before patchSecurity() so that the MCP auth + // operation IDs are already in _anonymousOperations when security is distributed. + if (props.mcpAuth) { + this._mcpAuth = this.createMcpAuth(props.mcpAuth); + this.injectMcpAuthPaths(this._mcpAuth); + } + this.patchSecurity(this.apiSpec); // TODO patch spec for Cognito user pool @@ -355,6 +497,21 @@ export class RestApi extends BaseApi { }); } + // Grant API Gateway permission to invoke MCP auth Lambda functions. + // These need explicit addPermission because they are injected directly into + // the OpenAPI spec (via injectMcpAuthPaths), not through addRestResource which + // would register them in this._functions for the bulk grant above. + if (this._mcpAuth) { + const mcpFunctions = this._mcpAuth.functions; + for (const [name, fn] of Object.entries(mcpFunctions)) { + fn.addPermission(`McpAuth${name}Invoke`, { + principal: new aws_iam.ServicePrincipal('apigateway.amazonaws.com'), + action: 'lambda:InvokeFunction', + sourceArn: this.api.arnForExecuteApi(), + }); + } + } + if (customDomainName && this.api.domainName) { new aws_route53.ARecord(this, 'DnsRecord', { zone: this.hostedZone!, @@ -674,6 +831,155 @@ export class RestApi extends BaseApi { } } + /** + * Returns the MCP auth construct if mcpAuth was configured. + * Useful for accessing the created Lambda functions or env vars. + */ + public getMcpAuth(): McpAuth | undefined { + return this._mcpAuth; + } + + /** + * Creates the McpAuth construct from the provided options. + * Derives apiDomain from the RestApi's own domain config. + * For Cognito, creates a user pool client automatically. + */ + private createMcpAuth(options: McpAuthOptions): McpAuth { + const apiDomain = this.apiFQDN; + if (!apiDomain) { + throw new Error('mcpAuth requires a domain name to be configured on the RestApi (domainName or hostedZone + apiHostname)'); + } + + if (options.cognito && options.generic) { + throw new Error('mcpAuth: provide either cognito or generic, not both'); + } + if (!options.cognito && !options.generic) { + throw new Error('mcpAuth: provide either cognito or generic configuration'); + } + + const allowedRedirectUris = options.allowedRedirectUris ?? [ + 'https://claude.ai/oauth/callback', + 'https://claude.ai/api/mcp/auth_callback', + 'https://chatgpt.com/oauth/callback', + ]; + + if (options.cognito) { + const cognitoOpts = options.cognito; + + // Delegate to McpCognitoAuth which handles user pool client creation + const mcpCognito = new McpCognitoAuth(this, 'McpCognitoAuth', { + auth: cognitoOpts.auth, + apiDomain, + authDomain: cognitoOpts.authDomain, + allowedRedirectUris, + serverInfo: options.serverInfo, + protocolVersions: options.protocolVersions, + scopes: options.scopes, + lambdaOptions: options.lambdaOptions, + stageName: this.props.stageName, + clientConstructId: cognitoOpts.clientConstructId, + additionalCallbackUrls: cognitoOpts.additionalCallbackUrls, + }); + + return mcpCognito.mcpAuth; + } else { + const genericOpts = options.generic!; + + return new McpAuth(this, 'McpAuth', { + apiDomain, + authorizeEndpoint: genericOpts.authorizeEndpoint, + tokenEndpoint: genericOpts.tokenEndpoint, + allowedRedirectUris, + clientId: genericOpts.clientId, + serverInfo: options.serverInfo, + protocolVersions: options.protocolVersions, + scopes: options.scopes, + stripParameters: options.stripParameters, + lambdaOptions: options.lambdaOptions, + stageName: this.props.stageName, + }); + } + } + + /** + * Injects MCP OAuth paths into the OpenAPI spec with Lambda proxy integrations + * pointing to the MCP auth construct's functions. All paths are anonymous (security: []). + */ + private injectMcpAuthPaths(mcpAuth: McpAuth) { + const fns = mcpAuth.functions; + + const mcpPaths: Record = { + '/.well-known/oauth-protected-resource': { + fn: fns.protectedResource, + method: 'get', + operationId: 'mcpOAuthProtectedResource', + summary: 'OAuth Protected Resource Metadata (RFC 9728)', + }, + '/.well-known/oauth-authorization-server': { + fn: fns.authorizationServer, + method: 'get', + operationId: 'mcpOAuthAuthorizationServer', + summary: 'OAuth Authorization Server Metadata (RFC 8414)', + }, + '/oauth/authorize': { + fn: fns.authorize, + method: 'get', + operationId: 'mcpOAuthAuthorize', + summary: 'OAuth Authorize Proxy', + }, + '/oauth/token': { + fn: fns.token, + method: 'post', + operationId: 'mcpOAuthToken', + summary: 'OAuth Token Proxy', + }, + '/oauth/register': { + fn: fns.register, + method: 'post', + operationId: 'mcpOAuthRegister', + summary: 'OAuth Dynamic Client Registration (RFC 7591)', + }, + }; + + for (const [path, config] of Object.entries(mcpPaths)) { + if (!this.apiSpec.paths) { + this.apiSpec.paths = {}; + } + + const operation: any = { + 'summary': config.summary, + 'operationId': config.operationId, + 'security': [], // Anonymous — no authorizer + 'responses': { + 200: { description: 'Success' }, + 302: { description: 'Redirect' }, + }, + 'x-amazon-apigateway-integration': { + type: 'aws_proxy', + httpMethod: 'POST', + uri: cdk.Stack.of(this).formatArn({ + resource: 'path', + service: 'apigateway', + account: 'lambda', + arnFormat: cdk.ArnFormat.SLASH_RESOURCE_NAME, + resourceName: `2015-03-31/functions/${config.fn.functionArn}/invocations`, + }), + passthroughBehavior: 'when_no_templates', + payloadFormatVersion: '1.0', + }, + }; + + if (!this.apiSpec.paths[path]) { + this.apiSpec.paths[path] = {}; + } + + (this.apiSpec.paths[path] as any)[config.method] = operation; + + // Mark as anonymous so patchSecurity doesn't override it + this._anonymousOperations.add(config.operationId); + } + } + /** * Returns the security-scheme container for the spec, creating it if needed. * OpenAPI 3.x stores schemes under `components.securitySchemes`; Swagger 2.0 diff --git a/src/mcp-auth/config.ts b/src/mcp-auth/config.ts new file mode 100644 index 00000000..bfafc14e --- /dev/null +++ b/src/mcp-auth/config.ts @@ -0,0 +1,44 @@ +/** + * Configuration for the MCP OAuth façade. + * Service-agnostic: works with any OAuth2/OIDC provider that exposes + * a standard authorize + token endpoint. + */ +export interface McpAuthConfig { + /** The API domain serving as the OAuth issuer (e.g. 'api.example.com') */ + readonly apiDomain: string; + + /** + * The upstream OAuth authorize endpoint URL. + * The authorize handler proxies requests here (302 redirect). + * Example: 'https://auth.example.com/oauth2/authorize' + */ + readonly authorizeEndpoint: string; + + /** + * The upstream OAuth token endpoint URL. + * The token handler proxies requests here. + * Example: 'https://auth.example.com/oauth2/token' + */ + readonly tokenEndpoint: string; + + /** Allowed redirect URIs for dynamic client registration */ + readonly allowedRedirectUris: string[]; + + /** The pre-provisioned OAuth client ID returned by registration */ + readonly clientId: string; + + /** MCP server info for the initialize response */ + readonly serverInfo: { readonly name: string; readonly version: string }; + + /** Supported MCP protocol versions */ + readonly protocolVersions: string[]; + + /** OAuth scopes to advertise in discovery metadata */ + readonly scopes?: string[]; + + /** + * Parameters to strip from authorize/token requests before proxying. + * Defaults to ['resource'] (RFC 8707 not supported by most providers). + */ + readonly stripParameters?: string[]; +} diff --git a/src/mcp-auth/handlers/authorization-server.ts b/src/mcp-auth/handlers/authorization-server.ts new file mode 100644 index 00000000..08904a51 --- /dev/null +++ b/src/mcp-auth/handlers/authorization-server.ts @@ -0,0 +1,35 @@ +import type { McpAuthConfig } from '../config'; +import type { McpOAuthHandler, McpOAuthResponse } from './types'; + +/** + * Factory for the `/.well-known/oauth-authorization-server` endpoint (RFC 8414). + * Returns OAuth server metadata with all endpoint URLs pointing to the API domain. + */ +export function createAuthorizationServerHandler(config: McpAuthConfig): McpOAuthHandler { + return async (event): Promise => { + console.log(JSON.stringify({ + event: 'oauth-authorization-server', + method: event.httpMethod, + path: event.path, + })); + + const scopes = config.scopes ?? ['openid', 'email', 'profile']; + const body = { + issuer: `https://${config.apiDomain}`, + authorization_endpoint: `https://${config.apiDomain}/oauth/authorize`, + token_endpoint: `https://${config.apiDomain}/oauth/token`, + registration_endpoint: `https://${config.apiDomain}/oauth/register`, + scopes_supported: scopes, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none'], + }; + + return { + statusCode: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }; + }; +} diff --git a/src/mcp-auth/handlers/authorize.ts b/src/mcp-auth/handlers/authorize.ts new file mode 100644 index 00000000..5dfe385b --- /dev/null +++ b/src/mcp-auth/handlers/authorize.ts @@ -0,0 +1,35 @@ +import type { McpAuthConfig } from '../config'; +import type { McpOAuthHandler, McpOAuthResponse } from './types'; + +/** + * Factory for the `/oauth/authorize` proxy endpoint. + * Strips configured parameters (default: 'resource') before redirecting + * to the upstream authorization endpoint. + */ +export function createAuthorizeHandler(config: McpAuthConfig): McpOAuthHandler { + return async (event): Promise => { + const params = new URLSearchParams(event.queryStringParameters as Record); + + // Strip unsupported parameters (e.g. RFC 8707 'resource') + const stripParams = config.stripParameters ?? ['resource']; + for (const param of stripParams) { + params.delete(param); + } + + const upstreamUrl = `${config.authorizeEndpoint}?${params.toString()}`; + + console.log(JSON.stringify({ + event: 'oauth-authorize-proxy', + upstreamUrl, + strippedParams: stripParams.filter(p => event.queryStringParameters?.[p]), + })); + + return { + statusCode: 302, + headers: { + 'Location': upstreamUrl, + 'Cache-Control': 'no-cache, no-store', + }, + }; + }; +} diff --git a/src/mcp-auth/handlers/index.ts b/src/mcp-auth/handlers/index.ts new file mode 100644 index 00000000..8256e28a --- /dev/null +++ b/src/mcp-auth/handlers/index.ts @@ -0,0 +1,7 @@ +export * from './types'; +export * from './authorize'; +export * from './authorization-server'; +export * from './protected-resource'; +export * from './register'; +export * from './token'; +export * from './validate-redirect-uris'; diff --git a/src/mcp-auth/handlers/protected-resource.ts b/src/mcp-auth/handlers/protected-resource.ts new file mode 100644 index 00000000..138aab46 --- /dev/null +++ b/src/mcp-auth/handlers/protected-resource.ts @@ -0,0 +1,27 @@ +import type { McpAuthConfig } from '../config'; +import type { McpOAuthHandler, McpOAuthResponse } from './types'; + +/** + * Factory for the `/.well-known/oauth-protected-resource` endpoint (RFC 9728). + * Returns the resource identifier and authorization servers. + */ +export function createProtectedResourceHandler(config: McpAuthConfig): McpOAuthHandler { + return async (event): Promise => { + console.log(JSON.stringify({ + event: 'oauth-protected-resource', + method: event.httpMethod, + path: event.path, + })); + + const body = { + resource: `https://${config.apiDomain}/mcp`, + authorization_servers: [`https://${config.apiDomain}`], + }; + + return { + statusCode: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }; + }; +} diff --git a/src/mcp-auth/handlers/register.ts b/src/mcp-auth/handlers/register.ts new file mode 100644 index 00000000..2c3cfe9f --- /dev/null +++ b/src/mcp-auth/handlers/register.ts @@ -0,0 +1,75 @@ +import type { McpAuthConfig } from '../config'; +import type { McpOAuthHandler, McpOAuthResponse } from './types'; +import { validateRedirectUris } from './validate-redirect-uris'; + +/** + * Factory for the `/oauth/register` endpoint (RFC 7591 simplified). + * Validates redirect URIs against the allowlist and returns the pre-provisioned client_id. + */ +export function createRegisterHandler(config: McpAuthConfig): McpOAuthHandler { + return async (event): Promise => { + console.log(JSON.stringify({ + event: 'register-oauth-client', + method: event.httpMethod, + path: event.path, + })); + + // Parse body + const rawBody = event.isBase64Encoded + ? Buffer.from(event.body ?? '', 'base64').toString('utf-8') + : (event.body ?? ''); + + let data: Record; + try { + data = typeof rawBody === 'string' ? JSON.parse(rawBody) : rawBody; + } catch { + return { + statusCode: 400, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ error: 'invalid_request', error_description: 'Malformed JSON body' }), + }; + } + + if (data === null || typeof data !== 'object' || Array.isArray(data)) { + return { + statusCode: 400, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ error: 'invalid_request', error_description: 'Request body must be a JSON object' }), + }; + } + + const redirectUris = data.redirect_uris as string[] | undefined; + const clientName = (data.client_name as string) ?? 'MCP Connector'; + + if (!redirectUris || !Array.isArray(redirectUris) || redirectUris.length === 0) { + return { + statusCode: 400, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ error: 'invalid_request', error_description: 'redirect_uris is required' }), + }; + } + + if (!validateRedirectUris(redirectUris, config.allowedRedirectUris)) { + return { + statusCode: 400, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ error: 'invalid_redirect_uri', error_description: 'One or more redirect_uris are not allowed' }), + }; + } + + const response = { + client_id: config.clientId, + client_name: clientName, + redirect_uris: redirectUris, + token_endpoint_auth_method: 'none', + }; + + console.log(JSON.stringify({ event: 'register-oauth-client-response', status: 201, body: response })); + + return { + statusCode: 201, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(response), + }; + }; +} diff --git a/src/mcp-auth/handlers/token.ts b/src/mcp-auth/handlers/token.ts new file mode 100644 index 00000000..82f2f830 --- /dev/null +++ b/src/mcp-auth/handlers/token.ts @@ -0,0 +1,80 @@ +import type { McpAuthConfig } from '../config'; +import type { McpOAuthHandler, McpOAuthResponse } from './types'; + +const ALLOWED_GRANT_TYPES = ['authorization_code', 'refresh_token']; + +/** + * Factory for the `/oauth/token` proxy endpoint. + * Forwards token exchange to the upstream token endpoint, + * stripping configured parameters from the request body. + */ +export function createTokenHandler(config: McpAuthConfig): McpOAuthHandler { + return async (event): Promise => { + const tokenUrl = config.tokenEndpoint; + + // Decode body if base64-encoded + const rawBody = event.isBase64Encoded + ? Buffer.from(event.body ?? '', 'base64').toString('utf-8') + : (event.body ?? ''); + + // Parse form-encoded body and strip unsupported parameters + const params = new URLSearchParams(rawBody); + const stripParamsList = config.stripParameters ?? ['resource']; + for (const param of stripParamsList) { + params.delete(param); + } + + // Validate grant_type + const grantType = params.get('grant_type'); + if (!grantType || !ALLOWED_GRANT_TYPES.includes(grantType)) { + return { + statusCode: 400, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ error: 'unsupported_grant_type', error_description: `grant_type must be one of: ${ALLOWED_GRANT_TYPES.join(', ')}` }), + }; + } + + const body = params.toString(); + + console.log(JSON.stringify({ + event: 'oauth-token-proxy', + tokenUrl, + grantType, + })); + + let responseBody: string; + let statusCode: number; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + try { + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + signal: controller.signal, + }); + responseBody = await response.text(); + statusCode = response.status; + } catch (err) { + console.log(JSON.stringify({ event: 'oauth-token-proxy-error', error: (err as Error).message })); + return { + statusCode: 502, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ error: 'upstream_error', error_description: 'Token endpoint unreachable' }), + }; + } finally { + clearTimeout(timeout); + } + + console.log(JSON.stringify({ + event: 'oauth-token-proxy-response', + status: statusCode, + })); + + return { + statusCode, + headers: { 'Content-Type': 'application/json' }, + body: responseBody, + }; + }; +} diff --git a/src/mcp-auth/handlers/types.ts b/src/mcp-auth/handlers/types.ts new file mode 100644 index 00000000..6040203e --- /dev/null +++ b/src/mcp-auth/handlers/types.ts @@ -0,0 +1,23 @@ +/** + * A generic Lambda-like handler: receives an event (simplified) and returns + * a response with statusCode, headers, and body. + */ +export interface McpOAuthResponse { + statusCode: number; + headers: Record; + body?: string; +} + +export interface McpOAuthEvent { + httpMethod: string; + path: string; + headers: Record; + queryStringParameters: Record | null; + body: string | null; + isBase64Encoded: boolean; +} + +/** + * Handler function signature returned by all factories. + */ +export type McpOAuthHandler = (event: McpOAuthEvent) => Promise; diff --git a/src/mcp-auth/handlers/validate-redirect-uris.ts b/src/mcp-auth/handlers/validate-redirect-uris.ts new file mode 100644 index 00000000..ba3b9001 --- /dev/null +++ b/src/mcp-auth/handlers/validate-redirect-uris.ts @@ -0,0 +1,18 @@ +/** + * Default allowlisted redirect URIs for MCP connector registration. + * These cover the most common MCP-compatible clients. + */ +export const DEFAULT_ALLOWED_REDIRECT_URIS: readonly string[] = [ + 'https://claude.ai/oauth/callback', + 'https://claude.ai/api/mcp/auth_callback', + 'https://chatgpt.com/oauth/callback', +]; + +/** + * Returns true if every URI in the list is on the allowlist. + * Uses the provided allowlist or falls back to the default. + */ +export function validateRedirectUris(uris: string[], allowlist?: readonly string[]): boolean { + const list = allowlist ?? DEFAULT_ALLOWED_REDIRECT_URIS; + return uris.every(uri => list.includes(uri)); +} diff --git a/src/mcp-auth/index.ts b/src/mcp-auth/index.ts new file mode 100644 index 00000000..ac69ce9c --- /dev/null +++ b/src/mcp-auth/index.ts @@ -0,0 +1,3 @@ +export * from './config'; +export * from './handlers'; +export * from './mcp'; diff --git a/src/mcp-auth/lambda-handlers/authorization-server.handler.ts b/src/mcp-auth/lambda-handlers/authorization-server.handler.ts new file mode 100644 index 00000000..a9b0dccf --- /dev/null +++ b/src/mcp-auth/lambda-handlers/authorization-server.handler.ts @@ -0,0 +1,23 @@ +import { getConfigFromEnv } from './env-config'; +import { createAuthorizationServerHandler } from '../handlers/authorization-server'; + +const handler = createAuthorizationServerHandler(getConfigFromEnv()); + +export async function lambdaHandler(event: AWSLambda.APIGatewayProxyEvent): Promise { + const result = await handler({ + httpMethod: event.httpMethod, + path: event.path, + headers: event.headers as Record, + queryStringParameters: event.queryStringParameters as Record | null, + body: event.body, + isBase64Encoded: event.isBase64Encoded, + }); + + return { + statusCode: result.statusCode, + headers: result.headers, + body: result.body ?? '', + }; +} + +export { lambdaHandler as handler }; diff --git a/src/mcp-auth/lambda-handlers/authorize.handler.ts b/src/mcp-auth/lambda-handlers/authorize.handler.ts new file mode 100644 index 00000000..4d2f45f7 --- /dev/null +++ b/src/mcp-auth/lambda-handlers/authorize.handler.ts @@ -0,0 +1,23 @@ +import { getConfigFromEnv } from './env-config'; +import { createAuthorizeHandler } from '../handlers/authorize'; + +const handler = createAuthorizeHandler(getConfigFromEnv()); + +export async function lambdaHandler(event: AWSLambda.APIGatewayProxyEvent): Promise { + const result = await handler({ + httpMethod: event.httpMethod, + path: event.path, + headers: event.headers as Record, + queryStringParameters: event.queryStringParameters as Record | null, + body: event.body, + isBase64Encoded: event.isBase64Encoded, + }); + + return { + statusCode: result.statusCode, + headers: result.headers, + body: result.body ?? '', + }; +} + +export { lambdaHandler as handler }; diff --git a/src/mcp-auth/lambda-handlers/env-config.ts b/src/mcp-auth/lambda-handlers/env-config.ts new file mode 100644 index 00000000..7d345c1a --- /dev/null +++ b/src/mcp-auth/lambda-handlers/env-config.ts @@ -0,0 +1,37 @@ +import type { McpAuthConfig } from '../config'; + +const REQUIRED_ENV_VARS = [ + 'MCP_API_DOMAIN', + 'MCP_AUTHORIZE_ENDPOINT', + 'MCP_TOKEN_ENDPOINT', + 'MCP_CLIENT_ID', + 'MCP_SERVER_NAME', + 'MCP_SERVER_VERSION', +] as const; + +/** + * Read MCP auth configuration from environment variables. + * Used by all Lambda handler entry points. + * Throws a descriptive error if any required env var is missing or empty. + */ +export function getConfigFromEnv(): McpAuthConfig { + const missing = REQUIRED_ENV_VARS.filter(name => !process.env[name]); + if (missing.length > 0) { + throw new Error(`MCP Auth: missing required environment variables: ${missing.join(', ')}`); + } + + return { + apiDomain: process.env.MCP_API_DOMAIN!, + authorizeEndpoint: process.env.MCP_AUTHORIZE_ENDPOINT!, + tokenEndpoint: process.env.MCP_TOKEN_ENDPOINT!, + clientId: process.env.MCP_CLIENT_ID!, + allowedRedirectUris: (process.env.MCP_ALLOWED_REDIRECT_URIS ?? '').split(',').filter(Boolean), + serverInfo: { + name: process.env.MCP_SERVER_NAME!, + version: process.env.MCP_SERVER_VERSION!, + }, + protocolVersions: (process.env.MCP_PROTOCOL_VERSIONS ?? '').split(',').filter(Boolean), + scopes: (process.env.MCP_SCOPES ?? '').split(',').filter(Boolean), + stripParameters: (process.env.MCP_STRIP_PARAMETERS ?? 'resource').split(',').filter(Boolean), + }; +} diff --git a/src/mcp-auth/lambda-handlers/protected-resource.handler.ts b/src/mcp-auth/lambda-handlers/protected-resource.handler.ts new file mode 100644 index 00000000..647a4833 --- /dev/null +++ b/src/mcp-auth/lambda-handlers/protected-resource.handler.ts @@ -0,0 +1,23 @@ +import { getConfigFromEnv } from './env-config'; +import { createProtectedResourceHandler } from '../handlers/protected-resource'; + +const handler = createProtectedResourceHandler(getConfigFromEnv()); + +export async function lambdaHandler(event: AWSLambda.APIGatewayProxyEvent): Promise { + const result = await handler({ + httpMethod: event.httpMethod, + path: event.path, + headers: event.headers as Record, + queryStringParameters: event.queryStringParameters as Record | null, + body: event.body, + isBase64Encoded: event.isBase64Encoded, + }); + + return { + statusCode: result.statusCode, + headers: result.headers, + body: result.body ?? '', + }; +} + +export { lambdaHandler as handler }; diff --git a/src/mcp-auth/lambda-handlers/register.handler.ts b/src/mcp-auth/lambda-handlers/register.handler.ts new file mode 100644 index 00000000..7f4431f4 --- /dev/null +++ b/src/mcp-auth/lambda-handlers/register.handler.ts @@ -0,0 +1,23 @@ +import { getConfigFromEnv } from './env-config'; +import { createRegisterHandler } from '../handlers/register'; + +const handler = createRegisterHandler(getConfigFromEnv()); + +export async function lambdaHandler(event: AWSLambda.APIGatewayProxyEvent): Promise { + const result = await handler({ + httpMethod: event.httpMethod, + path: event.path, + headers: event.headers as Record, + queryStringParameters: event.queryStringParameters as Record | null, + body: event.body, + isBase64Encoded: event.isBase64Encoded, + }); + + return { + statusCode: result.statusCode, + headers: result.headers, + body: result.body ?? '', + }; +} + +export { lambdaHandler as handler }; diff --git a/src/mcp-auth/lambda-handlers/token.handler.ts b/src/mcp-auth/lambda-handlers/token.handler.ts new file mode 100644 index 00000000..d7f666b9 --- /dev/null +++ b/src/mcp-auth/lambda-handlers/token.handler.ts @@ -0,0 +1,23 @@ +import { getConfigFromEnv } from './env-config'; +import { createTokenHandler } from '../handlers/token'; + +const handler = createTokenHandler(getConfigFromEnv()); + +export async function lambdaHandler(event: AWSLambda.APIGatewayProxyEvent): Promise { + const result = await handler({ + httpMethod: event.httpMethod, + path: event.path, + headers: event.headers as Record, + queryStringParameters: event.queryStringParameters as Record | null, + body: event.body, + isBase64Encoded: event.isBase64Encoded, + }); + + return { + statusCode: result.statusCode, + headers: result.headers, + body: result.body ?? '', + }; +} + +export { lambdaHandler as handler }; diff --git a/src/mcp-auth/mcp/errors.ts b/src/mcp-auth/mcp/errors.ts new file mode 100644 index 00000000..532bc157 --- /dev/null +++ b/src/mcp-auth/mcp/errors.ts @@ -0,0 +1,17 @@ +export class McpUnauthorizedError extends Error { + public readonly challenge: string; + constructor(challenge: string, message = 'Unauthorized') { + super(message); + this.name = 'McpUnauthorizedError'; + this.challenge = challenge; + } +} + +export class McpProtocolError extends Error { + public readonly code: number; + constructor(code: number, message: string) { + super(message); + this.name = 'McpProtocolError'; + this.code = code; + } +} diff --git a/src/mcp-auth/mcp/index.ts b/src/mcp-auth/mcp/index.ts new file mode 100644 index 00000000..ea0ee07c --- /dev/null +++ b/src/mcp-auth/mcp/index.ts @@ -0,0 +1,4 @@ +export * from './errors'; +export * from './jsonrpc'; +export * from './server'; +export * from './types'; diff --git a/src/mcp-auth/mcp/jsonrpc.ts b/src/mcp-auth/mcp/jsonrpc.ts new file mode 100644 index 00000000..8b184a5e --- /dev/null +++ b/src/mcp-auth/mcp/jsonrpc.ts @@ -0,0 +1,64 @@ +import { McpProtocolError } from './errors'; + +/** Standard JSON-RPC error codes. */ +export const PARSE_ERROR = -32700; +export const INVALID_REQUEST = -32600; +export const METHOD_NOT_FOUND = -32601; +export const INVALID_PARAMS = -32602; +export const INTERNAL_ERROR = -32603; + +export interface JsonRpcRequest { + jsonrpc: '2.0'; + id?: unknown; + method: string; + params?: unknown; +} + +/** + * Validate and parse a JSON-RPC request from a raw body value. + * Throws McpProtocolError if the body is malformed. + */ +export function parseJsonRpcRequest(body: unknown): JsonRpcRequest { + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + throw new McpProtocolError(INVALID_REQUEST, 'Request body must be a JSON object'); + } + + const obj = body as Record; + + if (obj.jsonrpc !== '2.0') { + throw new McpProtocolError(INVALID_REQUEST, 'Invalid or missing jsonrpc version, must be "2.0"'); + } + + if (typeof obj.method !== 'string' || obj.method.length === 0) { + throw new McpProtocolError(INVALID_REQUEST, 'Missing or invalid method field'); + } + + return { + jsonrpc: '2.0', + id: obj.id, + method: obj.method, + params: obj.params, + }; +} + +/** Build a JSON-RPC success response object. */ +export function jsonRpcSuccess(id: unknown, result: unknown): object { + return { + jsonrpc: '2.0', + id: id ?? null, + result, + }; +} + +/** Build a JSON-RPC error response object. */ +export function jsonRpcError(id: unknown, code: number, message: string, data?: unknown): object { + const error: Record = { code, message }; + if (data !== undefined) { + error.data = data; + } + return { + jsonrpc: '2.0', + id: id ?? null, + error, + }; +} diff --git a/src/mcp-auth/mcp/server.ts b/src/mcp-auth/mcp/server.ts new file mode 100644 index 00000000..605d5253 --- /dev/null +++ b/src/mcp-auth/mcp/server.ts @@ -0,0 +1,122 @@ +import { McpUnauthorizedError } from './errors'; +import { jsonRpcError, jsonRpcSuccess, INTERNAL_ERROR, INVALID_PARAMS, METHOD_NOT_FOUND, parseJsonRpcRequest } from './jsonrpc'; +import type { McpResponse, McpServerOptions } from './types'; + +export interface McpServerHandle { + handle(body: unknown, headers: Record): Promise; +} + +/** + * Creates an MCP JSON-RPC server that authenticates callers, + * advertises tools, and dispatches tool invocations. + */ +export function createMcpServer(options: McpServerOptions): McpServerHandle { + return { handle }; + + async function handle(body: unknown, headers: Record): Promise { + // 1. Resolve credentials + let principal: TPrincipal; + try { + principal = await options.resolver.resolve(headers); + } catch (err) { + if (err instanceof McpUnauthorizedError) { + return { + statusCode: 401, + headers: { + 'Content-Type': 'application/json', + 'WWW-Authenticate': err.challenge, + }, + body: JSON.stringify({ error: err.message }), + }; + } + throw err; + } + + // 2. Parse JSON-RPC request + let request; + try { + request = parseJsonRpcRequest(body); + } catch (err: unknown) { + const code = (err as { code?: number }).code ?? -32600; + const message = (err as { message?: string }).message ?? 'Invalid request'; + return jsonResponse(200, jsonRpcError(null, code, message)); + } + + // 3. Dispatch on method + const { id, method, params } = request; + + switch (method) { + case 'initialize': + return handleInitialize(id, params); + case 'notifications/initialized': + return { statusCode: 202, headers: { 'Content-Type': 'application/json' }, body: '' }; + case 'tools/list': + return handleToolsList(id, principal); + case 'tools/call': + return handleToolsCall(id, principal, params); + default: + return jsonResponse(200, jsonRpcError(id, METHOD_NOT_FOUND, `Method not found: ${method}`)); + } + } + + function handleInitialize(id: unknown, params: unknown): McpResponse { + const clientVersion = (params as Record | undefined)?.protocolVersion as string | undefined; + + if (!clientVersion || !options.protocolVersions.includes(clientVersion)) { + return jsonResponse(200, jsonRpcError(id, INVALID_PARAMS, `Unsupported protocol version. Supported: ${options.protocolVersions.join(', ')}`)); + } + + return jsonResponse(200, jsonRpcSuccess(id, { + protocolVersion: clientVersion, + capabilities: { tools: {} }, + serverInfo: options.serverInfo, + })); + } + + async function handleToolsList(id: unknown, principal: TPrincipal): Promise { + const tools = await Promise.all( + options.tools.map(async (tool) => { + const description = typeof tool.description === 'function' + ? await tool.description(principal) + : tool.description; + return { + name: tool.name, + description, + inputSchema: tool.inputSchema, + }; + }), + ); + + return jsonResponse(200, jsonRpcSuccess(id, { tools })); + } + + async function handleToolsCall(id: unknown, principal: TPrincipal, params: unknown): Promise { + const p = params as Record | undefined; + const toolName = p?.name as string | undefined; + + if (!toolName) { + return jsonResponse(200, jsonRpcError(id, INVALID_PARAMS, 'Missing params.name')); + } + + const tool = options.tools.find((t) => t.name === toolName); + if (!tool) { + return jsonResponse(200, jsonRpcError(id, INVALID_PARAMS, `Unknown tool: ${toolName}`)); + } + + try { + const result = await tool.invoke(principal, p?.arguments); + return jsonResponse(200, jsonRpcSuccess(id, result)); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Internal error'; + return jsonResponse(200, jsonRpcError(id, INTERNAL_ERROR, message)); + } + } + + function jsonResponse(statusCode: number, body: object): McpResponse { + return { + statusCode, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }; + } +} diff --git a/src/mcp-auth/mcp/types.ts b/src/mcp-auth/mcp/types.ts new file mode 100644 index 00000000..0fc02b6b --- /dev/null +++ b/src/mcp-auth/mcp/types.ts @@ -0,0 +1,30 @@ +export interface McpToolResult { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +} + +export interface McpToolDefinition { + name: string; + /** Static text, or resolved per-caller (e.g., personalized source hints). */ + description: string | ((principal: TPrincipal) => Promise); + inputSchema: object; // JSON Schema + invoke(principal: TPrincipal, args: unknown): Promise; +} + +export interface McpCredentialResolver { + /** Throws McpUnauthorizedError when credentials are missing/invalid. */ + resolve(headers: Record): Promise; +} + +export interface McpServerOptions { + serverInfo: { name: string; version: string }; + protocolVersions: string[]; // Supported versions, newest first + resolver: McpCredentialResolver; + tools: Array>; +} + +export interface McpResponse { + statusCode: number; + headers: Record; + body: string; +} diff --git a/test/constructs/mcp-auth.test.ts b/test/constructs/mcp-auth.test.ts new file mode 100644 index 00000000..f772b3a1 --- /dev/null +++ b/test/constructs/mcp-auth.test.ts @@ -0,0 +1,246 @@ +import { App, Stack } from 'aws-cdk-lib'; +import { Template, Match } from 'aws-cdk-lib/assertions'; +import { McpAuth } from '../../src/constructs/mcp-auth'; + +// Mock the LambdaFunction to avoid NodejsFunction bundling issues +jest.mock('../../src/constructs/func', () => { + const awsLambda = jest.requireActual('aws-cdk-lib/aws-lambda'); + const { Construct } = jest.requireActual('constructs'); + + class MockLambdaFunction extends awsLambda.Function { + constructor(scope: typeof Construct, id: string, props: any) { + super(scope, id, { + runtime: awsLambda.Runtime.NODEJS_18_X, + handler: 'index.handler', + code: awsLambda.Code.fromInline('exports.handler = async () => {}'), + description: props.description, + environment: props.additionalEnv, + }); + } + } + + return { + LambdaFunction: MockLambdaFunction, + }; +}); + +describe('McpAuth', () => { + let app: App; + let stack: Stack; + + const defaultProps = { + apiDomain: 'api.example.com', + authorizeEndpoint: 'https://auth.example.com/oauth2/authorize', + tokenEndpoint: 'https://auth.example.com/oauth2/token', + allowedRedirectUris: ['https://claude.ai/oauth/callback'], + clientId: 'test-client-id', + serverInfo: { name: 'test-server', version: '1.0.0' }, + stageName: 'dev', + }; + + beforeEach(() => { + app = new App(); + stack = new Stack(app, 'TestStack'); + }); + + test('creates 5 Lambda functions', () => { + new McpAuth(stack, 'McpAuth', defaultProps); + + const template = Template.fromStack(stack); + template.resourceCountIs('AWS::Lambda::Function', 5); + }); + + test('Lambda functions have correct descriptions', () => { + new McpAuth(stack, 'McpAuth', defaultProps); + + const template = Template.fromStack(stack); + const lambdas = template.findResources('AWS::Lambda::Function'); + const descriptions = Object.values(lambdas).map((fn: any) => fn.Properties.Description); + + expect(descriptions).toContain('[dev] MCP OAuth Protected Resource'); + expect(descriptions).toContain('[dev] MCP OAuth Authorization Server'); + expect(descriptions).toContain('[dev] MCP OAuth Authorize Proxy'); + expect(descriptions).toContain('[dev] MCP OAuth Token Proxy'); + expect(descriptions).toContain('[dev] MCP OAuth Register'); + }); + + test('Lambda functions have MCP_API_DOMAIN environment variable', () => { + new McpAuth(stack, 'McpAuth', defaultProps); + + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + MCP_API_DOMAIN: 'api.example.com', + }), + }, + }); + }); + + test('Lambda functions have MCP_AUTHORIZE_ENDPOINT environment variable', () => { + new McpAuth(stack, 'McpAuth', defaultProps); + + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + MCP_AUTHORIZE_ENDPOINT: 'https://auth.example.com/oauth2/authorize', + }), + }, + }); + }); + + test('Lambda functions have MCP_TOKEN_ENDPOINT environment variable', () => { + new McpAuth(stack, 'McpAuth', defaultProps); + + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + MCP_TOKEN_ENDPOINT: 'https://auth.example.com/oauth2/token', + }), + }, + }); + }); + + test('Lambda functions have MCP_CLIENT_ID environment variable', () => { + new McpAuth(stack, 'McpAuth', defaultProps); + + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + MCP_CLIENT_ID: 'test-client-id', + }), + }, + }); + }); + + test('Lambda functions have all required MCP environment variables', () => { + new McpAuth(stack, 'McpAuth', defaultProps); + + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + MCP_API_DOMAIN: 'api.example.com', + MCP_AUTHORIZE_ENDPOINT: 'https://auth.example.com/oauth2/authorize', + MCP_TOKEN_ENDPOINT: 'https://auth.example.com/oauth2/token', + MCP_CLIENT_ID: 'test-client-id', + MCP_SERVER_NAME: 'test-server', + MCP_SERVER_VERSION: '1.0.0', + MCP_PROTOCOL_VERSIONS: '2025-11-25,2025-03-26,2024-11-05', + MCP_SCOPES: 'openid,email,profile', + MCP_ALLOWED_REDIRECT_URIS: 'https://claude.ai/oauth/callback', + MCP_STRIP_PARAMETERS: 'resource', + }), + }, + }); + }); + + test('uses custom protocol versions when provided', () => { + new McpAuth(stack, 'McpAuth', { + ...defaultProps, + protocolVersions: ['2025-11-25'], + }); + + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + MCP_PROTOCOL_VERSIONS: '2025-11-25', + }), + }, + }); + }); + + test('uses custom scopes when provided', () => { + new McpAuth(stack, 'McpAuth', { + ...defaultProps, + scopes: ['openid', 'custom:read'], + }); + + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + MCP_SCOPES: 'openid,custom:read', + }), + }, + }); + }); + + test('uses custom strip parameters when provided', () => { + new McpAuth(stack, 'McpAuth', { + ...defaultProps, + stripParameters: ['resource', 'audience'], + }); + + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + MCP_STRIP_PARAMETERS: 'resource,audience', + }), + }, + }); + }); + + test('includes additional env variables when provided', () => { + new McpAuth(stack, 'McpAuth', { + ...defaultProps, + additionalEnv: { CUSTOM_VAR: 'custom-value' }, + }); + + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + CUSTOM_VAR: 'custom-value', + }), + }, + }); + }); + + test('exposes mcpEnvVars with all config values', () => { + const mcpAuth = new McpAuth(stack, 'McpAuth', defaultProps); + + expect(mcpAuth.mcpEnvVars).toEqual(expect.objectContaining({ + MCP_API_DOMAIN: 'api.example.com', + MCP_AUTHORIZE_ENDPOINT: 'https://auth.example.com/oauth2/authorize', + MCP_TOKEN_ENDPOINT: 'https://auth.example.com/oauth2/token', + MCP_CLIENT_ID: 'test-client-id', + MCP_SERVER_NAME: 'test-server', + MCP_SERVER_VERSION: '1.0.0', + })); + }); + + test('exposes functions property with all 5 function references', () => { + const mcpAuth = new McpAuth(stack, 'McpAuth', defaultProps); + + expect(mcpAuth.functions.protectedResource).toBeDefined(); + expect(mcpAuth.functions.authorizationServer).toBeDefined(); + expect(mcpAuth.functions.authorize).toBeDefined(); + expect(mcpAuth.functions.token).toBeDefined(); + expect(mcpAuth.functions.register).toBeDefined(); + }); + + test('multiple allowed redirect URIs are comma-separated', () => { + new McpAuth(stack, 'McpAuth', { + ...defaultProps, + allowedRedirectUris: [ + 'https://claude.ai/oauth/callback', + 'https://chatgpt.com/oauth/callback', + ], + }); + + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + MCP_ALLOWED_REDIRECT_URIS: 'https://claude.ai/oauth/callback,https://chatgpt.com/oauth/callback', + }), + }, + }); + }); +}); diff --git a/test/constructs/rest-api-mcp.test.ts b/test/constructs/rest-api-mcp.test.ts new file mode 100644 index 00000000..b7e12cf7 --- /dev/null +++ b/test/constructs/rest-api-mcp.test.ts @@ -0,0 +1,142 @@ +import { App, Stack } from 'aws-cdk-lib'; + +// Mock the LambdaFunction to avoid NodejsFunction bundling +jest.mock('../../src/constructs/func', () => { + const awsLambda = jest.requireActual('aws-cdk-lib/aws-lambda'); + class MockLambdaFunction extends awsLambda.Function { + constructor(scope: any, id: string, props: any) { + super(scope, id, { + runtime: awsLambda.Runtime.NODEJS_18_X, + handler: 'index.handler', + code: awsLambda.Code.fromInline('exports.handler = async () => {}'), + description: props.description, + environment: props.additionalEnv, + }); + } + } + return { LambdaFunction: MockLambdaFunction }; +}); + +// We need to test RestApi with mcpAuth but RestApi reads a YAML file, +// so we mock that too +jest.mock('node:fs', () => { + const actual = jest.requireActual('node:fs'); + return { + ...actual, + readFileSync: (filePath: string, ...args: any[]) => { + if (filePath === 'test-api.yaml' || filePath.endsWith('test-api.yaml')) { + return ` +openapi: '3.0.1' +info: + title: TestApi + version: '1.0' +paths: + /items: + get: + operationId: getItems + responses: + '200': + description: OK +`; + } + return actual.readFileSync(filePath, ...args); + }, + existsSync: (filePath: string) => { + if (filePath.includes('lambda/rest.')) return true; + return actual.existsSync(filePath); + }, + writeFileSync: jest.fn(), + }; +}); + +import { RestApi } from '../../src/constructs/rest-api'; + +describe('RestApi with mcpAuth', () => { + describe('generic mode', () => { + let stack: Stack; + let apiSpec: any; + + beforeAll(() => { + const app = new App(); + stack = new Stack(app, 'TestStack', { env: { account: '123456789012', region: 'us-east-1' } }); + + const api = new RestApi(stack, 'Api', { + apiName: 'TestApi', + stageName: 'dev', + definitionFileName: 'test-api.yaml', + cors: false, + domainName: 'example.com', + apiHostname: 'api', + mcpAuth: { + generic: { + authorizeEndpoint: 'https://auth.example.com/oauth2/authorize', + tokenEndpoint: 'https://auth.example.com/oauth2/token', + clientId: 'test-client-id', + }, + serverInfo: { name: 'test-server', version: '1.0.0' }, + }, + }); + + apiSpec = api.apiSpec; + }); + + it('injects /.well-known/oauth-protected-resource GET path', () => { + const pathItem = apiSpec.paths['/.well-known/oauth-protected-resource']; + expect(pathItem).toBeDefined(); + expect(pathItem.get).toBeDefined(); + expect(pathItem.get.operationId).toBe('mcpOAuthProtectedResource'); + expect(pathItem.get.security).toEqual([]); + }); + + it('injects /.well-known/oauth-authorization-server GET path', () => { + const pathItem = apiSpec.paths['/.well-known/oauth-authorization-server']; + expect(pathItem).toBeDefined(); + expect(pathItem.get).toBeDefined(); + expect(pathItem.get.operationId).toBe('mcpOAuthAuthorizationServer'); + expect(pathItem.get.security).toEqual([]); + }); + + it('injects /oauth/authorize GET path', () => { + const pathItem = apiSpec.paths['/oauth/authorize']; + expect(pathItem).toBeDefined(); + expect(pathItem.get).toBeDefined(); + expect(pathItem.get.operationId).toBe('mcpOAuthAuthorize'); + expect(pathItem.get.security).toEqual([]); + }); + + it('injects /oauth/token POST path', () => { + const pathItem = apiSpec.paths['/oauth/token']; + expect(pathItem).toBeDefined(); + expect(pathItem.post).toBeDefined(); + expect(pathItem.post.operationId).toBe('mcpOAuthToken'); + expect(pathItem.post.security).toEqual([]); + }); + + it('injects /oauth/register POST path', () => { + const pathItem = apiSpec.paths['/oauth/register']; + expect(pathItem).toBeDefined(); + expect(pathItem.post).toBeDefined(); + expect(pathItem.post.operationId).toBe('mcpOAuthRegister'); + expect(pathItem.post.security).toEqual([]); + }); + + it('all MCP paths have x-amazon-apigateway-integration', () => { + const mcpPaths = [ + '/.well-known/oauth-protected-resource', + '/.well-known/oauth-authorization-server', + '/oauth/authorize', + '/oauth/token', + '/oauth/register', + ]; + + for (const p of mcpPaths) { + const pathItem = apiSpec.paths[p]; + const method = p.startsWith('/oauth/token') || p.startsWith('/oauth/register') ? 'post' : 'get'; + const operation = pathItem[method]; + expect(operation['x-amazon-apigateway-integration']).toBeDefined(); + expect(operation['x-amazon-apigateway-integration'].type).toBe('aws_proxy'); + expect(operation['x-amazon-apigateway-integration'].httpMethod).toBe('POST'); + } + }); + }); +}); diff --git a/test/mcp-auth/handlers.test.ts b/test/mcp-auth/handlers.test.ts new file mode 100644 index 00000000..c871ea30 --- /dev/null +++ b/test/mcp-auth/handlers.test.ts @@ -0,0 +1,449 @@ +import type { McpAuthConfig } from '../../src/mcp-auth/config'; +import { createAuthorizationServerHandler } from '../../src/mcp-auth/handlers/authorization-server'; +import { createAuthorizeHandler } from '../../src/mcp-auth/handlers/authorize'; +import { createProtectedResourceHandler } from '../../src/mcp-auth/handlers/protected-resource'; +import { createRegisterHandler } from '../../src/mcp-auth/handlers/register'; +import { createTokenHandler } from '../../src/mcp-auth/handlers/token'; +import type { McpOAuthEvent } from '../../src/mcp-auth/handlers/types'; +import { validateRedirectUris, DEFAULT_ALLOWED_REDIRECT_URIS } from '../../src/mcp-auth/handlers/validate-redirect-uris'; + +const baseConfig: McpAuthConfig = { + apiDomain: 'api.example.com', + authorizeEndpoint: 'https://auth.example.com/oauth2/authorize', + tokenEndpoint: 'https://auth.example.com/oauth2/token', + allowedRedirectUris: ['https://claude.ai/oauth/callback', 'https://chatgpt.com/oauth/callback'], + clientId: 'test-client-id', + serverInfo: { name: 'test-server', version: '1.0.0' }, + protocolVersions: ['2025-11-25', '2025-03-26'], + scopes: ['openid', 'email'], + stripParameters: ['resource'], +}; + +function makeEvent(overrides: Partial = {}): McpOAuthEvent { + return { + httpMethod: 'GET', + path: '/', + headers: {}, + queryStringParameters: null, + body: null, + isBase64Encoded: false, + ...overrides, + }; +} + +describe('createProtectedResourceHandler', () => { + test('returns resource and authorization_servers based on apiDomain', async () => { + const handler = createProtectedResourceHandler(baseConfig); + const result = await handler(makeEvent()); + + expect(result.statusCode).toBe(200); + expect(result.headers['Content-Type']).toBe('application/json'); + + const body = JSON.parse(result.body!); + expect(body.resource).toBe('https://api.example.com/mcp'); + expect(body.authorization_servers).toEqual(['https://api.example.com']); + }); +}); + +describe('createAuthorizationServerHandler', () => { + test('returns correct OAuth metadata', async () => { + const handler = createAuthorizationServerHandler(baseConfig); + const result = await handler(makeEvent()); + + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body!); + + expect(body.issuer).toBe('https://api.example.com'); + expect(body.authorization_endpoint).toBe('https://api.example.com/oauth/authorize'); + expect(body.token_endpoint).toBe('https://api.example.com/oauth/token'); + expect(body.registration_endpoint).toBe('https://api.example.com/oauth/register'); + expect(body.scopes_supported).toEqual(['openid', 'email']); + expect(body.response_types_supported).toEqual(['code']); + expect(body.grant_types_supported).toEqual(['authorization_code', 'refresh_token']); + expect(body.code_challenge_methods_supported).toEqual(['S256']); + expect(body.token_endpoint_auth_methods_supported).toEqual(['none']); + }); + + test('uses default scopes when not configured', async () => { + const config: McpAuthConfig = { ...baseConfig, scopes: undefined }; + const handler = createAuthorizationServerHandler(config); + const result = await handler(makeEvent()); + + const body = JSON.parse(result.body!); + expect(body.scopes_supported).toEqual(['openid', 'email', 'profile']); + }); +}); + +describe('createAuthorizeHandler', () => { + test('redirects to authorizeEndpoint with query params', async () => { + const handler = createAuthorizeHandler(baseConfig); + const result = await handler(makeEvent({ + queryStringParameters: { + client_id: 'my-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + state: 'abc123', + }, + })); + + expect(result.statusCode).toBe(302); + expect(result.headers['Cache-Control']).toBe('no-cache, no-store'); + + const location = result.headers.Location; + expect(location).toContain('https://auth.example.com/oauth2/authorize?'); + expect(location).toContain('client_id=my-client'); + expect(location).toContain('redirect_uri='); + expect(location).toContain('response_type=code'); + expect(location).toContain('state=abc123'); + }); + + test('strips configured parameters (resource by default)', async () => { + const handler = createAuthorizeHandler(baseConfig); + const result = await handler(makeEvent({ + queryStringParameters: { + client_id: 'my-client', + resource: 'https://api.example.com/mcp', + response_type: 'code', + }, + })); + + const location = result.headers.Location; + expect(location).not.toContain('resource='); + expect(location).toContain('client_id=my-client'); + expect(location).toContain('response_type=code'); + }); + + test('strips custom parameters when configured', async () => { + const config: McpAuthConfig = { ...baseConfig, stripParameters: ['resource', 'audience'] }; + const handler = createAuthorizeHandler(config); + const result = await handler(makeEvent({ + queryStringParameters: { + client_id: 'my-client', + resource: 'https://api.example.com/mcp', + audience: 'aud', + response_type: 'code', + }, + })); + + const location = result.headers.Location; + expect(location).not.toContain('resource='); + expect(location).not.toContain('audience='); + expect(location).toContain('client_id=my-client'); + }); + + test('uses default stripParameters when not configured', async () => { + const config: McpAuthConfig = { ...baseConfig, stripParameters: undefined }; + const handler = createAuthorizeHandler(config); + const result = await handler(makeEvent({ + queryStringParameters: { + client_id: 'my-client', + resource: 'should-be-stripped', + }, + })); + + const location = result.headers.Location; + expect(location).not.toContain('resource='); + }); +}); + +describe('createTokenHandler', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + test('proxies token request to tokenEndpoint', async () => { + const mockResponse = { access_token: 'tok_123', token_type: 'Bearer' }; + global.fetch = jest.fn().mockResolvedValue({ + status: 200, + text: () => Promise.resolve(JSON.stringify(mockResponse)), + }); + + const handler = createTokenHandler(baseConfig); + const result = await handler(makeEvent({ + httpMethod: 'POST', + body: 'grant_type=authorization_code&code=abc123&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback', + })); + + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body!)).toEqual(mockResponse); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://auth.example.com/oauth2/token', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }), + ); + }); + + test('strips resource parameter from body', async () => { + global.fetch = jest.fn().mockResolvedValue({ + status: 200, + text: () => Promise.resolve('{}'), + }); + + const handler = createTokenHandler(baseConfig); + await handler(makeEvent({ + httpMethod: 'POST', + body: 'grant_type=authorization_code&resource=https%3A%2F%2Fapi.example.com%2Fmcp&code=abc', + })); + + const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; + const sentBody = fetchCall[1].body; + expect(sentBody).not.toContain('resource='); + expect(sentBody).toContain('grant_type=authorization_code'); + expect(sentBody).toContain('code=abc'); + }); + + test('handles base64-encoded body', async () => { + global.fetch = jest.fn().mockResolvedValue({ + status: 200, + text: () => Promise.resolve('{"ok":true}'), + }); + + const handler = createTokenHandler(baseConfig); + const bodyPlain = 'grant_type=authorization_code&code=test123'; + const bodyB64 = Buffer.from(bodyPlain).toString('base64'); + + await handler(makeEvent({ + httpMethod: 'POST', + body: bodyB64, + isBase64Encoded: true, + })); + + const fetchCall = (global.fetch as jest.Mock).mock.calls[0]; + const sentBody = fetchCall[1].body; + expect(sentBody).toContain('grant_type=authorization_code'); + expect(sentBody).toContain('code=test123'); + }); + + test('returns 502 when upstream is unreachable', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); + + const handler = createTokenHandler(baseConfig); + const result = await handler(makeEvent({ + httpMethod: 'POST', + body: 'grant_type=authorization_code&code=abc', + })); + + expect(result.statusCode).toBe(502); + const body = JSON.parse(result.body!); + expect(body.error).toBe('upstream_error'); + expect(body.error_description).toBe('Token endpoint unreachable'); + }); + + test('passes through upstream error status codes', async () => { + global.fetch = jest.fn().mockResolvedValue({ + status: 400, + text: () => Promise.resolve(JSON.stringify({ error: 'invalid_grant' })), + }); + + const handler = createTokenHandler(baseConfig); + const result = await handler(makeEvent({ + httpMethod: 'POST', + body: 'grant_type=authorization_code&code=expired', + })); + + expect(result.statusCode).toBe(400); + expect(JSON.parse(result.body!).error).toBe('invalid_grant'); + }); + + test('rejects missing grant_type with 400', async () => { + const handler = createTokenHandler(baseConfig); + const result = await handler(makeEvent({ + httpMethod: 'POST', + body: 'code=abc123&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback', + })); + + expect(result.statusCode).toBe(400); + const body = JSON.parse(result.body!); + expect(body.error).toBe('unsupported_grant_type'); + }); + + test('rejects unsupported grant_type with 400', async () => { + const handler = createTokenHandler(baseConfig); + const result = await handler(makeEvent({ + httpMethod: 'POST', + body: 'grant_type=client_credentials&client_id=abc', + })); + + expect(result.statusCode).toBe(400); + const body = JSON.parse(result.body!); + expect(body.error).toBe('unsupported_grant_type'); + expect(body.error_description).toContain('authorization_code'); + expect(body.error_description).toContain('refresh_token'); + }); + + test('accepts refresh_token grant_type', async () => { + global.fetch = jest.fn().mockResolvedValue({ + status: 200, + text: () => Promise.resolve('{"access_token":"new_tok"}'), + }); + + const handler = createTokenHandler(baseConfig); + const result = await handler(makeEvent({ + httpMethod: 'POST', + body: 'grant_type=refresh_token&refresh_token=rt_abc', + })); + + expect(result.statusCode).toBe(200); + expect(global.fetch).toHaveBeenCalled(); + }); +}); + +describe('createRegisterHandler', () => { + test('returns client_id for valid redirect URIs', async () => { + const handler = createRegisterHandler(baseConfig); + const result = await handler(makeEvent({ + httpMethod: 'POST', + body: JSON.stringify({ + redirect_uris: ['https://claude.ai/oauth/callback'], + client_name: 'My MCP Client', + }), + })); + + expect(result.statusCode).toBe(201); + const body = JSON.parse(result.body!); + expect(body.client_id).toBe('test-client-id'); + expect(body.client_name).toBe('My MCP Client'); + expect(body.redirect_uris).toEqual(['https://claude.ai/oauth/callback']); + expect(body.token_endpoint_auth_method).toBe('none'); + }); + + test('uses default client_name when not provided', async () => { + const handler = createRegisterHandler(baseConfig); + const result = await handler(makeEvent({ + httpMethod: 'POST', + body: JSON.stringify({ + redirect_uris: ['https://claude.ai/oauth/callback'], + }), + })); + + expect(result.statusCode).toBe(201); + const body = JSON.parse(result.body!); + expect(body.client_name).toBe('MCP Connector'); + }); + + test('rejects when redirect_uris is not on the allowlist', async () => { + const handler = createRegisterHandler(baseConfig); + const result = await handler(makeEvent({ + httpMethod: 'POST', + body: JSON.stringify({ + redirect_uris: ['https://evil.com/callback'], + }), + })); + + expect(result.statusCode).toBe(400); + const body = JSON.parse(result.body!); + expect(body.error).toBe('invalid_redirect_uri'); + }); + + test('rejects when redirect_uris is missing', async () => { + const handler = createRegisterHandler(baseConfig); + const result = await handler(makeEvent({ + httpMethod: 'POST', + body: JSON.stringify({ client_name: 'Test' }), + })); + + expect(result.statusCode).toBe(400); + const body = JSON.parse(result.body!); + expect(body.error).toBe('invalid_request'); + expect(body.error_description).toBe('redirect_uris is required'); + }); + + test('rejects when redirect_uris is empty array', async () => { + const handler = createRegisterHandler(baseConfig); + const result = await handler(makeEvent({ + httpMethod: 'POST', + body: JSON.stringify({ redirect_uris: [] }), + })); + + expect(result.statusCode).toBe(400); + const body = JSON.parse(result.body!); + expect(body.error).toBe('invalid_request'); + }); + + test('rejects malformed JSON body', async () => { + const handler = createRegisterHandler(baseConfig); + const result = await handler(makeEvent({ + httpMethod: 'POST', + body: 'not-json', + })); + + expect(result.statusCode).toBe(400); + const body = JSON.parse(result.body!); + expect(body.error).toBe('invalid_request'); + expect(body.error_description).toBe('Malformed JSON body'); + }); + + test('rejects non-object body (array)', async () => { + const handler = createRegisterHandler(baseConfig); + const result = await handler(makeEvent({ + httpMethod: 'POST', + body: JSON.stringify([]), + })); + + expect(result.statusCode).toBe(400); + const body = JSON.parse(result.body!); + expect(body.error).toBe('invalid_request'); + expect(body.error_description).toBe('Request body must be a JSON object'); + }); + + test('handles base64-encoded body', async () => { + const handler = createRegisterHandler(baseConfig); + const jsonBody = JSON.stringify({ + redirect_uris: ['https://claude.ai/oauth/callback'], + }); + const result = await handler(makeEvent({ + httpMethod: 'POST', + body: Buffer.from(jsonBody).toString('base64'), + isBase64Encoded: true, + })); + + expect(result.statusCode).toBe(201); + const body = JSON.parse(result.body!); + expect(body.client_id).toBe('test-client-id'); + }); +}); + +describe('validateRedirectUris', () => { + test('returns true when all URIs are on the allowlist', () => { + expect(validateRedirectUris( + ['https://claude.ai/oauth/callback'], + ['https://claude.ai/oauth/callback', 'https://chatgpt.com/oauth/callback'], + )).toBe(true); + }); + + test('returns false when any URI is not on the allowlist', () => { + expect(validateRedirectUris( + ['https://claude.ai/oauth/callback', 'https://evil.com/callback'], + ['https://claude.ai/oauth/callback'], + )).toBe(false); + }); + + test('returns false for completely unknown URIs', () => { + expect(validateRedirectUris( + ['https://unknown.com/callback'], + ['https://claude.ai/oauth/callback'], + )).toBe(false); + }); + + test('returns true for empty URI list', () => { + expect(validateRedirectUris([], ['https://claude.ai/oauth/callback'])).toBe(true); + }); + + test('uses DEFAULT_ALLOWED_REDIRECT_URIS when no allowlist provided', () => { + expect(validateRedirectUris(['https://claude.ai/oauth/callback'])).toBe(true); + expect(validateRedirectUris(['https://claude.ai/api/mcp/auth_callback'])).toBe(true); + expect(validateRedirectUris(['https://chatgpt.com/oauth/callback'])).toBe(true); + expect(validateRedirectUris(['https://unknown.com/callback'])).toBe(false); + }); + + test('DEFAULT_ALLOWED_REDIRECT_URIS contains expected entries', () => { + expect(DEFAULT_ALLOWED_REDIRECT_URIS).toContain('https://claude.ai/oauth/callback'); + expect(DEFAULT_ALLOWED_REDIRECT_URIS).toContain('https://claude.ai/api/mcp/auth_callback'); + expect(DEFAULT_ALLOWED_REDIRECT_URIS).toContain('https://chatgpt.com/oauth/callback'); + }); +}); diff --git a/test/mcp-auth/mcp-server.test.ts b/test/mcp-auth/mcp-server.test.ts new file mode 100644 index 00000000..f635e10a --- /dev/null +++ b/test/mcp-auth/mcp-server.test.ts @@ -0,0 +1,341 @@ +import { McpUnauthorizedError } from '../../src/mcp-auth/mcp/errors'; +import { createMcpServer, McpServerHandle } from '../../src/mcp-auth/mcp/server'; +import type { McpCredentialResolver, McpToolDefinition, McpServerOptions } from '../../src/mcp-auth/mcp/types'; + +interface TestPrincipal { + userId: string; +} + +function makeServer(overrides: Partial> = {}): McpServerHandle { + const defaultResolver: McpCredentialResolver = { + resolve: async () => ({ userId: 'user-123' }), + }; + + const defaultTool: McpToolDefinition = { + name: 'echo', + description: 'Echo the input', + inputSchema: { type: 'object', properties: { message: { type: 'string' } } }, + invoke: async (_principal, args) => ({ + content: [{ type: 'text', text: (args as any)?.message ?? 'no message' }], + }), + }; + + return createMcpServer({ + serverInfo: { name: 'test-server', version: '1.0.0' }, + protocolVersions: ['2025-11-25', '2025-03-26'], + resolver: defaultResolver, + tools: [defaultTool], + ...overrides, + }); +} + +function rpcBody(method: string, params?: unknown, id: unknown = 1) { + return { jsonrpc: '2.0', id, method, params }; +} + +describe('createMcpServer', () => { + describe('initialize handshake', () => { + test('returns server info and capabilities for a supported protocol version', async () => { + const server = makeServer(); + const result = await server.handle( + rpcBody('initialize', { protocolVersion: '2025-11-25' }), + {}, + ); + + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body.result.protocolVersion).toBe('2025-11-25'); + expect(body.result.capabilities).toEqual({ tools: {} }); + expect(body.result.serverInfo).toEqual({ name: 'test-server', version: '1.0.0' }); + }); + + test('accepts any supported version', async () => { + const server = makeServer(); + const result = await server.handle( + rpcBody('initialize', { protocolVersion: '2025-03-26' }), + {}, + ); + + const body = JSON.parse(result.body); + expect(body.result.protocolVersion).toBe('2025-03-26'); + }); + + test('rejects unsupported protocol version', async () => { + const server = makeServer(); + const result = await server.handle( + rpcBody('initialize', { protocolVersion: '1999-01-01' }), + {}, + ); + + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body.error).toBeDefined(); + expect(body.error.code).toBe(-32602); // INVALID_PARAMS + expect(body.error.message).toContain('Unsupported protocol version'); + }); + + test('rejects missing protocol version', async () => { + const server = makeServer(); + const result = await server.handle( + rpcBody('initialize', {}), + {}, + ); + + const body = JSON.parse(result.body); + expect(body.error).toBeDefined(); + expect(body.error.code).toBe(-32602); + }); + }); + + describe('notifications/initialized', () => { + test('returns 202 for initialized notification', async () => { + const server = makeServer(); + const result = await server.handle( + rpcBody('notifications/initialized'), + {}, + ); + + expect(result.statusCode).toBe(202); + }); + }); + + describe('tools/list', () => { + test('returns list of available tools', async () => { + const server = makeServer(); + const result = await server.handle(rpcBody('tools/list'), {}); + + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body.result.tools).toHaveLength(1); + expect(body.result.tools[0].name).toBe('echo'); + expect(body.result.tools[0].description).toBe('Echo the input'); + expect(body.result.tools[0].inputSchema).toEqual({ + type: 'object', + properties: { message: { type: 'string' } }, + }); + }); + + test('supports dynamic description function', async () => { + const server = makeServer({ + tools: [{ + name: 'dynamic', + description: async (principal: TestPrincipal) => `Hello ${principal.userId}`, + inputSchema: { type: 'object' }, + invoke: async () => ({ content: [{ type: 'text', text: 'ok' }] }), + }], + }); + + const result = await server.handle(rpcBody('tools/list'), {}); + const body = JSON.parse(result.body); + expect(body.result.tools[0].description).toBe('Hello user-123'); + }); + + test('returns multiple tools', async () => { + const server = makeServer({ + tools: [ + { + name: 'tool-a', + description: 'Tool A', + inputSchema: { type: 'object' }, + invoke: async () => ({ content: [{ type: 'text', text: 'a' }] }), + }, + { + name: 'tool-b', + description: 'Tool B', + inputSchema: { type: 'object' }, + invoke: async () => ({ content: [{ type: 'text', text: 'b' }] }), + }, + ], + }); + + const result = await server.handle(rpcBody('tools/list'), {}); + const body = JSON.parse(result.body); + expect(body.result.tools).toHaveLength(2); + expect(body.result.tools.map((t: any) => t.name)).toEqual(['tool-a', 'tool-b']); + }); + }); + + describe('tools/call', () => { + test('dispatches to the correct tool and returns result', async () => { + const server = makeServer(); + const result = await server.handle( + rpcBody('tools/call', { name: 'echo', arguments: { message: 'hello' } }), + {}, + ); + + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body.result.content).toEqual([{ type: 'text', text: 'hello' }]); + }); + + test('passes principal to tool invoke', async () => { + const invokeFn = jest.fn().mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }); + const server = makeServer({ + tools: [{ + name: 'auth-tool', + description: 'Needs auth', + inputSchema: { type: 'object' }, + invoke: invokeFn, + }], + }); + + await server.handle(rpcBody('tools/call', { name: 'auth-tool', arguments: {} }), {}); + expect(invokeFn).toHaveBeenCalledWith({ userId: 'user-123' }, {}); + }); + + test('returns error for unknown tool name', async () => { + const server = makeServer(); + const result = await server.handle( + rpcBody('tools/call', { name: 'nonexistent', arguments: {} }), + {}, + ); + + const body = JSON.parse(result.body); + expect(body.error).toBeDefined(); + expect(body.error.code).toBe(-32602); // INVALID_PARAMS + expect(body.error.message).toContain('Unknown tool: nonexistent'); + }); + + test('returns error when tool name is missing', async () => { + const server = makeServer(); + const result = await server.handle( + rpcBody('tools/call', {}), + {}, + ); + + const body = JSON.parse(result.body); + expect(body.error).toBeDefined(); + expect(body.error.code).toBe(-32602); + expect(body.error.message).toContain('Missing params.name'); + }); + + test('returns internal error when tool throws', async () => { + const server = makeServer({ + tools: [{ + name: 'failing', + description: 'Always fails', + inputSchema: { type: 'object' }, + invoke: async () => { throw new Error('boom'); }, + }], + }); + + const result = await server.handle( + rpcBody('tools/call', { name: 'failing', arguments: {} }), + {}, + ); + + const body = JSON.parse(result.body); + expect(body.error).toBeDefined(); + expect(body.error.code).toBe(-32603); // INTERNAL_ERROR + expect(body.error.message).toBe('boom'); + }); + }); + + describe('unknown method', () => { + test('returns METHOD_NOT_FOUND for unknown methods', async () => { + const server = makeServer(); + const result = await server.handle( + rpcBody('unknown/method'), + {}, + ); + + const body = JSON.parse(result.body); + expect(body.error).toBeDefined(); + expect(body.error.code).toBe(-32601); // METHOD_NOT_FOUND + expect(body.error.message).toContain('Method not found: unknown/method'); + }); + }); + + describe('unauthorized handling', () => { + test('returns 401 with WWW-Authenticate when resolver throws McpUnauthorizedError', async () => { + const server = makeServer({ + resolver: { + resolve: async () => { + throw new McpUnauthorizedError('Bearer realm="mcp"', 'Invalid token'); + }, + }, + }); + + const result = await server.handle(rpcBody('tools/list'), {}); + + expect(result.statusCode).toBe(401); + expect(result.headers['WWW-Authenticate']).toBe('Bearer realm="mcp"'); + const body = JSON.parse(result.body); + expect(body.error).toBe('Invalid token'); + }); + + test('re-throws non-McpUnauthorizedError from resolver', async () => { + const server = makeServer({ + resolver: { + resolve: async () => { throw new Error('Unexpected'); }, + }, + }); + + await expect( + server.handle(rpcBody('tools/list'), {}), + ).rejects.toThrow('Unexpected'); + }); + + test('passes headers to resolver', async () => { + const resolveFn = jest.fn().mockResolvedValue({ userId: 'from-header' }); + const server = makeServer({ + resolver: { resolve: resolveFn }, + }); + + await server.handle(rpcBody('tools/list'), { authorization: 'Bearer tok123' }); + expect(resolveFn).toHaveBeenCalledWith({ authorization: 'Bearer tok123' }); + }); + }); + + describe('JSON-RPC parsing', () => { + test('rejects non-object body', async () => { + const server = makeServer(); + const result = await server.handle('not-an-object', {}); + + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body.error).toBeDefined(); + expect(body.error.code).toBe(-32600); // INVALID_REQUEST + }); + + test('rejects body with missing jsonrpc version', async () => { + const server = makeServer(); + const result = await server.handle({ method: 'initialize', id: 1 }, {}); + + const body = JSON.parse(result.body); + expect(body.error).toBeDefined(); + expect(body.error.code).toBe(-32600); + }); + + test('rejects body with missing method', async () => { + const server = makeServer(); + const result = await server.handle({ jsonrpc: '2.0', id: 1 }, {}); + + const body = JSON.parse(result.body); + expect(body.error).toBeDefined(); + expect(body.error.code).toBe(-32600); + }); + + test('preserves request id in responses', async () => { + const server = makeServer(); + const result = await server.handle( + rpcBody('initialize', { protocolVersion: '2025-11-25' }, 42), + {}, + ); + + const body = JSON.parse(result.body); + expect(body.id).toBe(42); + }); + + test('uses null id when request id is missing', async () => { + const server = makeServer(); + const result = await server.handle( + { jsonrpc: '2.0', method: 'initialize', params: { protocolVersion: '2025-11-25' } }, + {}, + ); + + const body = JSON.parse(result.body); + expect(body.id).toBeNull(); + }); + }); +});