Skip to content

Commit bb3b0e6

Browse files
Merge pull request #20 from heroku/http_simple
Adding transport - Stateless Streamable HTTP
2 parents 562178b + d59bb28 commit bb3b0e6

17 files changed

+461
-48
lines changed

.github/workflows/test.yml

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
name: MCP Tests
2+
3+
on:
4+
push:
5+
branches:
6+
pull_request:
7+
8+
jobs:
9+
###########################################################################
10+
# 1 - Local integration tests (always run)
11+
###########################################################################
12+
local:
13+
runs-on: ubuntu-latest
14+
15+
# Dummy key lets the clients authenticate against the local servers.
16+
env:
17+
API_KEY: ci-test-key
18+
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- uses: actions/setup-python@v5
23+
with:
24+
python-version: "3.11"
25+
26+
# Optional: cache wheels to speed up numpy / scipy installs
27+
- uses: actions/cache@v4
28+
with:
29+
path: ~/.cache/pip
30+
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
31+
32+
- name: Install dependencies
33+
run: |
34+
python -m pip install --upgrade pip
35+
pip install -r requirements.txt
36+
pip install -r requirements-dev.txt
37+
38+
- name: Run pytest (local transports)
39+
run: pytest -q
40+
41+
42+
###########################################################################
43+
# 2 - Remote smoke-test (only when secrets are set)
44+
#
45+
# • Put MCP_SERVER_URL → “https://<app>.herokuapp.com”
46+
# • Put API_KEY → same key you set as Heroku config var
47+
#
48+
# The fixture auto-skips the remote case if these are missing, so
49+
# the job is conditionally *created* only when both secrets exist.
50+
###########################################################################
51+
remote:
52+
if: ${{ secrets.MCP_SERVER_URL != '' && secrets.API_KEY != '' }}
53+
runs-on: ubuntu-latest
54+
55+
env:
56+
MCP_SERVER_URL: ${{ secrets.MCP_SERVER_URL }}
57+
API_KEY: ${{ secrets.API_KEY }}
58+
59+
steps:
60+
- uses: actions/checkout@v4
61+
62+
- uses: actions/setup-python@v5
63+
with:
64+
python-version: "3.11"
65+
66+
- uses: actions/cache@v4
67+
with:
68+
path: ~/.cache/pip
69+
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
70+
71+
- name: Install dependencies
72+
run: |
73+
python -m pip install --upgrade pip
74+
pip install -r requirements.txt
75+
76+
# We reuse the *same* test-suite; the fixture detects MCP_SERVER_URL
77+
# and adds the “remote” parameter automatically.
78+
- name: Run pytest (remote smoke)
79+
run: pytest -q

Procfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
web: uvicorn src.sse_server:app --host=0.0.0.0 --port=${PORT:-8000} --workers=${WEB_CONCURRENCY:-1}
1+
web: uvicorn src.${REMOTE_SERVER_TRANSPORT_MODULE:-streamable_http_server}:app --host=0.0.0.0 --port=${PORT:-8000} --workers=${WEB_CONCURRENCY:-1}
22
mcp-python: python -m src.stdio_server

