Skip to content

Commit 1f268f0

Browse files
committed
Added tests and Cursor install instuctions
1 parent 053ae44 commit 1f268f0

8 files changed

Lines changed: 456 additions & 16 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,6 @@ wheels/
88

99
# Virtual environments
1010
.venv
11+
12+
# IDEs
13+
.cursor

README.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ To use this server with the [Claude Desktop app](https://claude.ai/download), ad
3030

3131
After updating the config, restart Claude Desktop.
3232

33+
## Manual configuration for Cursor
34+
35+
This MCP can also be used in cursor with a similar configuration from above added to your [Cursor](https://www.cursor.com/) global environment or to individual projects.
36+
37+
Examples [here](https://docs.cursor.com/context/model-context-protocol#configuring-mcp-servers)
38+
3339
## Local development
3440

3541
### Clone the repo (or your fork)
@@ -103,4 +109,32 @@ This MCP provides the following tools for interacting with DevHub CMS:
103109

104110
## Usage with LLMs
105111

106-
This MCP is designed to be used with Large Language Models that support the Model Context Protocol. It allows LLMs to manage content in DevHub CMS without needing direct API access integrated into the LLM natively.
112+
This MCP is designed to be used with Large Language Models that support the Model Context Protocol. It allows LLMs to manage content in DevHub CMS without needing direct API access integrated into the LLM natively.
113+
114+
## Testing
115+
116+
This package includes a test suite with mocked requests to the DevHub API, allowing you to test the functionality without making actual API calls.
117+
118+
### Running Tests
119+
120+
To run the tests, first install the package with test dependencies:
121+
122+
```bash
123+
uv pip install -e ".[test]"
124+
```
125+
126+
Run the tests with pytest:
127+
128+
```bash
129+
pytest
130+
```
131+
132+
For more detailed output and test coverage information:
133+
134+
```bash
135+
pytest -v --cov=devhub_cms_mcp
136+
```
137+
138+
### Test Structure
139+
140+
- `tests/devhub_cms_mcp/test_mcp_integration.py`: Tests for MCP integration endpoints

pyproject.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ dependencies = [
99
"requests-oauthlib>=2.0.0",
1010
]
1111

12+
[project.optional-dependencies]
13+
test = [
14+
"pytest>=7.0.0",
15+
"pytest-mock>=3.10.0",
16+
"pytest-cov>=4.1.0",
17+
]
18+
1219
[build-system]
1320
requires = ["setuptools>=61.0", "wheel"]
1421
build-backend = "setuptools.build_meta"
@@ -18,3 +25,8 @@ devhub-cms-mcp = "devhub_cms_mcp.server:main"
1825

1926
[tool.setuptools]
2027
package-dir = {"" = "src"}
28+
29+
[tool.pytest.ini_options]
30+
testpaths = ["tests"]
31+
python_files = "test_*.py"
32+
python_functions = "test_*"

src/devhub_cms_mcp/server.py

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@
1010
"DevHub CMS MCP",
1111
description="Integration with DevHub CMS to manage content")
1212

13-
devhub_api = OAuth1Session(
14-
os.environ['DEVHUB_API_KEY'],
15-
client_secret=os.environ['DEVHUB_API_SECRET'])
16-
base_url = '{}/api/v2/'.format(os.environ['DEVHUB_BASE_URL'])
13+
14+
def get_client():
15+
"""Get DevHub API client and base_url."""
16+
client = OAuth1Session(
17+
os.environ['DEVHUB_API_KEY'],
18+
client_secret=os.environ['DEVHUB_API_SECRET'])
19+
base_url = '{}/api/v2/'.format(os.environ['DEVHUB_BASE_URL'])
20+
return client, base_url
1721

1822

1923
@mcp.tool()
@@ -35,7 +39,8 @@ def get_hours_of_operation(location_id: int, hours_type: str = 'primary') -> lis
3539
location_id: DevHub Location ID
3640
hours_type: Defaults to 'primary' unless the user specifies a different type
3741
"""
38-
r = devhub_api.get('{}locations/{}'.format(base_url, location_id))
42+
client, base_url = get_client()
43+
r = client.get('{}locations/{}'.format(base_url, location_id))
3944
content = json.loads(r.content)
4045
return content['hours_by_type'].get(hours_type, [])
4146

@@ -60,7 +65,8 @@ def update_hours(location_id: int, new_hours: list, hours_type: str = 'primary')
6065
new_hours: Structured format of the new hours
6166
hours_type: Defaults to 'primary' unless the user specifies a different type
6267
"""
63-
r = devhub_api.put(
68+
client, base_url = get_client()
69+
r = client.put(
6470
'{}locations/{}/'.format(base_url, location_id),
6571
json={
6672
'hours': [
@@ -85,14 +91,15 @@ def upload_image(base64_image_content: str, filename: str) -> str:
8591
base64_image_content: Base 64 encoded content of the image file
8692
filename: Filename including the extension
8793
"""
94+
client, base_url = get_client()
8895
payload = {
8996
'type': 'image',
9097
'upload': {
9198
'file': base64_image_content,
9299
'filename': filename,
93100
}
94101
}
95-
r = devhub_api.post(
102+
r = client.post(
96103
'{}images/'.format(base_url),
97104
json=payload,
98105
)
@@ -110,7 +117,8 @@ def get_blog_post(post_id: int) -> str:
110117
Args:
111118
post_id: Blog post id
112119
"""
113-
r = devhub_api.get('{}posts/{}/'.format(base_url, post_id))
120+
client, base_url = get_client()
121+
r = client.get('{}posts/{}/'.format(base_url, post_id))
114122
post = r.json()
115123
return f"""
116124
Post ID: {post['id']}
@@ -131,11 +139,13 @@ def create_blog_post(site_id: int, title: str, content: str) -> str:
131139
title: Blog post title
132140
content: HTML content of blog post. Should not include a <h1> tag, only h2+
133141
"""
134-
payload = {}
135-
payload['site_id'] = site_id
136-
payload['content'] = content
137-
payload['title'] = title
138-
r = devhub_api.post(
142+
client, base_url = get_client()
143+
payload = {
144+
'content': content,
145+
'site_id': site_id,
146+
'title': title,
147+
}
148+
r = client.post(
139149
'{}posts/'.format(base_url),
140150
json=payload,
141151
)
@@ -159,12 +169,13 @@ def update_blog_post(post_id: int, title: str = None, content: str = None) -> st
159169
title: Blog post title
160170
content: HTML content of blog post. Should not include a <h1> tag, only h2+
161171
"""
172+
client, base_url = get_client()
162173
payload = {}
163174
if content:
164175
payload['content'] = content
165176
if title:
166177
payload['title'] = title
167-
r = devhub_api.put(
178+
r = client.put(
168179
'{}posts/{}/'.format(base_url, post_id),
169180
json=payload,
170181
)
@@ -188,7 +199,8 @@ def get_nearest_location(business_id: int, latitude: float, longitude: float) ->
188199
latitude: Latitude of the location
189200
longitude: Longitude of the location
190201
"""
191-
r = devhub_api.get('{}locations/'.format(base_url), params={
202+
client, base_url = get_client()
203+
r = client.get('{}locations/'.format(base_url), params={
192204
'business_id': business_id,
193205
'near_lat': latitude,
194206
'near_lon': longitude,

tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Tests package for devhub-cms-mcp

tests/devhub_cms_mcp/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Tests for devhub_cms_mcp package
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import json
2+
import os
3+
import unittest
4+
from unittest.mock import patch, MagicMock
5+
6+
from devhub_cms_mcp.server import get_hours_of_operation, update_hours, upload_image
7+
from devhub_cms_mcp.server import get_blog_post, create_blog_post, update_blog_post, get_nearest_location
8+
9+
10+
class TestMcpIntegration(unittest.TestCase):
11+
"""Tests for the DevHub CMS MCP integration."""
12+
13+
def setUp(self):
14+
"""Set up test environment with environment variables."""
15+
os.environ['DEVHUB_API_KEY'] = 'test_key'
16+
os.environ['DEVHUB_API_SECRET'] = 'test_secret'
17+
os.environ['DEVHUB_BASE_URL'] = 'https://devhub.example.com'
18+
19+
@patch('requests_oauthlib.OAuth1Session.get')
20+
def test_get_hours_of_operation_endpoint(self, mock_get):
21+
"""Test get_hours_of_operation MCP endpoint."""
22+
# Mock response
23+
mock_response = MagicMock()
24+
mock_response.content = json.dumps({
25+
'hours_by_type': {
26+
'primary': [
27+
[["09:00:00", "17:00:00"]], # Monday
28+
[["09:00:00", "17:00:00"]], # Tuesday
29+
[["09:00:00", "17:00:00"]], # Wednesday
30+
[["09:00:00", "17:00:00"]], # Thursday
31+
[["09:00:00", "17:00:00"]], # Friday
32+
[], # Saturday (closed)
33+
[] # Sunday (closed)
34+
]
35+
}
36+
}).encode()
37+
mock_get.return_value = mock_response
38+
39+
# Call function directly
40+
result = get_hours_of_operation(location_id=123, hours_type='primary')
41+
42+
# Verify response
43+
self.assertEqual(len(result), 7) # One entry per day
44+
self.assertEqual(result[0], [["09:00:00", "17:00:00"]]) # Monday
45+
46+
@patch('requests_oauthlib.OAuth1Session.put')
47+
def test_update_hours_endpoint(self, mock_put):
48+
"""Test update_hours MCP endpoint."""
49+
# Mock response
50+
mock_response = MagicMock()
51+
mock_response.content = json.dumps({
52+
'success': True
53+
}).encode()
54+
mock_put.return_value = mock_response
55+
56+
# Test data
57+
new_hours = [
58+
[["09:00:00", "17:00:00"]], # Monday
59+
[["09:00:00", "17:00:00"]], # Tuesday
60+
[["09:00:00", "17:00:00"]], # Wednesday
61+
[["09:00:00", "17:00:00"]], # Thursday
62+
[["09:00:00", "17:00:00"]], # Friday
63+
[["10:00:00", "15:00:00"]], # Saturday
64+
[] # Sunday (closed)
65+
]
66+
67+
# Call function directly
68+
result = update_hours(location_id=123, new_hours=new_hours, hours_type="primary")
69+
70+
# Verify response
71+
self.assertEqual(result, 'Updated successfully')
72+
73+
@patch('requests_oauthlib.OAuth1Session.post')
74+
def test_upload_image_endpoint(self, mock_post):
75+
"""Test upload_image MCP endpoint."""
76+
# Mock response
77+
mock_response = MagicMock()
78+
mock_response.json.return_value = {
79+
'id': 456,
80+
'absolute_path': '/media/uploads/image.jpg'
81+
}
82+
mock_post.return_value = mock_response
83+
84+
# Call function directly
85+
result = upload_image(
86+
base64_image_content='base64encodedcontent',
87+
filename='test.jpg'
88+
)
89+
90+
# Verify response
91+
self.assertIn('Image ID: 456', result)
92+
self.assertIn('/media/uploads/image.jpg', result)
93+
94+
@patch('requests_oauthlib.OAuth1Session.get')
95+
def test_get_blog_post_endpoint(self, mock_get):
96+
"""Test get_blog_post MCP endpoint."""
97+
# Mock response
98+
mock_response = MagicMock()
99+
mock_response.json.return_value = {
100+
'id': 789,
101+
'title': 'Test Blog Post',
102+
'date': '2025-03-17',
103+
'content': '<p>This is a test blog post.</p>'
104+
}
105+
mock_get.return_value = mock_response
106+
107+
# Call function directly
108+
result = get_blog_post(post_id=789)
109+
110+
# Verify response
111+
self.assertIn('Post ID: 789', result)
112+
self.assertIn('Title: Test Blog Post', result)
113+
114+
@patch('requests_oauthlib.OAuth1Session.post')
115+
def test_create_blog_post_endpoint(self, mock_post):
116+
"""Test create_blog_post MCP endpoint."""
117+
# Mock response
118+
mock_response = MagicMock()
119+
mock_response.json.return_value = {
120+
'id': 999,
121+
'title': 'New Test Post',
122+
'date': '2025-03-17',
123+
'content': '<p>This is a new test blog post.</p>'
124+
}
125+
mock_post.return_value = mock_response
126+
127+
# Call function directly
128+
result = create_blog_post(
129+
site_id=42,
130+
title="New Test Post",
131+
content="<p>This is a new test blog post.</p>"
132+
)
133+
134+
# Verify response
135+
self.assertIn('Post ID: 999', result)
136+
self.assertIn('Title: New Test Post', result)
137+
138+
@patch('requests_oauthlib.OAuth1Session.put')
139+
def test_update_blog_post_endpoint(self, mock_put):
140+
"""Test update_blog_post MCP endpoint."""
141+
# Mock response
142+
mock_response = MagicMock()
143+
mock_response.json.return_value = {
144+
'id': 789,
145+
'title': 'Updated Test Post',
146+
'date': '2025-03-17',
147+
'content': '<p>This is an updated test blog post.</p>'
148+
}
149+
mock_put.return_value = mock_response
150+
151+
# Call function directly
152+
result = update_blog_post(
153+
post_id=789,
154+
title="Updated Test Post",
155+
content="<p>This is an updated test blog post.</p>"
156+
)
157+
158+
# Verify response
159+
self.assertIn('Post ID: 789', result)
160+
self.assertIn('Title: Updated Test Post', result)
161+
162+
@patch('requests_oauthlib.OAuth1Session.get')
163+
def test_get_nearest_location_endpoint(self, mock_get):
164+
"""Test get_nearest_location MCP endpoint."""
165+
# Mock response
166+
mock_response = MagicMock()
167+
mock_response.content = json.dumps({
168+
'objects': [{
169+
'id': 123,
170+
'location_name': 'Test Location',
171+
'location_url': 'https://example.com/location/123',
172+
'street': '123 Main St',
173+
'city': 'Test City',
174+
'state': 'TS',
175+
'country': 'Test Country'
176+
}]
177+
}).encode()
178+
mock_get.return_value = mock_response
179+
180+
# Call function directly
181+
result = get_nearest_location(
182+
business_id=42,
183+
latitude=37.7749,
184+
longitude=-122.4194
185+
)
186+
187+
# Verify response
188+
self.assertIn('Location ID: 123', result)
189+
self.assertIn('Location name: Test Location', result)
190+
191+
192+
if __name__ == '__main__':
193+
unittest.main()

0 commit comments

Comments
 (0)