Skip to content

Commit 167695e

Browse files
committed
Refactor MCP mock server configuration and update tests for OAuth support
- Removed the old `mcp-mock-server` service and replaced it with `mock-mcp` in both `docker-compose` files. - Updated health check and dependencies to reflect the new service name. - Modified E2E test configurations to use the new `mock-mcp` service URL. - Added a minimal mock MCP server implementation with OAuth support for testing. - Updated feature tests to check for OAuth authentication requirements and response headers.
1 parent a263db1 commit 167695e

9 files changed

Lines changed: 217 additions & 70 deletions

File tree

docker-compose-library.yaml

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,4 @@
11
services:
2-
# Mock MCP server for testing
3-
mcp-mock-server:
4-
build:
5-
context: .
6-
dockerfile: dev-tools/mcp-mock-server/Dockerfile
7-
container_name: mcp-mock-server
8-
ports:
9-
- "3000:3000"
10-
networks:
11-
- lightspeednet
12-
healthcheck:
13-
test: ["CMD", "curl", "-f", "http://localhost:3000/"]
14-
interval: 5s
15-
timeout: 3s
16-
retries: 3
17-
start_period: 5s
182

193
# Lightspeed Stack with embedded llama-stack (library mode)
204
lightspeed-stack:
@@ -26,7 +10,7 @@ services:
2610
ports:
2711
- "8080:8080"
2812
depends_on:
29-
mcp-mock-server:
13+
mock-mcp:
3014
condition: service_healthy
3115
networks:
3216
- lightspeednet
@@ -93,6 +77,23 @@ services:
9377
retries: 3
9478
start_period: 2s
9579

80+
mock-mcp:
81+
build:
82+
context: ./tests/e2e/mock_mcp_server
83+
dockerfile: Dockerfile
84+
container_name: mock-mcp
85+
ports:
86+
- "3000:3000"
87+
networks:
88+
- lightspeednet
89+
healthcheck:
90+
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3000/health')"]
91+
interval: 5s
92+
timeout: 3s
93+
retries: 3
94+
start_period: 2s
95+
96+
9697
networks:
9798
lightspeednet:
9899
driver: bridge

docker-compose.yaml

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,4 @@
11
services:
2-
# Mock MCP server for testing
3-
mcp-mock-server:
4-
build:
5-
context: .
6-
dockerfile: dev-tools/mcp-mock-server/Dockerfile
7-
container_name: mcp-mock-server
8-
ports:
9-
- "3000:3000"
10-
networks:
11-
- lightspeednet
12-
healthcheck:
13-
test: ["CMD", "curl", "-f", "http://localhost:3000/"]
14-
interval: 5s
15-
timeout: 3s
16-
retries: 3
17-
start_period: 5s
182

193
# Red Hat llama-stack distribution with FAISS
204
llama-stack:
@@ -90,7 +74,7 @@ services:
9074
depends_on:
9175
llama-stack:
9276
condition: service_healthy
93-
mcp-mock-server:
77+
mock-mcp:
9478
condition: service_healthy
9579
networks:
9680
- lightspeednet
@@ -118,6 +102,23 @@ services:
118102
retries: 3
119103
start_period: 2s
120104

105+
mock-mcp:
106+
build:
107+
context: ./tests/e2e/mock_mcp_server
108+
dockerfile: Dockerfile
109+
container_name: mock-mcp
110+
ports:
111+
- "3000:3000"
112+
networks:
113+
- lightspeednet
114+
healthcheck:
115+
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3000/health')"]
116+
interval: 5s
117+
timeout: 3s
118+
retries: 3
119+
start_period: 2s
120+
121+
121122
volumes:
122123
llama-storage:
123124

lightspeed-stack.yaml.backup

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Lightspeed Core Service (LCS)
2+
service:
3+
host: 0.0.0.0
4+
port: 8080
5+
base_url: http://localhost:8080
6+
auth_enabled: false
7+
workers: 1
8+
color_log: true
9+
access_log: true
10+
llama_stack:
11+
# Uses a remote llama-stack service
12+
# The instance would have already been started with a llama-stack-run.yaml file
13+
use_as_library_client: false
14+
# Alternative for "as library use"
15+
# use_as_library_client: true
16+
# library_client_config_path: <path-to-llama-stack-run.yaml-file>
17+
url: http://localhost:8321
18+
api_key: xyzzy
19+
user_data_collection:
20+
feedback_enabled: true
21+
feedback_storage: "/tmp/data/feedback"
22+
transcripts_enabled: true
23+
transcripts_storage: "/tmp/data/transcripts"
24+
25+
# Conversation cache for storing Q&A history
26+
conversation_cache:
27+
type: "sqlite"
28+
sqlite:
29+
db_path: "/tmp/data/conversation-cache.db" # Persistent across requests, can be deleted between test runs
30+
31+
authentication:
32+
module: "noop"
33+
34+
35+
# OKP Solr for supplementary RAG
36+
solr:
37+
enabled: false
38+
offline: true

