Skip to content

Commit c05f990

Browse files
authored
test(fetch_tools): add MCP mock server for integration testing (#68)
1 parent 3cde479 commit c05f990

File tree

9 files changed

+474
-178
lines changed

9 files changed

+474
-178
lines changed

.github/workflows/ci.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ jobs:
4646
steps:
4747
- name: Checkout repository
4848
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
49+
with:
50+
submodules: true
4951

5052
- name: Setup Nix
5153
uses: ./.github/actions/setup-nix

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[submodule "vendor/stackone-ai-node"]
2+
path = vendor/stackone-ai-node
3+
url = https://github.com/StackOneHQ/stackone-ai-node.git

flake.nix

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,17 +98,31 @@
9898

9999
# security
100100
gitleaks
101+
102+
# Node.js for MCP mock server
103+
bun
104+
pnpm_10
105+
typescript-go
101106
];
102107

103108
shellHook = ''
104109
echo "StackOne AI Python SDK development environment"
105110
106-
# Install dependencies only if .venv is missing or uv.lock is newer
111+
# Install Python dependencies only if .venv is missing or uv.lock is newer
107112
if [ ! -d .venv ] || [ uv.lock -nt .venv ]; then
108-
echo "📦 Installing dependencies..."
113+
echo "📦 Installing Python dependencies..."
109114
uv sync --all-extras
110115
fi
111116
117+
# Install Node.js dependencies for MCP mock server (used in tests)
118+
if [ -f vendor/stackone-ai-node/package.json ]; then
119+
if [ ! -f vendor/stackone-ai-node/node_modules/.pnpm/lock.yaml ] || \
120+
[ vendor/stackone-ai-node/pnpm-lock.yaml -nt vendor/stackone-ai-node/node_modules/.pnpm/lock.yaml ]; then
121+
echo "📦 Installing MCP mock server dependencies..."
122+
(cd vendor/stackone-ai-node && pnpm install --frozen-lockfile)
123+
fi
124+
fi
125+
112126
# Install git hooks
113127
${config.pre-commit.installationScript}
114128
'';

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ asyncio_mode = "strict"
6969
asyncio_default_fixture_loop_scope = "function"
7070
markers = [
7171
"asyncio: mark test as async",
72+
"integration: mark test as integration test requiring MCP mock server",
7273
]
7374

7475
[tool.ruff.lint.per-file-ignores]

tests/conftest.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Pytest configuration and fixtures for StackOne AI tests."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import shutil
7+
import socket
8+
import subprocess
9+
import time
10+
from collections.abc import Generator
11+
from pathlib import Path
12+
13+
import pytest
14+
15+
16+
def _find_free_port() -> int:
17+
"""Find a free port on localhost."""
18+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
19+
s.bind(("", 0))
20+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
21+
return s.getsockname()[1]
22+
23+
24+
def _wait_for_server(host: str, port: int, timeout: float = 10.0) -> bool:
25+
"""Wait for a server to become available."""
26+
start = time.time()
27+
while time.time() - start < timeout:
28+
try:
29+
with socket.create_connection((host, port), timeout=1.0):
30+
return True
31+
except OSError:
32+
time.sleep(0.1)
33+
return False
34+
35+
36+
@pytest.fixture(scope="session")
37+
def mcp_mock_server() -> Generator[str, None, None]:
38+
"""
39+
Start the Node MCP mock server for integration tests.
40+
41+
This fixture starts the Hono-based MCP mock server using bun,
42+
importing from the stackone-ai-node submodule.
43+
44+
Requires: bun to be installed (via Nix flake).
45+
46+
Usage:
47+
def test_mcp_integration(mcp_mock_server):
48+
toolset = StackOneToolSet(
49+
api_key="test-key",
50+
base_url=mcp_mock_server,
51+
)
52+
tools = toolset.fetch_tools()
53+
"""
54+
project_root = Path(__file__).parent.parent
55+
serve_script = project_root / "tests" / "mocks" / "serve.ts"
56+
vendor_dir = project_root / "vendor" / "stackone-ai-node"
57+
58+
if not serve_script.exists():
59+
pytest.skip("MCP mock server script not found at tests/mocks/serve.ts")
60+
61+
if not vendor_dir.exists():
62+
pytest.skip("stackone-ai-node submodule not found. Run 'git submodule update --init'")
63+
64+
# Check for bun runtime
65+
bun_path = shutil.which("bun")
66+
if not bun_path:
67+
pytest.skip("bun not found. Install via Nix flake.")
68+
69+
port = _find_free_port()
70+
base_url = f"http://localhost:{port}"
71+
72+
# Install dependencies if needed
73+
node_modules = vendor_dir / "node_modules"
74+
if not node_modules.exists():
75+
subprocess.run(
76+
[bun_path, "install"],
77+
cwd=vendor_dir,
78+
check=True,
79+
capture_output=True,
80+
)
81+
82+
# Start the server from project root
83+
env = os.environ.copy()
84+
env["PORT"] = str(port)
85+
86+
process = subprocess.Popen(
87+
[bun_path, "run", str(serve_script)],
88+
cwd=project_root,
89+
env=env,
90+
stdout=subprocess.PIPE,
91+
stderr=subprocess.PIPE,
92+
)
93+
94+
try:
95+
# Wait for server to start
96+
if not _wait_for_server("localhost", port, timeout=30.0):
97+
stdout, stderr = process.communicate(timeout=5)
98+
raise RuntimeError(
99+
f"MCP mock server failed to start:\nstdout: {stdout.decode()}\nstderr: {stderr.decode()}"
100+
)
101+
102+
yield base_url
103+
104+
finally:
105+
process.terminate()
106+
try:
107+
process.wait(timeout=5)
108+
except subprocess.TimeoutExpired:
109+
process.kill()
110+
process.wait()
111+
112+
113+
@pytest.fixture
114+
def mcp_server_url(mcp_mock_server: str) -> str:
115+
"""Alias for mcp_mock_server for clearer test naming."""
116+
return mcp_mock_server

