This is page 1 of 4. Use http://codebase.md/calvernaz/alphavantage?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .bumpversion.cfg ├── .github │ ├── FUNDING.yml │ └── workflows │ └── publish.yml ├── .gitignore ├── .python-version ├── CONTRIBUTING.md ├── deploy │ └── aws-stateless-mcp-lambda │ ├── .aws-sam │ │ └── build.toml │ ├── deploy.sh │ ├── lambda_function.py │ ├── README.md │ ├── requirements.txt │ └── template.yaml ├── DEVELOPMENT.md ├── Dockerfile ├── LICENSE ├── pyproject.toml ├── pytest.ini ├── README.md ├── scripts │ └── publish.py ├── smithery.yaml ├── src │ ├── alphavantage_mcp_client │ │ └── client.py │ └── alphavantage_mcp_server │ ├── __init__.py │ ├── api.py │ ├── oauth.py │ ├── prompts.py │ ├── response_utils.py │ ├── server.py │ ├── telemetry_bootstrap.py │ ├── telemetry_instrument.py │ └── tools.py ├── tests │ ├── test_api.py │ ├── test_http_mcp_client.py │ ├── test_http_transport.py │ ├── test_integration.py │ ├── test_stdio_transport.py │ └── test_telemetry.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- ``` 1 | 3.12.7 2 | ``` -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- ``` 1 | [bumpversion] 2 | current_version = 0.3.24 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:pyproject.toml] 7 | search = version = "{current_version}" 8 | replace = version = "{new_version}" 9 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | .idea 2 | aws-chalice 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | .pnpm-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # Snowpack dependency directory (https://snowpack.dev/) 48 | web_modules/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional stylelint cache 60 | .stylelintcache 61 | 62 | # Microbundle cache 63 | .rpt2_cache/ 64 | .rts2_cache_cjs/ 65 | .rts2_cache_es/ 66 | .rts2_cache_umd/ 67 | 68 | # Optional REPL history 69 | .node_repl_history 70 | 71 | # Output of 'npm pack' 72 | *.tgz 73 | 74 | # Yarn Integrity file 75 | .yarn-integrity 76 | 77 | # dotenv environment variable files 78 | .env 79 | .env.development.local 80 | .env.test.local 81 | .env.production.local 82 | .env.local 83 | .env 84 | 85 | # parcel-bundler cache (https://parceljs.org/) 86 | .cache 87 | .parcel-cache 88 | 89 | # Next.js build output 90 | .next 91 | out 92 | 93 | # Nuxt.js build / generate output 94 | .nuxt 95 | dist 96 | 97 | # Gatsby files 98 | .cache/ 99 | # Comment in the public line in if your project uses Gatsby and not Next.js 100 | # https://nextjs.org/blog/next-9-1#public-directory-support 101 | # public 102 | 103 | # vuepress build output 104 | .vuepress/dist 105 | 106 | # vuepress v2.x temp and cache directory 107 | .temp 108 | .cache 109 | 110 | # Docusaurus cache and generated files 111 | .docusaurus 112 | 113 | # Serverless directories 114 | .serverless/ 115 | 116 | # FuseBox cache 117 | .fusebox/ 118 | 119 | # DynamoDB Local files 120 | .dynamodb/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v2 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .yarn/install-state.gz 133 | .pnp.* 134 | 135 | build/ 136 | 137 | gcp-oauth.keys.json 138 | .*-server-credentials.json 139 | 140 | # Byte-compiled / optimized / DLL files 141 | __pycache__/ 142 | *.py[cod] 143 | *$py.class 144 | 145 | # C extensions 146 | *.so 147 | 148 | # Distribution / packaging 149 | .Python 150 | build/ 151 | develop-eggs/ 152 | dist/ 153 | downloads/ 154 | eggs/ 155 | .eggs/ 156 | lib/ 157 | lib64/ 158 | parts/ 159 | sdist/ 160 | var/ 161 | wheels/ 162 | share/python-wheels/ 163 | *.egg-info/ 164 | .installed.cfg 165 | *.egg 166 | MANIFEST 167 | 168 | # PyInstaller 169 | # Usually these files are written by a python script from a template 170 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 171 | *.manifest 172 | *.spec 173 | 174 | # Installer logs 175 | pip-log.txt 176 | pip-delete-this-directory.txt 177 | 178 | # Unit test / coverage reports 179 | htmlcov/ 180 | .tox/ 181 | .nox/ 182 | .coverage 183 | .coverage.* 184 | .cache 185 | nosetests.xml 186 | coverage.xml 187 | *.cover 188 | *.py,cover 189 | .hypothesis/ 190 | .pytest_cache/ 191 | cover/ 192 | 193 | # Translations 194 | *.mo 195 | *.pot 196 | 197 | # Django stuff: 198 | *.log 199 | local_settings.py 200 | db.sqlite3 201 | db.sqlite3-journal 202 | 203 | # Flask stuff: 204 | instance/ 205 | .webassets-cache 206 | 207 | # Scrapy stuff: 208 | .scrapy 209 | 210 | # Sphinx documentation 211 | docs/_build/ 212 | 213 | # PyBuilder 214 | .pybuilder/ 215 | target/ 216 | 217 | # Jupyter Notebook 218 | .ipynb_checkpoints 219 | 220 | # IPython 221 | profile_default/ 222 | ipython_config.py 223 | 224 | # pyenv 225 | # For a library or package, you might want to ignore these files since the code is 226 | # intended to run in multiple environments; otherwise, check them in: 227 | # .python-version 228 | 229 | # pipenv 230 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 231 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 232 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 233 | # install all needed dependencies. 234 | #Pipfile.lock 235 | 236 | # poetry 237 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 238 | # This is especially recommended for binary packages to ensure reproducibility, and is more 239 | # commonly ignored for libraries. 240 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 241 | #poetry.lock 242 | 243 | # pdm 244 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 245 | #pdm.lock 246 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 247 | # in version control. 248 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 249 | .pdm.toml 250 | .pdm-python 251 | .pdm-build/ 252 | 253 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 254 | __pypackages__/ 255 | 256 | # Celery stuff 257 | celerybeat-schedule 258 | celerybeat.pid 259 | 260 | # SageMath parsed files 261 | *.sage.py 262 | 263 | # Environments 264 | .env 265 | .venv 266 | env/ 267 | venv/ 268 | ENV/ 269 | env.bak/ 270 | venv.bak/ 271 | 272 | # Spyder project settings 273 | .spyderproject 274 | .spyproject 275 | 276 | # Rope project settings 277 | .ropeproject 278 | 279 | # mkdocs documentation 280 | /site 281 | 282 | # mypy 283 | .mypy_cache/ 284 | .dmypy.json 285 | dmypy.json 286 | 287 | # Pyre type checker 288 | .pyre/ 289 | 290 | # pytype static type analyzer 291 | .pytype/ 292 | 293 | # Cython debug symbols 294 | cython_debug/ 295 | 296 | .DS_Store 297 | 298 | # PyCharm 299 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 300 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 301 | # and can be added to the global gitignore or merged into this file. For a more nuclear 302 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 303 | #.idea/ 304 | windsurfrules.md 305 | 306 | 307 | deploy/aws-chalice/vendor 308 | deploy/aws-chalice/.chalice ``` -------------------------------------------------------------------------------- /deploy/aws-stateless-mcp-lambda/README.md: -------------------------------------------------------------------------------- ```markdown 1 | # AWS Stateless MCP Lambda Deployment 2 | 3 | This deployment uses the **stateless MCP pattern** from [aws-samples/sample-serverless-mcp-servers](https://github.com/aws-samples/sample-serverless-mcp-servers/tree/main/stateless-mcp-on-lambda-python) to deploy the AlphaVantage MCP Server on AWS Lambda. 4 | 5 | ## 🎯 Why Stateless MCP? 6 | 7 | Unlike our previous attempts with Chalice and Lambda Web Adapter, this approach is specifically designed for **stateless MCP servers** that work perfectly with Lambda's execution model: 8 | 9 | - ✅ **No session state management** - Each request is independent 10 | - ✅ **Perfect for Lambda** - Stateless execution model matches Lambda 11 | - ✅ **Horizontal scaling** - Seamless elasticity and load distribution 12 | - ✅ **AWS-recommended pattern** - Based on official AWS samples 13 | 14 | ## 🏗️ Architecture 15 | 16 | ``` 17 | Internet → API Gateway → Lambda Function → AlphaVantage MCP Server → AlphaVantage API 18 | ``` 19 | 20 | Each Lambda invocation: 21 | 1. Receives MCP JSON-RPC request via API Gateway 22 | 2. Calls appropriate AlphaVantage MCP server function directly 23 | 3. Returns MCP-compliant JSON response 24 | 4. No persistent connections or session state required 25 | 26 | ## 🚀 Quick Start 27 | 28 | ### Prerequisites 29 | 30 | ```bash 31 | # Install AWS CLI 32 | pip install awscli 33 | 34 | # Install AWS SAM CLI 35 | pip install aws-sam-cli 36 | 37 | # Configure AWS credentials 38 | aws configure 39 | ``` 40 | 41 | ### Deploy 42 | 43 | ```bash 44 | # Set your AlphaVantage API key 45 | export ALPHAVANTAGE_API_KEY=your_api_key_here 46 | 47 | # Optional: Enable OAuth 2.1 48 | export OAUTH_ENABLED=true 49 | export OAUTH_AUTHORIZATION_SERVER_URL=https://your-oauth-server.com 50 | 51 | # Deploy 52 | cd deploy/aws-stateless-mcp-lambda 53 | chmod +x deploy.sh 54 | ./deploy.sh 55 | ``` 56 | 57 | ## 🧪 Testing 58 | 59 | After deployment, test with these commands: 60 | 61 | ### 1. Initialize MCP Session 62 | ```bash 63 | curl -X POST 'https://your-api-id.execute-api.region.amazonaws.com/prod/mcp' \ 64 | -H 'Content-Type: application/json' \ 65 | -H 'Accept: application/json' \ 66 | -d '{ 67 | "jsonrpc": "2.0", 68 | "id": 1, 69 | "method": "initialize", 70 | "params": { 71 | "protocolVersion": "2024-11-05", 72 | "capabilities": {}, 73 | "clientInfo": {"name": "test-client", "version": "1.0.0"} 74 | } 75 | }' 76 | ``` 77 | 78 | ### 2. List Available Tools 79 | ```bash 80 | curl -X POST 'https://your-api-id.execute-api.region.amazonaws.com/prod/mcp' \ 81 | -H 'Content-Type: application/json' \ 82 | -H 'Accept: application/json' \ 83 | -d '{ 84 | "jsonrpc": "2.0", 85 | "id": 2, 86 | "method": "tools/list" 87 | }' 88 | ``` 89 | 90 | ### 3. Call a Tool 91 | ```bash 92 | curl -X POST 'https://your-api-id.execute-api.region.amazonaws.com/prod/mcp' \ 93 | -H 'Content-Type: application/json' \ 94 | -H 'Accept: application/json' \ 95 | -d '{ 96 | "jsonrpc": "2.0", 97 | "id": 3, 98 | "method": "tools/call", 99 | "params": { 100 | "name": "stock_quote", 101 | "arguments": {"symbol": "AAPL"} 102 | } 103 | }' 104 | ``` 105 | 106 | ## 🔐 OAuth 2.1 Support 107 | 108 | Enable OAuth authentication by setting environment variables: 109 | 110 | ```bash 111 | export OAUTH_ENABLED=true 112 | export OAUTH_AUTHORIZATION_SERVER_URL=https://your-oauth-server.com 113 | export OAUTH_CLIENT_ID=your_client_id 114 | export OAUTH_CLIENT_SECRET=your_client_secret 115 | ``` 116 | 117 | When OAuth is enabled, include Bearer token in requests: 118 | 119 | ```bash 120 | curl -X POST 'https://your-api-id.execute-api.region.amazonaws.com/prod/mcp' \ 121 | -H 'Content-Type: application/json' \ 122 | -H 'Authorization: Bearer your_access_token' \ 123 | -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' 124 | ``` 125 | 126 | ## 📊 Available Tools 127 | 128 | The AlphaVantage MCP Server provides 50+ financial data tools: 129 | 130 | ### Stock Data 131 | - `get_stock_quote` - Real-time stock quotes 132 | - `get_intraday_data` - Intraday time series 133 | - `get_daily_data` - Daily time series 134 | - `get_weekly_data` - Weekly time series 135 | - `get_monthly_data` - Monthly time series 136 | 137 | ### Technical Indicators 138 | - `get_sma` - Simple Moving Average 139 | - `get_ema` - Exponential Moving Average 140 | - `get_rsi` - Relative Strength Index 141 | - `get_macd` - MACD indicator 142 | - And 30+ more technical indicators 143 | 144 | ### Fundamental Data 145 | - `get_company_overview` - Company fundamentals 146 | - `get_income_statement` - Income statements 147 | - `get_balance_sheet` - Balance sheets 148 | - `get_cash_flow` - Cash flow statements 149 | 150 | ### Economic Data 151 | - `get_gdp` - GDP data 152 | - `get_inflation` - Inflation rates 153 | - `get_unemployment` - Unemployment rates 154 | - And more economic indicators 155 | 156 | ## 🔍 Monitoring 157 | 158 | ### CloudWatch Logs 159 | ```bash 160 | # Follow Lambda logs 161 | aws logs tail /aws/lambda/alphavantage-stateless-mcp-alphavantage-mcp --follow 162 | 163 | # Get function metrics 164 | aws lambda get-function --function-name alphavantage-stateless-mcp-alphavantage-mcp 165 | ``` 166 | 167 | ### API Gateway Metrics 168 | - Monitor request count, latency, and errors in CloudWatch 169 | - Set up alarms for high error rates or latency 170 | 171 | ## 🛠️ Troubleshooting 172 | 173 | ### Common Issues 174 | 175 | **1. Import Errors** 176 | ``` 177 | ModuleNotFoundError: No module named 'alphavantage_mcp_server' 178 | ``` 179 | - **Solution**: Ensure the Lambda layer is properly built with source code 180 | 181 | **2. API Key Errors** 182 | ``` 183 | {"error": "API key required"} 184 | ``` 185 | - **Solution**: Verify `ALPHAVANTAGE_API_KEY` environment variable is set 186 | 187 | **3. Tool Not Found** 188 | ``` 189 | {"error": {"code": -32601, "message": "Method not found"}} 190 | ``` 191 | - **Solution**: Check tool name spelling and availability with `tools/list` 192 | 193 | ### Debug Mode 194 | 195 | Enable debug logging by setting environment variable: 196 | ```bash 197 | export DEBUG=true 198 | ``` 199 | 200 | ## 💰 Cost Estimation 201 | 202 | ### Lambda Costs 203 | - **Requests**: $0.20 per 1M requests 204 | - **Duration**: $0.0000166667 per GB-second 205 | - **Example**: 10,000 requests/month ≈ $2-5/month 206 | 207 | ### API Gateway Costs 208 | - **REST API**: $3.50 per million API calls 209 | - **Data transfer**: $0.09 per GB 210 | 211 | ### Total Estimated Cost 212 | - **Light usage** (1K requests/month): ~$1/month 213 | - **Moderate usage** (10K requests/month): ~$5/month 214 | - **Heavy usage** (100K requests/month): ~$40/month 215 | 216 | ## 🧹 Cleanup 217 | 218 | Remove all AWS resources: 219 | 220 | ```bash 221 | aws cloudformation delete-stack --stack-name alphavantage-stateless-mcp 222 | ``` 223 | 224 | 225 | ## 📚 References 226 | 227 | - [AWS Sample Serverless MCP Servers](https://github.com/aws-samples/sample-serverless-mcp-servers) 228 | - [MCP Specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports) 229 | - [AlphaVantage API Documentation](https://www.alphavantage.co/documentation/) 230 | - [AWS Lambda Documentation](https://docs.aws.amazon.com/lambda/) 231 | 232 | ## 🤝 Contributing 233 | 234 | This deployment is based on the official AWS sample pattern. For improvements: 235 | 236 | 1. Test changes locally with SAM 237 | 2. Update the Lambda function code 238 | 3. Redeploy with `./deploy.sh` 239 | 4. Verify with test commands 240 | 241 | ## 📄 License 242 | 243 | This deployment follows the same MIT-0 license as the AWS sample repository. 244 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # ✅ Official Alpha Vantage MCP Server 2 | 3 | [](https://smithery.ai/server/@calvernaz/alphavantage) 4 | [](https://mseep.ai/app/b76d0966-edd1-46fd-9cfb-b29a6d8cb563) 5 | 6 | A MCP server for the stock market data API, Alphavantage API. 7 | 8 | **MCP Server URL**: https://mcp.alphavantage.co 9 | 10 | **PyPi**: https://pypi.org/project/alphavantage-mcp/ 11 | 12 | ## Configuration 13 | 14 | ### Getting an API Key 15 | 1. Sign up for a [Free Alphavantage API key](https://www.alphavantage.co/support/#api-key) 16 | 2. Add the API key to your environment variables as `ALPHAVANTAGE_API_KEY` 17 | 18 | 19 | ## Installation 20 | 21 | ### Option 1: Using uvx (Recommended) 22 | 23 | The easiest way to use the AlphaVantage MCP server is with `uvx`: 24 | 25 | ```bash 26 | # Run directly without installation 27 | uvx alphavantage-mcp 28 | 29 | # Or with specific arguments 30 | uvx alphavantage-mcp --server http --port 8080 31 | ``` 32 | 33 | ### Option 2: Using pip 34 | 35 | ```bash 36 | pip install alphavantage-mcp 37 | alphavantage-mcp 38 | ``` 39 | 40 | ### Option 3: From source 41 | 42 | ```bash 43 | git clone https://github.com/calvernaz/alphavantage.git 44 | cd alphavantage 45 | uv run alphavantage 46 | ``` 47 | 48 | ## Server Modes 49 | 50 | The AlphaVantage server can run in two different modes: 51 | 52 | ### Stdio Server (Default) 53 | This is the standard MCP server mode used for tools like Claude Desktop. 54 | 55 | ```bash 56 | alphavantage 57 | # or explicitly: 58 | alphavantage --server stdio 59 | ``` 60 | 61 | ### Streamable HTTP Server 62 | This mode provides real-time updates via HTTP streaming. 63 | 64 | ```bash 65 | alphavantage --server http --port 8080 66 | ``` 67 | 68 | ### Streamable HTTP Server with OAuth 2.1 Authentication 69 | This mode adds OAuth 2.1 authentication to the HTTP server, following the MCP specification for secure access. 70 | 71 | ```bash 72 | alphavantage --server http --port 8080 --oauth 73 | ``` 74 | 75 | #### OAuth Configuration 76 | 77 | When using the `--oauth` flag, the server requires OAuth 2.1 configuration via environment variables: 78 | 79 | **Required Environment Variables:** 80 | ```bash 81 | export OAUTH_AUTHORIZATION_SERVER_URL="https://your-auth-server.com/realms/your-realm" 82 | export OAUTH_RESOURCE_SERVER_URI="https://your-mcp-server.com" 83 | ``` 84 | 85 | **Optional Environment Variables:** 86 | ```bash 87 | # Token validation method (default: jwt) 88 | export OAUTH_TOKEN_VALIDATION_METHOD="jwt" # or "introspection" 89 | 90 | # For JWT validation 91 | export OAUTH_JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" 92 | export OAUTH_JWT_ALGORITHM="RS256" # default 93 | 94 | # For token introspection validation 95 | export OAUTH_INTROSPECTION_ENDPOINT="https://your-auth-server.com/realms/your-realm/protocol/openid-connect/token/introspect" 96 | export OAUTH_INTROSPECTION_CLIENT_ID="your-client-id" 97 | export OAUTH_INTROSPECTION_CLIENT_SECRET="your-client-secret" 98 | 99 | # Optional: Required scopes (space-separated) 100 | export OAUTH_REQUIRED_SCOPES="mcp:access mcp:read" 101 | 102 | # Optional: Enable session binding for additional security (default: true) 103 | export OAUTH_SESSION_BINDING_ENABLED="true" 104 | ``` 105 | 106 | #### OAuth Features 107 | 108 | The OAuth implementation provides: 109 | 110 | - **OAuth 2.0 Protected Resource Metadata** endpoint (`/.well-known/oauth-protected-resource`) 111 | - **Bearer token authentication** for all MCP requests 112 | - **JWT and Token Introspection** validation methods 113 | - **MCP Security Best Practices** compliance: 114 | - Token audience validation (prevents token passthrough attacks) 115 | - Session hijacking prevention with secure session IDs 116 | - User-bound sessions for additional security 117 | - Proper WWW-Authenticate headers for 401 responses 118 | 119 | #### Example: Keycloak Configuration 120 | 121 | For testing with Keycloak: 122 | 123 | ```bash 124 | # Keycloak OAuth configuration 125 | export OAUTH_AUTHORIZATION_SERVER_URL="https://keycloak.example.com/realms/mcp-realm" 126 | export OAUTH_RESOURCE_SERVER_URI="https://mcp.example.com" 127 | export OAUTH_TOKEN_VALIDATION_METHOD="introspection" 128 | export OAUTH_INTROSPECTION_ENDPOINT="https://keycloak.example.com/realms/mcp-realm/protocol/openid-connect/token/introspect" 129 | export OAUTH_INTROSPECTION_CLIENT_ID="mcp-server" 130 | export OAUTH_INTROSPECTION_CLIENT_SECRET="your-keycloak-client-secret" 131 | export OAUTH_REQUIRED_SCOPES="mcp:access" 132 | 133 | # Start server with OAuth 134 | alphavantage --server http --port 8080 --oauth 135 | ``` 136 | 137 | #### OAuth Client Flow 138 | 139 | When OAuth is enabled, MCP clients must: 140 | 141 | 1. **Discover** the authorization server via `GET /.well-known/oauth-protected-resource` 142 | 2. **Register** with the authorization server (if using Dynamic Client Registration) 143 | 3. **Obtain access tokens** from the authorization server 144 | 4. **Include tokens** in requests: `Authorization: Bearer <access-token>` 145 | 5. **Handle 401/403 responses** and refresh tokens as needed 146 | 147 | Options: 148 | - `--server`: Choose between `stdio` (default) or `http` server mode 149 | - `--port`: Specify the port for the Streamable HTTP server (default: 8080) 150 | - `--oauth`: Enable OAuth 2.1 authentication (requires `--server http`) 151 | 152 | ## 📊 Telemetry 153 | 154 | The AlphaVantage MCP server includes optional Prometheus metrics for monitoring and observability. 155 | 156 | ### Enabling Telemetry 157 | 158 | Set the following environment variables to enable telemetry: 159 | 160 | ```bash 161 | # Enable telemetry (default: true) 162 | export MCP_TELEMETRY_ENABLED=true 163 | 164 | # Server identification (optional) 165 | export MCP_SERVER_NAME=alphavantage 166 | export MCP_SERVER_VERSION=1.0.0 167 | 168 | # Metrics server port (default: 9464) 169 | export MCP_METRICS_PORT=9464 170 | ``` 171 | 172 | ### Metrics Endpoint 173 | 174 | When telemetry is enabled, Prometheus metrics are available at: 175 | 176 | ``` 177 | http://localhost:9464/metrics 178 | ``` 179 | 180 | ### Available Metrics 181 | 182 | The server collects the following metrics for each tool call: 183 | 184 | - **`mcp_tool_calls_total`** - Total number of tool calls (labeled by tool and outcome) 185 | - **`mcp_tool_latency_seconds`** - Tool execution latency histogram 186 | - **`mcp_tool_request_bytes`** - Request payload size histogram 187 | - **`mcp_tool_response_bytes`** - Response payload size histogram 188 | - **`mcp_tool_active_concurrency`** - Active concurrent tool calls gauge 189 | - **`mcp_tool_errors_total`** - Total errors by type (timeout, bad_input, connection, unknown) 190 | 191 | ### Example Usage with Telemetry 192 | 193 | ```bash 194 | # Start server with telemetry enabled 195 | export MCP_TELEMETRY_ENABLED=true 196 | export MCP_SERVER_NAME=alphavantage-prod 197 | export ALPHAVANTAGE_API_KEY=your_api_key 198 | alphavantage --server http --port 8080 199 | 200 | # View metrics 201 | curl http://localhost:9464/metrics 202 | ``` 203 | 204 | ## 🚀 AWS Serverless Deployment 205 | 206 | Deploy the AlphaVantage MCP Server on AWS Lambda using the stateless MCP pattern for production-ready, scalable deployment. 207 | 208 | ### Quick AWS Deployment 209 | 210 | ```bash 211 | cd deploy/aws-stateless-mcp-lambda 212 | export ALPHAVANTAGE_API_KEY=your_api_key_here 213 | ./deploy.sh 214 | ``` 215 | 216 | **Features:** 217 | - ✅ **Stateless MCP pattern** - Perfect for Lambda's execution model 218 | - ✅ **Auto-scaling** - Handles any load with AWS Lambda + API Gateway 219 | - ✅ **Cost-effective** - Pay only for requests (~$1-5/month for typical usage) 220 | - ✅ **Production-ready** - Based on AWS official sample patterns 221 | - ✅ **OAuth 2.1 support** - Optional authentication for secure access 222 | 223 | **📖 Full Documentation:** See [AWS Deployment Guide](deploy/aws-stateless-mcp-lambda/README.md) for complete setup instructions, testing, monitoring, and troubleshooting. 224 | 225 | ### Usage with Claude Desktop 226 | 227 | #### Option 1: Using uvx (Recommended) 228 | Add this to your `claude_desktop_config.json`: 229 | 230 | ```json 231 | { 232 | "mcpServers": { 233 | "alphavantage": { 234 | "command": "uvx", 235 | "args": ["alphavantage-mcp"], 236 | "env": { 237 | "ALPHAVANTAGE_API_KEY": "YOUR_API_KEY_HERE" 238 | } 239 | } 240 | } 241 | } 242 | ``` 243 | 244 | #### Option 2: From source 245 | If you cloned the repository, use this configuration: 246 | 247 | ```json 248 | { 249 | "mcpServers": { 250 | "alphavantage": { 251 | "command": "uv", 252 | "args": [ 253 | "--directory", 254 | "<DIRECTORY-OF-CLONED-PROJECT>/alphavantage", 255 | "run", 256 | "alphavantage" 257 | ], 258 | "env": { 259 | "ALPHAVANTAGE_API_KEY": "YOUR_API_KEY_HERE" 260 | } 261 | } 262 | } 263 | } 264 | ``` 265 | #### Running the Server in Streamable HTTP Mode 266 | 267 | **Using uvx:** 268 | ```json 269 | { 270 | "mcpServers": { 271 | "alphavantage": { 272 | "command": "uvx", 273 | "args": ["alphavantage-mcp", "--server", "http", "--port", "8080"], 274 | "env": { 275 | "ALPHAVANTAGE_API_KEY": "YOUR_API_KEY_HERE" 276 | } 277 | } 278 | } 279 | } 280 | ``` 281 | 282 | **From source:** 283 | ```json 284 | { 285 | "mcpServers": { 286 | "alphavantage": { 287 | "command": "uv", 288 | "args": [ 289 | "--directory", 290 | "<DIRECTORY-OF-CLONED-PROJECT>/alphavantage", 291 | "run", 292 | "alphavantage", 293 | "--server", 294 | "http", 295 | "--port", 296 | "8080" 297 | ], 298 | "env": { 299 | "ALPHAVANTAGE_API_KEY": "YOUR_API_KEY_HERE" 300 | } 301 | } 302 | } 303 | } 304 | ``` 305 | 306 | 307 | ## 📺 Demo Video 308 | 309 | Watch a quick demonstration of the Alpha Vantage MCP Server in action: 310 | 311 | [](https://github.com/user-attachments/assets/bc9ecffb-eab6-4a4d-bbf6-9fc8178f15c3) 312 | 313 | 314 | ## 🔧 Development & Publishing 315 | 316 | ### Publishing to PyPI 317 | 318 | This project includes scripts for publishing to PyPI and TestPyPI: 319 | 320 | ```bash 321 | # Publish to TestPyPI (for testing) 322 | python scripts/publish.py --test 323 | 324 | # Publish to PyPI (production) 325 | python scripts/publish.py 326 | 327 | # Use uv publish instead of twine 328 | python scripts/publish.py --test --use-uv 329 | ``` 330 | 331 | The script uses `twine` by default (recommended) but can also use `uv publish` with the `--use-uv` flag. 332 | 333 | ### GitHub Actions 334 | 335 | The repository includes a GitHub Actions workflow for automated publishing: 336 | 337 | - **Trusted Publishing**: Uses PyPA's official publish action with OpenID Connect 338 | - **Manual Trigger**: Can be triggered manually with options for TestPyPI vs PyPI 339 | - **Twine Fallback**: Supports both trusted publishing and twine-based publishing 340 | 341 | To set up publishing: 342 | 343 | 1. **For Trusted Publishing** (recommended): 344 | - Configure trusted publishing on PyPI/TestPyPI with your GitHub repository 345 | - No secrets needed - uses OpenID Connect 346 | 347 | 2. **For Token-based Publishing**: 348 | - Add `PYPI_API_TOKEN` and `TEST_PYPI_API_TOKEN` secrets to your repository 349 | - Use the "Use twine" option in the workflow dispatch 350 | 351 | ## 🤝 Contributing 352 | 353 | We welcome contributions from the community! To get started, check out our [contribution](CONTRIBUTING.md) guide for setup instructions, 354 | development tips, and guidelines. 355 | ``` -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- ```markdown 1 | # Contributing to AlphaVantage MCP Server 2 | 3 | Thanks for your interest in contributing! 🎉 4 | This project is the official [MCP (Model Context Protocol)](https://github.com/modelcontextprotocol/servers) server for [Alpha Vantage](https://www.alphavantage.co). Here's how to get started with local development and testing. 5 | 6 | --- 7 | 8 | ## 🚀 Getting Started 9 | 10 | ### 1. Clone the repo 11 | 12 | ```bash 13 | git clone https://github.com/calvernaz/alphavantage.git 14 | cd alphavantage 15 | ``` 16 | ## Set up your environment 17 | 18 | You'll need an [Alpha Vantage API key](https://www.alphavantage.co/support/#api-key). 19 | 20 | Create a .env file in the project root: 21 | 22 | ```bash 23 | touch .env 24 | ``` 25 | 26 | Add your API key to the .env file: 27 | 28 | ```bash 29 | ALPHAVANTAGE_API_KEY=your_api_key_here 30 | ``` 31 | 32 | Alternatively, you can export it directly in your terminal: 33 | ```bash 34 | export ALPHAVANTAGE_API_KEY=your_api_key_here 35 | ``` 36 | 37 | ## 🧪 Running Locally with Inspector 38 | 39 | Use the MCP Inspector to run and test your server locally with hot reload. 40 | 41 | ```bash 42 | npm install -g @modelcontextprotocol/inspector 43 | ``` 44 | 45 | Then, run the server: 46 | 47 | ```bash 48 | npx @modelcontextprotocol/inspector uv --directory ~/alphavantage run alphavantage 49 | ``` 50 | > Replace ~/code/alphavantage with your actual path to this repo. 51 | 52 | ``` -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- ```yaml 1 | github: [calvernaz] 2 | ``` -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- ``` 1 | [pytest] 2 | asyncio_mode=auto 3 | asyncio_default_fixture_loop_scope="function" ``` -------------------------------------------------------------------------------- /deploy/aws-stateless-mcp-lambda/requirements.txt: -------------------------------------------------------------------------------- ``` 1 | # Core MCP dependencies 2 | mcp>=1.0.0 3 | 4 | # AlphaVantage MCP server dependencies 5 | requests>=2.31.0 6 | aiohttp>=3.9.0 7 | pydantic>=2.5.0 8 | toml>=0.10.2 9 | uvicorn>=0.24.0 10 | starlette>=0.27.0 11 | 12 | # OAuth 2.1 support 13 | PyJWT>=2.8.0 14 | httpx>=0.25.0 15 | cryptography>=41.0.0 16 | 17 | # AWS Lambda runtime 18 | boto3>=1.34.0 19 | ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: http 5 | configSchema: 6 | # JSON Schema defining the configuration options for the MCP. 7 | type: object 8 | required: 9 | - alphavantageApiKey 10 | properties: 11 | alphavantageApiKey: 12 | type: string 13 | description: The API key for the Alphavantage server. 14 | commandFunction: 15 | # A function that produces the CLI command to start the MCP on stdio. 16 | |- 17 | config => ({ command: 'alphavantage', env: { ALPHAVANTAGE_API_KEY: config.alphavantageApiKey } }) 18 | ``` -------------------------------------------------------------------------------- /deploy/aws-stateless-mcp-lambda/.aws-sam/build.toml: -------------------------------------------------------------------------------- ```toml 1 | # This file is auto generated by SAM CLI build command 2 | 3 | [function_build_definitions.f642707c-4652-4f92-8ce7-4478f43aac20] 4 | codeuri = "/Users/medusa/code/alphavantage/deploy/aws-stateless-mcp-lambda" 5 | runtime = "python3.12" 6 | architecture = "x86_64" 7 | handler = "lambda_function.lambda_handler" 8 | manifest_hash = "" 9 | packagetype = "Zip" 10 | functions = ["AlphaVantageMCPFunction"] 11 | 12 | [layer_build_definitions.02ea97b0-d24e-4cf4-b3e6-7682870a9300] 13 | layer_name = "AlphaVantageMCPLayer" 14 | codeuri = "/Users/medusa/code/alphavantage/src" 15 | build_method = "python3.12" 16 | compatible_runtimes = ["python3.12"] 17 | architecture = "x86_64" 18 | manifest_hash = "" 19 | layer = "AlphaVantageMCPLayer" 20 | ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | # Use a Python image with uv pre-installed 2 | FROM ghcr.io/astral-sh/uv:python3.12-alpine 3 | 4 | # Install the project into `/app` 5 | WORKDIR /app 6 | 7 | # Enable bytecode compilation 8 | ENV UV_COMPILE_BYTECODE=1 9 | 10 | # Copy from the cache instead of linking since it's a mounted volume 11 | ENV UV_LINK_MODE=copy 12 | 13 | # Install the project's dependencies using the lockfile and settings 14 | RUN --mount=type=cache,target=/root/.cache/uv \ 15 | --mount=type=bind,source=uv.lock,target=uv.lock \ 16 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ 17 | uv sync --locked --no-install-project --no-dev --python /usr/local/bin/python3 18 | 19 | # Then, add the rest of the project source code and install it 20 | # Installing separately from its dependencies allows optimal layer caching 21 | COPY . /app 22 | 23 | # Remove any existing virtual environment that might have been copied from host 24 | RUN rm -rf .venv 25 | 26 | RUN --mount=type=cache,target=/root/.cache/uv \ 27 | uv sync --locked --no-dev --python /usr/local/bin/python3 28 | 29 | # Place executables in the environment at the front of the path 30 | ENV PATH="/app/.venv/bin:$PATH" 31 | 32 | # Set transport mode to HTTP and port to 8080 as required by Smithery proxy 33 | ENV TRANSPORT=http 34 | ENV PORT=8080 35 | EXPOSE 8080 36 | 37 | # Reset the entrypoint, don't invoke `uv` 38 | ENTRYPOINT [] 39 | 40 | # Run the application directly using the venv Python 41 | CMD ["alphavantage"] ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml 1 | [project] 2 | name = "alphavantage-mcp" 3 | version = "0.3.24" 4 | description = "AlphaVantage MCP server - Financial data tools for Model Context Protocol" 5 | readme = "README.md" 6 | requires-python = ">=3.12" 7 | keywords = ["mcp", "alphavantage", "financial", "stocks", "api", "server"] 8 | license = {text = "MIT"} 9 | classifiers = [ 10 | "Development Status :: 4 - Beta", 11 | "Intended Audience :: Developers", 12 | "License :: OSI Approved :: MIT License", 13 | "Programming Language :: Python :: 3", 14 | "Programming Language :: Python :: 3.12", 15 | "Topic :: Office/Business :: Financial", 16 | "Topic :: Software Development :: Libraries :: Python Modules", 17 | ] 18 | dependencies = [ 19 | "mcp>=1.9.4", 20 | "httpx>=0.28.1", 21 | "starlette>=0.47.2", 22 | "uvicorn>=0.32.1", 23 | "PyJWT>=2.10.1", 24 | "prometheus-client>=0.20.0", 25 | "toml>=0.10.2", 26 | "packaging>=21.0", 27 | ] 28 | [[project.authors]] 29 | name = "Cesar Alvernaz" 30 | email = "[email protected]" 31 | 32 | [project.urls] 33 | Homepage = "https://github.com/calvernaz/alphavantage" 34 | Repository = "https://github.com/calvernaz/alphavantage" 35 | Issues = "https://github.com/calvernaz/alphavantage/issues" 36 | Documentation = "https://github.com/calvernaz/alphavantage#readme" 37 | 38 | 39 | [build-system] 40 | requires = [ "hatchling",] 41 | build-backend = "hatchling.build" 42 | 43 | [tool.hatch.build.targets.wheel] 44 | packages = ["src/alphavantage_mcp_server"] 45 | 46 | [dependency-groups] 47 | dev = [ 48 | "pytest>=8.4.1", 49 | "pytest-asyncio>=0.24.0", 50 | "ruff>=0.9.9", 51 | "build>=1.0.0", 52 | "twine>=4.0.0", 53 | ] 54 | 55 | [project.scripts] 56 | alphavantage-mcp = "alphavantage_mcp_server:main" 57 | ``` -------------------------------------------------------------------------------- /src/alphavantage_mcp_server/__init__.py: -------------------------------------------------------------------------------- ```python 1 | import asyncio 2 | import argparse 3 | import os 4 | from . import server 5 | 6 | 7 | def main(): 8 | """Main entry point for the package.""" 9 | parser = argparse.ArgumentParser(description="AlphaVantage MCP Server") 10 | parser.add_argument( 11 | "--server", 12 | type=str, 13 | choices=["stdio", "http"], 14 | help="Server type: stdio or http (default: stdio, or from TRANSPORT env var)", 15 | ) 16 | parser.add_argument( 17 | "--port", 18 | type=int, 19 | help="Port for HTTP server (default: 8080, or from PORT env var)", 20 | ) 21 | parser.add_argument( 22 | "--oauth", 23 | action="store_true", 24 | help="Enable OAuth 2.1 authentication for HTTP server (requires --server http)", 25 | ) 26 | 27 | args = parser.parse_args() 28 | 29 | # Determine server type: command line arg takes precedence, then env var, then default to stdio 30 | server_type = args.server 31 | if server_type is None: 32 | transport_env = os.getenv("TRANSPORT", "").lower() 33 | if transport_env == "http": 34 | server_type = "http" 35 | else: 36 | server_type = "stdio" 37 | 38 | # Determine port: command line arg takes precedence, then env var, then default to 8080 39 | port = args.port 40 | if port is None: 41 | try: 42 | port = int(os.getenv("PORT", "8080")) 43 | except ValueError: 44 | port = 8080 45 | 46 | # Validate OAuth flag usage 47 | if args.oauth and server_type != "http": 48 | parser.error( 49 | "--oauth flag can only be used with --server http or TRANSPORT=http" 50 | ) 51 | 52 | # Use the patched server.main function directly 53 | asyncio.run( 54 | server.main(server_type=server_type, port=port, oauth_enabled=args.oauth) 55 | ) 56 | 57 | 58 | if __name__ == "__main__": 59 | main() 60 | 61 | 62 | __all__ = ["main", "server"] 63 | ``` -------------------------------------------------------------------------------- /tests/test_telemetry.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Unit tests for telemetry modules. 3 | """ 4 | 5 | import sys 6 | from unittest.mock import patch, MagicMock 7 | 8 | import pytest 9 | 10 | # Mock the external dependencies 11 | sys.modules["prometheus_client"] = MagicMock() 12 | 13 | 14 | class TestTelemetryInstrumentation: 15 | """Test telemetry instrumentation functionality.""" 16 | 17 | def test_instrument_tool_decorator_disabled(self): 18 | """Test that decorator does nothing when telemetry is disabled.""" 19 | with patch( 20 | "src.alphavantage_mcp_server.telemetry_instrument.is_telemetry_enabled", 21 | return_value=False, 22 | ): 23 | from src.alphavantage_mcp_server.telemetry_instrument import instrument_tool 24 | 25 | @instrument_tool("test_tool") 26 | def test_function(x, y): 27 | return x + y 28 | 29 | result = test_function(2, 3) 30 | assert result == 5 31 | 32 | def test_error_classification(self): 33 | """Test error classification logic.""" 34 | from src.alphavantage_mcp_server.telemetry_instrument import _classify_error 35 | 36 | assert _classify_error(TimeoutError()) == "timeout" 37 | assert _classify_error(ValueError()) == "bad_input" 38 | assert _classify_error(TypeError()) == "bad_input" 39 | assert _classify_error(KeyError()) == "bad_input" 40 | assert _classify_error(ConnectionError()) == "connection" 41 | assert _classify_error(RuntimeError()) == "unknown" 42 | 43 | def test_size_calculation(self): 44 | """Test size calculation function.""" 45 | from src.alphavantage_mcp_server.telemetry_instrument import _get_size_bytes 46 | 47 | assert _get_size_bytes("hello") == 5 48 | assert _get_size_bytes(b"hello") == 5 49 | assert _get_size_bytes(None) == 0 50 | assert _get_size_bytes(123) > 0 51 | 52 | 53 | if __name__ == "__main__": 54 | pytest.main([__file__]) 55 | ``` -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- ```yaml 1 | name: Publish to PyPI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | inputs: 8 | test_pypi: 9 | description: 'Publish to TestPyPI instead of PyPI' 10 | required: false 11 | default: false 12 | type: boolean 13 | use_twine: 14 | description: 'Use twine instead of trusted publishing' 15 | required: false 16 | default: false 17 | type: boolean 18 | 19 | jobs: 20 | publish: 21 | runs-on: ubuntu-latest 22 | environment: 23 | name: ${{ github.event.inputs.test_pypi == 'true' && 'testpypi' || 'pypi' }} 24 | permissions: 25 | id-token: write # For trusted publishing 26 | contents: read 27 | 28 | steps: 29 | - uses: actions/checkout@v4 30 | 31 | - name: Install uv 32 | uses: astral-sh/setup-uv@v4 33 | with: 34 | version: "latest" 35 | 36 | - name: Set up Python 37 | run: uv python install 3.12 38 | 39 | - name: Install dependencies 40 | run: uv sync --dev 41 | 42 | - name: Build package 43 | run: uv build 44 | 45 | - name: Publish to TestPyPI (trusted publishing) 46 | if: github.event.inputs.test_pypi == 'true' && github.event.inputs.use_twine != 'true' 47 | uses: pypa/gh-action-pypi-publish@release/v1 48 | with: 49 | repository-url: https://test.pypi.org/legacy/ 50 | 51 | - name: Publish to PyPI (trusted publishing) 52 | if: github.event.inputs.test_pypi != 'true' && github.event.inputs.use_twine != 'true' 53 | uses: pypa/gh-action-pypi-publish@release/v1 54 | 55 | - name: Publish to TestPyPI (twine) 56 | if: github.event.inputs.test_pypi == 'true' && github.event.inputs.use_twine == 'true' 57 | env: 58 | TWINE_USERNAME: __token__ 59 | TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }} 60 | run: uv run twine upload --repository testpypi dist/* 61 | 62 | - name: Publish to PyPI (twine) 63 | if: github.event.inputs.test_pypi != 'true' && github.event.inputs.use_twine == 'true' 64 | env: 65 | TWINE_USERNAME: __token__ 66 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 67 | run: uv run twine upload dist/* 68 | ``` -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- ```markdown 1 | # Run the server 2 | 3 | Set the environment variable `ALPHAVANTAGE_API_KEY` to your Alphavantage API key. 4 | 5 | ```bash 6 | uv --directory ~/code/alphavantage run alphavantage 7 | ``` 8 | 9 | ### Response Limiting Utilities 10 | 11 | #### 1. Modify API Functions 12 | Add `max_data_points` parameter to technical indicator functions: 13 | 14 | ```python 15 | async def fetch_sma( 16 | symbol: str, 17 | interval: str = None, 18 | month: str = None, 19 | time_period: int = None, 20 | series_type: str = None, 21 | datatype: str = "json", 22 | max_data_points: int = 100, # NEW PARAMETER 23 | ) -> dict[str, str] | str: 24 | ``` 25 | 26 | #### 2. Apply Response Limiting Logic 27 | ```python 28 | # In fetch_sma and other technical indicator functions 29 | if datatype == "csv": 30 | return response.text 31 | 32 | # For JSON responses, apply response limiting 33 | full_response = response.json() 34 | 35 | from .response_utils import limit_time_series_response, should_limit_response 36 | 37 | if should_limit_response(full_response): 38 | return limit_time_series_response(full_response, max_data_points) 39 | 40 | return full_response 41 | ``` 42 | 43 | #### 3. Update Tool Definitions 44 | Add `max_data_points` parameter to tool schemas: 45 | 46 | ```python 47 | types.Tool( 48 | name=AlphavantageTools.SMA.value, 49 | description="Fetch simple moving average", 50 | inputSchema={ 51 | "type": "object", 52 | "properties": { 53 | "symbol": {"type": "string"}, 54 | "interval": {"type": "string"}, 55 | "month": {"type": "string"}, 56 | "time_period": {"type": "number"}, 57 | "series_type": {"type": "string"}, 58 | "datatype": {"type": "string"}, 59 | "max_data_points": { 60 | "type": "number", 61 | "description": "Maximum number of data points to return (default: 100)", 62 | "default": 100 63 | }, 64 | }, 65 | "required": ["symbol", "interval", "time_period", "series_type"], 66 | }, 67 | ), 68 | ``` 69 | 70 | #### 4. Update Tool Handlers 71 | Pass `max_data_points` parameter to API functions: 72 | 73 | ```python 74 | case AlphavantageTools.SMA.value: 75 | symbol = arguments.get("symbol") 76 | interval = arguments.get("interval") 77 | month = arguments.get("month") 78 | time_period = arguments.get("time_period") 79 | series_type = arguments.get("series_type") 80 | datatype = arguments.get("datatype", "json") 81 | max_data_points = arguments.get("max_data_points", 100) # NEW 82 | 83 | if not symbol or not interval or not time_period or not series_type: 84 | raise ValueError( 85 | "Missing required arguments: symbol, interval, time_period, series_type" 86 | ) 87 | 88 | result = await fetch_sma( 89 | symbol, interval, month, time_period, series_type, datatype, max_data_points 90 | ) 91 | ``` 92 | 93 | # Format 94 | 95 | ```bash 96 | ruff check src/alphavantage_mcp_server/ --fix 97 | ``` 98 | 99 | # Run Tests 100 | 101 | ```bash 102 | pytest tests/*.py 103 | ``` 104 | 105 | # Versioning 106 | 107 | ```bash 108 | bumpversion patch 109 | ``` ``` -------------------------------------------------------------------------------- /deploy/aws-stateless-mcp-lambda/template.yaml: -------------------------------------------------------------------------------- ```yaml 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: > 4 | Stateless AlphaVantage MCP Server on AWS Lambda 5 | Based on aws-samples/sample-serverless-mcp-servers pattern 6 | 7 | Parameters: 8 | AlphaVantageApiKey: 9 | Type: String 10 | Description: AlphaVantage API Key 11 | NoEcho: true 12 | 13 | OAuthEnabled: 14 | Type: String 15 | Default: 'false' 16 | AllowedValues: ['true', 'false'] 17 | Description: Enable OAuth 2.1 authentication 18 | 19 | OAuthAuthorizationServerUrl: 20 | Type: String 21 | Default: '' 22 | Description: OAuth Authorization Server URL (optional) 23 | 24 | Globals: 25 | Function: 26 | Timeout: 30 27 | MemorySize: 512 28 | Runtime: python3.12 29 | Environment: 30 | Variables: 31 | ALPHAVANTAGE_API_KEY: !Ref AlphaVantageApiKey 32 | OAUTH_ENABLED: !Ref OAuthEnabled 33 | OAUTH_AUTHORIZATION_SERVER_URL: !Ref OAuthAuthorizationServerUrl 34 | 35 | Resources: 36 | AlphaVantageMCPFunction: 37 | Type: AWS::Serverless::Function 38 | Properties: 39 | FunctionName: !Sub "${AWS::StackName}-alphavantage-mcp" 40 | CodeUri: . 41 | Handler: lambda_function.lambda_handler 42 | Description: Stateless AlphaVantage MCP Server 43 | Layers: 44 | - !Ref AlphaVantageMCPLayer 45 | Events: 46 | McpApiPost: 47 | Type: Api 48 | Properties: 49 | Path: /mcp 50 | Method: post 51 | McpApiOptions: 52 | Type: Api 53 | Properties: 54 | Path: /mcp 55 | Method: options 56 | Environment: 57 | Variables: 58 | PYTHONPATH: /var/task:/opt/python 59 | 60 | # Lambda Layer for AlphaVantage MCP Server source code 61 | AlphaVantageMCPLayer: 62 | Type: AWS::Serverless::LayerVersion 63 | Properties: 64 | LayerName: !Sub "${AWS::StackName}-alphavantage-mcp-layer" 65 | Description: AlphaVantage MCP Server source code 66 | ContentUri: ../../src/ 67 | CompatibleRuntimes: 68 | - python3.12 69 | Metadata: 70 | BuildMethod: python3.12 71 | 72 | Outputs: 73 | McpApiUrl: 74 | Description: "API Gateway endpoint URL for MCP requests" 75 | Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/mcp" 76 | Export: 77 | Name: !Sub "${AWS::StackName}-McpApiUrl" 78 | 79 | FunctionName: 80 | Description: "Lambda Function Name" 81 | Value: !Ref AlphaVantageMCPFunction 82 | Export: 83 | Name: !Sub "${AWS::StackName}-FunctionName" 84 | 85 | TestCommands: 86 | Description: "Test commands for the deployed MCP server" 87 | Value: !Sub | 88 | # Initialize MCP session 89 | curl -X POST https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/mcp \ 90 | -H 'Content-Type: application/json' \ 91 | -H 'Accept: application/json' \ 92 | -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}' 93 | 94 | # List available tools 95 | curl -X POST https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/mcp \ 96 | -H 'Content-Type: application/json' \ 97 | -H 'Accept: application/json' \ 98 | -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' 99 | 100 | # Call a tool (example: get stock quote) 101 | curl -X POST https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/mcp \ 102 | -H 'Content-Type: application/json' \ 103 | -H 'Accept: application/json' \ 104 | -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_stock_quote","arguments":{"symbol":"AAPL"}}}' 105 | ``` -------------------------------------------------------------------------------- /tests/test_stdio_transport.py: -------------------------------------------------------------------------------- ```python 1 | import json 2 | import sys 3 | 4 | import pytest 5 | from mcp import ClientSession 6 | from mcp.client.stdio import stdio_client, StdioServerParameters 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_stdio_stock_quote(): 11 | """Test stdio transport with stock_quote tool""" 12 | 13 | # Start the MCP server as a subprocess 14 | server_params = StdioServerParameters( 15 | command=sys.executable, 16 | args=[ 17 | "-c", 18 | "import sys; sys.path.insert(0, 'src'); from alphavantage_mcp_server import main; main()", 19 | "--server", 20 | "stdio", 21 | ], 22 | ) 23 | 24 | # Connect to the server using stdio client 25 | client = stdio_client(server_params) 26 | async with client as (read_stream, write_stream): 27 | async with ClientSession(read_stream, write_stream) as session: 28 | # Initialize the session 29 | await session.initialize() 30 | 31 | # List available tools 32 | response = await session.list_tools() 33 | tools = response.tools 34 | tool_names = [tool.name for tool in tools] 35 | 36 | # Verify stock_quote tool is available 37 | assert "stock_quote" in tool_names, ( 38 | f"stock_quote not found in tools: {tool_names}" 39 | ) 40 | 41 | # Find the stock_quote tool 42 | next(tool for tool in tools if tool.name == "stock_quote") 43 | 44 | # Test calling the stock_quote tool 45 | result = await session.call_tool("stock_quote", {"symbol": "AAPL"}) 46 | 47 | # Verify we got a result 48 | assert result is not None 49 | assert hasattr(result, "content") 50 | assert len(result.content) > 0 51 | 52 | # Parse the JSON result to verify it contains stock data 53 | content_text = result.content[0].text 54 | stock_data = json.loads(content_text) 55 | 56 | # Verify the response contains expected stock quote fields 57 | assert "Global Quote" in stock_data or "Error Message" in stock_data 58 | 59 | if "Global Quote" in stock_data: 60 | global_quote = stock_data["Global Quote"] 61 | assert "01. symbol" in global_quote 62 | assert "05. price" in global_quote 63 | assert global_quote["01. symbol"] == "AAPL" 64 | 65 | 66 | @pytest.mark.asyncio 67 | async def test_stdio_tool_list(): 68 | """Test that stdio transport can list all available tools""" 69 | 70 | server_params = StdioServerParameters( 71 | command=sys.executable, 72 | args=[ 73 | "-c", 74 | "import sys; sys.path.insert(0, 'src'); from alphavantage_mcp_server import main; main()", 75 | "--server", 76 | "stdio", 77 | ], 78 | ) 79 | 80 | client = stdio_client(server_params) 81 | async with client as (read_stream, write_stream): 82 | async with ClientSession(read_stream, write_stream) as session: 83 | await session.initialize() 84 | 85 | response = await session.list_tools() 86 | tools = response.tools 87 | 88 | # Verify we have tools 89 | assert len(tools) > 0 90 | 91 | # Verify essential tools are present 92 | tool_names = [tool.name for tool in tools] 93 | expected_tools = ["stock_quote", "time_series_daily"] 94 | 95 | for expected_tool in expected_tools: 96 | assert expected_tool in tool_names, ( 97 | f"{expected_tool} not found in tools" 98 | ) 99 | 100 | # Verify each tool has required attributes 101 | for tool in tools: 102 | assert hasattr(tool, "name") 103 | assert hasattr(tool, "description") 104 | assert hasattr(tool, "inputSchema") 105 | assert tool.name is not None 106 | assert tool.description is not None 107 | assert tool.inputSchema is not None 108 | ``` -------------------------------------------------------------------------------- /scripts/publish.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python3 2 | """ 3 | Script to build and publish the alphavantage-mcp package to PyPI. 4 | 5 | Usage: 6 | python scripts/publish.py --test # Publish to TestPyPI using twine 7 | python scripts/publish.py # Publish to PyPI using twine 8 | python scripts/publish.py --test --use-uv # Publish to TestPyPI using uv publish 9 | python scripts/publish.py --use-uv # Publish to PyPI using uv publish 10 | """ 11 | 12 | import argparse 13 | import subprocess 14 | import sys 15 | from pathlib import Path 16 | 17 | 18 | def run_command(cmd: list[str], description: str) -> bool: 19 | """Run a command and return True if successful.""" 20 | print(f"🔄 {description}...") 21 | try: 22 | result = subprocess.run(cmd, check=True, capture_output=True, text=True) 23 | print(f"✅ {description} completed successfully") 24 | if result.stdout: 25 | print(f" Output: {result.stdout.strip()}") 26 | return True 27 | except subprocess.CalledProcessError as e: 28 | print(f"❌ {description} failed") 29 | print(f" Error: {e.stderr.strip()}") 30 | return False 31 | 32 | 33 | def main(): 34 | parser = argparse.ArgumentParser(description="Build and publish alphavantage-mcp package") 35 | parser.add_argument( 36 | "--test", 37 | action="store_true", 38 | help="Publish to TestPyPI instead of PyPI" 39 | ) 40 | parser.add_argument( 41 | "--skip-build", 42 | action="store_true", 43 | help="Skip building and only upload existing dist files" 44 | ) 45 | parser.add_argument( 46 | "--use-uv", 47 | action="store_true", 48 | help="Use uv publish instead of twine (default: use twine)" 49 | ) 50 | args = parser.parse_args() 51 | 52 | # Change to project root 53 | project_root = Path(__file__).parent.parent 54 | print(f"📁 Working in: {project_root}") 55 | 56 | if not args.skip_build: 57 | # Clean previous builds 58 | dist_dir = project_root / "dist" 59 | if dist_dir.exists(): 60 | print("🧹 Cleaning previous builds...") 61 | import shutil 62 | shutil.rmtree(dist_dir) 63 | 64 | # Build the package 65 | if not run_command( 66 | ["uv", "build"], 67 | "Building package" 68 | ): 69 | sys.exit(1) 70 | 71 | # Check if dist files exist 72 | dist_dir = project_root / "dist" 73 | if not dist_dir.exists() or not list(dist_dir.glob("*.whl")): 74 | print("❌ No built packages found in dist/") 75 | print(" Run without --skip-build to build the package first") 76 | sys.exit(1) 77 | 78 | # Upload to PyPI or TestPyPI 79 | if args.test: 80 | repository_name = "testpypi" 81 | print("🧪 Publishing to TestPyPI...") 82 | print(" You can install with: pip install -i https://test.pypi.org/simple/ alphavantage-mcp") 83 | else: 84 | repository_name = "pypi" 85 | print("🚀 Publishing to PyPI...") 86 | print(" After publishing, users can install with: uvx alphavantage-mcp") 87 | 88 | # Choose upload method 89 | if args.use_uv: 90 | # Use uv publish 91 | repository_url = "https://test.pypi.org/legacy/" if args.test else "https://upload.pypi.org/legacy/" 92 | upload_cmd = [ 93 | "uv", "publish", 94 | "--publish-url", repository_url, 95 | str(dist_dir / "*") 96 | ] 97 | auth_help = "uv publish --help" 98 | else: 99 | # Use twine (default) 100 | upload_cmd = [ 101 | "uv", "run", "twine", "upload", 102 | "--repository", repository_name, 103 | str(dist_dir / "*") 104 | ] 105 | auth_help = "~/.pypirc file or environment variables" 106 | 107 | if not run_command(upload_cmd, f"Uploading to {repository_name}"): 108 | print(f"\n💡 If authentication failed, make sure you have:") 109 | print(f" 1. Created an account on {repository_name}") 110 | print(f" 2. Generated an API token") 111 | print(f" 3. Set up authentication with: {auth_help}") 112 | sys.exit(1) 113 | 114 | print(f"\n🎉 Package successfully published to {repository_name.upper()}!") 115 | 116 | if args.test: 117 | print("\n📋 Next steps:") 118 | print(" 1. Test the installation: pip install -i https://test.pypi.org/simple/ alphavantage-mcp") 119 | print(" 2. If everything works, publish to PyPI: python scripts/publish.py") 120 | else: 121 | print("\n📋 Users can now install with:") 122 | print(" uvx alphavantage-mcp") 123 | print(" or") 124 | print(" pip install alphavantage-mcp") 125 | 126 | 127 | if __name__ == "__main__": 128 | main() 129 | ``` -------------------------------------------------------------------------------- /src/alphavantage_mcp_client/client.py: -------------------------------------------------------------------------------- ```python 1 | import os 2 | import sys 3 | import json 4 | import asyncio 5 | from typing import Optional, Literal 6 | from contextlib import AsyncExitStack 7 | 8 | from mcp import ClientSession 9 | from mcp.client.sse import sse_client 10 | from mcp.client.streamable_http import streamablehttp_client 11 | 12 | from openai import OpenAI 13 | 14 | BASE_URL = "http://localhost:8080/" 15 | 16 | 17 | class MCPClient: 18 | def __init__(self): 19 | # Initialize session and client objects 20 | self.session: Optional[ClientSession] = None 21 | self.exit_stack = AsyncExitStack() 22 | self.client = OpenAI( 23 | base_url=os.getenv("OPENAI_BASE_URL"), 24 | api_key=os.getenv("OPENAI_API_KEY"), 25 | ) 26 | 27 | async def connect_to_server( 28 | self, server_protocol: Literal["sse", "streamable-http"] 29 | ): 30 | """Connect to an MCP server""" 31 | if server_protocol == "sse": 32 | client = sse_client(BASE_URL + "sse") 33 | rs, ws = await self.exit_stack.enter_async_context(client) 34 | elif server_protocol == "streamable-http": 35 | client = streamablehttp_client(BASE_URL + "mcp") 36 | rs, ws, _ = await self.exit_stack.enter_async_context(client) 37 | else: 38 | raise Exception("Unknown transport protocol") 39 | self.session = await self.exit_stack.enter_async_context(ClientSession(rs, ws)) 40 | 41 | await self.session.initialize() 42 | 43 | # List available tools 44 | response = await self.session.list_tools() 45 | tools = response.tools_definitions 46 | print("\nConnected to server with tools:", [tool.name for tool in tools]) 47 | 48 | async def process_query(self, query: str) -> str: 49 | """Process a query using LLM and available tools""" 50 | messages = [{"role": "user", "content": query}] 51 | 52 | response = await self.session.list_tools() 53 | available_tools = [ 54 | { 55 | "type": "function", 56 | "function": { 57 | "name": tool.name, 58 | "description": tool.description, 59 | "parameters": tool.inputSchema, 60 | }, 61 | } 62 | for tool in response.tools 63 | ] 64 | 65 | # Initial LLM API call 66 | response = self.client.chat.completions.create( 67 | model=self.client.models.list().data[0].id, 68 | messages=messages, 69 | tools=available_tools, 70 | tool_choice="auto", 71 | ) 72 | 73 | # Process response and handle tool calls 74 | final_text = [] 75 | 76 | content = response.choices[0].message.content 77 | final_text.append(content) 78 | 79 | if response.choices[0].message.tool_calls: 80 | for tc in response.choices[0].message.tool_calls: 81 | f = tc.function 82 | tool_name = f.name 83 | tool_args = f.arguments 84 | 85 | # Execute tool call 86 | result = await self.session.call_tool(tool_name, json.loads(tool_args)) 87 | final_text.append(f"[Calling tool {tool_name} with args {tool_args}]") 88 | 89 | # Continue conversation with tool results 90 | messages.append({"role": "assistant", "content": content}) 91 | messages.append({"role": "user", "content": result.content}) 92 | 93 | # Get next response from LLM 94 | response = self.client.chat.completions.create( 95 | model=self.client.models.list().data[0].id, 96 | messages=messages, 97 | ) 98 | 99 | final_text.append(response.choices[0].message.content) 100 | 101 | return "\n".join(final_text) 102 | 103 | async def chat_loop(self): 104 | """Run an interactive chat loop""" 105 | print("\nMCP Client Started!") 106 | print("Type your queries or 'quit' to exit.") 107 | 108 | while True: 109 | try: 110 | query = input("\nQuery: ").strip() 111 | 112 | if query.lower() == "quit": 113 | break 114 | 115 | response = await self.process_query(query) 116 | print("\n" + response) 117 | 118 | except Exception as e: 119 | raise e 120 | print(f"\nError: {str(e)}") 121 | 122 | async def cleanup(self): 123 | """Clean up resources""" 124 | await self.exit_stack.aclose() 125 | 126 | 127 | async def start(): 128 | if len(sys.argv) == 2 and sys.argv[1] in ("sse", "streamable-http"): 129 | client = MCPClient() 130 | try: 131 | await client.connect_to_server(sys.argv[1]) 132 | await client.chat_loop() 133 | finally: 134 | await client.cleanup() 135 | else: 136 | raise Exception("Usage: uv run client sse|streamable-http") 137 | 138 | 139 | def main(): 140 | asyncio.run(start()) 141 | 142 | 143 | if __name__ == "__main__": 144 | main() 145 | ``` -------------------------------------------------------------------------------- /src/alphavantage_mcp_server/telemetry_bootstrap.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Telemetry Bootstrap Module 3 | 4 | This module initializes Prometheus metrics for the AlphaVantage MCP server. 5 | It provides centralized configuration and setup for telemetry components. 6 | """ 7 | 8 | import os 9 | import logging 10 | import threading 11 | from typing import Optional 12 | from prometheus_client import Counter, Histogram, Gauge, start_http_server 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | # Environment variable configuration 17 | MCP_TELEMETRY_ENABLED = os.getenv("MCP_TELEMETRY_ENABLED", "true").lower() == "true" 18 | MCP_SERVER_NAME = os.getenv("MCP_SERVER_NAME", "alphavantage") 19 | MCP_SERVER_VERSION = os.getenv("MCP_SERVER_VERSION", "dev") 20 | MCP_METRICS_PORT = int(os.getenv("MCP_METRICS_PORT", "9464")) 21 | 22 | # Global telemetry state 23 | _telemetry_initialized = False 24 | _metrics_server_started = False 25 | _metrics_server_thread: Optional[threading.Thread] = None 26 | 27 | # Prometheus metrics - these will be initialized in init_telemetry() 28 | MCP_CALLS: Optional[Counter] = None 29 | MCP_ERRS: Optional[Counter] = None 30 | MCP_LAT: Optional[Histogram] = None 31 | MCP_REQ_B: Optional[Histogram] = None 32 | MCP_RES_B: Optional[Histogram] = None 33 | MCP_CONC: Optional[Gauge] = None 34 | 35 | 36 | def _create_prometheus_metrics(): 37 | """Create and return Prometheus metrics objects.""" 38 | global MCP_CALLS, MCP_ERRS, MCP_LAT, MCP_REQ_B, MCP_RES_B, MCP_CONC 39 | 40 | MCP_CALLS = Counter( 41 | "mcp_tool_calls_total", 42 | "Total number of MCP tool calls", 43 | ["tool", "server", "version", "outcome"], 44 | ) 45 | 46 | MCP_ERRS = Counter( 47 | "mcp_tool_errors_total", 48 | "Total number of MCP tool errors", 49 | ["tool", "error_kind"], 50 | ) 51 | 52 | MCP_LAT = Histogram( 53 | "mcp_tool_latency_seconds", 54 | "MCP tool call latency in seconds", 55 | ["tool", "server", "version"], 56 | buckets=[0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0], 57 | ) 58 | 59 | MCP_REQ_B = Histogram( 60 | "mcp_tool_request_bytes", 61 | "MCP tool request size in bytes", 62 | ["tool"], 63 | buckets=[64, 256, 1024, 4096, 16384, 65536, 262144, 1048576], 64 | ) 65 | 66 | MCP_RES_B = Histogram( 67 | "mcp_tool_response_bytes", 68 | "MCP tool response size in bytes", 69 | ["tool"], 70 | buckets=[64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304], 71 | ) 72 | 73 | MCP_CONC = Gauge( 74 | "mcp_tool_active_concurrency", 75 | "Number of currently active MCP tool calls", 76 | ["tool"], 77 | ) 78 | 79 | 80 | def _start_metrics_server(): 81 | """Start the Prometheus metrics HTTP server.""" 82 | global _metrics_server_started, _metrics_server_thread 83 | 84 | if _metrics_server_started: 85 | return 86 | 87 | try: 88 | 89 | def run_server(): 90 | try: 91 | start_http_server(MCP_METRICS_PORT, addr="127.0.0.1") 92 | logger.info( 93 | f"Prometheus metrics server started on 127.0.0.1:{MCP_METRICS_PORT}" 94 | ) 95 | except Exception as e: 96 | logger.error(f"Failed to start metrics server: {e}") 97 | 98 | _metrics_server_thread = threading.Thread(target=run_server, daemon=True) 99 | _metrics_server_thread.start() 100 | _metrics_server_started = True 101 | 102 | except Exception as e: 103 | logger.error(f"Failed to start metrics server thread: {e}") 104 | 105 | 106 | def init_telemetry(start_metrics: bool = True) -> None: 107 | """ 108 | Initialize telemetry system. 109 | 110 | Args: 111 | start_metrics: Whether to start the Prometheus metrics HTTP server. 112 | Set to False for Lambda environments. 113 | """ 114 | global _telemetry_initialized 115 | 116 | if _telemetry_initialized: 117 | logger.debug("Telemetry already initialized") 118 | return 119 | 120 | if not MCP_TELEMETRY_ENABLED: 121 | logger.info("Telemetry disabled via MCP_TELEMETRY_ENABLED") 122 | return 123 | 124 | try: 125 | logger.info( 126 | f"Initializing telemetry for {MCP_SERVER_NAME} v{MCP_SERVER_VERSION}" 127 | ) 128 | 129 | # Initialize Prometheus metrics 130 | _create_prometheus_metrics() 131 | logger.debug("Prometheus metrics created") 132 | 133 | # Start metrics server if requested 134 | if start_metrics: 135 | _start_metrics_server() 136 | 137 | _telemetry_initialized = True 138 | logger.info("Telemetry initialization complete") 139 | 140 | except Exception as e: 141 | logger.error(f"Failed to initialize telemetry: {e}") 142 | raise 143 | 144 | 145 | def is_telemetry_enabled() -> bool: 146 | """Check if telemetry is enabled and initialized.""" 147 | return MCP_TELEMETRY_ENABLED and _telemetry_initialized 148 | 149 | 150 | # Export the metric objects for use by other modules 151 | __all__ = [ 152 | "init_telemetry", 153 | "is_telemetry_enabled", 154 | "MCP_CALLS", 155 | "MCP_ERRS", 156 | "MCP_LAT", 157 | "MCP_REQ_B", 158 | "MCP_RES_B", 159 | "MCP_CONC", 160 | "MCP_SERVER_NAME", 161 | "MCP_SERVER_VERSION", 162 | ] 163 | ``` -------------------------------------------------------------------------------- /src/alphavantage_mcp_server/response_utils.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Response utilities for handling large API responses and preventing token limit issues. 3 | """ 4 | 5 | import json 6 | from typing import Dict, Any 7 | 8 | 9 | def limit_time_series_response( 10 | response: Dict[str, Any], max_data_points: int = 100, preserve_metadata: bool = True 11 | ) -> Dict[str, Any]: 12 | """ 13 | Limit the number of data points in a time series response to prevent token limit issues. 14 | 15 | Args: 16 | response: The full API response from AlphaVantage 17 | max_data_points: Maximum number of data points to include (default: 100) 18 | preserve_metadata: Whether to preserve metadata sections (default: True) 19 | 20 | Returns: 21 | Limited response with reduced data points 22 | """ 23 | if not isinstance(response, dict): 24 | return response 25 | 26 | limited_response = {} 27 | 28 | # Preserve metadata sections (they're usually small) 29 | if preserve_metadata: 30 | for key, value in response.items(): 31 | if not isinstance(value, dict) or len(value) < 50: 32 | limited_response[key] = value 33 | 34 | # Find and limit the main time series data section 35 | time_series_keys = [ 36 | key 37 | for key in response.keys() 38 | if any( 39 | indicator in key.lower() 40 | for indicator in [ 41 | "time series", 42 | "technical analysis", 43 | "sma", 44 | "ema", 45 | "rsi", 46 | "macd", 47 | "bbands", 48 | "stoch", 49 | "adx", 50 | "aroon", 51 | "cci", 52 | "mom", 53 | "roc", 54 | "willr", 55 | "ad", 56 | "obv", 57 | "ht_", 58 | "atr", 59 | "natr", 60 | "trix", 61 | "ultosc", 62 | "dx", 63 | "minus_di", 64 | "plus_di", 65 | "minus_dm", 66 | "plus_dm", 67 | "midpoint", 68 | "midprice", 69 | "sar", 70 | "trange", 71 | "adosc", 72 | ] 73 | ) 74 | ] 75 | 76 | for ts_key in time_series_keys: 77 | if ts_key in response and isinstance(response[ts_key], dict): 78 | time_series_data = response[ts_key] 79 | 80 | # Get the most recent data points (sorted by date descending) 81 | sorted_dates = sorted(time_series_data.keys(), reverse=True) 82 | limited_dates = sorted_dates[:max_data_points] 83 | 84 | # Create limited time series with only recent data 85 | limited_time_series = { 86 | date: time_series_data[date] for date in limited_dates 87 | } 88 | 89 | limited_response[ts_key] = limited_time_series 90 | 91 | # Add summary info about the limitation 92 | if len(sorted_dates) > max_data_points: 93 | limited_response[f"{ts_key}_summary"] = { 94 | "total_data_points_available": len(sorted_dates), 95 | "data_points_returned": len(limited_dates), 96 | "date_range_returned": { 97 | "from": min(limited_dates), 98 | "to": max(limited_dates), 99 | }, 100 | "note": f"Response limited to {max_data_points} most recent data points to prevent token limit issues", 101 | } 102 | 103 | return limited_response 104 | 105 | 106 | def estimate_response_size(response: Any) -> int: 107 | """ 108 | Estimate the token size of a response (rough approximation). 109 | 110 | Args: 111 | response: The response to estimate 112 | 113 | Returns: 114 | Estimated number of tokens 115 | """ 116 | try: 117 | json_str = json.dumps(response, indent=2) 118 | # Rough approximation: 1 token ≈ 4 characters 119 | return len(json_str) // 4 120 | except Exception: 121 | return 0 122 | 123 | 124 | def should_limit_response(response: Any, max_tokens: int = 15000) -> bool: 125 | """ 126 | Check if a response should be limited based on estimated token count. 127 | 128 | Args: 129 | response: The response to check 130 | max_tokens: Maximum allowed tokens (default: 15000) 131 | 132 | Returns: 133 | True if response should be limited 134 | """ 135 | estimated_tokens = estimate_response_size(response) 136 | return estimated_tokens > max_tokens 137 | 138 | 139 | def create_response_summary(response: Dict[str, Any]) -> Dict[str, Any]: 140 | """ 141 | Create a summary of a large response instead of returning the full data. 142 | 143 | Args: 144 | response: The full response to summarize 145 | 146 | Returns: 147 | Summary of the response 148 | """ 149 | summary = { 150 | "response_type": "summary", 151 | "reason": "Full response too large, showing summary to prevent token limit issues", 152 | } 153 | 154 | # Add metadata sections 155 | for key, value in response.items(): 156 | if not isinstance(value, dict) or len(value) < 10: 157 | summary[key] = value 158 | 159 | # Summarize large data sections 160 | for key, value in response.items(): 161 | if isinstance(value, dict) and len(value) >= 10: 162 | summary[f"{key}_info"] = { 163 | "data_points": len(value), 164 | "date_range": { 165 | "earliest": min(value.keys()) if value else None, 166 | "latest": max(value.keys()) if value else None, 167 | }, 168 | "sample_fields": list(list(value.values())[0].keys()) if value else [], 169 | "note": "Use a more specific date range or limit parameter to get actual data", 170 | } 171 | 172 | return summary 173 | ``` -------------------------------------------------------------------------------- /deploy/aws-stateless-mcp-lambda/deploy.sh: -------------------------------------------------------------------------------- ```bash 1 | #!/bin/bash 2 | 3 | # AWS Stateless MCP Lambda Deployment Script 4 | # Based on aws-samples/sample-serverless-mcp-servers pattern 5 | 6 | set -e 7 | 8 | echo "🚀 AlphaVantage Stateless MCP Server Deployment" 9 | echo "==============================================" 10 | 11 | # Check prerequisites 12 | echo "📋 Checking prerequisites..." 13 | 14 | # Check if AWS CLI is installed 15 | if ! command -v aws &> /dev/null; then 16 | echo "❌ AWS CLI is not installed. Please install it first." 17 | exit 1 18 | fi 19 | 20 | # Check if SAM CLI is installed 21 | if ! command -v sam &> /dev/null; then 22 | echo "❌ AWS SAM CLI is not installed. Please install it first." 23 | echo " Install with: pip install aws-sam-cli" 24 | exit 1 25 | fi 26 | 27 | # Check AWS credentials 28 | if ! aws sts get-caller-identity &> /dev/null; then 29 | echo "❌ AWS credentials not configured. Please run 'aws configure' first." 30 | exit 1 31 | fi 32 | 33 | # Check required environment variables 34 | if [ -z "$ALPHAVANTAGE_API_KEY" ]; then 35 | echo "❌ ALPHAVANTAGE_API_KEY environment variable is required." 36 | echo " Set it with: export ALPHAVANTAGE_API_KEY=your_api_key_here" 37 | exit 1 38 | fi 39 | 40 | echo "✅ Prerequisites check passed" 41 | 42 | # Set deployment parameters 43 | STACK_NAME="alphavantage-stateless-mcp" 44 | OAUTH_ENABLED="${OAUTH_ENABLED:-false}" 45 | OAUTH_AUTHORIZATION_SERVER_URL="${OAUTH_AUTHORIZATION_SERVER_URL:-}" 46 | 47 | echo "" 48 | echo "📦 Deployment Configuration:" 49 | echo " Stack Name: $STACK_NAME" 50 | echo " OAuth Enabled: $OAUTH_ENABLED" 51 | echo " OAuth Server URL: ${OAUTH_AUTHORIZATION_SERVER_URL:-'(not set)'}" 52 | echo "" 53 | 54 | # Confirm deployment 55 | read -p "🤔 Do you want to proceed with deployment? (y/N): " -n 1 -r 56 | echo 57 | if [[ ! $REPLY =~ ^[Yy]$ ]]; then 58 | echo "❌ Deployment cancelled." 59 | exit 1 60 | fi 61 | 62 | # Build the SAM application 63 | echo "" 64 | echo "🔨 Building SAM application..." 65 | sam build --use-container 66 | 67 | if [ $? -ne 0 ]; then 68 | echo "❌ SAM build failed." 69 | exit 1 70 | fi 71 | 72 | echo "✅ Build completed successfully" 73 | 74 | # Deploy the SAM application 75 | echo "" 76 | echo "🚀 Deploying to AWS..." 77 | 78 | # Deploy with conditional parameter handling 79 | if [ -n "$OAUTH_AUTHORIZATION_SERVER_URL" ]; then 80 | # Deploy with OAuth URL 81 | sam deploy \ 82 | --stack-name "$STACK_NAME" \ 83 | --capabilities CAPABILITY_IAM \ 84 | --resolve-s3 \ 85 | --parameter-overrides \ 86 | "AlphaVantageApiKey=$ALPHAVANTAGE_API_KEY" \ 87 | "OAuthEnabled=$OAUTH_ENABLED" \ 88 | "OAuthAuthorizationServerUrl=$OAUTH_AUTHORIZATION_SERVER_URL" \ 89 | --no-confirm-changeset \ 90 | --no-fail-on-empty-changeset 91 | else 92 | # Deploy without OAuth URL (use default empty value) 93 | sam deploy \ 94 | --stack-name "$STACK_NAME" \ 95 | --capabilities CAPABILITY_IAM \ 96 | --resolve-s3 \ 97 | --parameter-overrides \ 98 | "AlphaVantageApiKey=$ALPHAVANTAGE_API_KEY" \ 99 | "OAuthEnabled=$OAUTH_ENABLED" \ 100 | --no-confirm-changeset \ 101 | --no-fail-on-empty-changeset 102 | fi 103 | 104 | if [ $? -ne 0 ]; then 105 | echo "❌ Deployment failed." 106 | exit 1 107 | fi 108 | 109 | echo "✅ Deployment completed successfully!" 110 | 111 | # Get the API endpoint 112 | echo "" 113 | echo "📡 Getting deployment information..." 114 | API_URL=$(aws cloudformation describe-stacks \ 115 | --stack-name "$STACK_NAME" \ 116 | --query 'Stacks[0].Outputs[?OutputKey==`McpApiUrl`].OutputValue' \ 117 | --output text) 118 | 119 | FUNCTION_NAME=$(aws cloudformation describe-stacks \ 120 | --stack-name "$STACK_NAME" \ 121 | --query 'Stacks[0].Outputs[?OutputKey==`FunctionName`].OutputValue' \ 122 | --output text) 123 | 124 | echo "" 125 | echo "🎉 Deployment Summary:" 126 | echo "======================" 127 | echo " API Endpoint: $API_URL" 128 | echo " Function Name: $FUNCTION_NAME" 129 | echo " Stack Name: $STACK_NAME" 130 | echo "" 131 | 132 | # Test the deployment 133 | echo "🧪 Testing the deployment..." 134 | echo "" 135 | 136 | echo "1️⃣ Testing MCP Initialize..." 137 | INIT_RESPONSE=$(curl -s -X POST "$API_URL" \ 138 | -H 'Content-Type: application/json' \ 139 | -H 'Accept: application/json' \ 140 | -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}') 141 | 142 | if echo "$INIT_RESPONSE" | grep -q '"result"'; then 143 | echo "✅ Initialize test passed" 144 | else 145 | echo "❌ Initialize test failed" 146 | echo "Response: $INIT_RESPONSE" 147 | fi 148 | 149 | echo "" 150 | echo "2️⃣ Testing Tools List..." 151 | TOOLS_RESPONSE=$(curl -s -X POST "$API_URL" \ 152 | -H 'Content-Type: application/json' \ 153 | -H 'Accept: application/json' \ 154 | -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}') 155 | 156 | if echo "$TOOLS_RESPONSE" | grep -q '"tools"'; then 157 | TOOL_COUNT=$(echo "$TOOLS_RESPONSE" | grep -o '"name"' | wc -l) 158 | echo "✅ Tools list test passed - Found $TOOL_COUNT tools" 159 | else 160 | echo "❌ Tools list test failed" 161 | echo "Response: $TOOLS_RESPONSE" 162 | fi 163 | 164 | echo "" 165 | echo "🎯 Manual Test Commands:" 166 | echo "========================" 167 | echo "" 168 | echo "# Initialize MCP session:" 169 | echo "curl -X POST '$API_URL' \\" 170 | echo " -H 'Content-Type: application/json' \\" 171 | echo " -H 'Accept: application/json' \\" 172 | echo " -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test-client\",\"version\":\"1.0.0\"}}}'" 173 | echo "" 174 | echo "# List available tools:" 175 | echo "curl -X POST '$API_URL' \\" 176 | echo " -H 'Content-Type: application/json' \\" 177 | echo " -H 'Accept: application/json' \\" 178 | echo " -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}'" 179 | echo "" 180 | echo "# Call a tool (get stock quote for AAPL):" 181 | echo "curl -X POST '$API_URL' \\" 182 | echo " -H 'Content-Type: application/json' \\" 183 | echo " -H 'Accept: application/json' \\" 184 | echo " -d '{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"get_stock_quote\",\"arguments\":{\"symbol\":\"AAPL\"}}}'" 185 | echo "" 186 | 187 | echo "📊 Monitoring:" 188 | echo "==============" 189 | echo " CloudWatch Logs: aws logs tail /aws/lambda/$FUNCTION_NAME --follow" 190 | echo " Function Metrics: aws lambda get-function --function-name $FUNCTION_NAME" 191 | echo "" 192 | 193 | echo "🧹 Cleanup (when done testing):" 194 | echo "================================" 195 | echo " aws cloudformation delete-stack --stack-name $STACK_NAME" 196 | echo "" 197 | 198 | echo "✅ Stateless MCP deployment completed successfully!" 199 | echo " Your AlphaVantage MCP server is now running serverlessly on AWS Lambda!" 200 | ``` -------------------------------------------------------------------------------- /src/alphavantage_mcp_server/telemetry_instrument.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | Telemetry Instrumentation Module 3 | 4 | This module provides the @instrument_tool decorator for wrapping MCP tool functions 5 | with telemetry collection using Prometheus metrics. 6 | """ 7 | 8 | import asyncio 9 | import functools 10 | import logging 11 | import time 12 | from typing import Any, Callable, Optional 13 | 14 | from .telemetry_bootstrap import ( 15 | MCP_CALLS, 16 | MCP_ERRS, 17 | MCP_LAT, 18 | MCP_REQ_B, 19 | MCP_RES_B, 20 | MCP_CONC, 21 | is_telemetry_enabled, 22 | ) 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | def _classify_error(error: Exception) -> str: 28 | """ 29 | Classify an error into a category for metrics labeling. 30 | 31 | Args: 32 | error: The exception to classify 33 | 34 | Returns: 35 | Error category string: "timeout", "bad_input", "connection", or "unknown" 36 | """ 37 | if isinstance(error, (TimeoutError, asyncio.TimeoutError)): 38 | return "timeout" 39 | elif isinstance(error, (ValueError, TypeError, KeyError, AttributeError)): 40 | return "bad_input" 41 | elif isinstance(error, (ConnectionError, OSError)): 42 | return "connection" 43 | else: 44 | return "unknown" 45 | 46 | 47 | def _get_size_bytes(obj: Any) -> int: 48 | """ 49 | Calculate the approximate size of an object in bytes. 50 | 51 | Args: 52 | obj: Object to measure 53 | 54 | Returns: 55 | Size in bytes (0 if measurement fails) 56 | """ 57 | try: 58 | if obj is None: 59 | return 0 60 | elif isinstance(obj, (str, bytes)): 61 | return len(obj) 62 | else: 63 | # For other objects, convert to string and measure 64 | return len(str(obj)) 65 | except Exception: 66 | return 0 67 | 68 | 69 | def instrument_tool(tool_name: str, transport: Optional[str] = None) -> Callable: 70 | """ 71 | Decorator to instrument MCP tool functions with telemetry collection. 72 | 73 | This decorator: 74 | - Increments/decrements active concurrency gauge 75 | - Measures execution duration 76 | - Classifies and counts errors 77 | - Emits calls_total metric with outcome 78 | - Measures request and response payload sizes 79 | 80 | Args: 81 | tool_name: Name of the tool for metrics labeling 82 | transport: Transport type ("stdio", "http", etc.) for metrics labeling 83 | 84 | Returns: 85 | Decorated function with telemetry instrumentation 86 | """ 87 | 88 | def decorator(func: Callable) -> Callable: 89 | if not is_telemetry_enabled(): 90 | # If telemetry is disabled, return the original function unchanged 91 | return func 92 | 93 | @functools.wraps(func) 94 | async def async_wrapper(*args, **kwargs) -> Any: 95 | """Async wrapper for instrumented functions.""" 96 | start_time = time.time() 97 | outcome = "error" 98 | error_kind = None 99 | 100 | # Increment active concurrency 101 | if MCP_CONC: 102 | MCP_CONC.labels(tool=tool_name).inc() 103 | 104 | try: 105 | # Measure request size (approximate) 106 | request_data = {"args": args, "kwargs": kwargs} 107 | request_size = _get_size_bytes(request_data) 108 | if MCP_REQ_B: 109 | MCP_REQ_B.labels(tool=tool_name).observe(request_size) 110 | 111 | # Execute the actual function 112 | result = await func(*args, **kwargs) 113 | 114 | # Measure response size 115 | response_size = _get_size_bytes(result) 116 | if MCP_RES_B: 117 | MCP_RES_B.labels(tool=tool_name).observe(response_size) 118 | 119 | outcome = "ok" 120 | return result 121 | 122 | except Exception as e: 123 | error_kind = _classify_error(e) 124 | 125 | # Increment error counter 126 | if MCP_ERRS: 127 | MCP_ERRS.labels(tool=tool_name, error_kind=error_kind).inc() 128 | 129 | logger.warning(f"Tool {tool_name} failed with {error_kind} error: {e}") 130 | raise 131 | 132 | finally: 133 | # Record execution time 134 | duration = time.time() - start_time 135 | if MCP_LAT: 136 | MCP_LAT.labels(tool=tool_name).observe(duration) 137 | 138 | # Increment total calls counter 139 | if MCP_CALLS: 140 | MCP_CALLS.labels(tool=tool_name, outcome=outcome).inc() 141 | 142 | # Decrement active concurrency 143 | if MCP_CONC: 144 | MCP_CONC.labels(tool=tool_name).dec() 145 | 146 | @functools.wraps(func) 147 | def sync_wrapper(*args, **kwargs) -> Any: 148 | """Sync wrapper for instrumented functions.""" 149 | start_time = time.time() 150 | outcome = "error" 151 | error_kind = None 152 | 153 | # Increment active concurrency 154 | if MCP_CONC: 155 | MCP_CONC.labels(tool=tool_name).inc() 156 | 157 | try: 158 | # Measure request size (approximate) 159 | request_data = {"args": args, "kwargs": kwargs} 160 | request_size = _get_size_bytes(request_data) 161 | if MCP_REQ_B: 162 | MCP_REQ_B.labels(tool=tool_name).observe(request_size) 163 | 164 | # Execute the actual function 165 | result = func(*args, **kwargs) 166 | 167 | # Measure response size 168 | response_size = _get_size_bytes(result) 169 | if MCP_RES_B: 170 | MCP_RES_B.labels(tool=tool_name).observe(response_size) 171 | 172 | outcome = "ok" 173 | return result 174 | 175 | except Exception as e: 176 | error_kind = _classify_error(e) 177 | 178 | # Increment error counter 179 | if MCP_ERRS: 180 | MCP_ERRS.labels(tool=tool_name, error_kind=error_kind).inc() 181 | 182 | logger.warning(f"Tool {tool_name} failed with {error_kind} error: {e}") 183 | raise 184 | 185 | finally: 186 | # Record execution time 187 | duration = time.time() - start_time 188 | if MCP_LAT: 189 | MCP_LAT.labels(tool=tool_name).observe(duration) 190 | 191 | # Increment total calls counter 192 | if MCP_CALLS: 193 | MCP_CALLS.labels(tool=tool_name, outcome=outcome).inc() 194 | 195 | # Decrement active concurrency 196 | if MCP_CONC: 197 | MCP_CONC.labels(tool=tool_name).dec() 198 | 199 | # Return appropriate wrapper based on whether function is async 200 | if asyncio.iscoroutinefunction(func): 201 | return async_wrapper 202 | else: 203 | return sync_wrapper 204 | 205 | return decorator 206 | 207 | 208 | # Export the decorator 209 | __all__ = ["instrument_tool"] 210 | ``` -------------------------------------------------------------------------------- /tests/test_http_mcp_client.py: -------------------------------------------------------------------------------- ```python 1 | import asyncio 2 | import json 3 | import subprocess 4 | import sys 5 | 6 | import pytest 7 | from mcp import ClientSession 8 | from mcp.client.streamable_http import streamablehttp_client 9 | 10 | 11 | @pytest.mark.asyncio 12 | async def test_http_transport_with_mcp_client(): 13 | """Test HTTP transport using proper MCP streamable HTTP client""" 14 | 15 | # Start the HTTP server 16 | server_process = subprocess.Popen( 17 | [ 18 | sys.executable, 19 | "-c", 20 | "import sys; sys.path.insert(0, 'src'); from alphavantage_mcp_server import main; main()", 21 | "--server", 22 | "http", 23 | "--port", 24 | "8087", 25 | ], 26 | stdout=subprocess.PIPE, 27 | stderr=subprocess.PIPE, 28 | text=True, 29 | ) 30 | 31 | try: 32 | # Give server time to start 33 | await asyncio.sleep(4) 34 | 35 | base_url = "http://localhost:8087/" 36 | 37 | client = streamablehttp_client(base_url + "mcp") 38 | 39 | async with client as streams: 40 | # Extract streams from the context 41 | if len(streams) == 3: 42 | read_stream, write_stream, session_manager = streams 43 | else: 44 | read_stream, write_stream = streams 45 | 46 | async with ClientSession(read_stream, write_stream) as session: 47 | # Initialize the session 48 | init_result = await session.initialize() 49 | assert init_result is not None 50 | assert init_result.serverInfo.name == "alphavantage" 51 | 52 | # List available tools 53 | response = await session.list_tools() 54 | tools = response.tools 55 | tool_names = [tool.name for tool in tools] 56 | 57 | # Verify essential tools are present 58 | assert "stock_quote" in tool_names 59 | assert len(tools) > 0 60 | 61 | # Test calling the stock_quote tool 62 | result = await session.call_tool("stock_quote", {"symbol": "AAPL"}) 63 | 64 | # Verify we got a result 65 | assert result is not None 66 | assert hasattr(result, "content") 67 | assert len(result.content) > 0 68 | 69 | # Parse the JSON result 70 | content_text = result.content[0].text 71 | stock_data = json.loads(content_text) 72 | 73 | assert "Global Quote" in stock_data or "Error Message" in stock_data 74 | assert stock_data["Global Quote"]["01. symbol"] == "AAPL" 75 | 76 | finally: 77 | # Clean up 78 | server_process.terminate() 79 | try: 80 | server_process.wait(timeout=5) 81 | except subprocess.TimeoutExpired: 82 | server_process.kill() 83 | 84 | 85 | @pytest.mark.asyncio 86 | async def test_http_transport_tool_listing(): 87 | """Test HTTP transport tool listing functionality""" 88 | 89 | # Start the HTTP server 90 | server_process = subprocess.Popen( 91 | [ 92 | sys.executable, 93 | "-c", 94 | "import sys; sys.path.insert(0, 'src'); from alphavantage_mcp_server import main; main()", 95 | "--server", 96 | "http", 97 | "--port", 98 | "8088", 99 | ], 100 | stdout=subprocess.PIPE, 101 | stderr=subprocess.PIPE, 102 | text=True, 103 | ) 104 | 105 | try: 106 | # Give server time to start 107 | await asyncio.sleep(4) 108 | 109 | base_url = "http://localhost:8088/" 110 | 111 | client = streamablehttp_client(base_url + "mcp") 112 | 113 | async with client as streams: 114 | if len(streams) == 3: 115 | read_stream, write_stream, session_manager = streams 116 | else: 117 | read_stream, write_stream = streams 118 | 119 | async with ClientSession(read_stream, write_stream) as session: 120 | await session.initialize() 121 | 122 | response = await session.list_tools() 123 | tools = response.tools 124 | 125 | # Verify we have tools 126 | assert len(tools) > 0 127 | 128 | # Verify essential tools are present 129 | tool_names = [tool.name for tool in tools] 130 | expected_tools = ["stock_quote", "time_series_daily"] 131 | 132 | for expected_tool in expected_tools: 133 | assert expected_tool in tool_names, ( 134 | f"{expected_tool} not found in tools" 135 | ) 136 | 137 | # Verify each tool has required attributes 138 | for tool in tools: 139 | assert hasattr(tool, "name") 140 | assert hasattr(tool, "description") 141 | assert hasattr(tool, "inputSchema") 142 | assert tool.name is not None 143 | assert tool.description is not None 144 | assert tool.inputSchema is not None 145 | 146 | finally: 147 | # Clean up 148 | server_process.terminate() 149 | try: 150 | server_process.wait(timeout=5) 151 | except subprocess.TimeoutExpired: 152 | server_process.kill() 153 | 154 | 155 | @pytest.mark.asyncio 156 | async def test_http_transport_tool_call(): 157 | """Test HTTP transport tool calling functionality""" 158 | 159 | # Start the HTTP server 160 | server_process = subprocess.Popen( 161 | [ 162 | sys.executable, 163 | "-c", 164 | "import sys; sys.path.insert(0, 'src'); from alphavantage_mcp_server import main; main()", 165 | "--server", 166 | "http", 167 | "--port", 168 | "8089", 169 | ], 170 | stdout=subprocess.PIPE, 171 | stderr=subprocess.PIPE, 172 | text=True, 173 | ) 174 | 175 | try: 176 | # Give server time to start 177 | await asyncio.sleep(4) 178 | 179 | base_url = "http://localhost:8089/" 180 | 181 | client = streamablehttp_client(base_url + "mcp") 182 | 183 | async with client as streams: 184 | if len(streams) == 3: 185 | read_stream, write_stream, session_manager = streams 186 | else: 187 | read_stream, write_stream = streams 188 | 189 | async with ClientSession(read_stream, write_stream) as session: 190 | # Initialize the session 191 | await session.initialize() 192 | 193 | # Call the stock_quote tool 194 | stock_result = await session.call_tool( 195 | "stock_quote", {"symbol": "MSFT"} 196 | ) 197 | 198 | # Verify we got a result 199 | assert stock_result is not None 200 | assert hasattr(stock_result, "content") 201 | assert len(stock_result.content) > 0 202 | 203 | # Parse and validate the result 204 | stock_content = stock_result.content[0].text 205 | stock_data = json.loads(stock_content) 206 | assert "Global Quote" in stock_data 207 | assert stock_data["Global Quote"]["01. symbol"] == "MSFT" 208 | 209 | # Call time_series_daily tool 210 | time_series_result = await session.call_tool( 211 | "time_series_daily", {"symbol": "IBM", "outputsize": "compact"} 212 | ) 213 | 214 | # Verify we got a result 215 | assert time_series_result is not None 216 | assert hasattr(time_series_result, "content") 217 | assert len(time_series_result.content) > 0 218 | 219 | # Parse and validate the result 220 | ts_content = time_series_result.content[0].text 221 | ts_data = json.loads(ts_content) 222 | assert "Time Series (Daily)" in ts_data 223 | assert "Meta Data" in ts_data 224 | assert ts_data["Meta Data"]["2. Symbol"] == "IBM" 225 | 226 | finally: 227 | # Clean up 228 | server_process.terminate() 229 | try: 230 | server_process.wait(timeout=5) 231 | except subprocess.TimeoutExpired: 232 | server_process.kill() 233 | ``` -------------------------------------------------------------------------------- /tests/test_http_transport.py: -------------------------------------------------------------------------------- ```python 1 | import asyncio 2 | import json 3 | import subprocess 4 | import sys 5 | 6 | import pytest 7 | from mcp import ClientSession 8 | from mcp.client.streamable_http import streamablehttp_client 9 | 10 | 11 | @pytest.mark.asyncio 12 | async def test_http_stock_quote(): 13 | """Test streamable-http transport with stock_quote tool""" 14 | 15 | # Start the HTTP server 16 | server_process = subprocess.Popen( 17 | [ 18 | sys.executable, 19 | "-c", 20 | "import sys; sys.path.insert(0, 'src'); from alphavantage_mcp_server import main; main()", 21 | "--server", 22 | "http", 23 | "--port", 24 | "8091", 25 | ], 26 | stdout=subprocess.PIPE, 27 | stderr=subprocess.PIPE, 28 | text=True, 29 | ) 30 | 31 | try: 32 | # Give server time to start 33 | await asyncio.sleep(4) 34 | 35 | base_url = "http://localhost:8091/" 36 | 37 | # Connect to the server using streamable-http client 38 | client = streamablehttp_client(base_url + "mcp") 39 | 40 | async with client as streams: 41 | # Handle different return formats from the client 42 | if len(streams) == 3: 43 | read_stream, write_stream, session_manager = streams 44 | else: 45 | read_stream, write_stream = streams 46 | 47 | async with ClientSession(read_stream, write_stream) as session: 48 | # Initialize the session 49 | await session.initialize() 50 | 51 | # List available tools 52 | response = await session.list_tools() 53 | tools = response.tools 54 | tool_names = [tool.name for tool in tools] 55 | 56 | # Verify stock_quote tool is available 57 | assert "stock_quote" in tool_names, ( 58 | f"stock_quote not found in tools: {tool_names}" 59 | ) 60 | 61 | # Test calling the stock_quote tool 62 | result = await session.call_tool("stock_quote", {"symbol": "AAPL"}) 63 | 64 | # Verify we got a result 65 | assert result is not None 66 | assert hasattr(result, "content") 67 | assert len(result.content) > 0 68 | 69 | # Parse the JSON result to verify it contains stock data 70 | content_text = result.content[0].text 71 | stock_data = json.loads(content_text) 72 | 73 | # Verify the response contains expected stock quote fields 74 | assert "Global Quote" in stock_data or "Error Message" in stock_data 75 | 76 | if "Global Quote" in stock_data: 77 | global_quote = stock_data["Global Quote"] 78 | assert "01. symbol" in global_quote 79 | assert "05. price" in global_quote 80 | assert global_quote["01. symbol"] == "AAPL" 81 | 82 | finally: 83 | # Clean up 84 | server_process.terminate() 85 | try: 86 | server_process.wait(timeout=5) 87 | except subprocess.TimeoutExpired: 88 | server_process.kill() 89 | 90 | 91 | @pytest.mark.asyncio 92 | async def test_http_tool_list(): 93 | """Test that streamable-http transport can list all available tools""" 94 | 95 | # Start the HTTP server 96 | server_process = subprocess.Popen( 97 | [ 98 | sys.executable, 99 | "-c", 100 | "import sys; sys.path.insert(0, 'src'); from alphavantage_mcp_server import main; main()", 101 | "--server", 102 | "http", 103 | "--port", 104 | "8092", 105 | ], 106 | stdout=subprocess.PIPE, 107 | stderr=subprocess.PIPE, 108 | text=True, 109 | ) 110 | 111 | try: 112 | # Give server time to start 113 | await asyncio.sleep(4) 114 | 115 | base_url = "http://localhost:8092/" 116 | 117 | client = streamablehttp_client(base_url + "mcp") 118 | 119 | async with client as streams: 120 | if len(streams) == 3: 121 | read_stream, write_stream, session_manager = streams 122 | else: 123 | read_stream, write_stream = streams 124 | 125 | async with ClientSession(read_stream, write_stream) as session: 126 | await session.initialize() 127 | 128 | response = await session.list_tools() 129 | tools = response.tools 130 | 131 | # Verify we have tools 132 | assert len(tools) > 0 133 | 134 | # Verify essential tools are present 135 | tool_names = [tool.name for tool in tools] 136 | expected_tools = ["stock_quote", "time_series_daily"] 137 | 138 | for expected_tool in expected_tools: 139 | assert expected_tool in tool_names, ( 140 | f"{expected_tool} not found in tools" 141 | ) 142 | 143 | # Verify each tool has required attributes 144 | for tool in tools: 145 | assert hasattr(tool, "name") 146 | assert hasattr(tool, "description") 147 | assert hasattr(tool, "inputSchema") 148 | assert tool.name is not None 149 | assert tool.description is not None 150 | assert tool.inputSchema is not None 151 | 152 | finally: 153 | # Clean up 154 | server_process.terminate() 155 | try: 156 | server_process.wait(timeout=5) 157 | except subprocess.TimeoutExpired: 158 | server_process.kill() 159 | 160 | 161 | @pytest.mark.asyncio 162 | async def test_http_multiple_calls(): 163 | """Test making multiple tool calls over streamable-http transport""" 164 | 165 | # Start the HTTP server 166 | server_process = subprocess.Popen( 167 | [ 168 | sys.executable, 169 | "-c", 170 | "import sys; sys.path.insert(0, 'src'); from alphavantage_mcp_server import main; main()", 171 | "--server", 172 | "http", 173 | "--port", 174 | "8093", 175 | ], 176 | stdout=subprocess.PIPE, 177 | stderr=subprocess.PIPE, 178 | text=True, 179 | ) 180 | 181 | try: 182 | # Give server time to start 183 | await asyncio.sleep(4) 184 | 185 | base_url = "http://localhost:8093/" 186 | 187 | client = streamablehttp_client(base_url + "mcp") 188 | 189 | async with client as streams: 190 | if len(streams) == 3: 191 | read_stream, write_stream, session_manager = streams 192 | else: 193 | read_stream, write_stream = streams 194 | 195 | async with ClientSession(read_stream, write_stream) as session: 196 | await session.initialize() 197 | 198 | # Make multiple calls to test session persistence 199 | symbols = ["AAPL", "GOOGL", "MSFT"] 200 | results = [] 201 | 202 | for symbol in symbols: 203 | result = await session.call_tool("stock_quote", {"symbol": symbol}) 204 | assert result is not None 205 | assert hasattr(result, "content") 206 | assert len(result.content) > 0 207 | 208 | content_text = result.content[0].text 209 | stock_data = json.loads(content_text) 210 | results.append(stock_data) 211 | 212 | # Verify we got results for all symbols 213 | assert len(results) == len(symbols) 214 | 215 | # Verify each result contains stock data or error message 216 | for i, result in enumerate(results): 217 | assert "Global Quote" in result or "Error Message" in result, ( 218 | f"Invalid result for {symbols[i]}" 219 | ) 220 | 221 | finally: 222 | # Clean up 223 | server_process.terminate() 224 | try: 225 | server_process.wait(timeout=5) 226 | except subprocess.TimeoutExpired: 227 | server_process.kill() 228 | 229 | 230 | @pytest.mark.asyncio 231 | async def test_http_server_info(): 232 | """Test retrieving server information over streamable-http transport""" 233 | 234 | # Start the HTTP server 235 | server_process = subprocess.Popen( 236 | [ 237 | sys.executable, 238 | "-c", 239 | "import sys; sys.path.insert(0, 'src'); from alphavantage_mcp_server import main; main()", 240 | "--server", 241 | "http", 242 | "--port", 243 | "8094", 244 | ], 245 | stdout=subprocess.PIPE, 246 | stderr=subprocess.PIPE, 247 | text=True, 248 | ) 249 | 250 | try: 251 | # Give server time to start 252 | await asyncio.sleep(4) 253 | 254 | base_url = "http://localhost:8094/" 255 | 256 | client = streamablehttp_client(base_url + "mcp") 257 | 258 | async with client as streams: 259 | if len(streams) == 3: 260 | read_stream, write_stream, session_manager = streams 261 | else: 262 | read_stream, write_stream = streams 263 | 264 | async with ClientSession(read_stream, write_stream) as session: 265 | # Initialize and get server info 266 | init_result = await session.initialize() 267 | 268 | # Verify server info is present 269 | assert hasattr(init_result, "serverInfo") 270 | assert init_result.serverInfo.name == "alphavantage" 271 | assert init_result.serverInfo.version is not None 272 | 273 | # Verify protocol version 274 | assert init_result.protocolVersion is not None 275 | 276 | finally: 277 | # Clean up 278 | server_process.terminate() 279 | try: 280 | server_process.wait(timeout=5) 281 | except subprocess.TimeoutExpired: 282 | server_process.kill() 283 | ``` -------------------------------------------------------------------------------- /tests/test_integration.py: -------------------------------------------------------------------------------- ```python 1 | import asyncio 2 | import json 3 | import subprocess 4 | import sys 5 | 6 | import httpx 7 | import pytest 8 | from mcp import ClientSession 9 | from mcp.client.stdio import stdio_client, StdioServerParameters 10 | 11 | 12 | @pytest.mark.asyncio 13 | async def test_stdio_basic_connection(): 14 | """Test basic stdio connection and tool listing""" 15 | 16 | server_params = StdioServerParameters( 17 | command=sys.executable, 18 | args=[ 19 | "-c", 20 | "import sys; sys.path.insert(0, 'src'); from alphavantage_mcp_server import main; main()", 21 | "--server", 22 | "stdio", 23 | ], 24 | ) 25 | 26 | client = stdio_client(server_params) 27 | async with client as (read_stream, write_stream): 28 | async with ClientSession(read_stream, write_stream) as session: 29 | # Initialize the session 30 | init_result = await session.initialize() 31 | assert init_result is not None 32 | assert init_result.serverInfo.name == "alphavantage" 33 | 34 | # List available tools 35 | response = await session.list_tools() 36 | tools = response.tools 37 | tool_names = [tool.name for tool in tools] 38 | 39 | # Verify essential tools are present 40 | assert "stock_quote" in tool_names 41 | assert len(tools) > 0 42 | 43 | 44 | @pytest.mark.asyncio 45 | async def test_http_basic_connection(): 46 | """Test basic HTTP connection using direct HTTP requests""" 47 | 48 | # Start the HTTP server 49 | server_process = subprocess.Popen( 50 | [ 51 | sys.executable, 52 | "-c", 53 | "import sys; sys.path.insert(0, 'src'); from alphavantage_mcp_server import main; main()", 54 | "--server", 55 | "http", 56 | "--port", 57 | "8084", 58 | ], 59 | stdout=subprocess.PIPE, 60 | stderr=subprocess.PIPE, 61 | text=True, 62 | ) 63 | 64 | try: 65 | # Give server time to start 66 | await asyncio.sleep(4) 67 | 68 | # Test basic HTTP endpoint 69 | async with httpx.AsyncClient() as client: 70 | # Test initialization request 71 | init_request = { 72 | "jsonrpc": "2.0", 73 | "id": 1, 74 | "method": "initialize", 75 | "params": { 76 | "protocolVersion": "2025-06-18", 77 | "capabilities": {}, 78 | "clientInfo": {"name": "test-client", "version": "1.0.0"}, 79 | }, 80 | } 81 | 82 | response = await client.post( 83 | "http://localhost:8084/mcp", 84 | json=init_request, 85 | headers={ 86 | "Content-Type": "application/json", 87 | "Accept": "application/json, text/event-stream", 88 | "MCP-Protocol-Version": "2025-06-18", 89 | }, 90 | timeout=10.0, 91 | ) 92 | 93 | assert response.status_code == 200 94 | 95 | json_data = response.json() 96 | assert json_data is not None 97 | assert "result" in json_data 98 | assert json_data["result"]["serverInfo"]["name"] == "alphavantage" 99 | 100 | # Send initialized notification (required after initialize) 101 | initialized_notification = { 102 | "jsonrpc": "2.0", 103 | "method": "notifications/initialized", 104 | } 105 | 106 | await client.post( 107 | "http://localhost:8084/mcp", 108 | json=initialized_notification, 109 | headers={ 110 | "Content-Type": "application/json", 111 | "Accept": "application/json, text/event-stream", 112 | "MCP-Protocol-Version": "2025-06-18", 113 | }, 114 | timeout=10.0, 115 | ) 116 | 117 | # Test tools/list request 118 | # https://modelcontextprotocol.io/specification/2025-06-18/server/tools#listing-tools 119 | tools_request = {"jsonrpc": "2.0", "id": 2, "method": "tools/list"} 120 | 121 | response = await client.post( 122 | "http://localhost:8084/mcp", 123 | json=tools_request, 124 | headers={ 125 | "Content-Type": "application/json", 126 | "Accept": "application/json, text/event-stream", 127 | "MCP-Protocol-Version": "2025-06-18", 128 | }, 129 | timeout=10.0, 130 | ) 131 | 132 | assert response.status_code == 200 133 | 134 | json_data = response.json() 135 | assert json_data is not None 136 | assert "result" in json_data 137 | assert "tools" in json_data["result"] 138 | 139 | tools = json_data["result"]["tools"] 140 | tool_names = [tool["name"] for tool in tools] 141 | assert "stock_quote" in tool_names 142 | assert len(tools) > 0 143 | 144 | finally: 145 | # Clean up 146 | server_process.terminate() 147 | try: 148 | server_process.wait(timeout=5) 149 | except subprocess.TimeoutExpired: 150 | server_process.kill() 151 | 152 | 153 | @pytest.mark.asyncio 154 | async def test_stdio_stock_quote_call(): 155 | """Test calling STOCK_QUOTE tool via stdio""" 156 | 157 | server_params = StdioServerParameters( 158 | command=sys.executable, 159 | args=[ 160 | "-c", 161 | "import sys; sys.path.insert(0, 'src'); from alphavantage_mcp_server import main; main()", 162 | "--server", 163 | "stdio", 164 | ], 165 | ) 166 | 167 | client = stdio_client(server_params) 168 | async with client as (read_stream, write_stream): 169 | async with ClientSession(read_stream, write_stream) as session: 170 | # Initialize 171 | await session.initialize() 172 | 173 | # Call stock_quote tool 174 | result = await session.call_tool("stock_quote", {"symbol": "AAPL"}) 175 | 176 | # Verify we got a result 177 | assert result is not None 178 | assert hasattr(result, "content") 179 | assert len(result.content) > 0 180 | 181 | # Parse the JSON result 182 | content_text = result.content[0].text 183 | stock_data = json.loads(content_text) 184 | 185 | # Should contain either valid data or error message 186 | assert "Global Quote" in stock_data or "Error Message" in stock_data 187 | 188 | 189 | @pytest.mark.asyncio 190 | async def test_http_stock_quote_call(): 191 | """Test calling STOCK_QUOTE tool via HTTP""" 192 | 193 | # Start the HTTP server 194 | server_process = subprocess.Popen( 195 | [ 196 | sys.executable, 197 | "-c", 198 | "import sys; sys.path.insert(0, 'src'); from alphavantage_mcp_server import main; main()", 199 | "--server", 200 | "http", 201 | "--port", 202 | "8085", 203 | ], 204 | stdout=subprocess.PIPE, 205 | stderr=subprocess.PIPE, 206 | text=True, 207 | ) 208 | 209 | try: 210 | # Give server time to start 211 | await asyncio.sleep(4) 212 | 213 | async with httpx.AsyncClient() as client: 214 | # Initialize first 215 | init_request = { 216 | "jsonrpc": "2.0", 217 | "id": 1, 218 | "method": "initialize", 219 | "params": { 220 | "protocolVersion": "2024-11-05", 221 | "capabilities": {}, 222 | "clientInfo": {"name": "test-client", "version": "1.0.0"}, 223 | }, 224 | } 225 | 226 | await client.post( 227 | "http://localhost:8085/mcp", 228 | json=init_request, 229 | headers={ 230 | "Content-Type": "application/json", 231 | "Accept": "application/json, text/event-stream", 232 | }, 233 | timeout=10.0, 234 | ) 235 | 236 | # Send initialized notification 237 | initialized_notification = { 238 | "jsonrpc": "2.0", 239 | "method": "notifications/initialized", 240 | } 241 | 242 | await client.post( 243 | "http://localhost:8085/mcp", 244 | json=initialized_notification, 245 | headers={ 246 | "Content-Type": "application/json", 247 | "Accept": "application/json, text/event-stream", 248 | }, 249 | timeout=10.0, 250 | ) 251 | 252 | # Call stock_quote tool 253 | tool_request = { 254 | "jsonrpc": "2.0", 255 | "id": 2, 256 | "method": "tools/call", 257 | "params": {"name": "stock_quote", "arguments": {"symbol": "AAPL"}}, 258 | } 259 | 260 | response = await client.post( 261 | "http://localhost:8085/mcp", 262 | json=tool_request, 263 | headers={ 264 | "Content-Type": "application/json", 265 | "Accept": "application/json, text/event-stream", 266 | }, 267 | timeout=30.0, 268 | ) 269 | 270 | assert response.status_code == 200 271 | 272 | # Parse SSE response for tool call 273 | json_data = response.json() 274 | assert json_data is not None 275 | assert "result" in json_data 276 | assert "content" in json_data["result"] 277 | 278 | content = json_data["result"]["content"] 279 | assert len(content) > 0 280 | 281 | # Parse the JSON result 282 | content_text = content[0]["text"] 283 | stock_data = json.loads(content_text) 284 | 285 | # Should contain either valid data or error message 286 | assert "Global Quote" in stock_data or "Error Message" in stock_data 287 | 288 | finally: 289 | # Clean up 290 | server_process.terminate() 291 | try: 292 | server_process.wait(timeout=5) 293 | except subprocess.TimeoutExpired: 294 | server_process.kill() 295 | ``` -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- ```python 1 | import csv 2 | import os 3 | from io import StringIO 4 | 5 | import pytest 6 | 7 | from alphavantage_mcp_server.api import ( 8 | fetch_earnings_calendar, 9 | fetch_earnings_call_transcript, 10 | fetch_sma, 11 | ) 12 | 13 | 14 | @pytest.mark.asyncio 15 | async def test_fetch_earnings_call_transcript(): 16 | """Test fetching earnings call transcript with real API call.""" 17 | data = await fetch_earnings_call_transcript(symbol="IBM", quarter="2024Q1") 18 | 19 | assert isinstance(data, dict), "API should return JSON data as string" 20 | 21 | assert "symbol" in data, "JSON should contain 'symbol' field" 22 | assert "quarter" in data, "JSON should contain 'quarter' field" 23 | assert "transcript" in data, "JSON should contain 'transcript' field" 24 | 25 | assert data["symbol"] == "IBM", "Should find IBM data in the response" 26 | assert data["transcript"], "Transcript should not be empty" 27 | 28 | first_entry = data["transcript"][0] 29 | required_fields = ["speaker", "title", "content", "sentiment"] 30 | for field in required_fields: 31 | assert field in first_entry, f"Field '{field}' missing from transcript entry" 32 | 33 | assert first_entry["content"], "Transcript content should not be empty" 34 | 35 | 36 | @pytest.mark.asyncio 37 | async def test_fetch_earnings_calendar(): 38 | """Test fetching earnings calendar with real API call.""" 39 | api_key = os.getenv("ALPHAVANTAGE_API_KEY") 40 | assert api_key, "ALPHAVANTAGE_API_KEY must be set in environment" 41 | 42 | result = await fetch_earnings_calendar(symbol="AAPL", horizon="3month") 43 | 44 | assert isinstance(result, str), "API should return CSV data as string" 45 | 46 | # Parse CSV data 47 | csv_reader = csv.DictReader(StringIO(result)) 48 | rows = list(csv_reader) 49 | 50 | # Basic validation of structure 51 | assert rows, "CSV should contain at least one row" 52 | 53 | # Check required fields in first row 54 | first_row = rows[0] 55 | required_fields = ["symbol", "name", "reportDate"] 56 | for field in required_fields: 57 | assert field in first_row, f"Field '{field}' missing from CSV data" 58 | 59 | # Check if we found AAPL data 60 | apple_entries = [row for row in rows if row["symbol"] == "AAPL"] 61 | assert apple_entries, "Should find AAPL entries in the response" 62 | 63 | 64 | @pytest.mark.asyncio 65 | async def test_fetch_sma(): 66 | """Test fetching SMA (Simple Moving Average) with real API call.""" 67 | api_key = os.getenv("ALPHAVANTAGE_API_KEY") 68 | assert api_key, "ALPHAVANTAGE_API_KEY must be set in environment" 69 | 70 | # Test with common parameters that should work 71 | result = await fetch_sma( 72 | symbol="AAPL", interval="daily", time_period=20, series_type="close" 73 | ) 74 | 75 | assert isinstance(result, dict), "API should return JSON data as dict" 76 | 77 | # Check for expected structure in SMA response 78 | assert "Meta Data" in result, "Response should contain 'Meta Data' section" 79 | 80 | # Find the technical analysis key (it varies by indicator) 81 | tech_analysis_key = None 82 | for key in result.keys(): 83 | if "Technical Analysis" in key and "SMA" in key: 84 | tech_analysis_key = key 85 | break 86 | 87 | assert tech_analysis_key is not None, ( 88 | "Response should contain Technical Analysis section for SMA" 89 | ) 90 | 91 | # Validate metadata 92 | meta_data = result["Meta Data"] 93 | assert "1: Symbol" in meta_data, "Meta Data should contain symbol" 94 | assert "2: Indicator" in meta_data, "Meta Data should contain indicator type" 95 | assert "3: Last Refreshed" in meta_data, ( 96 | "Meta Data should contain last refreshed date" 97 | ) 98 | assert "4: Interval" in meta_data, "Meta Data should contain interval" 99 | assert "5: Time Period" in meta_data, "Meta Data should contain time period" 100 | assert "6: Series Type" in meta_data, "Meta Data should contain series type" 101 | 102 | assert meta_data["1: Symbol"] == "AAPL", "Symbol should match request" 103 | assert meta_data["2: Indicator"] == "Simple Moving Average (SMA)", ( 104 | "Indicator should be SMA" 105 | ) 106 | assert meta_data["4: Interval"] == "daily", "Interval should match request" 107 | assert meta_data["5: Time Period"] == 20, "Time period should match request" 108 | assert meta_data["6: Series Type"] == "close", "Series type should match request" 109 | 110 | # Validate technical analysis data 111 | sma_data = result[tech_analysis_key] 112 | assert isinstance(sma_data, dict), "SMA data should be a dictionary" 113 | assert len(sma_data) > 0, "SMA data should contain at least one data point" 114 | 115 | # Check structure of first data point 116 | first_date = list(sma_data.keys())[0] 117 | first_data_point = sma_data[first_date] 118 | assert isinstance(first_data_point, dict), "Each data point should be a dictionary" 119 | assert "SMA" in first_data_point, "Data point should contain SMA value" 120 | 121 | # Validate that SMA value is numeric 122 | sma_value = first_data_point["SMA"] 123 | assert isinstance(sma_value, str), "SMA value should be string (as returned by API)" 124 | float(sma_value) # Should not raise exception if valid number 125 | 126 | 127 | @pytest.mark.asyncio 128 | async def test_fetch_sma_with_month(): 129 | """Test fetching SMA with month parameter for intraday data.""" 130 | api_key = os.getenv("ALPHAVANTAGE_API_KEY") 131 | assert api_key, "ALPHAVANTAGE_API_KEY must be set in environment" 132 | 133 | # Test with intraday interval and month parameter 134 | result = await fetch_sma( 135 | symbol="MSFT", 136 | interval="60min", 137 | time_period=14, 138 | series_type="close", 139 | month="2024-01", 140 | ) 141 | 142 | assert isinstance(result, dict), "API should return JSON data as dict" 143 | assert "Meta Data" in result, "Response should contain 'Meta Data' section" 144 | 145 | # Validate that month parameter was applied 146 | meta_data = result["Meta Data"] 147 | assert "7: Time Zone" in meta_data, "Meta Data should contain time zone" 148 | assert meta_data["7: Time Zone"] == "US/Eastern", "Time zone should be US/Eastern" 149 | 150 | 151 | @pytest.mark.asyncio 152 | async def test_fetch_sma_csv_format(): 153 | """Test fetching SMA in CSV format.""" 154 | api_key = os.getenv("ALPHAVANTAGE_API_KEY") 155 | assert api_key, "ALPHAVANTAGE_API_KEY must be set in environment" 156 | 157 | result = await fetch_sma( 158 | symbol="GOOGL", 159 | interval="daily", 160 | time_period=10, 161 | series_type="close", 162 | datatype="csv", 163 | ) 164 | 165 | assert isinstance(result, str), "CSV format should return string data" 166 | assert len(result) > 0, "CSV data should not be empty" 167 | 168 | # Basic CSV validation 169 | lines = result.strip().split("\n") 170 | assert len(lines) > 1, "CSV should have header and at least one data row" 171 | 172 | # Check CSV header 173 | header = lines[0] 174 | assert "time" in header.lower(), "CSV should contain time column" 175 | assert "sma" in header.lower(), "CSV should contain SMA column" 176 | 177 | 178 | @pytest.mark.asyncio 179 | async def test_fetch_sma_with_response_limiting(): 180 | """Test SMA response limiting functionality to prevent token limit issues.""" 181 | api_key = os.getenv("ALPHAVANTAGE_API_KEY") 182 | assert api_key, "ALPHAVANTAGE_API_KEY must be set in environment" 183 | 184 | # Test with a small max_data_points to demonstrate limiting 185 | result = await fetch_sma( 186 | symbol="NVDA", 187 | interval="daily", 188 | time_period=14, 189 | series_type="close", 190 | max_data_points=10, # Limit to only 10 data points 191 | ) 192 | 193 | assert isinstance(result, dict), "API should return JSON data as dict" 194 | assert "Meta Data" in result, "Response should contain 'Meta Data' section" 195 | 196 | # Find the technical analysis key 197 | tech_analysis_key = None 198 | for key in result.keys(): 199 | if "Technical Analysis" in key and "SMA" in key: 200 | tech_analysis_key = key 201 | break 202 | 203 | assert tech_analysis_key is not None, ( 204 | "Response should contain Technical Analysis section for SMA" 205 | ) 206 | 207 | # Check that response was limited 208 | sma_data = result[tech_analysis_key] 209 | assert len(sma_data) <= 10, ( 210 | f"Response should be limited to 10 data points, got {len(sma_data)}" 211 | ) 212 | 213 | # Check for summary information if response was limited 214 | summary_key = f"{tech_analysis_key}_summary" 215 | if summary_key in result: 216 | summary = result[summary_key] 217 | assert "total_data_points_available" in summary, ( 218 | "Summary should show total available data points" 219 | ) 220 | assert "data_points_returned" in summary, ( 221 | "Summary should show returned data points" 222 | ) 223 | assert "note" in summary, "Summary should contain explanation note" 224 | assert summary["data_points_returned"] == len(sma_data), ( 225 | "Summary count should match actual data" 226 | ) 227 | 228 | # Verify dates are in descending order (most recent first) 229 | dates = list(sma_data.keys()) 230 | sorted_dates = sorted(dates, reverse=True) 231 | assert dates == sorted_dates, "Data points should be ordered by most recent first" 232 | 233 | 234 | @pytest.mark.asyncio 235 | async def test_fetch_sma_large_response_handling(): 236 | """Test SMA handling of potentially large responses.""" 237 | api_key = os.getenv("ALPHAVANTAGE_API_KEY") 238 | assert api_key, "ALPHAVANTAGE_API_KEY must be set in environment" 239 | 240 | # Test with default max_data_points (100) 241 | result = await fetch_sma( 242 | symbol="AAPL", 243 | interval="daily", 244 | time_period=20, 245 | series_type="close", 246 | # Using default max_data_points=100 247 | ) 248 | 249 | assert isinstance(result, dict), "API should return JSON data as dict" 250 | 251 | # Find the technical analysis key 252 | tech_analysis_key = None 253 | for key in result.keys(): 254 | if "Technical Analysis" in key and "SMA" in key: 255 | tech_analysis_key = key 256 | break 257 | 258 | assert tech_analysis_key is not None, ( 259 | "Response should contain Technical Analysis section" 260 | ) 261 | 262 | # Check that response respects the default limit 263 | sma_data = result[tech_analysis_key] 264 | assert len(sma_data) <= 100, ( 265 | f"Response should be limited to 100 data points by default, got {len(sma_data)}" 266 | ) 267 | 268 | # Verify all data points have valid SMA values 269 | for date, data_point in sma_data.items(): 270 | assert "SMA" in data_point, f"Data point for {date} should contain SMA value" 271 | sma_value = data_point["SMA"] 272 | assert isinstance(sma_value, str), "SMA value should be string" 273 | float(sma_value) # Should not raise exception if valid number 274 | ``` -------------------------------------------------------------------------------- /deploy/aws-stateless-mcp-lambda/lambda_function.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | AWS Lambda function for stateless AlphaVantage MCP Server. 3 | Based on aws-samples/sample-serverless-mcp-servers/stateless-mcp-on-lambda-python pattern. 4 | """ 5 | 6 | import asyncio 7 | import json 8 | import os 9 | import sys 10 | from typing import Any, Dict 11 | 12 | # Add the source directory to Python path for imports 13 | sys.path.insert(0, "/opt/python") 14 | 15 | # Import MCP components 16 | 17 | # Import AlphaVantage MCP server components 18 | from alphavantage_mcp_server.server import ( 19 | handle_list_tools, 20 | handle_call_tool, 21 | list_prompts, 22 | get_prompt, 23 | get_version, 24 | ) 25 | from alphavantage_mcp_server.oauth import ( 26 | OAuthResourceServer, 27 | create_oauth_config_from_env, 28 | ) 29 | 30 | 31 | def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: 32 | """ 33 | AWS Lambda handler for stateless MCP requests. 34 | Each request is handled independently without session state. 35 | """ 36 | try: 37 | # Parse the incoming request 38 | if "body" not in event: 39 | return create_error_response(400, "Missing request body") 40 | 41 | # Handle both string and already-parsed JSON bodies 42 | if isinstance(event["body"], str): 43 | try: 44 | request_data = json.loads(event["body"]) 45 | except json.JSONDecodeError: 46 | return create_error_response(400, "Invalid JSON in request body") 47 | else: 48 | request_data = event["body"] 49 | 50 | # Validate JSON-RPC format 51 | if not isinstance(request_data, dict) or "jsonrpc" not in request_data: 52 | return create_error_response(400, "Invalid JSON-RPC request") 53 | 54 | # Handle OAuth if enabled 55 | oauth_server = None 56 | oauth_enabled = os.environ.get("OAUTH_ENABLED", "false").lower() == "true" 57 | if oauth_enabled: 58 | oauth_config = create_oauth_config_from_env() 59 | if oauth_config: 60 | oauth_server = OAuthResourceServer(oauth_config) 61 | 62 | # Check authentication for non-initialize requests 63 | method = request_data.get("method", "") 64 | if method != "initialize": 65 | auth_result = validate_oauth_request(event, oauth_server) 66 | if not auth_result["authenticated"]: 67 | return auth_result["response"] 68 | 69 | # Process the MCP request 70 | response = asyncio.run(handle_mcp_request(request_data, oauth_server)) 71 | 72 | return { 73 | "statusCode": 200, 74 | "headers": { 75 | "Content-Type": "application/json", 76 | "Access-Control-Allow-Origin": "*", 77 | "Access-Control-Allow-Methods": "GET, POST, OPTIONS", 78 | "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Session-ID", 79 | }, 80 | "body": json.dumps(response), 81 | } 82 | 83 | except Exception as e: 84 | print(f"Lambda handler error: {str(e)}") 85 | return create_error_response(500, f"Internal server error: {str(e)}") 86 | 87 | 88 | async def handle_mcp_request( 89 | request_data: Dict[str, Any], oauth_server: OAuthResourceServer = None 90 | ) -> Dict[str, Any]: 91 | """ 92 | Handle MCP request in stateless mode. 93 | Each request creates a fresh server instance. 94 | """ 95 | method = request_data.get("method", "") 96 | request_id = request_data.get("id", 1) 97 | params = request_data.get("params", {}) 98 | 99 | try: 100 | # Handle different MCP methods 101 | if method == "initialize": 102 | return handle_initialize(request_id, params) 103 | 104 | elif method == "tools/list": 105 | return await handle_tools_list_request(request_id) 106 | 107 | elif method == "tools/call": 108 | return await handle_tools_call_request(request_id, params) 109 | 110 | elif method == "prompts/list": 111 | return await handle_prompts_list_request(request_id) 112 | 113 | elif method == "prompts/get": 114 | return await handle_prompts_get_request(request_id, params) 115 | 116 | else: 117 | return create_jsonrpc_error( 118 | request_id, -32601, f"Method not found: {method}" 119 | ) 120 | 121 | except Exception as e: 122 | print(f"MCP request error: {str(e)}") 123 | return create_jsonrpc_error(request_id, -32603, f"Internal error: {str(e)}") 124 | 125 | 126 | def handle_initialize(request_id: Any, params: Dict[str, Any]) -> Dict[str, Any]: 127 | """Handle MCP initialize request - stateless mode""" 128 | try: 129 | version = get_version() 130 | except Exception: 131 | version = "0.3.17" # Fallback version for Lambda 132 | 133 | return { 134 | "jsonrpc": "2.0", 135 | "id": request_id, 136 | "result": { 137 | "protocolVersion": "2024-11-05", 138 | "capabilities": { 139 | "experimental": {}, 140 | "prompts": {"listChanged": False}, 141 | "tools": {"listChanged": False}, 142 | }, 143 | "serverInfo": {"name": "alphavantage", "version": version}, 144 | }, 145 | } 146 | 147 | 148 | async def handle_tools_list_request(request_id: Any) -> Dict[str, Any]: 149 | """Handle tools/list request - get all available tools""" 150 | try: 151 | # Call the AlphaVantage server's handle_list_tools function directly 152 | tools = await handle_list_tools() 153 | 154 | # Convert MCP Tool objects to JSON-serializable format 155 | tools_json = [] 156 | for tool in tools: 157 | tool_dict = {"name": tool.name, "description": tool.description} 158 | if hasattr(tool, "inputSchema") and tool.inputSchema: 159 | tool_dict["inputSchema"] = tool.inputSchema 160 | tools_json.append(tool_dict) 161 | 162 | return {"jsonrpc": "2.0", "id": request_id, "result": {"tools": tools_json}} 163 | 164 | except Exception as e: 165 | print(f"Tools list error: {str(e)}") 166 | return create_jsonrpc_error( 167 | request_id, -32603, f"Failed to list tools: {str(e)}" 168 | ) 169 | 170 | 171 | async def handle_tools_call_request( 172 | request_id: Any, params: Dict[str, Any] 173 | ) -> Dict[str, Any]: 174 | """Handle tools/call request - execute a tool""" 175 | try: 176 | tool_name = params.get("name") 177 | arguments = params.get("arguments", {}) 178 | 179 | if not tool_name: 180 | return create_jsonrpc_error(request_id, -32602, "Missing tool name") 181 | 182 | # Call the tool using the AlphaVantage server's handle_call_tool function 183 | result = await handle_call_tool(tool_name, arguments) 184 | 185 | # Convert MCP result objects to JSON-serializable format 186 | content_list = [] 187 | if isinstance(result, list): 188 | for item in result: 189 | if hasattr(item, "text"): 190 | # TextContent object 191 | content_list.append({"type": "text", "text": item.text}) 192 | elif hasattr(item, "data"): 193 | # ImageContent object 194 | content_list.append( 195 | { 196 | "type": "image", 197 | "data": item.data, 198 | "mimeType": getattr(item, "mimeType", "image/png"), 199 | } 200 | ) 201 | elif hasattr(item, "uri"): 202 | # EmbeddedResource object 203 | content_list.append( 204 | { 205 | "type": "resource", 206 | "resource": { 207 | "uri": item.uri, 208 | "text": getattr(item, "text", ""), 209 | "mimeType": getattr(item, "mimeType", "text/plain"), 210 | }, 211 | } 212 | ) 213 | else: 214 | # Fallback for unknown types 215 | content_list.append({"type": "text", "text": str(item)}) 216 | else: 217 | # Single result 218 | if hasattr(result, "text"): 219 | content_list.append({"type": "text", "text": result.text}) 220 | else: 221 | content_list.append({"type": "text", "text": str(result)}) 222 | 223 | return {"jsonrpc": "2.0", "id": request_id, "result": {"content": content_list}} 224 | 225 | except Exception as e: 226 | print(f"Tool call error: {str(e)}") 227 | return create_jsonrpc_error( 228 | request_id, -32603, f"Tool execution failed: {str(e)}" 229 | ) 230 | 231 | 232 | async def handle_prompts_list_request(request_id: Any) -> Dict[str, Any]: 233 | """Handle prompts/list request""" 234 | try: 235 | # Call the AlphaVantage server's list_prompts function directly 236 | prompts = await list_prompts() 237 | 238 | # Convert to JSON-serializable format 239 | prompts_json = [] 240 | for prompt in prompts: 241 | prompt_dict = {"name": prompt.name, "description": prompt.description} 242 | if hasattr(prompt, "arguments") and prompt.arguments: 243 | prompt_dict["arguments"] = prompt.arguments 244 | prompts_json.append(prompt_dict) 245 | 246 | return {"jsonrpc": "2.0", "id": request_id, "result": {"prompts": prompts_json}} 247 | 248 | except Exception as e: 249 | print(f"Prompts list error: {str(e)}") 250 | return create_jsonrpc_error( 251 | request_id, -32603, f"Failed to list prompts: {str(e)}" 252 | ) 253 | 254 | 255 | async def handle_prompts_get_request( 256 | request_id: Any, params: Dict[str, Any] 257 | ) -> Dict[str, Any]: 258 | """Handle prompts/get request""" 259 | try: 260 | prompt_name = params.get("name") 261 | arguments = params.get("arguments", {}) 262 | 263 | if not prompt_name: 264 | return create_jsonrpc_error(request_id, -32602, "Missing prompt name") 265 | 266 | # Call the prompt using the AlphaVantage server's get_prompt function 267 | result = await get_prompt(prompt_name, arguments) 268 | 269 | return {"jsonrpc": "2.0", "id": request_id, "result": result} 270 | 271 | except Exception as e: 272 | print(f"Prompt get error: {str(e)}") 273 | return create_jsonrpc_error( 274 | request_id, -32603, f"Failed to get prompt: {str(e)}" 275 | ) 276 | 277 | 278 | def validate_oauth_request( 279 | event: Dict[str, Any], oauth_server: OAuthResourceServer 280 | ) -> Dict[str, Any]: 281 | """Validate OAuth authentication for the request""" 282 | try: 283 | # Extract authorization header 284 | headers = event.get("headers", {}) 285 | auth_header = headers.get("Authorization") or headers.get("authorization") 286 | 287 | if not auth_header or not auth_header.startswith("Bearer "): 288 | return { 289 | "authenticated": False, 290 | "response": create_oauth_error_response( 291 | 401, "invalid_token", "Missing or invalid authorization header" 292 | ), 293 | } 294 | 295 | # TODO: Implement OAuth token validation using oauth_server 296 | # For now, return authenticated=True to allow requests 297 | return {"authenticated": True} 298 | 299 | except Exception as e: 300 | return { 301 | "authenticated": False, 302 | "response": create_oauth_error_response( 303 | 500, "server_error", f"OAuth validation error: {str(e)}" 304 | ), 305 | } 306 | 307 | 308 | def create_error_response(status_code: int, message: str) -> Dict[str, Any]: 309 | """Create a standard error response""" 310 | return { 311 | "statusCode": status_code, 312 | "headers": { 313 | "Content-Type": "application/json", 314 | "Access-Control-Allow-Origin": "*", 315 | }, 316 | "body": json.dumps({"error": {"code": status_code, "message": message}}), 317 | } 318 | 319 | 320 | def create_jsonrpc_error(request_id: Any, code: int, message: str) -> Dict[str, Any]: 321 | """Create a JSON-RPC error response""" 322 | return { 323 | "jsonrpc": "2.0", 324 | "id": request_id, 325 | "error": {"code": code, "message": message}, 326 | } 327 | 328 | 329 | def create_oauth_error_response( 330 | status_code: int, error_type: str, description: str 331 | ) -> Dict[str, Any]: 332 | """Create an OAuth error response""" 333 | return { 334 | "statusCode": status_code, 335 | "headers": { 336 | "Content-Type": "application/json", 337 | "WWW-Authenticate": f'Bearer error="{error_type}", error_description="{description}"', 338 | "Access-Control-Allow-Origin": "*", 339 | }, 340 | "body": json.dumps({"error": error_type, "error_description": description}), 341 | } 342 | ``` -------------------------------------------------------------------------------- /src/alphavantage_mcp_server/oauth.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | OAuth 2.1 implementation for AlphaVantage MCP Server. 3 | 4 | This module provides OAuth 2.1 resource server functionality as required by the 5 | Model Context Protocol specification (2025-06-18). It supports configuration-driven 6 | OAuth that works with any compliant OAuth 2.1 authorization server. 7 | 8 | Key features: 9 | - OAuth 2.0 Protected Resource Metadata (RFC 9728) 10 | - Access token validation (JWT and introspection) 11 | - WWW-Authenticate header handling 12 | - Configuration-driven authorization server discovery 13 | - MCP Security Best Practices compliance 14 | 15 | Security Features: 16 | - Token audience validation (prevents token passthrough attacks) 17 | - Secure session ID generation and binding 18 | - User-specific session binding to prevent session hijacking 19 | - Proper error handling with OAuth-compliant responses 20 | """ 21 | 22 | import logging 23 | import secrets 24 | from dataclasses import dataclass 25 | from typing import Dict, List, Optional, Tuple, Union 26 | from urllib.parse import urljoin 27 | 28 | import httpx 29 | import jwt 30 | from starlette.requests import Request 31 | from starlette.responses import JSONResponse, Response 32 | 33 | logger = logging.getLogger(__name__) 34 | 35 | 36 | @dataclass 37 | class OAuthConfig: 38 | """Configuration for OAuth 2.1 resource server functionality.""" 39 | 40 | # Authorization server configuration 41 | authorization_server_url: str 42 | """URL of the OAuth 2.1 authorization server (e.g., https://auth.example.com)""" 43 | 44 | # Resource server identity 45 | resource_server_uri: str 46 | """Canonical URI of this MCP server (e.g., https://mcp.example.com)""" 47 | 48 | # Token validation settings 49 | token_validation_method: str = "jwt" 50 | """Method for token validation: 'jwt' or 'introspection'""" 51 | 52 | jwt_public_key: Optional[str] = None 53 | """Public key for JWT validation (PEM format)""" 54 | 55 | jwt_algorithm: str = "RS256" 56 | """JWT signing algorithm""" 57 | 58 | introspection_endpoint: Optional[str] = None 59 | """Token introspection endpoint URL (RFC 7662)""" 60 | 61 | introspection_client_id: Optional[str] = None 62 | """Client ID for introspection requests""" 63 | 64 | introspection_client_secret: Optional[str] = None 65 | """Client secret for introspection requests""" 66 | 67 | # Metadata configuration 68 | resource_metadata_path: str = "/.well-known/oauth-protected-resource" 69 | """Path for OAuth 2.0 Protected Resource Metadata endpoint""" 70 | 71 | # Optional scopes 72 | required_scopes: List[str] = None 73 | """List of required scopes for accessing this resource""" 74 | 75 | # Security settings 76 | session_binding_enabled: bool = True 77 | """Enable user-specific session binding for security""" 78 | 79 | def __post_init__(self): 80 | """Validate configuration after initialization.""" 81 | if not self.authorization_server_url: 82 | raise ValueError("authorization_server_url is required") 83 | if not self.resource_server_uri: 84 | raise ValueError("resource_server_uri is required") 85 | 86 | if self.token_validation_method == "jwt" and not self.jwt_public_key: 87 | logger.warning("JWT validation selected but no public key provided") 88 | elif ( 89 | self.token_validation_method == "introspection" 90 | and not self.introspection_endpoint 91 | ): 92 | raise ValueError( 93 | "introspection_endpoint required for introspection validation" 94 | ) 95 | 96 | if self.required_scopes is None: 97 | self.required_scopes = [] 98 | 99 | 100 | class OAuthError(Exception): 101 | """Base exception for OAuth-related errors.""" 102 | 103 | def __init__(self, error: str, description: str = None, status_code: int = 401): 104 | self.error = error 105 | self.description = description 106 | self.status_code = status_code 107 | super().__init__(f"{error}: {description}" if description else error) 108 | 109 | 110 | class TokenValidationResult: 111 | """Result of token validation.""" 112 | 113 | def __init__(self, valid: bool, claims: Dict = None, error: str = None): 114 | self.valid = valid 115 | self.claims = claims or {} 116 | self.error = error 117 | 118 | @property 119 | def subject(self) -> Optional[str]: 120 | """Get the subject (user) from token claims.""" 121 | return self.claims.get("sub") 122 | 123 | @property 124 | def audience(self) -> Optional[Union[str, List[str]]]: 125 | """Get the audience from token claims.""" 126 | return self.claims.get("aud") 127 | 128 | @property 129 | def scopes(self) -> List[str]: 130 | """Get the scopes from token claims.""" 131 | scope_claim = self.claims.get("scope", "") 132 | if isinstance(scope_claim, str): 133 | return scope_claim.split() if scope_claim else [] 134 | elif isinstance(scope_claim, list): 135 | return scope_claim 136 | return [] 137 | 138 | @property 139 | def user_id(self) -> Optional[str]: 140 | """Get a unique user identifier for session binding.""" 141 | # Use subject as the primary user identifier 142 | return self.subject 143 | 144 | 145 | class SecureSessionManager: 146 | """ 147 | Secure session management following MCP security best practices. 148 | 149 | Implements: 150 | - Secure, non-deterministic session IDs 151 | - User-specific session binding 152 | - Session validation 153 | """ 154 | 155 | def __init__(self): 156 | self._sessions: Dict[str, Dict] = {} 157 | 158 | def generate_session_id(self, user_id: str) -> str: 159 | """ 160 | Generate a secure session ID bound to a user. 161 | 162 | Format: <user_id_hash>:<secure_random_token> 163 | This prevents session hijacking by binding sessions to users. 164 | """ 165 | # Generate cryptographically secure random token 166 | secure_token = secrets.token_urlsafe(32) 167 | 168 | # Create a hash of user_id for binding (not reversible) 169 | import hashlib 170 | 171 | user_hash = hashlib.sha256(user_id.encode()).hexdigest()[:16] 172 | 173 | session_id = f"{user_hash}:{secure_token}" 174 | 175 | # Store session metadata 176 | self._sessions[session_id] = { 177 | "user_id": user_id, 178 | "created_at": __import__("time").time(), 179 | } 180 | 181 | logger.debug(f"Generated secure session ID for user: {user_id}") 182 | return session_id 183 | 184 | def validate_session(self, session_id: str, user_id: str) -> bool: 185 | """ 186 | Validate that a session ID belongs to the specified user. 187 | 188 | This prevents session hijacking attacks. 189 | """ 190 | if not session_id or session_id not in self._sessions: 191 | return False 192 | 193 | session_data = self._sessions[session_id] 194 | return session_data.get("user_id") == user_id 195 | 196 | def cleanup_expired_sessions(self, max_age_seconds: int = 3600): 197 | """Clean up expired sessions.""" 198 | current_time = __import__("time").time() 199 | expired_sessions = [ 200 | sid 201 | for sid, data in self._sessions.items() 202 | if current_time - data.get("created_at", 0) > max_age_seconds 203 | ] 204 | 205 | for sid in expired_sessions: 206 | del self._sessions[sid] 207 | 208 | if expired_sessions: 209 | logger.info(f"Cleaned up {len(expired_sessions)} expired sessions") 210 | 211 | 212 | class OAuthResourceServer: 213 | """OAuth 2.1 Resource Server implementation for MCP.""" 214 | 215 | def __init__(self, config: OAuthConfig): 216 | self.config = config 217 | self.session_manager = SecureSessionManager() 218 | self._http_client = httpx.AsyncClient() 219 | logger.info( 220 | f"Initialized OAuth resource server for {config.resource_server_uri}" 221 | ) 222 | 223 | async def get_protected_resource_metadata(self) -> Dict: 224 | """ 225 | Generate OAuth 2.0 Protected Resource Metadata (RFC 9728). 226 | 227 | Returns metadata that clients use to discover the authorization server. 228 | """ 229 | metadata = { 230 | "resource": self.config.resource_server_uri, 231 | "authorization_servers": [self.config.authorization_server_url], 232 | } 233 | 234 | if self.config.required_scopes: 235 | metadata["scopes_supported"] = self.config.required_scopes 236 | 237 | logger.debug(f"Generated resource metadata: {metadata}") 238 | return metadata 239 | 240 | async def handle_resource_metadata_request(self, request: Request) -> JSONResponse: 241 | """Handle requests to the protected resource metadata endpoint.""" 242 | try: 243 | metadata = await self.get_protected_resource_metadata() 244 | return JSONResponse( 245 | content=metadata, headers={"Content-Type": "application/json"} 246 | ) 247 | except Exception as e: 248 | logger.error(f"Error serving resource metadata: {e}") 249 | return JSONResponse( 250 | content={ 251 | "error": "server_error", 252 | "error_description": "Failed to generate metadata", 253 | }, 254 | status_code=500, 255 | ) 256 | 257 | def extract_bearer_token(self, request: Request) -> Optional[str]: 258 | """Extract Bearer token from Authorization header.""" 259 | auth_header = request.headers.get("Authorization", "") 260 | if not auth_header.startswith("Bearer "): 261 | return None 262 | return auth_header[7:] # Remove "Bearer " prefix 263 | 264 | async def validate_jwt_token(self, token: str) -> TokenValidationResult: 265 | """Validate JWT access token with audience validation.""" 266 | if not self.config.jwt_public_key: 267 | return TokenValidationResult(False, error="JWT public key not configured") 268 | 269 | try: 270 | # Decode and verify JWT with strict audience validation 271 | # This prevents token passthrough attacks (MCP Security Best Practice) 272 | claims = jwt.decode( 273 | token, 274 | self.config.jwt_public_key, 275 | algorithms=[self.config.jwt_algorithm], 276 | audience=self.config.resource_server_uri, # Strict audience validation 277 | options={"verify_aud": True}, # Ensure audience is verified 278 | ) 279 | 280 | logger.debug(f"JWT validation successful for subject: {claims.get('sub')}") 281 | return TokenValidationResult(True, claims) 282 | 283 | except jwt.ExpiredSignatureError: 284 | logger.warning("Token validation failed: Token expired") 285 | return TokenValidationResult(False, error="Token expired") 286 | except jwt.InvalidAudienceError: 287 | logger.warning( 288 | f"Token validation failed: Invalid audience. Expected: {self.config.resource_server_uri}" 289 | ) 290 | return TokenValidationResult(False, error="Invalid audience") 291 | except jwt.InvalidTokenError as e: 292 | logger.warning(f"Token validation failed: {str(e)}") 293 | return TokenValidationResult(False, error=f"Invalid token: {str(e)}") 294 | except Exception as e: 295 | logger.error(f"JWT validation error: {e}") 296 | return TokenValidationResult(False, error="Token validation failed") 297 | 298 | async def validate_token_introspection(self, token: str) -> TokenValidationResult: 299 | """Validate token using OAuth 2.0 Token Introspection (RFC 7662).""" 300 | if not self.config.introspection_endpoint: 301 | return TokenValidationResult( 302 | False, error="Introspection endpoint not configured" 303 | ) 304 | 305 | try: 306 | # Prepare introspection request 307 | auth = None 308 | if ( 309 | self.config.introspection_client_id 310 | and self.config.introspection_client_secret 311 | ): 312 | auth = ( 313 | self.config.introspection_client_id, 314 | self.config.introspection_client_secret, 315 | ) 316 | 317 | response = await self._http_client.post( 318 | self.config.introspection_endpoint, 319 | data={"token": token}, 320 | auth=auth, 321 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 322 | ) 323 | 324 | if response.status_code != 200: 325 | logger.warning( 326 | f"Introspection failed with status: {response.status_code}" 327 | ) 328 | return TokenValidationResult(False, error="Introspection failed") 329 | 330 | introspection_result = response.json() 331 | 332 | # Check if token is active 333 | if not introspection_result.get("active", False): 334 | return TokenValidationResult(False, error="Token inactive") 335 | 336 | # Validate audience (prevents token passthrough attacks) 337 | token_audience = introspection_result.get("aud") 338 | if token_audience: 339 | if isinstance(token_audience, list): 340 | if self.config.resource_server_uri not in token_audience: 341 | logger.warning( 342 | f"Token audience mismatch. Expected: {self.config.resource_server_uri}, Got: {token_audience}" 343 | ) 344 | return TokenValidationResult(False, error="Invalid audience") 345 | elif token_audience != self.config.resource_server_uri: 346 | logger.warning( 347 | f"Token audience mismatch. Expected: {self.config.resource_server_uri}, Got: {token_audience}" 348 | ) 349 | return TokenValidationResult(False, error="Invalid audience") 350 | 351 | logger.debug( 352 | f"Token introspection successful for subject: {introspection_result.get('sub')}" 353 | ) 354 | return TokenValidationResult(True, introspection_result) 355 | 356 | except Exception as e: 357 | logger.error(f"Token introspection error: {e}") 358 | return TokenValidationResult(False, error="Introspection failed") 359 | 360 | async def validate_access_token(self, token: str) -> TokenValidationResult: 361 | """Validate access token using configured method.""" 362 | if self.config.token_validation_method == "jwt": 363 | result = await self.validate_jwt_token(token) 364 | elif self.config.token_validation_method == "introspection": 365 | result = await self.validate_token_introspection(token) 366 | else: 367 | return TokenValidationResult(False, error="Unknown validation method") 368 | 369 | # Check required scopes if token is valid 370 | if result.valid and self.config.required_scopes: 371 | token_scopes = result.scopes 372 | missing_scopes = set(self.config.required_scopes) - set(token_scopes) 373 | if missing_scopes: 374 | logger.warning(f"Token missing required scopes: {missing_scopes}") 375 | return TokenValidationResult(False, error="Insufficient scopes") 376 | 377 | return result 378 | 379 | async def authenticate_request( 380 | self, request: Request, session_id: str = None 381 | ) -> Tuple[bool, Optional[TokenValidationResult]]: 382 | """ 383 | Authenticate an incoming request with session validation. 384 | 385 | Implements MCP security best practices: 386 | - Verifies all inbound requests when OAuth is enabled 387 | - Validates session binding to prevent hijacking 388 | 389 | Returns: 390 | Tuple of (is_authenticated, validation_result) 391 | """ 392 | token = self.extract_bearer_token(request) 393 | if not token: 394 | logger.debug("No Bearer token found in request") 395 | return False, None 396 | 397 | result = await self.validate_access_token(token) 398 | if not result.valid: 399 | logger.warning(f"Token validation failed: {result.error}") 400 | return False, result 401 | 402 | # Additional session validation if session binding is enabled 403 | if self.config.session_binding_enabled and session_id and result.user_id: 404 | if not self.session_manager.validate_session(session_id, result.user_id): 405 | logger.warning(f"Session validation failed for user: {result.user_id}") 406 | return False, TokenValidationResult(False, error="Invalid session") 407 | 408 | logger.debug(f"Request authenticated for subject: {result.subject}") 409 | return True, result 410 | 411 | def create_www_authenticate_header(self) -> str: 412 | """Create WWW-Authenticate header for 401 responses.""" 413 | metadata_url = urljoin( 414 | self.config.resource_server_uri, self.config.resource_metadata_path 415 | ) 416 | return f'Bearer resource="{metadata_url}"' 417 | 418 | async def create_unauthorized_response( 419 | self, error: str = "invalid_token", description: str = None 420 | ) -> Response: 421 | """Create a 401 Unauthorized response with proper headers.""" 422 | www_auth = self.create_www_authenticate_header() 423 | 424 | error_response = {"error": error} 425 | if description: 426 | error_response["error_description"] = description 427 | 428 | return JSONResponse( 429 | content=error_response, 430 | status_code=401, 431 | headers={"WWW-Authenticate": www_auth}, 432 | ) 433 | 434 | async def create_forbidden_response( 435 | self, error: str = "insufficient_scope", description: str = None 436 | ) -> Response: 437 | """Create a 403 Forbidden response.""" 438 | error_response = {"error": error} 439 | if description: 440 | error_response["error_description"] = description 441 | 442 | return JSONResponse(content=error_response, status_code=403) 443 | 444 | def generate_secure_session(self, user_id: str) -> str: 445 | """Generate a secure session ID for a user.""" 446 | return self.session_manager.generate_session_id(user_id) 447 | 448 | async def cleanup(self): 449 | """Cleanup resources.""" 450 | await self._http_client.aclose() 451 | self.session_manager.cleanup_expired_sessions() 452 | 453 | 454 | def create_oauth_config_from_env() -> Optional[OAuthConfig]: 455 | """Create OAuth configuration from environment variables.""" 456 | import os 457 | 458 | auth_server_url = os.getenv("OAUTH_AUTHORIZATION_SERVER_URL") 459 | resource_uri = os.getenv("OAUTH_RESOURCE_SERVER_URI") 460 | 461 | if not auth_server_url or not resource_uri: 462 | logger.info("OAuth environment variables not found, OAuth disabled") 463 | return None 464 | 465 | return OAuthConfig( 466 | authorization_server_url=auth_server_url, 467 | resource_server_uri=resource_uri, 468 | token_validation_method=os.getenv("OAUTH_TOKEN_VALIDATION_METHOD", "jwt"), 469 | jwt_public_key=os.getenv("OAUTH_JWT_PUBLIC_KEY"), 470 | jwt_algorithm=os.getenv("OAUTH_JWT_ALGORITHM", "RS256"), 471 | introspection_endpoint=os.getenv("OAUTH_INTROSPECTION_ENDPOINT"), 472 | introspection_client_id=os.getenv("OAUTH_INTROSPECTION_CLIENT_ID"), 473 | introspection_client_secret=os.getenv("OAUTH_INTROSPECTION_CLIENT_SECRET"), 474 | required_scopes=os.getenv("OAUTH_REQUIRED_SCOPES", "").split() 475 | if os.getenv("OAUTH_REQUIRED_SCOPES") 476 | else [], 477 | session_binding_enabled=os.getenv( 478 | "OAUTH_SESSION_BINDING_ENABLED", "true" 479 | ).lower() 480 | == "true", 481 | ) 482 | ```