tests/e2e/configuration/library-mode/lightspeed-stack.yaml

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,8 @@ user_data_collection:
1818
authentication:
1919
module: "noop"
2020
mcp_servers:
21-
# Mock server with client-provided auth - should appear in mcp-auth/client-options response
22-
- name: "github-api"
21+
- name: "mcp-oauth"
2322
provider_id: "model-context-protocol"
24-
url: "http://mcp-mock-server:3000"
23+
url: "http://mock-mcp:3000"
2524
authorization_headers:
26-
Authorization: "client"
27-
# Mock server with client-provided auth (different header) - should appear in response
28-
- name: "gitlab-api"
29-
provider_id: "model-context-protocol"
30-
url: "http://mcp-mock-server:3000"
31-
authorization_headers:
32-
X-API-Token: "client"
33-
# Mock server with no auth - should NOT appear in response
34-
- name: "public-api"
35-
provider_id: "model-context-protocol"
36-
url: "http://mcp-mock-server:3000"
25+
Authorization: "oauth"

tests/e2e/configuration/server-mode/lightspeed-stack.yaml

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ service:
99
llama_stack:
1010
# Server mode - connects to separate llama-stack service
1111
use_as_library_client: false
12-
url: http://llama-stack:8321
12+
url: http://localhost:8321
1313
api_key: xyzzy
1414
user_data_collection:
1515
feedback_enabled: true
@@ -19,19 +19,8 @@ user_data_collection:
1919
authentication:
2020
module: "noop"
2121
mcp_servers:
22-
# Mock server with client-provided auth - should appear in mcp-auth/client-options response
23-
- name: "github-api"
22+
- name: "mcp-oauth"
2423
provider_id: "model-context-protocol"
25-
url: "http://mcp-mock-server:3000"
24+
url: "http://localhost:3000"
2625
authorization_headers:
27-
Authorization: "client"
28-
# Mock server with client-provided auth (different header) - should appear in response
29-
- name: "gitlab-api"
30-
provider_id: "model-context-protocol"
31-
url: "http://mcp-mock-server:3000"
32-
authorization_headers:
33-
X-API-Token: "client"
34-
# Mock server with no auth - should NOT appear in response
35-
- name: "public-api"
36-
provider_id: "model-context-protocol"
37-
url: "http://mcp-mock-server:3000"
26+
Authorization: "oauth"