README.md

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66
- [Manual Deployment](#manual-deployment)
77
- [**Set Required Environment Variables from Heroku CLI**](#set-required-environment-variables-from-heroku-cli)
88
- [Local Testing](#local-testing)
9-
- [Local SSE](#local-sse)
10-
- [Local SSE - Example Requests](#local-sse---example-requests)
9+
- [Local Streamable HTTP, SSE](#local-streamable-http-sse)
10+
- [Local Streamable HTTP, SSE - Example Requests](#local-streamable-http-sse---example-requests)
1111
- [Local STDIO](#local-stdio)
1212
- [1. Local STDIO - Example Python STDIO Client](#1-local-stdio---example-python-stdio-client)
1313
- [2. Local STDIO - Direct Calls](#2-local-stdio---direct-calls)
1414
- [Remote Testing](#remote-testing)
15-
- [Remote SSE](#remote-sse)
15+
- [Remote Streamable HTTP, SSE](#remote-streamable-http-sse)
1616
- [Remote STDIO](#remote-stdio)
1717
- [1. Remote STDIO - Example Python STDIO Client, Running On-Server](#1-remote-stdio---example-python-stdio-client-running-on-server)
1818
- [2. Remote STDIO - Direct Calls to One-Off Dyno](#2-remote-stdio---direct-calls-to-one-off-dyno)
@@ -35,6 +35,8 @@ heroku buildpacks:set heroku/python -a $APP_NAME
3535
# set a private API key that you create, for example:
3636
heroku config:set API_KEY=$(openssl rand -hex 32) -a $APP_NAME
3737
heroku config:set STDIO_MODE_ONLY=<true/false> -a $APP_NAME
38+
# set the remote server type (module) that your web process will use (only relevant for web deployments)
39+
heroku config:set REMOTE_SERVER_TRANSPORT_MODULE=<streamable_http_server/sse_server>
3840
```
3941
*Note: we recommend setting `STDIO_MODE_ONLY` to `true` for security and code execution isolation security in non-dev environments.*
4042

@@ -67,38 +69,41 @@ heroku logs --tail -a $APP_NAME
6769
```
6870

6971
## Local Testing
70-
### Local SSE
7172
One-time packages installation:
7273
```bash
7374
virtualenv venv
7475
source venv/bin/activate
7576
pip install -r requirements.txt
7677
```
7778

78-
If you're testing SSE, in one terminal pane you'll need to start the server:
79-
```
79+
### Local Streamable HTTP, SSE
80+
If you're testing (stateless) Streamable HTTP OR SSE, in one terminal pane you'll need to start the server:
81+
```bash
8082
source venv/bin/activate
8183
export API_KEY=$(heroku config:get API_KEY -a $APP_NAME)
82-
uvicorn src.sse_server:app --reload
84+
# Either run src.streamable_http_server or src.sse_server, here:
85+
uvicorn src.streamable_http_server:app --reload
8386
```
84-
*Running with --reload is optional, but great for local development*
87+
*Running with `--reload` is optional, but great for local development*
8588

8689
Next, in a new pane, you can try running some queries against your server:
87-
#### Local SSE - Example Requests
90+
#### Local Streamable HTTP, SSE - Example Requests
8891
First run:
8992
```bash
9093
export API_KEY=$(heroku config:get API_KEY -a $APP_NAME)
9194
```
9295

96+
In the following commands, use either `example_clients/streamable_http_client.py` if you ran the streamable HTTP server above, or `example_clients/sse_client.py` if you're running the SSE server.
97+
9398
List tools:
9499
```bash
95-
python example_clients/test_sse.py mcp list_tools | jq
100+
python example_clients/streamable_http_client.py mcp list_tools | jq
96101
```
97102

98103
Example tool call request:
99104
*NOTE: this will intentionally NOT work if you have set `STDIO_MODE_ONLY` to `true`.*
100105
```bash
101-
python example_clients/test_sse.py mcp call_tool --args '{
106+
python example_clients/streamable_http_client.py mcp call_tool --args '{
102107
"name": "code_exec_python",
103108
"arguments": {
104109
"code": "import numpy as np; print(np.random.rand(50).tolist())",
@@ -113,12 +118,12 @@ There are two ways to easily test out your MCP server in STDIO mode:
113118
#### 1. Local STDIO - Example Python STDIO Client
114119
List tools:
115120
```bash
116-
python example_clients/test_stdio.py mcp list_tools | jq
121+
python example_clients/stdio_client.py mcp list_tools | jq
117122
```
118123

119124
Example tool call request:
120125
```bash
121-
python example_clients/test_stdio.py mcp call_tool --args '{
126+
python example_clients/stdio_client.py mcp call_tool --args '{
122127
"name": "code_exec_python",
123128
"arguments": {
124129
"code": "import numpy as np; print(np.random.rand(50).tolist())",
@@ -143,34 +148,40 @@ EOF
143148

144149
## Remote Testing
145150

146-
### Remote SSE
147-
To test your remote `SSE` server, you'll need to make sure a web process is actually spun up. To save on costs, by default this repository doesn't spin up web dynos on creation, as many folks only want to use `STDIO` mode (local and one-off dyno) requests:
148-
```
151+
### Remote Streamable HTTP, SSE
152+
To test your remote `Streamble HTTP` or `SSE` server, you'll need to make sure a web process is actually spun up. To save on costs, by default this repository doesn't spin up web dynos on creation, as many folks only want to use `STDIO` mode (local and one-off dyno) requests:
153+
```bash
149154
heroku ps:scale web=1 -a $APP_NAME
150155
```
151156
You only need to do this once, unless you spin back down to 0 web dynos to save on costs (`heroku ps:scale web=0 -a $APP_NAME`). To confirm currently running dynos, use `heroku ps -a $APP_NAME`.
152157

158+
By default, this app deploys a Streamable HTTP MCP server - if you want to deploy an SSE server, run:
159+
```bash
160+
heroku config:set REMOTE_SERVER_TRANSPORT_MODULE=sse_server
161+
```
162+
Which will affect which server is run in the Procfile.
163+
153164
Next, run:
154165

155166
```bash
156167
export API_KEY=$(heroku config:get API_KEY -a $APP_NAME)
157168
export MCP_SERVER_URL=$(heroku info -s -a $APP_NAME | grep web_url | cut -d= -f2)
158169
```
159170

160-
Next, you can run the same queries as shown in the [Local SSE - Example Requests](#local-sse---example-requests) testing section - because you've set `MCP_SERVER_URL`, the client will call out to your deployed server.
171+
Next, you can run the same queries as shown in the [Local SSE - Example Requests](#local-streamable-http-sse---example-requests) testing section - because you've set `MCP_SERVER_URL`, the client will call out to your deployed server.
161172

162173
### Remote STDIO
163174
There are two ways to test out your remote MCP server in STDIO mode:
164175

165176
#### 1. Remote STDIO - Example Python STDIO Client, Running On-Server
166177
To run against your deployed code, you can run the example client code on your deployed server inside a one-off dyno:
167178
```bash
168-
heroku run --app $APP_NAME -- bash -c 'python -m example_clients.test_stdio mcp list_tools | jq'
179+
heroku run --app $APP_NAME -- bash -c 'python -m example_clients.stdio_client mcp list_tools | jq'
169180
```
170181
or:
171182
```bash
172183
heroku run --app $APP_NAME -- bash -c '
173-
python -m example_clients.test_stdio mcp call_tool --args '\''{
184+
python -m example_clients.stdio_client mcp call_tool --args '\''{
174185
"name": "code_exec_python",
175186
"arguments": {
176187
"code": "import numpy as np; print(np.random.rand(50).tolist())",

app.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
"description": "Only allow tool requests via STDIO mode?",
1717
"value": "false"
1818
},
19+
"REMOTE_SERVER_TRANSPORT_MODULE": {
20+
"description": "Tranport module name used for deployed web app (applicable when web formation size is >0). `streamable_http_server` or `sse_server`.",
21+
"value": "streamable_http_server"
22+
},
1923
"USE_TEMP_DIR": {
2024
"description": "Run Python code in a temporary virtualenv (not a sandbox, but does isolate packages).",
2125
"value": "false"
File renamed without changes.
File renamed without changes.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Spins up a test client to interact with MCP server in SSE-mode."""
2+
import os
3+
import asyncio
4+
import json
5+
import sys
6+
from mando import command, main
7+
from mcp import ClientSession
8+
from mcp.client.streamable_http import streamablehttp_client
9+
10+
API_KEY=os.environ.get('API_KEY')
11+
MCP_SERVER_URL = os.getenv("MCP_SERVER_URL", "http://localhost:8000").rstrip("/") + '/mcp/'
12+
13+
async def run(method_name: str, raw_args: str = None):
14+
"""Generalized runner for MCP client methods."""
15+
try:
16+
args = json.loads(raw_args) if raw_args else {}
17+
except json.JSONDecodeError:
18+
raise ValueError(f"Could not parse JSON args: {raw_args}")
19+
20+
headers = {"Authorization": f"Bearer {API_KEY}"}
21+
22+
async with streamablehttp_client(MCP_SERVER_URL, headers=headers) as (read_stream, write_stream, get_session_id):
23+
async with ClientSession(read_stream, write_stream) as session:
24+
await session.initialize()
25+
method = getattr(session, method_name)
26+
return await method(**args)
27+
28+
29+
@command
30+
def mcp(method_name, args=None):
31+
result = asyncio.run(run(method_name, args))
32+
print(json.dumps(result.model_dump(), indent=2))
33+
34+
if __name__ == "__main__" and not hasattr(sys, "ps1"):
35+
main()

pytest.ini

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[pytest]
2+
addopts = -ra
3+
asyncio_mode = auto
4+
asyncio_default_fixture_loop_scope = function

requirements.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
mcp==1.7.1
1+
mcp[client,server]==1.9.2
22
fastapi==0.115.12
33
uvicorn==0.34.3
44
python-dotenv==1.1.0
55
mando==0.8.2
6+
# --- dev / CI only -------------------------------------------------
7+
pytest>=8.0
8+
pytest-asyncio>=0.23

src/api_key_middleware.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from fastapi import Request
2+
from fastapi.responses import JSONResponse
3+
from starlette.middleware.base import BaseHTTPMiddleware
4+
# local:
5+
from src import config
6+
7+
API_KEY = config.get_env_variable("API_KEY")
8+
9+
# MCP is still working on ironing out SSE authentication protocols, so for now we'll build our own middleware layer:
10+
class APIKeyMiddleware(BaseHTTPMiddleware):
11+
async def dispatch(self, request: Request, call_next):
12+
auth_header = request.headers.get("authorization")
13+
api_key_header = request.headers.get("x-api-key")
14+
token = None
15+
16+
if auth_header and auth_header.lower().startswith("bearer "):
17+
token = auth_header[7:]
18+
elif api_key_header:
19+
token = api_key_header
20+
21+
if token != API_KEY:
22+
return JSONResponse({"error": "Unauthorized"}, status_code=401)
23+
24+
return await call_next(request)

0 commit comments

Comments
 (0)