Skip to content

Commit 4b0b288

Browse files
Merge pull request #19 from heroku/http_simple_tests
(depends on Http simple streamable http) E2E remote & local integration tests
2 parents b607403 + 7115e7f commit 4b0b288

File tree

12 files changed

+353
-8
lines changed

12 files changed

+353
-8
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

README.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,17 +93,17 @@ First run:
9393
export API_KEY=$(heroku config:get API_KEY -a $APP_NAME)
9494
```
9595

96-
In the following commands, use either `example_clients/test_streamable_http.py` if you ran the streamable HTTP server above, or `example_clients/test_sse.py` if you're running the SSE server.
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.
9797

9898
List tools:
9999
```bash
100-
python example_clients/test_streamable_http.py mcp list_tools | jq
100+
python example_clients/streamable_http_client.py mcp list_tools | jq
101101
```
102102

103103
Example tool call request:
104104
*NOTE: this will intentionally NOT work if you have set `STDIO_MODE_ONLY` to `true`.*
105105
```bash
106-
python example_clients/test_streamable_http.py mcp call_tool --args '{
106+
python example_clients/streamable_http_client.py mcp call_tool --args '{
107107
"name": "code_exec_python",
108108
"arguments": {
109109
"code": "import numpy as np; print(np.random.rand(50).tolist())",
@@ -118,12 +118,12 @@ There are two ways to easily test out your MCP server in STDIO mode:
118118
#### 1. Local STDIO - Example Python STDIO Client
119119
List tools:
120120
```bash
121-
python example_clients/test_stdio.py mcp list_tools | jq
121+
python example_clients/stdio_client.py mcp list_tools | jq
122122
```
123123

124124
Example tool call request:
125125
```bash
126-
python example_clients/test_stdio.py mcp call_tool --args '{
126+
python example_clients/stdio_client.py mcp call_tool --args '{
127127
"name": "code_exec_python",
128128
"arguments": {
129129
"code": "import numpy as np; print(np.random.rand(50).tolist())",
@@ -176,12 +176,12 @@ There are two ways to test out your remote MCP server in STDIO mode:
176176
#### 1. Remote STDIO - Example Python STDIO Client, Running On-Server
177177
To run against your deployed code, you can run the example client code on your deployed server inside a one-off dyno:
178178
```bash
179-
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'
180180
```
181181
or:
182182
```bash
183183
heroku run --app $APP_NAME -- bash -c '
184-
python -m example_clients.test_stdio mcp call_tool --args '\''{
184+
python -m example_clients.stdio_client mcp call_tool --args '\''{
185185
"name": "code_exec_python",
186186
"arguments": {
187187
"code": "import numpy as np; print(np.random.rand(50).tolist())",

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.9.2
1+
mcp[client,server]==1.9.2
22
fastapi==0.115.12
33
uvicorn==0.34.2
44
python-dotenv==1.1.0
55
mando==0.8.2
6+
# --- dev / CI only -------------------------------------------------
7+
pytest>=8.0
8+
pytest-asyncio>=0.23

tests/README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# End-to-End Test Suite (`tests/`)
2+
3+
You know what's better than unit tests? End to end integration tests.
4+
5+
These pytest tests run **every MCP transport** this repo supports, both locally and (optionally) against a deployed Heroku app.
6+
7+
| Context ID | Transport exercised | Needs running **web** dyno? |
8+
|-------------------|-----------------------------------------------------------|-----------------------------|
9+
| `http_local` | Streamable HTTP on `localhost:8000/mcp/` ||
10+
| `sse_local` | SSE on `localhost:8000/mcp/sse` ||
11+
| `stdio_local` | STDIO (example client boots its own server) ||
12+
| `remote` | Transport named in **`$REMOTE_SERVER_TRANSPORT_MODULE`**&nbsp;(`streamable_http_server` or `sse_server`) served by your web dyno | **Yes** |
13+
| `remote_stdio` | STDIO via a **one-off Heroku dyno** | **No** – works even at `web=0` |
14+
15+
*If the web dyno is asleep or scaled to `0`, the `remote` tests auto-skip.
16+
`remote_stdio` still runs, because it spins up its own one-off dyno.*
17+
18+
To scale up the number of web dynos running in your app to 1, run:
19+
```bash
20+
heroku ps:scale web=1 -a "$APP_NAME"
21+
```
22+
23+
---
24+
25+
## 1 · Install dependencies
26+
27+
```bash
28+
# inside an activated venv
29+
pip install -r requirements.txt
30+
```
31+
32+
## 2 · Run local transports only
33+
```bash
34+
pytest tests -q
35+
```
36+
37+
## 3 - Run local & remote transports
38+
```bash
39+
MCP_SERVER_URL=$(heroku info -s -a "$APP_NAME" | grep web_url | cut -d= -f2 | tr -d '\n') \
40+
API_KEY=$(heroku config:get API_KEY -a "$APP_NAME") \
41+
pytest tests -q
42+
```

tests/__init__.py

Whitespace-only changes.

tests/client_runner.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""
2+
Launch one of the example_clients in a fresh subprocess and return STDOUT.
3+
"""
4+
from __future__ import annotations
5+
import asyncio, os, sys, subprocess, textwrap
6+
from pathlib import Path
7+
from typing import Sequence, Mapping
8+
9+
ROOT = Path(__file__).resolve().parents[1]
10+
EXAMPLES = ROOT / "example_clients"
11+
PYTHON_EXE = sys.executable
12+
13+
14+
async def call_client(
15+
module_name: str,
16+
cli_args: Sequence[str],
17+
extra_env: Mapping[str, str] | None = None,
18+
) -> str:
19+
env = os.environ.copy() | (extra_env or {})
20+
21+
# ----- NEW: run STDIO client inside a Heroku one-off dyno ----------
22+
if module_name == "remote_stdio":
23+
app = env.get("APP_NAME")
24+
if not app:
25+
raise RuntimeError("APP_NAME env-var required for remote_stdio context")
26+
cmd = [
27+
"heroku", "run", "--exit-code", "--app", app, "--",
28+
"python", "-m", "example_clients.stdio_client", "mcp", *cli_args,
29+
]
30+
# -------------------------------------------------------------------
31+
else:
32+
cmd = [
33+
PYTHON_EXE,
34+
"-m", f"example_clients.{module_name}",
35+
"mcp", *cli_args,
36+
]
37+
38+
proc = await asyncio.create_subprocess_exec(
39+
*cmd, cwd=ROOT, env=env,
40+
stdout=subprocess.PIPE, stderr=subprocess.PIPE
41+
)
42+
out_b, err_b = await proc.communicate()
43+
out, err = out_b.decode(), err_b.decode()
44+
if proc.returncode:
45+
raise RuntimeError(
46+
textwrap.dedent(
47+
f"""
48+
Client {module_name} exited with {proc.returncode}
49+
CMD : {' '.join(cmd)}
50+
STDERR:
51+
{err}"""
52+
)
53+
)
54+
return out

0 commit comments

Comments
 (0)