tests/e2e/features/info.feature

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -124,16 +124,24 @@ Feature: Info tests
124124
{"detail": {"response": "Unable to connect to Llama Stack", "cause": "Connection error."}}
125125
"""
126126

127+
Scenario: Check if tools endpoint reports error when mcp requires authentication
128+
Given The system is in default state
129+
When I access REST API endpoint "tools" using HTTP GET method
130+
Then The status code of the response is 401
131+
And The body of the response is the following
132+
"""
133+
{
134+
"detail": {
135+
"response": "Missing or invalid credentials provided by client",
136+
"cause": "MCP server at http://mock-mcp:3000 requires OAuth"
137+
}
138+
}
139+
"""
140+
And The headers of the response contains the following "www-authenticate"
141+
127142
Scenario: Check if metrics endpoint is working
128143
Given The system is in default state
129144
When I access endpoint "metrics" using HTTP GET method
130145
Then The status code of the response is 200
131146
And The body of the response contains ls_provider_model_configuration
132147

133-
Scenario: Check if MCP client auth options endpoint is working
134-
Given The system is in default state
135-
When I access REST API endpoint "mcp-auth/client-options" using HTTP GET method
136-
Then The status code of the response is 200
137-
And The body of the response has proper client auth options structure
138-
And The response contains server "github-api" with client auth header "Authorization"
139-
And The response contains server "gitlab-api" with client auth header "X-API-Token"

tests/e2e/features/steps/common_http.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,15 @@ def check_prediction_result(context: Context) -> None:
188188
assert result == expected_body, f"got:\n{result}\nwant:\n{expected_body}"
189189

190190

191+
@then("The headers of the response contains {substring}")
192+
def check_response_headers_contains(context: Context, substring: str) -> None:
193+
"""Check that response body contains a substring."""
194+
assert context.response is not None, "Request needs to be performed first"
195+
assert (
196+
substring in context.response.headers
197+
), f"The response headers '{context.response.headers}' doesn't contain '{substring}'"
198+
199+
191200
@then('The body of the response, ignoring the "{field}" field, is the following')
192201
def check_prediction_result_ignoring_field(context: Context, field: str) -> None:
193202
"""Check the content of the response to be exactly the same.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
FROM python:3.12-slim
2+
WORKDIR /app
3+
COPY server.py .
4+
EXPOSE 3000
5+
CMD ["python", "server.py"]
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
#!/usr/bin/env python3
2+
"""Minimal mock MCP server for E2E tests with OAuth support.
3+
4+
Responds to GET (OAuth probe) with 401 and WWW-Authenticate. Accepts POST
5+
(MCP JSON-RPC) when Authorization: Bearer <token> is present; otherwise 401.
6+
Uses only Python stdlib.
7+
"""
8+
9+
import json
10+
from http.server import HTTPServer, BaseHTTPRequestHandler
11+
from typing import Any
12+
13+
# Standard OAuth-style challenge so the client can drive an OAuth flow
14+
WWW_AUTHENTICATE = 'Bearer realm="mock-mcp", error="invalid_token"'
15+
16+
17+
class Handler(BaseHTTPRequestHandler):
18+
"""HTTP handler: GET/POST without valid Bearer → 401; POST with Bearer → MCP."""
19+
20+
def _require_oauth(self) -> None:
21+
"""Send 401 with WWW-Authenticate."""
22+
self.send_response(401)
23+
self.send_header("WWW-Authenticate", WWW_AUTHENTICATE)
24+
self.send_header("Content-Type", "application/json")
25+
body = b'{"error":"unauthorized"}'
26+
self.send_header("Content-Length", str(len(body)))
27+
self.end_headers()
28+
self.wfile.write(body)
29+
30+
def _parse_auth(self) -> str | None:
31+
"""Return Bearer token if present, else None."""
32+
auth = self.headers.get("Authorization")
33+
if auth and auth.startswith("Bearer "):
34+
return auth[7:].strip()
35+
return None
36+
37+
def _json_response(self, data: dict) -> None:
38+
"""Send JSON response."""
39+
body = json.dumps(data).encode()
40+
self.send_response(200)
41+
self.send_header("Content-Type", "application/json")
42+
self.send_header("Content-Length", str(len(body)))
43+
self.end_headers()
44+
self.wfile.write(body)
45+
46+
def do_GET(self) -> None: # pylint: disable=invalid-name
47+
"""OAuth probe: always 401 with WWW-Authenticate."""
48+
if self.path == "/health":
49+
self._json_response({"status": "ok"})
50+
else:
51+
self._require_oauth()
52+
53+
def do_POST(self) -> None: # pylint: disable=invalid-name
54+
"""MCP JSON-RPC: 401 without valid Bearer; 200 with minimal responses otherwise."""
55+
if self._parse_auth() is None:
56+
self._require_oauth()
57+
return
58+
59+
length = int(self.headers.get("Content-Length", 0))
60+
raw = self.rfile.read(length) if length else b"{}"
61+
try:
62+
req = json.loads(raw.decode("utf-8"))
63+
req_id = req.get("id", 1)
64+
method = req.get("method", "")
65+
except (json.JSONDecodeError, UnicodeDecodeError):
66+
req_id = 1
67+
method = ""
68+
69+
if method == "initialize":
70+
self._json_response(
71+
{
72+
"jsonrpc": "2.0",
73+
"id": req_id,
74+
"result": {
75+
"protocolVersion": "2024-11-05",
76+
"capabilities": {"tools": {}},
77+
"serverInfo": {"name": "mock-mcp-e2e", "version": "1.0.0"},
78+
},
79+
}
80+
)
81+
elif method == "tools/list":
82+
self._json_response(
83+
{
84+
"jsonrpc": "2.0",
85+
"id": req_id,
86+
"result": {
87+
"tools": [
88+
{
89+
"name": "mock_tool",
90+
"description": "Mock tool for E2E",
91+
"inputSchema": {"type": "object"},
92+
}
93+
],
94+
},
95+
}
96+
)
97+
else:
98+
self._json_response({"jsonrpc": "2.0", "id": req_id, "result": {}})
99+
100+
def log_message(self, format: str, *args: Any) -> None:
101+
"""Suppress request logging for minimal output."""
102+
103+
104+
if __name__ == "__main__":
105+
server = HTTPServer(("0.0.0.0", 3000), Handler)
106+
print("Mock MCP server on :3000")
107+
server.serve_forever()

0 commit comments

Comments
 (0)