Skip to content

Commit c0d6e37

Browse files
committed
fix(smithery): implement SmitheryConfigMiddleware for query parameter handling and add tests
1 parent 6df2bf9 commit c0d6e37

File tree

4 files changed

+533
-22
lines changed

4 files changed

+533
-22
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ linkedin-scraper = { git = "https://github.com/joeyism/linkedin_scraper.git" }
2020

2121
[dependency-groups]
2222
dev = [
23+
"aiohttp>=3.12.13",
2324
"pre-commit>=4.2.0",
2425
"pytest>=8.3.5",
2526
"pytest-asyncio>=1.0.0",

smithery_main.py

Lines changed: 74 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,38 +12,87 @@
1212
import os
1313
import logging
1414
from urllib.parse import parse_qs
15+
from fastmcp.server.middleware import Middleware, MiddlewareContext
1516

1617
from linkedin_mcp_server.config import get_config, reset_config
1718
from linkedin_mcp_server.drivers.chrome import initialize_driver
1819
from linkedin_mcp_server.server import create_mcp_server, shutdown_handler
1920

2021

21-
def setup_smithery_environment(query_string: str | None = None) -> None:
22+
class SmitheryConfigMiddleware(Middleware):
2223
"""
23-
Set up environment variables from Smithery query parameters.
24+
FastMCP middleware to handle Smithery query parameter configuration.
2425
25-
Args:
26-
query_string: Query parameters from Smithery configuration
26+
Intercepts HTTP requests and extracts configuration from query parameters,
27+
then temporarily sets environment variables for the duration of the request.
2728
"""
28-
if not query_string:
29-
return
3029

31-
# Parse query parameters
32-
parsed = parse_qs(query_string)
33-
34-
# Map Smithery parameters to environment variables
35-
param_mapping = {
36-
"linkedin_email": "LINKEDIN_EMAIL",
37-
"linkedin_password": "LINKEDIN_PASSWORD",
38-
}
39-
40-
for param, env_var in param_mapping.items():
41-
if param in parsed and parsed[param]:
42-
value = parsed[param][0] # Take first value
43-
os.environ[env_var] = value
44-
45-
# Reset config to pick up new environment variables
46-
reset_config()
30+
def __init__(self):
31+
super().__init__()
32+
self.param_mapping = {
33+
"linkedin_email": "LINKEDIN_EMAIL",
34+
"linkedin_password": "LINKEDIN_PASSWORD",
35+
}
36+
37+
async def on_call_tool(self, context: MiddlewareContext, call_next):
38+
"""
39+
Called before each tool execution.
40+
Extract configuration from HTTP request query parameters.
41+
"""
42+
# Store original environment variables
43+
original_env = {}
44+
for env_var in self.param_mapping.values():
45+
original_env[env_var] = os.environ.get(env_var)
46+
47+
# Extract query parameters from the request context
48+
query_params = self._extract_query_params(context)
49+
50+
if query_params:
51+
# Apply configuration from query parameters
52+
self._apply_config(query_params)
53+
54+
# Reset configuration to pick up new environment variables
55+
reset_config()
56+
57+
try:
58+
# Execute the tool with the new configuration
59+
result = await call_next(context)
60+
return result
61+
finally:
62+
# Restore original environment variables
63+
self._restore_env(original_env)
64+
65+
def _extract_query_params(self, context: MiddlewareContext) -> dict:
66+
"""Extract query parameters from the request context."""
67+
# Check if we can access FastMCP context for HTTP transport
68+
if hasattr(context, "fastmcp_context") and context.fastmcp_context:
69+
# Check if there's transport-specific information
70+
if hasattr(context.fastmcp_context, "transport_info"):
71+
transport_info = context.fastmcp_context.transport_info
72+
if hasattr(transport_info, "query_params"):
73+
return dict(transport_info.query_params)
74+
75+
# Try to get from environment if set by HTTP server
76+
query_string = os.environ.get("QUERY_STRING", "")
77+
if query_string:
78+
return {k: v[0] for k, v in parse_qs(query_string).items()}
79+
80+
return {}
81+
82+
def _apply_config(self, query_params: dict):
83+
"""Apply configuration from query parameters to environment variables."""
84+
for param, env_var in self.param_mapping.items():
85+
if param in query_params and query_params[param]:
86+
os.environ[env_var] = query_params[param]
87+
print(f"🔧 Applied config: {param} -> {env_var}")
88+
89+
def _restore_env(self, original_env: dict):
90+
"""Restore original environment variables."""
91+
for env_var, original_value in original_env.items():
92+
if original_value is not None:
93+
os.environ[env_var] = original_value
94+
elif env_var in os.environ:
95+
del os.environ[env_var]
4796

4897

4998
def main() -> None:
@@ -85,6 +134,9 @@ def main() -> None:
85134
# Create MCP server (tools will be registered and available for discovery)
86135
mcp = create_mcp_server()
87136

137+
# Add Smithery configuration middleware
138+
mcp.add_middleware(SmitheryConfigMiddleware())
139+
88140
# Start HTTP server
89141
print("\n🚀 Running LinkedIn MCP server (Smithery HTTP mode)...")
90142
print(f"📡 HTTP server listening on http://0.0.0.0:{port}/mcp")

tests/test_smithery_config.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# tests/test_smithery_config.py
2+
"""
3+
Test Smithery configuration parameter passing.
4+
"""
5+
6+
import pytest
7+
import os
8+
from unittest.mock import patch, MagicMock
9+
from fastmcp.client import Client
10+
from fastmcp.server.middleware import MiddlewareContext
11+
from linkedin_mcp_server.server import create_mcp_server
12+
from smithery_main import SmitheryConfigMiddleware
13+
14+
15+
@pytest.mark.asyncio
16+
async def test_smithery_middleware_extracts_config():
17+
"""Test that SmitheryConfigMiddleware correctly extracts configuration from query parameters."""
18+
middleware = SmitheryConfigMiddleware()
19+
20+
# Mock MiddlewareContext with query parameters via environment
21+
context = MagicMock(spec=MiddlewareContext)
22+
context.fastmcp_context = None
23+
24+
# Set query string in environment to simulate HTTP request
25+
os.environ["QUERY_STRING"] = (
26+
"linkedin_email=test@example.com&linkedin_password=testpass123"
27+
)
28+
29+
# Mock call_next
30+
async def mock_call_next(ctx):
31+
# During tool execution, check that env vars are set
32+
assert os.environ.get("LINKEDIN_EMAIL") == "test@example.com"
33+
assert os.environ.get("LINKEDIN_PASSWORD") == "testpass123"
34+
return MagicMock()
35+
36+
# Store original env vars
37+
original_email = os.environ.get("LINKEDIN_EMAIL")
38+
original_password = os.environ.get("LINKEDIN_PASSWORD")
39+
original_query_string = os.environ.get("QUERY_STRING")
40+
41+
try:
42+
# Execute middleware
43+
await middleware.on_call_tool(context, mock_call_next)
44+
45+
# After execution, env vars should be restored
46+
assert os.environ.get("LINKEDIN_EMAIL") == original_email
47+
assert os.environ.get("LINKEDIN_PASSWORD") == original_password
48+
49+
print("✅ Smithery middleware correctly handles configuration")
50+
51+
finally:
52+
# Cleanup
53+
if original_email is not None:
54+
os.environ["LINKEDIN_EMAIL"] = original_email
55+
elif "LINKEDIN_EMAIL" in os.environ:
56+
del os.environ["LINKEDIN_EMAIL"]
57+
58+
if original_password is not None:
59+
os.environ["LINKEDIN_PASSWORD"] = original_password
60+
elif "LINKEDIN_PASSWORD" in os.environ:
61+
del os.environ["LINKEDIN_PASSWORD"]
62+
63+
if original_query_string is not None:
64+
os.environ["QUERY_STRING"] = original_query_string
65+
elif "QUERY_STRING" in os.environ:
66+
del os.environ["QUERY_STRING"]
67+
68+
69+
@pytest.mark.asyncio
70+
async def test_smithery_middleware_with_empty_config():
71+
"""Test that middleware works correctly with no configuration."""
72+
middleware = SmitheryConfigMiddleware()
73+
74+
# Mock context with no query parameters
75+
context = MagicMock(spec=MiddlewareContext)
76+
context.fastmcp_context = None
77+
78+
# Mock call_next
79+
async def mock_call_next(ctx):
80+
return MagicMock()
81+
82+
# Should not raise any errors
83+
result = await middleware.on_call_tool(context, mock_call_next)
84+
assert result is not None
85+
86+
print("✅ Smithery middleware handles empty configuration")
87+
88+
89+
@pytest.mark.asyncio
90+
async def test_smithery_server_with_middleware():
91+
"""Test that MCP server with Smithery middleware can be created and tools discovered."""
92+
with patch("sys.argv", ["smithery_main.py"]):
93+
# Create server (simulate smithery_main.py)
94+
mcp = create_mcp_server()
95+
96+
# Add middleware
97+
mcp.add_middleware(SmitheryConfigMiddleware())
98+
99+
# Test that tools are discoverable
100+
async with Client(mcp) as client:
101+
tools = await client.list_tools()
102+
103+
tool_names = [tool.name for tool in tools]
104+
expected_tools = [
105+
"get_person_profile",
106+
"get_company_profile",
107+
"get_job_details",
108+
"close_session",
109+
]
110+
111+
for expected_tool in expected_tools:
112+
assert expected_tool in tool_names, f"Tool '{expected_tool}' not found"
113+
114+
print(f"✅ Smithery server with middleware: {len(tools)} tools discovered")
115+
116+
117+
def test_smithery_middleware_param_mapping():
118+
"""Test that SmitheryConfigMiddleware has correct parameter mapping."""
119+
middleware = SmitheryConfigMiddleware()
120+
121+
expected_mapping = {
122+
"linkedin_email": "LINKEDIN_EMAIL",
123+
"linkedin_password": "LINKEDIN_PASSWORD",
124+
}
125+
126+
assert middleware.param_mapping == expected_mapping
127+
print("✅ Smithery middleware parameter mapping is correct")
128+
129+
130+
if __name__ == "__main__":
131+
# Run tests manually if executed directly
132+
import asyncio
133+
134+
asyncio.run(test_smithery_middleware_extracts_config())
135+
asyncio.run(test_smithery_middleware_with_empty_config())
136+
asyncio.run(test_smithery_server_with_middleware())
137+
test_smithery_middleware_param_mapping()
138+
print("🎉 All Smithery configuration tests passed!")

0 commit comments

Comments
 (0)