From b45ec17df69f261403783cbbe59dd3a4aa5a141c Mon Sep 17 00:00:00 2001 From: Tomasz Czubocha Date: Wed, 29 Jul 2026 18:01:31 +0200 Subject: [PATCH 1/9] feat: add MCP server examples (AgentCore Gateway Lambda tools, official SDK on Lambda with streaming) --- README.md | 2 + .../mcp-server-lambda-tools/README.md | 72 +++ .../handlers/calculator.mjs | 17 + .../mcp-server-lambda-tools/package-lock.json | 469 ++++++++++++++++++ .../mcp-server-lambda-tools/package.json | 13 + .../mcp-server-lambda-tools/serverless.yml | 65 +++ .../mcp-server-lambda-tools/test-client.mjs | 62 +++ aws-node-mcp-server/README.md | 68 +++ aws-node-mcp-server/client.mjs | 67 +++ aws-node-mcp-server/package-lock.json | 94 ++++ aws-node-mcp-server/package.json | 12 + aws-node-mcp-server/serverless.yml | 45 ++ aws-node-mcp-server/src/lambda.mjs | 75 +++ aws-node-mcp-server/src/server.mjs | 115 +++++ examples.json | 24 + 15 files changed, 1200 insertions(+) create mode 100644 aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/README.md create mode 100644 aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/handlers/calculator.mjs create mode 100644 aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/package-lock.json create mode 100644 aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/package.json create mode 100644 aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/serverless.yml create mode 100644 aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/test-client.mjs create mode 100644 aws-node-mcp-server/README.md create mode 100644 aws-node-mcp-server/client.mjs create mode 100644 aws-node-mcp-server/package-lock.json create mode 100644 aws-node-mcp-server/package.json create mode 100644 aws-node-mcp-server/serverless.yml create mode 100644 aws-node-mcp-server/src/lambda.mjs create mode 100644 aws-node-mcp-server/src/server.mjs diff --git a/README.md b/README.md index c7fa36137..6341d47fe 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ serverless deploy | [AWS Serverless Github Webhook Listener example in NodeJS](https://github.com/serverless/examples/tree/v4/aws-node-github-webhook-listener)
This service will listen to github webhooks fired by a given repository. | nodeJS | | [GraphQL query endpoint in NodeJS on AWS with DynamoDB](https://github.com/serverless/examples/tree/v4/aws-node-graphql-api-with-dynamodb)
A single-module GraphQL endpoint with query and mutation functionality. | nodeJS | | [AWS Serverless IoT Event example in NodeJS](https://github.com/serverless/examples/tree/v4/aws-node-iot-event)
This example demonstrates how to setup a AWS IoT Rule to send events to a Lambda function. | nodeJS | +| [AWS MCP Server on Lambda (NodeJS)](https://github.com/serverless/examples/tree/v4/aws-node-mcp-server)
Run an MCP server built with the official MCP TypeScript SDK on AWS Lambda, with streaming responses through API Gateway. | nodeJS | | [Node.js AWS Lambda connecting to MongoDB Atlas](https://github.com/serverless/examples/tree/v4/aws-node-mongodb-atlas)
Shows how to connect AWS Lambda to MongoDB Atlas. | nodeJS | | [Running Puppeteer on AWS Lambda](https://github.com/serverless/examples/tree/v4/aws-node-puppeteer)
This example shows you how to run Puppeteer on AWS Lambda | nodeJS | | [AWS Recursive Lambda function Invocation example in NodeJS](https://github.com/serverless/examples/tree/v4/aws-node-recursive-function)
This is an example of a function that will recursively call itself. | nodeJS | @@ -167,6 +168,7 @@ serverless deploy | [Bedrock AgentCore: LangGraph Multi-Gateway Agents (JavaScript)](https://github.com/serverless/examples/tree/v4/aws-bedrock-agentcore/javascript/langgraph-multi-gateway)
LangGraph JS agents using separate public and private AgentCore Gateways with different authorization levels. | nodeJS | | [Bedrock AgentCore: LangGraph Agent with Token Streaming (JavaScript)](https://github.com/serverless/examples/tree/v4/aws-bedrock-agentcore/javascript/langgraph-streaming)
LangGraph JS agent streaming LLM tokens in real time over SSE via BedrockAgentCoreApp. | nodeJS | | [Bedrock AgentCore: Standalone MCP Server (JavaScript)](https://github.com/serverless/examples/tree/v4/aws-bedrock-agentcore/javascript/mcp-server)
Standalone JavaScript MCP server deployed to AWS Bedrock AgentCore Runtime, consumable by any MCP client. | nodeJS | +| [Bedrock AgentCore: MCP Server from Plain Lambda Functions (JavaScript)](https://github.com/serverless/examples/tree/v4/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools)
MCP server whose tools are plain Lambda functions - no MCP SDK in your code - using Bedrock AgentCore Gateway. | nodeJS | | [Bedrock AgentCore: Strands Agent with Browser (JavaScript)](https://github.com/serverless/examples/tree/v4/aws-bedrock-agentcore/javascript/strands-browser)
Strands Agents JavaScript agent using AgentCore Browser tools for web automation. | nodeJS | | [Bedrock AgentCore: LangGraph Basic Agent, Code Deploy (Python)](https://github.com/serverless/examples/tree/v4/aws-bedrock-agentcore/python/langgraph-basic-code)
Minimal LangGraph agent deployed to AWS Bedrock AgentCore using code (zip) deployment. | python | | [Bedrock AgentCore: LangGraph Basic Agent, Docker Deploy (Python)](https://github.com/serverless/examples/tree/v4/aws-bedrock-agentcore/python/langgraph-basic-docker)
Minimal LangGraph agent deployed to AWS Bedrock AgentCore using Docker/container deployment. | python | diff --git a/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/README.md b/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/README.md new file mode 100644 index 000000000..75b51ae2f --- /dev/null +++ b/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/README.md @@ -0,0 +1,72 @@ + + +# MCP Server from Plain Lambda Functions + +Deploy a fully managed [Model Context Protocol](https://modelcontextprotocol.io) server whose tools are **plain Lambda functions** — no MCP SDK, no HTTP server, and no protocol code anywhere in your project. + +The Serverless Framework provisions a [Bedrock AgentCore Gateway](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway.html), which *is* the MCP server: an AWS-managed `/mcp` endpoint that answers `tools/list` and `server/discover` itself and performs one Lambda invocation per tool call. Your handlers receive the tool arguments as the event and return the bare result: + +```js +export const handler = async (event) => event.a + event.b +``` + +With `supportedVersions: ['2026-07-28', ...]`, the endpoint serves the stateless MCP protocol revision — self-contained requests with no handshake and no sessions — alongside earlier revisions on the same URL. + +**How this differs from the [`mcp-server`](../mcp-server) example:** that one deploys *your own* MCP server (built with the MCP SDK, running as a container on AgentCore Runtime) — full control over the protocol, streaming, and sessions, in exchange for owning the server code. This example inverts the trade: AWS runs the MCP server for you and your code shrinks to plain functions, with the tool schema declared in `serverless.yml`. Pick `mcp-server` when you need SDK-level capabilities; pick this one when your tools are simple request/response functions. + +## Usage + +### Deploy + +```bash +serverless deploy +``` + +The deploy output prints the gateway's MCP endpoint: + +``` +agents: + mcpServer: https://mcp-server-lambda-tools-mcpserver-....gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp +``` + +### Test + +The gateway uses AWS_IAM (SigV4) authorization by default. The included test client signs requests with your local AWS credentials: + +```bash +npm install +GATEWAY_URL= node test-client.mjs +``` + +It calls `server/discover`, `tools/list`, and `tools/call`: + +``` +=== tools/call (calculator___add) -> HTTP 200 === +{"result":{"isError":false,"content":[{"type":"text","text":"42"}],"resultType":"complete"},...} +``` + +Note that tool names are namespaced by their gateway target: the `add` tool of the `calculator` target is invoked as `calculator___add`. + +### Connect MCP clients + +MCP clients such as Claude or AI IDEs authenticate with OAuth rather than SigV4. To support them, configure a JWT authorizer on the gateway (see the commented `authorizer` block in `serverless.yml`) with your OpenID Connect provider's discovery URL — for example an Amazon Cognito user pool. + +### Add tools + +Each entry in `ai.tools` maps a schema to a function. A single `toolSchema` array can declare multiple tools backed by one function — the invoked tool name is available in `context.clientContext.custom['bedrockAgentCoreToolName']`. Beyond Lambda, gateway tools can also wrap OpenAPI definitions (`openapi: ./spec.yml`) or proxy external MCP servers (`mcp: https://example.com/mcp`). + +### Cleanup + +```bash +serverless remove +``` diff --git a/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/handlers/calculator.mjs b/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/handlers/calculator.mjs new file mode 100644 index 000000000..f099e4271 --- /dev/null +++ b/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/handlers/calculator.mjs @@ -0,0 +1,17 @@ +/** + * A plain Lambda handler exposed as an MCP tool through AgentCore Gateway. + * + * The Gateway passes the tool's input arguments as the event and embeds the + * return value as the MCP tool result - return the bare result directly. + * + * When one function backs multiple tools, the invoked tool name arrives in + * context.clientContext.custom['bedrockAgentCoreToolName'] in the form + * `___` (for example `calculator___add`). + */ +export const handler = async (event) => { + const { a, b } = event + if (typeof a !== 'number' || typeof b !== 'number') { + throw new Error('a and b must be numbers') + } + return a + b +} diff --git a/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/package-lock.json b/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/package-lock.json new file mode 100644 index 000000000..9175d62af --- /dev/null +++ b/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/package-lock.json @@ -0,0 +1,469 @@ +{ + "name": "mcp-server-lambda-tools", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mcp-server-lambda-tools", + "version": "1.0.0", + "devDependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-sdk/credential-provider-node": "^3.750.0", + "@smithy/protocol-http": "^5.0.0", + "@smithy/signature-v4": "^5.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.1.tgz", + "integrity": "sha512-KVtQRtc00ES/y+Sc3vYXeP6pCIcNlBJCZOwvqSy8ZpVGmbM5+IG+AfhuTKQ2oXmIVqZJewaGMMpzPkywC6xg0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@aws-sdk/xml-builder": "^3.972.37", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.29.8", + "@smithy/signature-v4": "^5.6.9", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.61", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.61.tgz", + "integrity": "sha512-qihs2ekMb89Nxd2JenCgVFhjbkb3EIo7HEBCBzyZACKVJdrLUZBLOmAE3xr0Sayml8n/jZSzwO/IufIiIzO7PQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.63", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.63.tgz", + "integrity": "sha512-yfozsS8wkWZEi/n6IsrodcFKBWZ0iNAezhJbTReMNc0z1Px17qdeAeuL1/wziCAmCZyXiW7QzP75ggJkBQv8jQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/node-http-handler": "^4.9.10", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.6.tgz", + "integrity": "sha512-jGLTW1bj148GL/6/IMlfY2fMYS9FtHOG+NahkFD4y0qkzYudNUahelxryY68/HGMslYuHClk1XaS/3b3eJzEkg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/credential-provider-env": "^3.972.61", + "@aws-sdk/credential-provider-http": "^3.972.63", + "@aws-sdk/credential-provider-login": "^3.972.68", + "@aws-sdk/credential-provider-process": "^3.972.61", + "@aws-sdk/credential-provider-sso": "^3.973.5", + "@aws-sdk/credential-provider-web-identity": "^3.972.67", + "@aws-sdk/nested-clients": "^3.997.35", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/credential-provider-imds": "^4.4.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.68.tgz", + "integrity": "sha512-w6tNci6g7RqFpLhj1f5xseBvaNojb4Pkgp5Jp5apl9hrJtaf2AA+rX9+qlhlWUK6kcyAFYPA7emO+55zj+S98Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/nested-clients": "^3.997.35", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.72.tgz", + "integrity": "sha512-blQ7F5QGzylnzeh5549zQLoCAiMHkXFLjFovEMaVy4b2X8JhUu+u9NXro1hyK95YHdVFNmBHKs2hIHtZchxKlQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.61", + "@aws-sdk/credential-provider-http": "^3.972.63", + "@aws-sdk/credential-provider-ini": "^3.973.6", + "@aws-sdk/credential-provider-process": "^3.972.61", + "@aws-sdk/credential-provider-sso": "^3.973.5", + "@aws-sdk/credential-provider-web-identity": "^3.972.67", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/credential-provider-imds": "^4.4.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.61", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.61.tgz", + "integrity": "sha512-xzRuj+fUVO4nkafKQJVKAF97kGpeQbfjuwmRrtGZNf42/1dkmcz6o7dswBy7alY0htQn5sCL1GWQYEykviWZkA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.5.tgz", + "integrity": "sha512-fZRjjWhLFelsDoOYjqShQTrIGYC3Pf9Mx9Czf+1ikfQDgktxjze33dVo1q1/ZQ+T0qbtejVoHNHrfD5aJVpv/w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/nested-clients": "^3.997.35", + "@aws-sdk/token-providers": "3.1095.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.67.tgz", + "integrity": "sha512-FTNZ05gkPBA6CKbU3N4zPgybV+stdazwMOya75CmGdcJL7p8Fw/BdHP8WVxJd0mvzyPK2cg/C3gli58Ir4HgCw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/nested-clients": "^3.997.35", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.35.tgz", + "integrity": "sha512-2MJfseVG/aXvIyOIBlYA/Oaf6qFDdsu4D8RKsEUdOQpVuLaor0BdxIBBtJLBNQQEe6Ku3YMvLljwb1MwVUpzRw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/signature-v4-multi-region": "^3.996.42", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/node-http-handler": "^4.9.10", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.42.tgz", + "integrity": "sha512-DBV4naZP6HYBlAvPpoQzOP12Wvfou/5rN8yJPXjBTBylU5qwCbh/tXr2MddHoIjgoRkEl/eS+IljiUqvmwey1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@smithy/signature-v4": "^5.6.9", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1095.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1095.0.tgz", + "integrity": "sha512-65SudS6y4nzaYHybtqcpm3sHe5jLhdMn68HRKS1nUx690BtQeaAQOoujQ+dpOjBATIVGVgKKjEP8tR+U06QJQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.0", + "@aws-sdk/nested-clients": "^3.997.35", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.8", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", + "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.30.0.tgz", + "integrity": "sha512-dl2yRglDxfzH9uJ4fSo4zTaAHa0zH7+V7BZMRWy8hEYIKT1BiqMUK/CN6T3ADQ3kbA5N1tmUulroJ2UtONS7Kw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.14", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.14.tgz", + "integrity": "sha512-QgbuahIb2qxQeZQvNK0sw3aF3JH5zwH8j2lLp5DUasVXexGGMWULAR+7z0omPXFolCP/m5wN9M5lm9EGdSviTQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.30.0", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.11", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.11.tgz", + "integrity": "sha512-o0Zkj1nKqJAoq+a+BrkhU39tRftMNjLwpc/z06Frfl43wpbHrJMaSAVZE4vTqlxtVkNaGaT0bIDxOp7tkFTuQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.30.0", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.9.11", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.11.tgz", + "integrity": "sha512-slbzbz8taEOzoXv/9y34YNBoE+ZHmddLykCgjDAjvMAsu2nM5s2Gzwa5OGF721tD8s+CKeFUBds5lSj9lcbuDg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.30.0", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.5.14", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.5.14.tgz", + "integrity": "sha512-R+aYfS8kDXMwO+LfpiWTJjjUsR+se4cd2ZBjMU3fqJmoSZ1jenTpgb4/k1SvvXcM9f0232KvufZoO5XRWoiPBw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.30.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.6.10", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.10.tgz", + "integrity": "sha512-EXhWePm3SXJAX38npIy4TXL2Aex/OVgCClTjelN2QHw/U+8CQUH8C7AaxsVzQcYieYPseywn48s89DmvuGjiAg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.30.0", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + } + } +} diff --git a/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/package.json b/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/package.json new file mode 100644 index 000000000..44c6b41b4 --- /dev/null +++ b/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/package.json @@ -0,0 +1,13 @@ +{ + "name": "mcp-server-lambda-tools", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "MCP server from plain Lambda functions via Bedrock AgentCore Gateway", + "devDependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-sdk/credential-provider-node": "^3.750.0", + "@smithy/protocol-http": "^5.0.0", + "@smithy/signature-v4": "^5.0.0" + } +} diff --git a/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/serverless.yml b/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/serverless.yml new file mode 100644 index 000000000..44e6244d5 --- /dev/null +++ b/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/serverless.yml @@ -0,0 +1,65 @@ +# MCP server from plain Lambda functions via Bedrock AgentCore Gateway. +# +# The Gateway is the MCP server: an AWS-managed /mcp endpoint that serves +# tools/list and server/discover itself and invokes a Lambda function per +# tool call. Tool handlers are plain functions - no MCP SDK, no HTTP server. +service: mcp-server-lambda-tools + +provider: + name: aws + +functions: + calculator: + handler: handlers/calculator.handler + runtime: nodejs24.x + +ai: + tools: + calculator: + function: calculator + toolSchema: # the Gateway serves tools/list from this schema + - name: add + description: Add two numbers together + inputSchema: + type: object + properties: + a: { type: number, description: First number } + b: { type: number, description: Second number } + required: [a, b] + # One function can back multiple tools - the invoked tool name arrives + # in context.clientContext.custom['bedrockAgentCoreToolName']: + # - name: multiply + # description: Multiply two numbers + # inputSchema: + # type: object + # properties: + # a: { type: number } + # b: { type: number } + # required: [a, b] + + # Tools don't have to be Lambda functions. The same gateway can expose + # an HTTP API described by an OpenAPI document: + # weather: + # openapi: ./weather-openapi.yml + # ...or proxy an external MCP server (streaming and elicitation pass + # through for this target type): + # external: + # mcp: https://mcp.example.com/mcp + + gateways: + mcpServer: + protocol: + # Advertise the MCP protocol revisions this endpoint serves. + supportedVersions: ['2026-07-28', '2025-03-26'] + # With many tools, semantic search helps agents find the right one: + # searchType: SEMANTIC + # instructions: 'Use the calculator tools for any arithmetic' + tools: [calculator] + # Default authorization is AWS_IAM (SigV4) - see test-client.mjs. + # For OAuth clients (Claude, AI IDEs), configure a JWT authorizer with + # your OpenID Connect provider instead: + # authorizer: + # type: CUSTOM_JWT + # jwt: + # discoveryUrl: https://cognito-idp.us-east-1.amazonaws.com//.well-known/openid-configuration + # allowedAudience: [] diff --git a/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/test-client.mjs b/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/test-client.mjs new file mode 100644 index 000000000..c128aa10a --- /dev/null +++ b/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/test-client.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +/** + * Test client for the deployed MCP gateway (AWS_IAM authorization). + * Signs requests with SigV4 using your local AWS credentials. + * + * Usage: + * GATEWAY_URL= node test-client.mjs + */ +import { SignatureV4 } from '@smithy/signature-v4' +import { HttpRequest } from '@smithy/protocol-http' +import { Sha256 } from '@aws-crypto/sha256-js' +import { defaultProvider } from '@aws-sdk/credential-provider-node' + +const GATEWAY_URL = process.env.GATEWAY_URL +if (!GATEWAY_URL) { + console.error('Set GATEWAY_URL to the URL printed by `serverless deploy`') + process.exit(1) +} +const url = new URL(GATEWAY_URL) +const REGION = process.env.AWS_REGION || 'us-east-1' + +const signer = new SignatureV4({ + service: 'bedrock-agentcore', + region: REGION, + credentials: defaultProvider(), + sha256: Sha256, +}) + +const META = { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'test-client', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': {}, +} + +let id = 0 +async function rpc(method, params, name) { + const body = JSON.stringify({ jsonrpc: '2.0', id: ++id, method, params }) + const request = new HttpRequest({ + method: 'POST', + protocol: url.protocol, + hostname: url.hostname, + path: url.pathname, + headers: { + host: url.hostname, + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': method, + ...(name && { 'mcp-name': name }), + }, + body, + }) + const signed = await signer.sign(request) + const res = await fetch(GATEWAY_URL, { method: 'POST', headers: signed.headers, body }) + console.log(`\n=== ${method}${name ? ` (${name})` : ''} -> HTTP ${res.status} ===`) + console.log((await res.text()).slice(0, 1200)) +} + +await rpc('server/discover', { _meta: META }) +await rpc('tools/list', { _meta: META }) +// Tool names are namespaced by their gateway target: ___ +await rpc('tools/call', { name: 'calculator___add', arguments: { a: 2, b: 40 }, _meta: META }, 'calculator___add') diff --git a/aws-node-mcp-server/README.md b/aws-node-mcp-server/README.md new file mode 100644 index 000000000..9b0f4fff5 --- /dev/null +++ b/aws-node-mcp-server/README.md @@ -0,0 +1,68 @@ + + +# MCP Server on AWS Lambda + +Run a [Model Context Protocol](https://modelcontextprotocol.io) server built with the **official [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)** on AWS Lambda, fronted by API Gateway with response streaming. + +The MCP `2026-07-28` protocol revision made servers stateless — every request is self-contained, so any request can be served by any Lambda execution environment with no sessions or sticky routing. This example has three parts: + +- **`src/server.mjs`** — a standard SDK v2 server (`createMcpHandler` + `registerTool` with zod schemas). Nothing Lambda-specific; the same file runs on any web-standard host. +- **`src/lambda.mjs`** — ~80 lines of adapter glue mapping the API Gateway event and the Lambda response stream onto the Node-style shapes that `@modelcontextprotocol/node` accepts. No MCP logic. +- **`serverless.yml`** — a REST API route with `response.transferMode: STREAM`, which enables Server-Sent Events and integration timeouts up to 15 minutes. + +Plain JSON and streaming SSE coexist on the one endpoint: simple tool calls return ordinary `application/json` responses, while calls that request progress stream `text/event-stream` with notifications arriving ahead of the final result. + +## Usage + +### Deploy + +```bash +npm install +serverless deploy +``` + +### Test + +```bash +ENDPOINT= node client.mjs +``` + +The client demonstrates both response modes: + +``` +=== tools/call (add) -> HTTP 200 (application/json) === +{"result":{"content":[{"type":"text","text":"42"}],"structuredContent":{"sum":42},"resultType":"complete",...}} + +=== tools/call (slow_report) -> HTTP 200 (text/event-stream) === +[+1150ms] {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"demo","progress":1,"total":3,...}} +[+1950ms] {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"demo","progress":2,"total":3,...}} +[+2750ms] {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"demo","progress":3,"total":3,...}} +[+2750ms] {"result":{"content":[{"type":"text","text":"completed 3 steps"}],"resultType":"complete",...}} +``` + +Older MCP clients that still send the `initialize` handshake are answered too — the SDK serves earlier protocol revisions on the same endpoint by default. + +### Going further + +The example is deliberately minimal; commented blocks show how to extend it: + +- **Authentication** (`serverless.yml` + `src/server.mjs`): either attach an API Gateway authorizer that validates Bearer JWTs before the function is invoked, or use the SDK's in-process `requireBearerAuth` with a `jose`-based verifier for any OpenID Connect provider. Pair it with an unauthenticated `/.well-known/oauth-protected-resource` route (the SDK's `oauthMetadataResponse` helper builds the document) so MCP clients can discover how to log in. +- **Elicitation** (`src/server.mjs`): a tool can pause mid-call and ask the user for input via `ctx.mcpReq.elicitInput(...)`; the client retries the request with the answers attached. +- **Resources**: expose readable documents next to tools with `registerResource`. +- **Long-running tools**: `timeoutInMillis` on the route and the function `timeout` can be raised up to 15 minutes. + +### Cleanup + +```bash +serverless remove +``` diff --git a/aws-node-mcp-server/client.mjs b/aws-node-mcp-server/client.mjs new file mode 100644 index 000000000..58644b511 --- /dev/null +++ b/aws-node-mcp-server/client.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * Try the deployed MCP server. + * Usage: ENDPOINT= node client.mjs + */ +const ENDPOINT = process.env.ENDPOINT +if (!ENDPOINT) { + console.error('Set ENDPOINT to the POST endpoint printed by `serverless deploy`') + process.exit(1) +} + +const META = { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'example-client', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': {}, +} +let id = 0 + +async function call(method, params, name) { + const res = await fetch(ENDPOINT, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': method, + ...(name && { 'mcp-name': name }), + }, + body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method, params }), + }) + console.log(`\n=== ${method}${name ? ` (${name})` : ''} -> HTTP ${res.status} (${res.headers.get('content-type')}) ===`) + if (!res.headers.get('content-type')?.includes('text/event-stream')) { + console.log((await res.text()).slice(0, 800)) + return + } + // SSE: print each event as it arrives, with timing + const t0 = Date.now() + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + for (;;) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + let idx + while ((idx = buffer.indexOf('\n\n')) >= 0) { + const data = buffer.slice(0, idx).split('\n').find((l) => l.startsWith('data:'))?.slice(5).trim() + buffer = buffer.slice(idx + 2) + if (data) console.log(`[+${Date.now() - t0}ms]`, data.slice(0, 160)) + } + } +} + +// Plain JSON responses - streaming mode carries these unchanged: +await call('tools/list', { _meta: META }) +await call('tools/call', { name: 'add', arguments: { a: 2, b: 40 }, _meta: META }, 'add') + +// A streaming SSE response - progress notifications arrive before the result: +await call( + 'tools/call', + { + name: 'slow_report', + arguments: { steps: 3, progressToken: 'demo' }, + _meta: { ...META, progressToken: 'demo' }, + }, + 'slow_report', +) diff --git a/aws-node-mcp-server/package-lock.json b/aws-node-mcp-server/package-lock.json new file mode 100644 index 000000000..595a88cca --- /dev/null +++ b/aws-node-mcp-server/package-lock.json @@ -0,0 +1,94 @@ +{ + "name": "aws-node-mcp-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aws-node-mcp-server", + "version": "1.0.0", + "dependencies": { + "@modelcontextprotocol/node": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "zod": "^4.2.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/node": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/node/-/node-2.0.0.tgz", + "integrity": "sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/server": "^2.0.0", + "hono": "^4.11.4" + }, + "peerDependenciesMeta": { + "hono": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hono": { + "version": "4.12.32", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", + "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/aws-node-mcp-server/package.json b/aws-node-mcp-server/package.json new file mode 100644 index 000000000..99fc9dfc5 --- /dev/null +++ b/aws-node-mcp-server/package.json @@ -0,0 +1,12 @@ +{ + "name": "aws-node-mcp-server", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "MCP server on AWS Lambda with the official MCP SDK and API Gateway response streaming", + "dependencies": { + "@modelcontextprotocol/node": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "zod": "^4.2.0" + } +} diff --git a/aws-node-mcp-server/serverless.yml b/aws-node-mcp-server/serverless.yml new file mode 100644 index 000000000..dded81b1f --- /dev/null +++ b/aws-node-mcp-server/serverless.yml @@ -0,0 +1,45 @@ +# MCP server on AWS Lambda, built with the official MCP TypeScript SDK v2 +# and served through API Gateway REST response streaming. +# +# Plain JSON responses and streaming SSE responses both work through the same +# endpoint - the server picks per request. +service: aws-node-mcp-server + +provider: + name: aws + runtime: nodejs22.x # the MCP SDK v2 requires Node.js 20+ + +functions: + mcp: + handler: src/lambda.handler + timeout: 120 + events: + - http: + method: POST + path: /mcp + # Response streaming lifts API Gateway's classic response limits and + # enables SSE, but the integration timeout must be raised explicitly + # alongside it (up to 15 minutes): + timeoutInMillis: 120000 + response: + transferMode: STREAM + # To reject unauthenticated requests before they invoke the function, + # attach an authorizer (see the jwtAuthorizer function below), or use + # the SDK's in-process bearer auth (see src/server.mjs): + # authorizer: + # name: jwtAuthorizer + - http: + method: GET + path: /mcp # the server answers the spec-mandated 405 for GET + + # OAuth discovery for MCP clients ("paste URL and log in"): serve the + # protected-resource metadata on an unauthenticated route. The SDK's + # oauthMetadata middleware can generate the document (see src/server.mjs): + # - http: + # method: GET + # path: /.well-known/oauth-protected-resource + + # A request authorizer validating Bearer JWTs against your OpenID Connect + # provider - pairs with the `authorizer` block above: + # jwtAuthorizer: + # handler: src/authorizer.handler diff --git a/aws-node-mcp-server/src/lambda.mjs b/aws-node-mcp-server/src/lambda.mjs new file mode 100644 index 000000000..372c1d1f5 --- /dev/null +++ b/aws-node-mcp-server/src/lambda.mjs @@ -0,0 +1,75 @@ +/** + * Lambda adapter for a web-standard MCP handler. + * + * The MCP SDK's serving entry is a web-standard fetch handler, and its + * @modelcontextprotocol/node package converts Node-style request/response + * objects to that interface using structural (duck-typed) shapes. Lambda's + * response streaming gives us exactly such shapes: the API Gateway event maps + * to the request side, and the awslambda response stream maps to the response + * side. This file is that glue - it contains no MCP-specific logic. + */ +import { toNodeHandler } from '@modelcontextprotocol/node' +import mcpHandler from './server.mjs' + +const node = toNodeHandler(mcpHandler) + +/** API Gateway (REST, streaming mode) proxy event -> Node-style request */ +function toNodeRequest(event) { + const headers = {} + for (const [k, v] of Object.entries(event.multiValueHeaders ?? {})) { + headers[k.toLowerCase()] = Array.isArray(v) && v.length === 1 ? v[0] : v + } + const qs = event.multiValueQueryStringParameters + ? new URLSearchParams( + Object.entries(event.multiValueQueryStringParameters).flatMap(([k, vs]) => vs.map((v) => [k, v])), + ).toString() + : '' + const body = + event.body == null ? null : Buffer.from(event.body, event.isBase64Encoded ? 'base64' : 'utf8') + return { + method: event.httpMethod, + url: event.path + (qs ? `?${qs}` : ''), + headers, + async *[Symbol.asyncIterator]() { + if (body) yield body + }, + } +} + +/** awslambda response stream -> Node-style response */ +function toNodeResponse(rawStream) { + let target = null // set once the handler calls writeHead + const pendingListeners = [] + return { + get destroyed() { + return (target ?? rawStream).destroyed === true + }, + writeHead(statusCode, headersRecord = {}) { + target = awslambda.HttpResponseStream.from(rawStream, { statusCode, headers: headersRecord }) + for (const [event, listener] of pendingListeners) target.on(event, listener) + return this + }, + write(chunk) { + return target.write(chunk) // real booleans keep backpressure working + }, + end(chunk) { + return target ? target.end(chunk) : rawStream.end(chunk) + }, + on(event, listener) { + ;(target ?? rawStream).on(event, listener) + if (!target) pendingListeners.push([event, listener]) + return this + }, + } +} + +export const handler = awslambda.streamifyResponse(async (event, responseStream) => { + const res = toNodeResponse(responseStream) + const finished = new Promise((resolve, reject) => { + responseStream.on('close', resolve) + responseStream.on('finish', resolve) + responseStream.on('error', reject) + }) + await node(toNodeRequest(event), res) + await finished +}) diff --git a/aws-node-mcp-server/src/server.mjs b/aws-node-mcp-server/src/server.mjs new file mode 100644 index 000000000..50651ce37 --- /dev/null +++ b/aws-node-mcp-server/src/server.mjs @@ -0,0 +1,115 @@ +/** + * A standard MCP server built with the official MCP TypeScript SDK v2. + * Nothing in this file is Lambda-specific - the same handler runs on any + * web-standard host; src/lambda.mjs adapts it to Lambda. + */ +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server' +import { z } from 'zod' + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +export default createMcpHandler(() => { + const server = new McpServer({ name: 'aws-node-mcp-server', version: '1.0.0' }) + + // A plain tool: zod schemas next to the implementation; returning + // structuredContent lets clients consume typed results. + server.registerTool( + 'add', + { + description: 'Add two numbers', + inputSchema: z.object({ a: z.number(), b: z.number() }), + outputSchema: z.object({ sum: z.number() }), + }, + async ({ a, b }) => { + const output = { sum: a + b } + return { + content: [{ type: 'text', text: String(output.sum) }], + structuredContent: output, + } + }, + ) + + // A long-running tool emitting progress notifications. When the client + // requests progress (a progressToken in _meta) and accepts text/event-stream, + // the response is served as SSE with notifications ahead of the final result. + server.registerTool( + 'slow_report', + { + description: 'Generate a report, reporting progress along the way', + inputSchema: z.object({ steps: z.number().default(3), progressToken: z.string().optional() }), + }, + async ({ steps, progressToken }, ctx) => { + for (let i = 1; i <= steps; i++) { + await sleep(800) + if (progressToken) { + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progressToken, progress: i, total: steps, message: `step ${i}` }, + }) + } + } + return { content: [{ type: 'text', text: `completed ${steps} steps` }] } + }, + ) + + // ------------------------------------------------------------------------- + // More SDK capabilities to explore (uncomment and adapt): + // ------------------------------------------------------------------------- + + // Elicitation - pause a tool call to ask the user for input (the client + // retries the request with the answers attached; protect the round-trip + // state with the SDK's requestState codec - see its docs): + // + // server.registerTool( + // 'approve_refund', + // { + // description: 'Refund an order after user confirmation', + // inputSchema: z.object({ orderId: z.string() }), + // }, + // async ({ orderId }, ctx) => { + // const result = await ctx.mcpReq.elicitInput({ + // mode: 'form', + // message: `Refund order ${orderId}?`, + // requestedSchema: { + // type: 'object', + // properties: { confirmed: { type: 'boolean' } }, + // required: ['confirmed'], + // }, + // }) + // if (result.action !== 'accept' || !result.content?.confirmed) { + // return { content: [{ type: 'text', text: 'refund cancelled' }] } + // } + // return { content: [{ type: 'text', text: `refunded ${orderId}` }] } + // }, + // ) + + // Resources - expose readable documents next to tools: + // + // server.registerResource( + // 'orders', + // 'orders://recent', + // { description: 'Recent orders' }, + // async () => ({ contents: [{ uri: 'orders://recent', text: JSON.stringify([]) }] }), + // ) + + return server +}) + +// --------------------------------------------------------------------------- +// In-process OAuth (alternative to an API Gateway authorizer): the SDK ships +// bearer-token middleware and OAuth discovery-document helpers. Wrap the +// handler's fetch face before exporting - any OIDC provider works: +// +// import { requireBearerAuth, oauthMetadataResponse } from '@modelcontextprotocol/server' +// import { createRemoteJWKSet, jwtVerify } from 'jose' +// +// const jwks = createRemoteJWKSet(new URL('https:///.well-known/jwks.json')) +// const verifier = { +// async verifyAccessToken(token) { +// const { payload } = await jwtVerify(token, jwks, { +// issuer: 'https:///', +// audience: 'https://', +// }) +// return { token, clientId: payload.azp, scopes: payload.scope?.split(' ') ?? [], expiresAt: payload.exp } +// }, +// } diff --git a/examples.json b/examples.json index 2e9bb402a..01614e0bf 100644 --- a/examples.json +++ b/examples.json @@ -289,6 +289,18 @@ "authorName": "Rupak Ganguly", "authorAvatar": "https://avatars0.githubusercontent.com/u/8188?v=4&s=140" }, + { + "title": "AWS MCP Server on Lambda (NodeJS)", + "name": "aws-node-mcp-server", + "description": "Run an MCP server built with the official MCP TypeScript SDK on AWS Lambda, with streaming responses through API Gateway.", + "githubUrl": "https://github.com/serverless/examples/tree/v4/aws-node-mcp-server", + "framework": "v4", + "language": "node", + "platform": "aws", + "authorLink": "https://github.com/serverless", + "authorName": "Serverless, Inc.", + "authorAvatar": "https://avatars1.githubusercontent.com/u/13742415?s=200&v=4" + }, { "title": "Node.js AWS Lambda connecting to MongoDB Atlas", "name": "aws-node-mongodb-atlas", @@ -1293,6 +1305,18 @@ "authorName": "Serverless, inc.", "authorAvatar": "https://avatars1.githubusercontent.com/u/13742415?s=200&v=4" }, + { + "title": "Bedrock AgentCore: MCP Server from Plain Lambda Functions (JavaScript)", + "name": "aws-bedrock-agentcore-javascript-mcp-server-lambda-tools", + "description": "MCP server whose tools are plain Lambda functions - no MCP SDK in your code - using Bedrock AgentCore Gateway.", + "githubUrl": "https://github.com/serverless/examples/tree/v4/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools", + "framework": "v4", + "language": "node", + "platform": "aws", + "authorLink": "https://github.com/serverless", + "authorName": "Serverless, Inc.", + "authorAvatar": "https://avatars1.githubusercontent.com/u/13742415?s=200&v=4" + }, { "title": "Bedrock AgentCore: Strands Agent with Browser (JavaScript)", "name": "aws-bedrock-agentcore-javascript-strands-browser", From c455ffc420051a059d1eec4c0890a141e00500d1 Mon Sep 17 00:00:00 2001 From: Tomasz Czubocha Date: Wed, 29 Jul 2026 18:13:00 +0200 Subject: [PATCH 2/9] feat: modernize AgentCore MCP server example to MCP SDK v2 (2026-07-28 protocol) --- .../javascript/mcp-server/README.md | 8 +- .../javascript/mcp-server/index.js | 195 +-- .../javascript/mcp-server/package-lock.json | 1120 +---------------- .../javascript/mcp-server/package.json | 4 +- .../javascript/mcp-server/test-invoke.js | 141 +-- 5 files changed, 204 insertions(+), 1264 deletions(-) diff --git a/aws-bedrock-agentcore/javascript/mcp-server/README.md b/aws-bedrock-agentcore/javascript/mcp-server/README.md index d6834bae8..809153e5d 100644 --- a/aws-bedrock-agentcore/javascript/mcp-server/README.md +++ b/aws-bedrock-agentcore/javascript/mcp-server/README.md @@ -32,7 +32,7 @@ A JavaScript MCP server deployed to AWS Bedrock AgentCore Runtime. Exposes simpl ```text mcp-server/ -├── index.js # MCP server (Express + @modelcontextprotocol/sdk) +├── index.js # MCP server (official MCP SDK v2 + node:http) ├── package.json # Dependencies and start script ├── serverless.yml # Serverless Framework configuration └── README.md @@ -54,10 +54,14 @@ sls dev ## How it works -The server uses Express to expose a stateless Streamable HTTP endpoint at `POST /mcp` on port 8000, which is the standard expected by AgentCore Runtime for MCP-protocol runtimes. Each request creates a fresh MCP server instance, processes the JSON-RPC message, and cleans up on close. +The server is built with the official [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) v2: `createMcpHandler` produces a web-standard handler serving the stateless MCP `2026-07-28` protocol revision (self-contained requests, `server/discover`, structured results) while answering older MCP clients through the SDK's built-in fallback on the same endpoint. `toNodeHandler` mounts it on a plain `node:http` server at `/mcp` on port 8000, the standard expected by AgentCore Runtime for MCP-protocol runtimes. The Runtime adds an `Mcp-Session-Id` header for its own session isolation; the stateless server accepts and ignores it. The `serverless.yml` sets `protocol: MCP` on the agent, which tells AgentCore to route MCP traffic to the runtime. +Commented blocks in `index.js` show optional capabilities to enable: progress notifications streamed over SSE from long-running tools, elicitation (a tool pausing to ask the user for input), and resources. + +**Related example:** [`mcp-server-lambda-tools`](../mcp-server-lambda-tools) inverts this trade — AWS runs the MCP server (AgentCore Gateway) and your code shrinks to plain Lambda functions with the tool schema declared in `serverless.yml`. Pick this example when you need full SDK-level control; pick that one for simple request/response tools with no MCP code at all. + ## Connecting MCP clients Once deployed, the runtime can be connected to any MCP client that supports remote MCP servers via Streamable HTTP. Refer to the [AgentCore MCP documentation](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-mcp.html) for invocation details and authentication setup. diff --git a/aws-bedrock-agentcore/javascript/mcp-server/index.js b/aws-bedrock-agentcore/javascript/mcp-server/index.js index 3e6da1303..6d0fd667a 100644 --- a/aws-bedrock-agentcore/javascript/mcp-server/index.js +++ b/aws-bedrock-agentcore/javascript/mcp-server/index.js @@ -1,8 +1,9 @@ /** * MCP Server for AWS Bedrock AgentCore Runtime * - * A stateless MCP server exposing simple tools via the Model Context Protocol. - * Uses Express + @modelcontextprotocol/sdk with Streamable HTTP transport. + * Built with the official MCP TypeScript SDK v2, serving the stateless + * MCP `2026-07-28` protocol revision - and answering older MCP clients + * through the SDK's built-in fallback on the same endpoint. * * Tools: * - add: Add two numbers @@ -10,136 +11,162 @@ * - get_current_time: Get the current date and time */ -import express from 'express' -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' +import { createServer } from 'node:http' +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server' +import { toNodeHandler } from '@modelcontextprotocol/node' import { z } from 'zod' // --------------------------------------------------------------------------- -// MCP Server factory — creates a fresh stateless server per request +// MCP server factory - the handler creates a fresh instance per request // --------------------------------------------------------------------------- -const createMcpServer = () => { - const server = new McpServer({ - name: 'mcp-server', - version: '1.0.0', - }) +const mcpHandler = createMcpHandler(() => { + const server = new McpServer({ name: 'mcp-server', version: '1.0.0' }) - // Tool: add two numbers server.registerTool( 'add', { title: 'Addition', description: 'Add two numbers together', - inputSchema: { + inputSchema: z.object({ a: z.number().describe('First number'), b: z.number().describe('Second number'), - }, + }), + outputSchema: z.object({ sum: z.number() }), + }, + async ({ a, b }) => { + const output = { sum: a + b } + return { + content: [{ type: 'text', text: String(output.sum) }], + structuredContent: output, // typed results for structured-output clients + } }, - async ({ a, b }) => ({ - content: [{ type: 'text', text: String(a + b) }], - }), ) - // Tool: multiply two numbers server.registerTool( 'multiply', { title: 'Multiplication', description: 'Multiply two numbers together', - inputSchema: { + inputSchema: z.object({ a: z.number().describe('First number'), b: z.number().describe('Second number'), - }, + }), + outputSchema: z.object({ product: z.number() }), + }, + async ({ a, b }) => { + const output = { product: a * b } + return { + content: [{ type: 'text', text: String(output.product) }], + structuredContent: output, + } }, - async ({ a, b }) => ({ - content: [{ type: 'text', text: String(a * b) }], - }), ) - // Tool: get current time server.registerTool( 'get_current_time', { title: 'Current Time', - description: - 'Get the current date and time. Optionally specify a timezone.', - inputSchema: { + description: 'Get the current date and time. Optionally specify a timezone.', + inputSchema: z.object({ timezone: z .string() .optional() - .describe( - 'Timezone (e.g. "America/New_York", "Europe/London", "UTC")', - ), - }, + .describe('Timezone (e.g. "America/New_York", "Europe/London", "UTC")'), + }), }, async ({ timezone }) => { - const now = new Date() - const options = { + const now = new Date().toLocaleString('en-US', { timeZone: timezone || 'UTC', dateStyle: 'full', timeStyle: 'long', - } - return { - content: [{ type: 'text', text: now.toLocaleString('en-US', options) }], - } + }) + return { content: [{ type: 'text', text: now }] } }, ) + // ------------------------------------------------------------------------- + // More SDK capabilities to explore (uncomment and adapt): + // ------------------------------------------------------------------------- + + // Progress notifications - long-running tools can stream progress over SSE + // to clients that request it (a progressToken in the request's _meta): + // + // server.registerTool( + // 'slow_report', + // { + // description: 'Generate a report, reporting progress along the way', + // inputSchema: z.object({ steps: z.number().default(3), progressToken: z.string().optional() }), + // }, + // async ({ steps, progressToken }, ctx) => { + // for (let i = 1; i <= steps; i++) { + // await new Promise((resolve) => setTimeout(resolve, 800)) + // if (progressToken) { + // await ctx.mcpReq.notify({ + // method: 'notifications/progress', + // params: { progressToken, progress: i, total: steps, message: `step ${i}` }, + // }) + // } + // } + // return { content: [{ type: 'text', text: `completed ${steps} steps` }] } + // }, + // ) + + // Elicitation - pause a tool call to ask the user for input; the client + // retries the request with the answers attached (multi round-trip requests): + // + // server.registerTool( + // 'approve_action', + // { + // description: 'Perform an action after user confirmation', + // inputSchema: z.object({ action: z.string() }), + // }, + // async ({ action }, ctx) => { + // const result = await ctx.mcpReq.elicitInput({ + // mode: 'form', + // message: `Proceed with ${action}?`, + // requestedSchema: { + // type: 'object', + // properties: { confirmed: { type: 'boolean' } }, + // required: ['confirmed'], + // }, + // }) + // if (result.action !== 'accept' || !result.content?.confirmed) { + // return { content: [{ type: 'text', text: 'cancelled' }] } + // } + // return { content: [{ type: 'text', text: `${action} done` }] } + // }, + // ) + + // Resources - expose readable documents next to tools: + // + // server.registerResource( + // 'status', + // 'status://server', + // { description: 'Server status document' }, + // async () => ({ contents: [{ uri: 'status://server', text: 'all good' }] }), + // ) + return server -} +}) // --------------------------------------------------------------------------- -// Express HTTP layer — stateless Streamable HTTP on port 8000 +// HTTP layer - AgentCore Runtime expects the MCP endpoint on port 8000 +// at /mcp. toNodeHandler adapts Node's req/res to the SDK's web-standard +// handler. The platform adds an Mcp-Session-Id header for its own session +// isolation; stateless servers accept and ignore it. // --------------------------------------------------------------------------- const PORT = 8000 -const app = express() -app.use(express.json()) +const node = toNodeHandler(mcpHandler) -// POST /mcp — handle MCP JSON-RPC requests (stateless) -app.post('/mcp', async (req, res) => { - const server = createMcpServer() - try { - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, // stateless — no persistent sessions - enableJsonResponse: true, - }) - await server.connect(transport) - res.on('close', () => { - transport.close() - server.close() - }) - await transport.handleRequest(req, res, req.body) - } catch (error) { - console.error('Error handling MCP request:', error) - if (!res.headersSent) { - res.status(500).json({ - jsonrpc: '2.0', - error: { - code: -32603, - message: 'Internal server error', - }, - id: null, - }) - } +createServer((req, res) => { + if (!req.url?.startsWith('/mcp')) { + res.writeHead(404, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ error: 'not found' })) + return } -}) - -// GET /mcp — method not allowed per MCP spec -app.get('/mcp', (_req, res) => { - res.writeHead(405).end( - JSON.stringify({ - jsonrpc: '2.0', - error: { - code: -32000, - message: 'Method not allowed.', - }, - id: null, - }), - ) -}) - -app.listen(PORT, '0.0.0.0', () => { + node(req, res) +}).listen(PORT, '0.0.0.0', () => { console.log(`MCP server running on http://0.0.0.0:${PORT}/mcp`) }) diff --git a/aws-bedrock-agentcore/javascript/mcp-server/package-lock.json b/aws-bedrock-agentcore/javascript/mcp-server/package-lock.json index 3ebf1af14..886e70808 100644 --- a/aws-bedrock-agentcore/javascript/mcp-server/package-lock.json +++ b/aws-bedrock-agentcore/javascript/mcp-server/package-lock.json @@ -9,8 +9,8 @@ "version": "1.0.0", "dependencies": { "@aws-sdk/client-bedrock-agentcore": "^3.993.0", - "@modelcontextprotocol/sdk": "^1.24.0", - "express": "^5.1.0", + "@modelcontextprotocol/node": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", "zod": "^4.4.3" }, "engines": { @@ -301,46 +301,52 @@ "hono": "^4" } }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/node": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/node/-/node-2.0.0.tgz", + "integrity": "sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" + "@hono/node-server": "^1.19.9" }, "engines": { - "node": ">=18" + "node": ">=20" }, "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" + "@modelcontextprotocol/server": "^2.0.0", + "hono": "^4.11.4" }, "peerDependenciesMeta": { - "@cfworker/json-schema": { + "hono": { "optional": true - }, - "zod": { - "optional": false } } }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@smithy/core": { "version": "3.29.3", "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.3.tgz", @@ -422,1071 +428,28 @@ "node": ">=18.0.0" } }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", "license": "MIT" }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", - "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", - "license": "MIT", - "dependencies": { - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/hono": { "version": "4.12.18", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-to-regexp": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", - "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", @@ -1495,15 +458,6 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25 || ^4" - } } } } diff --git a/aws-bedrock-agentcore/javascript/mcp-server/package.json b/aws-bedrock-agentcore/javascript/mcp-server/package.json index c56b2a5a7..f1bbe79cc 100644 --- a/aws-bedrock-agentcore/javascript/mcp-server/package.json +++ b/aws-bedrock-agentcore/javascript/mcp-server/package.json @@ -9,8 +9,8 @@ }, "dependencies": { "@aws-sdk/client-bedrock-agentcore": "^3.993.0", - "@modelcontextprotocol/sdk": "^1.24.0", - "express": "^5.1.0", + "@modelcontextprotocol/node": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", "zod": "^4.4.3" }, "engines": { diff --git a/aws-bedrock-agentcore/javascript/mcp-server/test-invoke.js b/aws-bedrock-agentcore/javascript/mcp-server/test-invoke.js index 68a3591d1..f9b503acc 100644 --- a/aws-bedrock-agentcore/javascript/mcp-server/test-invoke.js +++ b/aws-bedrock-agentcore/javascript/mcp-server/test-invoke.js @@ -2,13 +2,11 @@ /** * Test script to invoke the MCP Server deployed to AgentCore Runtime. * + * Uses the stateless MCP 2026-07-28 protocol: every request is + * self-contained - no initialize handshake and no session bookkeeping. + * * Usage: * RUNTIME_ARN=arn:aws:bedrock-agentcore:... node test-invoke.js - * - * Or set RUNTIME_ARN environment variable before running. - * - * Requires: @aws-sdk/client-bedrock-agentcore - * npm install @aws-sdk/client-bedrock-agentcore */ import { @@ -27,35 +25,31 @@ if (!RUNTIME_ARN) { } const client = new BedrockAgentCoreClient({ region: REGION }) -let sessionId = null -async function invoke(method, params, id) { - const payload = { jsonrpc: '2.0', method, id } - if (params) payload.params = params +// The per-request envelope required by protocol revision 2026-07-28: +const META = { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'test-client', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': {}, +} +let id = 0 +async function invoke(method, params = {}) { + const payload = { + jsonrpc: '2.0', + id: ++id, + method, + params: { ...params, _meta: { ...META, ...(params._meta ?? {}) } }, + } const command = new InvokeAgentRuntimeCommand({ agentRuntimeArn: RUNTIME_ARN, qualifier: 'DEFAULT', - ...(sessionId ? { runtimeSessionId: sessionId } : {}), payload: Buffer.from(JSON.stringify(payload)), contentType: 'application/json', accept: 'application/json, text/event-stream', }) - const response = await client.send(command) - - // Capture session ID from first response - if (!sessionId && response.runtimeSessionId) { - sessionId = response.runtimeSessionId - } - - // Parse response body - const body = - typeof response.response === 'string' - ? response.response - : await streamToString(response.response) - - return JSON.parse(body) + return JSON.parse(await streamToString(response.response)) } async function streamToString(stream) { @@ -63,91 +57,52 @@ async function streamToString(stream) { if (stream instanceof Uint8Array || Buffer.isBuffer(stream)) { return new TextDecoder().decode(stream) } - // Handle ReadableStream or async iterator const chunks = [] for await (const chunk of stream) { - chunks.push( - typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk), - ) + chunks.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)) } return chunks.join('') } -async function testInitialize() { - console.log('=== Initialize ===') - const result = await invoke( - 'initialize', - { - protocolVersion: '2025-11-25', - clientInfo: { name: 'test-client', version: '1.0.0' }, - capabilities: {}, - }, - 1, - ) - console.log( - 'Server:', - result.result?.serverInfo?.name, - result.result?.serverInfo?.version, - ) - console.log('Protocol:', result.result?.protocolVersion) - console.log() -} - -async function testListTools() { - console.log('=== tools/list ===') - const result = await invoke('tools/list', undefined, 2) - const tools = result.result?.tools || [] - for (const tool of tools) { - console.log(` - ${tool.name}: ${tool.description}`) - } - console.log() -} - -async function testCallTool(name, args, id) { - console.log(`=== tools/call: ${name}(${JSON.stringify(args)}) ===`) - const result = await invoke('tools/call', { name, arguments: args }, id) - const content = - result.result?.content?.[0]?.text ?? JSON.stringify(result.result) - console.log(` Result: ${content}`) - console.log() - return content -} - async function main() { - console.log('MCP Server Test Suite') + console.log('MCP Server Test Suite (stateless, protocol 2026-07-28)') console.log(`Runtime ARN: ${RUNTIME_ARN}`) console.log(`Region: ${REGION}`) console.log('='.repeat(50) + '\n') - // Step 1: Initialize - await testInitialize() - - // Step 2: Send initialized notification - const notifPayload = { jsonrpc: '2.0', method: 'notifications/initialized' } - const notifCommand = new InvokeAgentRuntimeCommand({ - agentRuntimeArn: RUNTIME_ARN, - qualifier: 'DEFAULT', - runtimeSessionId: sessionId, - payload: Buffer.from(JSON.stringify(notifPayload)), - contentType: 'application/json', - accept: 'application/json, text/event-stream', - }) - await client.send(notifCommand) + console.log('=== server/discover ===') + const discover = await invoke('server/discover') + console.log('Server:', discover.result?.serverInfo?.name, discover.result?.serverInfo?.version) + console.log('Supported versions:', discover.result?.supportedVersions) + console.log() - // Step 3: List tools - await testListTools() + console.log('=== tools/list ===') + const list = await invoke('tools/list') + for (const tool of list.result?.tools ?? []) { + console.log(` - ${tool.name}: ${tool.description}`) + } + console.log() - // Step 4: Call each tool - await testCallTool('add', { a: 5, b: 3 }, 10) - await testCallTool('multiply', { a: 7, b: 6 }, 11) - await testCallTool('get_current_time', { timezone: 'UTC' }, 12) - await testCallTool('get_current_time', { timezone: 'America/New_York' }, 13) + const calls = [ + ['add', { a: 5, b: 3 }], + ['multiply', { a: 4, b: 7 }], + ['get_current_time', { timezone: 'Europe/Warsaw' }], + ] + for (const [name, args] of calls) { + console.log(`=== tools/call: ${name}(${JSON.stringify(args)}) ===`) + const result = await invoke('tools/call', { name, arguments: args }) + const text = result.result?.content?.[0]?.text ?? JSON.stringify(result.result ?? result.error) + console.log(` Result: ${text}`) + if (result.result?.structuredContent) { + console.log(` Structured: ${JSON.stringify(result.result.structuredContent)}`) + } + console.log() + } - console.log('='.repeat(50)) - console.log('All tests completed!') + console.log('All tests completed.') } main().catch((err) => { - console.error('Test failed:', err) + console.error('Test failed:', err.message) process.exit(1) }) From d8c4ab82683505f6c7152810497580ffa2843821 Mon Sep 17 00:00:00 2001 From: Tomasz Czubocha Date: Wed, 29 Jul 2026 18:43:44 +0200 Subject: [PATCH 3/9] feat: verify and correct optional MCP features in examples (return-style MRTR elicitation, header allowlist, multi-tool handler) --- .../handlers/calculator.mjs | 6 +- .../mcp-server-lambda-tools/serverless.yml | 6 +- .../javascript/mcp-server/README.md | 32 +++++ .../javascript/mcp-server/index.js | 27 ++-- .../javascript/mcp-server/package-lock.json | 123 +++++++++++++++--- .../javascript/mcp-server/package.json | 9 +- .../javascript/mcp-server/serverless.yml | 8 ++ .../javascript/mcp-server/test-invoke.js | 102 +++++++++------ aws-node-mcp-server/src/server.mjs | 31 +++-- 9 files changed, 259 insertions(+), 85 deletions(-) diff --git a/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/handlers/calculator.mjs b/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/handlers/calculator.mjs index f099e4271..514a3d37b 100644 --- a/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/handlers/calculator.mjs +++ b/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/handlers/calculator.mjs @@ -8,10 +8,14 @@ * context.clientContext.custom['bedrockAgentCoreToolName'] in the form * `___` (for example `calculator___add`). */ -export const handler = async (event) => { +export const handler = async (event, context) => { const { a, b } = event if (typeof a !== 'number' || typeof b !== 'number') { throw new Error('a and b must be numbers') } + // `___` - branch on the tool name when one function backs + // several tools: + const tool = context.clientContext?.custom?.bedrockAgentCoreToolName ?? '' + if (tool.endsWith('___multiply')) return a * b return a + b } diff --git a/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/serverless.yml b/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/serverless.yml index 44e6244d5..62d556623 100644 --- a/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/serverless.yml +++ b/aws-bedrock-agentcore/javascript/mcp-server-lambda-tools/serverless.yml @@ -27,7 +27,8 @@ ai: b: { type: number, description: Second number } required: [a, b] # One function can back multiple tools - the invoked tool name arrives - # in context.clientContext.custom['bedrockAgentCoreToolName']: + # in context.clientContext.custom['bedrockAgentCoreToolName'] and the + # handler branches on it (see handlers/calculator.mjs): # - name: multiply # description: Multiply two numbers # inputSchema: @@ -51,7 +52,8 @@ ai: protocol: # Advertise the MCP protocol revisions this endpoint serves. supportedVersions: ['2026-07-28', '2025-03-26'] - # With many tools, semantic search helps agents find the right one: + # With many tools, semantic search helps agents find the right one + # (adds a built-in x_amz_bedrock_agentcore_search tool): # searchType: SEMANTIC # instructions: 'Use the calculator tools for any arithmetic' tools: [calculator] diff --git a/aws-bedrock-agentcore/javascript/mcp-server/README.md b/aws-bedrock-agentcore/javascript/mcp-server/README.md index 809153e5d..5f195b881 100644 --- a/aws-bedrock-agentcore/javascript/mcp-server/README.md +++ b/aws-bedrock-agentcore/javascript/mcp-server/README.md @@ -45,6 +45,38 @@ npm install sls deploy ``` +## Test + +The deploy output prints the runtime's MCP endpoint URL. The test script calls it directly - the same way real MCP clients connect - signing requests with your local AWS credentials (SigV4, the runtime's default inbound auth): + +```bash +ENDPOINT= node test-invoke.js +``` + +``` +=== tools/call: add({"a":5,"b":3}) === + Result: 8 + Structured: {"sum":8} +``` + +Note the `requestHeaders.allowlist` block in `serverless.yml`: protocol revision `2026-07-28` requires the `Mcp-Method` and `Mcp-Name` headers on every request, and the allowlist forwards them to the container. + +## Test + +The deploy output prints the runtime's MCP endpoint URL. The test script calls it directly - the same way real MCP clients connect - signing requests with your local AWS credentials (SigV4, the runtime's default inbound auth): + +```bash +ENDPOINT= node test-invoke.js +``` + +``` +=== tools/call: add({"a":5,"b":3}) === + Result: 8 + Structured: {"sum":8} +``` + +Note the `requestHeaders.allowlist` block in `serverless.yml`: protocol revision `2026-07-28` requires the `Mcp-Method` and `Mcp-Name` headers on every request, and the allowlist forwards them to the container. + ## Local development ```bash diff --git a/aws-bedrock-agentcore/javascript/mcp-server/index.js b/aws-bedrock-agentcore/javascript/mcp-server/index.js index 6d0fd667a..ddf57483e 100644 --- a/aws-bedrock-agentcore/javascript/mcp-server/index.js +++ b/aws-bedrock-agentcore/javascript/mcp-server/index.js @@ -115,6 +115,8 @@ const mcpHandler = createMcpHandler(() => { // Elicitation - pause a tool call to ask the user for input; the client // retries the request with the answers attached (multi round-trip requests): // + // (also add acceptedContent and inputRequired to the imports above) + // // server.registerTool( // 'approve_action', // { @@ -122,16 +124,21 @@ const mcpHandler = createMcpHandler(() => { // inputSchema: z.object({ action: z.string() }), // }, // async ({ action }, ctx) => { - // const result = await ctx.mcpReq.elicitInput({ - // mode: 'form', - // message: `Proceed with ${action}?`, - // requestedSchema: { - // type: 'object', - // properties: { confirmed: { type: 'boolean' } }, - // required: ['confirmed'], - // }, - // }) - // if (result.action !== 'accept' || !result.content?.confirmed) { + // // First pass: no answers yet - return input_required so the client + // // asks the user and retries this call with the responses attached. + // const answer = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', z.object({ confirmed: z.boolean() })) + // if (answer === undefined) { + // return inputRequired({ + // inputRequests: { + // confirm: inputRequired.elicit({ + // message: `Proceed with ${action}?`, + // requestedSchema: z.object({ confirmed: z.boolean() }), + // }), + // }, + // }) + // } + // // Re-entry: the retried request carries the user's answer. + // if (!answer.confirmed) { // return { content: [{ type: 'text', text: 'cancelled' }] } // } // return { content: [{ type: 'text', text: `${action} done` }] } diff --git a/aws-bedrock-agentcore/javascript/mcp-server/package-lock.json b/aws-bedrock-agentcore/javascript/mcp-server/package-lock.json index 886e70808..08c9f7529 100644 --- a/aws-bedrock-agentcore/javascript/mcp-server/package-lock.json +++ b/aws-bedrock-agentcore/javascript/mcp-server/package-lock.json @@ -8,38 +8,52 @@ "name": "mcp-server", "version": "1.0.0", "dependencies": { - "@aws-sdk/client-bedrock-agentcore": "^3.993.0", "@modelcontextprotocol/node": "^2.0.0", "@modelcontextprotocol/server": "^2.0.0", "zod": "^4.4.3" }, + "devDependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-sdk/credential-provider-node": "^3.750.0", + "@smithy/protocol-http": "^5.0.0", + "@smithy/signature-v4": "^5.0.0" + }, "engines": { "node": "24.x" } }, - "node_modules/@aws-sdk/client-bedrock-agentcore": { - "version": "3.1085.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-agentcore/-/client-bedrock-agentcore-3.1085.0.tgz", - "integrity": "sha512-IQeQxUxDznQBPNz2vFsCtcaTNTHtticFPZ9dnZywyT6RoQrIKCbRBEIRhTwp4Q2m6mvkEhz/61TtsLFSmyNO7w==", + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.975.1", - "@aws-sdk/credential-provider-node": "^3.972.66", - "@aws-sdk/types": "^3.974.0", - "@smithy/core": "^3.29.2", - "@smithy/fetch-http-handler": "^5.6.4", - "@smithy/node-http-handler": "^4.9.4", - "@smithy/types": "^4.16.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" } }, "node_modules/@aws-sdk/core": { "version": "3.975.1", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.1.tgz", "integrity": "sha512-8qh/6EYb7hl/ZwVfQufhbMEZs1gQIc7GbdrIf4eprQJ7cv042+74nE6l3YDfyWNzb9iPXb8fRyYSHkNIk5eE6Q==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.974.0", @@ -59,6 +73,7 @@ "version": "3.972.57", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.57.tgz", "integrity": "sha512-1RfJaF7SW1TOnvNGU7kaYjwUf5H3sfm+synGH1bHhRlqcnxCt3szebH3dmKEyY4tuGcbQ6ffzUT89cRitBV8OQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.975.1", @@ -75,6 +90,7 @@ "version": "3.972.59", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.59.tgz", "integrity": "sha512-sRCkpTiFnCdQvuaRVjQ6SVoHu6i7RUpurVo1c4F81HWhPvUJ7Wdp5MNtSdX1O29CNXc8em3O5m52hCjVtAD9SA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.975.1", @@ -93,6 +109,7 @@ "version": "3.973.1", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.1.tgz", "integrity": "sha512-6d8H6ZAh3ZPKZ6fe1nG2OWeZEZPtt9ravoD1dezPdPtsSkJRoxGAnFSHwKT3E/Te6fHE30zRzjV6TD12rvF6yQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.975.1", @@ -117,6 +134,7 @@ "version": "3.972.63", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.63.tgz", "integrity": "sha512-GREWRrMj0XnNKMaVa/Mauoaui26qBEHu71WWqXbwZOu/jFQOnPZjTf7u0KtGKC8VGa6VUs9kDWGgocrKNLS9vw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.975.1", @@ -134,6 +152,7 @@ "version": "3.972.66", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.66.tgz", "integrity": "sha512-f+qjRXZpz7sgzbc4QB+6nLKfyKFgRRXzWdXbsKPv/VhVRyHsDyq4yBWC/B75BAJpFIcUeI2XR/3gdWJ677zB4A==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.57", @@ -156,6 +175,7 @@ "version": "3.972.57", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.57.tgz", "integrity": "sha512-TiVQhuU0pbhIZAUZacbPHMyzrIdiH+lnx+PMY/Pu/b93dJrq3wdZwzUJ0TPpvNxaqbHsxJvQZW3/h/beLiKq7Q==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.975.1", @@ -172,6 +192,7 @@ "version": "3.973.1", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.1.tgz", "integrity": "sha512-3foTZUJ4821Ij60X7K3NJroygiZLnbBmarN+T//O2cjkISan90zElN3NBmgSlDrTQ7Gs6z/yO8V7h60QNcDZHQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.975.1", @@ -190,6 +211,7 @@ "version": "3.972.63", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.63.tgz", "integrity": "sha512-8qZLFhM69eKcS37m459ctPR05Qimycm/74OPVioe6wNZabMT54GYhwBju0+J656RkMasNSawWQu+c8CmBe3TUQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.975.1", @@ -207,6 +229,7 @@ "version": "3.997.31", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.31.tgz", "integrity": "sha512-BDHTpwcsZHEBNEJzOg/B1BkFYJxAXY50dau/NyVWs3d51F0WgIUGSWZot/Os+N3KpDhXeaXnz37mWffAvduREw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.975.1", @@ -226,6 +249,7 @@ "version": "3.996.39", "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.39.tgz", "integrity": "sha512-8+srXqYIF8KYMLC4FxMLEM5Ek7kUNibJu1R4m8/fUhhNYIZZz26oGtKkCr8I/HiG2fFQxBvaGgQZT4/mqRCSnA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.974.0", @@ -241,6 +265,7 @@ "version": "3.1083.0", "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1083.0.tgz", "integrity": "sha512-s0woKnxuHrExLc5L2ArIH5BMkbonHPtt+5hSBM8oknp9M6QTuUmmAmJ2E0EdzCGONrO+8+ADPqvv6UX0nNcc7A==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.975.1", @@ -258,6 +283,7 @@ "version": "3.974.0", "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz", "integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.16.0", @@ -271,6 +297,7 @@ "version": "3.972.34", "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.34.tgz", "integrity": "sha512-wHhWL1y7sN3enBA8POrPpQM5jCcmu2ozyhbRei4c8OjVcEaEs6yLucLa/pla457ggS/ysuy7bosagz3HaJkZXA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.16.0", @@ -284,6 +311,7 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18.0.0" @@ -348,9 +376,10 @@ } }, "node_modules/@smithy/core": { - "version": "3.29.3", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.3.tgz", - "integrity": "sha512-L+Ys6ecjk5vwPMAKHBpPKlJ3DkqwNcnfEISXBZIsVvWG/XKXfsAP8mwIYlTeLcd2ElHdesPI8OuOmJSFAPhm6A==", + "version": "3.31.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.1.tgz", + "integrity": "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.16.1", @@ -364,6 +393,7 @@ "version": "4.4.8", "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.8.tgz", "integrity": "sha512-q9J7JTiXrAhB8sDp4px97uEPT7CwKH61Co78grdNQvU8QZAdiuaSRhP0tUVf2ogy36RZTrlMU1rBmDEH+cnkiA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.29.3", @@ -378,6 +408,7 @@ "version": "5.6.5", "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.5.tgz", "integrity": "sha512-SuqeisTyPoiIPtIYru/sGxGyXzmZ+8nnFOhC+qRPglt06Ebd1yH//CDltZB2J/3WBNVhwfUaZ0EtHB3cm2X32g==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.29.3", @@ -388,10 +419,24 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@smithy/node-http-handler": { "version": "4.9.5", "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.5.tgz", "integrity": "sha512-bNqdxTQTxmLbomSmlkZFz8L6B/feQ2HHzw4L2zY7Ecp2XffYAZq2uzdWDdxJHJFbEvqd+SRuluJso0P8+xPdbw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.29.3", @@ -402,10 +447,25 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/protocol-http": { + "version": "5.5.16", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.5.16.tgz", + "integrity": "sha512-iPaxIe9mTyidyhM6WF2/gSLPezyCwSlZcqlDBj2KCoSS994iw4yf1se1xmZkWsY98ZpwjDR/ZMubOSVx+Nrokw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.31.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/signature-v4": { "version": "5.6.4", "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.4.tgz", "integrity": "sha512-B89bpf2t/y/wia6LZ+4JfHXYQT9PnVftsH05rgJKKIStS7r/4XSs9HOjtPoLtgcA6HCW9jVqX5DBbq7E0PAkiQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.29.3", @@ -420,6 +480,7 @@ "version": "4.16.1", "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -428,10 +489,39 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, "license": "MIT" }, "node_modules/hono": { @@ -448,6 +538,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD" }, "node_modules/zod": { diff --git a/aws-bedrock-agentcore/javascript/mcp-server/package.json b/aws-bedrock-agentcore/javascript/mcp-server/package.json index f1bbe79cc..56c8ab68c 100644 --- a/aws-bedrock-agentcore/javascript/mcp-server/package.json +++ b/aws-bedrock-agentcore/javascript/mcp-server/package.json @@ -8,12 +8,17 @@ "start": "node index.js" }, "dependencies": { - "@aws-sdk/client-bedrock-agentcore": "^3.993.0", "@modelcontextprotocol/node": "^2.0.0", "@modelcontextprotocol/server": "^2.0.0", "zod": "^4.4.3" }, "engines": { "node": "24.x" + }, + "devDependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-sdk/credential-provider-node": "^3.750.0", + "@smithy/protocol-http": "^5.0.0", + "@smithy/signature-v4": "^5.0.0" } -} +} \ No newline at end of file diff --git a/aws-bedrock-agentcore/javascript/mcp-server/serverless.yml b/aws-bedrock-agentcore/javascript/mcp-server/serverless.yml index b7886e0d7..1400841bf 100644 --- a/aws-bedrock-agentcore/javascript/mcp-server/serverless.yml +++ b/aws-bedrock-agentcore/javascript/mcp-server/serverless.yml @@ -15,3 +15,11 @@ ai: agents: assistant: protocol: MCP + # Forward the MCP standard headers to the container - protocol revision + # 2026-07-28 requires Mcp-Method / Mcp-Name on every request, and the + # SDK validates them against the body: + requestHeaders: + allowlist: + - Mcp-Method + - Mcp-Name + - Mcp-Protocol-Version diff --git a/aws-bedrock-agentcore/javascript/mcp-server/test-invoke.js b/aws-bedrock-agentcore/javascript/mcp-server/test-invoke.js index f9b503acc..ce1287c28 100644 --- a/aws-bedrock-agentcore/javascript/mcp-server/test-invoke.js +++ b/aws-bedrock-agentcore/javascript/mcp-server/test-invoke.js @@ -1,30 +1,45 @@ #!/usr/bin/env node /** - * Test script to invoke the MCP Server deployed to AgentCore Runtime. + * Test script for the MCP Server deployed to AgentCore Runtime. * - * Uses the stateless MCP 2026-07-28 protocol: every request is - * self-contained - no initialize handshake and no session bookkeeping. + * Calls the runtime's HTTPS MCP endpoint directly - the same way real MCP + * clients connect - using the stateless MCP 2026-07-28 protocol: every + * request is self-contained, with the required Mcp-* headers attached and + * no session bookkeeping. Requests are signed with SigV4 (IAM inbound auth, + * the runtime default). * * Usage: - * RUNTIME_ARN=arn:aws:bedrock-agentcore:... node test-invoke.js + * ENDPOINT= node test-invoke.js */ -import { - BedrockAgentCoreClient, - InvokeAgentRuntimeCommand, -} from '@aws-sdk/client-bedrock-agentcore' +import { SignatureV4 } from '@smithy/signature-v4' +import { HttpRequest } from '@smithy/protocol-http' +import { Sha256 } from '@aws-crypto/sha256-js' +import { defaultProvider } from '@aws-sdk/credential-provider-node' -const RUNTIME_ARN = process.env.RUNTIME_ARN +const ENDPOINT = process.env.ENDPOINT const REGION = process.env.AWS_REGION || 'us-east-1' -if (!RUNTIME_ARN) { - console.error('Error: RUNTIME_ARN environment variable is required.') - console.error('Usage: RUNTIME_ARN= node test-invoke.js') - console.error('\nGet your runtime ARN from: serverless info') +if (!ENDPOINT) { + console.error('Error: ENDPOINT environment variable is required.') + console.error('Usage: ENDPOINT= node test-invoke.js') process.exit(1) } -const client = new BedrockAgentCoreClient({ region: REGION }) +// The runtime URL embeds the agent ARN - the WHOLE ARN must be URL-encoded in +// the path (including its inner "/"), and MCP-protocol runtimes serve under +// /invocations?qualifier=DEFAULT. +const raw = new URL(ENDPOINT) +const arn = decodeURIComponent(raw.pathname.match(/\/runtimes\/(.+)\/invocations/)[1]) +const url = new URL(`${raw.origin}/runtimes/${encodeURIComponent(arn)}/invocations`) +if (!url.searchParams.has('qualifier')) url.searchParams.set('qualifier', 'DEFAULT') + +const signer = new SignatureV4({ + service: 'bedrock-agentcore', + region: REGION, + credentials: defaultProvider(), + sha256: Sha256, +}) // The per-request envelope required by protocol revision 2026-07-28: const META = { @@ -34,45 +49,50 @@ const META = { } let id = 0 -async function invoke(method, params = {}) { - const payload = { +async function invoke(method, params = {}, toolName) { + const body = JSON.stringify({ jsonrpc: '2.0', id: ++id, method, params: { ...params, _meta: { ...META, ...(params._meta ?? {}) } }, - } - const command = new InvokeAgentRuntimeCommand({ - agentRuntimeArn: RUNTIME_ARN, - qualifier: 'DEFAULT', - payload: Buffer.from(JSON.stringify(payload)), - contentType: 'application/json', - accept: 'application/json, text/event-stream', }) - const response = await client.send(command) - return JSON.parse(await streamToString(response.response)) -} - -async function streamToString(stream) { - if (typeof stream === 'string') return stream - if (stream instanceof Uint8Array || Buffer.isBuffer(stream)) { - return new TextDecoder().decode(stream) - } - const chunks = [] - for await (const chunk of stream) { - chunks.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)) - } - return chunks.join('') + const request = new HttpRequest({ + method: 'POST', + protocol: url.protocol, + hostname: url.hostname, + path: url.pathname, + query: Object.fromEntries(url.searchParams), + headers: { + host: url.hostname, + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': method, + ...(toolName && { 'mcp-name': toolName }), + }, + body, + }) + const signed = await signer.sign(request) + const res = await fetch(`${url.origin}${url.pathname}?${url.searchParams}`, { + method: 'POST', + headers: signed.headers, + body, + }) + const text = await res.text() + // Responses may arrive as plain JSON or as a single SSE event + const data = text.startsWith('event:') || text.startsWith('data:') + ? text.split('\n').find((l) => l.startsWith('data:'))?.slice(5) + : text + return { status: res.status, ...JSON.parse(data) } } async function main() { console.log('MCP Server Test Suite (stateless, protocol 2026-07-28)') - console.log(`Runtime ARN: ${RUNTIME_ARN}`) - console.log(`Region: ${REGION}`) + console.log(`Endpoint: ${url.origin}${url.pathname}`) console.log('='.repeat(50) + '\n') console.log('=== server/discover ===') const discover = await invoke('server/discover') - console.log('Server:', discover.result?.serverInfo?.name, discover.result?.serverInfo?.version) console.log('Supported versions:', discover.result?.supportedVersions) console.log() @@ -90,7 +110,7 @@ async function main() { ] for (const [name, args] of calls) { console.log(`=== tools/call: ${name}(${JSON.stringify(args)}) ===`) - const result = await invoke('tools/call', { name, arguments: args }) + const result = await invoke('tools/call', { name, arguments: args }, name) const text = result.result?.content?.[0]?.text ?? JSON.stringify(result.result ?? result.error) console.log(` Result: ${text}`) if (result.result?.structuredContent) { diff --git a/aws-node-mcp-server/src/server.mjs b/aws-node-mcp-server/src/server.mjs index 50651ce37..7f0cba526 100644 --- a/aws-node-mcp-server/src/server.mjs +++ b/aws-node-mcp-server/src/server.mjs @@ -56,9 +56,12 @@ export default createMcpHandler(() => { // More SDK capabilities to explore (uncomment and adapt): // ------------------------------------------------------------------------- - // Elicitation - pause a tool call to ask the user for input (the client - // retries the request with the answers attached; protect the round-trip - // state with the SDK's requestState codec - see its docs): + // Elicitation - pause a tool call to ask the user for input. The handler + // returns an input_required result; the client asks the user and retries + // the call with the answers attached, re-entering the handler. Add + // acceptedContent and inputRequired to the imports above. To protect + // round-trip state that influences your logic, use the SDK's + // createRequestStateCodec with a key from KMS/Secrets Manager. // // server.registerTool( // 'approve_refund', @@ -67,16 +70,18 @@ export default createMcpHandler(() => { // inputSchema: z.object({ orderId: z.string() }), // }, // async ({ orderId }, ctx) => { - // const result = await ctx.mcpReq.elicitInput({ - // mode: 'form', - // message: `Refund order ${orderId}?`, - // requestedSchema: { - // type: 'object', - // properties: { confirmed: { type: 'boolean' } }, - // required: ['confirmed'], - // }, - // }) - // if (result.action !== 'accept' || !result.content?.confirmed) { + // const answer = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', z.object({ confirmed: z.boolean() })) + // if (answer === undefined) { + // return inputRequired({ + // inputRequests: { + // confirm: inputRequired.elicit({ + // message: `Refund order ${orderId}?`, + // requestedSchema: z.object({ confirmed: z.boolean() }), + // }), + // }, + // }) + // } + // if (!answer.confirmed) { // return { content: [{ type: 'text', text: 'refund cancelled' }] } // } // return { content: [{ type: 'text', text: `refunded ${orderId}` }] } From 0a6f1e038798bcd2c4476220ffe4a8484679ffff Mon Sep 17 00:00:00 2001 From: Tomasz Czubocha Date: Wed, 29 Jul 2026 18:48:31 +0200 Subject: [PATCH 4/9] docs: correct elicitation description to return-style MRTR API --- aws-node-mcp-server/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws-node-mcp-server/README.md b/aws-node-mcp-server/README.md index 9b0f4fff5..5158b5da9 100644 --- a/aws-node-mcp-server/README.md +++ b/aws-node-mcp-server/README.md @@ -57,7 +57,7 @@ Older MCP clients that still send the `initialize` handshake are answered too The example is deliberately minimal; commented blocks show how to extend it: - **Authentication** (`serverless.yml` + `src/server.mjs`): either attach an API Gateway authorizer that validates Bearer JWTs before the function is invoked, or use the SDK's in-process `requireBearerAuth` with a `jose`-based verifier for any OpenID Connect provider. Pair it with an unauthenticated `/.well-known/oauth-protected-resource` route (the SDK's `oauthMetadataResponse` helper builds the document) so MCP clients can discover how to log in. -- **Elicitation** (`src/server.mjs`): a tool can pause mid-call and ask the user for input via `ctx.mcpReq.elicitInput(...)`; the client retries the request with the answers attached. +- **Elicitation** (`src/server.mjs`): a tool can pause mid-call and ask the user for input by returning `inputRequired(...)`; the client asks the user and retries the call with the answers attached, and the handler reads them with `acceptedContent(...)` on re-entry. - **Resources**: expose readable documents next to tools with `registerResource`. - **Long-running tools**: `timeoutInMillis` on the route and the function `timeout` can be raised up to 15 minutes. From 3a18186fc7f734892edf0bcb5b3eb531450cf280 Mon Sep 17 00:00:00 2001 From: Tomasz Czubocha Date: Wed, 29 Jul 2026 19:03:35 +0200 Subject: [PATCH 5/9] feat: add MCP server on Lambda Function URL example; verify optional features on both front doors --- README.md | 1 + aws-node-mcp-server-function-url/README.md | 70 ++++++++++ aws-node-mcp-server-function-url/client.mjs | 67 ++++++++++ .../package-lock.json | 94 ++++++++++++++ aws-node-mcp-server-function-url/package.json | 12 ++ .../serverless.yml | 25 ++++ .../src/lambda.mjs | 69 ++++++++++ .../src/server.mjs | 120 ++++++++++++++++++ aws-node-mcp-server/README.md | 2 + aws-node-mcp-server/serverless.yml | 2 +- examples.json | 12 ++ 11 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 aws-node-mcp-server-function-url/README.md create mode 100644 aws-node-mcp-server-function-url/client.mjs create mode 100644 aws-node-mcp-server-function-url/package-lock.json create mode 100644 aws-node-mcp-server-function-url/package.json create mode 100644 aws-node-mcp-server-function-url/serverless.yml create mode 100644 aws-node-mcp-server-function-url/src/lambda.mjs create mode 100644 aws-node-mcp-server-function-url/src/server.mjs diff --git a/README.md b/README.md index 6341d47fe..f4f6f0fb2 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ serverless deploy | [GraphQL query endpoint in NodeJS on AWS with DynamoDB](https://github.com/serverless/examples/tree/v4/aws-node-graphql-api-with-dynamodb)
A single-module GraphQL endpoint with query and mutation functionality. | nodeJS | | [AWS Serverless IoT Event example in NodeJS](https://github.com/serverless/examples/tree/v4/aws-node-iot-event)
This example demonstrates how to setup a AWS IoT Rule to send events to a Lambda function. | nodeJS | | [AWS MCP Server on Lambda (NodeJS)](https://github.com/serverless/examples/tree/v4/aws-node-mcp-server)
Run an MCP server built with the official MCP TypeScript SDK on AWS Lambda, with streaming responses through API Gateway. | nodeJS | +| [AWS MCP Server on Lambda Function URL (NodeJS)](https://github.com/serverless/examples/tree/v4/aws-node-mcp-server-function-url)
Run an MCP server built with the official MCP TypeScript SDK on a Lambda Function URL with response streaming - no API Gateway. | nodeJS | | [Node.js AWS Lambda connecting to MongoDB Atlas](https://github.com/serverless/examples/tree/v4/aws-node-mongodb-atlas)
Shows how to connect AWS Lambda to MongoDB Atlas. | nodeJS | | [Running Puppeteer on AWS Lambda](https://github.com/serverless/examples/tree/v4/aws-node-puppeteer)
This example shows you how to run Puppeteer on AWS Lambda | nodeJS | | [AWS Recursive Lambda function Invocation example in NodeJS](https://github.com/serverless/examples/tree/v4/aws-node-recursive-function)
This is an example of a function that will recursively call itself. | nodeJS | diff --git a/aws-node-mcp-server-function-url/README.md b/aws-node-mcp-server-function-url/README.md new file mode 100644 index 000000000..c345f1f71 --- /dev/null +++ b/aws-node-mcp-server-function-url/README.md @@ -0,0 +1,70 @@ + + +# MCP Server on a Lambda Function URL + +Run a [Model Context Protocol](https://modelcontextprotocol.io) server built with the **official [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)** on a Lambda Function URL with response streaming — the leanest MCP hosting on AWS: no API Gateway, no per-request fee, and streamed responses bounded only by the function timeout. + +The MCP `2026-07-28` protocol revision made servers stateless — every request is self-contained, so any request can be served by any Lambda execution environment. Three parts: + +- **`src/server.mjs`** — a standard SDK v2 server (`createMcpHandler` + `registerTool` with zod schemas). Nothing Lambda-specific. +- **`src/lambda.mjs`** — ~80 lines of adapter glue mapping the Function URL event and the Lambda response stream onto the Node-style shapes that `@modelcontextprotocol/node` accepts. No MCP logic. +- **`serverless.yml`** — a Function URL with `invokeMode: RESPONSE_STREAM`. + +Plain JSON and streaming SSE coexist on the one endpoint: simple tool calls return ordinary `application/json`, while calls that request progress stream `text/event-stream` with notifications arriving ahead of the final result. + +**Related example:** [`aws-node-mcp-server`](../aws-node-mcp-server) serves the same MCP server through an API Gateway REST API in stream mode instead — pick that one when you want a custom domain, WAF, throttling, or an authorizer that rejects requests before they invoke the function; pick this one for the simplest and cheapest setup. + +## Usage + +### Deploy + +```bash +npm install +serverless deploy +``` + +### Test + +```bash +ENDPOINT= node client.mjs +``` + +The client demonstrates both response modes: + +``` +=== tools/call (add) -> HTTP 200 (application/json) === +{"result":{"content":[{"type":"text","text":"42"}],"structuredContent":{"sum":42},"resultType":"complete",...}} + +=== tools/call (slow_report) -> HTTP 200 (text/event-stream) === +[+1200ms] {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"demo","progress":1,"total":3,...}} +[+2000ms] {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"demo","progress":2,"total":3,...}} +[+2800ms] {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"demo","progress":3,"total":3,...}} +[+2800ms] {"result":{"content":[{"type":"text","text":"completed 3 steps"}],"resultType":"complete",...}} +``` + +Older MCP clients that still send the `initialize` handshake are answered too — the SDK serves earlier protocol revisions on the same endpoint by default. + +### Going further + +The example is deliberately minimal; commented blocks show how to extend it: + +- **Authentication** (`serverless.yml` + `src/server.mjs`): set `authorizer: aws_iam` for SigV4 callers, or keep the URL open and validate OAuth Bearer tokens in-process with the SDK's `requireBearerAuth` and a `jose`-based verifier — works with any OpenID Connect provider. +- **Elicitation** (`src/server.mjs`): a tool can pause mid-call and ask the user for input by returning `inputRequired(...)`; the client asks the user and retries the call with the answers attached, and the handler reads them with `acceptedContent(...)` on re-entry. +- **Resources**: expose readable documents next to tools with `registerResource`. +- **Long-running tools**: raise the function `timeout` — streamed responses run up to 15 minutes with no other configuration. + +### Cleanup + +```bash +serverless remove +``` diff --git a/aws-node-mcp-server-function-url/client.mjs b/aws-node-mcp-server-function-url/client.mjs new file mode 100644 index 000000000..58644b511 --- /dev/null +++ b/aws-node-mcp-server-function-url/client.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * Try the deployed MCP server. + * Usage: ENDPOINT= node client.mjs + */ +const ENDPOINT = process.env.ENDPOINT +if (!ENDPOINT) { + console.error('Set ENDPOINT to the POST endpoint printed by `serverless deploy`') + process.exit(1) +} + +const META = { + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'example-client', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': {}, +} +let id = 0 + +async function call(method, params, name) { + const res = await fetch(ENDPOINT, { + method: 'POST', + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': method, + ...(name && { 'mcp-name': name }), + }, + body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method, params }), + }) + console.log(`\n=== ${method}${name ? ` (${name})` : ''} -> HTTP ${res.status} (${res.headers.get('content-type')}) ===`) + if (!res.headers.get('content-type')?.includes('text/event-stream')) { + console.log((await res.text()).slice(0, 800)) + return + } + // SSE: print each event as it arrives, with timing + const t0 = Date.now() + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + for (;;) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + let idx + while ((idx = buffer.indexOf('\n\n')) >= 0) { + const data = buffer.slice(0, idx).split('\n').find((l) => l.startsWith('data:'))?.slice(5).trim() + buffer = buffer.slice(idx + 2) + if (data) console.log(`[+${Date.now() - t0}ms]`, data.slice(0, 160)) + } + } +} + +// Plain JSON responses - streaming mode carries these unchanged: +await call('tools/list', { _meta: META }) +await call('tools/call', { name: 'add', arguments: { a: 2, b: 40 }, _meta: META }, 'add') + +// A streaming SSE response - progress notifications arrive before the result: +await call( + 'tools/call', + { + name: 'slow_report', + arguments: { steps: 3, progressToken: 'demo' }, + _meta: { ...META, progressToken: 'demo' }, + }, + 'slow_report', +) diff --git a/aws-node-mcp-server-function-url/package-lock.json b/aws-node-mcp-server-function-url/package-lock.json new file mode 100644 index 000000000..2cc98171a --- /dev/null +++ b/aws-node-mcp-server-function-url/package-lock.json @@ -0,0 +1,94 @@ +{ + "name": "aws-node-mcp-server-function-url", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aws-node-mcp-server-function-url", + "version": "1.0.0", + "dependencies": { + "@modelcontextprotocol/node": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "zod": "^4.2.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/node": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/node/-/node-2.0.0.tgz", + "integrity": "sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/server": "^2.0.0", + "hono": "^4.11.4" + }, + "peerDependenciesMeta": { + "hono": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hono": { + "version": "4.12.32", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", + "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/aws-node-mcp-server-function-url/package.json b/aws-node-mcp-server-function-url/package.json new file mode 100644 index 000000000..e3e73a29d --- /dev/null +++ b/aws-node-mcp-server-function-url/package.json @@ -0,0 +1,12 @@ +{ + "name": "aws-node-mcp-server-function-url", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "MCP server on a Lambda Function URL with response streaming - no API Gateway", + "dependencies": { + "@modelcontextprotocol/node": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "zod": "^4.2.0" + } +} \ No newline at end of file diff --git a/aws-node-mcp-server-function-url/serverless.yml b/aws-node-mcp-server-function-url/serverless.yml new file mode 100644 index 000000000..39ed2d9dc --- /dev/null +++ b/aws-node-mcp-server-function-url/serverless.yml @@ -0,0 +1,25 @@ +# MCP server on a Lambda Function URL with response streaming. +# +# The leanest possible MCP hosting on AWS: no API Gateway, no per-request +# fee - the Function URL invokes the Lambda directly, and response streaming +# carries both plain JSON and SSE on the same endpoint. +service: aws-node-mcp-server-function-url + +provider: + name: aws + runtime: nodejs22.x # the MCP SDK v2 requires Node.js 20+ + +functions: + mcp: + handler: src/lambda.handler + timeout: 120 # streamed responses can run up to the full function timeout + url: + invokeMode: RESPONSE_STREAM + # Function URLs support two auth modes. The default (no authorizer) is a + # public endpoint - fine for trying the example, but add auth for real + # deployments: + # - IAM (SigV4) for AWS-credentialed callers: + # authorizer: aws_iam + # - or OAuth for MCP clients (Claude, AI IDEs) validated in-process with + # the SDK's requireBearerAuth - see the commented block in + # src/server.mjs. diff --git a/aws-node-mcp-server-function-url/src/lambda.mjs b/aws-node-mcp-server-function-url/src/lambda.mjs new file mode 100644 index 000000000..b15e1c686 --- /dev/null +++ b/aws-node-mcp-server-function-url/src/lambda.mjs @@ -0,0 +1,69 @@ +/** + * Lambda Function URL adapter for a web-standard MCP handler. + * + * The MCP SDK's serving entry is a web-standard fetch handler; its + * @modelcontextprotocol/node package converts Node-style request/response + * objects to that interface using structural (duck-typed) shapes. A Function + * URL invocation with response streaming provides exactly such shapes: the + * HTTP v2 event maps to the request side, and the awslambda response stream + * maps to the response side. This file is that glue - no MCP logic. + */ +import { toNodeHandler } from '@modelcontextprotocol/node' +import mcpHandler from './server.mjs' + +const node = toNodeHandler(mcpHandler) + +/** Function URL (HTTP API v2.0) event -> Node-style request */ +function toNodeRequest(event) { + const headers = {} + for (const [k, v] of Object.entries(event.headers ?? {})) headers[k.toLowerCase()] = v + if (event.cookies?.length) headers.cookie = event.cookies.join('; ') + const body = + event.body == null ? null : Buffer.from(event.body, event.isBase64Encoded ? 'base64' : 'utf8') + return { + method: event.requestContext.http.method, + url: event.rawPath + (event.rawQueryString ? `?${event.rawQueryString}` : ''), + headers, + async *[Symbol.asyncIterator]() { + if (body) yield body + }, + } +} + +/** awslambda response stream -> Node-style response */ +function toNodeResponse(rawStream) { + let target = null // set once the handler calls writeHead + const pendingListeners = [] + return { + get destroyed() { + return (target ?? rawStream).destroyed === true + }, + writeHead(statusCode, headersRecord = {}) { + target = awslambda.HttpResponseStream.from(rawStream, { statusCode, headers: headersRecord }) + for (const [event, listener] of pendingListeners) target.on(event, listener) + return this + }, + write(chunk) { + return target.write(chunk) // real booleans keep backpressure working + }, + end(chunk) { + return target ? target.end(chunk) : rawStream.end(chunk) + }, + on(event, listener) { + ;(target ?? rawStream).on(event, listener) + if (!target) pendingListeners.push([event, listener]) + return this + }, + } +} + +export const handler = awslambda.streamifyResponse(async (event, responseStream) => { + const res = toNodeResponse(responseStream) + const finished = new Promise((resolve, reject) => { + responseStream.on('close', resolve) + responseStream.on('finish', resolve) + responseStream.on('error', reject) + }) + await node(toNodeRequest(event), res) + await finished +}) diff --git a/aws-node-mcp-server-function-url/src/server.mjs b/aws-node-mcp-server-function-url/src/server.mjs new file mode 100644 index 000000000..7f0cba526 --- /dev/null +++ b/aws-node-mcp-server-function-url/src/server.mjs @@ -0,0 +1,120 @@ +/** + * A standard MCP server built with the official MCP TypeScript SDK v2. + * Nothing in this file is Lambda-specific - the same handler runs on any + * web-standard host; src/lambda.mjs adapts it to Lambda. + */ +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server' +import { z } from 'zod' + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +export default createMcpHandler(() => { + const server = new McpServer({ name: 'aws-node-mcp-server', version: '1.0.0' }) + + // A plain tool: zod schemas next to the implementation; returning + // structuredContent lets clients consume typed results. + server.registerTool( + 'add', + { + description: 'Add two numbers', + inputSchema: z.object({ a: z.number(), b: z.number() }), + outputSchema: z.object({ sum: z.number() }), + }, + async ({ a, b }) => { + const output = { sum: a + b } + return { + content: [{ type: 'text', text: String(output.sum) }], + structuredContent: output, + } + }, + ) + + // A long-running tool emitting progress notifications. When the client + // requests progress (a progressToken in _meta) and accepts text/event-stream, + // the response is served as SSE with notifications ahead of the final result. + server.registerTool( + 'slow_report', + { + description: 'Generate a report, reporting progress along the way', + inputSchema: z.object({ steps: z.number().default(3), progressToken: z.string().optional() }), + }, + async ({ steps, progressToken }, ctx) => { + for (let i = 1; i <= steps; i++) { + await sleep(800) + if (progressToken) { + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progressToken, progress: i, total: steps, message: `step ${i}` }, + }) + } + } + return { content: [{ type: 'text', text: `completed ${steps} steps` }] } + }, + ) + + // ------------------------------------------------------------------------- + // More SDK capabilities to explore (uncomment and adapt): + // ------------------------------------------------------------------------- + + // Elicitation - pause a tool call to ask the user for input. The handler + // returns an input_required result; the client asks the user and retries + // the call with the answers attached, re-entering the handler. Add + // acceptedContent and inputRequired to the imports above. To protect + // round-trip state that influences your logic, use the SDK's + // createRequestStateCodec with a key from KMS/Secrets Manager. + // + // server.registerTool( + // 'approve_refund', + // { + // description: 'Refund an order after user confirmation', + // inputSchema: z.object({ orderId: z.string() }), + // }, + // async ({ orderId }, ctx) => { + // const answer = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', z.object({ confirmed: z.boolean() })) + // if (answer === undefined) { + // return inputRequired({ + // inputRequests: { + // confirm: inputRequired.elicit({ + // message: `Refund order ${orderId}?`, + // requestedSchema: z.object({ confirmed: z.boolean() }), + // }), + // }, + // }) + // } + // if (!answer.confirmed) { + // return { content: [{ type: 'text', text: 'refund cancelled' }] } + // } + // return { content: [{ type: 'text', text: `refunded ${orderId}` }] } + // }, + // ) + + // Resources - expose readable documents next to tools: + // + // server.registerResource( + // 'orders', + // 'orders://recent', + // { description: 'Recent orders' }, + // async () => ({ contents: [{ uri: 'orders://recent', text: JSON.stringify([]) }] }), + // ) + + return server +}) + +// --------------------------------------------------------------------------- +// In-process OAuth (alternative to an API Gateway authorizer): the SDK ships +// bearer-token middleware and OAuth discovery-document helpers. Wrap the +// handler's fetch face before exporting - any OIDC provider works: +// +// import { requireBearerAuth, oauthMetadataResponse } from '@modelcontextprotocol/server' +// import { createRemoteJWKSet, jwtVerify } from 'jose' +// +// const jwks = createRemoteJWKSet(new URL('https:///.well-known/jwks.json')) +// const verifier = { +// async verifyAccessToken(token) { +// const { payload } = await jwtVerify(token, jwks, { +// issuer: 'https:///', +// audience: 'https://', +// }) +// return { token, clientId: payload.azp, scopes: payload.scope?.split(' ') ?? [], expiresAt: payload.exp } +// }, +// } diff --git a/aws-node-mcp-server/README.md b/aws-node-mcp-server/README.md index 5158b5da9..5bea40d2a 100644 --- a/aws-node-mcp-server/README.md +++ b/aws-node-mcp-server/README.md @@ -22,6 +22,8 @@ The MCP `2026-07-28` protocol revision made servers stateless — every request Plain JSON and streaming SSE coexist on the one endpoint: simple tool calls return ordinary `application/json` responses, while calls that request progress stream `text/event-stream` with notifications arriving ahead of the final result. +**Related example:** [`aws-node-mcp-server-function-url`](../aws-node-mcp-server-function-url) serves the same MCP server through a Lambda Function URL instead — no API Gateway, no per-request fee. Pick this example when you want a custom domain, WAF, throttling, or an authorizer that rejects requests before they invoke the function. + ## Usage ### Deploy diff --git a/aws-node-mcp-server/serverless.yml b/aws-node-mcp-server/serverless.yml index dded81b1f..31bf049db 100644 --- a/aws-node-mcp-server/serverless.yml +++ b/aws-node-mcp-server/serverless.yml @@ -22,7 +22,7 @@ functions: # alongside it (up to 15 minutes): timeoutInMillis: 120000 response: - transferMode: STREAM + transferMode: stream # To reject unauthenticated requests before they invoke the function, # attach an authorizer (see the jwtAuthorizer function below), or use # the SDK's in-process bearer auth (see src/server.mjs): diff --git a/examples.json b/examples.json index 01614e0bf..9905d8d69 100644 --- a/examples.json +++ b/examples.json @@ -301,6 +301,18 @@ "authorName": "Serverless, Inc.", "authorAvatar": "https://avatars1.githubusercontent.com/u/13742415?s=200&v=4" }, + { + "title": "AWS MCP Server on Lambda Function URL (NodeJS)", + "name": "aws-node-mcp-server-function-url", + "description": "Run an MCP server built with the official MCP TypeScript SDK on a Lambda Function URL with response streaming - no API Gateway.", + "githubUrl": "https://github.com/serverless/examples/tree/v4/aws-node-mcp-server-function-url", + "framework": "v4", + "language": "node", + "platform": "aws", + "authorLink": "https://github.com/serverless", + "authorName": "Serverless, Inc.", + "authorAvatar": "https://avatars1.githubusercontent.com/u/13742415?s=200&v=4" + }, { "title": "Node.js AWS Lambda connecting to MongoDB Atlas", "name": "aws-node-mongodb-atlas", From 85e334b5af0c03a7f21292473d3a232dba0d4e8c Mon Sep 17 00:00:00 2001 From: Tomasz Czubocha Date: Wed, 29 Jul 2026 21:46:44 +0200 Subject: [PATCH 6/9] feat: unified aws-mcp-servers example family with shared canonical server and test client - new top-level aws-mcp-servers/ hub: rest-api, function-url, hono (official @modelcontextprotocol/hono + streamHandle), express-web-adapter (LWA zip), fastify-container (LWA container image), umbrella README with comparison tables, pointer stubs to the AgentCore-hosted variants - every example serves the same canonical MCP server (tools with streamed progress and an elicitation round-trip, resources incl. a template, a prompt, server/discover instructions, cache hints) and ships the same client.mjs (12 protocol checks incl. -32020/-32021 negatives, LONG=1 36s streaming case, SigV4 mode) - AgentCore Runtime mcp-server example aligned to the same server surface - all six examples live-verified 13/13 with the shared client --- README.md | 7 +- .../javascript/mcp-server/README.md | 47 +- .../javascript/mcp-server/client.mjs | 316 ++++++ .../javascript/mcp-server/index.js | 171 +-- .../javascript/mcp-server/serverless.yml | 15 +- .../javascript/mcp-server/src/server.mjs | 193 ++++ .../javascript/mcp-server/test-invoke.js | 128 --- aws-mcp-servers/README.md | 79 ++ aws-mcp-servers/agentcore-gateway/README.md | 5 + aws-mcp-servers/agentcore-runtime/README.md | 5 + aws-mcp-servers/express-web-adapter/README.md | 82 ++ .../express-web-adapter/client.mjs | 316 ++++++ .../express-web-adapter/package-lock.json | 998 ++++++++++++++++++ .../express-web-adapter/package.json | 15 + aws-mcp-servers/express-web-adapter/run.sh | 2 + .../express-web-adapter/serverless.yml | 54 + .../express-web-adapter/src/authorizer.mjs | 33 + .../express-web-adapter/src/index.mjs | 29 + .../express-web-adapter/src/server.mjs | 193 ++++ aws-mcp-servers/fastify-container/Dockerfile | 24 + aws-mcp-servers/fastify-container/README.md | 80 ++ aws-mcp-servers/fastify-container/client.mjs | 316 ++++++ .../fastify-container/package-lock.json | 741 +++++++++++++ .../fastify-container/package.json | 14 + .../fastify-container/serverless.yml | 26 + .../fastify-container/src/index.mjs | 30 + .../fastify-container/src/server.mjs | 193 ++++ aws-mcp-servers/function-url/README.md | 77 ++ aws-mcp-servers/function-url/client.mjs | 316 ++++++ .../function-url}/package-lock.json | 4 +- .../function-url}/package.json | 4 +- .../function-url}/serverless.yml | 5 +- .../function-url}/src/lambda.mjs | 0 aws-mcp-servers/function-url/src/server.mjs | 193 ++++ aws-mcp-servers/hono/README.md | 73 ++ aws-mcp-servers/hono/client.mjs | 316 ++++++ aws-mcp-servers/hono/package-lock.json | 74 ++ aws-mcp-servers/hono/package.json | 13 + aws-mcp-servers/hono/serverless.yml | 23 + aws-mcp-servers/hono/src/lambda.mjs | 22 + aws-mcp-servers/hono/src/server.mjs | 193 ++++ aws-mcp-servers/rest-api/README.md | 78 ++ aws-mcp-servers/rest-api/client.mjs | 316 ++++++ .../rest-api}/package-lock.json | 14 +- .../rest-api}/package.json | 3 +- .../rest-api}/serverless.yml | 16 +- aws-mcp-servers/rest-api/src/authorizer.mjs | 33 + .../rest-api}/src/lambda.mjs | 0 aws-mcp-servers/rest-api/src/server.mjs | 193 ++++ aws-node-mcp-server-function-url/README.md | 70 -- aws-node-mcp-server-function-url/client.mjs | 67 -- .../src/server.mjs | 120 --- aws-node-mcp-server/README.md | 70 -- aws-node-mcp-server/client.mjs | 67 -- aws-node-mcp-server/src/server.mjs | 120 --- examples.json | 48 +- 56 files changed, 5781 insertions(+), 859 deletions(-) create mode 100644 aws-bedrock-agentcore/javascript/mcp-server/client.mjs create mode 100644 aws-bedrock-agentcore/javascript/mcp-server/src/server.mjs delete mode 100644 aws-bedrock-agentcore/javascript/mcp-server/test-invoke.js create mode 100644 aws-mcp-servers/README.md create mode 100644 aws-mcp-servers/agentcore-gateway/README.md create mode 100644 aws-mcp-servers/agentcore-runtime/README.md create mode 100644 aws-mcp-servers/express-web-adapter/README.md create mode 100644 aws-mcp-servers/express-web-adapter/client.mjs create mode 100644 aws-mcp-servers/express-web-adapter/package-lock.json create mode 100644 aws-mcp-servers/express-web-adapter/package.json create mode 100755 aws-mcp-servers/express-web-adapter/run.sh create mode 100644 aws-mcp-servers/express-web-adapter/serverless.yml create mode 100644 aws-mcp-servers/express-web-adapter/src/authorizer.mjs create mode 100644 aws-mcp-servers/express-web-adapter/src/index.mjs create mode 100644 aws-mcp-servers/express-web-adapter/src/server.mjs create mode 100644 aws-mcp-servers/fastify-container/Dockerfile create mode 100644 aws-mcp-servers/fastify-container/README.md create mode 100644 aws-mcp-servers/fastify-container/client.mjs create mode 100644 aws-mcp-servers/fastify-container/package-lock.json create mode 100644 aws-mcp-servers/fastify-container/package.json create mode 100644 aws-mcp-servers/fastify-container/serverless.yml create mode 100644 aws-mcp-servers/fastify-container/src/index.mjs create mode 100644 aws-mcp-servers/fastify-container/src/server.mjs create mode 100644 aws-mcp-servers/function-url/README.md create mode 100644 aws-mcp-servers/function-url/client.mjs rename {aws-node-mcp-server => aws-mcp-servers/function-url}/package-lock.json (97%) rename {aws-node-mcp-server-function-url => aws-mcp-servers/function-url}/package.json (86%) rename {aws-node-mcp-server-function-url => aws-mcp-servers/function-url}/serverless.yml (85%) rename {aws-node-mcp-server-function-url => aws-mcp-servers/function-url}/src/lambda.mjs (100%) create mode 100644 aws-mcp-servers/function-url/src/server.mjs create mode 100644 aws-mcp-servers/hono/README.md create mode 100644 aws-mcp-servers/hono/client.mjs create mode 100644 aws-mcp-servers/hono/package-lock.json create mode 100644 aws-mcp-servers/hono/package.json create mode 100644 aws-mcp-servers/hono/serverless.yml create mode 100644 aws-mcp-servers/hono/src/lambda.mjs create mode 100644 aws-mcp-servers/hono/src/server.mjs create mode 100644 aws-mcp-servers/rest-api/README.md create mode 100644 aws-mcp-servers/rest-api/client.mjs rename {aws-node-mcp-server-function-url => aws-mcp-servers/rest-api}/package-lock.json (87%) rename {aws-node-mcp-server => aws-mcp-servers/rest-api}/package.json (85%) rename {aws-node-mcp-server => aws-mcp-servers/rest-api}/serverless.yml (70%) create mode 100644 aws-mcp-servers/rest-api/src/authorizer.mjs rename {aws-node-mcp-server => aws-mcp-servers/rest-api}/src/lambda.mjs (100%) create mode 100644 aws-mcp-servers/rest-api/src/server.mjs delete mode 100644 aws-node-mcp-server-function-url/README.md delete mode 100644 aws-node-mcp-server-function-url/client.mjs delete mode 100644 aws-node-mcp-server-function-url/src/server.mjs delete mode 100644 aws-node-mcp-server/README.md delete mode 100644 aws-node-mcp-server/client.mjs delete mode 100644 aws-node-mcp-server/src/server.mjs diff --git a/README.md b/README.md index f4f6f0fb2..03fc76ff5 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,11 @@ serverless deploy | [AWS Serverless Github Webhook Listener example in NodeJS](https://github.com/serverless/examples/tree/v4/aws-node-github-webhook-listener)
This service will listen to github webhooks fired by a given repository. | nodeJS | | [GraphQL query endpoint in NodeJS on AWS with DynamoDB](https://github.com/serverless/examples/tree/v4/aws-node-graphql-api-with-dynamodb)
A single-module GraphQL endpoint with query and mutation functionality. | nodeJS | | [AWS Serverless IoT Event example in NodeJS](https://github.com/serverless/examples/tree/v4/aws-node-iot-event)
This example demonstrates how to setup a AWS IoT Rule to send events to a Lambda function. | nodeJS | -| [AWS MCP Server on Lambda (NodeJS)](https://github.com/serverless/examples/tree/v4/aws-node-mcp-server)
Run an MCP server built with the official MCP TypeScript SDK on AWS Lambda, with streaming responses through API Gateway. | nodeJS | -| [AWS MCP Server on Lambda Function URL (NodeJS)](https://github.com/serverless/examples/tree/v4/aws-node-mcp-server-function-url)
Run an MCP server built with the official MCP TypeScript SDK on a Lambda Function URL with response streaming - no API Gateway. | nodeJS | +| [AWS MCP Server behind API Gateway REST (NodeJS)](https://github.com/serverless/examples/tree/v4/aws-mcp-servers/rest-api)
Run an MCP server built with the official MCP TypeScript SDK on AWS Lambda, with streaming responses through API Gateway REST response streaming. | nodeJS | +| [AWS MCP Server on Lambda Function URL (NodeJS)](https://github.com/serverless/examples/tree/v4/aws-mcp-servers/function-url)
Run an MCP server built with the official MCP TypeScript SDK on a Lambda Function URL with response streaming - no API Gateway. | nodeJS | +| [AWS MCP Server with Hono (NodeJS)](https://github.com/serverless/examples/tree/v4/aws-mcp-servers/hono)
Run an MCP server on AWS Lambda with the official MCP Hono integration and Hono's aws-lambda adapter - no hand-written Lambda glue at all. | nodeJS | +| [AWS MCP Server with Express and Lambda Web Adapter (NodeJS)](https://github.com/serverless/examples/tree/v4/aws-mcp-servers/express-web-adapter)
Run an MCP server as a plain Express app on AWS Lambda with Lambda Web Adapter - zip deployment, streaming through API Gateway REST. | nodeJS | +| [AWS MCP Server with Fastify in a Container Image (NodeJS)](https://github.com/serverless/examples/tree/v4/aws-mcp-servers/fastify-container)
Run an MCP server as a containerized Fastify app on AWS Lambda with Lambda Web Adapter - the same image runs on Fargate or anywhere else. | nodeJS | | [Node.js AWS Lambda connecting to MongoDB Atlas](https://github.com/serverless/examples/tree/v4/aws-node-mongodb-atlas)
Shows how to connect AWS Lambda to MongoDB Atlas. | nodeJS | | [Running Puppeteer on AWS Lambda](https://github.com/serverless/examples/tree/v4/aws-node-puppeteer)
This example shows you how to run Puppeteer on AWS Lambda | nodeJS | | [AWS Recursive Lambda function Invocation example in NodeJS](https://github.com/serverless/examples/tree/v4/aws-node-recursive-function)
This is an example of a function that will recursively call itself. | nodeJS | diff --git a/aws-bedrock-agentcore/javascript/mcp-server/README.md b/aws-bedrock-agentcore/javascript/mcp-server/README.md index 5f195b881..a87357a9f 100644 --- a/aws-bedrock-agentcore/javascript/mcp-server/README.md +++ b/aws-bedrock-agentcore/javascript/mcp-server/README.md @@ -12,27 +12,24 @@ authorAvatar: 'https://avatars1.githubusercontent.com/u/13742415?s=200&v=4' # MCP Server Example -A JavaScript MCP server deployed to AWS Bedrock AgentCore Runtime. Exposes simple tools via the [Model Context Protocol](https://modelcontextprotocol.io/) that can be consumed by any MCP client (Cursor, Claude Desktop, Amazon Q CLI, etc.). +A JavaScript MCP server deployed to AWS Bedrock AgentCore Runtime. Exposes tools via the [Model Context Protocol](https://modelcontextprotocol.io/) that can be consumed by any MCP client (Cursor, Claude Desktop, Amazon Q CLI, etc.). -## Tools - -| Tool | Description | -| ------------------ | ------------------------------------------------------ | -| `add` | Add two numbers together | -| `multiply` | Multiply two numbers together | -| `get_current_time` | Get the current date and time (with optional timezone) | +It serves the same canonical MCP server as the [aws-mcp-servers](../../../aws-mcp-servers/README.md) example family - tools (`add`, `slow_report` with streamed progress, `approve_refund` with an elicitation round-trip), resources (`guide://usage` plus an `orders://{orderId}` template), a prompt, `instructions` served via `server/discover`, and cache hints - so the family's shared test client works here too, and you can compare hosting on AgentCore Runtime against the Lambda-based options side by side. ## Prerequisites - Node.js 24.x - AWS account with credentials configured - Serverless Framework installed (`npm i -g serverless`) +- Docker running (the runtime is deployed as a container) ## Project structure ```text mcp-server/ -├── index.js # MCP server (official MCP SDK v2 + node:http) +├── index.js # HTTP layer: node:http + toNodeHandler on port 8000 +├── src/server.mjs # the canonical MCP server (official MCP SDK v2) +├── client.mjs # the family's shared test client ├── package.json # Dependencies and start script ├── serverless.yml # Serverless Framework configuration └── README.md @@ -47,32 +44,18 @@ sls deploy ## Test -The deploy output prints the runtime's MCP endpoint URL. The test script calls it directly - the same way real MCP clients connect - signing requests with your local AWS credentials (SigV4, the runtime's default inbound auth): +The deploy output prints the runtime's MCP endpoint URL. The shared client calls it directly - the same way real MCP clients connect - signing requests with your local AWS credentials (SigV4, the runtime's default inbound auth): ```bash -ENDPOINT= node test-invoke.js +AUTH=sigv4 SERVICE=bedrock-agentcore ENDPOINT= node client.mjs ``` ``` -=== tools/call: add({"a":5,"b":3}) === - Result: 8 - Structured: {"sum":8} -``` - -Note the `requestHeaders.allowlist` block in `serverless.yml`: protocol revision `2026-07-28` requires the `Mcp-Method` and `Mcp-Name` headers on every request, and the allowlist forwards them to the container. - -## Test - -The deploy output prints the runtime's MCP endpoint URL. The test script calls it directly - the same way real MCP clients connect - signing requests with your local AWS credentials (SigV4, the runtime's default inbound auth): - -```bash -ENDPOINT= node test-invoke.js -``` - -``` -=== tools/call: add({"a":5,"b":3}) === - Result: 8 - Structured: {"sum":8} +PASS 1. tools/list returns the canonical tools with cache hints — ttlMs=300000 cacheScope=public +PASS 2. add returns plain JSON with structured content — sum=42 +PASS 3. slow_report streams incremental progress over SSE — 3 progress events, first at +810ms +PASS 4. approve_refund elicitation round-trip (accept and cancel) — input_required -> refunded o-1 / refund cancelled +... ``` Note the `requestHeaders.allowlist` block in `serverless.yml`: protocol revision `2026-07-28` requires the `Mcp-Method` and `Mcp-Name` headers on every request, and the allowlist forwards them to the container. @@ -88,9 +71,7 @@ sls dev The server is built with the official [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) v2: `createMcpHandler` produces a web-standard handler serving the stateless MCP `2026-07-28` protocol revision (self-contained requests, `server/discover`, structured results) while answering older MCP clients through the SDK's built-in fallback on the same endpoint. `toNodeHandler` mounts it on a plain `node:http` server at `/mcp` on port 8000, the standard expected by AgentCore Runtime for MCP-protocol runtimes. The Runtime adds an `Mcp-Session-Id` header for its own session isolation; the stateless server accepts and ignores it. -The `serverless.yml` sets `protocol: MCP` on the agent, which tells AgentCore to route MCP traffic to the runtime. - -Commented blocks in `index.js` show optional capabilities to enable: progress notifications streamed over SSE from long-running tools, elicitation (a tool pausing to ask the user for input), and resources. +The `serverless.yml` sets `protocol: MCP` on the agent, which tells AgentCore to route MCP traffic to the runtime. A commented `authorizer.jwt` block shows platform OAuth: the runtime validates Bearer JWTs from any OpenID Connect provider before requests reach the container - all configuration, no code. **Related example:** [`mcp-server-lambda-tools`](../mcp-server-lambda-tools) inverts this trade — AWS runs the MCP server (AgentCore Gateway) and your code shrinks to plain Lambda functions with the tool schema declared in `serverless.yml`. Pick this example when you need full SDK-level control; pick that one for simple request/response tools with no MCP code at all. diff --git a/aws-bedrock-agentcore/javascript/mcp-server/client.mjs b/aws-bedrock-agentcore/javascript/mcp-server/client.mjs new file mode 100644 index 000000000..66f738e9a --- /dev/null +++ b/aws-bedrock-agentcore/javascript/mcp-server/client.mjs @@ -0,0 +1,316 @@ +#!/usr/bin/env node +/** + * Shared test client for the aws-mcp-servers examples. Every example serves + * the same canonical MCP server, so this same script tests all of them. + * + * Usage: + * ENDPOINT= node client.mjs + * LONG=1 ENDPOINT=... node client.mjs # adds a ~36s streaming case + * AUTH=sigv4 SERVICE=bedrock-agentcore ENDPOINT= node client.mjs + * AUTH=sigv4 SERVICE=lambda ENDPOINT=... node client.mjs # IAM-auth Function URL + * + * Exercises the full 2026-07-28 surface: plain JSON, SSE progress streaming, + * an elicitation round-trip, resources (static + template), prompts, + * server/discover, cache hints, the legacy fallback, and two negative cases + * (header mismatch -32020, missing capability -32021). + */ + +const ENDPOINT = process.env.ENDPOINT +if (!ENDPOINT) { + console.error('Set ENDPOINT to the MCP endpoint URL') + process.exit(1) +} + +// --- endpoint + optional SigV4 signing -------------------------------------- + +let url = new URL(ENDPOINT) +let signer +if (process.env.AUTH === 'sigv4') { + const service = process.env.SERVICE || 'lambda' + if (service === 'bedrock-agentcore' && url.pathname.includes('/runtimes/')) { + // AgentCore runtime URLs embed the agent ARN - the WHOLE ARN must be + // URL-encoded in the path (including its inner "/"). + const arn = decodeURIComponent(url.pathname.match(/\/runtimes\/(.+?)\/invocations/)[1]) + url = new URL(`${url.origin}/runtimes/${encodeURIComponent(arn)}/invocations`) + url.searchParams.set('qualifier', 'DEFAULT') + } + const [{ SignatureV4 }, { HttpRequest }, { Sha256 }, { defaultProvider }] = await Promise.all([ + import('@smithy/signature-v4'), + import('@smithy/protocol-http'), + import('@aws-crypto/sha256-js'), + import('@aws-sdk/credential-provider-node'), + ]) + const sigv4 = new SignatureV4({ + service, + region: process.env.AWS_REGION || 'us-east-1', + credentials: defaultProvider(), + sha256: Sha256, + }) + signer = async (method, headers, body) => { + const request = new HttpRequest({ + method, + protocol: url.protocol, + hostname: url.hostname, + path: url.pathname, + query: Object.fromEntries(url.searchParams), + headers: { host: url.hostname, ...headers }, + ...(body != null && { body }), + }) + return (await sigv4.sign(request)).headers + } +} + +// --- request helpers --------------------------------------------------------- + +const CAPS_ELICIT = { elicitation: { form: {} } } +const meta = (capabilities = {}) => ({ + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'aws-mcp-servers-client', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': capabilities, +}) + +let id = 0 +async function request({ method, params = {}, name, capabilities, headerMethod, envelope = true }) { + const body = JSON.stringify({ + jsonrpc: '2.0', + id: ++id, + method, + params: envelope ? { ...params, _meta: { ...meta(capabilities), ...(params._meta ?? {}) } } : params, + }) + let headers = { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...(envelope && { + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': headerMethod ?? method, + ...(name && { 'mcp-name': name }), + }), + } + if (signer) headers = await signer('POST', headers, body) + const t0 = Date.now() + const res = await fetch(url, { method: 'POST', headers, body }) + const contentType = res.headers.get('content-type') ?? '' + if (contentType.includes('text/event-stream')) { + const events = [] + const decoder = new TextDecoder() + let buffer = '' + for await (const chunk of res.body) { + buffer += decoder.decode(chunk, { stream: true }) + let sep + while ((sep = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, sep) + buffer = buffer.slice(sep + 2) + const data = frame.split('\n').find((l) => l.startsWith('data:'))?.slice(5).trim() + if (data) events.push({ t: Date.now() - t0, data: JSON.parse(data) }) + } + } + const final = events.at(-1)?.data + return { status: res.status, contentType, events, json: final, ms: Date.now() - t0 } + } + const text = await res.text() + let json + try { + json = JSON.parse(text) + } catch { + json = { raw: text } + } + return { status: res.status, contentType, events: [], json, ms: Date.now() - t0 } +} + +// --- tiny harness ------------------------------------------------------------ + +const results = [] +async function check(label, fn) { + try { + const detail = await fn() + results.push({ label, ok: true }) + console.log(`PASS ${label}${detail ? ` — ${detail}` : ''}`) + } catch (err) { + results.push({ label, ok: false }) + console.log(`FAIL ${label} — ${err.message}`) + } +} +const assert = (cond, msg) => { + if (!cond) throw new Error(msg) +} +// AgentCore Runtime replaces 4xx response bodies with its own error envelope +// (-32010, "Received error (400) ..."); the 400 status still proves the SDK +// rejected the request, so the negative cases accept that form there. +const rejectedWith = (json, code) => + json.error?.code === code || + (process.env.SERVICE === 'bedrock-agentcore' && + json.error?.code === -32010 && + /\(4\d\d\)/.test(json.error?.message ?? '')) + +// --- test cases ---------------------------------------------------------------- + +await check('1. tools/list returns the canonical tools with cache hints', async () => { + const r = await request({ method: 'tools/list' }) + assert(r.status === 200, `HTTP ${r.status}`) + assert(r.contentType.includes('application/json'), `content-type ${r.contentType}`) + const names = (r.json.result?.tools ?? []).map((t) => t.name).sort() + assert(JSON.stringify(names) === JSON.stringify(['add', 'approve_refund', 'slow_report']), `tools: ${names}`) + assert(r.json.result.ttlMs === 300000, `ttlMs ${r.json.result.ttlMs}`) + assert(r.json.result.cacheScope === 'public', `cacheScope ${r.json.result.cacheScope}`) + return `ttlMs=${r.json.result.ttlMs} cacheScope=${r.json.result.cacheScope}` +}) + +await check('2. add returns plain JSON with structured content', async () => { + const r = await request({ method: 'tools/call', params: { name: 'add', arguments: { a: 2, b: 40 } }, name: 'add' }) + assert(r.status === 200, `HTTP ${r.status}`) + assert(r.contentType.includes('application/json'), `content-type ${r.contentType}`) + assert(r.json.result?.structuredContent?.sum === 42, JSON.stringify(r.json)) + return 'sum=42' +}) + +await check('3. slow_report streams incremental progress over SSE', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'slow_report', arguments: { steps: 3, progressToken: 'pt' }, _meta: { progressToken: 'pt' } }, + name: 'slow_report', + }) + assert(r.status === 200, `HTTP ${r.status}`) + assert(r.contentType.includes('text/event-stream'), `content-type ${r.contentType}`) + const progress = r.events.filter((e) => e.data.method === 'notifications/progress') + assert(progress.length === 3, `${progress.length} progress events`) + assert(progress[0].t < r.ms - 500, 'first progress event did not arrive before the final result') + assert(r.json.result?.content?.[0]?.text === 'completed 3 steps', JSON.stringify(r.json)) + return `3 progress events, first at +${progress[0].t}ms, done in ${r.ms}ms` +}) + +await check('4. approve_refund elicitation round-trip (accept and cancel)', async () => { + const ask = await request({ + method: 'tools/call', + params: { name: 'approve_refund', arguments: { orderId: 'o-1' } }, + name: 'approve_refund', + capabilities: CAPS_ELICIT, + }) + assert(ask.json.result?.resultType === 'input_required', JSON.stringify(ask.json)) + assert(ask.json.result?.inputRequests?.confirm, 'missing inputRequests.confirm') + const accepted = await request({ + method: 'tools/call', + params: { + name: 'approve_refund', + arguments: { orderId: 'o-1' }, + inputResponses: { confirm: { action: 'accept', content: { confirmed: true } } }, + }, + name: 'approve_refund', + capabilities: CAPS_ELICIT, + }) + assert(accepted.json.result?.content?.[0]?.text === 'refunded o-1', JSON.stringify(accepted.json)) + const cancelled = await request({ + method: 'tools/call', + params: { + name: 'approve_refund', + arguments: { orderId: 'o-1' }, + inputResponses: { confirm: { action: 'accept', content: { confirmed: false } } }, + }, + name: 'approve_refund', + capabilities: CAPS_ELICIT, + }) + assert(cancelled.json.result?.content?.[0]?.text === 'refund cancelled', JSON.stringify(cancelled.json)) + return 'input_required -> refunded o-1 / refund cancelled' +}) + +await check('5. resources list + read (Mcp-Name carries the uri)', async () => { + const list = await request({ method: 'resources/list' }) + const uris = (list.json.result?.resources ?? []).map((r) => r.uri) + assert(uris.includes('guide://usage'), `resources: ${uris}`) + const read = await request({ method: 'resources/read', params: { uri: 'guide://usage' }, name: 'guide://usage' }) + assert(read.json.result?.contents?.[0]?.text?.startsWith('# Usage'), JSON.stringify(read.json)) + return 'guide://usage readable' +}) + +await check('6. resource template read with per-resource cache hint', async () => { + const templates = await request({ method: 'resources/templates/list' }) + const uriTemplates = (templates.json.result?.resourceTemplates ?? []).map((t) => t.uriTemplate) + assert(uriTemplates.includes('orders://{orderId}'), `templates: ${uriTemplates}`) + const read = await request({ method: 'resources/read', params: { uri: 'orders://o-42' }, name: 'orders://o-42' }) + const record = JSON.parse(read.json.result?.contents?.[0]?.text ?? '{}') + assert(record.orderId === 'o-42' && record.status === 'shipped', JSON.stringify(read.json)) + assert(read.json.result.ttlMs === 60000, `ttlMs ${read.json.result.ttlMs}`) + return `orders://o-42 -> ${record.status}, ttlMs=${read.json.result.ttlMs}` +}) + +await check('7. prompts list + get', async () => { + const list = await request({ method: 'prompts/list' }) + const names = (list.json.result?.prompts ?? []).map((p) => p.name) + assert(names.includes('summarize_order'), `prompts: ${names}`) + const got = await request({ + method: 'prompts/get', + params: { name: 'summarize_order', arguments: { orderId: 'o-7' } }, + name: 'summarize_order', + }) + const text = got.json.result?.messages?.[0]?.content?.text ?? '' + assert(text.includes('o-7'), JSON.stringify(got.json)) + return 'summarize_order fills its argument' +}) + +await check('8. server/discover surfaces the instructions', async () => { + const r = await request({ method: 'server/discover' }) + assert((r.json.result?.instructions ?? '').includes('Demo MCP server'), JSON.stringify(r.json.result ?? r.json)) + return 'instructions present' +}) + +await check('9. legacy initialize is answered on the same endpoint', async () => { + const r = await request({ + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'legacy', version: '1.0.0' } }, + envelope: false, + }) + assert(r.status === 200, `HTTP ${r.status}`) + const version = r.json.result?.protocolVersion ?? '' + assert(version.startsWith('2025-'), JSON.stringify(r.json)) + return `served as ${version}` +}) + +await check('10. GET is answered with the spec-mandated 405', async () => { + let headers = { accept: 'application/json, text/event-stream' } + if (signer) headers = await signer('GET', headers, null) + const res = await fetch(url, { method: 'GET', headers }) + assert(res.status === 405, `HTTP ${res.status}`) + return 'HTTP 405' +}) + +await check('11. Mcp-Method header mismatching the body is rejected (-32020)', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'add', arguments: { a: 1, b: 1 } }, + name: 'add', + headerMethod: 'tools/list', + }) + assert(rejectedWith(r.json, -32020), JSON.stringify(r.json)) + return r.json.error.code === -32020 ? '-32020' : 'rejected (platform-wrapped 400)' +}) + +await check('12. elicitation without the client capability is rejected (-32021)', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'approve_refund', arguments: { orderId: 'o-9' } }, + name: 'approve_refund', + capabilities: {}, + }) + assert(rejectedWith(r.json, -32021), JSON.stringify(r.json)) + return r.json.error.code === -32021 ? '-32021' : 'rejected (platform-wrapped 400)' +}) + +if (process.env.LONG) { + await check('13. LONG: 45-step stream (~36s) survives past the 29s mark', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'slow_report', arguments: { steps: 45, progressToken: 'long' }, _meta: { progressToken: 'long' } }, + name: 'slow_report', + }) + const progress = r.events.filter((e) => e.data.method === 'notifications/progress') + assert(progress.length === 45, `${progress.length} progress events`) + assert(r.json.result?.content?.[0]?.text === 'completed 45 steps', 'final result missing (stream cut?)') + assert(r.ms > 34000, `finished suspiciously fast (${r.ms}ms)`) + return `45 events over ${(r.ms / 1000).toFixed(1)}s, final result received` + }) +} + +// --- summary ------------------------------------------------------------------- + +const failed = results.filter((r) => !r.ok) +console.log(`\n${results.length - failed.length}/${results.length} passed`) +process.exit(failed.length ? 1 : 0) diff --git a/aws-bedrock-agentcore/javascript/mcp-server/index.js b/aws-bedrock-agentcore/javascript/mcp-server/index.js index ddf57483e..2ed85bb27 100644 --- a/aws-bedrock-agentcore/javascript/mcp-server/index.js +++ b/aws-bedrock-agentcore/javascript/mcp-server/index.js @@ -1,171 +1,22 @@ /** - * MCP Server for AWS Bedrock AgentCore Runtime + * MCP Server for AWS Bedrock AgentCore Runtime. * - * Built with the official MCP TypeScript SDK v2, serving the stateless - * MCP `2026-07-28` protocol revision - and answering older MCP clients - * through the SDK's built-in fallback on the same endpoint. + * Serves the canonical aws-mcp-servers example server (src/server.mjs) - + * built with the official MCP TypeScript SDK v2, speaking the stateless MCP + * 2026-07-28 protocol revision, with the SDK's built-in fallback for older + * clients on the same endpoint. * - * Tools: - * - add: Add two numbers - * - multiply: Multiply two numbers - * - get_current_time: Get the current date and time + * AgentCore Runtime expects the MCP endpoint on port 8000 at /mcp. + * toNodeHandler adapts Node's req/res to the SDK's web-standard handler. + * The platform adds an Mcp-Session-Id header for its own session isolation; + * stateless servers accept and ignore it. */ - import { createServer } from 'node:http' -import { createMcpHandler, McpServer } from '@modelcontextprotocol/server' import { toNodeHandler } from '@modelcontextprotocol/node' -import { z } from 'zod' - -// --------------------------------------------------------------------------- -// MCP server factory - the handler creates a fresh instance per request -// --------------------------------------------------------------------------- - -const mcpHandler = createMcpHandler(() => { - const server = new McpServer({ name: 'mcp-server', version: '1.0.0' }) - - server.registerTool( - 'add', - { - title: 'Addition', - description: 'Add two numbers together', - inputSchema: z.object({ - a: z.number().describe('First number'), - b: z.number().describe('Second number'), - }), - outputSchema: z.object({ sum: z.number() }), - }, - async ({ a, b }) => { - const output = { sum: a + b } - return { - content: [{ type: 'text', text: String(output.sum) }], - structuredContent: output, // typed results for structured-output clients - } - }, - ) - - server.registerTool( - 'multiply', - { - title: 'Multiplication', - description: 'Multiply two numbers together', - inputSchema: z.object({ - a: z.number().describe('First number'), - b: z.number().describe('Second number'), - }), - outputSchema: z.object({ product: z.number() }), - }, - async ({ a, b }) => { - const output = { product: a * b } - return { - content: [{ type: 'text', text: String(output.product) }], - structuredContent: output, - } - }, - ) - - server.registerTool( - 'get_current_time', - { - title: 'Current Time', - description: 'Get the current date and time. Optionally specify a timezone.', - inputSchema: z.object({ - timezone: z - .string() - .optional() - .describe('Timezone (e.g. "America/New_York", "Europe/London", "UTC")'), - }), - }, - async ({ timezone }) => { - const now = new Date().toLocaleString('en-US', { - timeZone: timezone || 'UTC', - dateStyle: 'full', - timeStyle: 'long', - }) - return { content: [{ type: 'text', text: now }] } - }, - ) - - // ------------------------------------------------------------------------- - // More SDK capabilities to explore (uncomment and adapt): - // ------------------------------------------------------------------------- - - // Progress notifications - long-running tools can stream progress over SSE - // to clients that request it (a progressToken in the request's _meta): - // - // server.registerTool( - // 'slow_report', - // { - // description: 'Generate a report, reporting progress along the way', - // inputSchema: z.object({ steps: z.number().default(3), progressToken: z.string().optional() }), - // }, - // async ({ steps, progressToken }, ctx) => { - // for (let i = 1; i <= steps; i++) { - // await new Promise((resolve) => setTimeout(resolve, 800)) - // if (progressToken) { - // await ctx.mcpReq.notify({ - // method: 'notifications/progress', - // params: { progressToken, progress: i, total: steps, message: `step ${i}` }, - // }) - // } - // } - // return { content: [{ type: 'text', text: `completed ${steps} steps` }] } - // }, - // ) - - // Elicitation - pause a tool call to ask the user for input; the client - // retries the request with the answers attached (multi round-trip requests): - // - // (also add acceptedContent and inputRequired to the imports above) - // - // server.registerTool( - // 'approve_action', - // { - // description: 'Perform an action after user confirmation', - // inputSchema: z.object({ action: z.string() }), - // }, - // async ({ action }, ctx) => { - // // First pass: no answers yet - return input_required so the client - // // asks the user and retries this call with the responses attached. - // const answer = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', z.object({ confirmed: z.boolean() })) - // if (answer === undefined) { - // return inputRequired({ - // inputRequests: { - // confirm: inputRequired.elicit({ - // message: `Proceed with ${action}?`, - // requestedSchema: z.object({ confirmed: z.boolean() }), - // }), - // }, - // }) - // } - // // Re-entry: the retried request carries the user's answer. - // if (!answer.confirmed) { - // return { content: [{ type: 'text', text: 'cancelled' }] } - // } - // return { content: [{ type: 'text', text: `${action} done` }] } - // }, - // ) - - // Resources - expose readable documents next to tools: - // - // server.registerResource( - // 'status', - // 'status://server', - // { description: 'Server status document' }, - // async () => ({ contents: [{ uri: 'status://server', text: 'all good' }] }), - // ) - - return server -}) - -// --------------------------------------------------------------------------- -// HTTP layer - AgentCore Runtime expects the MCP endpoint on port 8000 -// at /mcp. toNodeHandler adapts Node's req/res to the SDK's web-standard -// handler. The platform adds an Mcp-Session-Id header for its own session -// isolation; stateless servers accept and ignore it. -// --------------------------------------------------------------------------- +import mcp from './src/server.mjs' const PORT = 8000 -const node = toNodeHandler(mcpHandler) +const node = toNodeHandler(mcp) createServer((req, res) => { if (!req.url?.startsWith('/mcp')) { diff --git a/aws-bedrock-agentcore/javascript/mcp-server/serverless.yml b/aws-bedrock-agentcore/javascript/mcp-server/serverless.yml index 1400841bf..2c655b7d7 100644 --- a/aws-bedrock-agentcore/javascript/mcp-server/serverless.yml +++ b/aws-bedrock-agentcore/javascript/mcp-server/serverless.yml @@ -1,7 +1,9 @@ # MCP Server Example # -# A JavaScript MCP server deployed to AWS Bedrock AgentCore Runtime. -# Exposes tools (add, multiply, get_current_time) via the Model Context Protocol. +# A JavaScript MCP server deployed to AWS Bedrock AgentCore Runtime, serving +# the canonical aws-mcp-servers example server: tools (add, slow_report with +# streamed progress, approve_refund with an elicitation round-trip), +# resources, a prompt, server/discover instructions, and cache hints. service: mcp-server @@ -23,3 +25,12 @@ ai: - Mcp-Method - Mcp-Name - Mcp-Protocol-Version + + # Platform OAuth (config-only): have the runtime validate Bearer JWTs + # from any OpenID Connect provider before requests reach the container. + # The default (no authorizer) is IAM/SigV4 auth. + # authorizer: + # jwt: + # discoveryUrl: https:///.well-known/openid-configuration + # allowedAudience: + # - https:// diff --git a/aws-bedrock-agentcore/javascript/mcp-server/src/server.mjs b/aws-bedrock-agentcore/javascript/mcp-server/src/server.mjs new file mode 100644 index 000000000..fdf1476e6 --- /dev/null +++ b/aws-bedrock-agentcore/javascript/mcp-server/src/server.mjs @@ -0,0 +1,193 @@ +/** + * A standard MCP server built with the official MCP TypeScript SDK v2, + * serving the stateless MCP 2026-07-28 protocol revision (and answering + * older clients through the SDK's built-in fallback on the same endpoint). + * + * Nothing in this file is Lambda-specific - the same handler runs on any + * web-standard host. Every example in aws-mcp-servers/ serves this exact + * server; only the hosting glue around it differs. + * + * Surface: + * tools: add (plain JSON), slow_report (progress streaming), + * approve_refund (elicitation - pauses to ask the user) + * resources: guide://usage, orders://{orderId} (template) + * prompts: summarize_order + * extras: instructions (served via server/discover), cache hints + */ +import { + acceptedContent, + createMcpHandler, + inputRequired, + McpServer, + ResourceTemplate, +} from '@modelcontextprotocol/server' +import { z } from 'zod' + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +export default createMcpHandler(() => { + const server = new McpServer( + { name: 'aws-mcp-server', version: '1.0.0' }, + { + // Shown to clients that probe the server/discover method: + instructions: + 'Demo MCP server. Call add for arithmetic, slow_report to watch ' + + 'streamed progress, approve_refund to see a tool ask the user for ' + + 'confirmation mid-call. Read guide://usage for a walkthrough.', + // Cache hints: let clients and shared caches reuse the tool list for + // five minutes. Without hints the SDK emits the conservative defaults + // (ttlMs: 0, cacheScope: private) on every cacheable result: + cacheHints: { 'tools/list': { ttlMs: 300000, cacheScope: 'public' } }, + }, + ) + + // A plain tool: zod schemas next to the implementation; returning + // structuredContent lets clients consume typed results. + server.registerTool( + 'add', + { + description: 'Add two numbers', + inputSchema: z.object({ a: z.number(), b: z.number() }), + outputSchema: z.object({ sum: z.number() }), + }, + async ({ a, b }) => { + const output = { sum: a + b } + return { + content: [{ type: 'text', text: String(output.sum) }], + structuredContent: output, + } + }, + ) + + // A long-running tool emitting progress notifications. When the client + // requests progress (a progressToken in _meta) and accepts text/event-stream, + // the response is served as SSE with notifications ahead of the final result. + server.registerTool( + 'slow_report', + { + description: 'Generate a report, reporting progress along the way', + inputSchema: z.object({ steps: z.number().default(3), progressToken: z.string().optional() }), + }, + async ({ steps, progressToken }, ctx) => { + for (let i = 1; i <= steps; i++) { + await sleep(800) + if (progressToken) { + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progressToken, progress: i, total: steps, message: `step ${i}` }, + }) + } + } + return { content: [{ type: 'text', text: `completed ${steps} steps` }] } + }, + ) + + // Elicitation - a tool that pauses to ask the user for input. The handler + // returns an input_required result; the client asks the user and retries + // the call with the answers attached, re-entering the handler, which reads + // them with acceptedContent. Works statelessly - no session required. + server.registerTool( + 'approve_refund', + { + description: 'Refund an order after user confirmation', + inputSchema: z.object({ orderId: z.string() }), + }, + async ({ orderId }, ctx) => { + const answer = acceptedContent( + ctx.mcpReq.inputResponses, + 'confirm', + z.object({ confirmed: z.boolean() }), + ) + if (answer === undefined) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: `Refund order ${orderId}?`, + requestedSchema: z.object({ confirmed: z.boolean() }), + }), + }, + }) + } + if (!answer.confirmed) { + return { content: [{ type: 'text', text: 'refund cancelled' }] } + } + return { content: [{ type: 'text', text: `refunded ${orderId}` }] } + }, + ) + + // A readable document next to the tools. Clients fetch it with + // resources/read (the Mcp-Name header carries the uri). + server.registerResource( + 'usage-guide', + 'guide://usage', + { description: 'How to use this server', mimeType: 'text/markdown' }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: '# Usage\n\nCall `add` for sums, `slow_report` for streamed progress, `approve_refund` for an elicitation round-trip.', + }, + ], + }), + ) + + // A resource template - parameterized URIs resolved per request. The + // per-resource cacheHint overrides the server-level hints for its reads. + server.registerResource( + 'order', + new ResourceTemplate('orders://{orderId}', { list: undefined }), + { description: 'Order record by id', cacheHint: { ttlMs: 60000, cacheScope: 'private' } }, + async (uri, { orderId }) => ({ + contents: [{ uri: uri.href, text: JSON.stringify({ orderId, status: 'shipped' }) }], + }), + ) + + // A prompt template clients can list and fill in. + server.registerPrompt( + 'summarize_order', + { + description: 'Ask the model to summarize an order', + argsSchema: z.object({ orderId: z.string() }), + }, + ({ orderId }) => ({ + messages: [ + { + role: 'user', + content: { type: 'text', text: `Summarize the status of order ${orderId} in one sentence.` }, + }, + ], + }), + ) + + return server +}) + +// --------------------------------------------------------------------------- +// Going further (uncomment and adapt): +// --------------------------------------------------------------------------- +// +// Signed round-trip state - when an elicitation round-trip must carry server +// state the retry cannot be allowed to tamper with (a computed price, an +// idempotency key), return it as requestState sealed with the SDK's HMAC +// codec. Store the >= 32-byte key in Secrets Manager or SSM: +// +// import { createRequestStateCodec } from '@modelcontextprotocol/server' +// const codec = createRequestStateCodec({ key: process.env.MCP_STATE_KEY, ttlSeconds: 600 }) +// +// In-process OAuth (works identically behind every front door): the SDK ships +// bearer-token middleware and OAuth discovery-document helpers. Wrap the +// handler's fetch face before exporting - any OpenID Connect provider works: +// +// import { requireBearerAuth, oauthMetadataResponse } from '@modelcontextprotocol/server' +// import { createRemoteJWKSet, jwtVerify } from 'jose' +// +// const jwks = createRemoteJWKSet(new URL('https:///.well-known/jwks.json')) +// const verifier = { +// async verifyAccessToken(token) { +// const { payload } = await jwtVerify(token, jwks, { +// issuer: 'https:///', +// audience: 'https://', +// }) +// return { token, clientId: payload.azp, scopes: payload.scope?.split(' ') ?? [], expiresAt: payload.exp } +// }, +// } diff --git a/aws-bedrock-agentcore/javascript/mcp-server/test-invoke.js b/aws-bedrock-agentcore/javascript/mcp-server/test-invoke.js deleted file mode 100644 index ce1287c28..000000000 --- a/aws-bedrock-agentcore/javascript/mcp-server/test-invoke.js +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env node -/** - * Test script for the MCP Server deployed to AgentCore Runtime. - * - * Calls the runtime's HTTPS MCP endpoint directly - the same way real MCP - * clients connect - using the stateless MCP 2026-07-28 protocol: every - * request is self-contained, with the required Mcp-* headers attached and - * no session bookkeeping. Requests are signed with SigV4 (IAM inbound auth, - * the runtime default). - * - * Usage: - * ENDPOINT= node test-invoke.js - */ - -import { SignatureV4 } from '@smithy/signature-v4' -import { HttpRequest } from '@smithy/protocol-http' -import { Sha256 } from '@aws-crypto/sha256-js' -import { defaultProvider } from '@aws-sdk/credential-provider-node' - -const ENDPOINT = process.env.ENDPOINT -const REGION = process.env.AWS_REGION || 'us-east-1' - -if (!ENDPOINT) { - console.error('Error: ENDPOINT environment variable is required.') - console.error('Usage: ENDPOINT= node test-invoke.js') - process.exit(1) -} - -// The runtime URL embeds the agent ARN - the WHOLE ARN must be URL-encoded in -// the path (including its inner "/"), and MCP-protocol runtimes serve under -// /invocations?qualifier=DEFAULT. -const raw = new URL(ENDPOINT) -const arn = decodeURIComponent(raw.pathname.match(/\/runtimes\/(.+)\/invocations/)[1]) -const url = new URL(`${raw.origin}/runtimes/${encodeURIComponent(arn)}/invocations`) -if (!url.searchParams.has('qualifier')) url.searchParams.set('qualifier', 'DEFAULT') - -const signer = new SignatureV4({ - service: 'bedrock-agentcore', - region: REGION, - credentials: defaultProvider(), - sha256: Sha256, -}) - -// The per-request envelope required by protocol revision 2026-07-28: -const META = { - 'io.modelcontextprotocol/protocolVersion': '2026-07-28', - 'io.modelcontextprotocol/clientInfo': { name: 'test-client', version: '1.0.0' }, - 'io.modelcontextprotocol/clientCapabilities': {}, -} - -let id = 0 -async function invoke(method, params = {}, toolName) { - const body = JSON.stringify({ - jsonrpc: '2.0', - id: ++id, - method, - params: { ...params, _meta: { ...META, ...(params._meta ?? {}) } }, - }) - const request = new HttpRequest({ - method: 'POST', - protocol: url.protocol, - hostname: url.hostname, - path: url.pathname, - query: Object.fromEntries(url.searchParams), - headers: { - host: url.hostname, - 'content-type': 'application/json', - accept: 'application/json, text/event-stream', - 'mcp-protocol-version': '2026-07-28', - 'mcp-method': method, - ...(toolName && { 'mcp-name': toolName }), - }, - body, - }) - const signed = await signer.sign(request) - const res = await fetch(`${url.origin}${url.pathname}?${url.searchParams}`, { - method: 'POST', - headers: signed.headers, - body, - }) - const text = await res.text() - // Responses may arrive as plain JSON or as a single SSE event - const data = text.startsWith('event:') || text.startsWith('data:') - ? text.split('\n').find((l) => l.startsWith('data:'))?.slice(5) - : text - return { status: res.status, ...JSON.parse(data) } -} - -async function main() { - console.log('MCP Server Test Suite (stateless, protocol 2026-07-28)') - console.log(`Endpoint: ${url.origin}${url.pathname}`) - console.log('='.repeat(50) + '\n') - - console.log('=== server/discover ===') - const discover = await invoke('server/discover') - console.log('Supported versions:', discover.result?.supportedVersions) - console.log() - - console.log('=== tools/list ===') - const list = await invoke('tools/list') - for (const tool of list.result?.tools ?? []) { - console.log(` - ${tool.name}: ${tool.description}`) - } - console.log() - - const calls = [ - ['add', { a: 5, b: 3 }], - ['multiply', { a: 4, b: 7 }], - ['get_current_time', { timezone: 'Europe/Warsaw' }], - ] - for (const [name, args] of calls) { - console.log(`=== tools/call: ${name}(${JSON.stringify(args)}) ===`) - const result = await invoke('tools/call', { name, arguments: args }, name) - const text = result.result?.content?.[0]?.text ?? JSON.stringify(result.result ?? result.error) - console.log(` Result: ${text}`) - if (result.result?.structuredContent) { - console.log(` Structured: ${JSON.stringify(result.result.structuredContent)}`) - } - console.log() - } - - console.log('All tests completed.') -} - -main().catch((err) => { - console.error('Test failed:', err.message) - process.exit(1) -}) diff --git a/aws-mcp-servers/README.md b/aws-mcp-servers/README.md new file mode 100644 index 000000000..73d364277 --- /dev/null +++ b/aws-mcp-servers/README.md @@ -0,0 +1,79 @@ +# MCP Servers on AWS + +Examples for hosting a [Model Context Protocol](https://modelcontextprotocol.io) server on AWS with the Serverless Framework. + +The MCP `2026-07-28` protocol revision made servers **stateless**: every request is self-contained - no initialize handshake, no sessions, no sticky routing - so any request can be served by any Lambda execution environment or container instance. That makes serverless hosting a natural fit, and these examples show every way to do it. + +**They all serve the same canonical MCP server** (built with the official [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) v2) and differ only in the hosting glue, so you can compare approaches directly and test every one with the same client script (`client.mjs`, shipped in each example). The canonical server exercises the full protocol surface: + +- **Tools** - `add` (plain JSON + structured output), `slow_report` (progress notifications streamed over SSE), `approve_refund` (elicitation: the tool pauses mid-call to ask the user for confirmation and resumes on retry) +- **Resources** - a static document plus an `orders://{orderId}` template +- **Prompts** - a fill-in prompt template +- **`server/discover`** - server `instructions` for client discovery +- **Cache hints** - `ttlMs`/`cacheScope` on list results, configured rather than the conservative defaults +- **Legacy fallback** - older clients sending `initialize` are answered on the same endpoint +- Plus commented extensions in each example: authentication (three flavors, see below) and HMAC-sealed round-trip state + +## The examples + +| Example | Hosting | Lambda glue | What it uniquely shows | +| --- | --- | --- | --- | +| [rest-api](rest-api) | API Gateway REST, `transferMode: stream` | ~80-line adapter (`src/lambda.mjs`) | The full-featured front door: custom domains, WAF, throttling, usage plans, gateway authorizers | +| [function-url](function-url) | Lambda Function URL, `invokeMode: RESPONSE_STREAM` | ~80-line adapter (Function URL event shape) | The leanest hosting: no API Gateway, no per-request fee, streams bounded only by function timeout | +| [hono](hono) | Lambda Function URL | **none** - official `@modelcontextprotocol/hono` + Hono's `streamHandle` | Zero custom glue: existing maintained packages do all the bridging | +| [express-web-adapter](express-web-adapter) | API Gateway REST stream + Lambda Web Adapter (**zip**) | **none** - the app is a plain Express server | Official `@modelcontextprotocol/express`; app code runs unchanged anywhere | +| [fastify-container](fastify-container) | Function URL + Lambda Web Adapter (**container image**) | **none** - the app is a plain Fastify server | Official `@modelcontextprotocol/fastify`; a portable image that also runs on Fargate/App Runner | +| [agentcore-runtime](agentcore-runtime) | AWS Bedrock AgentCore Runtime (managed container) | none - plain HTTP container | Managed MCP hosting: session isolation, config-only platform OAuth | +| [agentcore-gateway](agentcore-gateway) | AWS Bedrock AgentCore Gateway | none - **and no MCP SDK either** | AWS runs the MCP server; your code is plain Lambda functions with the schema in `serverless.yml` | + +## Three independent choices + +The Lambda-based examples vary along three axes that are **fully independent** - each example pins one combination to stay minimal, not because the pieces require each other: + +1. **Web framework / bridge** - hand-written adapter, Hono, Express, or Fastify. Any of them works with any packaging and any front door. +2. **Packaging** - zip (no Docker needed) or container image (portable beyond Lambda). Express runs fine in a container; Fastify runs fine in a zip. +3. **Front door** - API Gateway REST in stream mode, or a Lambda Function URL. Both carry plain JSON and SSE on the same endpoint; both were exercised with every bridge. + +## Choosing a front door + +| | API Gateway REST (stream) | Lambda Function URL | +| --- | --- | --- | +| Custom domain, WAF, throttling, usage plans | yes | domain via CloudFront in front | +| Reject unauthenticated requests before invoking | yes (authorizer) | IAM/SigV4 only | +| Streaming (SSE) | yes - raise `timeoutInMillis` alongside (up to 15 min) | yes - function `timeout` is the only bound | +| Per-request cost | per-request fee (see [API Gateway pricing](https://aws.amazon.com/api-gateway/pricing/)), no streaming surcharge | none (Lambda streaming meters egress beyond the first 6 MB per response - see [Lambda pricing](https://aws.amazon.com/lambda/pricing/)) | + +AgentCore Runtime replaces the front-door question entirely (the platform hosts the endpoint, IAM or JWT auth, consumption-based pricing - see [AgentCore pricing](https://aws.amazon.com/bedrock/agentcore/pricing/)); AgentCore Gateway additionally replaces the MCP server itself. + +## Capabilities and limitations + +| | Lambda examples (any bridge) | AgentCore Runtime | AgentCore Gateway | +| --- | --- | --- | --- | +| Streamed progress (SSE) | yes | yes | not from Lambda-backed tools | +| Elicitation (tool asks the user) | yes | yes | not from Lambda-backed tools | +| Exact tool names | yes | yes | prefixed `target___tool` | +| Cache hints (`ttlMs`/`cacheScope`) | yes (configurable) | yes (configurable) | fixed by the platform | +| Max tool duration | 15 min (function timeout) | platform limits | synchronous Lambda invoke | +| Your code contains MCP | the server (official SDK) | the server (official SDK) | nothing - plain functions | +| Semantic tool search | - | - | yes (built-in search tool) | + +## Authentication + +Every example includes the same three options (commented, with complete code): + +1. **Platform auth** - reject requests before they reach your code: an API Gateway JWT authorizer (`src/authorizer.mjs` in the REST-fronted examples - works with any OpenID Connect provider), `authorizer: aws_iam` on Function URLs, or AgentCore's config-only `authorizer.jwt`. +2. **In-process** - the SDK's `requireBearerAuth` with a `jose` JWKS verifier; identical behind every front door. +3. **OAuth discovery** - a `/.well-known/oauth-protected-resource` route so MCP clients can find your authorization server. + +## Testing + +Each example ships the identical `client.mjs`: + +```bash +ENDPOINT= node client.mjs # 12 protocol checks +LONG=1 ENDPOINT= node client.mjs # + a ~36s streaming case +AUTH=sigv4 SERVICE=bedrock-agentcore ENDPOINT= node client.mjs # AgentCore Runtime (SigV4) +AUTH=sigv4 SERVICE=lambda ENDPOINT= node client.mjs # IAM-auth Function URL +``` + +The checks cover the whole surface, including two negative cases proving the `2026-07-28` protocol validation is active end to end: a mismatching `Mcp-Method` header (`-32020`) and an elicitation call from a client that did not declare the capability (`-32021`). diff --git a/aws-mcp-servers/agentcore-gateway/README.md b/aws-mcp-servers/agentcore-gateway/README.md new file mode 100644 index 000000000..0b8a80e0e --- /dev/null +++ b/aws-mcp-servers/agentcore-gateway/README.md @@ -0,0 +1,5 @@ +# MCP Server from Plain Lambda Functions (AgentCore Gateway) + +This example lives at [`aws-bedrock-agentcore/javascript/mcp-server-lambda-tools`](../../aws-bedrock-agentcore/javascript/mcp-server-lambda-tools) (kept with the AgentCore example family). + +It inverts the trade the other examples in this directory make: AWS runs the MCP server (Bedrock AgentCore Gateway) and your code shrinks to plain Lambda functions, with the tool schema declared in `serverless.yml` - no MCP SDK in your code at all. The trade-offs (tool naming, no streaming or elicitation from Lambda-backed tools) are covered in the [comparison table](../README.md). diff --git a/aws-mcp-servers/agentcore-runtime/README.md b/aws-mcp-servers/agentcore-runtime/README.md new file mode 100644 index 000000000..0c3c8bf05 --- /dev/null +++ b/aws-mcp-servers/agentcore-runtime/README.md @@ -0,0 +1,5 @@ +# MCP Server on AgentCore Runtime + +This example lives at [`aws-bedrock-agentcore/javascript/mcp-server`](../../aws-bedrock-agentcore/javascript/mcp-server) (kept with the AgentCore example family). + +It serves the same canonical MCP server as the examples in this directory, hosted on AWS Bedrock AgentCore Runtime: the server runs as a plain HTTP container - no Lambda adapter at all - with managed scaling, session isolation, and config-only platform OAuth (`authorizer.jwt`). See the [comparison table](../README.md) for how it stacks up against the Lambda-based options. diff --git a/aws-mcp-servers/express-web-adapter/README.md b/aws-mcp-servers/express-web-adapter/README.md new file mode 100644 index 000000000..14cde70c4 --- /dev/null +++ b/aws-mcp-servers/express-web-adapter/README.md @@ -0,0 +1,82 @@ + + +# MCP Server with Express + Lambda Web Adapter + +Run a [Model Context Protocol](https://modelcontextprotocol.io) server as a **plain Express app** on AWS Lambda. [AWS Lambda Web Adapter](https://github.com/aws/aws-lambda-web-adapter) (a public layer configured entirely in `serverless.yml`) proxies Lambda events to the HTTP server over localhost - the application code contains nothing Lambda-specific and runs unchanged on Fargate, EC2, or your laptop. The Express integration is the MCP SDK's official [`@modelcontextprotocol/express`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/packages/middleware/express) package. + +> **The web framework and the packaging are independent choices.** This example pairs Express with a **zip** deployment because zip needs no Docker; the [fastify-container](../fastify-container) sibling pairs Fastify with a **container image**. Swap either dimension - Express in a container, Fastify in a zip - without touching application code. + +This is one of the [aws-mcp-servers](../README.md) examples - they all serve the **same canonical MCP server** (`src/server.mjs`) and differ only in the hosting glue, so the same test client works against every one of them. + +How the pieces fit: + +- **`src/index.mjs`** - `createMcpExpressApp()` + one route mounting the MCP handler via `toNodeHandler`, listening on `PORT`. +- **`run.sh`** - the Lambda "handler": the adapter layer's exec wrapper runs it to boot the HTTP server, then forwards invocations to it. +- **`serverless.yml`** - the Lambda Web Adapter layer + `AWS_LWA_INVOKE_MODE: response_stream`, behind API Gateway REST with `response.transferMode: stream` (streaming works end to end, SSE included). + +## Usage + +### Deploy + +```bash +npm install +serverless deploy +``` + +### Test + +Every example in this family ships the same test client: + +```bash +ENDPOINT= node client.mjs +``` + +``` +PASS 1. tools/list returns the canonical tools with cache hints — ttlMs=300000 cacheScope=public +PASS 2. add returns plain JSON with structured content — sum=42 +PASS 3. slow_report streams incremental progress over SSE — 3 progress events, first at +810ms +PASS 4. approve_refund elicitation round-trip (accept and cancel) — input_required -> refunded o-1 / refund cancelled +PASS 5. resources list + read (Mcp-Name carries the uri) — guide://usage readable +PASS 6. resource template read with per-resource cache hint — orders://o-42 -> shipped, ttlMs=60000 +PASS 7. prompts list + get — summarize_order fills its argument +PASS 8. server/discover surfaces the instructions — instructions present +PASS 9. legacy initialize is answered on the same endpoint — served as 2025-06-18 +PASS 10. GET is answered with the spec-mandated 405 — HTTP 405 +PASS 11. Mcp-Method header mismatching the body is rejected (-32020) — -32020 +PASS 12. elicitation without the client capability is rejected (-32021) — -32021 + +12/12 passed +``` + +Add `LONG=1` for a ~36-second streaming case proving streams run far past API Gateway's classic 29-second ceiling. + +You can also run the server locally - it is just an Express app: + +```bash +PORT=8000 node src/index.mjs +ENDPOINT=http://localhost:8000/mcp node client.mjs +``` + +### Authentication + +The demo endpoint is public. For real deployments (both included): + +- **Reject at the gateway** - uncomment the `authorizer` wiring in `serverless.yml`: [src/authorizer.mjs](src/authorizer.mjs) is a complete Lambda authorizer validating Bearer JWTs against any OpenID Connect provider. +- **Validate in-process** - the SDK's `requireBearerAuth` with a `jose` JWKS verifier (commented block at the bottom of `src/server.mjs`). +- Behind an IAM-auth Function URL, the adapter's `AWS_LWA_AUTHORIZATION_SOURCE` can restore a client-supplied token from another header into `Authorization` (SigV4 reserves the original). + +### Cleanup + +```bash +serverless remove +``` diff --git a/aws-mcp-servers/express-web-adapter/client.mjs b/aws-mcp-servers/express-web-adapter/client.mjs new file mode 100644 index 000000000..66f738e9a --- /dev/null +++ b/aws-mcp-servers/express-web-adapter/client.mjs @@ -0,0 +1,316 @@ +#!/usr/bin/env node +/** + * Shared test client for the aws-mcp-servers examples. Every example serves + * the same canonical MCP server, so this same script tests all of them. + * + * Usage: + * ENDPOINT= node client.mjs + * LONG=1 ENDPOINT=... node client.mjs # adds a ~36s streaming case + * AUTH=sigv4 SERVICE=bedrock-agentcore ENDPOINT= node client.mjs + * AUTH=sigv4 SERVICE=lambda ENDPOINT=... node client.mjs # IAM-auth Function URL + * + * Exercises the full 2026-07-28 surface: plain JSON, SSE progress streaming, + * an elicitation round-trip, resources (static + template), prompts, + * server/discover, cache hints, the legacy fallback, and two negative cases + * (header mismatch -32020, missing capability -32021). + */ + +const ENDPOINT = process.env.ENDPOINT +if (!ENDPOINT) { + console.error('Set ENDPOINT to the MCP endpoint URL') + process.exit(1) +} + +// --- endpoint + optional SigV4 signing -------------------------------------- + +let url = new URL(ENDPOINT) +let signer +if (process.env.AUTH === 'sigv4') { + const service = process.env.SERVICE || 'lambda' + if (service === 'bedrock-agentcore' && url.pathname.includes('/runtimes/')) { + // AgentCore runtime URLs embed the agent ARN - the WHOLE ARN must be + // URL-encoded in the path (including its inner "/"). + const arn = decodeURIComponent(url.pathname.match(/\/runtimes\/(.+?)\/invocations/)[1]) + url = new URL(`${url.origin}/runtimes/${encodeURIComponent(arn)}/invocations`) + url.searchParams.set('qualifier', 'DEFAULT') + } + const [{ SignatureV4 }, { HttpRequest }, { Sha256 }, { defaultProvider }] = await Promise.all([ + import('@smithy/signature-v4'), + import('@smithy/protocol-http'), + import('@aws-crypto/sha256-js'), + import('@aws-sdk/credential-provider-node'), + ]) + const sigv4 = new SignatureV4({ + service, + region: process.env.AWS_REGION || 'us-east-1', + credentials: defaultProvider(), + sha256: Sha256, + }) + signer = async (method, headers, body) => { + const request = new HttpRequest({ + method, + protocol: url.protocol, + hostname: url.hostname, + path: url.pathname, + query: Object.fromEntries(url.searchParams), + headers: { host: url.hostname, ...headers }, + ...(body != null && { body }), + }) + return (await sigv4.sign(request)).headers + } +} + +// --- request helpers --------------------------------------------------------- + +const CAPS_ELICIT = { elicitation: { form: {} } } +const meta = (capabilities = {}) => ({ + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'aws-mcp-servers-client', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': capabilities, +}) + +let id = 0 +async function request({ method, params = {}, name, capabilities, headerMethod, envelope = true }) { + const body = JSON.stringify({ + jsonrpc: '2.0', + id: ++id, + method, + params: envelope ? { ...params, _meta: { ...meta(capabilities), ...(params._meta ?? {}) } } : params, + }) + let headers = { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...(envelope && { + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': headerMethod ?? method, + ...(name && { 'mcp-name': name }), + }), + } + if (signer) headers = await signer('POST', headers, body) + const t0 = Date.now() + const res = await fetch(url, { method: 'POST', headers, body }) + const contentType = res.headers.get('content-type') ?? '' + if (contentType.includes('text/event-stream')) { + const events = [] + const decoder = new TextDecoder() + let buffer = '' + for await (const chunk of res.body) { + buffer += decoder.decode(chunk, { stream: true }) + let sep + while ((sep = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, sep) + buffer = buffer.slice(sep + 2) + const data = frame.split('\n').find((l) => l.startsWith('data:'))?.slice(5).trim() + if (data) events.push({ t: Date.now() - t0, data: JSON.parse(data) }) + } + } + const final = events.at(-1)?.data + return { status: res.status, contentType, events, json: final, ms: Date.now() - t0 } + } + const text = await res.text() + let json + try { + json = JSON.parse(text) + } catch { + json = { raw: text } + } + return { status: res.status, contentType, events: [], json, ms: Date.now() - t0 } +} + +// --- tiny harness ------------------------------------------------------------ + +const results = [] +async function check(label, fn) { + try { + const detail = await fn() + results.push({ label, ok: true }) + console.log(`PASS ${label}${detail ? ` — ${detail}` : ''}`) + } catch (err) { + results.push({ label, ok: false }) + console.log(`FAIL ${label} — ${err.message}`) + } +} +const assert = (cond, msg) => { + if (!cond) throw new Error(msg) +} +// AgentCore Runtime replaces 4xx response bodies with its own error envelope +// (-32010, "Received error (400) ..."); the 400 status still proves the SDK +// rejected the request, so the negative cases accept that form there. +const rejectedWith = (json, code) => + json.error?.code === code || + (process.env.SERVICE === 'bedrock-agentcore' && + json.error?.code === -32010 && + /\(4\d\d\)/.test(json.error?.message ?? '')) + +// --- test cases ---------------------------------------------------------------- + +await check('1. tools/list returns the canonical tools with cache hints', async () => { + const r = await request({ method: 'tools/list' }) + assert(r.status === 200, `HTTP ${r.status}`) + assert(r.contentType.includes('application/json'), `content-type ${r.contentType}`) + const names = (r.json.result?.tools ?? []).map((t) => t.name).sort() + assert(JSON.stringify(names) === JSON.stringify(['add', 'approve_refund', 'slow_report']), `tools: ${names}`) + assert(r.json.result.ttlMs === 300000, `ttlMs ${r.json.result.ttlMs}`) + assert(r.json.result.cacheScope === 'public', `cacheScope ${r.json.result.cacheScope}`) + return `ttlMs=${r.json.result.ttlMs} cacheScope=${r.json.result.cacheScope}` +}) + +await check('2. add returns plain JSON with structured content', async () => { + const r = await request({ method: 'tools/call', params: { name: 'add', arguments: { a: 2, b: 40 } }, name: 'add' }) + assert(r.status === 200, `HTTP ${r.status}`) + assert(r.contentType.includes('application/json'), `content-type ${r.contentType}`) + assert(r.json.result?.structuredContent?.sum === 42, JSON.stringify(r.json)) + return 'sum=42' +}) + +await check('3. slow_report streams incremental progress over SSE', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'slow_report', arguments: { steps: 3, progressToken: 'pt' }, _meta: { progressToken: 'pt' } }, + name: 'slow_report', + }) + assert(r.status === 200, `HTTP ${r.status}`) + assert(r.contentType.includes('text/event-stream'), `content-type ${r.contentType}`) + const progress = r.events.filter((e) => e.data.method === 'notifications/progress') + assert(progress.length === 3, `${progress.length} progress events`) + assert(progress[0].t < r.ms - 500, 'first progress event did not arrive before the final result') + assert(r.json.result?.content?.[0]?.text === 'completed 3 steps', JSON.stringify(r.json)) + return `3 progress events, first at +${progress[0].t}ms, done in ${r.ms}ms` +}) + +await check('4. approve_refund elicitation round-trip (accept and cancel)', async () => { + const ask = await request({ + method: 'tools/call', + params: { name: 'approve_refund', arguments: { orderId: 'o-1' } }, + name: 'approve_refund', + capabilities: CAPS_ELICIT, + }) + assert(ask.json.result?.resultType === 'input_required', JSON.stringify(ask.json)) + assert(ask.json.result?.inputRequests?.confirm, 'missing inputRequests.confirm') + const accepted = await request({ + method: 'tools/call', + params: { + name: 'approve_refund', + arguments: { orderId: 'o-1' }, + inputResponses: { confirm: { action: 'accept', content: { confirmed: true } } }, + }, + name: 'approve_refund', + capabilities: CAPS_ELICIT, + }) + assert(accepted.json.result?.content?.[0]?.text === 'refunded o-1', JSON.stringify(accepted.json)) + const cancelled = await request({ + method: 'tools/call', + params: { + name: 'approve_refund', + arguments: { orderId: 'o-1' }, + inputResponses: { confirm: { action: 'accept', content: { confirmed: false } } }, + }, + name: 'approve_refund', + capabilities: CAPS_ELICIT, + }) + assert(cancelled.json.result?.content?.[0]?.text === 'refund cancelled', JSON.stringify(cancelled.json)) + return 'input_required -> refunded o-1 / refund cancelled' +}) + +await check('5. resources list + read (Mcp-Name carries the uri)', async () => { + const list = await request({ method: 'resources/list' }) + const uris = (list.json.result?.resources ?? []).map((r) => r.uri) + assert(uris.includes('guide://usage'), `resources: ${uris}`) + const read = await request({ method: 'resources/read', params: { uri: 'guide://usage' }, name: 'guide://usage' }) + assert(read.json.result?.contents?.[0]?.text?.startsWith('# Usage'), JSON.stringify(read.json)) + return 'guide://usage readable' +}) + +await check('6. resource template read with per-resource cache hint', async () => { + const templates = await request({ method: 'resources/templates/list' }) + const uriTemplates = (templates.json.result?.resourceTemplates ?? []).map((t) => t.uriTemplate) + assert(uriTemplates.includes('orders://{orderId}'), `templates: ${uriTemplates}`) + const read = await request({ method: 'resources/read', params: { uri: 'orders://o-42' }, name: 'orders://o-42' }) + const record = JSON.parse(read.json.result?.contents?.[0]?.text ?? '{}') + assert(record.orderId === 'o-42' && record.status === 'shipped', JSON.stringify(read.json)) + assert(read.json.result.ttlMs === 60000, `ttlMs ${read.json.result.ttlMs}`) + return `orders://o-42 -> ${record.status}, ttlMs=${read.json.result.ttlMs}` +}) + +await check('7. prompts list + get', async () => { + const list = await request({ method: 'prompts/list' }) + const names = (list.json.result?.prompts ?? []).map((p) => p.name) + assert(names.includes('summarize_order'), `prompts: ${names}`) + const got = await request({ + method: 'prompts/get', + params: { name: 'summarize_order', arguments: { orderId: 'o-7' } }, + name: 'summarize_order', + }) + const text = got.json.result?.messages?.[0]?.content?.text ?? '' + assert(text.includes('o-7'), JSON.stringify(got.json)) + return 'summarize_order fills its argument' +}) + +await check('8. server/discover surfaces the instructions', async () => { + const r = await request({ method: 'server/discover' }) + assert((r.json.result?.instructions ?? '').includes('Demo MCP server'), JSON.stringify(r.json.result ?? r.json)) + return 'instructions present' +}) + +await check('9. legacy initialize is answered on the same endpoint', async () => { + const r = await request({ + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'legacy', version: '1.0.0' } }, + envelope: false, + }) + assert(r.status === 200, `HTTP ${r.status}`) + const version = r.json.result?.protocolVersion ?? '' + assert(version.startsWith('2025-'), JSON.stringify(r.json)) + return `served as ${version}` +}) + +await check('10. GET is answered with the spec-mandated 405', async () => { + let headers = { accept: 'application/json, text/event-stream' } + if (signer) headers = await signer('GET', headers, null) + const res = await fetch(url, { method: 'GET', headers }) + assert(res.status === 405, `HTTP ${res.status}`) + return 'HTTP 405' +}) + +await check('11. Mcp-Method header mismatching the body is rejected (-32020)', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'add', arguments: { a: 1, b: 1 } }, + name: 'add', + headerMethod: 'tools/list', + }) + assert(rejectedWith(r.json, -32020), JSON.stringify(r.json)) + return r.json.error.code === -32020 ? '-32020' : 'rejected (platform-wrapped 400)' +}) + +await check('12. elicitation without the client capability is rejected (-32021)', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'approve_refund', arguments: { orderId: 'o-9' } }, + name: 'approve_refund', + capabilities: {}, + }) + assert(rejectedWith(r.json, -32021), JSON.stringify(r.json)) + return r.json.error.code === -32021 ? '-32021' : 'rejected (platform-wrapped 400)' +}) + +if (process.env.LONG) { + await check('13. LONG: 45-step stream (~36s) survives past the 29s mark', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'slow_report', arguments: { steps: 45, progressToken: 'long' }, _meta: { progressToken: 'long' } }, + name: 'slow_report', + }) + const progress = r.events.filter((e) => e.data.method === 'notifications/progress') + assert(progress.length === 45, `${progress.length} progress events`) + assert(r.json.result?.content?.[0]?.text === 'completed 45 steps', 'final result missing (stream cut?)') + assert(r.ms > 34000, `finished suspiciously fast (${r.ms}ms)`) + return `45 events over ${(r.ms / 1000).toFixed(1)}s, final result received` + }) +} + +// --- summary ------------------------------------------------------------------- + +const failed = results.filter((r) => !r.ok) +console.log(`\n${results.length - failed.length}/${results.length} passed`) +process.exit(failed.length ? 1 : 0) diff --git a/aws-mcp-servers/express-web-adapter/package-lock.json b/aws-mcp-servers/express-web-adapter/package-lock.json new file mode 100644 index 000000000..4c07ded24 --- /dev/null +++ b/aws-mcp-servers/express-web-adapter/package-lock.json @@ -0,0 +1,998 @@ +{ + "name": "aws-mcp-express", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aws-mcp-express", + "version": "1.0.0", + "dependencies": { + "@modelcontextprotocol/express": "^2.0.0", + "@modelcontextprotocol/node": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "express": "^5.1.0", + "jose": "^6.0.0", + "zod": "^4.2.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/express": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/express/-/express-2.0.0.tgz", + "integrity": "sha512-Snlr8j9FR9LcVvEJPF7qJ7d5zTL4Bes2dk7RcacN9eSZ7OLohwxqhEvWu1+UxELyScgLbLLOdeVEGdwKI1iwVQ==", + "license": "MIT", + "dependencies": { + "cors": "^2.8.5" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/server": "^2.0.0", + "express": "^4.18.0 || ^5.0.0" + } + }, + "node_modules/@modelcontextprotocol/node": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/node/-/node-2.0.0.tgz", + "integrity": "sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/server": "^2.0.0", + "hono": "^4.11.4" + }, + "peerDependenciesMeta": { + "hono": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.32", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", + "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/aws-mcp-servers/express-web-adapter/package.json b/aws-mcp-servers/express-web-adapter/package.json new file mode 100644 index 000000000..5216406f1 --- /dev/null +++ b/aws-mcp-servers/express-web-adapter/package.json @@ -0,0 +1,15 @@ +{ + "name": "aws-mcp-express", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "MCP server as a plain Express app on AWS Lambda via Lambda Web Adapter (zip deployment)", + "dependencies": { + "@modelcontextprotocol/express": "^2.0.0", + "@modelcontextprotocol/node": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "express": "^5.1.0", + "jose": "^6.0.0", + "zod": "^4.2.0" + } +} diff --git a/aws-mcp-servers/express-web-adapter/run.sh b/aws-mcp-servers/express-web-adapter/run.sh new file mode 100755 index 000000000..ca91880bc --- /dev/null +++ b/aws-mcp-servers/express-web-adapter/run.sh @@ -0,0 +1,2 @@ +#!/bin/bash +exec node src/index.mjs diff --git a/aws-mcp-servers/express-web-adapter/serverless.yml b/aws-mcp-servers/express-web-adapter/serverless.yml new file mode 100644 index 000000000..00c29636a --- /dev/null +++ b/aws-mcp-servers/express-web-adapter/serverless.yml @@ -0,0 +1,54 @@ +# MCP server as a plain Express app on Lambda, via AWS Lambda Web Adapter. +# +# The Lambda Web Adapter layer proxies Lambda events to the Express server +# over localhost HTTP - the application code contains nothing Lambda-specific. +# Served through API Gateway REST response streaming. +service: aws-mcp-express + +provider: + name: aws + runtime: nodejs22.x # the MCP SDK v2 requires Node.js 20+ + +functions: + mcp: + # The handler is the startup script (the layer's exec wrapper runs it and + # proxies invocations to the HTTP server it starts): + handler: run.sh + timeout: 120 + layers: + # AWS Lambda Web Adapter, published by AWS (region-agnostic account, + # arch-specific): https://github.com/aws/aws-lambda-web-adapter + - arn:aws:lambda:${aws:region}:753240598075:layer:LambdaAdapterLayerX86:25 + environment: + AWS_LAMBDA_EXEC_WRAPPER: /opt/bootstrap + AWS_LWA_INVOKE_MODE: response_stream # stream responses (SSE) end-to-end + PORT: '8000' + events: + - http: + method: POST + path: /mcp + # Response streaming lifts API Gateway's classic response limits and + # enables SSE, but the integration timeout must be raised explicitly + # alongside it (up to 15 minutes): + timeoutInMillis: 120000 + response: + transferMode: stream + # To reject unauthenticated requests before they invoke the function, + # attach the JWT authorizer below (src/authorizer.mjs validates + # Bearer tokens against any OpenID Connect provider), or use the + # SDK's in-process bearer auth (see src/server.mjs): + # authorizer: + # name: jwtAuthorizer + - http: + method: GET + path: /mcp # the server answers the spec-mandated 405 for GET + response: + transferMode: stream # every route on a streaming handler streams + + # The request authorizer pairing with the `authorizer` block above: + # jwtAuthorizer: + # handler: src/authorizer.handler + # environment: + # JWKS_URL: https:///.well-known/jwks.json + # TOKEN_ISSUER: https:/// + # TOKEN_AUDIENCE: https:// diff --git a/aws-mcp-servers/express-web-adapter/src/authorizer.mjs b/aws-mcp-servers/express-web-adapter/src/authorizer.mjs new file mode 100644 index 000000000..6e451fced --- /dev/null +++ b/aws-mcp-servers/express-web-adapter/src/authorizer.mjs @@ -0,0 +1,33 @@ +/** + * A Lambda TOKEN authorizer validating OAuth Bearer JWTs against any OpenID + * Connect provider - API Gateway rejects unauthenticated requests before they + * ever invoke (and bill) the MCP function. + * + * Configure via environment variables on the authorizer function: + * JWKS_URL e.g. https://your-tenant.example.com/.well-known/jwks.json + * TOKEN_ISSUER e.g. https://your-tenant.example.com/ + * TOKEN_AUDIENCE e.g. https://your-mcp-endpoint + */ +import { createRemoteJWKSet, jwtVerify } from 'jose' + +const jwks = createRemoteJWKSet(new URL(process.env.JWKS_URL)) + +export const handler = async (event) => { + const token = event.authorizationToken?.replace(/^Bearer /i, '') + try { + const { payload } = await jwtVerify(token, jwks, { + issuer: process.env.TOKEN_ISSUER, + audience: process.env.TOKEN_AUDIENCE, + }) + return { + principalId: payload.sub, + policyDocument: { + Version: '2012-10-17', + Statement: [{ Action: 'execute-api:Invoke', Effect: 'Allow', Resource: event.methodArn }], + }, + } + } catch { + // API Gateway maps this exact message to a 401 response. + throw new Error('Unauthorized') + } +} diff --git a/aws-mcp-servers/express-web-adapter/src/index.mjs b/aws-mcp-servers/express-web-adapter/src/index.mjs new file mode 100644 index 000000000..8187e52df --- /dev/null +++ b/aws-mcp-servers/express-web-adapter/src/index.mjs @@ -0,0 +1,29 @@ +/** + * A real Express app serving the MCP handler - no Lambda-specific code at + * all. AWS Lambda Web Adapter (configured in serverless.yml) proxies Lambda + * events to this HTTP server over localhost; the same file runs unchanged on + * Fargate, EC2, or your laptop. + * + * The web framework and the packaging are independent choices: this example + * pairs Express with a zip deployment because zip needs no Docker; the + * fastify-container sibling pairs Fastify with a container image. Swap either + * dimension without touching application code. + */ +import { createMcpExpressApp } from '@modelcontextprotocol/express' +import { toNodeHandler } from '@modelcontextprotocol/node' +import mcp from './server.mjs' + +// host: '0.0.0.0' declares this app as intentionally exposed, which disables +// the factory's localhost DNS-rebinding protections - correct behind a cloud +// front door, where the platform already pins the Host header. +const app = createMcpExpressApp({ host: '0.0.0.0' }) + +// createMcpExpressApp installs express.json(), so the parsed body rides +// along. Forward it only for POST: express.json() defaults req.body to {} +// on bodyless requests, and a present-but-empty parsed body changes how the +// handler classifies them. +const node = toNodeHandler(mcp) +app.all('/mcp', (req, res) => node(req, res, req.method === 'POST' ? req.body : undefined)) + +const port = Number(process.env.PORT ?? 8000) +app.listen(port, () => console.log(`MCP server listening on http://0.0.0.0:${port}/mcp`)) diff --git a/aws-mcp-servers/express-web-adapter/src/server.mjs b/aws-mcp-servers/express-web-adapter/src/server.mjs new file mode 100644 index 000000000..fdf1476e6 --- /dev/null +++ b/aws-mcp-servers/express-web-adapter/src/server.mjs @@ -0,0 +1,193 @@ +/** + * A standard MCP server built with the official MCP TypeScript SDK v2, + * serving the stateless MCP 2026-07-28 protocol revision (and answering + * older clients through the SDK's built-in fallback on the same endpoint). + * + * Nothing in this file is Lambda-specific - the same handler runs on any + * web-standard host. Every example in aws-mcp-servers/ serves this exact + * server; only the hosting glue around it differs. + * + * Surface: + * tools: add (plain JSON), slow_report (progress streaming), + * approve_refund (elicitation - pauses to ask the user) + * resources: guide://usage, orders://{orderId} (template) + * prompts: summarize_order + * extras: instructions (served via server/discover), cache hints + */ +import { + acceptedContent, + createMcpHandler, + inputRequired, + McpServer, + ResourceTemplate, +} from '@modelcontextprotocol/server' +import { z } from 'zod' + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +export default createMcpHandler(() => { + const server = new McpServer( + { name: 'aws-mcp-server', version: '1.0.0' }, + { + // Shown to clients that probe the server/discover method: + instructions: + 'Demo MCP server. Call add for arithmetic, slow_report to watch ' + + 'streamed progress, approve_refund to see a tool ask the user for ' + + 'confirmation mid-call. Read guide://usage for a walkthrough.', + // Cache hints: let clients and shared caches reuse the tool list for + // five minutes. Without hints the SDK emits the conservative defaults + // (ttlMs: 0, cacheScope: private) on every cacheable result: + cacheHints: { 'tools/list': { ttlMs: 300000, cacheScope: 'public' } }, + }, + ) + + // A plain tool: zod schemas next to the implementation; returning + // structuredContent lets clients consume typed results. + server.registerTool( + 'add', + { + description: 'Add two numbers', + inputSchema: z.object({ a: z.number(), b: z.number() }), + outputSchema: z.object({ sum: z.number() }), + }, + async ({ a, b }) => { + const output = { sum: a + b } + return { + content: [{ type: 'text', text: String(output.sum) }], + structuredContent: output, + } + }, + ) + + // A long-running tool emitting progress notifications. When the client + // requests progress (a progressToken in _meta) and accepts text/event-stream, + // the response is served as SSE with notifications ahead of the final result. + server.registerTool( + 'slow_report', + { + description: 'Generate a report, reporting progress along the way', + inputSchema: z.object({ steps: z.number().default(3), progressToken: z.string().optional() }), + }, + async ({ steps, progressToken }, ctx) => { + for (let i = 1; i <= steps; i++) { + await sleep(800) + if (progressToken) { + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progressToken, progress: i, total: steps, message: `step ${i}` }, + }) + } + } + return { content: [{ type: 'text', text: `completed ${steps} steps` }] } + }, + ) + + // Elicitation - a tool that pauses to ask the user for input. The handler + // returns an input_required result; the client asks the user and retries + // the call with the answers attached, re-entering the handler, which reads + // them with acceptedContent. Works statelessly - no session required. + server.registerTool( + 'approve_refund', + { + description: 'Refund an order after user confirmation', + inputSchema: z.object({ orderId: z.string() }), + }, + async ({ orderId }, ctx) => { + const answer = acceptedContent( + ctx.mcpReq.inputResponses, + 'confirm', + z.object({ confirmed: z.boolean() }), + ) + if (answer === undefined) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: `Refund order ${orderId}?`, + requestedSchema: z.object({ confirmed: z.boolean() }), + }), + }, + }) + } + if (!answer.confirmed) { + return { content: [{ type: 'text', text: 'refund cancelled' }] } + } + return { content: [{ type: 'text', text: `refunded ${orderId}` }] } + }, + ) + + // A readable document next to the tools. Clients fetch it with + // resources/read (the Mcp-Name header carries the uri). + server.registerResource( + 'usage-guide', + 'guide://usage', + { description: 'How to use this server', mimeType: 'text/markdown' }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: '# Usage\n\nCall `add` for sums, `slow_report` for streamed progress, `approve_refund` for an elicitation round-trip.', + }, + ], + }), + ) + + // A resource template - parameterized URIs resolved per request. The + // per-resource cacheHint overrides the server-level hints for its reads. + server.registerResource( + 'order', + new ResourceTemplate('orders://{orderId}', { list: undefined }), + { description: 'Order record by id', cacheHint: { ttlMs: 60000, cacheScope: 'private' } }, + async (uri, { orderId }) => ({ + contents: [{ uri: uri.href, text: JSON.stringify({ orderId, status: 'shipped' }) }], + }), + ) + + // A prompt template clients can list and fill in. + server.registerPrompt( + 'summarize_order', + { + description: 'Ask the model to summarize an order', + argsSchema: z.object({ orderId: z.string() }), + }, + ({ orderId }) => ({ + messages: [ + { + role: 'user', + content: { type: 'text', text: `Summarize the status of order ${orderId} in one sentence.` }, + }, + ], + }), + ) + + return server +}) + +// --------------------------------------------------------------------------- +// Going further (uncomment and adapt): +// --------------------------------------------------------------------------- +// +// Signed round-trip state - when an elicitation round-trip must carry server +// state the retry cannot be allowed to tamper with (a computed price, an +// idempotency key), return it as requestState sealed with the SDK's HMAC +// codec. Store the >= 32-byte key in Secrets Manager or SSM: +// +// import { createRequestStateCodec } from '@modelcontextprotocol/server' +// const codec = createRequestStateCodec({ key: process.env.MCP_STATE_KEY, ttlSeconds: 600 }) +// +// In-process OAuth (works identically behind every front door): the SDK ships +// bearer-token middleware and OAuth discovery-document helpers. Wrap the +// handler's fetch face before exporting - any OpenID Connect provider works: +// +// import { requireBearerAuth, oauthMetadataResponse } from '@modelcontextprotocol/server' +// import { createRemoteJWKSet, jwtVerify } from 'jose' +// +// const jwks = createRemoteJWKSet(new URL('https:///.well-known/jwks.json')) +// const verifier = { +// async verifyAccessToken(token) { +// const { payload } = await jwtVerify(token, jwks, { +// issuer: 'https:///', +// audience: 'https://', +// }) +// return { token, clientId: payload.azp, scopes: payload.scope?.split(' ') ?? [], expiresAt: payload.exp } +// }, +// } diff --git a/aws-mcp-servers/fastify-container/Dockerfile b/aws-mcp-servers/fastify-container/Dockerfile new file mode 100644 index 000000000..b9d26fa5d --- /dev/null +++ b/aws-mcp-servers/fastify-container/Dockerfile @@ -0,0 +1,24 @@ +# A portable HTTP-server image made Lambda-compatible by the Lambda Web +# Adapter extension - the only Lambda-specific line is the COPY below. The +# same image runs unchanged on Fargate, App Runner, or any container host. +# +# The platform is pinned so the image matches the Lambda function's +# architecture (x86_64 by default) no matter what machine builds it - an +# image built on an Apple Silicon laptop without the pin fails at invoke +# with "exec format error". For Graviton, switch this to linux/arm64 and +# set `architecture: arm64` on the function. +FROM --platform=linux/amd64 public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 AS lambda-adapter + +FROM --platform=linux/amd64 public.ecr.aws/docker/library/node:22-slim + +COPY --from=lambda-adapter /lambda-adapter /opt/extensions/lambda-adapter + +ENV PORT=8000 AWS_LWA_INVOKE_MODE=response_stream + +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev +COPY src ./src + +EXPOSE 8000 +CMD ["node", "src/index.mjs"] diff --git a/aws-mcp-servers/fastify-container/README.md b/aws-mcp-servers/fastify-container/README.md new file mode 100644 index 000000000..7e7f5717e --- /dev/null +++ b/aws-mcp-servers/fastify-container/README.md @@ -0,0 +1,80 @@ + + +# MCP Server with Fastify in a container image + +Run a [Model Context Protocol](https://modelcontextprotocol.io) server as a **containerized Fastify app** on AWS Lambda. The subject of this example is the packaging: a portable container image whose only Lambda-specific line is the [AWS Lambda Web Adapter](https://github.com/aws/aws-lambda-web-adapter) extension `COPY` in the Dockerfile - the same image runs unchanged on Fargate, App Runner, or any container host. The Fastify integration is the MCP SDK's official [`@modelcontextprotocol/fastify`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/packages/middleware/fastify) package. + +> **The web framework and the packaging are independent choices.** This example pairs Fastify with a **container image**; the [express-web-adapter](../express-web-adapter) sibling pairs Express with a **zip** deployment (no Docker needed). Swap either dimension - Fastify in a zip, Express in a container - without touching application code. + +This is one of the [aws-mcp-servers](../README.md) examples - they all serve the **same canonical MCP server** (`src/server.mjs`) and differ only in the hosting glue, so the same test client works against every one of them. + +How the pieces fit: + +- **`src/index.mjs`** - `createMcpFastifyApp()` + one route mounting the MCP handler via `toNodeHandler` (with `reply.hijack()` so SSE streams flow on the raw response), listening on `PORT`. +- **`Dockerfile`** - `node:22-slim` + the Lambda Web Adapter extension + the app. `AWS_LWA_INVOKE_MODE=response_stream` streams responses end to end. +- **`serverless.yml`** - an ECR image build (`provider.ecr.images`) wired to the function, served on a Function URL with `invokeMode: RESPONSE_STREAM`. + +## Usage + +### Deploy + +Docker must be running - deploying builds the image and pushes it to ECR: + +```bash +npm install +serverless deploy +``` + +### Test + +Every example in this family ships the same test client: + +```bash +ENDPOINT=mcp node client.mjs +``` + +``` +PASS 1. tools/list returns the canonical tools with cache hints — ttlMs=300000 cacheScope=public +PASS 2. add returns plain JSON with structured content — sum=42 +PASS 3. slow_report streams incremental progress over SSE — 3 progress events, first at +810ms +PASS 4. approve_refund elicitation round-trip (accept and cancel) — input_required -> refunded o-1 / refund cancelled +PASS 5. resources list + read (Mcp-Name carries the uri) — guide://usage readable +PASS 6. resource template read with per-resource cache hint — orders://o-42 -> shipped, ttlMs=60000 +PASS 7. prompts list + get — summarize_order fills its argument +PASS 8. server/discover surfaces the instructions — instructions present +PASS 9. legacy initialize is answered on the same endpoint — served as 2025-06-18 +PASS 10. GET is answered with the spec-mandated 405 — HTTP 405 +PASS 11. Mcp-Method header mismatching the body is rejected (-32020) — -32020 +PASS 12. elicitation without the client capability is rejected (-32021) — -32021 + +12/12 passed +``` + +Add `LONG=1` for a ~36-second streaming case - on Function URLs the function `timeout` is the only bound. + +You can also run the server locally - it is just a Fastify app (or `docker build` and run the image): + +```bash +PORT=8000 node src/index.mjs +ENDPOINT=http://localhost:8000/mcp node client.mjs +``` + +### Authentication + +The demo endpoint is public. For real deployments (both included as comments): uncomment `authorizer: aws_iam` in `serverless.yml` for AWS-credentialed callers (the shared client tests this mode with `AUTH=sigv4 SERVICE=lambda`), or validate OAuth Bearer tokens in-process with the SDK's `requireBearerAuth` and a `jose` JWKS verifier (commented block at the bottom of `src/server.mjs`). + +### Cleanup + +```bash +serverless remove +``` diff --git a/aws-mcp-servers/fastify-container/client.mjs b/aws-mcp-servers/fastify-container/client.mjs new file mode 100644 index 000000000..66f738e9a --- /dev/null +++ b/aws-mcp-servers/fastify-container/client.mjs @@ -0,0 +1,316 @@ +#!/usr/bin/env node +/** + * Shared test client for the aws-mcp-servers examples. Every example serves + * the same canonical MCP server, so this same script tests all of them. + * + * Usage: + * ENDPOINT= node client.mjs + * LONG=1 ENDPOINT=... node client.mjs # adds a ~36s streaming case + * AUTH=sigv4 SERVICE=bedrock-agentcore ENDPOINT= node client.mjs + * AUTH=sigv4 SERVICE=lambda ENDPOINT=... node client.mjs # IAM-auth Function URL + * + * Exercises the full 2026-07-28 surface: plain JSON, SSE progress streaming, + * an elicitation round-trip, resources (static + template), prompts, + * server/discover, cache hints, the legacy fallback, and two negative cases + * (header mismatch -32020, missing capability -32021). + */ + +const ENDPOINT = process.env.ENDPOINT +if (!ENDPOINT) { + console.error('Set ENDPOINT to the MCP endpoint URL') + process.exit(1) +} + +// --- endpoint + optional SigV4 signing -------------------------------------- + +let url = new URL(ENDPOINT) +let signer +if (process.env.AUTH === 'sigv4') { + const service = process.env.SERVICE || 'lambda' + if (service === 'bedrock-agentcore' && url.pathname.includes('/runtimes/')) { + // AgentCore runtime URLs embed the agent ARN - the WHOLE ARN must be + // URL-encoded in the path (including its inner "/"). + const arn = decodeURIComponent(url.pathname.match(/\/runtimes\/(.+?)\/invocations/)[1]) + url = new URL(`${url.origin}/runtimes/${encodeURIComponent(arn)}/invocations`) + url.searchParams.set('qualifier', 'DEFAULT') + } + const [{ SignatureV4 }, { HttpRequest }, { Sha256 }, { defaultProvider }] = await Promise.all([ + import('@smithy/signature-v4'), + import('@smithy/protocol-http'), + import('@aws-crypto/sha256-js'), + import('@aws-sdk/credential-provider-node'), + ]) + const sigv4 = new SignatureV4({ + service, + region: process.env.AWS_REGION || 'us-east-1', + credentials: defaultProvider(), + sha256: Sha256, + }) + signer = async (method, headers, body) => { + const request = new HttpRequest({ + method, + protocol: url.protocol, + hostname: url.hostname, + path: url.pathname, + query: Object.fromEntries(url.searchParams), + headers: { host: url.hostname, ...headers }, + ...(body != null && { body }), + }) + return (await sigv4.sign(request)).headers + } +} + +// --- request helpers --------------------------------------------------------- + +const CAPS_ELICIT = { elicitation: { form: {} } } +const meta = (capabilities = {}) => ({ + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'aws-mcp-servers-client', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': capabilities, +}) + +let id = 0 +async function request({ method, params = {}, name, capabilities, headerMethod, envelope = true }) { + const body = JSON.stringify({ + jsonrpc: '2.0', + id: ++id, + method, + params: envelope ? { ...params, _meta: { ...meta(capabilities), ...(params._meta ?? {}) } } : params, + }) + let headers = { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...(envelope && { + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': headerMethod ?? method, + ...(name && { 'mcp-name': name }), + }), + } + if (signer) headers = await signer('POST', headers, body) + const t0 = Date.now() + const res = await fetch(url, { method: 'POST', headers, body }) + const contentType = res.headers.get('content-type') ?? '' + if (contentType.includes('text/event-stream')) { + const events = [] + const decoder = new TextDecoder() + let buffer = '' + for await (const chunk of res.body) { + buffer += decoder.decode(chunk, { stream: true }) + let sep + while ((sep = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, sep) + buffer = buffer.slice(sep + 2) + const data = frame.split('\n').find((l) => l.startsWith('data:'))?.slice(5).trim() + if (data) events.push({ t: Date.now() - t0, data: JSON.parse(data) }) + } + } + const final = events.at(-1)?.data + return { status: res.status, contentType, events, json: final, ms: Date.now() - t0 } + } + const text = await res.text() + let json + try { + json = JSON.parse(text) + } catch { + json = { raw: text } + } + return { status: res.status, contentType, events: [], json, ms: Date.now() - t0 } +} + +// --- tiny harness ------------------------------------------------------------ + +const results = [] +async function check(label, fn) { + try { + const detail = await fn() + results.push({ label, ok: true }) + console.log(`PASS ${label}${detail ? ` — ${detail}` : ''}`) + } catch (err) { + results.push({ label, ok: false }) + console.log(`FAIL ${label} — ${err.message}`) + } +} +const assert = (cond, msg) => { + if (!cond) throw new Error(msg) +} +// AgentCore Runtime replaces 4xx response bodies with its own error envelope +// (-32010, "Received error (400) ..."); the 400 status still proves the SDK +// rejected the request, so the negative cases accept that form there. +const rejectedWith = (json, code) => + json.error?.code === code || + (process.env.SERVICE === 'bedrock-agentcore' && + json.error?.code === -32010 && + /\(4\d\d\)/.test(json.error?.message ?? '')) + +// --- test cases ---------------------------------------------------------------- + +await check('1. tools/list returns the canonical tools with cache hints', async () => { + const r = await request({ method: 'tools/list' }) + assert(r.status === 200, `HTTP ${r.status}`) + assert(r.contentType.includes('application/json'), `content-type ${r.contentType}`) + const names = (r.json.result?.tools ?? []).map((t) => t.name).sort() + assert(JSON.stringify(names) === JSON.stringify(['add', 'approve_refund', 'slow_report']), `tools: ${names}`) + assert(r.json.result.ttlMs === 300000, `ttlMs ${r.json.result.ttlMs}`) + assert(r.json.result.cacheScope === 'public', `cacheScope ${r.json.result.cacheScope}`) + return `ttlMs=${r.json.result.ttlMs} cacheScope=${r.json.result.cacheScope}` +}) + +await check('2. add returns plain JSON with structured content', async () => { + const r = await request({ method: 'tools/call', params: { name: 'add', arguments: { a: 2, b: 40 } }, name: 'add' }) + assert(r.status === 200, `HTTP ${r.status}`) + assert(r.contentType.includes('application/json'), `content-type ${r.contentType}`) + assert(r.json.result?.structuredContent?.sum === 42, JSON.stringify(r.json)) + return 'sum=42' +}) + +await check('3. slow_report streams incremental progress over SSE', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'slow_report', arguments: { steps: 3, progressToken: 'pt' }, _meta: { progressToken: 'pt' } }, + name: 'slow_report', + }) + assert(r.status === 200, `HTTP ${r.status}`) + assert(r.contentType.includes('text/event-stream'), `content-type ${r.contentType}`) + const progress = r.events.filter((e) => e.data.method === 'notifications/progress') + assert(progress.length === 3, `${progress.length} progress events`) + assert(progress[0].t < r.ms - 500, 'first progress event did not arrive before the final result') + assert(r.json.result?.content?.[0]?.text === 'completed 3 steps', JSON.stringify(r.json)) + return `3 progress events, first at +${progress[0].t}ms, done in ${r.ms}ms` +}) + +await check('4. approve_refund elicitation round-trip (accept and cancel)', async () => { + const ask = await request({ + method: 'tools/call', + params: { name: 'approve_refund', arguments: { orderId: 'o-1' } }, + name: 'approve_refund', + capabilities: CAPS_ELICIT, + }) + assert(ask.json.result?.resultType === 'input_required', JSON.stringify(ask.json)) + assert(ask.json.result?.inputRequests?.confirm, 'missing inputRequests.confirm') + const accepted = await request({ + method: 'tools/call', + params: { + name: 'approve_refund', + arguments: { orderId: 'o-1' }, + inputResponses: { confirm: { action: 'accept', content: { confirmed: true } } }, + }, + name: 'approve_refund', + capabilities: CAPS_ELICIT, + }) + assert(accepted.json.result?.content?.[0]?.text === 'refunded o-1', JSON.stringify(accepted.json)) + const cancelled = await request({ + method: 'tools/call', + params: { + name: 'approve_refund', + arguments: { orderId: 'o-1' }, + inputResponses: { confirm: { action: 'accept', content: { confirmed: false } } }, + }, + name: 'approve_refund', + capabilities: CAPS_ELICIT, + }) + assert(cancelled.json.result?.content?.[0]?.text === 'refund cancelled', JSON.stringify(cancelled.json)) + return 'input_required -> refunded o-1 / refund cancelled' +}) + +await check('5. resources list + read (Mcp-Name carries the uri)', async () => { + const list = await request({ method: 'resources/list' }) + const uris = (list.json.result?.resources ?? []).map((r) => r.uri) + assert(uris.includes('guide://usage'), `resources: ${uris}`) + const read = await request({ method: 'resources/read', params: { uri: 'guide://usage' }, name: 'guide://usage' }) + assert(read.json.result?.contents?.[0]?.text?.startsWith('# Usage'), JSON.stringify(read.json)) + return 'guide://usage readable' +}) + +await check('6. resource template read with per-resource cache hint', async () => { + const templates = await request({ method: 'resources/templates/list' }) + const uriTemplates = (templates.json.result?.resourceTemplates ?? []).map((t) => t.uriTemplate) + assert(uriTemplates.includes('orders://{orderId}'), `templates: ${uriTemplates}`) + const read = await request({ method: 'resources/read', params: { uri: 'orders://o-42' }, name: 'orders://o-42' }) + const record = JSON.parse(read.json.result?.contents?.[0]?.text ?? '{}') + assert(record.orderId === 'o-42' && record.status === 'shipped', JSON.stringify(read.json)) + assert(read.json.result.ttlMs === 60000, `ttlMs ${read.json.result.ttlMs}`) + return `orders://o-42 -> ${record.status}, ttlMs=${read.json.result.ttlMs}` +}) + +await check('7. prompts list + get', async () => { + const list = await request({ method: 'prompts/list' }) + const names = (list.json.result?.prompts ?? []).map((p) => p.name) + assert(names.includes('summarize_order'), `prompts: ${names}`) + const got = await request({ + method: 'prompts/get', + params: { name: 'summarize_order', arguments: { orderId: 'o-7' } }, + name: 'summarize_order', + }) + const text = got.json.result?.messages?.[0]?.content?.text ?? '' + assert(text.includes('o-7'), JSON.stringify(got.json)) + return 'summarize_order fills its argument' +}) + +await check('8. server/discover surfaces the instructions', async () => { + const r = await request({ method: 'server/discover' }) + assert((r.json.result?.instructions ?? '').includes('Demo MCP server'), JSON.stringify(r.json.result ?? r.json)) + return 'instructions present' +}) + +await check('9. legacy initialize is answered on the same endpoint', async () => { + const r = await request({ + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'legacy', version: '1.0.0' } }, + envelope: false, + }) + assert(r.status === 200, `HTTP ${r.status}`) + const version = r.json.result?.protocolVersion ?? '' + assert(version.startsWith('2025-'), JSON.stringify(r.json)) + return `served as ${version}` +}) + +await check('10. GET is answered with the spec-mandated 405', async () => { + let headers = { accept: 'application/json, text/event-stream' } + if (signer) headers = await signer('GET', headers, null) + const res = await fetch(url, { method: 'GET', headers }) + assert(res.status === 405, `HTTP ${res.status}`) + return 'HTTP 405' +}) + +await check('11. Mcp-Method header mismatching the body is rejected (-32020)', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'add', arguments: { a: 1, b: 1 } }, + name: 'add', + headerMethod: 'tools/list', + }) + assert(rejectedWith(r.json, -32020), JSON.stringify(r.json)) + return r.json.error.code === -32020 ? '-32020' : 'rejected (platform-wrapped 400)' +}) + +await check('12. elicitation without the client capability is rejected (-32021)', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'approve_refund', arguments: { orderId: 'o-9' } }, + name: 'approve_refund', + capabilities: {}, + }) + assert(rejectedWith(r.json, -32021), JSON.stringify(r.json)) + return r.json.error.code === -32021 ? '-32021' : 'rejected (platform-wrapped 400)' +}) + +if (process.env.LONG) { + await check('13. LONG: 45-step stream (~36s) survives past the 29s mark', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'slow_report', arguments: { steps: 45, progressToken: 'long' }, _meta: { progressToken: 'long' } }, + name: 'slow_report', + }) + const progress = r.events.filter((e) => e.data.method === 'notifications/progress') + assert(progress.length === 45, `${progress.length} progress events`) + assert(r.json.result?.content?.[0]?.text === 'completed 45 steps', 'final result missing (stream cut?)') + assert(r.ms > 34000, `finished suspiciously fast (${r.ms}ms)`) + return `45 events over ${(r.ms / 1000).toFixed(1)}s, final result received` + }) +} + +// --- summary ------------------------------------------------------------------- + +const failed = results.filter((r) => !r.ok) +console.log(`\n${results.length - failed.length}/${results.length} passed`) +process.exit(failed.length ? 1 : 0) diff --git a/aws-mcp-servers/fastify-container/package-lock.json b/aws-mcp-servers/fastify-container/package-lock.json new file mode 100644 index 000000000..240a7e706 --- /dev/null +++ b/aws-mcp-servers/fastify-container/package-lock.json @@ -0,0 +1,741 @@ +{ + "name": "aws-mcp-fastify-container", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aws-mcp-fastify-container", + "version": "1.0.0", + "dependencies": { + "@modelcontextprotocol/fastify": "^2.0.0", + "@modelcontextprotocol/node": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "fastify": "^5.6.0", + "zod": "^4.2.0" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.2.tgz", + "integrity": "sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/fastify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/fastify/-/fastify-2.0.0.tgz", + "integrity": "sha512-x41AmyQ+olgAjX8EBkVpCztxIcWaInUlVpEatUBSWINZAmTnQYSYqOp9eigb7dxE2W+CtzhP1H+9uUgHRxJPVg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/server": "^2.0.0", + "fastify": "^5.2.0" + } + }, + "node_modules/@modelcontextprotocol/node": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/node/-/node-2.0.0.tgz", + "integrity": "sha512-Y4hAC2XdGDUdDOCbLDOCA4+aL3NUldjsOWlDL/YwpAxrPhRm1xHd7lZ+mLacvZ9t3PaH28wgNoaLQGrIk1P2pg==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/server": "^2.0.0", + "hono": "^4.11.4" + }, + "peerDependenciesMeta": { + "hono": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-json-stringify/node_modules/fast-uri": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.1.tgz", + "integrity": "sha512-YPOs1zD5TG2+EZt+r88LwF6mclA7TPkpwMP7ZN3TO2HiHS8TXvq7QA/17iJsV9dubcLo/f8eEYqMBruyQV21hQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.10.0.tgz", + "integrity": "sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-my-way": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", + "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hono": { + "version": "4.12.32", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", + "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/aws-mcp-servers/fastify-container/package.json b/aws-mcp-servers/fastify-container/package.json new file mode 100644 index 000000000..2ad00f00a --- /dev/null +++ b/aws-mcp-servers/fastify-container/package.json @@ -0,0 +1,14 @@ +{ + "name": "aws-mcp-fastify-container", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "MCP server as a containerized Fastify app on AWS Lambda via Lambda Web Adapter (container image deployment)", + "dependencies": { + "@modelcontextprotocol/fastify": "^2.0.0", + "@modelcontextprotocol/node": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "fastify": "^5.6.0", + "zod": "^4.2.0" + } +} diff --git a/aws-mcp-servers/fastify-container/serverless.yml b/aws-mcp-servers/fastify-container/serverless.yml new file mode 100644 index 000000000..dc2ae3c5a --- /dev/null +++ b/aws-mcp-servers/fastify-container/serverless.yml @@ -0,0 +1,26 @@ +# MCP server as a containerized Fastify app on Lambda, via AWS Lambda Web +# Adapter baked into the image (see Dockerfile). Deploying builds the image +# and pushes it to ECR (Docker must be running). Served on a Function URL +# with response streaming. +service: aws-mcp-fastify-container + +provider: + name: aws + ecr: + images: + mcp: + path: ./ + +functions: + mcp: + image: + name: mcp + timeout: 120 # streamed responses can run up to the full function timeout + url: + invokeMode: RESPONSE_STREAM + # The default (no authorizer) is a public endpoint - fine for trying the + # example, but add auth for real deployments: + # - IAM (SigV4) for AWS-credentialed callers: + # authorizer: aws_iam + # - or OAuth validated in-process with the SDK's requireBearerAuth - see + # the commented block in src/server.mjs. diff --git a/aws-mcp-servers/fastify-container/src/index.mjs b/aws-mcp-servers/fastify-container/src/index.mjs new file mode 100644 index 000000000..d67c8e64b --- /dev/null +++ b/aws-mcp-servers/fastify-container/src/index.mjs @@ -0,0 +1,30 @@ +/** + * A real Fastify app serving the MCP handler - no Lambda-specific code at + * all. AWS Lambda Web Adapter (baked into the container image as an + * extension) proxies Lambda events to this HTTP server over localhost; the + * same image runs unchanged on Fargate, App Runner, or anywhere else. + * + * The web framework and the packaging are independent choices: this example + * pairs Fastify with a container image; the express-web-adapter sibling pairs + * Express with a zip deployment. Swap either dimension without touching + * application code. + */ +import { createMcpFastifyApp } from '@modelcontextprotocol/fastify' +import { toNodeHandler } from '@modelcontextprotocol/node' +import mcp from './server.mjs' + +// host: '0.0.0.0' declares this app as intentionally exposed, which disables +// the factory's localhost DNS-rebinding protections - correct behind a cloud +// front door, where the platform already pins the Host header. +const app = createMcpFastifyApp({ host: '0.0.0.0' }) + +const node = toNodeHandler(mcp) +app.all('/mcp', (request, reply) => { + // Hand the raw response over to the MCP adapter (SSE needs the real stream). + reply.hijack() + node(request.raw, reply.raw, request.body) +}) + +const port = Number(process.env.PORT ?? 8000) +await app.listen({ port, host: '0.0.0.0' }) +console.log(`MCP server listening on http://0.0.0.0:${port}/mcp`) diff --git a/aws-mcp-servers/fastify-container/src/server.mjs b/aws-mcp-servers/fastify-container/src/server.mjs new file mode 100644 index 000000000..fdf1476e6 --- /dev/null +++ b/aws-mcp-servers/fastify-container/src/server.mjs @@ -0,0 +1,193 @@ +/** + * A standard MCP server built with the official MCP TypeScript SDK v2, + * serving the stateless MCP 2026-07-28 protocol revision (and answering + * older clients through the SDK's built-in fallback on the same endpoint). + * + * Nothing in this file is Lambda-specific - the same handler runs on any + * web-standard host. Every example in aws-mcp-servers/ serves this exact + * server; only the hosting glue around it differs. + * + * Surface: + * tools: add (plain JSON), slow_report (progress streaming), + * approve_refund (elicitation - pauses to ask the user) + * resources: guide://usage, orders://{orderId} (template) + * prompts: summarize_order + * extras: instructions (served via server/discover), cache hints + */ +import { + acceptedContent, + createMcpHandler, + inputRequired, + McpServer, + ResourceTemplate, +} from '@modelcontextprotocol/server' +import { z } from 'zod' + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +export default createMcpHandler(() => { + const server = new McpServer( + { name: 'aws-mcp-server', version: '1.0.0' }, + { + // Shown to clients that probe the server/discover method: + instructions: + 'Demo MCP server. Call add for arithmetic, slow_report to watch ' + + 'streamed progress, approve_refund to see a tool ask the user for ' + + 'confirmation mid-call. Read guide://usage for a walkthrough.', + // Cache hints: let clients and shared caches reuse the tool list for + // five minutes. Without hints the SDK emits the conservative defaults + // (ttlMs: 0, cacheScope: private) on every cacheable result: + cacheHints: { 'tools/list': { ttlMs: 300000, cacheScope: 'public' } }, + }, + ) + + // A plain tool: zod schemas next to the implementation; returning + // structuredContent lets clients consume typed results. + server.registerTool( + 'add', + { + description: 'Add two numbers', + inputSchema: z.object({ a: z.number(), b: z.number() }), + outputSchema: z.object({ sum: z.number() }), + }, + async ({ a, b }) => { + const output = { sum: a + b } + return { + content: [{ type: 'text', text: String(output.sum) }], + structuredContent: output, + } + }, + ) + + // A long-running tool emitting progress notifications. When the client + // requests progress (a progressToken in _meta) and accepts text/event-stream, + // the response is served as SSE with notifications ahead of the final result. + server.registerTool( + 'slow_report', + { + description: 'Generate a report, reporting progress along the way', + inputSchema: z.object({ steps: z.number().default(3), progressToken: z.string().optional() }), + }, + async ({ steps, progressToken }, ctx) => { + for (let i = 1; i <= steps; i++) { + await sleep(800) + if (progressToken) { + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progressToken, progress: i, total: steps, message: `step ${i}` }, + }) + } + } + return { content: [{ type: 'text', text: `completed ${steps} steps` }] } + }, + ) + + // Elicitation - a tool that pauses to ask the user for input. The handler + // returns an input_required result; the client asks the user and retries + // the call with the answers attached, re-entering the handler, which reads + // them with acceptedContent. Works statelessly - no session required. + server.registerTool( + 'approve_refund', + { + description: 'Refund an order after user confirmation', + inputSchema: z.object({ orderId: z.string() }), + }, + async ({ orderId }, ctx) => { + const answer = acceptedContent( + ctx.mcpReq.inputResponses, + 'confirm', + z.object({ confirmed: z.boolean() }), + ) + if (answer === undefined) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: `Refund order ${orderId}?`, + requestedSchema: z.object({ confirmed: z.boolean() }), + }), + }, + }) + } + if (!answer.confirmed) { + return { content: [{ type: 'text', text: 'refund cancelled' }] } + } + return { content: [{ type: 'text', text: `refunded ${orderId}` }] } + }, + ) + + // A readable document next to the tools. Clients fetch it with + // resources/read (the Mcp-Name header carries the uri). + server.registerResource( + 'usage-guide', + 'guide://usage', + { description: 'How to use this server', mimeType: 'text/markdown' }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: '# Usage\n\nCall `add` for sums, `slow_report` for streamed progress, `approve_refund` for an elicitation round-trip.', + }, + ], + }), + ) + + // A resource template - parameterized URIs resolved per request. The + // per-resource cacheHint overrides the server-level hints for its reads. + server.registerResource( + 'order', + new ResourceTemplate('orders://{orderId}', { list: undefined }), + { description: 'Order record by id', cacheHint: { ttlMs: 60000, cacheScope: 'private' } }, + async (uri, { orderId }) => ({ + contents: [{ uri: uri.href, text: JSON.stringify({ orderId, status: 'shipped' }) }], + }), + ) + + // A prompt template clients can list and fill in. + server.registerPrompt( + 'summarize_order', + { + description: 'Ask the model to summarize an order', + argsSchema: z.object({ orderId: z.string() }), + }, + ({ orderId }) => ({ + messages: [ + { + role: 'user', + content: { type: 'text', text: `Summarize the status of order ${orderId} in one sentence.` }, + }, + ], + }), + ) + + return server +}) + +// --------------------------------------------------------------------------- +// Going further (uncomment and adapt): +// --------------------------------------------------------------------------- +// +// Signed round-trip state - when an elicitation round-trip must carry server +// state the retry cannot be allowed to tamper with (a computed price, an +// idempotency key), return it as requestState sealed with the SDK's HMAC +// codec. Store the >= 32-byte key in Secrets Manager or SSM: +// +// import { createRequestStateCodec } from '@modelcontextprotocol/server' +// const codec = createRequestStateCodec({ key: process.env.MCP_STATE_KEY, ttlSeconds: 600 }) +// +// In-process OAuth (works identically behind every front door): the SDK ships +// bearer-token middleware and OAuth discovery-document helpers. Wrap the +// handler's fetch face before exporting - any OpenID Connect provider works: +// +// import { requireBearerAuth, oauthMetadataResponse } from '@modelcontextprotocol/server' +// import { createRemoteJWKSet, jwtVerify } from 'jose' +// +// const jwks = createRemoteJWKSet(new URL('https:///.well-known/jwks.json')) +// const verifier = { +// async verifyAccessToken(token) { +// const { payload } = await jwtVerify(token, jwks, { +// issuer: 'https:///', +// audience: 'https://', +// }) +// return { token, clientId: payload.azp, scopes: payload.scope?.split(' ') ?? [], expiresAt: payload.exp } +// }, +// } diff --git a/aws-mcp-servers/function-url/README.md b/aws-mcp-servers/function-url/README.md new file mode 100644 index 000000000..0efa868ec --- /dev/null +++ b/aws-mcp-servers/function-url/README.md @@ -0,0 +1,77 @@ + + +# MCP Server on a Lambda Function URL + +Run a [Model Context Protocol](https://modelcontextprotocol.io) server built with the **official [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)** on a Lambda Function URL with response streaming - the leanest MCP hosting on AWS: no API Gateway, no per-request fee, and streamed responses bounded only by the function timeout. + +This is one of the [aws-mcp-servers](../README.md) examples - they all serve the **same canonical MCP server** and differ only in the hosting glue, so the same test client works against every one of them. Three parts: + +- **`src/server.mjs`** - the canonical server: tools (`add`, `slow_report` with streamed progress, `approve_refund` with an elicitation round-trip), resources (`guide://usage` plus an `orders://{orderId}` template), a prompt, `instructions` served via `server/discover`, and cache hints on the tool list. Nothing Lambda-specific. +- **`src/lambda.mjs`** - ~80 lines of adapter glue mapping the Function URL event and the Lambda response stream onto the Node-style shapes that `@modelcontextprotocol/node` accepts. No MCP logic. (For glue-free alternatives built on existing packages, see the [hono](../hono) and [express-web-adapter](../express-web-adapter) examples.) +- **`serverless.yml`** - a Function URL with `invokeMode: RESPONSE_STREAM`. + +Plain JSON and streaming SSE coexist on the one endpoint. **Related:** [rest-api](../rest-api) serves the same server through API Gateway REST in stream mode - pick that one when you want a custom domain, WAF, throttling, or an authorizer that rejects requests before they invoke the function; pick this one for the simplest and cheapest setup. + +## Usage + +### Deploy + +```bash +npm install +serverless deploy +``` + +### Test + +Every example in this family ships the same test client: + +```bash +ENDPOINT=mcp node client.mjs +``` + +``` +PASS 1. tools/list returns the canonical tools with cache hints — ttlMs=300000 cacheScope=public +PASS 2. add returns plain JSON with structured content — sum=42 +PASS 3. slow_report streams incremental progress over SSE — 3 progress events, first at +810ms +PASS 4. approve_refund elicitation round-trip (accept and cancel) — input_required -> refunded o-1 / refund cancelled +PASS 5. resources list + read (Mcp-Name carries the uri) — guide://usage readable +PASS 6. resource template read with per-resource cache hint — orders://o-42 -> shipped, ttlMs=60000 +PASS 7. prompts list + get — summarize_order fills its argument +PASS 8. server/discover surfaces the instructions — instructions present +PASS 9. legacy initialize is answered on the same endpoint — served as 2025-06-18 +PASS 10. GET is answered with the spec-mandated 405 — HTTP 405 +PASS 11. Mcp-Method header mismatching the body is rejected (-32020) — -32020 +PASS 12. elicitation without the client capability is rejected (-32021) — -32021 + +12/12 passed +``` + +Add `LONG=1` for a ~36-second streaming case - on Function URLs no extra timeout configuration is needed; the function `timeout` is the only bound. + +### Authentication + +The demo endpoint is public. For real deployments, two options (both included as comments): + +- **IAM (SigV4)** - uncomment `authorizer: aws_iam` in `serverless.yml` for AWS-credentialed callers; the shared client tests this mode with `AUTH=sigv4 SERVICE=lambda`. +- **Validate in-process** - the SDK's `requireBearerAuth` with a `jose` JWKS verifier (commented block at the bottom of `src/server.mjs`) validates OAuth Bearer tokens from any OpenID Connect provider. + +### Going further + +- **Signed round-trip state**: seal server state across elicitation retries with `createRequestStateCodec` (see the comment in `src/server.mjs`). +- **Long-running tools**: raise the function `timeout` - streamed responses run up to 15 minutes with no other configuration. + +### Cleanup + +```bash +serverless remove +``` diff --git a/aws-mcp-servers/function-url/client.mjs b/aws-mcp-servers/function-url/client.mjs new file mode 100644 index 000000000..66f738e9a --- /dev/null +++ b/aws-mcp-servers/function-url/client.mjs @@ -0,0 +1,316 @@ +#!/usr/bin/env node +/** + * Shared test client for the aws-mcp-servers examples. Every example serves + * the same canonical MCP server, so this same script tests all of them. + * + * Usage: + * ENDPOINT= node client.mjs + * LONG=1 ENDPOINT=... node client.mjs # adds a ~36s streaming case + * AUTH=sigv4 SERVICE=bedrock-agentcore ENDPOINT= node client.mjs + * AUTH=sigv4 SERVICE=lambda ENDPOINT=... node client.mjs # IAM-auth Function URL + * + * Exercises the full 2026-07-28 surface: plain JSON, SSE progress streaming, + * an elicitation round-trip, resources (static + template), prompts, + * server/discover, cache hints, the legacy fallback, and two negative cases + * (header mismatch -32020, missing capability -32021). + */ + +const ENDPOINT = process.env.ENDPOINT +if (!ENDPOINT) { + console.error('Set ENDPOINT to the MCP endpoint URL') + process.exit(1) +} + +// --- endpoint + optional SigV4 signing -------------------------------------- + +let url = new URL(ENDPOINT) +let signer +if (process.env.AUTH === 'sigv4') { + const service = process.env.SERVICE || 'lambda' + if (service === 'bedrock-agentcore' && url.pathname.includes('/runtimes/')) { + // AgentCore runtime URLs embed the agent ARN - the WHOLE ARN must be + // URL-encoded in the path (including its inner "/"). + const arn = decodeURIComponent(url.pathname.match(/\/runtimes\/(.+?)\/invocations/)[1]) + url = new URL(`${url.origin}/runtimes/${encodeURIComponent(arn)}/invocations`) + url.searchParams.set('qualifier', 'DEFAULT') + } + const [{ SignatureV4 }, { HttpRequest }, { Sha256 }, { defaultProvider }] = await Promise.all([ + import('@smithy/signature-v4'), + import('@smithy/protocol-http'), + import('@aws-crypto/sha256-js'), + import('@aws-sdk/credential-provider-node'), + ]) + const sigv4 = new SignatureV4({ + service, + region: process.env.AWS_REGION || 'us-east-1', + credentials: defaultProvider(), + sha256: Sha256, + }) + signer = async (method, headers, body) => { + const request = new HttpRequest({ + method, + protocol: url.protocol, + hostname: url.hostname, + path: url.pathname, + query: Object.fromEntries(url.searchParams), + headers: { host: url.hostname, ...headers }, + ...(body != null && { body }), + }) + return (await sigv4.sign(request)).headers + } +} + +// --- request helpers --------------------------------------------------------- + +const CAPS_ELICIT = { elicitation: { form: {} } } +const meta = (capabilities = {}) => ({ + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'aws-mcp-servers-client', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': capabilities, +}) + +let id = 0 +async function request({ method, params = {}, name, capabilities, headerMethod, envelope = true }) { + const body = JSON.stringify({ + jsonrpc: '2.0', + id: ++id, + method, + params: envelope ? { ...params, _meta: { ...meta(capabilities), ...(params._meta ?? {}) } } : params, + }) + let headers = { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...(envelope && { + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': headerMethod ?? method, + ...(name && { 'mcp-name': name }), + }), + } + if (signer) headers = await signer('POST', headers, body) + const t0 = Date.now() + const res = await fetch(url, { method: 'POST', headers, body }) + const contentType = res.headers.get('content-type') ?? '' + if (contentType.includes('text/event-stream')) { + const events = [] + const decoder = new TextDecoder() + let buffer = '' + for await (const chunk of res.body) { + buffer += decoder.decode(chunk, { stream: true }) + let sep + while ((sep = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, sep) + buffer = buffer.slice(sep + 2) + const data = frame.split('\n').find((l) => l.startsWith('data:'))?.slice(5).trim() + if (data) events.push({ t: Date.now() - t0, data: JSON.parse(data) }) + } + } + const final = events.at(-1)?.data + return { status: res.status, contentType, events, json: final, ms: Date.now() - t0 } + } + const text = await res.text() + let json + try { + json = JSON.parse(text) + } catch { + json = { raw: text } + } + return { status: res.status, contentType, events: [], json, ms: Date.now() - t0 } +} + +// --- tiny harness ------------------------------------------------------------ + +const results = [] +async function check(label, fn) { + try { + const detail = await fn() + results.push({ label, ok: true }) + console.log(`PASS ${label}${detail ? ` — ${detail}` : ''}`) + } catch (err) { + results.push({ label, ok: false }) + console.log(`FAIL ${label} — ${err.message}`) + } +} +const assert = (cond, msg) => { + if (!cond) throw new Error(msg) +} +// AgentCore Runtime replaces 4xx response bodies with its own error envelope +// (-32010, "Received error (400) ..."); the 400 status still proves the SDK +// rejected the request, so the negative cases accept that form there. +const rejectedWith = (json, code) => + json.error?.code === code || + (process.env.SERVICE === 'bedrock-agentcore' && + json.error?.code === -32010 && + /\(4\d\d\)/.test(json.error?.message ?? '')) + +// --- test cases ---------------------------------------------------------------- + +await check('1. tools/list returns the canonical tools with cache hints', async () => { + const r = await request({ method: 'tools/list' }) + assert(r.status === 200, `HTTP ${r.status}`) + assert(r.contentType.includes('application/json'), `content-type ${r.contentType}`) + const names = (r.json.result?.tools ?? []).map((t) => t.name).sort() + assert(JSON.stringify(names) === JSON.stringify(['add', 'approve_refund', 'slow_report']), `tools: ${names}`) + assert(r.json.result.ttlMs === 300000, `ttlMs ${r.json.result.ttlMs}`) + assert(r.json.result.cacheScope === 'public', `cacheScope ${r.json.result.cacheScope}`) + return `ttlMs=${r.json.result.ttlMs} cacheScope=${r.json.result.cacheScope}` +}) + +await check('2. add returns plain JSON with structured content', async () => { + const r = await request({ method: 'tools/call', params: { name: 'add', arguments: { a: 2, b: 40 } }, name: 'add' }) + assert(r.status === 200, `HTTP ${r.status}`) + assert(r.contentType.includes('application/json'), `content-type ${r.contentType}`) + assert(r.json.result?.structuredContent?.sum === 42, JSON.stringify(r.json)) + return 'sum=42' +}) + +await check('3. slow_report streams incremental progress over SSE', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'slow_report', arguments: { steps: 3, progressToken: 'pt' }, _meta: { progressToken: 'pt' } }, + name: 'slow_report', + }) + assert(r.status === 200, `HTTP ${r.status}`) + assert(r.contentType.includes('text/event-stream'), `content-type ${r.contentType}`) + const progress = r.events.filter((e) => e.data.method === 'notifications/progress') + assert(progress.length === 3, `${progress.length} progress events`) + assert(progress[0].t < r.ms - 500, 'first progress event did not arrive before the final result') + assert(r.json.result?.content?.[0]?.text === 'completed 3 steps', JSON.stringify(r.json)) + return `3 progress events, first at +${progress[0].t}ms, done in ${r.ms}ms` +}) + +await check('4. approve_refund elicitation round-trip (accept and cancel)', async () => { + const ask = await request({ + method: 'tools/call', + params: { name: 'approve_refund', arguments: { orderId: 'o-1' } }, + name: 'approve_refund', + capabilities: CAPS_ELICIT, + }) + assert(ask.json.result?.resultType === 'input_required', JSON.stringify(ask.json)) + assert(ask.json.result?.inputRequests?.confirm, 'missing inputRequests.confirm') + const accepted = await request({ + method: 'tools/call', + params: { + name: 'approve_refund', + arguments: { orderId: 'o-1' }, + inputResponses: { confirm: { action: 'accept', content: { confirmed: true } } }, + }, + name: 'approve_refund', + capabilities: CAPS_ELICIT, + }) + assert(accepted.json.result?.content?.[0]?.text === 'refunded o-1', JSON.stringify(accepted.json)) + const cancelled = await request({ + method: 'tools/call', + params: { + name: 'approve_refund', + arguments: { orderId: 'o-1' }, + inputResponses: { confirm: { action: 'accept', content: { confirmed: false } } }, + }, + name: 'approve_refund', + capabilities: CAPS_ELICIT, + }) + assert(cancelled.json.result?.content?.[0]?.text === 'refund cancelled', JSON.stringify(cancelled.json)) + return 'input_required -> refunded o-1 / refund cancelled' +}) + +await check('5. resources list + read (Mcp-Name carries the uri)', async () => { + const list = await request({ method: 'resources/list' }) + const uris = (list.json.result?.resources ?? []).map((r) => r.uri) + assert(uris.includes('guide://usage'), `resources: ${uris}`) + const read = await request({ method: 'resources/read', params: { uri: 'guide://usage' }, name: 'guide://usage' }) + assert(read.json.result?.contents?.[0]?.text?.startsWith('# Usage'), JSON.stringify(read.json)) + return 'guide://usage readable' +}) + +await check('6. resource template read with per-resource cache hint', async () => { + const templates = await request({ method: 'resources/templates/list' }) + const uriTemplates = (templates.json.result?.resourceTemplates ?? []).map((t) => t.uriTemplate) + assert(uriTemplates.includes('orders://{orderId}'), `templates: ${uriTemplates}`) + const read = await request({ method: 'resources/read', params: { uri: 'orders://o-42' }, name: 'orders://o-42' }) + const record = JSON.parse(read.json.result?.contents?.[0]?.text ?? '{}') + assert(record.orderId === 'o-42' && record.status === 'shipped', JSON.stringify(read.json)) + assert(read.json.result.ttlMs === 60000, `ttlMs ${read.json.result.ttlMs}`) + return `orders://o-42 -> ${record.status}, ttlMs=${read.json.result.ttlMs}` +}) + +await check('7. prompts list + get', async () => { + const list = await request({ method: 'prompts/list' }) + const names = (list.json.result?.prompts ?? []).map((p) => p.name) + assert(names.includes('summarize_order'), `prompts: ${names}`) + const got = await request({ + method: 'prompts/get', + params: { name: 'summarize_order', arguments: { orderId: 'o-7' } }, + name: 'summarize_order', + }) + const text = got.json.result?.messages?.[0]?.content?.text ?? '' + assert(text.includes('o-7'), JSON.stringify(got.json)) + return 'summarize_order fills its argument' +}) + +await check('8. server/discover surfaces the instructions', async () => { + const r = await request({ method: 'server/discover' }) + assert((r.json.result?.instructions ?? '').includes('Demo MCP server'), JSON.stringify(r.json.result ?? r.json)) + return 'instructions present' +}) + +await check('9. legacy initialize is answered on the same endpoint', async () => { + const r = await request({ + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'legacy', version: '1.0.0' } }, + envelope: false, + }) + assert(r.status === 200, `HTTP ${r.status}`) + const version = r.json.result?.protocolVersion ?? '' + assert(version.startsWith('2025-'), JSON.stringify(r.json)) + return `served as ${version}` +}) + +await check('10. GET is answered with the spec-mandated 405', async () => { + let headers = { accept: 'application/json, text/event-stream' } + if (signer) headers = await signer('GET', headers, null) + const res = await fetch(url, { method: 'GET', headers }) + assert(res.status === 405, `HTTP ${res.status}`) + return 'HTTP 405' +}) + +await check('11. Mcp-Method header mismatching the body is rejected (-32020)', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'add', arguments: { a: 1, b: 1 } }, + name: 'add', + headerMethod: 'tools/list', + }) + assert(rejectedWith(r.json, -32020), JSON.stringify(r.json)) + return r.json.error.code === -32020 ? '-32020' : 'rejected (platform-wrapped 400)' +}) + +await check('12. elicitation without the client capability is rejected (-32021)', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'approve_refund', arguments: { orderId: 'o-9' } }, + name: 'approve_refund', + capabilities: {}, + }) + assert(rejectedWith(r.json, -32021), JSON.stringify(r.json)) + return r.json.error.code === -32021 ? '-32021' : 'rejected (platform-wrapped 400)' +}) + +if (process.env.LONG) { + await check('13. LONG: 45-step stream (~36s) survives past the 29s mark', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'slow_report', arguments: { steps: 45, progressToken: 'long' }, _meta: { progressToken: 'long' } }, + name: 'slow_report', + }) + const progress = r.events.filter((e) => e.data.method === 'notifications/progress') + assert(progress.length === 45, `${progress.length} progress events`) + assert(r.json.result?.content?.[0]?.text === 'completed 45 steps', 'final result missing (stream cut?)') + assert(r.ms > 34000, `finished suspiciously fast (${r.ms}ms)`) + return `45 events over ${(r.ms / 1000).toFixed(1)}s, final result received` + }) +} + +// --- summary ------------------------------------------------------------------- + +const failed = results.filter((r) => !r.ok) +console.log(`\n${results.length - failed.length}/${results.length} passed`) +process.exit(failed.length ? 1 : 0) diff --git a/aws-node-mcp-server/package-lock.json b/aws-mcp-servers/function-url/package-lock.json similarity index 97% rename from aws-node-mcp-server/package-lock.json rename to aws-mcp-servers/function-url/package-lock.json index 595a88cca..2ae0e613b 100644 --- a/aws-node-mcp-server/package-lock.json +++ b/aws-mcp-servers/function-url/package-lock.json @@ -1,11 +1,11 @@ { - "name": "aws-node-mcp-server", + "name": "aws-mcp-function-url", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "aws-node-mcp-server", + "name": "aws-mcp-function-url", "version": "1.0.0", "dependencies": { "@modelcontextprotocol/node": "^2.0.0", diff --git a/aws-node-mcp-server-function-url/package.json b/aws-mcp-servers/function-url/package.json similarity index 86% rename from aws-node-mcp-server-function-url/package.json rename to aws-mcp-servers/function-url/package.json index e3e73a29d..0aa958249 100644 --- a/aws-node-mcp-server-function-url/package.json +++ b/aws-mcp-servers/function-url/package.json @@ -1,5 +1,5 @@ { - "name": "aws-node-mcp-server-function-url", + "name": "aws-mcp-function-url", "version": "1.0.0", "private": true, "type": "module", @@ -9,4 +9,4 @@ "@modelcontextprotocol/server": "^2.0.0", "zod": "^4.2.0" } -} \ No newline at end of file +} diff --git a/aws-node-mcp-server-function-url/serverless.yml b/aws-mcp-servers/function-url/serverless.yml similarity index 85% rename from aws-node-mcp-server-function-url/serverless.yml rename to aws-mcp-servers/function-url/serverless.yml index 39ed2d9dc..508bda4e3 100644 --- a/aws-node-mcp-server-function-url/serverless.yml +++ b/aws-mcp-servers/function-url/serverless.yml @@ -3,7 +3,7 @@ # The leanest possible MCP hosting on AWS: no API Gateway, no per-request # fee - the Function URL invokes the Lambda directly, and response streaming # carries both plain JSON and SSE on the same endpoint. -service: aws-node-mcp-server-function-url +service: aws-mcp-function-url provider: name: aws @@ -18,7 +18,8 @@ functions: # Function URLs support two auth modes. The default (no authorizer) is a # public endpoint - fine for trying the example, but add auth for real # deployments: - # - IAM (SigV4) for AWS-credentialed callers: + # - IAM (SigV4) for AWS-credentialed callers (the shared client tests + # this with AUTH=sigv4 SERVICE=lambda): # authorizer: aws_iam # - or OAuth for MCP clients (Claude, AI IDEs) validated in-process with # the SDK's requireBearerAuth - see the commented block in diff --git a/aws-node-mcp-server-function-url/src/lambda.mjs b/aws-mcp-servers/function-url/src/lambda.mjs similarity index 100% rename from aws-node-mcp-server-function-url/src/lambda.mjs rename to aws-mcp-servers/function-url/src/lambda.mjs diff --git a/aws-mcp-servers/function-url/src/server.mjs b/aws-mcp-servers/function-url/src/server.mjs new file mode 100644 index 000000000..fdf1476e6 --- /dev/null +++ b/aws-mcp-servers/function-url/src/server.mjs @@ -0,0 +1,193 @@ +/** + * A standard MCP server built with the official MCP TypeScript SDK v2, + * serving the stateless MCP 2026-07-28 protocol revision (and answering + * older clients through the SDK's built-in fallback on the same endpoint). + * + * Nothing in this file is Lambda-specific - the same handler runs on any + * web-standard host. Every example in aws-mcp-servers/ serves this exact + * server; only the hosting glue around it differs. + * + * Surface: + * tools: add (plain JSON), slow_report (progress streaming), + * approve_refund (elicitation - pauses to ask the user) + * resources: guide://usage, orders://{orderId} (template) + * prompts: summarize_order + * extras: instructions (served via server/discover), cache hints + */ +import { + acceptedContent, + createMcpHandler, + inputRequired, + McpServer, + ResourceTemplate, +} from '@modelcontextprotocol/server' +import { z } from 'zod' + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +export default createMcpHandler(() => { + const server = new McpServer( + { name: 'aws-mcp-server', version: '1.0.0' }, + { + // Shown to clients that probe the server/discover method: + instructions: + 'Demo MCP server. Call add for arithmetic, slow_report to watch ' + + 'streamed progress, approve_refund to see a tool ask the user for ' + + 'confirmation mid-call. Read guide://usage for a walkthrough.', + // Cache hints: let clients and shared caches reuse the tool list for + // five minutes. Without hints the SDK emits the conservative defaults + // (ttlMs: 0, cacheScope: private) on every cacheable result: + cacheHints: { 'tools/list': { ttlMs: 300000, cacheScope: 'public' } }, + }, + ) + + // A plain tool: zod schemas next to the implementation; returning + // structuredContent lets clients consume typed results. + server.registerTool( + 'add', + { + description: 'Add two numbers', + inputSchema: z.object({ a: z.number(), b: z.number() }), + outputSchema: z.object({ sum: z.number() }), + }, + async ({ a, b }) => { + const output = { sum: a + b } + return { + content: [{ type: 'text', text: String(output.sum) }], + structuredContent: output, + } + }, + ) + + // A long-running tool emitting progress notifications. When the client + // requests progress (a progressToken in _meta) and accepts text/event-stream, + // the response is served as SSE with notifications ahead of the final result. + server.registerTool( + 'slow_report', + { + description: 'Generate a report, reporting progress along the way', + inputSchema: z.object({ steps: z.number().default(3), progressToken: z.string().optional() }), + }, + async ({ steps, progressToken }, ctx) => { + for (let i = 1; i <= steps; i++) { + await sleep(800) + if (progressToken) { + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progressToken, progress: i, total: steps, message: `step ${i}` }, + }) + } + } + return { content: [{ type: 'text', text: `completed ${steps} steps` }] } + }, + ) + + // Elicitation - a tool that pauses to ask the user for input. The handler + // returns an input_required result; the client asks the user and retries + // the call with the answers attached, re-entering the handler, which reads + // them with acceptedContent. Works statelessly - no session required. + server.registerTool( + 'approve_refund', + { + description: 'Refund an order after user confirmation', + inputSchema: z.object({ orderId: z.string() }), + }, + async ({ orderId }, ctx) => { + const answer = acceptedContent( + ctx.mcpReq.inputResponses, + 'confirm', + z.object({ confirmed: z.boolean() }), + ) + if (answer === undefined) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: `Refund order ${orderId}?`, + requestedSchema: z.object({ confirmed: z.boolean() }), + }), + }, + }) + } + if (!answer.confirmed) { + return { content: [{ type: 'text', text: 'refund cancelled' }] } + } + return { content: [{ type: 'text', text: `refunded ${orderId}` }] } + }, + ) + + // A readable document next to the tools. Clients fetch it with + // resources/read (the Mcp-Name header carries the uri). + server.registerResource( + 'usage-guide', + 'guide://usage', + { description: 'How to use this server', mimeType: 'text/markdown' }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: '# Usage\n\nCall `add` for sums, `slow_report` for streamed progress, `approve_refund` for an elicitation round-trip.', + }, + ], + }), + ) + + // A resource template - parameterized URIs resolved per request. The + // per-resource cacheHint overrides the server-level hints for its reads. + server.registerResource( + 'order', + new ResourceTemplate('orders://{orderId}', { list: undefined }), + { description: 'Order record by id', cacheHint: { ttlMs: 60000, cacheScope: 'private' } }, + async (uri, { orderId }) => ({ + contents: [{ uri: uri.href, text: JSON.stringify({ orderId, status: 'shipped' }) }], + }), + ) + + // A prompt template clients can list and fill in. + server.registerPrompt( + 'summarize_order', + { + description: 'Ask the model to summarize an order', + argsSchema: z.object({ orderId: z.string() }), + }, + ({ orderId }) => ({ + messages: [ + { + role: 'user', + content: { type: 'text', text: `Summarize the status of order ${orderId} in one sentence.` }, + }, + ], + }), + ) + + return server +}) + +// --------------------------------------------------------------------------- +// Going further (uncomment and adapt): +// --------------------------------------------------------------------------- +// +// Signed round-trip state - when an elicitation round-trip must carry server +// state the retry cannot be allowed to tamper with (a computed price, an +// idempotency key), return it as requestState sealed with the SDK's HMAC +// codec. Store the >= 32-byte key in Secrets Manager or SSM: +// +// import { createRequestStateCodec } from '@modelcontextprotocol/server' +// const codec = createRequestStateCodec({ key: process.env.MCP_STATE_KEY, ttlSeconds: 600 }) +// +// In-process OAuth (works identically behind every front door): the SDK ships +// bearer-token middleware and OAuth discovery-document helpers. Wrap the +// handler's fetch face before exporting - any OpenID Connect provider works: +// +// import { requireBearerAuth, oauthMetadataResponse } from '@modelcontextprotocol/server' +// import { createRemoteJWKSet, jwtVerify } from 'jose' +// +// const jwks = createRemoteJWKSet(new URL('https:///.well-known/jwks.json')) +// const verifier = { +// async verifyAccessToken(token) { +// const { payload } = await jwtVerify(token, jwks, { +// issuer: 'https:///', +// audience: 'https://', +// }) +// return { token, clientId: payload.azp, scopes: payload.scope?.split(' ') ?? [], expiresAt: payload.exp } +// }, +// } diff --git a/aws-mcp-servers/hono/README.md b/aws-mcp-servers/hono/README.md new file mode 100644 index 000000000..d883fc4e5 --- /dev/null +++ b/aws-mcp-servers/hono/README.md @@ -0,0 +1,73 @@ + + +# MCP Server with Hono - zero custom glue + +Run a [Model Context Protocol](https://modelcontextprotocol.io) server on AWS Lambda using **only existing, maintained packages** for the Lambda bridge: the MCP SDK's official [`@modelcontextprotocol/hono`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/packages/middleware/hono) integration plus [Hono](https://hono.dev)'s own `streamHandle` aws-lambda adapter. The entire Lambda-specific code is a route: + +```js +const app = createMcpHonoApp({ host: '0.0.0.0' }) +app.all('/mcp', (c) => mcp.fetch(c.req.raw, { parsedBody: c.get('parsedBody') })) +export const handler = streamHandle(app) +``` + +`streamHandle` dispatches on the incoming event shape, so this same export works behind a Function URL (used here), API Gateway (REST stream or HTTP API), and ALB. + +This is one of the [aws-mcp-servers](../README.md) examples - they all serve the **same canonical MCP server** (`src/server.mjs`: tools with streamed progress and an elicitation round-trip, resources, a prompt, `server/discover` instructions, cache hints) and differ only in the hosting glue, so the same test client works against every one of them. Compare [function-url](../function-url), which does the same bridging with ~80 lines of hand-written adapter and one less dependency. + +A note on `createMcpHonoApp`: its localhost Host/Origin validation exists to protect *locally running* servers from DNS rebinding. Behind a Function URL or API Gateway the platform already pins the Host header, so `host: '0.0.0.0'` (intentionally exposed) is the right setting - it logs a one-line warning per cold start. Access control for a cloud endpoint comes from the auth options below instead. + +## Usage + +### Deploy + +```bash +npm install +serverless deploy +``` + +### Test + +Every example in this family ships the same test client: + +```bash +ENDPOINT=mcp node client.mjs +``` + +``` +PASS 1. tools/list returns the canonical tools with cache hints — ttlMs=300000 cacheScope=public +PASS 2. add returns plain JSON with structured content — sum=42 +PASS 3. slow_report streams incremental progress over SSE — 3 progress events, first at +810ms +PASS 4. approve_refund elicitation round-trip (accept and cancel) — input_required -> refunded o-1 / refund cancelled +PASS 5. resources list + read (Mcp-Name carries the uri) — guide://usage readable +PASS 6. resource template read with per-resource cache hint — orders://o-42 -> shipped, ttlMs=60000 +PASS 7. prompts list + get — summarize_order fills its argument +PASS 8. server/discover surfaces the instructions — instructions present +PASS 9. legacy initialize is answered on the same endpoint — served as 2025-06-18 +PASS 10. GET is answered with the spec-mandated 405 — HTTP 405 +PASS 11. Mcp-Method header mismatching the body is rejected (-32020) — -32020 +PASS 12. elicitation without the client capability is rejected (-32021) — -32021 + +12/12 passed +``` + +Add `LONG=1` for a ~36-second streaming case. + +### Authentication + +The demo endpoint is public. For real deployments (both included as comments): uncomment `authorizer: aws_iam` in `serverless.yml` for AWS-credentialed callers (the shared client tests this mode with `AUTH=sigv4 SERVICE=lambda`), or validate OAuth Bearer tokens in-process with the SDK's `requireBearerAuth` and a `jose` JWKS verifier (commented block at the bottom of `src/server.mjs`) - works with any OpenID Connect provider. + +### Cleanup + +```bash +serverless remove +``` diff --git a/aws-mcp-servers/hono/client.mjs b/aws-mcp-servers/hono/client.mjs new file mode 100644 index 000000000..66f738e9a --- /dev/null +++ b/aws-mcp-servers/hono/client.mjs @@ -0,0 +1,316 @@ +#!/usr/bin/env node +/** + * Shared test client for the aws-mcp-servers examples. Every example serves + * the same canonical MCP server, so this same script tests all of them. + * + * Usage: + * ENDPOINT= node client.mjs + * LONG=1 ENDPOINT=... node client.mjs # adds a ~36s streaming case + * AUTH=sigv4 SERVICE=bedrock-agentcore ENDPOINT= node client.mjs + * AUTH=sigv4 SERVICE=lambda ENDPOINT=... node client.mjs # IAM-auth Function URL + * + * Exercises the full 2026-07-28 surface: plain JSON, SSE progress streaming, + * an elicitation round-trip, resources (static + template), prompts, + * server/discover, cache hints, the legacy fallback, and two negative cases + * (header mismatch -32020, missing capability -32021). + */ + +const ENDPOINT = process.env.ENDPOINT +if (!ENDPOINT) { + console.error('Set ENDPOINT to the MCP endpoint URL') + process.exit(1) +} + +// --- endpoint + optional SigV4 signing -------------------------------------- + +let url = new URL(ENDPOINT) +let signer +if (process.env.AUTH === 'sigv4') { + const service = process.env.SERVICE || 'lambda' + if (service === 'bedrock-agentcore' && url.pathname.includes('/runtimes/')) { + // AgentCore runtime URLs embed the agent ARN - the WHOLE ARN must be + // URL-encoded in the path (including its inner "/"). + const arn = decodeURIComponent(url.pathname.match(/\/runtimes\/(.+?)\/invocations/)[1]) + url = new URL(`${url.origin}/runtimes/${encodeURIComponent(arn)}/invocations`) + url.searchParams.set('qualifier', 'DEFAULT') + } + const [{ SignatureV4 }, { HttpRequest }, { Sha256 }, { defaultProvider }] = await Promise.all([ + import('@smithy/signature-v4'), + import('@smithy/protocol-http'), + import('@aws-crypto/sha256-js'), + import('@aws-sdk/credential-provider-node'), + ]) + const sigv4 = new SignatureV4({ + service, + region: process.env.AWS_REGION || 'us-east-1', + credentials: defaultProvider(), + sha256: Sha256, + }) + signer = async (method, headers, body) => { + const request = new HttpRequest({ + method, + protocol: url.protocol, + hostname: url.hostname, + path: url.pathname, + query: Object.fromEntries(url.searchParams), + headers: { host: url.hostname, ...headers }, + ...(body != null && { body }), + }) + return (await sigv4.sign(request)).headers + } +} + +// --- request helpers --------------------------------------------------------- + +const CAPS_ELICIT = { elicitation: { form: {} } } +const meta = (capabilities = {}) => ({ + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'aws-mcp-servers-client', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': capabilities, +}) + +let id = 0 +async function request({ method, params = {}, name, capabilities, headerMethod, envelope = true }) { + const body = JSON.stringify({ + jsonrpc: '2.0', + id: ++id, + method, + params: envelope ? { ...params, _meta: { ...meta(capabilities), ...(params._meta ?? {}) } } : params, + }) + let headers = { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...(envelope && { + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': headerMethod ?? method, + ...(name && { 'mcp-name': name }), + }), + } + if (signer) headers = await signer('POST', headers, body) + const t0 = Date.now() + const res = await fetch(url, { method: 'POST', headers, body }) + const contentType = res.headers.get('content-type') ?? '' + if (contentType.includes('text/event-stream')) { + const events = [] + const decoder = new TextDecoder() + let buffer = '' + for await (const chunk of res.body) { + buffer += decoder.decode(chunk, { stream: true }) + let sep + while ((sep = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, sep) + buffer = buffer.slice(sep + 2) + const data = frame.split('\n').find((l) => l.startsWith('data:'))?.slice(5).trim() + if (data) events.push({ t: Date.now() - t0, data: JSON.parse(data) }) + } + } + const final = events.at(-1)?.data + return { status: res.status, contentType, events, json: final, ms: Date.now() - t0 } + } + const text = await res.text() + let json + try { + json = JSON.parse(text) + } catch { + json = { raw: text } + } + return { status: res.status, contentType, events: [], json, ms: Date.now() - t0 } +} + +// --- tiny harness ------------------------------------------------------------ + +const results = [] +async function check(label, fn) { + try { + const detail = await fn() + results.push({ label, ok: true }) + console.log(`PASS ${label}${detail ? ` — ${detail}` : ''}`) + } catch (err) { + results.push({ label, ok: false }) + console.log(`FAIL ${label} — ${err.message}`) + } +} +const assert = (cond, msg) => { + if (!cond) throw new Error(msg) +} +// AgentCore Runtime replaces 4xx response bodies with its own error envelope +// (-32010, "Received error (400) ..."); the 400 status still proves the SDK +// rejected the request, so the negative cases accept that form there. +const rejectedWith = (json, code) => + json.error?.code === code || + (process.env.SERVICE === 'bedrock-agentcore' && + json.error?.code === -32010 && + /\(4\d\d\)/.test(json.error?.message ?? '')) + +// --- test cases ---------------------------------------------------------------- + +await check('1. tools/list returns the canonical tools with cache hints', async () => { + const r = await request({ method: 'tools/list' }) + assert(r.status === 200, `HTTP ${r.status}`) + assert(r.contentType.includes('application/json'), `content-type ${r.contentType}`) + const names = (r.json.result?.tools ?? []).map((t) => t.name).sort() + assert(JSON.stringify(names) === JSON.stringify(['add', 'approve_refund', 'slow_report']), `tools: ${names}`) + assert(r.json.result.ttlMs === 300000, `ttlMs ${r.json.result.ttlMs}`) + assert(r.json.result.cacheScope === 'public', `cacheScope ${r.json.result.cacheScope}`) + return `ttlMs=${r.json.result.ttlMs} cacheScope=${r.json.result.cacheScope}` +}) + +await check('2. add returns plain JSON with structured content', async () => { + const r = await request({ method: 'tools/call', params: { name: 'add', arguments: { a: 2, b: 40 } }, name: 'add' }) + assert(r.status === 200, `HTTP ${r.status}`) + assert(r.contentType.includes('application/json'), `content-type ${r.contentType}`) + assert(r.json.result?.structuredContent?.sum === 42, JSON.stringify(r.json)) + return 'sum=42' +}) + +await check('3. slow_report streams incremental progress over SSE', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'slow_report', arguments: { steps: 3, progressToken: 'pt' }, _meta: { progressToken: 'pt' } }, + name: 'slow_report', + }) + assert(r.status === 200, `HTTP ${r.status}`) + assert(r.contentType.includes('text/event-stream'), `content-type ${r.contentType}`) + const progress = r.events.filter((e) => e.data.method === 'notifications/progress') + assert(progress.length === 3, `${progress.length} progress events`) + assert(progress[0].t < r.ms - 500, 'first progress event did not arrive before the final result') + assert(r.json.result?.content?.[0]?.text === 'completed 3 steps', JSON.stringify(r.json)) + return `3 progress events, first at +${progress[0].t}ms, done in ${r.ms}ms` +}) + +await check('4. approve_refund elicitation round-trip (accept and cancel)', async () => { + const ask = await request({ + method: 'tools/call', + params: { name: 'approve_refund', arguments: { orderId: 'o-1' } }, + name: 'approve_refund', + capabilities: CAPS_ELICIT, + }) + assert(ask.json.result?.resultType === 'input_required', JSON.stringify(ask.json)) + assert(ask.json.result?.inputRequests?.confirm, 'missing inputRequests.confirm') + const accepted = await request({ + method: 'tools/call', + params: { + name: 'approve_refund', + arguments: { orderId: 'o-1' }, + inputResponses: { confirm: { action: 'accept', content: { confirmed: true } } }, + }, + name: 'approve_refund', + capabilities: CAPS_ELICIT, + }) + assert(accepted.json.result?.content?.[0]?.text === 'refunded o-1', JSON.stringify(accepted.json)) + const cancelled = await request({ + method: 'tools/call', + params: { + name: 'approve_refund', + arguments: { orderId: 'o-1' }, + inputResponses: { confirm: { action: 'accept', content: { confirmed: false } } }, + }, + name: 'approve_refund', + capabilities: CAPS_ELICIT, + }) + assert(cancelled.json.result?.content?.[0]?.text === 'refund cancelled', JSON.stringify(cancelled.json)) + return 'input_required -> refunded o-1 / refund cancelled' +}) + +await check('5. resources list + read (Mcp-Name carries the uri)', async () => { + const list = await request({ method: 'resources/list' }) + const uris = (list.json.result?.resources ?? []).map((r) => r.uri) + assert(uris.includes('guide://usage'), `resources: ${uris}`) + const read = await request({ method: 'resources/read', params: { uri: 'guide://usage' }, name: 'guide://usage' }) + assert(read.json.result?.contents?.[0]?.text?.startsWith('# Usage'), JSON.stringify(read.json)) + return 'guide://usage readable' +}) + +await check('6. resource template read with per-resource cache hint', async () => { + const templates = await request({ method: 'resources/templates/list' }) + const uriTemplates = (templates.json.result?.resourceTemplates ?? []).map((t) => t.uriTemplate) + assert(uriTemplates.includes('orders://{orderId}'), `templates: ${uriTemplates}`) + const read = await request({ method: 'resources/read', params: { uri: 'orders://o-42' }, name: 'orders://o-42' }) + const record = JSON.parse(read.json.result?.contents?.[0]?.text ?? '{}') + assert(record.orderId === 'o-42' && record.status === 'shipped', JSON.stringify(read.json)) + assert(read.json.result.ttlMs === 60000, `ttlMs ${read.json.result.ttlMs}`) + return `orders://o-42 -> ${record.status}, ttlMs=${read.json.result.ttlMs}` +}) + +await check('7. prompts list + get', async () => { + const list = await request({ method: 'prompts/list' }) + const names = (list.json.result?.prompts ?? []).map((p) => p.name) + assert(names.includes('summarize_order'), `prompts: ${names}`) + const got = await request({ + method: 'prompts/get', + params: { name: 'summarize_order', arguments: { orderId: 'o-7' } }, + name: 'summarize_order', + }) + const text = got.json.result?.messages?.[0]?.content?.text ?? '' + assert(text.includes('o-7'), JSON.stringify(got.json)) + return 'summarize_order fills its argument' +}) + +await check('8. server/discover surfaces the instructions', async () => { + const r = await request({ method: 'server/discover' }) + assert((r.json.result?.instructions ?? '').includes('Demo MCP server'), JSON.stringify(r.json.result ?? r.json)) + return 'instructions present' +}) + +await check('9. legacy initialize is answered on the same endpoint', async () => { + const r = await request({ + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'legacy', version: '1.0.0' } }, + envelope: false, + }) + assert(r.status === 200, `HTTP ${r.status}`) + const version = r.json.result?.protocolVersion ?? '' + assert(version.startsWith('2025-'), JSON.stringify(r.json)) + return `served as ${version}` +}) + +await check('10. GET is answered with the spec-mandated 405', async () => { + let headers = { accept: 'application/json, text/event-stream' } + if (signer) headers = await signer('GET', headers, null) + const res = await fetch(url, { method: 'GET', headers }) + assert(res.status === 405, `HTTP ${res.status}`) + return 'HTTP 405' +}) + +await check('11. Mcp-Method header mismatching the body is rejected (-32020)', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'add', arguments: { a: 1, b: 1 } }, + name: 'add', + headerMethod: 'tools/list', + }) + assert(rejectedWith(r.json, -32020), JSON.stringify(r.json)) + return r.json.error.code === -32020 ? '-32020' : 'rejected (platform-wrapped 400)' +}) + +await check('12. elicitation without the client capability is rejected (-32021)', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'approve_refund', arguments: { orderId: 'o-9' } }, + name: 'approve_refund', + capabilities: {}, + }) + assert(rejectedWith(r.json, -32021), JSON.stringify(r.json)) + return r.json.error.code === -32021 ? '-32021' : 'rejected (platform-wrapped 400)' +}) + +if (process.env.LONG) { + await check('13. LONG: 45-step stream (~36s) survives past the 29s mark', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'slow_report', arguments: { steps: 45, progressToken: 'long' }, _meta: { progressToken: 'long' } }, + name: 'slow_report', + }) + const progress = r.events.filter((e) => e.data.method === 'notifications/progress') + assert(progress.length === 45, `${progress.length} progress events`) + assert(r.json.result?.content?.[0]?.text === 'completed 45 steps', 'final result missing (stream cut?)') + assert(r.ms > 34000, `finished suspiciously fast (${r.ms}ms)`) + return `45 events over ${(r.ms / 1000).toFixed(1)}s, final result received` + }) +} + +// --- summary ------------------------------------------------------------------- + +const failed = results.filter((r) => !r.ok) +console.log(`\n${results.length - failed.length}/${results.length} passed`) +process.exit(failed.length ? 1 : 0) diff --git a/aws-mcp-servers/hono/package-lock.json b/aws-mcp-servers/hono/package-lock.json new file mode 100644 index 000000000..5844571a1 --- /dev/null +++ b/aws-mcp-servers/hono/package-lock.json @@ -0,0 +1,74 @@ +{ + "name": "aws-mcp-hono", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aws-mcp-hono", + "version": "1.0.0", + "dependencies": { + "@modelcontextprotocol/hono": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "hono": "^4.11.0", + "zod": "^4.2.0" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/hono": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/hono/-/hono-2.0.0.tgz", + "integrity": "sha512-lKCSXRusLxpX63G+6og9ewcRkqVbjB2xSxpvU/VJhxcUkpvGgpDrmcgNRQ4dTapGr8AspNFdRBdmJc500Vhl/A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/server": "^2.0.0", + "hono": "^4.11.4" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hono": { + "version": "4.12.32", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", + "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/aws-mcp-servers/hono/package.json b/aws-mcp-servers/hono/package.json new file mode 100644 index 000000000..267b19683 --- /dev/null +++ b/aws-mcp-servers/hono/package.json @@ -0,0 +1,13 @@ +{ + "name": "aws-mcp-hono", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "MCP server on AWS Lambda via the official MCP Hono integration and Hono's aws-lambda adapter - zero custom glue", + "dependencies": { + "@modelcontextprotocol/hono": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "hono": "^4.11.0", + "zod": "^4.2.0" + } +} diff --git a/aws-mcp-servers/hono/serverless.yml b/aws-mcp-servers/hono/serverless.yml new file mode 100644 index 000000000..9e1906861 --- /dev/null +++ b/aws-mcp-servers/hono/serverless.yml @@ -0,0 +1,23 @@ +# MCP server on Lambda via Hono's aws-lambda adapter - zero custom glue. +# +# The MCP SDK's official Hono integration plus Hono's own streamHandle do all +# the bridging; src/lambda.mjs is just a route. Served on a Function URL with +# response streaming. +service: aws-mcp-hono + +provider: + name: aws + runtime: nodejs22.x # the MCP SDK v2 requires Node.js 20+ + +functions: + mcp: + handler: src/lambda.handler + timeout: 120 # streamed responses can run up to the full function timeout + url: + invokeMode: RESPONSE_STREAM + # The default (no authorizer) is a public endpoint - fine for trying the + # example, but add auth for real deployments: + # - IAM (SigV4) for AWS-credentialed callers: + # authorizer: aws_iam + # - or OAuth validated in-process with the SDK's requireBearerAuth - see + # the commented block in src/server.mjs. diff --git a/aws-mcp-servers/hono/src/lambda.mjs b/aws-mcp-servers/hono/src/lambda.mjs new file mode 100644 index 000000000..76b35e920 --- /dev/null +++ b/aws-mcp-servers/hono/src/lambda.mjs @@ -0,0 +1,22 @@ +/** + * The whole Lambda bridge - no hand-written event or stream mapping: + * Hono's own aws-lambda adapter (streamHandle) converts Lambda events to + * web-standard Requests and pumps the Response into the Lambda response + * stream, and the MCP handler's fetch face plugs straight into a route. + * streamHandle dispatches on the event shape, so the same export works + * behind a Function URL, API Gateway (REST or HTTP API), and ALB. + */ +import { createMcpHonoApp } from '@modelcontextprotocol/hono' +import { streamHandle } from 'hono/aws-lambda' +import mcp from './server.mjs' + +// host: '0.0.0.0' declares this app as intentionally exposed, which disables +// the factory's localhost DNS-rebinding protections - correct behind a cloud +// front door, where the platform already pins the Host header. (It logs a +// one-line warning per cold start.) Serving the same app locally or behind a +// custom domain? Use createMcpHonoApp({ allowedHosts: ['api.example.com'] }). +const app = createMcpHonoApp({ host: '0.0.0.0' }) + +app.all('/mcp', (c) => mcp.fetch(c.req.raw, { parsedBody: c.get('parsedBody') })) + +export const handler = streamHandle(app) diff --git a/aws-mcp-servers/hono/src/server.mjs b/aws-mcp-servers/hono/src/server.mjs new file mode 100644 index 000000000..fdf1476e6 --- /dev/null +++ b/aws-mcp-servers/hono/src/server.mjs @@ -0,0 +1,193 @@ +/** + * A standard MCP server built with the official MCP TypeScript SDK v2, + * serving the stateless MCP 2026-07-28 protocol revision (and answering + * older clients through the SDK's built-in fallback on the same endpoint). + * + * Nothing in this file is Lambda-specific - the same handler runs on any + * web-standard host. Every example in aws-mcp-servers/ serves this exact + * server; only the hosting glue around it differs. + * + * Surface: + * tools: add (plain JSON), slow_report (progress streaming), + * approve_refund (elicitation - pauses to ask the user) + * resources: guide://usage, orders://{orderId} (template) + * prompts: summarize_order + * extras: instructions (served via server/discover), cache hints + */ +import { + acceptedContent, + createMcpHandler, + inputRequired, + McpServer, + ResourceTemplate, +} from '@modelcontextprotocol/server' +import { z } from 'zod' + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +export default createMcpHandler(() => { + const server = new McpServer( + { name: 'aws-mcp-server', version: '1.0.0' }, + { + // Shown to clients that probe the server/discover method: + instructions: + 'Demo MCP server. Call add for arithmetic, slow_report to watch ' + + 'streamed progress, approve_refund to see a tool ask the user for ' + + 'confirmation mid-call. Read guide://usage for a walkthrough.', + // Cache hints: let clients and shared caches reuse the tool list for + // five minutes. Without hints the SDK emits the conservative defaults + // (ttlMs: 0, cacheScope: private) on every cacheable result: + cacheHints: { 'tools/list': { ttlMs: 300000, cacheScope: 'public' } }, + }, + ) + + // A plain tool: zod schemas next to the implementation; returning + // structuredContent lets clients consume typed results. + server.registerTool( + 'add', + { + description: 'Add two numbers', + inputSchema: z.object({ a: z.number(), b: z.number() }), + outputSchema: z.object({ sum: z.number() }), + }, + async ({ a, b }) => { + const output = { sum: a + b } + return { + content: [{ type: 'text', text: String(output.sum) }], + structuredContent: output, + } + }, + ) + + // A long-running tool emitting progress notifications. When the client + // requests progress (a progressToken in _meta) and accepts text/event-stream, + // the response is served as SSE with notifications ahead of the final result. + server.registerTool( + 'slow_report', + { + description: 'Generate a report, reporting progress along the way', + inputSchema: z.object({ steps: z.number().default(3), progressToken: z.string().optional() }), + }, + async ({ steps, progressToken }, ctx) => { + for (let i = 1; i <= steps; i++) { + await sleep(800) + if (progressToken) { + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progressToken, progress: i, total: steps, message: `step ${i}` }, + }) + } + } + return { content: [{ type: 'text', text: `completed ${steps} steps` }] } + }, + ) + + // Elicitation - a tool that pauses to ask the user for input. The handler + // returns an input_required result; the client asks the user and retries + // the call with the answers attached, re-entering the handler, which reads + // them with acceptedContent. Works statelessly - no session required. + server.registerTool( + 'approve_refund', + { + description: 'Refund an order after user confirmation', + inputSchema: z.object({ orderId: z.string() }), + }, + async ({ orderId }, ctx) => { + const answer = acceptedContent( + ctx.mcpReq.inputResponses, + 'confirm', + z.object({ confirmed: z.boolean() }), + ) + if (answer === undefined) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: `Refund order ${orderId}?`, + requestedSchema: z.object({ confirmed: z.boolean() }), + }), + }, + }) + } + if (!answer.confirmed) { + return { content: [{ type: 'text', text: 'refund cancelled' }] } + } + return { content: [{ type: 'text', text: `refunded ${orderId}` }] } + }, + ) + + // A readable document next to the tools. Clients fetch it with + // resources/read (the Mcp-Name header carries the uri). + server.registerResource( + 'usage-guide', + 'guide://usage', + { description: 'How to use this server', mimeType: 'text/markdown' }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: '# Usage\n\nCall `add` for sums, `slow_report` for streamed progress, `approve_refund` for an elicitation round-trip.', + }, + ], + }), + ) + + // A resource template - parameterized URIs resolved per request. The + // per-resource cacheHint overrides the server-level hints for its reads. + server.registerResource( + 'order', + new ResourceTemplate('orders://{orderId}', { list: undefined }), + { description: 'Order record by id', cacheHint: { ttlMs: 60000, cacheScope: 'private' } }, + async (uri, { orderId }) => ({ + contents: [{ uri: uri.href, text: JSON.stringify({ orderId, status: 'shipped' }) }], + }), + ) + + // A prompt template clients can list and fill in. + server.registerPrompt( + 'summarize_order', + { + description: 'Ask the model to summarize an order', + argsSchema: z.object({ orderId: z.string() }), + }, + ({ orderId }) => ({ + messages: [ + { + role: 'user', + content: { type: 'text', text: `Summarize the status of order ${orderId} in one sentence.` }, + }, + ], + }), + ) + + return server +}) + +// --------------------------------------------------------------------------- +// Going further (uncomment and adapt): +// --------------------------------------------------------------------------- +// +// Signed round-trip state - when an elicitation round-trip must carry server +// state the retry cannot be allowed to tamper with (a computed price, an +// idempotency key), return it as requestState sealed with the SDK's HMAC +// codec. Store the >= 32-byte key in Secrets Manager or SSM: +// +// import { createRequestStateCodec } from '@modelcontextprotocol/server' +// const codec = createRequestStateCodec({ key: process.env.MCP_STATE_KEY, ttlSeconds: 600 }) +// +// In-process OAuth (works identically behind every front door): the SDK ships +// bearer-token middleware and OAuth discovery-document helpers. Wrap the +// handler's fetch face before exporting - any OpenID Connect provider works: +// +// import { requireBearerAuth, oauthMetadataResponse } from '@modelcontextprotocol/server' +// import { createRemoteJWKSet, jwtVerify } from 'jose' +// +// const jwks = createRemoteJWKSet(new URL('https:///.well-known/jwks.json')) +// const verifier = { +// async verifyAccessToken(token) { +// const { payload } = await jwtVerify(token, jwks, { +// issuer: 'https:///', +// audience: 'https://', +// }) +// return { token, clientId: payload.azp, scopes: payload.scope?.split(' ') ?? [], expiresAt: payload.exp } +// }, +// } diff --git a/aws-mcp-servers/rest-api/README.md b/aws-mcp-servers/rest-api/README.md new file mode 100644 index 000000000..84a35c851 --- /dev/null +++ b/aws-mcp-servers/rest-api/README.md @@ -0,0 +1,78 @@ + + +# MCP Server behind API Gateway REST (response streaming) + +Run a [Model Context Protocol](https://modelcontextprotocol.io) server built with the **official [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)** on AWS Lambda behind an API Gateway REST API in stream mode. Pick this front door when you want a custom domain, WAF, throttling, usage plans, or an authorizer that rejects requests before they invoke the function. + +This is one of the [aws-mcp-servers](../README.md) examples - they all serve the **same canonical MCP server** and differ only in the hosting glue, so the same test client works against every one of them. Three parts: + +- **`src/server.mjs`** - the canonical server: tools (`add`, `slow_report` with streamed progress, `approve_refund` with an elicitation round-trip), resources (`guide://usage` plus an `orders://{orderId}` template), a prompt, `instructions` served via `server/discover`, and cache hints on the tool list. Nothing Lambda-specific - the same file runs on any web-standard host. +- **`src/lambda.mjs`** - ~80 lines of adapter glue mapping the API Gateway event and the Lambda response stream onto the Node-style shapes that `@modelcontextprotocol/node` accepts. No MCP logic. (For glue-free alternatives built on existing packages, see the [hono](../hono) and [express-web-adapter](../express-web-adapter) examples.) +- **`serverless.yml`** - a REST API with `response.transferMode: stream` and a raised `timeoutInMillis` (streams end at the integration timeout, so lift it alongside). + +Plain JSON and streaming SSE coexist on the one endpoint: simple tool calls return ordinary `application/json`, while calls that request progress stream `text/event-stream` with notifications arriving ahead of the final result. + +## Usage + +### Deploy + +```bash +npm install +serverless deploy +``` + +### Test + +Every example in this family ships the same test client: + +```bash +ENDPOINT= node client.mjs +``` + +``` +PASS 1. tools/list returns the canonical tools with cache hints — ttlMs=300000 cacheScope=public +PASS 2. add returns plain JSON with structured content — sum=42 +PASS 3. slow_report streams incremental progress over SSE — 3 progress events, first at +810ms +PASS 4. approve_refund elicitation round-trip (accept and cancel) — input_required -> refunded o-1 / refund cancelled +PASS 5. resources list + read (Mcp-Name carries the uri) — guide://usage readable +PASS 6. resource template read with per-resource cache hint — orders://o-42 -> shipped, ttlMs=60000 +PASS 7. prompts list + get — summarize_order fills its argument +PASS 8. server/discover surfaces the instructions — instructions present +PASS 9. legacy initialize is answered on the same endpoint — served as 2025-06-18 +PASS 10. GET is answered with the spec-mandated 405 — HTTP 405 +PASS 11. Mcp-Method header mismatching the body is rejected (-32020) — -32020 +PASS 12. elicitation without the client capability is rejected (-32021) — -32021 + +12/12 passed +``` + +Add `LONG=1` for a ~36-second streaming case proving streams run far past API Gateway's classic 29-second ceiling. + +### Authentication + +The demo endpoint is public. For real deployments, two options (both included): + +- **Reject at the gateway** - uncomment the `authorizer` wiring in `serverless.yml`: [src/authorizer.mjs](src/authorizer.mjs) is a complete Lambda authorizer validating Bearer JWTs against any OpenID Connect provider, so unauthenticated requests never invoke (or bill) the MCP function. +- **Validate in-process** - the SDK's `requireBearerAuth` with a `jose` JWKS verifier (commented block at the bottom of `src/server.mjs`); works identically behind every front door, at the cost of unauthenticated requests still invoking the function. + +### Going further + +- **Signed round-trip state**: seal server state across elicitation retries with `createRequestStateCodec` (see the comment in `src/server.mjs`). +- **OAuth discovery**: serve `/.well-known/oauth-protected-resource` so MCP clients can discover your authorization server (commented route in `serverless.yml`). +- **Long-running tools**: raise the function `timeout` and `timeoutInMillis` - streamed responses run up to 15 minutes. + +### Cleanup + +```bash +serverless remove +``` diff --git a/aws-mcp-servers/rest-api/client.mjs b/aws-mcp-servers/rest-api/client.mjs new file mode 100644 index 000000000..66f738e9a --- /dev/null +++ b/aws-mcp-servers/rest-api/client.mjs @@ -0,0 +1,316 @@ +#!/usr/bin/env node +/** + * Shared test client for the aws-mcp-servers examples. Every example serves + * the same canonical MCP server, so this same script tests all of them. + * + * Usage: + * ENDPOINT= node client.mjs + * LONG=1 ENDPOINT=... node client.mjs # adds a ~36s streaming case + * AUTH=sigv4 SERVICE=bedrock-agentcore ENDPOINT= node client.mjs + * AUTH=sigv4 SERVICE=lambda ENDPOINT=... node client.mjs # IAM-auth Function URL + * + * Exercises the full 2026-07-28 surface: plain JSON, SSE progress streaming, + * an elicitation round-trip, resources (static + template), prompts, + * server/discover, cache hints, the legacy fallback, and two negative cases + * (header mismatch -32020, missing capability -32021). + */ + +const ENDPOINT = process.env.ENDPOINT +if (!ENDPOINT) { + console.error('Set ENDPOINT to the MCP endpoint URL') + process.exit(1) +} + +// --- endpoint + optional SigV4 signing -------------------------------------- + +let url = new URL(ENDPOINT) +let signer +if (process.env.AUTH === 'sigv4') { + const service = process.env.SERVICE || 'lambda' + if (service === 'bedrock-agentcore' && url.pathname.includes('/runtimes/')) { + // AgentCore runtime URLs embed the agent ARN - the WHOLE ARN must be + // URL-encoded in the path (including its inner "/"). + const arn = decodeURIComponent(url.pathname.match(/\/runtimes\/(.+?)\/invocations/)[1]) + url = new URL(`${url.origin}/runtimes/${encodeURIComponent(arn)}/invocations`) + url.searchParams.set('qualifier', 'DEFAULT') + } + const [{ SignatureV4 }, { HttpRequest }, { Sha256 }, { defaultProvider }] = await Promise.all([ + import('@smithy/signature-v4'), + import('@smithy/protocol-http'), + import('@aws-crypto/sha256-js'), + import('@aws-sdk/credential-provider-node'), + ]) + const sigv4 = new SignatureV4({ + service, + region: process.env.AWS_REGION || 'us-east-1', + credentials: defaultProvider(), + sha256: Sha256, + }) + signer = async (method, headers, body) => { + const request = new HttpRequest({ + method, + protocol: url.protocol, + hostname: url.hostname, + path: url.pathname, + query: Object.fromEntries(url.searchParams), + headers: { host: url.hostname, ...headers }, + ...(body != null && { body }), + }) + return (await sigv4.sign(request)).headers + } +} + +// --- request helpers --------------------------------------------------------- + +const CAPS_ELICIT = { elicitation: { form: {} } } +const meta = (capabilities = {}) => ({ + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'aws-mcp-servers-client', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': capabilities, +}) + +let id = 0 +async function request({ method, params = {}, name, capabilities, headerMethod, envelope = true }) { + const body = JSON.stringify({ + jsonrpc: '2.0', + id: ++id, + method, + params: envelope ? { ...params, _meta: { ...meta(capabilities), ...(params._meta ?? {}) } } : params, + }) + let headers = { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...(envelope && { + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': headerMethod ?? method, + ...(name && { 'mcp-name': name }), + }), + } + if (signer) headers = await signer('POST', headers, body) + const t0 = Date.now() + const res = await fetch(url, { method: 'POST', headers, body }) + const contentType = res.headers.get('content-type') ?? '' + if (contentType.includes('text/event-stream')) { + const events = [] + const decoder = new TextDecoder() + let buffer = '' + for await (const chunk of res.body) { + buffer += decoder.decode(chunk, { stream: true }) + let sep + while ((sep = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, sep) + buffer = buffer.slice(sep + 2) + const data = frame.split('\n').find((l) => l.startsWith('data:'))?.slice(5).trim() + if (data) events.push({ t: Date.now() - t0, data: JSON.parse(data) }) + } + } + const final = events.at(-1)?.data + return { status: res.status, contentType, events, json: final, ms: Date.now() - t0 } + } + const text = await res.text() + let json + try { + json = JSON.parse(text) + } catch { + json = { raw: text } + } + return { status: res.status, contentType, events: [], json, ms: Date.now() - t0 } +} + +// --- tiny harness ------------------------------------------------------------ + +const results = [] +async function check(label, fn) { + try { + const detail = await fn() + results.push({ label, ok: true }) + console.log(`PASS ${label}${detail ? ` — ${detail}` : ''}`) + } catch (err) { + results.push({ label, ok: false }) + console.log(`FAIL ${label} — ${err.message}`) + } +} +const assert = (cond, msg) => { + if (!cond) throw new Error(msg) +} +// AgentCore Runtime replaces 4xx response bodies with its own error envelope +// (-32010, "Received error (400) ..."); the 400 status still proves the SDK +// rejected the request, so the negative cases accept that form there. +const rejectedWith = (json, code) => + json.error?.code === code || + (process.env.SERVICE === 'bedrock-agentcore' && + json.error?.code === -32010 && + /\(4\d\d\)/.test(json.error?.message ?? '')) + +// --- test cases ---------------------------------------------------------------- + +await check('1. tools/list returns the canonical tools with cache hints', async () => { + const r = await request({ method: 'tools/list' }) + assert(r.status === 200, `HTTP ${r.status}`) + assert(r.contentType.includes('application/json'), `content-type ${r.contentType}`) + const names = (r.json.result?.tools ?? []).map((t) => t.name).sort() + assert(JSON.stringify(names) === JSON.stringify(['add', 'approve_refund', 'slow_report']), `tools: ${names}`) + assert(r.json.result.ttlMs === 300000, `ttlMs ${r.json.result.ttlMs}`) + assert(r.json.result.cacheScope === 'public', `cacheScope ${r.json.result.cacheScope}`) + return `ttlMs=${r.json.result.ttlMs} cacheScope=${r.json.result.cacheScope}` +}) + +await check('2. add returns plain JSON with structured content', async () => { + const r = await request({ method: 'tools/call', params: { name: 'add', arguments: { a: 2, b: 40 } }, name: 'add' }) + assert(r.status === 200, `HTTP ${r.status}`) + assert(r.contentType.includes('application/json'), `content-type ${r.contentType}`) + assert(r.json.result?.structuredContent?.sum === 42, JSON.stringify(r.json)) + return 'sum=42' +}) + +await check('3. slow_report streams incremental progress over SSE', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'slow_report', arguments: { steps: 3, progressToken: 'pt' }, _meta: { progressToken: 'pt' } }, + name: 'slow_report', + }) + assert(r.status === 200, `HTTP ${r.status}`) + assert(r.contentType.includes('text/event-stream'), `content-type ${r.contentType}`) + const progress = r.events.filter((e) => e.data.method === 'notifications/progress') + assert(progress.length === 3, `${progress.length} progress events`) + assert(progress[0].t < r.ms - 500, 'first progress event did not arrive before the final result') + assert(r.json.result?.content?.[0]?.text === 'completed 3 steps', JSON.stringify(r.json)) + return `3 progress events, first at +${progress[0].t}ms, done in ${r.ms}ms` +}) + +await check('4. approve_refund elicitation round-trip (accept and cancel)', async () => { + const ask = await request({ + method: 'tools/call', + params: { name: 'approve_refund', arguments: { orderId: 'o-1' } }, + name: 'approve_refund', + capabilities: CAPS_ELICIT, + }) + assert(ask.json.result?.resultType === 'input_required', JSON.stringify(ask.json)) + assert(ask.json.result?.inputRequests?.confirm, 'missing inputRequests.confirm') + const accepted = await request({ + method: 'tools/call', + params: { + name: 'approve_refund', + arguments: { orderId: 'o-1' }, + inputResponses: { confirm: { action: 'accept', content: { confirmed: true } } }, + }, + name: 'approve_refund', + capabilities: CAPS_ELICIT, + }) + assert(accepted.json.result?.content?.[0]?.text === 'refunded o-1', JSON.stringify(accepted.json)) + const cancelled = await request({ + method: 'tools/call', + params: { + name: 'approve_refund', + arguments: { orderId: 'o-1' }, + inputResponses: { confirm: { action: 'accept', content: { confirmed: false } } }, + }, + name: 'approve_refund', + capabilities: CAPS_ELICIT, + }) + assert(cancelled.json.result?.content?.[0]?.text === 'refund cancelled', JSON.stringify(cancelled.json)) + return 'input_required -> refunded o-1 / refund cancelled' +}) + +await check('5. resources list + read (Mcp-Name carries the uri)', async () => { + const list = await request({ method: 'resources/list' }) + const uris = (list.json.result?.resources ?? []).map((r) => r.uri) + assert(uris.includes('guide://usage'), `resources: ${uris}`) + const read = await request({ method: 'resources/read', params: { uri: 'guide://usage' }, name: 'guide://usage' }) + assert(read.json.result?.contents?.[0]?.text?.startsWith('# Usage'), JSON.stringify(read.json)) + return 'guide://usage readable' +}) + +await check('6. resource template read with per-resource cache hint', async () => { + const templates = await request({ method: 'resources/templates/list' }) + const uriTemplates = (templates.json.result?.resourceTemplates ?? []).map((t) => t.uriTemplate) + assert(uriTemplates.includes('orders://{orderId}'), `templates: ${uriTemplates}`) + const read = await request({ method: 'resources/read', params: { uri: 'orders://o-42' }, name: 'orders://o-42' }) + const record = JSON.parse(read.json.result?.contents?.[0]?.text ?? '{}') + assert(record.orderId === 'o-42' && record.status === 'shipped', JSON.stringify(read.json)) + assert(read.json.result.ttlMs === 60000, `ttlMs ${read.json.result.ttlMs}`) + return `orders://o-42 -> ${record.status}, ttlMs=${read.json.result.ttlMs}` +}) + +await check('7. prompts list + get', async () => { + const list = await request({ method: 'prompts/list' }) + const names = (list.json.result?.prompts ?? []).map((p) => p.name) + assert(names.includes('summarize_order'), `prompts: ${names}`) + const got = await request({ + method: 'prompts/get', + params: { name: 'summarize_order', arguments: { orderId: 'o-7' } }, + name: 'summarize_order', + }) + const text = got.json.result?.messages?.[0]?.content?.text ?? '' + assert(text.includes('o-7'), JSON.stringify(got.json)) + return 'summarize_order fills its argument' +}) + +await check('8. server/discover surfaces the instructions', async () => { + const r = await request({ method: 'server/discover' }) + assert((r.json.result?.instructions ?? '').includes('Demo MCP server'), JSON.stringify(r.json.result ?? r.json)) + return 'instructions present' +}) + +await check('9. legacy initialize is answered on the same endpoint', async () => { + const r = await request({ + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'legacy', version: '1.0.0' } }, + envelope: false, + }) + assert(r.status === 200, `HTTP ${r.status}`) + const version = r.json.result?.protocolVersion ?? '' + assert(version.startsWith('2025-'), JSON.stringify(r.json)) + return `served as ${version}` +}) + +await check('10. GET is answered with the spec-mandated 405', async () => { + let headers = { accept: 'application/json, text/event-stream' } + if (signer) headers = await signer('GET', headers, null) + const res = await fetch(url, { method: 'GET', headers }) + assert(res.status === 405, `HTTP ${res.status}`) + return 'HTTP 405' +}) + +await check('11. Mcp-Method header mismatching the body is rejected (-32020)', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'add', arguments: { a: 1, b: 1 } }, + name: 'add', + headerMethod: 'tools/list', + }) + assert(rejectedWith(r.json, -32020), JSON.stringify(r.json)) + return r.json.error.code === -32020 ? '-32020' : 'rejected (platform-wrapped 400)' +}) + +await check('12. elicitation without the client capability is rejected (-32021)', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'approve_refund', arguments: { orderId: 'o-9' } }, + name: 'approve_refund', + capabilities: {}, + }) + assert(rejectedWith(r.json, -32021), JSON.stringify(r.json)) + return r.json.error.code === -32021 ? '-32021' : 'rejected (platform-wrapped 400)' +}) + +if (process.env.LONG) { + await check('13. LONG: 45-step stream (~36s) survives past the 29s mark', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'slow_report', arguments: { steps: 45, progressToken: 'long' }, _meta: { progressToken: 'long' } }, + name: 'slow_report', + }) + const progress = r.events.filter((e) => e.data.method === 'notifications/progress') + assert(progress.length === 45, `${progress.length} progress events`) + assert(r.json.result?.content?.[0]?.text === 'completed 45 steps', 'final result missing (stream cut?)') + assert(r.ms > 34000, `finished suspiciously fast (${r.ms}ms)`) + return `45 events over ${(r.ms / 1000).toFixed(1)}s, final result received` + }) +} + +// --- summary ------------------------------------------------------------------- + +const failed = results.filter((r) => !r.ok) +console.log(`\n${results.length - failed.length}/${results.length} passed`) +process.exit(failed.length ? 1 : 0) diff --git a/aws-node-mcp-server-function-url/package-lock.json b/aws-mcp-servers/rest-api/package-lock.json similarity index 87% rename from aws-node-mcp-server-function-url/package-lock.json rename to aws-mcp-servers/rest-api/package-lock.json index 2cc98171a..bb281ae2a 100644 --- a/aws-node-mcp-server-function-url/package-lock.json +++ b/aws-mcp-servers/rest-api/package-lock.json @@ -1,15 +1,16 @@ { - "name": "aws-node-mcp-server-function-url", + "name": "aws-mcp-rest-api", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "aws-node-mcp-server-function-url", + "name": "aws-mcp-rest-api", "version": "1.0.0", "dependencies": { "@modelcontextprotocol/node": "^2.0.0", "@modelcontextprotocol/server": "^2.0.0", + "jose": "^6.0.0", "zod": "^4.2.0" } }, @@ -81,6 +82,15 @@ "node": ">=16.9.0" } }, + "node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/aws-node-mcp-server/package.json b/aws-mcp-servers/rest-api/package.json similarity index 85% rename from aws-node-mcp-server/package.json rename to aws-mcp-servers/rest-api/package.json index 99fc9dfc5..6e6e56b5f 100644 --- a/aws-node-mcp-server/package.json +++ b/aws-mcp-servers/rest-api/package.json @@ -1,5 +1,5 @@ { - "name": "aws-node-mcp-server", + "name": "aws-mcp-rest-api", "version": "1.0.0", "private": true, "type": "module", @@ -7,6 +7,7 @@ "dependencies": { "@modelcontextprotocol/node": "^2.0.0", "@modelcontextprotocol/server": "^2.0.0", + "jose": "^6.0.0", "zod": "^4.2.0" } } diff --git a/aws-node-mcp-server/serverless.yml b/aws-mcp-servers/rest-api/serverless.yml similarity index 70% rename from aws-node-mcp-server/serverless.yml rename to aws-mcp-servers/rest-api/serverless.yml index 31bf049db..34f41c67f 100644 --- a/aws-node-mcp-server/serverless.yml +++ b/aws-mcp-servers/rest-api/serverless.yml @@ -3,7 +3,7 @@ # # Plain JSON responses and streaming SSE responses both work through the same # endpoint - the server picks per request. -service: aws-node-mcp-server +service: aws-mcp-rest-api provider: name: aws @@ -24,13 +24,16 @@ functions: response: transferMode: stream # To reject unauthenticated requests before they invoke the function, - # attach an authorizer (see the jwtAuthorizer function below), or use - # the SDK's in-process bearer auth (see src/server.mjs): + # attach the JWT authorizer below (src/authorizer.mjs validates + # Bearer tokens against any OpenID Connect provider), or use the + # SDK's in-process bearer auth (see src/server.mjs): # authorizer: # name: jwtAuthorizer - http: method: GET path: /mcp # the server answers the spec-mandated 405 for GET + response: + transferMode: stream # every route on a streaming handler streams # OAuth discovery for MCP clients ("paste URL and log in"): serve the # protected-resource metadata on an unauthenticated route. The SDK's @@ -39,7 +42,10 @@ functions: # method: GET # path: /.well-known/oauth-protected-resource - # A request authorizer validating Bearer JWTs against your OpenID Connect - # provider - pairs with the `authorizer` block above: + # The request authorizer pairing with the `authorizer` block above: # jwtAuthorizer: # handler: src/authorizer.handler + # environment: + # JWKS_URL: https:///.well-known/jwks.json + # TOKEN_ISSUER: https:/// + # TOKEN_AUDIENCE: https:// diff --git a/aws-mcp-servers/rest-api/src/authorizer.mjs b/aws-mcp-servers/rest-api/src/authorizer.mjs new file mode 100644 index 000000000..6e451fced --- /dev/null +++ b/aws-mcp-servers/rest-api/src/authorizer.mjs @@ -0,0 +1,33 @@ +/** + * A Lambda TOKEN authorizer validating OAuth Bearer JWTs against any OpenID + * Connect provider - API Gateway rejects unauthenticated requests before they + * ever invoke (and bill) the MCP function. + * + * Configure via environment variables on the authorizer function: + * JWKS_URL e.g. https://your-tenant.example.com/.well-known/jwks.json + * TOKEN_ISSUER e.g. https://your-tenant.example.com/ + * TOKEN_AUDIENCE e.g. https://your-mcp-endpoint + */ +import { createRemoteJWKSet, jwtVerify } from 'jose' + +const jwks = createRemoteJWKSet(new URL(process.env.JWKS_URL)) + +export const handler = async (event) => { + const token = event.authorizationToken?.replace(/^Bearer /i, '') + try { + const { payload } = await jwtVerify(token, jwks, { + issuer: process.env.TOKEN_ISSUER, + audience: process.env.TOKEN_AUDIENCE, + }) + return { + principalId: payload.sub, + policyDocument: { + Version: '2012-10-17', + Statement: [{ Action: 'execute-api:Invoke', Effect: 'Allow', Resource: event.methodArn }], + }, + } + } catch { + // API Gateway maps this exact message to a 401 response. + throw new Error('Unauthorized') + } +} diff --git a/aws-node-mcp-server/src/lambda.mjs b/aws-mcp-servers/rest-api/src/lambda.mjs similarity index 100% rename from aws-node-mcp-server/src/lambda.mjs rename to aws-mcp-servers/rest-api/src/lambda.mjs diff --git a/aws-mcp-servers/rest-api/src/server.mjs b/aws-mcp-servers/rest-api/src/server.mjs new file mode 100644 index 000000000..fdf1476e6 --- /dev/null +++ b/aws-mcp-servers/rest-api/src/server.mjs @@ -0,0 +1,193 @@ +/** + * A standard MCP server built with the official MCP TypeScript SDK v2, + * serving the stateless MCP 2026-07-28 protocol revision (and answering + * older clients through the SDK's built-in fallback on the same endpoint). + * + * Nothing in this file is Lambda-specific - the same handler runs on any + * web-standard host. Every example in aws-mcp-servers/ serves this exact + * server; only the hosting glue around it differs. + * + * Surface: + * tools: add (plain JSON), slow_report (progress streaming), + * approve_refund (elicitation - pauses to ask the user) + * resources: guide://usage, orders://{orderId} (template) + * prompts: summarize_order + * extras: instructions (served via server/discover), cache hints + */ +import { + acceptedContent, + createMcpHandler, + inputRequired, + McpServer, + ResourceTemplate, +} from '@modelcontextprotocol/server' +import { z } from 'zod' + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +export default createMcpHandler(() => { + const server = new McpServer( + { name: 'aws-mcp-server', version: '1.0.0' }, + { + // Shown to clients that probe the server/discover method: + instructions: + 'Demo MCP server. Call add for arithmetic, slow_report to watch ' + + 'streamed progress, approve_refund to see a tool ask the user for ' + + 'confirmation mid-call. Read guide://usage for a walkthrough.', + // Cache hints: let clients and shared caches reuse the tool list for + // five minutes. Without hints the SDK emits the conservative defaults + // (ttlMs: 0, cacheScope: private) on every cacheable result: + cacheHints: { 'tools/list': { ttlMs: 300000, cacheScope: 'public' } }, + }, + ) + + // A plain tool: zod schemas next to the implementation; returning + // structuredContent lets clients consume typed results. + server.registerTool( + 'add', + { + description: 'Add two numbers', + inputSchema: z.object({ a: z.number(), b: z.number() }), + outputSchema: z.object({ sum: z.number() }), + }, + async ({ a, b }) => { + const output = { sum: a + b } + return { + content: [{ type: 'text', text: String(output.sum) }], + structuredContent: output, + } + }, + ) + + // A long-running tool emitting progress notifications. When the client + // requests progress (a progressToken in _meta) and accepts text/event-stream, + // the response is served as SSE with notifications ahead of the final result. + server.registerTool( + 'slow_report', + { + description: 'Generate a report, reporting progress along the way', + inputSchema: z.object({ steps: z.number().default(3), progressToken: z.string().optional() }), + }, + async ({ steps, progressToken }, ctx) => { + for (let i = 1; i <= steps; i++) { + await sleep(800) + if (progressToken) { + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progressToken, progress: i, total: steps, message: `step ${i}` }, + }) + } + } + return { content: [{ type: 'text', text: `completed ${steps} steps` }] } + }, + ) + + // Elicitation - a tool that pauses to ask the user for input. The handler + // returns an input_required result; the client asks the user and retries + // the call with the answers attached, re-entering the handler, which reads + // them with acceptedContent. Works statelessly - no session required. + server.registerTool( + 'approve_refund', + { + description: 'Refund an order after user confirmation', + inputSchema: z.object({ orderId: z.string() }), + }, + async ({ orderId }, ctx) => { + const answer = acceptedContent( + ctx.mcpReq.inputResponses, + 'confirm', + z.object({ confirmed: z.boolean() }), + ) + if (answer === undefined) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: `Refund order ${orderId}?`, + requestedSchema: z.object({ confirmed: z.boolean() }), + }), + }, + }) + } + if (!answer.confirmed) { + return { content: [{ type: 'text', text: 'refund cancelled' }] } + } + return { content: [{ type: 'text', text: `refunded ${orderId}` }] } + }, + ) + + // A readable document next to the tools. Clients fetch it with + // resources/read (the Mcp-Name header carries the uri). + server.registerResource( + 'usage-guide', + 'guide://usage', + { description: 'How to use this server', mimeType: 'text/markdown' }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: '# Usage\n\nCall `add` for sums, `slow_report` for streamed progress, `approve_refund` for an elicitation round-trip.', + }, + ], + }), + ) + + // A resource template - parameterized URIs resolved per request. The + // per-resource cacheHint overrides the server-level hints for its reads. + server.registerResource( + 'order', + new ResourceTemplate('orders://{orderId}', { list: undefined }), + { description: 'Order record by id', cacheHint: { ttlMs: 60000, cacheScope: 'private' } }, + async (uri, { orderId }) => ({ + contents: [{ uri: uri.href, text: JSON.stringify({ orderId, status: 'shipped' }) }], + }), + ) + + // A prompt template clients can list and fill in. + server.registerPrompt( + 'summarize_order', + { + description: 'Ask the model to summarize an order', + argsSchema: z.object({ orderId: z.string() }), + }, + ({ orderId }) => ({ + messages: [ + { + role: 'user', + content: { type: 'text', text: `Summarize the status of order ${orderId} in one sentence.` }, + }, + ], + }), + ) + + return server +}) + +// --------------------------------------------------------------------------- +// Going further (uncomment and adapt): +// --------------------------------------------------------------------------- +// +// Signed round-trip state - when an elicitation round-trip must carry server +// state the retry cannot be allowed to tamper with (a computed price, an +// idempotency key), return it as requestState sealed with the SDK's HMAC +// codec. Store the >= 32-byte key in Secrets Manager or SSM: +// +// import { createRequestStateCodec } from '@modelcontextprotocol/server' +// const codec = createRequestStateCodec({ key: process.env.MCP_STATE_KEY, ttlSeconds: 600 }) +// +// In-process OAuth (works identically behind every front door): the SDK ships +// bearer-token middleware and OAuth discovery-document helpers. Wrap the +// handler's fetch face before exporting - any OpenID Connect provider works: +// +// import { requireBearerAuth, oauthMetadataResponse } from '@modelcontextprotocol/server' +// import { createRemoteJWKSet, jwtVerify } from 'jose' +// +// const jwks = createRemoteJWKSet(new URL('https:///.well-known/jwks.json')) +// const verifier = { +// async verifyAccessToken(token) { +// const { payload } = await jwtVerify(token, jwks, { +// issuer: 'https:///', +// audience: 'https://', +// }) +// return { token, clientId: payload.azp, scopes: payload.scope?.split(' ') ?? [], expiresAt: payload.exp } +// }, +// } diff --git a/aws-node-mcp-server-function-url/README.md b/aws-node-mcp-server-function-url/README.md deleted file mode 100644 index c345f1f71..000000000 --- a/aws-node-mcp-server-function-url/README.md +++ /dev/null @@ -1,70 +0,0 @@ - - -# MCP Server on a Lambda Function URL - -Run a [Model Context Protocol](https://modelcontextprotocol.io) server built with the **official [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)** on a Lambda Function URL with response streaming — the leanest MCP hosting on AWS: no API Gateway, no per-request fee, and streamed responses bounded only by the function timeout. - -The MCP `2026-07-28` protocol revision made servers stateless — every request is self-contained, so any request can be served by any Lambda execution environment. Three parts: - -- **`src/server.mjs`** — a standard SDK v2 server (`createMcpHandler` + `registerTool` with zod schemas). Nothing Lambda-specific. -- **`src/lambda.mjs`** — ~80 lines of adapter glue mapping the Function URL event and the Lambda response stream onto the Node-style shapes that `@modelcontextprotocol/node` accepts. No MCP logic. -- **`serverless.yml`** — a Function URL with `invokeMode: RESPONSE_STREAM`. - -Plain JSON and streaming SSE coexist on the one endpoint: simple tool calls return ordinary `application/json`, while calls that request progress stream `text/event-stream` with notifications arriving ahead of the final result. - -**Related example:** [`aws-node-mcp-server`](../aws-node-mcp-server) serves the same MCP server through an API Gateway REST API in stream mode instead — pick that one when you want a custom domain, WAF, throttling, or an authorizer that rejects requests before they invoke the function; pick this one for the simplest and cheapest setup. - -## Usage - -### Deploy - -```bash -npm install -serverless deploy -``` - -### Test - -```bash -ENDPOINT= node client.mjs -``` - -The client demonstrates both response modes: - -``` -=== tools/call (add) -> HTTP 200 (application/json) === -{"result":{"content":[{"type":"text","text":"42"}],"structuredContent":{"sum":42},"resultType":"complete",...}} - -=== tools/call (slow_report) -> HTTP 200 (text/event-stream) === -[+1200ms] {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"demo","progress":1,"total":3,...}} -[+2000ms] {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"demo","progress":2,"total":3,...}} -[+2800ms] {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"demo","progress":3,"total":3,...}} -[+2800ms] {"result":{"content":[{"type":"text","text":"completed 3 steps"}],"resultType":"complete",...}} -``` - -Older MCP clients that still send the `initialize` handshake are answered too — the SDK serves earlier protocol revisions on the same endpoint by default. - -### Going further - -The example is deliberately minimal; commented blocks show how to extend it: - -- **Authentication** (`serverless.yml` + `src/server.mjs`): set `authorizer: aws_iam` for SigV4 callers, or keep the URL open and validate OAuth Bearer tokens in-process with the SDK's `requireBearerAuth` and a `jose`-based verifier — works with any OpenID Connect provider. -- **Elicitation** (`src/server.mjs`): a tool can pause mid-call and ask the user for input by returning `inputRequired(...)`; the client asks the user and retries the call with the answers attached, and the handler reads them with `acceptedContent(...)` on re-entry. -- **Resources**: expose readable documents next to tools with `registerResource`. -- **Long-running tools**: raise the function `timeout` — streamed responses run up to 15 minutes with no other configuration. - -### Cleanup - -```bash -serverless remove -``` diff --git a/aws-node-mcp-server-function-url/client.mjs b/aws-node-mcp-server-function-url/client.mjs deleted file mode 100644 index 58644b511..000000000 --- a/aws-node-mcp-server-function-url/client.mjs +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env node -/** - * Try the deployed MCP server. - * Usage: ENDPOINT= node client.mjs - */ -const ENDPOINT = process.env.ENDPOINT -if (!ENDPOINT) { - console.error('Set ENDPOINT to the POST endpoint printed by `serverless deploy`') - process.exit(1) -} - -const META = { - 'io.modelcontextprotocol/protocolVersion': '2026-07-28', - 'io.modelcontextprotocol/clientInfo': { name: 'example-client', version: '1.0.0' }, - 'io.modelcontextprotocol/clientCapabilities': {}, -} -let id = 0 - -async function call(method, params, name) { - const res = await fetch(ENDPOINT, { - method: 'POST', - headers: { - 'content-type': 'application/json', - accept: 'application/json, text/event-stream', - 'mcp-protocol-version': '2026-07-28', - 'mcp-method': method, - ...(name && { 'mcp-name': name }), - }, - body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method, params }), - }) - console.log(`\n=== ${method}${name ? ` (${name})` : ''} -> HTTP ${res.status} (${res.headers.get('content-type')}) ===`) - if (!res.headers.get('content-type')?.includes('text/event-stream')) { - console.log((await res.text()).slice(0, 800)) - return - } - // SSE: print each event as it arrives, with timing - const t0 = Date.now() - const reader = res.body.getReader() - const decoder = new TextDecoder() - let buffer = '' - for (;;) { - const { done, value } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) - let idx - while ((idx = buffer.indexOf('\n\n')) >= 0) { - const data = buffer.slice(0, idx).split('\n').find((l) => l.startsWith('data:'))?.slice(5).trim() - buffer = buffer.slice(idx + 2) - if (data) console.log(`[+${Date.now() - t0}ms]`, data.slice(0, 160)) - } - } -} - -// Plain JSON responses - streaming mode carries these unchanged: -await call('tools/list', { _meta: META }) -await call('tools/call', { name: 'add', arguments: { a: 2, b: 40 }, _meta: META }, 'add') - -// A streaming SSE response - progress notifications arrive before the result: -await call( - 'tools/call', - { - name: 'slow_report', - arguments: { steps: 3, progressToken: 'demo' }, - _meta: { ...META, progressToken: 'demo' }, - }, - 'slow_report', -) diff --git a/aws-node-mcp-server-function-url/src/server.mjs b/aws-node-mcp-server-function-url/src/server.mjs deleted file mode 100644 index 7f0cba526..000000000 --- a/aws-node-mcp-server-function-url/src/server.mjs +++ /dev/null @@ -1,120 +0,0 @@ -/** - * A standard MCP server built with the official MCP TypeScript SDK v2. - * Nothing in this file is Lambda-specific - the same handler runs on any - * web-standard host; src/lambda.mjs adapts it to Lambda. - */ -import { createMcpHandler, McpServer } from '@modelcontextprotocol/server' -import { z } from 'zod' - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) - -export default createMcpHandler(() => { - const server = new McpServer({ name: 'aws-node-mcp-server', version: '1.0.0' }) - - // A plain tool: zod schemas next to the implementation; returning - // structuredContent lets clients consume typed results. - server.registerTool( - 'add', - { - description: 'Add two numbers', - inputSchema: z.object({ a: z.number(), b: z.number() }), - outputSchema: z.object({ sum: z.number() }), - }, - async ({ a, b }) => { - const output = { sum: a + b } - return { - content: [{ type: 'text', text: String(output.sum) }], - structuredContent: output, - } - }, - ) - - // A long-running tool emitting progress notifications. When the client - // requests progress (a progressToken in _meta) and accepts text/event-stream, - // the response is served as SSE with notifications ahead of the final result. - server.registerTool( - 'slow_report', - { - description: 'Generate a report, reporting progress along the way', - inputSchema: z.object({ steps: z.number().default(3), progressToken: z.string().optional() }), - }, - async ({ steps, progressToken }, ctx) => { - for (let i = 1; i <= steps; i++) { - await sleep(800) - if (progressToken) { - await ctx.mcpReq.notify({ - method: 'notifications/progress', - params: { progressToken, progress: i, total: steps, message: `step ${i}` }, - }) - } - } - return { content: [{ type: 'text', text: `completed ${steps} steps` }] } - }, - ) - - // ------------------------------------------------------------------------- - // More SDK capabilities to explore (uncomment and adapt): - // ------------------------------------------------------------------------- - - // Elicitation - pause a tool call to ask the user for input. The handler - // returns an input_required result; the client asks the user and retries - // the call with the answers attached, re-entering the handler. Add - // acceptedContent and inputRequired to the imports above. To protect - // round-trip state that influences your logic, use the SDK's - // createRequestStateCodec with a key from KMS/Secrets Manager. - // - // server.registerTool( - // 'approve_refund', - // { - // description: 'Refund an order after user confirmation', - // inputSchema: z.object({ orderId: z.string() }), - // }, - // async ({ orderId }, ctx) => { - // const answer = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', z.object({ confirmed: z.boolean() })) - // if (answer === undefined) { - // return inputRequired({ - // inputRequests: { - // confirm: inputRequired.elicit({ - // message: `Refund order ${orderId}?`, - // requestedSchema: z.object({ confirmed: z.boolean() }), - // }), - // }, - // }) - // } - // if (!answer.confirmed) { - // return { content: [{ type: 'text', text: 'refund cancelled' }] } - // } - // return { content: [{ type: 'text', text: `refunded ${orderId}` }] } - // }, - // ) - - // Resources - expose readable documents next to tools: - // - // server.registerResource( - // 'orders', - // 'orders://recent', - // { description: 'Recent orders' }, - // async () => ({ contents: [{ uri: 'orders://recent', text: JSON.stringify([]) }] }), - // ) - - return server -}) - -// --------------------------------------------------------------------------- -// In-process OAuth (alternative to an API Gateway authorizer): the SDK ships -// bearer-token middleware and OAuth discovery-document helpers. Wrap the -// handler's fetch face before exporting - any OIDC provider works: -// -// import { requireBearerAuth, oauthMetadataResponse } from '@modelcontextprotocol/server' -// import { createRemoteJWKSet, jwtVerify } from 'jose' -// -// const jwks = createRemoteJWKSet(new URL('https:///.well-known/jwks.json')) -// const verifier = { -// async verifyAccessToken(token) { -// const { payload } = await jwtVerify(token, jwks, { -// issuer: 'https:///', -// audience: 'https://', -// }) -// return { token, clientId: payload.azp, scopes: payload.scope?.split(' ') ?? [], expiresAt: payload.exp } -// }, -// } diff --git a/aws-node-mcp-server/README.md b/aws-node-mcp-server/README.md deleted file mode 100644 index 5bea40d2a..000000000 --- a/aws-node-mcp-server/README.md +++ /dev/null @@ -1,70 +0,0 @@ - - -# MCP Server on AWS Lambda - -Run a [Model Context Protocol](https://modelcontextprotocol.io) server built with the **official [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)** on AWS Lambda, fronted by API Gateway with response streaming. - -The MCP `2026-07-28` protocol revision made servers stateless — every request is self-contained, so any request can be served by any Lambda execution environment with no sessions or sticky routing. This example has three parts: - -- **`src/server.mjs`** — a standard SDK v2 server (`createMcpHandler` + `registerTool` with zod schemas). Nothing Lambda-specific; the same file runs on any web-standard host. -- **`src/lambda.mjs`** — ~80 lines of adapter glue mapping the API Gateway event and the Lambda response stream onto the Node-style shapes that `@modelcontextprotocol/node` accepts. No MCP logic. -- **`serverless.yml`** — a REST API route with `response.transferMode: STREAM`, which enables Server-Sent Events and integration timeouts up to 15 minutes. - -Plain JSON and streaming SSE coexist on the one endpoint: simple tool calls return ordinary `application/json` responses, while calls that request progress stream `text/event-stream` with notifications arriving ahead of the final result. - -**Related example:** [`aws-node-mcp-server-function-url`](../aws-node-mcp-server-function-url) serves the same MCP server through a Lambda Function URL instead — no API Gateway, no per-request fee. Pick this example when you want a custom domain, WAF, throttling, or an authorizer that rejects requests before they invoke the function. - -## Usage - -### Deploy - -```bash -npm install -serverless deploy -``` - -### Test - -```bash -ENDPOINT= node client.mjs -``` - -The client demonstrates both response modes: - -``` -=== tools/call (add) -> HTTP 200 (application/json) === -{"result":{"content":[{"type":"text","text":"42"}],"structuredContent":{"sum":42},"resultType":"complete",...}} - -=== tools/call (slow_report) -> HTTP 200 (text/event-stream) === -[+1150ms] {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"demo","progress":1,"total":3,...}} -[+1950ms] {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"demo","progress":2,"total":3,...}} -[+2750ms] {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"demo","progress":3,"total":3,...}} -[+2750ms] {"result":{"content":[{"type":"text","text":"completed 3 steps"}],"resultType":"complete",...}} -``` - -Older MCP clients that still send the `initialize` handshake are answered too — the SDK serves earlier protocol revisions on the same endpoint by default. - -### Going further - -The example is deliberately minimal; commented blocks show how to extend it: - -- **Authentication** (`serverless.yml` + `src/server.mjs`): either attach an API Gateway authorizer that validates Bearer JWTs before the function is invoked, or use the SDK's in-process `requireBearerAuth` with a `jose`-based verifier for any OpenID Connect provider. Pair it with an unauthenticated `/.well-known/oauth-protected-resource` route (the SDK's `oauthMetadataResponse` helper builds the document) so MCP clients can discover how to log in. -- **Elicitation** (`src/server.mjs`): a tool can pause mid-call and ask the user for input by returning `inputRequired(...)`; the client asks the user and retries the call with the answers attached, and the handler reads them with `acceptedContent(...)` on re-entry. -- **Resources**: expose readable documents next to tools with `registerResource`. -- **Long-running tools**: `timeoutInMillis` on the route and the function `timeout` can be raised up to 15 minutes. - -### Cleanup - -```bash -serverless remove -``` diff --git a/aws-node-mcp-server/client.mjs b/aws-node-mcp-server/client.mjs deleted file mode 100644 index 58644b511..000000000 --- a/aws-node-mcp-server/client.mjs +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env node -/** - * Try the deployed MCP server. - * Usage: ENDPOINT= node client.mjs - */ -const ENDPOINT = process.env.ENDPOINT -if (!ENDPOINT) { - console.error('Set ENDPOINT to the POST endpoint printed by `serverless deploy`') - process.exit(1) -} - -const META = { - 'io.modelcontextprotocol/protocolVersion': '2026-07-28', - 'io.modelcontextprotocol/clientInfo': { name: 'example-client', version: '1.0.0' }, - 'io.modelcontextprotocol/clientCapabilities': {}, -} -let id = 0 - -async function call(method, params, name) { - const res = await fetch(ENDPOINT, { - method: 'POST', - headers: { - 'content-type': 'application/json', - accept: 'application/json, text/event-stream', - 'mcp-protocol-version': '2026-07-28', - 'mcp-method': method, - ...(name && { 'mcp-name': name }), - }, - body: JSON.stringify({ jsonrpc: '2.0', id: ++id, method, params }), - }) - console.log(`\n=== ${method}${name ? ` (${name})` : ''} -> HTTP ${res.status} (${res.headers.get('content-type')}) ===`) - if (!res.headers.get('content-type')?.includes('text/event-stream')) { - console.log((await res.text()).slice(0, 800)) - return - } - // SSE: print each event as it arrives, with timing - const t0 = Date.now() - const reader = res.body.getReader() - const decoder = new TextDecoder() - let buffer = '' - for (;;) { - const { done, value } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) - let idx - while ((idx = buffer.indexOf('\n\n')) >= 0) { - const data = buffer.slice(0, idx).split('\n').find((l) => l.startsWith('data:'))?.slice(5).trim() - buffer = buffer.slice(idx + 2) - if (data) console.log(`[+${Date.now() - t0}ms]`, data.slice(0, 160)) - } - } -} - -// Plain JSON responses - streaming mode carries these unchanged: -await call('tools/list', { _meta: META }) -await call('tools/call', { name: 'add', arguments: { a: 2, b: 40 }, _meta: META }, 'add') - -// A streaming SSE response - progress notifications arrive before the result: -await call( - 'tools/call', - { - name: 'slow_report', - arguments: { steps: 3, progressToken: 'demo' }, - _meta: { ...META, progressToken: 'demo' }, - }, - 'slow_report', -) diff --git a/aws-node-mcp-server/src/server.mjs b/aws-node-mcp-server/src/server.mjs deleted file mode 100644 index 7f0cba526..000000000 --- a/aws-node-mcp-server/src/server.mjs +++ /dev/null @@ -1,120 +0,0 @@ -/** - * A standard MCP server built with the official MCP TypeScript SDK v2. - * Nothing in this file is Lambda-specific - the same handler runs on any - * web-standard host; src/lambda.mjs adapts it to Lambda. - */ -import { createMcpHandler, McpServer } from '@modelcontextprotocol/server' -import { z } from 'zod' - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) - -export default createMcpHandler(() => { - const server = new McpServer({ name: 'aws-node-mcp-server', version: '1.0.0' }) - - // A plain tool: zod schemas next to the implementation; returning - // structuredContent lets clients consume typed results. - server.registerTool( - 'add', - { - description: 'Add two numbers', - inputSchema: z.object({ a: z.number(), b: z.number() }), - outputSchema: z.object({ sum: z.number() }), - }, - async ({ a, b }) => { - const output = { sum: a + b } - return { - content: [{ type: 'text', text: String(output.sum) }], - structuredContent: output, - } - }, - ) - - // A long-running tool emitting progress notifications. When the client - // requests progress (a progressToken in _meta) and accepts text/event-stream, - // the response is served as SSE with notifications ahead of the final result. - server.registerTool( - 'slow_report', - { - description: 'Generate a report, reporting progress along the way', - inputSchema: z.object({ steps: z.number().default(3), progressToken: z.string().optional() }), - }, - async ({ steps, progressToken }, ctx) => { - for (let i = 1; i <= steps; i++) { - await sleep(800) - if (progressToken) { - await ctx.mcpReq.notify({ - method: 'notifications/progress', - params: { progressToken, progress: i, total: steps, message: `step ${i}` }, - }) - } - } - return { content: [{ type: 'text', text: `completed ${steps} steps` }] } - }, - ) - - // ------------------------------------------------------------------------- - // More SDK capabilities to explore (uncomment and adapt): - // ------------------------------------------------------------------------- - - // Elicitation - pause a tool call to ask the user for input. The handler - // returns an input_required result; the client asks the user and retries - // the call with the answers attached, re-entering the handler. Add - // acceptedContent and inputRequired to the imports above. To protect - // round-trip state that influences your logic, use the SDK's - // createRequestStateCodec with a key from KMS/Secrets Manager. - // - // server.registerTool( - // 'approve_refund', - // { - // description: 'Refund an order after user confirmation', - // inputSchema: z.object({ orderId: z.string() }), - // }, - // async ({ orderId }, ctx) => { - // const answer = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', z.object({ confirmed: z.boolean() })) - // if (answer === undefined) { - // return inputRequired({ - // inputRequests: { - // confirm: inputRequired.elicit({ - // message: `Refund order ${orderId}?`, - // requestedSchema: z.object({ confirmed: z.boolean() }), - // }), - // }, - // }) - // } - // if (!answer.confirmed) { - // return { content: [{ type: 'text', text: 'refund cancelled' }] } - // } - // return { content: [{ type: 'text', text: `refunded ${orderId}` }] } - // }, - // ) - - // Resources - expose readable documents next to tools: - // - // server.registerResource( - // 'orders', - // 'orders://recent', - // { description: 'Recent orders' }, - // async () => ({ contents: [{ uri: 'orders://recent', text: JSON.stringify([]) }] }), - // ) - - return server -}) - -// --------------------------------------------------------------------------- -// In-process OAuth (alternative to an API Gateway authorizer): the SDK ships -// bearer-token middleware and OAuth discovery-document helpers. Wrap the -// handler's fetch face before exporting - any OIDC provider works: -// -// import { requireBearerAuth, oauthMetadataResponse } from '@modelcontextprotocol/server' -// import { createRemoteJWKSet, jwtVerify } from 'jose' -// -// const jwks = createRemoteJWKSet(new URL('https:///.well-known/jwks.json')) -// const verifier = { -// async verifyAccessToken(token) { -// const { payload } = await jwtVerify(token, jwks, { -// issuer: 'https:///', -// audience: 'https://', -// }) -// return { token, clientId: payload.azp, scopes: payload.scope?.split(' ') ?? [], expiresAt: payload.exp } -// }, -// } diff --git a/examples.json b/examples.json index 9905d8d69..111d0d3e1 100644 --- a/examples.json +++ b/examples.json @@ -290,10 +290,10 @@ "authorAvatar": "https://avatars0.githubusercontent.com/u/8188?v=4&s=140" }, { - "title": "AWS MCP Server on Lambda (NodeJS)", - "name": "aws-node-mcp-server", - "description": "Run an MCP server built with the official MCP TypeScript SDK on AWS Lambda, with streaming responses through API Gateway.", - "githubUrl": "https://github.com/serverless/examples/tree/v4/aws-node-mcp-server", + "title": "AWS MCP Server behind API Gateway REST (NodeJS)", + "name": "aws-mcp-servers-rest-api", + "description": "Run an MCP server built with the official MCP TypeScript SDK on AWS Lambda, with streaming responses through API Gateway REST response streaming.", + "githubUrl": "https://github.com/serverless/examples/tree/v4/aws-mcp-servers/rest-api", "framework": "v4", "language": "node", "platform": "aws", @@ -303,9 +303,45 @@ }, { "title": "AWS MCP Server on Lambda Function URL (NodeJS)", - "name": "aws-node-mcp-server-function-url", + "name": "aws-mcp-servers-function-url", "description": "Run an MCP server built with the official MCP TypeScript SDK on a Lambda Function URL with response streaming - no API Gateway.", - "githubUrl": "https://github.com/serverless/examples/tree/v4/aws-node-mcp-server-function-url", + "githubUrl": "https://github.com/serverless/examples/tree/v4/aws-mcp-servers/function-url", + "framework": "v4", + "language": "node", + "platform": "aws", + "authorLink": "https://github.com/serverless", + "authorName": "Serverless, Inc.", + "authorAvatar": "https://avatars1.githubusercontent.com/u/13742415?s=200&v=4" + }, + { + "title": "AWS MCP Server with Hono (NodeJS)", + "name": "aws-mcp-servers-hono", + "description": "Run an MCP server on AWS Lambda with the official MCP Hono integration and Hono's aws-lambda adapter - no hand-written Lambda glue at all.", + "githubUrl": "https://github.com/serverless/examples/tree/v4/aws-mcp-servers/hono", + "framework": "v4", + "language": "node", + "platform": "aws", + "authorLink": "https://github.com/serverless", + "authorName": "Serverless, Inc.", + "authorAvatar": "https://avatars1.githubusercontent.com/u/13742415?s=200&v=4" + }, + { + "title": "AWS MCP Server with Express and Lambda Web Adapter (NodeJS)", + "name": "aws-mcp-servers-express-web-adapter", + "description": "Run an MCP server as a plain Express app on AWS Lambda with Lambda Web Adapter - zip deployment, streaming through API Gateway REST.", + "githubUrl": "https://github.com/serverless/examples/tree/v4/aws-mcp-servers/express-web-adapter", + "framework": "v4", + "language": "node", + "platform": "aws", + "authorLink": "https://github.com/serverless", + "authorName": "Serverless, Inc.", + "authorAvatar": "https://avatars1.githubusercontent.com/u/13742415?s=200&v=4" + }, + { + "title": "AWS MCP Server with Fastify in a Container Image (NodeJS)", + "name": "aws-mcp-servers-fastify-container", + "description": "Run an MCP server as a containerized Fastify app on AWS Lambda with Lambda Web Adapter - the same image runs on Fargate or anywhere else.", + "githubUrl": "https://github.com/serverless/examples/tree/v4/aws-mcp-servers/fastify-container", "framework": "v4", "language": "node", "platform": "aws", From cc2a638274629f9444105687658d1790fbf8aa88 Mon Sep 17 00:00:00 2001 From: Tomasz Czubocha Date: Wed, 29 Jul 2026 21:54:17 +0200 Subject: [PATCH 7/9] chore: hub-canonical shared client/server with validate.js drift check --- aws-mcp-servers/README.md | 2 + aws-mcp-servers/client.mjs | 316 +++++++++++++++++++++++++++++++++++++ aws-mcp-servers/server.mjs | 193 ++++++++++++++++++++++ validate.js | 35 ++++ 4 files changed, 546 insertions(+) create mode 100644 aws-mcp-servers/client.mjs create mode 100644 aws-mcp-servers/server.mjs diff --git a/aws-mcp-servers/README.md b/aws-mcp-servers/README.md index 73d364277..cf9a05ab4 100644 --- a/aws-mcp-servers/README.md +++ b/aws-mcp-servers/README.md @@ -77,3 +77,5 @@ AUTH=sigv4 SERVICE=lambda ENDPOINT= node client.mjs # IAM-auth ``` The checks cover the whole surface, including two negative cases proving the `2026-07-28` protocol validation is active end to end: a mismatching `Mcp-Method` header (`-32020`) and an elicitation call from a client that did not declare the capability (`-32021`). + +The canonical copies of the shared files live in this directory ([client.mjs](client.mjs), [server.mjs](server.mjs)); every example carries an identical copy so each directory stays self-contained. Edit the canonical, re-copy into the examples - `node validate.js` at the repo root fails on any drift. diff --git a/aws-mcp-servers/client.mjs b/aws-mcp-servers/client.mjs new file mode 100644 index 000000000..66f738e9a --- /dev/null +++ b/aws-mcp-servers/client.mjs @@ -0,0 +1,316 @@ +#!/usr/bin/env node +/** + * Shared test client for the aws-mcp-servers examples. Every example serves + * the same canonical MCP server, so this same script tests all of them. + * + * Usage: + * ENDPOINT= node client.mjs + * LONG=1 ENDPOINT=... node client.mjs # adds a ~36s streaming case + * AUTH=sigv4 SERVICE=bedrock-agentcore ENDPOINT= node client.mjs + * AUTH=sigv4 SERVICE=lambda ENDPOINT=... node client.mjs # IAM-auth Function URL + * + * Exercises the full 2026-07-28 surface: plain JSON, SSE progress streaming, + * an elicitation round-trip, resources (static + template), prompts, + * server/discover, cache hints, the legacy fallback, and two negative cases + * (header mismatch -32020, missing capability -32021). + */ + +const ENDPOINT = process.env.ENDPOINT +if (!ENDPOINT) { + console.error('Set ENDPOINT to the MCP endpoint URL') + process.exit(1) +} + +// --- endpoint + optional SigV4 signing -------------------------------------- + +let url = new URL(ENDPOINT) +let signer +if (process.env.AUTH === 'sigv4') { + const service = process.env.SERVICE || 'lambda' + if (service === 'bedrock-agentcore' && url.pathname.includes('/runtimes/')) { + // AgentCore runtime URLs embed the agent ARN - the WHOLE ARN must be + // URL-encoded in the path (including its inner "/"). + const arn = decodeURIComponent(url.pathname.match(/\/runtimes\/(.+?)\/invocations/)[1]) + url = new URL(`${url.origin}/runtimes/${encodeURIComponent(arn)}/invocations`) + url.searchParams.set('qualifier', 'DEFAULT') + } + const [{ SignatureV4 }, { HttpRequest }, { Sha256 }, { defaultProvider }] = await Promise.all([ + import('@smithy/signature-v4'), + import('@smithy/protocol-http'), + import('@aws-crypto/sha256-js'), + import('@aws-sdk/credential-provider-node'), + ]) + const sigv4 = new SignatureV4({ + service, + region: process.env.AWS_REGION || 'us-east-1', + credentials: defaultProvider(), + sha256: Sha256, + }) + signer = async (method, headers, body) => { + const request = new HttpRequest({ + method, + protocol: url.protocol, + hostname: url.hostname, + path: url.pathname, + query: Object.fromEntries(url.searchParams), + headers: { host: url.hostname, ...headers }, + ...(body != null && { body }), + }) + return (await sigv4.sign(request)).headers + } +} + +// --- request helpers --------------------------------------------------------- + +const CAPS_ELICIT = { elicitation: { form: {} } } +const meta = (capabilities = {}) => ({ + 'io.modelcontextprotocol/protocolVersion': '2026-07-28', + 'io.modelcontextprotocol/clientInfo': { name: 'aws-mcp-servers-client', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': capabilities, +}) + +let id = 0 +async function request({ method, params = {}, name, capabilities, headerMethod, envelope = true }) { + const body = JSON.stringify({ + jsonrpc: '2.0', + id: ++id, + method, + params: envelope ? { ...params, _meta: { ...meta(capabilities), ...(params._meta ?? {}) } } : params, + }) + let headers = { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...(envelope && { + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': headerMethod ?? method, + ...(name && { 'mcp-name': name }), + }), + } + if (signer) headers = await signer('POST', headers, body) + const t0 = Date.now() + const res = await fetch(url, { method: 'POST', headers, body }) + const contentType = res.headers.get('content-type') ?? '' + if (contentType.includes('text/event-stream')) { + const events = [] + const decoder = new TextDecoder() + let buffer = '' + for await (const chunk of res.body) { + buffer += decoder.decode(chunk, { stream: true }) + let sep + while ((sep = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, sep) + buffer = buffer.slice(sep + 2) + const data = frame.split('\n').find((l) => l.startsWith('data:'))?.slice(5).trim() + if (data) events.push({ t: Date.now() - t0, data: JSON.parse(data) }) + } + } + const final = events.at(-1)?.data + return { status: res.status, contentType, events, json: final, ms: Date.now() - t0 } + } + const text = await res.text() + let json + try { + json = JSON.parse(text) + } catch { + json = { raw: text } + } + return { status: res.status, contentType, events: [], json, ms: Date.now() - t0 } +} + +// --- tiny harness ------------------------------------------------------------ + +const results = [] +async function check(label, fn) { + try { + const detail = await fn() + results.push({ label, ok: true }) + console.log(`PASS ${label}${detail ? ` — ${detail}` : ''}`) + } catch (err) { + results.push({ label, ok: false }) + console.log(`FAIL ${label} — ${err.message}`) + } +} +const assert = (cond, msg) => { + if (!cond) throw new Error(msg) +} +// AgentCore Runtime replaces 4xx response bodies with its own error envelope +// (-32010, "Received error (400) ..."); the 400 status still proves the SDK +// rejected the request, so the negative cases accept that form there. +const rejectedWith = (json, code) => + json.error?.code === code || + (process.env.SERVICE === 'bedrock-agentcore' && + json.error?.code === -32010 && + /\(4\d\d\)/.test(json.error?.message ?? '')) + +// --- test cases ---------------------------------------------------------------- + +await check('1. tools/list returns the canonical tools with cache hints', async () => { + const r = await request({ method: 'tools/list' }) + assert(r.status === 200, `HTTP ${r.status}`) + assert(r.contentType.includes('application/json'), `content-type ${r.contentType}`) + const names = (r.json.result?.tools ?? []).map((t) => t.name).sort() + assert(JSON.stringify(names) === JSON.stringify(['add', 'approve_refund', 'slow_report']), `tools: ${names}`) + assert(r.json.result.ttlMs === 300000, `ttlMs ${r.json.result.ttlMs}`) + assert(r.json.result.cacheScope === 'public', `cacheScope ${r.json.result.cacheScope}`) + return `ttlMs=${r.json.result.ttlMs} cacheScope=${r.json.result.cacheScope}` +}) + +await check('2. add returns plain JSON with structured content', async () => { + const r = await request({ method: 'tools/call', params: { name: 'add', arguments: { a: 2, b: 40 } }, name: 'add' }) + assert(r.status === 200, `HTTP ${r.status}`) + assert(r.contentType.includes('application/json'), `content-type ${r.contentType}`) + assert(r.json.result?.structuredContent?.sum === 42, JSON.stringify(r.json)) + return 'sum=42' +}) + +await check('3. slow_report streams incremental progress over SSE', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'slow_report', arguments: { steps: 3, progressToken: 'pt' }, _meta: { progressToken: 'pt' } }, + name: 'slow_report', + }) + assert(r.status === 200, `HTTP ${r.status}`) + assert(r.contentType.includes('text/event-stream'), `content-type ${r.contentType}`) + const progress = r.events.filter((e) => e.data.method === 'notifications/progress') + assert(progress.length === 3, `${progress.length} progress events`) + assert(progress[0].t < r.ms - 500, 'first progress event did not arrive before the final result') + assert(r.json.result?.content?.[0]?.text === 'completed 3 steps', JSON.stringify(r.json)) + return `3 progress events, first at +${progress[0].t}ms, done in ${r.ms}ms` +}) + +await check('4. approve_refund elicitation round-trip (accept and cancel)', async () => { + const ask = await request({ + method: 'tools/call', + params: { name: 'approve_refund', arguments: { orderId: 'o-1' } }, + name: 'approve_refund', + capabilities: CAPS_ELICIT, + }) + assert(ask.json.result?.resultType === 'input_required', JSON.stringify(ask.json)) + assert(ask.json.result?.inputRequests?.confirm, 'missing inputRequests.confirm') + const accepted = await request({ + method: 'tools/call', + params: { + name: 'approve_refund', + arguments: { orderId: 'o-1' }, + inputResponses: { confirm: { action: 'accept', content: { confirmed: true } } }, + }, + name: 'approve_refund', + capabilities: CAPS_ELICIT, + }) + assert(accepted.json.result?.content?.[0]?.text === 'refunded o-1', JSON.stringify(accepted.json)) + const cancelled = await request({ + method: 'tools/call', + params: { + name: 'approve_refund', + arguments: { orderId: 'o-1' }, + inputResponses: { confirm: { action: 'accept', content: { confirmed: false } } }, + }, + name: 'approve_refund', + capabilities: CAPS_ELICIT, + }) + assert(cancelled.json.result?.content?.[0]?.text === 'refund cancelled', JSON.stringify(cancelled.json)) + return 'input_required -> refunded o-1 / refund cancelled' +}) + +await check('5. resources list + read (Mcp-Name carries the uri)', async () => { + const list = await request({ method: 'resources/list' }) + const uris = (list.json.result?.resources ?? []).map((r) => r.uri) + assert(uris.includes('guide://usage'), `resources: ${uris}`) + const read = await request({ method: 'resources/read', params: { uri: 'guide://usage' }, name: 'guide://usage' }) + assert(read.json.result?.contents?.[0]?.text?.startsWith('# Usage'), JSON.stringify(read.json)) + return 'guide://usage readable' +}) + +await check('6. resource template read with per-resource cache hint', async () => { + const templates = await request({ method: 'resources/templates/list' }) + const uriTemplates = (templates.json.result?.resourceTemplates ?? []).map((t) => t.uriTemplate) + assert(uriTemplates.includes('orders://{orderId}'), `templates: ${uriTemplates}`) + const read = await request({ method: 'resources/read', params: { uri: 'orders://o-42' }, name: 'orders://o-42' }) + const record = JSON.parse(read.json.result?.contents?.[0]?.text ?? '{}') + assert(record.orderId === 'o-42' && record.status === 'shipped', JSON.stringify(read.json)) + assert(read.json.result.ttlMs === 60000, `ttlMs ${read.json.result.ttlMs}`) + return `orders://o-42 -> ${record.status}, ttlMs=${read.json.result.ttlMs}` +}) + +await check('7. prompts list + get', async () => { + const list = await request({ method: 'prompts/list' }) + const names = (list.json.result?.prompts ?? []).map((p) => p.name) + assert(names.includes('summarize_order'), `prompts: ${names}`) + const got = await request({ + method: 'prompts/get', + params: { name: 'summarize_order', arguments: { orderId: 'o-7' } }, + name: 'summarize_order', + }) + const text = got.json.result?.messages?.[0]?.content?.text ?? '' + assert(text.includes('o-7'), JSON.stringify(got.json)) + return 'summarize_order fills its argument' +}) + +await check('8. server/discover surfaces the instructions', async () => { + const r = await request({ method: 'server/discover' }) + assert((r.json.result?.instructions ?? '').includes('Demo MCP server'), JSON.stringify(r.json.result ?? r.json)) + return 'instructions present' +}) + +await check('9. legacy initialize is answered on the same endpoint', async () => { + const r = await request({ + method: 'initialize', + params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'legacy', version: '1.0.0' } }, + envelope: false, + }) + assert(r.status === 200, `HTTP ${r.status}`) + const version = r.json.result?.protocolVersion ?? '' + assert(version.startsWith('2025-'), JSON.stringify(r.json)) + return `served as ${version}` +}) + +await check('10. GET is answered with the spec-mandated 405', async () => { + let headers = { accept: 'application/json, text/event-stream' } + if (signer) headers = await signer('GET', headers, null) + const res = await fetch(url, { method: 'GET', headers }) + assert(res.status === 405, `HTTP ${res.status}`) + return 'HTTP 405' +}) + +await check('11. Mcp-Method header mismatching the body is rejected (-32020)', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'add', arguments: { a: 1, b: 1 } }, + name: 'add', + headerMethod: 'tools/list', + }) + assert(rejectedWith(r.json, -32020), JSON.stringify(r.json)) + return r.json.error.code === -32020 ? '-32020' : 'rejected (platform-wrapped 400)' +}) + +await check('12. elicitation without the client capability is rejected (-32021)', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'approve_refund', arguments: { orderId: 'o-9' } }, + name: 'approve_refund', + capabilities: {}, + }) + assert(rejectedWith(r.json, -32021), JSON.stringify(r.json)) + return r.json.error.code === -32021 ? '-32021' : 'rejected (platform-wrapped 400)' +}) + +if (process.env.LONG) { + await check('13. LONG: 45-step stream (~36s) survives past the 29s mark', async () => { + const r = await request({ + method: 'tools/call', + params: { name: 'slow_report', arguments: { steps: 45, progressToken: 'long' }, _meta: { progressToken: 'long' } }, + name: 'slow_report', + }) + const progress = r.events.filter((e) => e.data.method === 'notifications/progress') + assert(progress.length === 45, `${progress.length} progress events`) + assert(r.json.result?.content?.[0]?.text === 'completed 45 steps', 'final result missing (stream cut?)') + assert(r.ms > 34000, `finished suspiciously fast (${r.ms}ms)`) + return `45 events over ${(r.ms / 1000).toFixed(1)}s, final result received` + }) +} + +// --- summary ------------------------------------------------------------------- + +const failed = results.filter((r) => !r.ok) +console.log(`\n${results.length - failed.length}/${results.length} passed`) +process.exit(failed.length ? 1 : 0) diff --git a/aws-mcp-servers/server.mjs b/aws-mcp-servers/server.mjs new file mode 100644 index 000000000..fdf1476e6 --- /dev/null +++ b/aws-mcp-servers/server.mjs @@ -0,0 +1,193 @@ +/** + * A standard MCP server built with the official MCP TypeScript SDK v2, + * serving the stateless MCP 2026-07-28 protocol revision (and answering + * older clients through the SDK's built-in fallback on the same endpoint). + * + * Nothing in this file is Lambda-specific - the same handler runs on any + * web-standard host. Every example in aws-mcp-servers/ serves this exact + * server; only the hosting glue around it differs. + * + * Surface: + * tools: add (plain JSON), slow_report (progress streaming), + * approve_refund (elicitation - pauses to ask the user) + * resources: guide://usage, orders://{orderId} (template) + * prompts: summarize_order + * extras: instructions (served via server/discover), cache hints + */ +import { + acceptedContent, + createMcpHandler, + inputRequired, + McpServer, + ResourceTemplate, +} from '@modelcontextprotocol/server' +import { z } from 'zod' + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +export default createMcpHandler(() => { + const server = new McpServer( + { name: 'aws-mcp-server', version: '1.0.0' }, + { + // Shown to clients that probe the server/discover method: + instructions: + 'Demo MCP server. Call add for arithmetic, slow_report to watch ' + + 'streamed progress, approve_refund to see a tool ask the user for ' + + 'confirmation mid-call. Read guide://usage for a walkthrough.', + // Cache hints: let clients and shared caches reuse the tool list for + // five minutes. Without hints the SDK emits the conservative defaults + // (ttlMs: 0, cacheScope: private) on every cacheable result: + cacheHints: { 'tools/list': { ttlMs: 300000, cacheScope: 'public' } }, + }, + ) + + // A plain tool: zod schemas next to the implementation; returning + // structuredContent lets clients consume typed results. + server.registerTool( + 'add', + { + description: 'Add two numbers', + inputSchema: z.object({ a: z.number(), b: z.number() }), + outputSchema: z.object({ sum: z.number() }), + }, + async ({ a, b }) => { + const output = { sum: a + b } + return { + content: [{ type: 'text', text: String(output.sum) }], + structuredContent: output, + } + }, + ) + + // A long-running tool emitting progress notifications. When the client + // requests progress (a progressToken in _meta) and accepts text/event-stream, + // the response is served as SSE with notifications ahead of the final result. + server.registerTool( + 'slow_report', + { + description: 'Generate a report, reporting progress along the way', + inputSchema: z.object({ steps: z.number().default(3), progressToken: z.string().optional() }), + }, + async ({ steps, progressToken }, ctx) => { + for (let i = 1; i <= steps; i++) { + await sleep(800) + if (progressToken) { + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progressToken, progress: i, total: steps, message: `step ${i}` }, + }) + } + } + return { content: [{ type: 'text', text: `completed ${steps} steps` }] } + }, + ) + + // Elicitation - a tool that pauses to ask the user for input. The handler + // returns an input_required result; the client asks the user and retries + // the call with the answers attached, re-entering the handler, which reads + // them with acceptedContent. Works statelessly - no session required. + server.registerTool( + 'approve_refund', + { + description: 'Refund an order after user confirmation', + inputSchema: z.object({ orderId: z.string() }), + }, + async ({ orderId }, ctx) => { + const answer = acceptedContent( + ctx.mcpReq.inputResponses, + 'confirm', + z.object({ confirmed: z.boolean() }), + ) + if (answer === undefined) { + return inputRequired({ + inputRequests: { + confirm: inputRequired.elicit({ + message: `Refund order ${orderId}?`, + requestedSchema: z.object({ confirmed: z.boolean() }), + }), + }, + }) + } + if (!answer.confirmed) { + return { content: [{ type: 'text', text: 'refund cancelled' }] } + } + return { content: [{ type: 'text', text: `refunded ${orderId}` }] } + }, + ) + + // A readable document next to the tools. Clients fetch it with + // resources/read (the Mcp-Name header carries the uri). + server.registerResource( + 'usage-guide', + 'guide://usage', + { description: 'How to use this server', mimeType: 'text/markdown' }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: '# Usage\n\nCall `add` for sums, `slow_report` for streamed progress, `approve_refund` for an elicitation round-trip.', + }, + ], + }), + ) + + // A resource template - parameterized URIs resolved per request. The + // per-resource cacheHint overrides the server-level hints for its reads. + server.registerResource( + 'order', + new ResourceTemplate('orders://{orderId}', { list: undefined }), + { description: 'Order record by id', cacheHint: { ttlMs: 60000, cacheScope: 'private' } }, + async (uri, { orderId }) => ({ + contents: [{ uri: uri.href, text: JSON.stringify({ orderId, status: 'shipped' }) }], + }), + ) + + // A prompt template clients can list and fill in. + server.registerPrompt( + 'summarize_order', + { + description: 'Ask the model to summarize an order', + argsSchema: z.object({ orderId: z.string() }), + }, + ({ orderId }) => ({ + messages: [ + { + role: 'user', + content: { type: 'text', text: `Summarize the status of order ${orderId} in one sentence.` }, + }, + ], + }), + ) + + return server +}) + +// --------------------------------------------------------------------------- +// Going further (uncomment and adapt): +// --------------------------------------------------------------------------- +// +// Signed round-trip state - when an elicitation round-trip must carry server +// state the retry cannot be allowed to tamper with (a computed price, an +// idempotency key), return it as requestState sealed with the SDK's HMAC +// codec. Store the >= 32-byte key in Secrets Manager or SSM: +// +// import { createRequestStateCodec } from '@modelcontextprotocol/server' +// const codec = createRequestStateCodec({ key: process.env.MCP_STATE_KEY, ttlSeconds: 600 }) +// +// In-process OAuth (works identically behind every front door): the SDK ships +// bearer-token middleware and OAuth discovery-document helpers. Wrap the +// handler's fetch face before exporting - any OpenID Connect provider works: +// +// import { requireBearerAuth, oauthMetadataResponse } from '@modelcontextprotocol/server' +// import { createRemoteJWKSet, jwtVerify } from 'jose' +// +// const jwks = createRemoteJWKSet(new URL('https:///.well-known/jwks.json')) +// const verifier = { +// async verifyAccessToken(token) { +// const { payload } = await jwtVerify(token, jwks, { +// issuer: 'https:///', +// audience: 'https://', +// }) +// return { token, clientId: payload.azp, scopes: payload.scope?.split(' ') ?? [], expiresAt: payload.exp } +// }, +// } diff --git a/validate.js b/validate.js index fe787de5d..a22f6f438 100644 --- a/validate.js +++ b/validate.js @@ -52,6 +52,41 @@ for (const dir of findExampleDirs(ROOT)) { } } +// The aws-mcp-servers examples all serve one canonical MCP server and ship +// one shared test client. The canonical copies live at aws-mcp-servers/ +// (client.mjs, server.mjs); every example carries an identical copy so each +// directory stays self-contained. Edit the canonical, then re-copy. +const SHARED_FILES = [ + ['aws-mcp-servers/client.mjs', [ + 'aws-mcp-servers/rest-api/client.mjs', + 'aws-mcp-servers/function-url/client.mjs', + 'aws-mcp-servers/hono/client.mjs', + 'aws-mcp-servers/express-web-adapter/client.mjs', + 'aws-mcp-servers/fastify-container/client.mjs', + 'aws-bedrock-agentcore/javascript/mcp-server/client.mjs', + ]], + ['aws-mcp-servers/server.mjs', [ + 'aws-mcp-servers/rest-api/src/server.mjs', + 'aws-mcp-servers/function-url/src/server.mjs', + 'aws-mcp-servers/hono/src/server.mjs', + 'aws-mcp-servers/express-web-adapter/src/server.mjs', + 'aws-mcp-servers/fastify-container/src/server.mjs', + 'aws-bedrock-agentcore/javascript/mcp-server/src/server.mjs', + ]], +]; +for (const [canonical, copies] of SHARED_FILES) { + const canonicalPath = path.join(ROOT, canonical); + if (!fs.existsSync(canonicalPath)) { errors.push(`${canonical}: canonical shared file missing`); continue; } + const want = fs.readFileSync(canonicalPath, 'utf8'); + for (const copy of copies) { + const copyPath = path.join(ROOT, copy); + if (!fs.existsSync(copyPath)) { errors.push(`${copy}: missing copy of ${canonical}`); continue; } + if (fs.readFileSync(copyPath, 'utf8') !== want) { + errors.push(`${copy}: differs from canonical ${canonical} — edit the canonical and re-copy`); + } + } +} + if (errors.length) { console.error(`${errors.length} validation error(s):`); errors.forEach((e) => console.error(` - ${e}`)); From 38e33563fbb67733c55944b7c73c29ab60b3e391 Mon Sep 17 00:00:00 2001 From: Tomasz Czubocha Date: Thu, 30 Jul 2026 12:39:25 +0200 Subject: [PATCH 8/9] fix: use regional endpoints for the REST-fronted MCP examples Edge-optimized REST endpoints cut streams that stay idle for 30 seconds, and the clock starts at invoke rather than at the first byte, so a tool that computes quietly for longer than that fails even with a raised timeoutInMillis. Measured on one function with timeoutInMillis 120000 and an identical 36s silent call: HTTP 504 after 30.4s on edge, HTTP 200 after 37.4s on regional. The examples passed their own suites on edge only because the demo tool emits progress every 800ms, which keeps the stream non-idle. Both REST-fronted examples now set endpointType REGIONAL, with the reason in a comment, and the READMEs note the constraint for long-running tools. --- aws-mcp-servers/README.md | 2 +- aws-mcp-servers/express-web-adapter/README.md | 4 ++++ aws-mcp-servers/express-web-adapter/serverless.yml | 6 ++++++ aws-mcp-servers/rest-api/README.md | 2 +- aws-mcp-servers/rest-api/serverless.yml | 6 ++++++ 5 files changed, 18 insertions(+), 2 deletions(-) diff --git a/aws-mcp-servers/README.md b/aws-mcp-servers/README.md index cf9a05ab4..ffc689a0c 100644 --- a/aws-mcp-servers/README.md +++ b/aws-mcp-servers/README.md @@ -40,7 +40,7 @@ The Lambda-based examples vary along three axes that are **fully independent** - | --- | --- | --- | | Custom domain, WAF, throttling, usage plans | yes | domain via CloudFront in front | | Reject unauthenticated requests before invoking | yes (authorizer) | IAM/SigV4 only | -| Streaming (SSE) | yes - raise `timeoutInMillis` alongside (up to 15 min) | yes - function `timeout` is the only bound | +| Streaming (SSE) | yes - raise `timeoutInMillis` alongside (up to 15 min), and keep the endpoint regional: edge-optimized endpoints cut streams idle for 30 s | yes - function `timeout` is the only bound | | Per-request cost | per-request fee (see [API Gateway pricing](https://aws.amazon.com/api-gateway/pricing/)), no streaming surcharge | none (Lambda streaming meters egress beyond the first 6 MB per response - see [Lambda pricing](https://aws.amazon.com/lambda/pricing/)) | AgentCore Runtime replaces the front-door question entirely (the platform hosts the endpoint, IAM or JWT auth, consumption-based pricing - see [AgentCore pricing](https://aws.amazon.com/bedrock/agentcore/pricing/)); AgentCore Gateway additionally replaces the MCP server itself. diff --git a/aws-mcp-servers/express-web-adapter/README.md b/aws-mcp-servers/express-web-adapter/README.md index 14cde70c4..0ca2bc596 100644 --- a/aws-mcp-servers/express-web-adapter/README.md +++ b/aws-mcp-servers/express-web-adapter/README.md @@ -75,6 +75,10 @@ The demo endpoint is public. For real deployments (both included): - **Validate in-process** - the SDK's `requireBearerAuth` with a `jose` JWKS verifier (commented block at the bottom of `src/server.mjs`). - Behind an IAM-auth Function URL, the adapter's `AWS_LWA_AUTHORIZATION_SOURCE` can restore a client-supplied token from another header into `Authorization` (SigV4 reserves the original). +### Going further + +- **Long-running tools**: raise the function `timeout` and `timeoutInMillis` together. Keep the endpoint regional (as configured): edge-optimized REST endpoints cut streams that stay idle for 30 seconds, so a tool that computes quietly for longer than that would fail there even with a raised timeout. + ### Cleanup ```bash diff --git a/aws-mcp-servers/express-web-adapter/serverless.yml b/aws-mcp-servers/express-web-adapter/serverless.yml index 00c29636a..c2c455041 100644 --- a/aws-mcp-servers/express-web-adapter/serverless.yml +++ b/aws-mcp-servers/express-web-adapter/serverless.yml @@ -8,6 +8,12 @@ service: aws-mcp-express provider: name: aws runtime: nodejs22.x # the MCP SDK v2 requires Node.js 20+ + # Regional endpoint, not the default edge-optimized one: edge REST endpoints + # cut idle streams after 30 seconds, which kills a tool that computes quietly + # for longer than that before responding. Regional raises that bound to + # 5 minutes (verified: an identical 36s silent call returns 200 on regional + # and 504 on edge). + endpointType: REGIONAL functions: mcp: diff --git a/aws-mcp-servers/rest-api/README.md b/aws-mcp-servers/rest-api/README.md index 84a35c851..3cdc18ffa 100644 --- a/aws-mcp-servers/rest-api/README.md +++ b/aws-mcp-servers/rest-api/README.md @@ -69,7 +69,7 @@ The demo endpoint is public. For real deployments, two options (both included): - **Signed round-trip state**: seal server state across elicitation retries with `createRequestStateCodec` (see the comment in `src/server.mjs`). - **OAuth discovery**: serve `/.well-known/oauth-protected-resource` so MCP clients can discover your authorization server (commented route in `serverless.yml`). -- **Long-running tools**: raise the function `timeout` and `timeoutInMillis` - streamed responses run up to 15 minutes. +- **Long-running tools**: raise the function `timeout` and `timeoutInMillis` - streamed responses run up to 15 minutes. Keep the endpoint regional (as configured): edge-optimized REST endpoints cut streams that stay idle for 30 seconds, so a tool that computes quietly for longer than that would fail there even with a raised timeout. ### Cleanup diff --git a/aws-mcp-servers/rest-api/serverless.yml b/aws-mcp-servers/rest-api/serverless.yml index 34f41c67f..db1b45da0 100644 --- a/aws-mcp-servers/rest-api/serverless.yml +++ b/aws-mcp-servers/rest-api/serverless.yml @@ -8,6 +8,12 @@ service: aws-mcp-rest-api provider: name: aws runtime: nodejs22.x # the MCP SDK v2 requires Node.js 20+ + # Regional endpoint, not the default edge-optimized one: edge REST endpoints + # cut idle streams after 30 seconds, which kills a tool that computes quietly + # for longer than that before responding. Regional raises that bound to + # 5 minutes (verified: an identical 36s silent call returns 200 on regional + # and 504 on edge). + endpointType: REGIONAL functions: mcp: From a6a68fe669f24f3b7c2a21537452fd57ca57a90e Mon Sep 17 00:00:00 2001 From: Tomasz Czubocha Date: Thu, 30 Jul 2026 12:49:02 +0200 Subject: [PATCH 9/9] test: cover the silent long call in the shared MCP client The suite's long-call case streams progress every 800ms, which keeps a stream non-idle and therefore passes even on an endpoint that kills idle streams - the reason the edge-endpoint defect went unnoticed. Adds a companion case that stays silent for ~36s and returns plain JSON, which is the shape that fails there (HTTP 504 at ~30s on edge, 200 on regional). Verified 14/14 against the canonical server. --- .../javascript/mcp-server/client.mjs | 19 +++++++++++++++++++ aws-mcp-servers/README.md | 2 +- aws-mcp-servers/client.mjs | 19 +++++++++++++++++++ .../express-web-adapter/client.mjs | 19 +++++++++++++++++++ aws-mcp-servers/fastify-container/client.mjs | 19 +++++++++++++++++++ aws-mcp-servers/function-url/client.mjs | 19 +++++++++++++++++++ aws-mcp-servers/hono/client.mjs | 19 +++++++++++++++++++ aws-mcp-servers/rest-api/client.mjs | 19 +++++++++++++++++++ 8 files changed, 134 insertions(+), 1 deletion(-) diff --git a/aws-bedrock-agentcore/javascript/mcp-server/client.mjs b/aws-bedrock-agentcore/javascript/mcp-server/client.mjs index 66f738e9a..8ede92bfe 100644 --- a/aws-bedrock-agentcore/javascript/mcp-server/client.mjs +++ b/aws-bedrock-agentcore/javascript/mcp-server/client.mjs @@ -309,6 +309,25 @@ if (process.env.LONG) { }) } +if (process.env.LONG) { + await check('14. LONG: a silent 36s call is not cut short', async () => { + // slow_report emits progress only when given a progressToken, so this call + // writes nothing at all for ~36s and then returns plain JSON. That is the + // case an edge-optimized API Gateway endpoint kills at 30s (its idle + // timeout starts at invoke, not at the first byte, and a raised + // timeoutInMillis does not help) - which is why the REST-fronted examples + // deploy on a regional endpoint. + const r = await request({ + method: 'tools/call', + params: { name: 'slow_report', arguments: { steps: 45 } }, + name: 'slow_report', + }) + assert(r.status === 200, `HTTP ${r.status} after ${(r.ms / 1000).toFixed(1)}s`) + assert(r.json.result?.content?.[0]?.text === 'completed 45 steps', JSON.stringify(r.json).slice(0, 200)) + return `plain JSON after ${(r.ms / 1000).toFixed(1)}s of silence` + }) +} + // --- summary ------------------------------------------------------------------- const failed = results.filter((r) => !r.ok) diff --git a/aws-mcp-servers/README.md b/aws-mcp-servers/README.md index ffc689a0c..4ffea22de 100644 --- a/aws-mcp-servers/README.md +++ b/aws-mcp-servers/README.md @@ -76,6 +76,6 @@ AUTH=sigv4 SERVICE=bedrock-agentcore ENDPOINT= node client.mjs # AgentCor AUTH=sigv4 SERVICE=lambda ENDPOINT= node client.mjs # IAM-auth Function URL ``` -The checks cover the whole surface, including two negative cases proving the `2026-07-28` protocol validation is active end to end: a mismatching `Mcp-Method` header (`-32020`) and an elicitation call from a client that did not declare the capability (`-32021`). +The checks cover the whole surface, including two negative cases proving the `2026-07-28` protocol validation is active end to end: a mismatching `Mcp-Method` header (`-32020`) and an elicitation call from a client that did not declare the capability (`-32021`). `LONG=1` adds two long-call cases - one streaming ~36 s of progress events, one staying completely silent for ~36 s before returning - because those two fail for different reasons on a misconfigured front door. The canonical copies of the shared files live in this directory ([client.mjs](client.mjs), [server.mjs](server.mjs)); every example carries an identical copy so each directory stays self-contained. Edit the canonical, re-copy into the examples - `node validate.js` at the repo root fails on any drift. diff --git a/aws-mcp-servers/client.mjs b/aws-mcp-servers/client.mjs index 66f738e9a..8ede92bfe 100644 --- a/aws-mcp-servers/client.mjs +++ b/aws-mcp-servers/client.mjs @@ -309,6 +309,25 @@ if (process.env.LONG) { }) } +if (process.env.LONG) { + await check('14. LONG: a silent 36s call is not cut short', async () => { + // slow_report emits progress only when given a progressToken, so this call + // writes nothing at all for ~36s and then returns plain JSON. That is the + // case an edge-optimized API Gateway endpoint kills at 30s (its idle + // timeout starts at invoke, not at the first byte, and a raised + // timeoutInMillis does not help) - which is why the REST-fronted examples + // deploy on a regional endpoint. + const r = await request({ + method: 'tools/call', + params: { name: 'slow_report', arguments: { steps: 45 } }, + name: 'slow_report', + }) + assert(r.status === 200, `HTTP ${r.status} after ${(r.ms / 1000).toFixed(1)}s`) + assert(r.json.result?.content?.[0]?.text === 'completed 45 steps', JSON.stringify(r.json).slice(0, 200)) + return `plain JSON after ${(r.ms / 1000).toFixed(1)}s of silence` + }) +} + // --- summary ------------------------------------------------------------------- const failed = results.filter((r) => !r.ok) diff --git a/aws-mcp-servers/express-web-adapter/client.mjs b/aws-mcp-servers/express-web-adapter/client.mjs index 66f738e9a..8ede92bfe 100644 --- a/aws-mcp-servers/express-web-adapter/client.mjs +++ b/aws-mcp-servers/express-web-adapter/client.mjs @@ -309,6 +309,25 @@ if (process.env.LONG) { }) } +if (process.env.LONG) { + await check('14. LONG: a silent 36s call is not cut short', async () => { + // slow_report emits progress only when given a progressToken, so this call + // writes nothing at all for ~36s and then returns plain JSON. That is the + // case an edge-optimized API Gateway endpoint kills at 30s (its idle + // timeout starts at invoke, not at the first byte, and a raised + // timeoutInMillis does not help) - which is why the REST-fronted examples + // deploy on a regional endpoint. + const r = await request({ + method: 'tools/call', + params: { name: 'slow_report', arguments: { steps: 45 } }, + name: 'slow_report', + }) + assert(r.status === 200, `HTTP ${r.status} after ${(r.ms / 1000).toFixed(1)}s`) + assert(r.json.result?.content?.[0]?.text === 'completed 45 steps', JSON.stringify(r.json).slice(0, 200)) + return `plain JSON after ${(r.ms / 1000).toFixed(1)}s of silence` + }) +} + // --- summary ------------------------------------------------------------------- const failed = results.filter((r) => !r.ok) diff --git a/aws-mcp-servers/fastify-container/client.mjs b/aws-mcp-servers/fastify-container/client.mjs index 66f738e9a..8ede92bfe 100644 --- a/aws-mcp-servers/fastify-container/client.mjs +++ b/aws-mcp-servers/fastify-container/client.mjs @@ -309,6 +309,25 @@ if (process.env.LONG) { }) } +if (process.env.LONG) { + await check('14. LONG: a silent 36s call is not cut short', async () => { + // slow_report emits progress only when given a progressToken, so this call + // writes nothing at all for ~36s and then returns plain JSON. That is the + // case an edge-optimized API Gateway endpoint kills at 30s (its idle + // timeout starts at invoke, not at the first byte, and a raised + // timeoutInMillis does not help) - which is why the REST-fronted examples + // deploy on a regional endpoint. + const r = await request({ + method: 'tools/call', + params: { name: 'slow_report', arguments: { steps: 45 } }, + name: 'slow_report', + }) + assert(r.status === 200, `HTTP ${r.status} after ${(r.ms / 1000).toFixed(1)}s`) + assert(r.json.result?.content?.[0]?.text === 'completed 45 steps', JSON.stringify(r.json).slice(0, 200)) + return `plain JSON after ${(r.ms / 1000).toFixed(1)}s of silence` + }) +} + // --- summary ------------------------------------------------------------------- const failed = results.filter((r) => !r.ok) diff --git a/aws-mcp-servers/function-url/client.mjs b/aws-mcp-servers/function-url/client.mjs index 66f738e9a..8ede92bfe 100644 --- a/aws-mcp-servers/function-url/client.mjs +++ b/aws-mcp-servers/function-url/client.mjs @@ -309,6 +309,25 @@ if (process.env.LONG) { }) } +if (process.env.LONG) { + await check('14. LONG: a silent 36s call is not cut short', async () => { + // slow_report emits progress only when given a progressToken, so this call + // writes nothing at all for ~36s and then returns plain JSON. That is the + // case an edge-optimized API Gateway endpoint kills at 30s (its idle + // timeout starts at invoke, not at the first byte, and a raised + // timeoutInMillis does not help) - which is why the REST-fronted examples + // deploy on a regional endpoint. + const r = await request({ + method: 'tools/call', + params: { name: 'slow_report', arguments: { steps: 45 } }, + name: 'slow_report', + }) + assert(r.status === 200, `HTTP ${r.status} after ${(r.ms / 1000).toFixed(1)}s`) + assert(r.json.result?.content?.[0]?.text === 'completed 45 steps', JSON.stringify(r.json).slice(0, 200)) + return `plain JSON after ${(r.ms / 1000).toFixed(1)}s of silence` + }) +} + // --- summary ------------------------------------------------------------------- const failed = results.filter((r) => !r.ok) diff --git a/aws-mcp-servers/hono/client.mjs b/aws-mcp-servers/hono/client.mjs index 66f738e9a..8ede92bfe 100644 --- a/aws-mcp-servers/hono/client.mjs +++ b/aws-mcp-servers/hono/client.mjs @@ -309,6 +309,25 @@ if (process.env.LONG) { }) } +if (process.env.LONG) { + await check('14. LONG: a silent 36s call is not cut short', async () => { + // slow_report emits progress only when given a progressToken, so this call + // writes nothing at all for ~36s and then returns plain JSON. That is the + // case an edge-optimized API Gateway endpoint kills at 30s (its idle + // timeout starts at invoke, not at the first byte, and a raised + // timeoutInMillis does not help) - which is why the REST-fronted examples + // deploy on a regional endpoint. + const r = await request({ + method: 'tools/call', + params: { name: 'slow_report', arguments: { steps: 45 } }, + name: 'slow_report', + }) + assert(r.status === 200, `HTTP ${r.status} after ${(r.ms / 1000).toFixed(1)}s`) + assert(r.json.result?.content?.[0]?.text === 'completed 45 steps', JSON.stringify(r.json).slice(0, 200)) + return `plain JSON after ${(r.ms / 1000).toFixed(1)}s of silence` + }) +} + // --- summary ------------------------------------------------------------------- const failed = results.filter((r) => !r.ok) diff --git a/aws-mcp-servers/rest-api/client.mjs b/aws-mcp-servers/rest-api/client.mjs index 66f738e9a..8ede92bfe 100644 --- a/aws-mcp-servers/rest-api/client.mjs +++ b/aws-mcp-servers/rest-api/client.mjs @@ -309,6 +309,25 @@ if (process.env.LONG) { }) } +if (process.env.LONG) { + await check('14. LONG: a silent 36s call is not cut short', async () => { + // slow_report emits progress only when given a progressToken, so this call + // writes nothing at all for ~36s and then returns plain JSON. That is the + // case an edge-optimized API Gateway endpoint kills at 30s (its idle + // timeout starts at invoke, not at the first byte, and a raised + // timeoutInMillis does not help) - which is why the REST-fronted examples + // deploy on a regional endpoint. + const r = await request({ + method: 'tools/call', + params: { name: 'slow_report', arguments: { steps: 45 } }, + name: 'slow_report', + }) + assert(r.status === 200, `HTTP ${r.status} after ${(r.ms / 1000).toFixed(1)}s`) + assert(r.json.result?.content?.[0]?.text === 'completed 45 steps', JSON.stringify(r.json).slice(0, 200)) + return `plain JSON after ${(r.ms / 1000).toFixed(1)}s of silence` + }) +} + // --- summary ------------------------------------------------------------------- const failed = results.filter((r) => !r.ok)