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
│ ├── __main__.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 | <a href="https://glama.ai/mcp/servers/@calvernaz/alphavantage">
9 | <img width="380" height="200" src="https://glama.ai/mcp/servers/@calvernaz/alphavantage/badge" alt="Alpha Vantage Server MCP server" />
10 | </a>
11 |
12 | **MCP Server URL**: https://mcp.alphavantage.co
13 |
14 | **PyPi**: https://pypi.org/project/alphavantage-mcp/
15 |
16 | ## Configuration
17 |
18 | ### Getting an API Key
19 | 1. Sign up for a [Free Alphavantage API key](https://www.alphavantage.co/support/#api-key)
20 | 2. Add the API key to your environment variables as `ALPHAVANTAGE_API_KEY`
21 |
22 |
23 | ## Installation
24 |
25 | ### Option 1: Using uvx (Recommended)
26 |
27 | The easiest way to use the AlphaVantage MCP server is with `uvx`:
28 |
29 | ```bash
30 | # Run directly without installation
31 | uvx alphavantage-mcp
32 |
33 | # Or with specific arguments
34 | uvx alphavantage-mcp --server http --port 8080
35 | ```
36 |
37 | ### Option 2: Using pip
38 |
39 | ```bash
40 | pip install alphavantage-mcp
41 | alphavantage-mcp
42 | ```
43 |
44 | ### Option 3: From source
45 |
46 | ```bash
47 | git clone https://github.com/calvernaz/alphavantage.git
48 | cd alphavantage
49 | uv run alphavantage
50 | ```
51 |
52 | ## Server Modes
53 |
54 | The AlphaVantage server can run in two different modes:
55 |
56 | ### Stdio Server (Default)
57 | This is the standard MCP server mode used for tools like Claude Desktop.
58 |
59 | ```bash
60 | alphavantage
61 | # or explicitly:
62 | alphavantage --server stdio
63 | ```
64 |
65 | ### Streamable HTTP Server
66 | This mode provides real-time updates via HTTP streaming.
67 |
68 | ```bash
69 | alphavantage --server http --port 8080
70 | ```
71 |
72 | ### Streamable HTTP Server with OAuth 2.1 Authentication
73 | This mode adds OAuth 2.1 authentication to the HTTP server, following the MCP specification for secure access.
74 |
75 | ```bash
76 | alphavantage --server http --port 8080 --oauth
77 | ```
78 |
79 | #### OAuth Configuration
80 |
81 | When using the `--oauth` flag, the server requires OAuth 2.1 configuration via environment variables:
82 |
83 | **Required Environment Variables:**
84 | ```bash
85 | export OAUTH_AUTHORIZATION_SERVER_URL="https://your-auth-server.com/realms/your-realm"
86 | export OAUTH_RESOURCE_SERVER_URI="https://your-mcp-server.com"
87 | ```
88 |
89 | **Optional Environment Variables:**
90 | ```bash
91 | # Token validation method (default: jwt)
92 | export OAUTH_TOKEN_VALIDATION_METHOD="jwt" # or "introspection"
93 |
94 | # For JWT validation
95 | export OAUTH_JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
96 | export OAUTH_JWT_ALGORITHM="RS256" # default
97 |
98 | # For token introspection validation
99 | export OAUTH_INTROSPECTION_ENDPOINT="https://your-auth-server.com/realms/your-realm/protocol/openid-connect/token/introspect"
100 | export OAUTH_INTROSPECTION_CLIENT_ID="your-client-id"
101 | export OAUTH_INTROSPECTION_CLIENT_SECRET="your-client-secret"
102 |
103 | # Optional: Required scopes (space-separated)
104 | export OAUTH_REQUIRED_SCOPES="mcp:access mcp:read"
105 |
106 | # Optional: Enable session binding for additional security (default: true)
107 | export OAUTH_SESSION_BINDING_ENABLED="true"
108 | ```
109 |
110 | #### OAuth Features
111 |
112 | The OAuth implementation provides:
113 |
114 | - **OAuth 2.0 Protected Resource Metadata** endpoint (`/.well-known/oauth-protected-resource`)
115 | - **Bearer token authentication** for all MCP requests
116 | - **JWT and Token Introspection** validation methods
117 | - **MCP Security Best Practices** compliance:
118 | - Token audience validation (prevents token passthrough attacks)
119 | - Session hijacking prevention with secure session IDs
120 | - User-bound sessions for additional security
121 | - Proper WWW-Authenticate headers for 401 responses
122 |
123 | #### Example: Keycloak Configuration
124 |
125 | For testing with Keycloak:
126 |
127 | ```bash
128 | # Keycloak OAuth configuration
129 | export OAUTH_AUTHORIZATION_SERVER_URL="https://keycloak.example.com/realms/mcp-realm"
130 | export OAUTH_RESOURCE_SERVER_URI="https://mcp.example.com"
131 | export OAUTH_TOKEN_VALIDATION_METHOD="introspection"
132 | export OAUTH_INTROSPECTION_ENDPOINT="https://keycloak.example.com/realms/mcp-realm/protocol/openid-connect/token/introspect"
133 | export OAUTH_INTROSPECTION_CLIENT_ID="mcp-server"
134 | export OAUTH_INTROSPECTION_CLIENT_SECRET="your-keycloak-client-secret"
135 | export OAUTH_REQUIRED_SCOPES="mcp:access"
136 |
137 | # Start server with OAuth
138 | alphavantage --server http --port 8080 --oauth
139 | ```
140 |
141 | #### OAuth Client Flow
142 |
143 | When OAuth is enabled, MCP clients must:
144 |
145 | 1. **Discover** the authorization server via `GET /.well-known/oauth-protected-resource`
146 | 2. **Register** with the authorization server (if using Dynamic Client Registration)
147 | 3. **Obtain access tokens** from the authorization server
148 | 4. **Include tokens** in requests: `Authorization: Bearer <access-token>`
149 | 5. **Handle 401/403 responses** and refresh tokens as needed
150 |
151 | Options:
152 | - `--server`: Choose between `stdio` (default) or `http` server mode
153 | - `--port`: Specify the port for the Streamable HTTP server (default: 8080)
154 | - `--oauth`: Enable OAuth 2.1 authentication (requires `--server http`)
155 |
156 | ## 📊 Telemetry
157 |
158 | The AlphaVantage MCP server includes optional Prometheus metrics for monitoring and observability.
159 |
160 | ### Enabling Telemetry
161 |
162 | Set the following environment variables to enable telemetry:
163 |
164 | ```bash
165 | # Enable telemetry (default: true)
166 | export MCP_TELEMETRY_ENABLED=true
167 |
168 | # Server identification (optional)
169 | export MCP_SERVER_NAME=alphavantage
170 | export MCP_SERVER_VERSION=1.0.0
171 |
172 | # Metrics server port (default: 9464)
173 | export MCP_METRICS_PORT=9464
174 | ```
175 |
176 | ### Metrics Endpoint
177 |
178 | When telemetry is enabled, Prometheus metrics are available at:
179 |
180 | ```
181 | http://localhost:9464/metrics
182 | ```
183 |
184 | ### Available Metrics
185 |
186 | The server collects the following metrics for each tool call:
187 |
188 | - **`mcp_tool_calls_total`** - Total number of tool calls (labeled by tool and outcome)
189 | - **`mcp_tool_latency_seconds`** - Tool execution latency histogram
190 | - **`mcp_tool_request_bytes`** - Request payload size histogram
191 | - **`mcp_tool_response_bytes`** - Response payload size histogram
192 | - **`mcp_tool_active_concurrency`** - Active concurrent tool calls gauge
193 | - **`mcp_tool_errors_total`** - Total errors by type (timeout, bad_input, connection, unknown)
194 |
195 | ### Example Usage with Telemetry
196 |
197 | ```bash
198 | # Start server with telemetry enabled
199 | export MCP_TELEMETRY_ENABLED=true
200 | export MCP_SERVER_NAME=alphavantage-prod
201 | export ALPHAVANTAGE_API_KEY=your_api_key
202 | alphavantage --server http --port 8080
203 |
204 | # View metrics
205 | curl http://localhost:9464/metrics
206 | ```
207 |
208 | ## 🚀 AWS Serverless Deployment
209 |
210 | Deploy the AlphaVantage MCP Server on AWS Lambda using the stateless MCP pattern for production-ready, scalable deployment.
211 |
212 | ### Quick AWS Deployment
213 |
214 | ```bash
215 | cd deploy/aws-stateless-mcp-lambda
216 | export ALPHAVANTAGE_API_KEY=your_api_key_here
217 | ./deploy.sh
218 | ```
219 |
220 | **Features:**
221 | - ✅ **Stateless MCP pattern** - Perfect for Lambda's execution model
222 | - ✅ **Auto-scaling** - Handles any load with AWS Lambda + API Gateway
223 | - ✅ **Cost-effective** - Pay only for requests (~$1-5/month for typical usage)
224 | - ✅ **Production-ready** - Based on AWS official sample patterns
225 | - ✅ **OAuth 2.1 support** - Optional authentication for secure access
226 |
227 | **📖 Full Documentation:** See [AWS Deployment Guide](deploy/aws-stateless-mcp-lambda/README.md) for complete setup instructions, testing, monitoring, and troubleshooting.
228 |
229 | ### Usage with Claude Desktop
230 |
231 | #### Option 1: Using uvx (Recommended)
232 | Add this to your `claude_desktop_config.json`:
233 |
234 | ```json
235 | {
236 | "mcpServers": {
237 | "alphavantage": {
238 | "command": "uvx",
239 | "args": ["alphavantage-mcp"],
240 | "env": {
241 | "ALPHAVANTAGE_API_KEY": "YOUR_API_KEY_HERE"
242 | }
243 | }
244 | }
245 | }
246 | ```
247 |
248 | #### Option 2: From source
249 | If you cloned the repository, use this configuration:
250 |
251 | ```json
252 | {
253 | "mcpServers": {
254 | "alphavantage": {
255 | "command": "uv",
256 | "args": [
257 | "--directory",
258 | "<DIRECTORY-OF-CLONED-PROJECT>/alphavantage",
259 | "run",
260 | "alphavantage"
261 | ],
262 | "env": {
263 | "ALPHAVANTAGE_API_KEY": "YOUR_API_KEY_HERE"
264 | }
265 | }
266 | }
267 | }
268 | ```
269 | #### Running the Server in Streamable HTTP Mode
270 |
271 | **Using uvx:**
272 | ```json
273 | {
274 | "mcpServers": {
275 | "alphavantage": {
276 | "command": "uvx",
277 | "args": ["alphavantage-mcp", "--server", "http", "--port", "8080"],
278 | "env": {
279 | "ALPHAVANTAGE_API_KEY": "YOUR_API_KEY_HERE"
280 | }
281 | }
282 | }
283 | }
284 | ```
285 |
286 | **From source:**
287 | ```json
288 | {
289 | "mcpServers": {
290 | "alphavantage": {
291 | "command": "uv",
292 | "args": [
293 | "--directory",
294 | "<DIRECTORY-OF-CLONED-PROJECT>/alphavantage",
295 | "run",
296 | "alphavantage",
297 | "--server",
298 | "http",
299 | "--port",
300 | "8080"
301 | ],
302 | "env": {
303 | "ALPHAVANTAGE_API_KEY": "YOUR_API_KEY_HERE"
304 | }
305 | }
306 | }
307 | }
308 | ```
309 |
310 |
311 | ## 📺 Demo Video
312 |
313 | Watch a quick demonstration of the Alpha Vantage MCP Server in action:
314 |
315 | [](https://github.com/user-attachments/assets/bc9ecffb-eab6-4a4d-bbf6-9fc8178f15c3)
316 |
317 |
318 | ## 🔧 Development & Publishing
319 |
320 | ### Publishing to PyPI
321 |
322 | This project includes scripts for publishing to PyPI and TestPyPI:
323 |
324 | ```bash
325 | # Publish to TestPyPI (for testing)
326 | python scripts/publish.py --test
327 |
328 | # Publish to PyPI (production)
329 | python scripts/publish.py
330 |
331 | # Use uv publish instead of twine
332 | python scripts/publish.py --test --use-uv
333 | ```
334 |
335 | The script uses `twine` by default (recommended) but can also use `uv publish` with the `--use-uv` flag.
336 |
337 | ### GitHub Actions
338 |
339 | The repository includes a GitHub Actions workflow for automated publishing:
340 |
341 | - **Trusted Publishing**: Uses PyPA's official publish action with OpenID Connect
342 | - **Manual Trigger**: Can be triggered manually with options for TestPyPI vs PyPI
343 | - **Twine Fallback**: Supports both trusted publishing and twine-based publishing
344 |
345 | To set up publishing:
346 |
347 | 1. **For Trusted Publishing** (recommended):
348 | - Configure trusted publishing on PyPI/TestPyPI with your GitHub repository
349 | - No secrets needed - uses OpenID Connect
350 |
351 | 2. **For Token-based Publishing**:
352 | - Add `PYPI_API_TOKEN` and `TEST_PYPI_API_TOKEN` secrets to your repository
353 | - Use the "Use twine" option in the workflow dispatch
354 |
355 | ## 🤝 Contributing
356 |
357 | We welcome contributions from the community! To get started, check out our [contribution](CONTRIBUTING.md) guide for setup instructions,
358 | development tips, and guidelines.
```
--------------------------------------------------------------------------------
/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"
```
--------------------------------------------------------------------------------
/src/alphavantage_mcp_server/__main__.py:
--------------------------------------------------------------------------------
```python
1 | """Entry point for running alphavantage-mcp-server as a module."""
2 | from . import main
3 |
4 | if __name__ == "__main__":
5 | main()
6 |
```
--------------------------------------------------------------------------------
/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 |
```