Skip to content

Commit bdde1fa

Browse files
committed
test: add MCP mock server infrastructure
Add pytest fixtures and Hono-based MCP mock server for testing fetch_tools against a real MCP protocol implementation: - tests/conftest.py: Session-scoped fixture that starts bun server automatically when tests require mcp_mock_server - tests/mocks/serve.ts: Standalone HTTP server importing createMcpApp from stackone-ai-node submodule, exposes /mcp and /actions/rpc The server is started once per test session and provides the same tool configurations as the Node SDK tests for consistency.
1 parent ac92b7f commit bdde1fa

File tree

2 files changed

+250
-0
lines changed

2 files changed

+250
-0
lines changed

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: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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(
104+
{ error: 'Internal Server Error', message: 'Test error response' },
105+
500,
106+
);
107+
}
108+
109+
// Default response for other actions
110+
return c.json({
111+
data: {
112+
action: body.action,
113+
received: {
114+
body: body.body,
115+
headers: body.headers,
116+
path: body.path,
117+
query: body.query,
118+
},
119+
},
120+
});
121+
});
122+
123+
console.log(`MCP Mock Server starting on port ${port}...`);
124+
125+
export default {
126+
port,
127+
fetch: app.fetch,
128+
};
129+
130+
console.log(`MCP Mock Server running at http://localhost:${port}`);
131+
console.log('Endpoints:');
132+
console.log(` - GET /health - Health check`);
133+
console.log(` - ALL /mcp - MCP protocol endpoint`);
134+
console.log(` - POST /actions/rpc - RPC execution endpoint`);

0 commit comments

Comments
 (0)