tests/mocks/serve.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* Standalone HTTP server for MCP mock testing.
3+
* Imports createMcpApp from stackone-ai-node vendor submodule.
4+
*
5+
* Usage:
6+
* bun run tests/mocks/serve.ts [port]
7+
*/
8+
import { Hono } from "hono";
9+
import { cors } from "hono/cors";
10+
import {
11+
accountMcpTools,
12+
createMcpApp,
13+
defaultMcpTools,
14+
exampleBamboohrTools,
15+
mixedProviderTools,
16+
} from "../../vendor/stackone-ai-node/mocks/mcp-server";
17+
18+
const port = parseInt(process.env.PORT || Bun.argv[2] || "8787", 10);
19+
20+
// Create the MCP app with all test tool configurations
21+
const mcpApp = createMcpApp({
22+
accountTools: {
23+
default: defaultMcpTools,
24+
acc1: accountMcpTools.acc1,
25+
acc2: accountMcpTools.acc2,
26+
acc3: accountMcpTools.acc3,
27+
"test-account": accountMcpTools["test-account"],
28+
mixed: mixedProviderTools,
29+
"your-bamboohr-account-id": exampleBamboohrTools,
30+
"your-stackone-account-id": exampleBamboohrTools,
31+
},
32+
});
33+
34+
// Create the main app with CORS and mount the MCP app
35+
const app = new Hono();
36+
37+
// Add CORS for cross-origin requests
38+
app.use("/*", cors());
39+
40+
// Health check endpoint
41+
app.get("/health", (c) => c.json({ status: "ok" }));
42+
43+
// Mount the MCP app (handles /mcp endpoint)
44+
app.route("/", mcpApp);
45+
46+
// RPC endpoint for tool execution
47+
app.post("/actions/rpc", async (c) => {
48+
const authHeader = c.req.header("Authorization");
49+
const accountIdHeader = c.req.header("x-account-id");
50+
51+
// Check for authentication
52+
if (!authHeader || !authHeader.startsWith("Basic ")) {
53+
return c.json(
54+
{ error: "Unauthorized", message: "Missing or invalid authorization header" },
55+
401,
56+
);
57+
}
58+
59+
const body = (await c.req.json()) as {
60+
action?: string;
61+
body?: Record<string, unknown>;
62+
headers?: Record<string, string>;
63+
path?: Record<string, string>;
64+
query?: Record<string, string>;
65+
};
66+
67+
// Validate action is provided
68+
if (!body.action) {
69+
return c.json({ error: "Bad Request", message: "Action is required" }, 400);
70+
}
71+
72+
// Test action to verify x-account-id is sent as HTTP header
73+
if (body.action === "test_account_id_header") {
74+
return c.json({
75+
data: {
76+
httpHeader: accountIdHeader,
77+
bodyHeader: body.headers?.["x-account-id"],
78+
},
79+
});
80+
}
81+
82+
// Return mock response based on action
83+
if (body.action === "bamboohr_get_employee") {
84+
return c.json({
85+
data: {
86+
id: body.path?.id || "test-id",
87+
name: "Test Employee",
88+
...body.body,
89+
},
90+
});
91+
}
92+
93+
if (body.action === "bamboohr_list_employees") {
94+
return c.json({
95+
data: [
96+
{ id: "1", name: "Employee 1" },
97+
{ id: "2", name: "Employee 2" },
98+
],
99+
});
100+
}
101+
102+
if (body.action === "test_error_action") {
103+
return c.json({ error: "Internal Server Error", message: "Test error response" }, 500);
104+
}
105+
106+
// Default response for other actions
107+
return c.json({
108+
data: {
109+
action: body.action,
110+
received: {
111+
body: body.body,
112+
headers: body.headers,
113+
path: body.path,
114+
query: body.query,
115+
},
116+
},
117+
});
118+
});
119+
120+
console.log(`MCP Mock Server starting on port ${port}...`);
121+
122+
export default {
123+
port,
124+
fetch: app.fetch,
125+
};
126+
127+
console.log(`MCP Mock Server running at http://localhost:${port}`);
128+
console.log("Endpoints:");
129+
console.log(` - GET /health - Health check`);
130+
console.log(` - ALL /mcp - MCP protocol endpoint`);
131+
console.log(` - POST /actions/rpc - RPC execution endpoint`);

0 commit comments

Comments
 (0)