# Directory Structure
```
├── .github
│ └── workflows
│ └── ci.yml
├── .gitignore
├── docs
│ ├── client_configuration.md
│ ├── configuration.md
│ ├── examples.md
│ ├── README.md
│ ├── troubleshooting.md
│ └── usage.md
├── LICENSE
├── pyproject.toml
├── README.md
├── setup.py
├── src
│ ├── __init__.py
│ ├── mcp
│ │ ├── __init__.py
│ │ ├── protocol.py
│ │ └── validation.py
│ ├── notification
│ │ ├── __init__.py
│ │ ├── macos.py
│ │ ├── manager.py
│ │ ├── platform.py
│ │ └── toast.py
│ └── server
│ ├── __init__.py
│ ├── commands.py
│ └── connection.py
└── tests
├── __init__.py
├── test_commands.py
├── test_connection.py
├── test_macos.py
├── test_manager.py
├── test_protocol.py
└── test_toast.py
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # UV
98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | #uv.lock
102 |
103 | # poetry
104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105 | # This is especially recommended for binary packages to ensure reproducibility, and is more
106 | # commonly ignored for libraries.
107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108 | #poetry.lock
109 |
110 | # pdm
111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
112 | #pdm.lock
113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
114 | # in version control.
115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
116 | .pdm.toml
117 | .pdm-python
118 | .pdm-build/
119 |
120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
121 | __pypackages__/
122 |
123 | # Celery stuff
124 | celerybeat-schedule
125 | celerybeat.pid
126 |
127 | # SageMath parsed files
128 | *.sage.py
129 |
130 | # Environments
131 | .env
132 | .venv
133 | env/
134 | venv/
135 | ENV/
136 | env.bak/
137 | venv.bak/
138 |
139 | # Spyder project settings
140 | .spyderproject
141 | .spyproject
142 |
143 | # Rope project settings
144 | .ropeproject
145 |
146 | # mkdocs documentation
147 | /site
148 |
149 | # mypy
150 | .mypy_cache/
151 | .dmypy.json
152 | dmypy.json
153 |
154 | # Pyre type checker
155 | .pyre/
156 |
157 | # pytype static type analyzer
158 | .pytype/
159 |
160 | # Cython debug symbols
161 | cython_debug/
162 |
163 | # PyCharm
164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
166 | # and can be added to the global gitignore or merged into this file. For a more nuclear
167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
168 | #.idea/
169 |
170 | # Ruff stuff:
171 | .ruff_cache/
172 |
173 | # PyPI configuration file
174 | .pypirc
175 |
```
--------------------------------------------------------------------------------
/src/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | toast-mcp-server package.
3 | """
4 |
```
--------------------------------------------------------------------------------
/src/notification/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Notification system package.
3 | """
4 |
```
--------------------------------------------------------------------------------
/src/server/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Server implementation package.
3 | """
4 |
```
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Test package for toast-mcp-server.
3 | """
4 |
```
--------------------------------------------------------------------------------
/src/mcp/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | MCP protocol implementation package.
3 | """
4 |
```
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
```python
1 | from setuptools import setup, find_packages
2 |
3 | setup(
4 | name="toast-mcp-server",
5 | version="0.1.0",
6 | packages=find_packages(),
7 | install_requires=[
8 | "win10toast;platform_system=='Windows'",
9 | ],
10 | python_requires=">=3.8",
11 | )
12 |
```
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
```toml
1 | [build-system]
2 | requires = ["setuptools>=42", "wheel"]
3 | build-backend = "setuptools.build_meta"
4 |
5 | [project]
6 | name = "toast-mcp-server"
7 | version = "0.1.0"
8 | description = "A Model Context Protocol (MCP) server with Windows 10 and macOS desktop notifications support"
9 | readme = "README.md"
10 | requires-python = ">=3.8"
11 | license = {text = "MIT"}
12 | dependencies = [
13 | "win10toast;platform_system=='Windows'",
14 | ]
15 |
16 | [tool.pytest.ini_options]
17 | testpaths = ["tests"]
18 | python_files = "test_*.py"
19 |
```
--------------------------------------------------------------------------------
/src/notification/platform.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Platform detection utilities for toast-mcp-server.
3 |
4 | This module provides utilities for detecting the current platform
5 | and selecting the appropriate notification system.
6 | """
7 |
8 | import logging
9 | import platform
10 | from typing import Dict, Any, Optional, List, Union, Callable
11 |
12 | from src.mcp.protocol import NotificationType
13 |
14 | logger = logging.getLogger(__name__)
15 |
16 |
17 | def is_windows() -> bool:
18 | """
19 | Check if the current platform is Windows.
20 |
21 | Returns:
22 | True if the current platform is Windows, False otherwise
23 | """
24 | return platform.system() == "Windows"
25 |
26 |
27 | def is_macos() -> bool:
28 | """
29 | Check if the current platform is macOS.
30 |
31 | Returns:
32 | True if the current platform is macOS, False otherwise
33 | """
34 | return platform.system() == "Darwin"
35 |
36 |
37 | def is_linux() -> bool:
38 | """
39 | Check if the current platform is Linux.
40 |
41 | Returns:
42 | True if the current platform is Linux, False otherwise
43 | """
44 | return platform.system() == "Linux"
45 |
46 |
47 | def get_platform_name() -> str:
48 | """
49 | Get the name of the current platform.
50 |
51 | Returns:
52 | Name of the current platform ("windows", "macos", "linux", or "unknown")
53 | """
54 | if is_windows():
55 | return "windows"
56 | elif is_macos():
57 | return "macos"
58 | elif is_linux():
59 | return "linux"
60 | else:
61 | return "unknown"
62 |
```
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: CI/CD
2 |
3 | on:
4 | push:
5 | branches: [ main, devin/* ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | jobs:
10 | test:
11 | runs-on: ubuntu-latest
12 | strategy:
13 | matrix:
14 | python-version: [3.8, 3.9, '3.10']
15 |
16 | steps:
17 | - uses: actions/checkout@v3
18 |
19 | - name: Set up Python ${{ matrix.python-version }}
20 | uses: actions/setup-python@v4
21 | with:
22 | python-version: ${{ matrix.python-version }}
23 |
24 | - name: Install dependencies
25 | run: |
26 | python -m pip install --upgrade pip
27 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
28 | pip install pytest pytest-cov flake8
29 | pip install -e .
30 |
31 | - name: Lint with flake8
32 | run: |
33 | # stop the build if there are Python syntax errors or undefined names
34 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
35 | # exit-zero treats all errors as warnings
36 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
37 |
38 | - name: Test with pytest
39 | run: |
40 | pytest --cov=src tests/
41 |
42 | build:
43 | runs-on: ubuntu-latest
44 | needs: test
45 | if: github.event_name == 'push' && github.ref == 'refs/heads/main'
46 |
47 | steps:
48 | - uses: actions/checkout@v3
49 |
50 | - name: Set up Python
51 | uses: actions/setup-python@v4
52 | with:
53 | python-version: '3.10'
54 |
55 | - name: Install dependencies
56 | run: |
57 | python -m pip install --upgrade pip
58 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
59 | pip install build
60 |
61 | - name: Build package
62 | run: |
63 | python -m build
64 |
65 | - name: Archive production artifacts
66 | uses: actions/upload-artifact@v3
67 | with:
68 | name: dist
69 | path: |
70 | dist/
71 |
72 | deploy:
73 | runs-on: ubuntu-latest
74 | needs: build
75 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' && startsWith(github.event.head_commit.message, 'Release')
76 |
77 | steps:
78 | - uses: actions/checkout@v3
79 |
80 | - name: Download artifacts
81 | uses: actions/download-artifact@v3
82 | with:
83 | name: dist
84 | path: dist
85 |
86 | - name: Set up Python
87 | uses: actions/setup-python@v4
88 | with:
89 | python-version: '3.10'
90 |
91 | - name: Install dependencies
92 | run: |
93 | python -m pip install --upgrade pip
94 | pip install twine
95 |
96 | - name: Create GitHub Release
97 | id: create_release
98 | uses: actions/create-release@v1
99 | env:
100 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
101 | with:
102 | tag_name: v${{ github.run_number }}
103 | release_name: Release v${{ github.run_number }}
104 | body: |
105 | Automated release from CI/CD pipeline
106 | draft: false
107 | prerelease: false
108 |
```
--------------------------------------------------------------------------------
/tests/test_toast.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Tests for the Windows 10 Toast Notification implementation.
3 | """
4 |
5 | import unittest
6 | from unittest.mock import patch, MagicMock
7 | from src.notification.toast import (
8 | ToastNotificationManager, NotificationFactory,
9 | show_notification, NotificationType
10 | )
11 |
12 |
13 | class TestToastNotification(unittest.TestCase):
14 | """Test cases for the Windows 10 Toast Notification implementation."""
15 |
16 | @patch('src.notification.toast.ToastNotifier')
17 | def test_toast_notification_manager_init(self, mock_toaster):
18 | """Test initializing the toast notification manager."""
19 | manager = ToastNotificationManager()
20 | self.assertIsNotNone(manager.toaster)
21 |
22 | @patch('src.notification.toast.ToastNotifier')
23 | def test_show_notification(self, mock_toaster):
24 | """Test showing a notification."""
25 | mock_instance = MagicMock()
26 | mock_toaster.return_value = mock_instance
27 |
28 | manager = ToastNotificationManager()
29 | result = manager.show_notification(
30 | title="Test Title",
31 | message="Test Message",
32 | notification_type=NotificationType.INFO,
33 | duration=5
34 | )
35 |
36 | mock_instance.show_toast.assert_called_once()
37 | self.assertTrue(result)
38 |
39 | args, kwargs = mock_instance.show_toast.call_args
40 | self.assertEqual(kwargs["title"], "Test Title")
41 | self.assertEqual(kwargs["msg"], "Test Message")
42 | self.assertEqual(kwargs["duration"], 5)
43 | self.assertTrue(kwargs["threaded"])
44 |
45 | @patch('src.notification.toast.ToastNotifier')
46 | def test_show_notification_with_exception(self, mock_toaster):
47 | """Test showing a notification with an exception."""
48 | mock_instance = MagicMock()
49 | mock_instance.show_toast.side_effect = Exception("Test exception")
50 | mock_toaster.return_value = mock_instance
51 |
52 | manager = ToastNotificationManager()
53 | result = manager.show_notification(
54 | title="Test Title",
55 | message="Test Message"
56 | )
57 |
58 | self.assertFalse(result)
59 |
60 | @patch('src.notification.toast.ToastNotificationManager')
61 | def test_notification_factory(self, mock_manager_class):
62 | """Test the notification factory."""
63 | mock_manager = MagicMock()
64 | mock_manager_class.return_value = mock_manager
65 | mock_manager.show_notification.return_value = True
66 |
67 | factory = NotificationFactory()
68 |
69 | result = factory.create_info_notification("Info Title", "Info Message")
70 | self.assertTrue(result)
71 | mock_manager.show_notification.assert_called_with(
72 | title="Info Title",
73 | message="Info Message",
74 | notification_type=NotificationType.INFO,
75 | duration=5
76 | )
77 |
78 | result = factory.create_warning_notification("Warning Title", "Warning Message")
79 | self.assertTrue(result)
80 | mock_manager.show_notification.assert_called_with(
81 | title="Warning Title",
82 | message="Warning Message",
83 | notification_type=NotificationType.WARNING,
84 | duration=7
85 | )
86 |
87 | result = factory.create_error_notification("Error Title", "Error Message")
88 | self.assertTrue(result)
89 | mock_manager.show_notification.assert_called_with(
90 | title="Error Title",
91 | message="Error Message",
92 | notification_type=NotificationType.ERROR,
93 | duration=10
94 | )
95 |
96 | result = factory.create_success_notification("Success Title", "Success Message")
97 | self.assertTrue(result)
98 | mock_manager.show_notification.assert_called_with(
99 | title="Success Title",
100 | message="Success Message",
101 | notification_type=NotificationType.SUCCESS,
102 | duration=5
103 | )
104 |
105 | @patch('src.notification.toast.notification_factory')
106 | def test_show_notification_helper(self, mock_factory):
107 | """Test the show_notification helper function."""
108 | mock_factory.create_info_notification.return_value = True
109 | mock_factory.create_warning_notification.return_value = True
110 | mock_factory.create_error_notification.return_value = True
111 | mock_factory.create_success_notification.return_value = True
112 |
113 | result = show_notification("Info Title", "Info Message", "info")
114 | self.assertTrue(result)
115 | mock_factory.create_info_notification.assert_called_with("Info Title", "Info Message", 5)
116 |
117 | result = show_notification("Warning Title", "Warning Message", "warning")
118 | self.assertTrue(result)
119 | mock_factory.create_warning_notification.assert_called_with("Warning Title", "Warning Message", 5)
120 |
121 | result = show_notification("Error Title", "Error Message", "error")
122 | self.assertTrue(result)
123 | mock_factory.create_error_notification.assert_called_with("Error Title", "Error Message", 5)
124 |
125 | result = show_notification("Success Title", "Success Message", "success")
126 | self.assertTrue(result)
127 | mock_factory.create_success_notification.assert_called_with("Success Title", "Success Message", 5)
128 |
129 | result = show_notification("Invalid Title", "Invalid Message", "invalid")
130 | self.assertTrue(result)
131 | mock_factory.create_info_notification.assert_called_with("Invalid Title", "Invalid Message", 5)
132 |
133 |
134 | if __name__ == "__main__":
135 | unittest.main()
136 |
```
--------------------------------------------------------------------------------
/src/mcp/validation.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Validation utilities for MCP protocol messages.
3 |
4 | This module provides functions for validating MCP messages and their contents
5 | to ensure they conform to the protocol specification.
6 | """
7 |
8 | import re
9 | from typing import Dict, Any, List, Optional, Tuple, Union
10 |
11 | from .protocol import NotificationType, MessageType
12 |
13 |
14 | def validate_notification(data: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
15 | """
16 | Validate a notification message.
17 |
18 | Args:
19 | data: The notification message data to validate
20 |
21 | Returns:
22 | A tuple of (is_valid, error_message)
23 | """
24 | if "title" not in data:
25 | return False, "Missing required field: title"
26 |
27 | if "message" not in data:
28 | return False, "Missing required field: message"
29 |
30 | if not isinstance(data["title"], str):
31 | return False, "Title must be a string"
32 |
33 | if len(data["title"]) > 100:
34 | return False, "Title exceeds maximum length of 100 characters"
35 |
36 | if not isinstance(data["message"], str):
37 | return False, "Message must be a string"
38 |
39 | if len(data["message"]) > 1000:
40 | return False, "Message exceeds maximum length of 1000 characters"
41 |
42 | if "notification_type" in data:
43 | try:
44 | NotificationType(data["notification_type"])
45 | except ValueError:
46 | valid_types = [t.value for t in NotificationType]
47 | return False, f"Invalid notification type. Must be one of: {', '.join(valid_types)}"
48 |
49 | if "duration" in data:
50 | if not isinstance(data["duration"], int):
51 | return False, "Duration must be an integer"
52 |
53 | if data["duration"] < 1 or data["duration"] > 60:
54 | return False, "Duration must be between 1 and 60 seconds"
55 |
56 | if "client_id" in data:
57 | if not isinstance(data["client_id"], str):
58 | return False, "Client ID must be a string"
59 |
60 | if not re.match(r'^[a-zA-Z0-9_\-\.]{1,50}$', data["client_id"]):
61 | return False, "Client ID contains invalid characters or exceeds maximum length"
62 |
63 | if "icon" in data and not isinstance(data["icon"], str):
64 | return False, "Icon must be a string"
65 |
66 | if "actions" in data:
67 | if not isinstance(data["actions"], list):
68 | return False, "Actions must be a list"
69 |
70 | for i, action in enumerate(data["actions"]):
71 | if not isinstance(action, dict):
72 | return False, f"Action at index {i} must be a dictionary"
73 |
74 | if "id" not in action:
75 | return False, f"Action at index {i} missing required field: id"
76 |
77 | if "text" not in action:
78 | return False, f"Action at index {i} missing required field: text"
79 |
80 | if not isinstance(action["id"], str) or not isinstance(action["text"], str):
81 | return False, f"Action id and text must be strings"
82 |
83 | return True, None
84 |
85 |
86 | def validate_message(message_type: MessageType, data: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
87 | """
88 | Validate a message based on its type.
89 |
90 | Args:
91 | message_type: The type of message to validate
92 | data: The message data to validate
93 |
94 | Returns:
95 | A tuple of (is_valid, error_message)
96 | """
97 | if message_type == MessageType.NOTIFICATION:
98 | return validate_notification(data)
99 |
100 | elif message_type == MessageType.RESPONSE:
101 | if "success" not in data:
102 | return False, "Response message missing required field: success"
103 |
104 | if not isinstance(data["success"], bool):
105 | return False, "Success field must be a boolean"
106 |
107 | return True, None
108 |
109 | elif message_type == MessageType.ERROR:
110 | if "code" not in data:
111 | return False, "Error message missing required field: code"
112 |
113 | if "message" not in data:
114 | return False, "Error message missing required field: message"
115 |
116 | if not isinstance(data["code"], int):
117 | return False, "Error code must be an integer"
118 |
119 | if not isinstance(data["message"], str):
120 | return False, "Error message must be a string"
121 |
122 | return True, None
123 |
124 | elif message_type in (MessageType.PING, MessageType.PONG):
125 | return True, None
126 |
127 | return False, f"Unknown message type: {message_type}"
128 |
129 |
130 | def validate_message_format(data: Dict[str, Any]) -> Tuple[bool, Optional[str], Optional[MessageType]]:
131 | """
132 | Validate the format of a message.
133 |
134 | Args:
135 | data: The message data to validate
136 |
137 | Returns:
138 | A tuple of (is_valid, error_message, message_type)
139 | """
140 | if not isinstance(data, dict):
141 | return False, "Message must be a dictionary", None
142 |
143 | if "type" not in data:
144 | return False, "Message missing required field: type", None
145 |
146 | try:
147 | message_type = MessageType(data["type"])
148 | except ValueError:
149 | valid_types = [t.value for t in MessageType]
150 | return False, f"Invalid message type. Must be one of: {', '.join(valid_types)}", None
151 |
152 | if "data" not in data:
153 | data["data"] = {} # Add empty data for types that don't require it
154 |
155 | if not isinstance(data["data"], dict):
156 | return False, "Message data must be a dictionary", None
157 |
158 | is_valid, error_message = validate_message(message_type, data["data"])
159 |
160 | return is_valid, error_message, message_type
161 |
```
--------------------------------------------------------------------------------
/tests/test_protocol.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Tests for the MCP protocol implementation.
3 | """
4 |
5 | import unittest
6 | import json
7 | from src.mcp.protocol import (
8 | MCPMessage, NotificationMessage, ResponseMessage, ErrorMessage,
9 | PingMessage, PongMessage, MessageType, NotificationType, parse_message
10 | )
11 | from src.mcp.validation import validate_message_format, validate_notification
12 |
13 |
14 | class TestMCPProtocol(unittest.TestCase):
15 | """Test cases for the MCP protocol implementation."""
16 |
17 | def test_notification_message_creation(self):
18 | """Test creating a notification message."""
19 | notification = NotificationMessage(
20 | title="Test Notification",
21 | message="This is a test notification",
22 | notification_type=NotificationType.INFO,
23 | duration=10,
24 | client_id="test-client",
25 | icon="info-icon",
26 | actions=[{"id": "action1", "text": "Click Me"}]
27 | )
28 |
29 | self.assertEqual(notification.msg_type, MessageType.NOTIFICATION)
30 | self.assertEqual(notification.data["title"], "Test Notification")
31 | self.assertEqual(notification.data["message"], "This is a test notification")
32 | self.assertEqual(notification.data["notification_type"], "info")
33 | self.assertEqual(notification.data["duration"], 10)
34 | self.assertEqual(notification.data["client_id"], "test-client")
35 | self.assertEqual(notification.data["icon"], "info-icon")
36 | self.assertEqual(len(notification.data["actions"]), 1)
37 | self.assertEqual(notification.data["actions"][0]["id"], "action1")
38 |
39 | def test_notification_message_serialization(self):
40 | """Test serializing a notification message to JSON."""
41 | notification = NotificationMessage(
42 | title="Test Notification",
43 | message="This is a test notification"
44 | )
45 |
46 | json_str = notification.to_json()
47 | data = json.loads(json_str)
48 |
49 | self.assertEqual(data["type"], "notification")
50 | self.assertEqual(data["data"]["title"], "Test Notification")
51 | self.assertEqual(data["data"]["message"], "This is a test notification")
52 | self.assertEqual(data["data"]["notification_type"], "info") # Default type
53 |
54 | def test_notification_message_deserialization(self):
55 | """Test deserializing a notification message from JSON."""
56 | json_str = json.dumps({
57 | "type": "notification",
58 | "data": {
59 | "title": "Test Notification",
60 | "message": "This is a test notification",
61 | "notification_type": "warning",
62 | "duration": 15
63 | }
64 | })
65 |
66 | message = MCPMessage.from_json(json_str)
67 |
68 | self.assertIsInstance(message, NotificationMessage)
69 | self.assertEqual(message.data["title"], "Test Notification")
70 | self.assertEqual(message.data["message"], "This is a test notification")
71 | self.assertEqual(message.data["notification_type"], "warning")
72 | self.assertEqual(message.data["duration"], 15)
73 |
74 | def test_response_message(self):
75 | """Test creating and serializing a response message."""
76 | response = ResponseMessage(
77 | success=True,
78 | message="Operation completed successfully",
79 | data={"id": 123}
80 | )
81 |
82 | self.assertEqual(response.msg_type, MessageType.RESPONSE)
83 | self.assertTrue(response.data["success"])
84 | self.assertEqual(response.data["message"], "Operation completed successfully")
85 | self.assertEqual(response.data["data"]["id"], 123)
86 |
87 | json_str = response.to_json()
88 | data = json.loads(json_str)
89 |
90 | self.assertEqual(data["type"], "response")
91 | self.assertTrue(data["data"]["success"])
92 |
93 | def test_error_message(self):
94 | """Test creating and serializing an error message."""
95 | error = ErrorMessage(
96 | error_code=404,
97 | error_message="Resource not found",
98 | details={"resource_id": "abc123"}
99 | )
100 |
101 | self.assertEqual(error.msg_type, MessageType.ERROR)
102 | self.assertEqual(error.data["code"], 404)
103 | self.assertEqual(error.data["message"], "Resource not found")
104 | self.assertEqual(error.data["details"]["resource_id"], "abc123")
105 |
106 | json_str = error.to_json()
107 | data = json.loads(json_str)
108 |
109 | self.assertEqual(data["type"], "error")
110 | self.assertEqual(data["data"]["code"], 404)
111 |
112 | def test_ping_pong_messages(self):
113 | """Test creating ping and pong messages."""
114 | ping = PingMessage()
115 | pong = PongMessage()
116 |
117 | self.assertEqual(ping.msg_type, MessageType.PING)
118 | self.assertEqual(pong.msg_type, MessageType.PONG)
119 |
120 | ping_json = ping.to_json()
121 | pong_json = pong.to_json()
122 |
123 | ping_data = json.loads(ping_json)
124 | pong_data = json.loads(pong_json)
125 |
126 | self.assertEqual(ping_data["type"], "ping")
127 | self.assertEqual(pong_data["type"], "pong")
128 |
129 | def test_parse_message(self):
130 | """Test parsing messages from different formats."""
131 | json_str = json.dumps({
132 | "type": "notification",
133 | "data": {
134 | "title": "Test",
135 | "message": "Test message"
136 | }
137 | })
138 |
139 | message1 = parse_message(json_str)
140 | self.assertIsInstance(message1, NotificationMessage)
141 |
142 | dict_data = {
143 | "type": "response",
144 | "data": {
145 | "success": True
146 | }
147 | }
148 |
149 | message2 = parse_message(dict_data)
150 | self.assertIsInstance(message2, ResponseMessage)
151 |
152 | def test_validation(self):
153 | """Test message validation."""
154 | valid_notification = {
155 | "type": "notification",
156 | "data": {
157 | "title": "Valid Title",
158 | "message": "Valid message content",
159 | "notification_type": "info",
160 | "duration": 5
161 | }
162 | }
163 |
164 | is_valid, error, msg_type = validate_message_format(valid_notification)
165 | self.assertTrue(is_valid)
166 | self.assertIsNone(error)
167 | self.assertEqual(msg_type, MessageType.NOTIFICATION)
168 |
169 | invalid_notification = {
170 | "type": "notification",
171 | "data": {
172 | "message": "Message without title"
173 | }
174 | }
175 |
176 | is_valid, error, msg_type = validate_message_format(invalid_notification)
177 | self.assertFalse(is_valid)
178 | self.assertIn("Missing required field: title", error)
179 |
180 | valid_data = {
181 | "title": "Test",
182 | "message": "Test message",
183 | "duration": 10
184 | }
185 |
186 | is_valid, error = validate_notification(valid_data)
187 | self.assertTrue(is_valid)
188 | self.assertIsNone(error)
189 |
190 | invalid_data = {
191 | "title": "Test",
192 | "message": "Test message",
193 | "duration": 100 # Too long
194 | }
195 |
196 | is_valid, error = validate_notification(invalid_data)
197 | self.assertFalse(is_valid)
198 | self.assertIn("Duration must be between 1 and 60 seconds", error)
199 |
200 |
201 | if __name__ == "__main__":
202 | unittest.main()
203 |
```
--------------------------------------------------------------------------------
/src/notification/toast.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Windows 10 Toast Notification implementation for toast-mcp-server.
3 |
4 | This module provides functionality to display Windows 10 toast notifications
5 | using the win10toast library.
6 | """
7 |
8 | import logging
9 | from typing import Dict, Any, Optional, List, Union
10 | from win10toast import ToastNotifier
11 |
12 | from src.mcp.protocol import NotificationType
13 |
14 | logger = logging.getLogger(__name__)
15 |
16 |
17 | class ToastNotificationManager:
18 | """
19 | Manager for Windows 10 Toast Notifications.
20 |
21 | This class handles the creation and display of Windows 10 toast notifications
22 | using the win10toast library.
23 | """
24 |
25 | def __init__(self):
26 | """Initialize the toast notification manager."""
27 | self.toaster = ToastNotifier()
28 | logger.info("Toast notification manager initialized")
29 |
30 | def show_notification(self,
31 | title: str,
32 | message: str,
33 | notification_type: NotificationType = NotificationType.INFO,
34 | duration: int = 5,
35 | icon_path: Optional[str] = None,
36 | threaded: bool = True) -> bool:
37 | """
38 | Show a Windows 10 toast notification.
39 |
40 | Args:
41 | title: Title of the notification
42 | message: Content of the notification
43 | notification_type: Type of notification (info, warning, error, success)
44 | duration: Duration to display the notification in seconds
45 | icon_path: Path to the icon file to display with the notification
46 | threaded: Whether to show the notification in a separate thread
47 |
48 | Returns:
49 | True if the notification was successfully displayed, False otherwise
50 | """
51 | if not icon_path:
52 | icon_path = self._get_default_icon(notification_type)
53 |
54 | logger.debug(f"Showing notification: {title} ({notification_type.value})")
55 |
56 | try:
57 | self.toaster.show_toast(
58 | title=title,
59 | msg=message,
60 | icon_path=icon_path,
61 | duration=duration,
62 | threaded=threaded
63 | )
64 | return True
65 | except Exception as e:
66 | logger.error(f"Failed to show notification: {str(e)}")
67 | return False
68 |
69 | def _get_default_icon(self, notification_type: NotificationType) -> str:
70 | """
71 | Get the default icon path for a notification type.
72 |
73 | Args:
74 | notification_type: Type of notification
75 |
76 | Returns:
77 | Path to the default icon for the notification type
78 | """
79 | icons = {
80 | NotificationType.INFO: "icons/info.ico",
81 | NotificationType.WARNING: "icons/warning.ico",
82 | NotificationType.ERROR: "icons/error.ico",
83 | NotificationType.SUCCESS: "icons/success.ico"
84 | }
85 |
86 | return icons.get(notification_type, "icons/default.ico")
87 |
88 |
89 | class NotificationFactory:
90 | """
91 | Factory for creating notifications based on notification type.
92 |
93 | This class provides methods for creating and displaying different types
94 | of notifications with appropriate default settings.
95 | """
96 |
97 | def __init__(self):
98 | """Initialize the notification factory."""
99 | self.toast_manager = ToastNotificationManager()
100 |
101 | def create_info_notification(self, title: str, message: str, duration: int = 5) -> bool:
102 | """
103 | Create and show an information notification.
104 |
105 | Args:
106 | title: Title of the notification
107 | message: Content of the notification
108 | duration: Duration to display the notification in seconds
109 |
110 | Returns:
111 | True if the notification was successfully displayed, False otherwise
112 | """
113 | return self.toast_manager.show_notification(
114 | title=title,
115 | message=message,
116 | notification_type=NotificationType.INFO,
117 | duration=duration
118 | )
119 |
120 | def create_warning_notification(self, title: str, message: str, duration: int = 7) -> bool:
121 | """
122 | Create and show a warning notification.
123 |
124 | Args:
125 | title: Title of the notification
126 | message: Content of the notification
127 | duration: Duration to display the notification in seconds
128 |
129 | Returns:
130 | True if the notification was successfully displayed, False otherwise
131 | """
132 | return self.toast_manager.show_notification(
133 | title=title,
134 | message=message,
135 | notification_type=NotificationType.WARNING,
136 | duration=duration
137 | )
138 |
139 | def create_error_notification(self, title: str, message: str, duration: int = 10) -> bool:
140 | """
141 | Create and show an error notification.
142 |
143 | Args:
144 | title: Title of the notification
145 | message: Content of the notification
146 | duration: Duration to display the notification in seconds
147 |
148 | Returns:
149 | True if the notification was successfully displayed, False otherwise
150 | """
151 | return self.toast_manager.show_notification(
152 | title=title,
153 | message=message,
154 | notification_type=NotificationType.ERROR,
155 | duration=duration
156 | )
157 |
158 | def create_success_notification(self, title: str, message: str, duration: int = 5) -> bool:
159 | """
160 | Create and show a success notification.
161 |
162 | Args:
163 | title: Title of the notification
164 | message: Content of the notification
165 | duration: Duration to display the notification in seconds
166 |
167 | Returns:
168 | True if the notification was successfully displayed, False otherwise
169 | """
170 | return self.toast_manager.show_notification(
171 | title=title,
172 | message=message,
173 | notification_type=NotificationType.SUCCESS,
174 | duration=duration
175 | )
176 |
177 |
178 | notification_factory = NotificationFactory()
179 |
180 |
181 | def show_notification(title: str, message: str, notification_type: str = "info", duration: int = 5) -> bool:
182 | """
183 | Show a notification with the specified parameters.
184 |
185 | This is a convenience function for showing notifications without directly
186 | interacting with the NotificationFactory or ToastNotificationManager classes.
187 |
188 | Args:
189 | title: Title of the notification
190 | message: Content of the notification
191 | notification_type: Type of notification ("info", "warning", "error", "success")
192 | duration: Duration to display the notification in seconds
193 |
194 | Returns:
195 | True if the notification was successfully displayed, False otherwise
196 | """
197 | try:
198 | notification_type_enum = NotificationType(notification_type)
199 | except ValueError:
200 | logger.warning(f"Invalid notification type: {notification_type}, using INFO")
201 | notification_type_enum = NotificationType.INFO
202 |
203 | if notification_type_enum == NotificationType.INFO:
204 | return notification_factory.create_info_notification(title, message, duration)
205 | elif notification_type_enum == NotificationType.WARNING:
206 | return notification_factory.create_warning_notification(title, message, duration)
207 | elif notification_type_enum == NotificationType.ERROR:
208 | return notification_factory.create_error_notification(title, message, duration)
209 | elif notification_type_enum == NotificationType.SUCCESS:
210 | return notification_factory.create_success_notification(title, message, duration)
211 |
212 | return notification_factory.create_info_notification(title, message, duration)
213 |
```
--------------------------------------------------------------------------------
/src/notification/manager.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Unified notification manager for toast-mcp-server.
3 |
4 | This module provides a unified interface for displaying notifications
5 | on different platforms, automatically selecting the appropriate
6 | notification system based on the current platform.
7 | """
8 |
9 | import logging
10 | from typing import Dict, Any, Optional, List, Union
11 |
12 | from src.mcp.protocol import NotificationType
13 | from src.notification.platform import is_windows, is_macos, get_platform_name
14 |
15 | logger = logging.getLogger(__name__)
16 |
17 |
18 | class NotificationManager:
19 | """
20 | Unified notification manager for multiple platforms.
21 |
22 | This class provides a unified interface for displaying notifications
23 | on different platforms, automatically selecting the appropriate
24 | notification system based on the current platform.
25 | """
26 |
27 | def __init__(self):
28 | """Initialize the notification manager."""
29 | self._platform = get_platform_name()
30 | self._notification_system = self._get_notification_system()
31 | logger.info(f"Notification manager initialized for platform: {self._platform}")
32 |
33 | def _get_notification_system(self):
34 | """
35 | Get the appropriate notification system for the current platform.
36 |
37 | Returns:
38 | Notification system for the current platform
39 | """
40 | if is_windows():
41 | from src.notification.toast import ToastNotificationManager
42 | return ToastNotificationManager()
43 | elif is_macos():
44 | from src.notification.macos import MacOSNotificationManager
45 | return MacOSNotificationManager()
46 | else:
47 | logger.warning(f"No notification system available for platform: {self._platform}")
48 | return None
49 |
50 | def show_notification(self,
51 | title: str,
52 | message: str,
53 | notification_type: NotificationType = NotificationType.INFO,
54 | duration: int = 5,
55 | **kwargs) -> bool:
56 | """
57 | Show a notification on the current platform.
58 |
59 | Args:
60 | title: Title of the notification
61 | message: Content of the notification
62 | notification_type: Type of notification (info, warning, error, success)
63 | duration: Duration to display the notification in seconds (ignored on some platforms)
64 | **kwargs: Additional platform-specific parameters
65 |
66 | Returns:
67 | True if the notification was successfully displayed, False otherwise
68 | """
69 | if not self._notification_system:
70 | logger.error(f"Cannot show notification on unsupported platform: {self._platform}")
71 | return False
72 |
73 | logger.debug(f"Showing notification on {self._platform}: {title} ({notification_type.value})")
74 |
75 | try:
76 | if is_windows():
77 | return self._notification_system.show_notification(
78 | title=title,
79 | message=message,
80 | notification_type=notification_type,
81 | duration=duration,
82 | **kwargs
83 | )
84 | elif is_macos():
85 | subtitle = kwargs.get("subtitle")
86 | sound = kwargs.get("sound", True)
87 |
88 | return self._notification_system.show_notification(
89 | title=title,
90 | message=message,
91 | notification_type=notification_type,
92 | duration=duration,
93 | subtitle=subtitle,
94 | sound=sound
95 | )
96 | else:
97 | return False
98 |
99 | except Exception as e:
100 | logger.error(f"Failed to show notification: {str(e)}")
101 | return False
102 |
103 |
104 | class NotificationFactory:
105 | """
106 | Factory for creating notifications based on notification type.
107 |
108 | This class provides methods for creating and displaying different types
109 | of notifications with appropriate default settings.
110 | """
111 |
112 | def __init__(self):
113 | """Initialize the notification factory."""
114 | self.notification_manager = NotificationManager()
115 |
116 | def create_info_notification(self, title: str, message: str, **kwargs) -> bool:
117 | """
118 | Create and show an information notification.
119 |
120 | Args:
121 | title: Title of the notification
122 | message: Content of the notification
123 | **kwargs: Additional platform-specific parameters
124 |
125 | Returns:
126 | True if the notification was successfully displayed, False otherwise
127 | """
128 | return self.notification_manager.show_notification(
129 | title=title,
130 | message=message,
131 | notification_type=NotificationType.INFO,
132 | **kwargs
133 | )
134 |
135 | def create_warning_notification(self, title: str, message: str, **kwargs) -> bool:
136 | """
137 | Create and show a warning notification.
138 |
139 | Args:
140 | title: Title of the notification
141 | message: Content of the notification
142 | **kwargs: Additional platform-specific parameters
143 |
144 | Returns:
145 | True if the notification was successfully displayed, False otherwise
146 | """
147 | return self.notification_manager.show_notification(
148 | title=title,
149 | message=message,
150 | notification_type=NotificationType.WARNING,
151 | **kwargs
152 | )
153 |
154 | def create_error_notification(self, title: str, message: str, **kwargs) -> bool:
155 | """
156 | Create and show an error notification.
157 |
158 | Args:
159 | title: Title of the notification
160 | message: Content of the notification
161 | **kwargs: Additional platform-specific parameters
162 |
163 | Returns:
164 | True if the notification was successfully displayed, False otherwise
165 | """
166 | return self.notification_manager.show_notification(
167 | title=title,
168 | message=message,
169 | notification_type=NotificationType.ERROR,
170 | **kwargs
171 | )
172 |
173 | def create_success_notification(self, title: str, message: str, **kwargs) -> bool:
174 | """
175 | Create and show a success notification.
176 |
177 | Args:
178 | title: Title of the notification
179 | message: Content of the notification
180 | **kwargs: Additional platform-specific parameters
181 |
182 | Returns:
183 | True if the notification was successfully displayed, False otherwise
184 | """
185 | return self.notification_manager.show_notification(
186 | title=title,
187 | message=message,
188 | notification_type=NotificationType.SUCCESS,
189 | **kwargs
190 | )
191 |
192 |
193 | notification_factory = NotificationFactory()
194 |
195 |
196 | def show_notification(title: str, message: str, notification_type: str = "info", **kwargs) -> bool:
197 | """
198 | Show a notification with the specified parameters.
199 |
200 | This is a convenience function for showing notifications without directly
201 | interacting with the NotificationFactory or platform-specific notification classes.
202 |
203 | Args:
204 | title: Title of the notification
205 | message: Content of the notification
206 | notification_type: Type of notification ("info", "warning", "error", "success")
207 | **kwargs: Additional platform-specific parameters
208 |
209 | Returns:
210 | True if the notification was successfully displayed, False otherwise
211 | """
212 | try:
213 | notification_type_enum = NotificationType(notification_type)
214 | except ValueError:
215 | logger.warning(f"Invalid notification type: {notification_type}, using INFO")
216 | notification_type_enum = NotificationType.INFO
217 |
218 | if notification_type_enum == NotificationType.INFO:
219 | return notification_factory.create_info_notification(title, message, **kwargs)
220 | elif notification_type_enum == NotificationType.WARNING:
221 | return notification_factory.create_warning_notification(title, message, **kwargs)
222 | elif notification_type_enum == NotificationType.ERROR:
223 | return notification_factory.create_error_notification(title, message, **kwargs)
224 | elif notification_type_enum == NotificationType.SUCCESS:
225 | return notification_factory.create_success_notification(title, message, **kwargs)
226 |
227 | return notification_factory.create_info_notification(title, message, **kwargs)
228 |
```
--------------------------------------------------------------------------------
/src/server/commands.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Command processing system for toast-mcp-server.
3 |
4 | This module handles the processing of commands received from clients,
5 | including command registration, validation, and execution.
6 | """
7 |
8 | import logging
9 | import asyncio
10 | from typing import Dict, Any, Optional, Callable, Awaitable, List, Union, Tuple
11 |
12 | from src.mcp.protocol import (
13 | MCPMessage, ResponseMessage, ErrorMessage, MessageType
14 | )
15 | from src.notification.toast import show_notification
16 |
17 | logger = logging.getLogger(__name__)
18 |
19 |
20 | CommandHandler = Callable[[Dict[str, Any], str], Awaitable[Tuple[bool, str, Optional[Dict[str, Any]]]]]
21 | CommandValidator = Callable[[Dict[str, Any]], Tuple[bool, Optional[str]]]
22 |
23 |
24 | class Command:
25 | """
26 | Represents a command that can be executed by the server.
27 |
28 | Commands are registered with the CommandProcessor and can be executed
29 | when received from clients.
30 | """
31 |
32 | def __init__(self,
33 | name: str,
34 | handler: CommandHandler,
35 | validator: Optional[CommandValidator] = None,
36 | description: str = "",
37 | requires_auth: bool = False):
38 | """
39 | Initialize a new command.
40 |
41 | Args:
42 | name: Name of the command
43 | handler: Function to handle the command execution
44 | validator: Optional function to validate command parameters
45 | description: Description of the command
46 | requires_auth: Whether the command requires authentication
47 | """
48 | self.name = name
49 | self.handler = handler
50 | self.validator = validator
51 | self.description = description
52 | self.requires_auth = requires_auth
53 |
54 | async def execute(self, params: Dict[str, Any], client_id: str) -> Tuple[bool, str, Optional[Dict[str, Any]]]:
55 | """
56 | Execute the command.
57 |
58 | Args:
59 | params: Parameters for the command
60 | client_id: ID of the client executing the command
61 |
62 | Returns:
63 | Tuple of (success, message, data)
64 | """
65 | if self.validator:
66 | is_valid, error = self.validator(params)
67 | if not is_valid:
68 | return False, f"Invalid parameters: {error}", None
69 |
70 | try:
71 | return await self.handler(params, client_id)
72 | except Exception as e:
73 | logger.error(f"Error executing command {self.name}: {str(e)}")
74 | return False, f"Error executing command: {str(e)}", None
75 |
76 |
77 | class CommandProcessor:
78 | """
79 | Processes commands received from clients.
80 |
81 | This class manages the registration and execution of commands,
82 | as well as the handling of command responses.
83 | """
84 |
85 | def __init__(self):
86 | """Initialize the command processor."""
87 | self.commands: Dict[str, Command] = {}
88 | self.authenticated_clients: List[str] = []
89 | logger.info("Command processor initialized")
90 |
91 | def register_command(self, command: Command) -> None:
92 | """
93 | Register a command with the processor.
94 |
95 | Args:
96 | command: The command to register
97 | """
98 | self.commands[command.name] = command
99 | logger.debug(f"Registered command: {command.name}")
100 |
101 | def register_commands(self, commands: List[Command]) -> None:
102 | """
103 | Register multiple commands with the processor.
104 |
105 | Args:
106 | commands: List of commands to register
107 | """
108 | for command in commands:
109 | self.register_command(command)
110 |
111 | async def process_command(self,
112 | command_name: str,
113 | params: Dict[str, Any],
114 | client_id: str) -> Tuple[bool, str, Optional[Dict[str, Any]]]:
115 | """
116 | Process a command.
117 |
118 | Args:
119 | command_name: Name of the command to execute
120 | params: Parameters for the command
121 | client_id: ID of the client executing the command
122 |
123 | Returns:
124 | Tuple of (success, message, data)
125 | """
126 | if command_name not in self.commands:
127 | return False, f"Unknown command: {command_name}", None
128 |
129 | command = self.commands[command_name]
130 |
131 | if command.requires_auth and client_id not in self.authenticated_clients:
132 | return False, "Authentication required", None
133 |
134 | return await command.execute(params, client_id)
135 |
136 | def authenticate_client(self, client_id: str) -> None:
137 | """
138 | Mark a client as authenticated.
139 |
140 | Args:
141 | client_id: ID of the client to authenticate
142 | """
143 | if client_id not in self.authenticated_clients:
144 | self.authenticated_clients.append(client_id)
145 | logger.debug(f"Client authenticated: {client_id}")
146 |
147 | def deauthenticate_client(self, client_id: str) -> None:
148 | """
149 | Remove a client's authentication.
150 |
151 | Args:
152 | client_id: ID of the client to deauthenticate
153 | """
154 | if client_id in self.authenticated_clients:
155 | self.authenticated_clients.remove(client_id)
156 | logger.debug(f"Client deauthenticated: {client_id}")
157 |
158 |
159 |
160 | async def handle_show_notification(params: Dict[str, Any], client_id: str) -> Tuple[bool, str, Optional[Dict[str, Any]]]:
161 | """
162 | Handle the 'show_notification' command.
163 |
164 | Args:
165 | params: Parameters for the command
166 | client_id: ID of the client executing the command
167 |
168 | Returns:
169 | Tuple of (success, message, data)
170 | """
171 | title = params.get("title", "")
172 | message = params.get("message", "")
173 | notification_type = params.get("type", "info")
174 | duration = params.get("duration", 5)
175 |
176 | success = show_notification(title, message, notification_type, duration)
177 |
178 | if success:
179 | return True, "Notification displayed successfully", None
180 | else:
181 | return False, "Failed to display notification", None
182 |
183 |
184 | async def handle_list_commands(params: Dict[str, Any], client_id: str) -> Tuple[bool, str, Optional[Dict[str, Any]]]:
185 | """
186 | Handle the 'list_commands' command.
187 |
188 | Args:
189 | params: Parameters for the command
190 | client_id: ID of the client executing the command
191 |
192 | Returns:
193 | Tuple of (success, message, data)
194 | """
195 | commands = [
196 | {"name": "show_notification", "description": "Display a Windows 10 toast notification"},
197 | {"name": "list_commands", "description": "List available commands"}
198 | ]
199 |
200 | return True, "Commands retrieved successfully", {"commands": commands}
201 |
202 |
203 | def validate_show_notification(params: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
204 | """
205 | Validate parameters for the 'show_notification' command.
206 |
207 | Args:
208 | params: Parameters to validate
209 |
210 | Returns:
211 | Tuple of (is_valid, error_message)
212 | """
213 | if "title" not in params:
214 | return False, "Missing required parameter: title"
215 |
216 | if "message" not in params:
217 | return False, "Missing required parameter: message"
218 |
219 | if not isinstance(params.get("title"), str):
220 | return False, "Title must be a string"
221 |
222 | if not isinstance(params.get("message"), str):
223 | return False, "Message must be a string"
224 |
225 | if "type" in params and params["type"] not in ["info", "warning", "error", "success"]:
226 | return False, "Invalid notification type"
227 |
228 | if "duration" in params and not isinstance(params["duration"], int):
229 | return False, "Duration must be an integer"
230 |
231 | return True, None
232 |
233 |
234 | DEFAULT_COMMANDS = [
235 | Command(
236 | name="show_notification",
237 | handler=handle_show_notification,
238 | validator=validate_show_notification,
239 | description="Display a Windows 10 toast notification"
240 | ),
241 | Command(
242 | name="list_commands",
243 | handler=handle_list_commands,
244 | description="List available commands"
245 | )
246 | ]
247 |
248 |
249 | command_processor = CommandProcessor()
250 |
251 | command_processor.register_commands(DEFAULT_COMMANDS)
252 |
253 |
254 | async def process_command_message(message_data: Dict[str, Any], client_id: str) -> MCPMessage:
255 | """
256 | Process a command message and return a response.
257 |
258 | Args:
259 | message_data: Dictionary containing the command message data
260 | client_id: ID of the client sending the command
261 |
262 | Returns:
263 | Response message to send back to the client
264 | """
265 | command_name = message_data.get("command")
266 | params = message_data.get("params", {})
267 |
268 | if not command_name:
269 | return ErrorMessage(400, "Missing command name")
270 |
271 | success, message, data = await command_processor.process_command(command_name, params, client_id)
272 |
273 | if success:
274 | return ResponseMessage(True, message, data)
275 | else:
276 | return ErrorMessage(400, message)
277 |
```
--------------------------------------------------------------------------------
/tests/test_connection.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Tests for the client connection management implementation.
3 | """
4 |
5 | import unittest
6 | import asyncio
7 | import json
8 | from unittest.mock import patch, MagicMock, AsyncMock
9 | from src.server.connection import (
10 | ClientConnection, ConnectionManager, run_server
11 | )
12 | from src.mcp.protocol import (
13 | MCPMessage, NotificationMessage, ResponseMessage, ErrorMessage,
14 | PingMessage, PongMessage, MessageType, NotificationType
15 | )
16 |
17 |
18 | class TestClientConnection(unittest.TestCase):
19 | """Test cases for the ClientConnection class."""
20 |
21 | def setUp(self):
22 | """Set up test fixtures."""
23 | self.reader = AsyncMock(spec=asyncio.StreamReader)
24 | self.writer = AsyncMock(spec=asyncio.StreamWriter)
25 | self.server = MagicMock(spec=ConnectionManager)
26 |
27 | self.writer.get_extra_info.return_value = ('127.0.0.1', 12345)
28 |
29 | self.client = ClientConnection(
30 | self.reader, self.writer, "test_client", self.server
31 | )
32 |
33 | @patch('src.server.connection.logger')
34 | async def test_handle_connection(self, mock_logger):
35 | """Test handling a client connection."""
36 | self.reader.readline.side_effect = [
37 | b'{"type": "ping", "data": {}}\n',
38 | b''
39 | ]
40 |
41 | await self.client.handle()
42 |
43 | self.reader.readline.assert_called()
44 |
45 | self.assertFalse(self.client.connected)
46 | self.server.remove_client.assert_called_once_with("test_client")
47 | self.writer.close.assert_called_once()
48 | self.writer.wait_closed.assert_called_once()
49 |
50 | @patch('src.server.connection.logger')
51 | async def test_process_message_ping(self, mock_logger):
52 | """Test processing a ping message."""
53 | await self.client._process_message('{"type": "ping", "data": {}}')
54 |
55 | self.writer.write.assert_called_once()
56 | written_data = self.writer.write.call_args[0][0].decode()
57 | self.assertIn('"type": "pong"', written_data)
58 |
59 | @patch('src.server.connection.show_notification')
60 | @patch('src.server.connection.logger')
61 | async def test_process_message_notification(self, mock_logger, mock_show_notification):
62 | """Test processing a notification message."""
63 | mock_show_notification.return_value = True
64 |
65 | await self.client._process_message(
66 | '{"type": "notification", "data": {"title": "Test", "message": "Test message"}}'
67 | )
68 |
69 | mock_show_notification.assert_called_once_with(
70 | "Test", "Test message", "info", 5
71 | )
72 |
73 | self.writer.write.assert_called_once()
74 | written_data = self.writer.write.call_args[0][0].decode()
75 | self.assertIn('"type": "response"', written_data)
76 | self.assertIn('"success": true', written_data)
77 |
78 | @patch('src.server.connection.logger')
79 | async def test_process_message_invalid_json(self, mock_logger):
80 | """Test processing an invalid JSON message."""
81 | await self.client._process_message('invalid json')
82 |
83 | self.writer.write.assert_called_once()
84 | written_data = self.writer.write.call_args[0][0].decode()
85 | self.assertIn('"type": "error"', written_data)
86 | self.assertIn('"code": 400', written_data)
87 |
88 | @patch('src.server.connection.logger')
89 | async def test_send_message(self, mock_logger):
90 | """Test sending a message to the client."""
91 | message = PingMessage()
92 |
93 | await self.client.send_message(message)
94 |
95 | self.writer.write.assert_called_once()
96 | self.writer.drain.assert_called_once()
97 |
98 | written_data = self.writer.write.call_args[0][0].decode()
99 | self.assertIn('"type": "ping"', written_data)
100 |
101 | @patch('src.server.connection.logger')
102 | async def test_close(self, mock_logger):
103 | """Test closing the client connection."""
104 | await self.client.close()
105 |
106 | self.assertFalse(self.client.connected)
107 | self.server.remove_client.assert_called_once_with("test_client")
108 | self.writer.close.assert_called_once()
109 | self.writer.wait_closed.assert_called_once()
110 |
111 | self.server.remove_client.reset_mock()
112 | self.writer.close.reset_mock()
113 | self.writer.wait_closed.reset_mock()
114 |
115 | await self.client.close()
116 |
117 | self.server.remove_client.assert_not_called()
118 | self.writer.close.assert_not_called()
119 | self.writer.wait_closed.assert_not_called()
120 |
121 |
122 | class TestConnectionManager(unittest.TestCase):
123 | """Test cases for the ConnectionManager class."""
124 |
125 | def setUp(self):
126 | """Set up test fixtures."""
127 | self.manager = ConnectionManager("127.0.0.1", 8765)
128 |
129 | @patch('src.server.connection.asyncio.start_server')
130 | @patch('src.server.connection.logger')
131 | async def test_start_server(self, mock_logger, mock_start_server):
132 | """Test starting the server."""
133 | mock_server = AsyncMock()
134 | mock_server.sockets = [MagicMock()]
135 | mock_server.sockets[0].getsockname.return_value = ('127.0.0.1', 8765)
136 | mock_start_server.return_value = mock_server
137 |
138 | task = asyncio.create_task(self.manager.start())
139 |
140 | await asyncio.sleep(0.1)
141 |
142 | task.cancel()
143 |
144 | try:
145 | await task
146 | except asyncio.CancelledError:
147 | pass
148 |
149 | mock_start_server.assert_called_once_with(
150 | self.manager._handle_new_connection, "127.0.0.1", 8765
151 | )
152 |
153 | @patch('src.server.connection.ClientConnection')
154 | @patch('src.server.connection.asyncio.create_task')
155 | @patch('src.server.connection.logger')
156 | async def test_handle_new_connection(self, mock_logger, mock_create_task, mock_client_connection):
157 | """Test handling a new connection."""
158 | reader = AsyncMock(spec=asyncio.StreamReader)
159 | writer = AsyncMock(spec=asyncio.StreamWriter)
160 | mock_client = MagicMock()
161 | mock_client_connection.return_value = mock_client
162 |
163 | await self.manager._handle_new_connection(reader, writer)
164 |
165 | mock_client_connection.assert_called_once_with(
166 | reader, writer, "client_1", self.manager
167 | )
168 |
169 | self.assertEqual(len(self.manager.clients), 1)
170 | self.assertEqual(self.manager.clients["client_1"], mock_client)
171 |
172 | mock_create_task.assert_called_once_with(mock_client.handle())
173 |
174 | @patch('src.server.connection.logger')
175 | def test_remove_client(self, mock_logger):
176 | """Test removing a client."""
177 | self.manager.clients["test_client"] = MagicMock()
178 |
179 | self.manager.remove_client("test_client")
180 |
181 | self.assertEqual(len(self.manager.clients), 0)
182 |
183 | self.manager.remove_client("non_existent_client")
184 |
185 | @patch('src.server.connection.logger')
186 | async def test_broadcast(self, mock_logger):
187 | """Test broadcasting a message to all clients."""
188 | client1 = MagicMock()
189 | client1.send_message = AsyncMock()
190 | client2 = MagicMock()
191 | client2.send_message = AsyncMock()
192 |
193 | self.manager.clients["client1"] = client1
194 | self.manager.clients["client2"] = client2
195 |
196 | message = PingMessage()
197 |
198 | await self.manager.broadcast(message)
199 |
200 | client1.send_message.assert_called_once_with(message)
201 | client2.send_message.assert_called_once_with(message)
202 |
203 | client1.send_message.reset_mock()
204 | client2.send_message.reset_mock()
205 |
206 | await self.manager.broadcast(message, exclude="client1")
207 |
208 | client1.send_message.assert_not_called()
209 | client2.send_message.assert_called_once_with(message)
210 |
211 | @patch('src.server.connection.logger')
212 | async def test_stop(self, mock_logger):
213 | """Test stopping the server."""
214 | client1 = MagicMock()
215 | client1.close = AsyncMock()
216 | client2 = MagicMock()
217 | client2.close = AsyncMock()
218 |
219 | self.manager.clients["client1"] = client1
220 | self.manager.clients["client2"] = client2
221 |
222 | self.manager.server = MagicMock()
223 | self.manager.server.wait_closed = AsyncMock()
224 |
225 | await self.manager.stop()
226 |
227 | client1.close.assert_called_once()
228 | client2.close.assert_called_once()
229 |
230 | self.manager.server.close.assert_called_once()
231 | self.manager.server.wait_closed.assert_called_once()
232 |
233 |
234 | @patch('src.server.connection.ConnectionManager')
235 | @patch('src.server.connection.logging')
236 | async def test_run_server(mock_logging, mock_manager_class):
237 | """Test running the server."""
238 | mock_manager = AsyncMock()
239 | mock_manager_class.return_value = mock_manager
240 |
241 | await run_server("127.0.0.1", 8765)
242 |
243 | mock_logging.basicConfig.assert_called_once()
244 |
245 | mock_manager_class.assert_called_once_with("127.0.0.1", 8765)
246 | mock_manager.start.assert_called_once()
247 |
248 |
249 | if __name__ == "__main__":
250 | unittest.main()
251 |
```
--------------------------------------------------------------------------------
/tests/test_macos.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Tests for the macOS Notification implementation.
3 | """
4 |
5 | import unittest
6 | from unittest.mock import patch, MagicMock
7 | import platform
8 | import subprocess
9 | from src.notification.macos import (
10 | MacOSNotificationManager, MacOSNotificationFactory,
11 | show_macos_notification, NotificationType
12 | )
13 |
14 |
15 | class TestMacOSNotification(unittest.TestCase):
16 | """Test cases for the macOS Notification implementation."""
17 |
18 | @patch('src.notification.macos.platform.system')
19 | def test_is_macos(self, mock_system):
20 | """Test platform detection."""
21 | mock_system.return_value = "Darwin"
22 | manager = MacOSNotificationManager()
23 | self.assertTrue(manager._is_macos())
24 |
25 | mock_system.return_value = "Linux"
26 | manager = MacOSNotificationManager()
27 | self.assertFalse(manager._is_macos())
28 |
29 | @patch('src.notification.macos.platform.system')
30 | @patch('src.notification.macos.subprocess.run')
31 | def test_show_notification(self, mock_run, mock_system):
32 | """Test showing a notification."""
33 | mock_system.return_value = "Darwin"
34 | mock_process = MagicMock()
35 | mock_process.returncode = 0
36 | mock_run.return_value = mock_process
37 |
38 | manager = MacOSNotificationManager()
39 | result = manager.show_notification(
40 | title="Test Title",
41 | message="Test Message",
42 | notification_type=NotificationType.INFO
43 | )
44 |
45 | self.assertTrue(result)
46 | mock_run.assert_called_once()
47 |
48 | args, kwargs = mock_run.call_args
49 | self.assertEqual(args[0][0], "osascript")
50 | self.assertEqual(args[0][1], "-e")
51 | self.assertIn("display notification", args[0][2])
52 | self.assertIn("Test Message", args[0][2])
53 | self.assertIn("Test Title", args[0][2])
54 |
55 | @patch('src.notification.macos.platform.system')
56 | @patch('src.notification.macos.subprocess.run')
57 | def test_show_notification_with_subtitle(self, mock_run, mock_system):
58 | """Test showing a notification with a subtitle."""
59 | mock_system.return_value = "Darwin"
60 | mock_process = MagicMock()
61 | mock_process.returncode = 0
62 | mock_run.return_value = mock_process
63 |
64 | manager = MacOSNotificationManager()
65 | result = manager.show_notification(
66 | title="Test Title",
67 | message="Test Message",
68 | subtitle="Test Subtitle",
69 | notification_type=NotificationType.INFO
70 | )
71 |
72 | self.assertTrue(result)
73 | mock_run.assert_called_once()
74 |
75 | args, kwargs = mock_run.call_args
76 | self.assertIn("subtitle", args[0][2])
77 | self.assertIn("Test Subtitle", args[0][2])
78 |
79 | @patch('src.notification.macos.platform.system')
80 | @patch('src.notification.macos.subprocess.run')
81 | def test_show_notification_without_sound(self, mock_run, mock_system):
82 | """Test showing a notification without sound."""
83 | mock_system.return_value = "Darwin"
84 | mock_process = MagicMock()
85 | mock_process.returncode = 0
86 | mock_run.return_value = mock_process
87 |
88 | manager = MacOSNotificationManager()
89 | result = manager.show_notification(
90 | title="Test Title",
91 | message="Test Message",
92 | sound=False,
93 | notification_type=NotificationType.INFO
94 | )
95 |
96 | self.assertTrue(result)
97 | mock_run.assert_called_once()
98 |
99 | args, kwargs = mock_run.call_args
100 | self.assertNotIn("sound name", args[0][2])
101 |
102 | @patch('src.notification.macos.platform.system')
103 | @patch('src.notification.macos.subprocess.run')
104 | def test_show_notification_on_non_macos(self, mock_run, mock_system):
105 | """Test showing a notification on a non-macOS platform."""
106 | mock_system.return_value = "Linux"
107 |
108 | manager = MacOSNotificationManager()
109 | result = manager.show_notification(
110 | title="Test Title",
111 | message="Test Message",
112 | notification_type=NotificationType.INFO
113 | )
114 |
115 | self.assertFalse(result)
116 | mock_run.assert_not_called()
117 |
118 | @patch('src.notification.macos.platform.system')
119 | @patch('src.notification.macos.subprocess.run')
120 | def test_show_notification_with_subprocess_error(self, mock_run, mock_system):
121 | """Test showing a notification with a subprocess error."""
122 | mock_system.return_value = "Darwin"
123 | mock_run.side_effect = subprocess.SubprocessError("Test error")
124 |
125 | manager = MacOSNotificationManager()
126 | result = manager.show_notification(
127 | title="Test Title",
128 | message="Test Message",
129 | notification_type=NotificationType.INFO
130 | )
131 |
132 | self.assertFalse(result)
133 |
134 | @patch('src.notification.macos.platform.system')
135 | @patch('src.notification.macos.subprocess.run')
136 | def test_show_notification_with_return_code_error(self, mock_run, mock_system):
137 | """Test showing a notification with a return code error."""
138 | mock_system.return_value = "Darwin"
139 | mock_process = MagicMock()
140 | mock_process.returncode = 1
141 | mock_process.stderr = "Test error"
142 | mock_run.return_value = mock_process
143 |
144 | manager = MacOSNotificationManager()
145 | result = manager.show_notification(
146 | title="Test Title",
147 | message="Test Message",
148 | notification_type=NotificationType.INFO
149 | )
150 |
151 | self.assertFalse(result)
152 |
153 | @patch('src.notification.macos.MacOSNotificationManager')
154 | def test_notification_factory(self, mock_manager_class):
155 | """Test the notification factory."""
156 | mock_manager = MagicMock()
157 | mock_manager_class.return_value = mock_manager
158 | mock_manager.show_notification.return_value = True
159 |
160 | factory = MacOSNotificationFactory()
161 |
162 | result = factory.create_info_notification("Info Title", "Info Message", "Info Subtitle")
163 | self.assertTrue(result)
164 | mock_manager.show_notification.assert_called_with(
165 | title="Info Title",
166 | message="Info Message",
167 | notification_type=NotificationType.INFO,
168 | subtitle="Info Subtitle"
169 | )
170 |
171 | result = factory.create_warning_notification("Warning Title", "Warning Message")
172 | self.assertTrue(result)
173 | mock_manager.show_notification.assert_called_with(
174 | title="Warning Title",
175 | message="Warning Message",
176 | notification_type=NotificationType.WARNING,
177 | subtitle=None
178 | )
179 |
180 | result = factory.create_error_notification("Error Title", "Error Message")
181 | self.assertTrue(result)
182 | mock_manager.show_notification.assert_called_with(
183 | title="Error Title",
184 | message="Error Message",
185 | notification_type=NotificationType.ERROR,
186 | subtitle=None
187 | )
188 |
189 | result = factory.create_success_notification("Success Title", "Success Message")
190 | self.assertTrue(result)
191 | mock_manager.show_notification.assert_called_with(
192 | title="Success Title",
193 | message="Success Message",
194 | notification_type=NotificationType.SUCCESS,
195 | subtitle=None
196 | )
197 |
198 | @patch('src.notification.macos.macos_notification_factory')
199 | def test_show_macos_notification_helper(self, mock_factory):
200 | """Test the show_macos_notification helper function."""
201 | mock_factory.create_info_notification.return_value = True
202 | mock_factory.create_warning_notification.return_value = True
203 | mock_factory.create_error_notification.return_value = True
204 | mock_factory.create_success_notification.return_value = True
205 |
206 | result = show_macos_notification("Info Title", "Info Message", "info", "Info Subtitle")
207 | self.assertTrue(result)
208 | mock_factory.create_info_notification.assert_called_with("Info Title", "Info Message", "Info Subtitle")
209 |
210 | result = show_macos_notification("Warning Title", "Warning Message", "warning")
211 | self.assertTrue(result)
212 | mock_factory.create_warning_notification.assert_called_with("Warning Title", "Warning Message", None)
213 |
214 | result = show_macos_notification("Error Title", "Error Message", "error")
215 | self.assertTrue(result)
216 | mock_factory.create_error_notification.assert_called_with("Error Title", "Error Message", None)
217 |
218 | result = show_macos_notification("Success Title", "Success Message", "success")
219 | self.assertTrue(result)
220 | mock_factory.create_success_notification.assert_called_with("Success Title", "Success Message", None)
221 |
222 | result = show_macos_notification("Invalid Title", "Invalid Message", "invalid")
223 | self.assertTrue(result)
224 | mock_factory.create_info_notification.assert_called_with("Invalid Title", "Invalid Message", None)
225 |
226 |
227 | if __name__ == "__main__":
228 | unittest.main()
229 |
```
--------------------------------------------------------------------------------
/src/mcp/protocol.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | MCP (Model Context Protocol) implementation for toast-mcp-server.
3 |
4 | This module defines the protocol specification and message handling for
5 | communication between MCP clients and the notification server.
6 | """
7 |
8 | import json
9 | import logging
10 | from enum import Enum
11 | from typing import Dict, Any, Optional, List, Union
12 |
13 | logger = logging.getLogger(__name__)
14 |
15 | class MessageType(Enum):
16 | """Enum defining the types of messages in the MCP protocol."""
17 | NOTIFICATION = "notification"
18 | RESPONSE = "response"
19 | ERROR = "error"
20 | PING = "ping"
21 | PONG = "pong"
22 |
23 |
24 | class NotificationType(Enum):
25 | """Enum defining the types of notifications supported."""
26 | INFO = "info"
27 | WARNING = "warning"
28 | ERROR = "error"
29 | SUCCESS = "success"
30 |
31 |
32 | class MCPMessage:
33 | """Base class for MCP protocol messages."""
34 |
35 | def __init__(self, msg_type: MessageType, data: Dict[str, Any] = None):
36 | """
37 | Initialize a new MCP message.
38 |
39 | Args:
40 | msg_type: The type of message
41 | data: Optional data payload for the message
42 | """
43 | self.msg_type = msg_type
44 | self.data = data or {}
45 |
46 | def to_dict(self) -> Dict[str, Any]:
47 | """Convert the message to a dictionary representation."""
48 | return {
49 | "type": self.msg_type.value,
50 | "data": self.data
51 | }
52 |
53 | def to_json(self) -> str:
54 | """Convert the message to a JSON string."""
55 | return json.dumps(self.to_dict())
56 |
57 | @classmethod
58 | def from_dict(cls, data: Dict[str, Any]) -> 'MCPMessage':
59 | """
60 | Create a message from a dictionary.
61 |
62 | Args:
63 | data: Dictionary containing message data
64 |
65 | Returns:
66 | An MCPMessage instance
67 |
68 | Raises:
69 | ValueError: If the message type is invalid or required fields are missing
70 | """
71 | if "type" not in data:
72 | raise ValueError("Message missing required 'type' field")
73 |
74 | try:
75 | msg_type = MessageType(data["type"])
76 | except ValueError:
77 | raise ValueError(f"Invalid message type: {data['type']}")
78 |
79 | msg_data = data.get("data", {})
80 |
81 | if msg_type == MessageType.NOTIFICATION:
82 | return NotificationMessage.from_dict(data)
83 | elif msg_type == MessageType.RESPONSE:
84 | return ResponseMessage.from_dict(data)
85 | elif msg_type == MessageType.ERROR:
86 | return ErrorMessage.from_dict(data)
87 | elif msg_type == MessageType.PING:
88 | return PingMessage()
89 | elif msg_type == MessageType.PONG:
90 | return PongMessage()
91 |
92 | return cls(msg_type, msg_data)
93 |
94 | @classmethod
95 | def from_json(cls, json_str: str) -> 'MCPMessage':
96 | """
97 | Create a message from a JSON string.
98 |
99 | Args:
100 | json_str: JSON string containing message data
101 |
102 | Returns:
103 | An MCPMessage instance
104 |
105 | Raises:
106 | ValueError: If the JSON is invalid or the message format is incorrect
107 | """
108 | try:
109 | data = json.loads(json_str)
110 | except json.JSONDecodeError:
111 | raise ValueError("Invalid JSON format")
112 |
113 | return cls.from_dict(data)
114 |
115 |
116 | class NotificationMessage(MCPMessage):
117 | """Message for sending notifications to the server."""
118 |
119 | def __init__(self,
120 | title: str,
121 | message: str,
122 | notification_type: NotificationType = NotificationType.INFO,
123 | duration: int = 5,
124 | client_id: str = None,
125 | icon: str = None,
126 | actions: List[Dict[str, str]] = None):
127 | """
128 | Initialize a new notification message.
129 |
130 | Args:
131 | title: Title of the notification
132 | message: Content of the notification
133 | notification_type: Type of notification (info, warning, error, success)
134 | duration: Duration to display the notification in seconds
135 | client_id: Optional identifier for the client sending the notification
136 | icon: Optional icon to display with the notification
137 | actions: Optional list of actions that can be taken on the notification
138 | """
139 | data = {
140 | "title": title,
141 | "message": message,
142 | "notification_type": notification_type.value,
143 | "duration": duration
144 | }
145 |
146 | if client_id:
147 | data["client_id"] = client_id
148 |
149 | if icon:
150 | data["icon"] = icon
151 |
152 | if actions:
153 | data["actions"] = actions
154 |
155 | super().__init__(MessageType.NOTIFICATION, data)
156 |
157 | @classmethod
158 | def from_dict(cls, data: Dict[str, Any]) -> 'NotificationMessage':
159 | """Create a NotificationMessage from a dictionary."""
160 | msg_data = data.get("data", {})
161 |
162 | if "title" not in msg_data or "message" not in msg_data:
163 | raise ValueError("Notification message missing required fields: title and message")
164 |
165 | try:
166 | notification_type = NotificationType(msg_data.get("notification_type", "info"))
167 | except ValueError:
168 | notification_type = NotificationType.INFO
169 | logger.warning(f"Invalid notification type: {msg_data.get('notification_type')}, using INFO")
170 |
171 | return cls(
172 | title=msg_data["title"],
173 | message=msg_data["message"],
174 | notification_type=notification_type,
175 | duration=msg_data.get("duration", 5),
176 | client_id=msg_data.get("client_id"),
177 | icon=msg_data.get("icon"),
178 | actions=msg_data.get("actions")
179 | )
180 |
181 |
182 | class ResponseMessage(MCPMessage):
183 | """Message for server responses to clients."""
184 |
185 | def __init__(self, success: bool, message: str = None, data: Dict[str, Any] = None):
186 | """
187 | Initialize a new response message.
188 |
189 | Args:
190 | success: Whether the operation was successful
191 | message: Optional message describing the result
192 | data: Optional additional data to include in the response
193 | """
194 | response_data = {
195 | "success": success
196 | }
197 |
198 | if message:
199 | response_data["message"] = message
200 |
201 | if data:
202 | response_data["data"] = data
203 |
204 | super().__init__(MessageType.RESPONSE, response_data)
205 |
206 | @classmethod
207 | def from_dict(cls, data: Dict[str, Any]) -> 'ResponseMessage':
208 | """Create a ResponseMessage from a dictionary."""
209 | msg_data = data.get("data", {})
210 |
211 | if "success" not in msg_data:
212 | raise ValueError("Response message missing required field: success")
213 |
214 | return cls(
215 | success=msg_data["success"],
216 | message=msg_data.get("message"),
217 | data=msg_data.get("data")
218 | )
219 |
220 |
221 | class ErrorMessage(MCPMessage):
222 | """Message for error responses."""
223 |
224 | def __init__(self, error_code: int, error_message: str, details: Any = None):
225 | """
226 | Initialize a new error message.
227 |
228 | Args:
229 | error_code: Numeric error code
230 | error_message: Human-readable error message
231 | details: Optional additional error details
232 | """
233 | error_data = {
234 | "code": error_code,
235 | "message": error_message
236 | }
237 |
238 | if details:
239 | error_data["details"] = details
240 |
241 | super().__init__(MessageType.ERROR, error_data)
242 |
243 | @classmethod
244 | def from_dict(cls, data: Dict[str, Any]) -> 'ErrorMessage':
245 | """Create an ErrorMessage from a dictionary."""
246 | msg_data = data.get("data", {})
247 |
248 | if "code" not in msg_data or "message" not in msg_data:
249 | raise ValueError("Error message missing required fields: code and message")
250 |
251 | return cls(
252 | error_code=msg_data["code"],
253 | error_message=msg_data["message"],
254 | details=msg_data.get("details")
255 | )
256 |
257 |
258 | class PingMessage(MCPMessage):
259 | """Message for ping requests."""
260 |
261 | def __init__(self):
262 | """Initialize a new ping message."""
263 | super().__init__(MessageType.PING)
264 |
265 |
266 | class PongMessage(MCPMessage):
267 | """Message for pong responses."""
268 |
269 | def __init__(self):
270 | """Initialize a new pong message."""
271 | super().__init__(MessageType.PONG)
272 |
273 |
274 | def parse_message(data: Union[str, Dict[str, Any]]) -> MCPMessage:
275 | """
276 | Parse a message from either a JSON string or a dictionary.
277 |
278 | Args:
279 | data: JSON string or dictionary containing message data
280 |
281 | Returns:
282 | An MCPMessage instance
283 |
284 | Raises:
285 | ValueError: If the message format is invalid
286 | """
287 | if isinstance(data, str):
288 | return MCPMessage.from_json(data)
289 | elif isinstance(data, dict):
290 | return MCPMessage.from_dict(data)
291 | else:
292 | raise ValueError(f"Unsupported message format: {type(data)}")
293 |
```
--------------------------------------------------------------------------------
/src/notification/macos.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | macOS Notification implementation for toast-mcp-server.
3 |
4 | This module provides functionality to display macOS notifications
5 | using the osascript command to interact with the macOS Notification Center.
6 | """
7 |
8 | import logging
9 | import subprocess
10 | import platform
11 | from typing import Dict, Any, Optional, List, Union
12 |
13 | from src.mcp.protocol import NotificationType
14 |
15 | logger = logging.getLogger(__name__)
16 |
17 |
18 | class MacOSNotificationManager:
19 | """
20 | Manager for macOS Notifications.
21 |
22 | This class handles the creation and display of macOS notifications
23 | using the osascript command to interact with the Notification Center.
24 | """
25 |
26 | def __init__(self):
27 | """Initialize the macOS notification manager."""
28 | if not self._is_macos():
29 | logger.warning("MacOSNotificationManager initialized on non-macOS platform")
30 | logger.info("macOS notification manager initialized")
31 |
32 | def _is_macos(self) -> bool:
33 | """
34 | Check if the current platform is macOS.
35 |
36 | Returns:
37 | True if the current platform is macOS, False otherwise
38 | """
39 | return platform.system() == "Darwin"
40 |
41 | def show_notification(self,
42 | title: str,
43 | message: str,
44 | notification_type: NotificationType = NotificationType.INFO,
45 | duration: int = 5,
46 | sound: bool = True,
47 | subtitle: Optional[str] = None) -> bool:
48 | """
49 | Show a macOS notification.
50 |
51 | Args:
52 | title: Title of the notification
53 | message: Content of the notification
54 | notification_type: Type of notification (info, warning, error, success)
55 | duration: Duration parameter is ignored on macOS (included for API compatibility)
56 | sound: Whether to play a sound with the notification
57 | subtitle: Optional subtitle for the notification
58 |
59 | Returns:
60 | True if the notification was successfully displayed, False otherwise
61 | """
62 | if not self._is_macos():
63 | logger.error("Cannot show macOS notification on non-macOS platform")
64 | return False
65 |
66 | logger.debug(f"Showing notification: {title} ({notification_type.value})")
67 |
68 | try:
69 | script = self._build_notification_script(title, message, subtitle, sound)
70 |
71 | result = subprocess.run(
72 | ["osascript", "-e", script],
73 | capture_output=True,
74 | text=True,
75 | check=True
76 | )
77 |
78 | if result.returncode == 0:
79 | return True
80 | else:
81 | logger.error(f"Failed to show notification: {result.stderr}")
82 | return False
83 |
84 | except subprocess.SubprocessError as e:
85 | logger.error(f"Failed to show notification: {str(e)}")
86 | return False
87 | except Exception as e:
88 | logger.error(f"Unexpected error showing notification: {str(e)}")
89 | return False
90 |
91 | def _build_notification_script(self,
92 | title: str,
93 | message: str,
94 | subtitle: Optional[str] = None,
95 | sound: bool = True) -> str:
96 | """
97 | Build the AppleScript command for displaying a notification.
98 |
99 | Args:
100 | title: Title of the notification
101 | message: Content of the notification
102 | subtitle: Optional subtitle for the notification
103 | sound: Whether to play a sound with the notification
104 |
105 | Returns:
106 | AppleScript command string
107 | """
108 | title_escaped = title.replace('"', '\\"')
109 | message_escaped = message.replace('"', '\\"')
110 |
111 | script = f'display notification "{message_escaped}" with title "{title_escaped}"'
112 |
113 | if subtitle:
114 | subtitle_escaped = subtitle.replace('"', '\\"')
115 | script += f' subtitle "{subtitle_escaped}"'
116 |
117 | if sound:
118 | script += " sound name \"Ping\""
119 |
120 | return script
121 |
122 |
123 | class MacOSNotificationFactory:
124 | """
125 | Factory for creating macOS notifications based on notification type.
126 |
127 | This class provides methods for creating and displaying different types
128 | of notifications with appropriate default settings.
129 | """
130 |
131 | def __init__(self):
132 | """Initialize the notification factory."""
133 | self.notification_manager = MacOSNotificationManager()
134 |
135 | def create_info_notification(self, title: str, message: str, subtitle: Optional[str] = None) -> bool:
136 | """
137 | Create and show an information notification.
138 |
139 | Args:
140 | title: Title of the notification
141 | message: Content of the notification
142 | subtitle: Optional subtitle for the notification
143 |
144 | Returns:
145 | True if the notification was successfully displayed, False otherwise
146 | """
147 | return self.notification_manager.show_notification(
148 | title=title,
149 | message=message,
150 | notification_type=NotificationType.INFO,
151 | subtitle=subtitle
152 | )
153 |
154 | def create_warning_notification(self, title: str, message: str, subtitle: Optional[str] = None) -> bool:
155 | """
156 | Create and show a warning notification.
157 |
158 | Args:
159 | title: Title of the notification
160 | message: Content of the notification
161 | subtitle: Optional subtitle for the notification
162 |
163 | Returns:
164 | True if the notification was successfully displayed, False otherwise
165 | """
166 | return self.notification_manager.show_notification(
167 | title=title,
168 | message=message,
169 | notification_type=NotificationType.WARNING,
170 | subtitle=subtitle
171 | )
172 |
173 | def create_error_notification(self, title: str, message: str, subtitle: Optional[str] = None) -> bool:
174 | """
175 | Create and show an error notification.
176 |
177 | Args:
178 | title: Title of the notification
179 | message: Content of the notification
180 | subtitle: Optional subtitle for the notification
181 |
182 | Returns:
183 | True if the notification was successfully displayed, False otherwise
184 | """
185 | return self.notification_manager.show_notification(
186 | title=title,
187 | message=message,
188 | notification_type=NotificationType.ERROR,
189 | subtitle=subtitle
190 | )
191 |
192 | def create_success_notification(self, title: str, message: str, subtitle: Optional[str] = None) -> bool:
193 | """
194 | Create and show a success notification.
195 |
196 | Args:
197 | title: Title of the notification
198 | message: Content of the notification
199 | subtitle: Optional subtitle for the notification
200 |
201 | Returns:
202 | True if the notification was successfully displayed, False otherwise
203 | """
204 | return self.notification_manager.show_notification(
205 | title=title,
206 | message=message,
207 | notification_type=NotificationType.SUCCESS,
208 | subtitle=subtitle
209 | )
210 |
211 |
212 | macos_notification_factory = MacOSNotificationFactory()
213 |
214 |
215 | def show_macos_notification(title: str, message: str, notification_type: str = "info", subtitle: Optional[str] = None) -> bool:
216 | """
217 | Show a macOS notification with the specified parameters.
218 |
219 | This is a convenience function for showing notifications without directly
220 | interacting with the MacOSNotificationFactory or MacOSNotificationManager classes.
221 |
222 | Args:
223 | title: Title of the notification
224 | message: Content of the notification
225 | notification_type: Type of notification ("info", "warning", "error", "success")
226 | subtitle: Optional subtitle for the notification
227 |
228 | Returns:
229 | True if the notification was successfully displayed, False otherwise
230 | """
231 | try:
232 | notification_type_enum = NotificationType(notification_type)
233 | except ValueError:
234 | logger.warning(f"Invalid notification type: {notification_type}, using INFO")
235 | notification_type_enum = NotificationType.INFO
236 |
237 | if notification_type_enum == NotificationType.INFO:
238 | return macos_notification_factory.create_info_notification(title, message, subtitle)
239 | elif notification_type_enum == NotificationType.WARNING:
240 | return macos_notification_factory.create_warning_notification(title, message, subtitle)
241 | elif notification_type_enum == NotificationType.ERROR:
242 | return macos_notification_factory.create_error_notification(title, message, subtitle)
243 | elif notification_type_enum == NotificationType.SUCCESS:
244 | return macos_notification_factory.create_success_notification(title, message, subtitle)
245 |
246 | return macos_notification_factory.create_info_notification(title, message, subtitle)
247 |
```
--------------------------------------------------------------------------------
/tests/test_manager.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Tests for the unified notification manager.
3 | """
4 |
5 | import unittest
6 | from unittest.mock import patch, MagicMock
7 | import platform
8 | from src.notification.manager import (
9 | NotificationManager, NotificationFactory,
10 | show_notification, NotificationType
11 | )
12 | from src.notification.platform import is_windows, is_macos
13 |
14 |
15 | class TestNotificationManager(unittest.TestCase):
16 | """Test cases for the NotificationManager class."""
17 |
18 | @patch('src.notification.manager.get_platform_name')
19 | @patch('src.notification.manager.is_windows')
20 | @patch('src.notification.manager.is_macos')
21 | def test_init_windows(self, mock_is_macos, mock_is_windows, mock_get_platform):
22 | """Test initializing the notification manager on Windows."""
23 | mock_get_platform.return_value = "windows"
24 | mock_is_windows.return_value = True
25 | mock_is_macos.return_value = False
26 |
27 | with patch('src.notification.manager.ToastNotificationManager') as mock_toast:
28 | manager = NotificationManager()
29 |
30 | mock_toast.assert_called_once()
31 |
32 | @patch('src.notification.manager.get_platform_name')
33 | @patch('src.notification.manager.is_windows')
34 | @patch('src.notification.manager.is_macos')
35 | def test_init_macos(self, mock_is_macos, mock_is_windows, mock_get_platform):
36 | """Test initializing the notification manager on macOS."""
37 | mock_get_platform.return_value = "macos"
38 | mock_is_windows.return_value = False
39 | mock_is_macos.return_value = True
40 |
41 | with patch('src.notification.manager.MacOSNotificationManager') as mock_macos:
42 | manager = NotificationManager()
43 |
44 | mock_macos.assert_called_once()
45 |
46 | @patch('src.notification.manager.get_platform_name')
47 | @patch('src.notification.manager.is_windows')
48 | @patch('src.notification.manager.is_macos')
49 | def test_init_unsupported(self, mock_is_macos, mock_is_windows, mock_get_platform):
50 | """Test initializing the notification manager on an unsupported platform."""
51 | mock_get_platform.return_value = "linux"
52 | mock_is_windows.return_value = False
53 | mock_is_macos.return_value = False
54 |
55 | manager = NotificationManager()
56 |
57 | self.assertIsNone(manager._notification_system)
58 |
59 | @patch('src.notification.manager.get_platform_name')
60 | @patch('src.notification.manager.is_windows')
61 | @patch('src.notification.manager.is_macos')
62 | def test_show_notification_windows(self, mock_is_macos, mock_is_windows, mock_get_platform):
63 | """Test showing a notification on Windows."""
64 | mock_get_platform.return_value = "windows"
65 | mock_is_windows.return_value = True
66 | mock_is_macos.return_value = False
67 |
68 | mock_system = MagicMock()
69 | mock_system.show_notification.return_value = True
70 |
71 | with patch('src.notification.manager.ToastNotificationManager', return_value=mock_system):
72 | manager = NotificationManager()
73 |
74 | result = manager.show_notification(
75 | title="Test Title",
76 | message="Test Message",
77 | notification_type=NotificationType.INFO,
78 | duration=5,
79 | icon_path="test.ico"
80 | )
81 |
82 | self.assertTrue(result)
83 | mock_system.show_notification.assert_called_once_with(
84 | title="Test Title",
85 | message="Test Message",
86 | notification_type=NotificationType.INFO,
87 | duration=5,
88 | icon_path="test.ico"
89 | )
90 |
91 | @patch('src.notification.manager.get_platform_name')
92 | @patch('src.notification.manager.is_windows')
93 | @patch('src.notification.manager.is_macos')
94 | def test_show_notification_macos(self, mock_is_macos, mock_is_windows, mock_get_platform):
95 | """Test showing a notification on macOS."""
96 | mock_get_platform.return_value = "macos"
97 | mock_is_windows.return_value = False
98 | mock_is_macos.return_value = True
99 |
100 | mock_system = MagicMock()
101 | mock_system.show_notification.return_value = True
102 |
103 | with patch('src.notification.manager.MacOSNotificationManager', return_value=mock_system):
104 | manager = NotificationManager()
105 |
106 | result = manager.show_notification(
107 | title="Test Title",
108 | message="Test Message",
109 | notification_type=NotificationType.INFO,
110 | duration=5,
111 | subtitle="Test Subtitle",
112 | sound=True
113 | )
114 |
115 | self.assertTrue(result)
116 | mock_system.show_notification.assert_called_once_with(
117 | title="Test Title",
118 | message="Test Message",
119 | notification_type=NotificationType.INFO,
120 | duration=5,
121 | subtitle="Test Subtitle",
122 | sound=True
123 | )
124 |
125 | @patch('src.notification.manager.get_platform_name')
126 | @patch('src.notification.manager.is_windows')
127 | @patch('src.notification.manager.is_macos')
128 | def test_show_notification_unsupported(self, mock_is_macos, mock_is_windows, mock_get_platform):
129 | """Test showing a notification on an unsupported platform."""
130 | mock_get_platform.return_value = "linux"
131 | mock_is_windows.return_value = False
132 | mock_is_macos.return_value = False
133 |
134 | manager = NotificationManager()
135 |
136 | result = manager.show_notification(
137 | title="Test Title",
138 | message="Test Message",
139 | notification_type=NotificationType.INFO
140 | )
141 |
142 | self.assertFalse(result)
143 |
144 | @patch('src.notification.manager.get_platform_name')
145 | @patch('src.notification.manager.is_windows')
146 | @patch('src.notification.manager.is_macos')
147 | def test_show_notification_exception(self, mock_is_macos, mock_is_windows, mock_get_platform):
148 | """Test showing a notification that raises an exception."""
149 | mock_get_platform.return_value = "windows"
150 | mock_is_windows.return_value = True
151 | mock_is_macos.return_value = False
152 |
153 | mock_system = MagicMock()
154 | mock_system.show_notification.side_effect = Exception("Test exception")
155 |
156 | with patch('src.notification.manager.ToastNotificationManager', return_value=mock_system):
157 | manager = NotificationManager()
158 |
159 | result = manager.show_notification(
160 | title="Test Title",
161 | message="Test Message",
162 | notification_type=NotificationType.INFO
163 | )
164 |
165 | self.assertFalse(result)
166 |
167 |
168 | class TestNotificationFactory(unittest.TestCase):
169 | """Test cases for the NotificationFactory class."""
170 |
171 | @patch('src.notification.manager.NotificationManager')
172 | def test_notification_factory(self, mock_manager_class):
173 | """Test the notification factory."""
174 | mock_manager = MagicMock()
175 | mock_manager_class.return_value = mock_manager
176 | mock_manager.show_notification.return_value = True
177 |
178 | factory = NotificationFactory()
179 |
180 | result = factory.create_info_notification("Info Title", "Info Message", param="value")
181 | self.assertTrue(result)
182 | mock_manager.show_notification.assert_called_with(
183 | title="Info Title",
184 | message="Info Message",
185 | notification_type=NotificationType.INFO,
186 | param="value"
187 | )
188 |
189 | result = factory.create_warning_notification("Warning Title", "Warning Message")
190 | self.assertTrue(result)
191 | mock_manager.show_notification.assert_called_with(
192 | title="Warning Title",
193 | message="Warning Message",
194 | notification_type=NotificationType.WARNING
195 | )
196 |
197 | result = factory.create_error_notification("Error Title", "Error Message")
198 | self.assertTrue(result)
199 | mock_manager.show_notification.assert_called_with(
200 | title="Error Title",
201 | message="Error Message",
202 | notification_type=NotificationType.ERROR
203 | )
204 |
205 | result = factory.create_success_notification("Success Title", "Success Message")
206 | self.assertTrue(result)
207 | mock_manager.show_notification.assert_called_with(
208 | title="Success Title",
209 | message="Success Message",
210 | notification_type=NotificationType.SUCCESS
211 | )
212 |
213 |
214 | @patch('src.notification.manager.notification_factory')
215 | class TestShowNotification(unittest.TestCase):
216 | """Test cases for the show_notification helper function."""
217 |
218 | def test_show_notification(self, mock_factory):
219 | """Test the show_notification helper function."""
220 | mock_factory.create_info_notification.return_value = True
221 | mock_factory.create_warning_notification.return_value = True
222 | mock_factory.create_error_notification.return_value = True
223 | mock_factory.create_success_notification.return_value = True
224 |
225 | result = show_notification("Info Title", "Info Message", "info", param="value")
226 | self.assertTrue(result)
227 | mock_factory.create_info_notification.assert_called_with("Info Title", "Info Message", param="value")
228 |
229 | result = show_notification("Warning Title", "Warning Message", "warning")
230 | self.assertTrue(result)
231 | mock_factory.create_warning_notification.assert_called_with("Warning Title", "Warning Message")
232 |
233 | result = show_notification("Error Title", "Error Message", "error")
234 | self.assertTrue(result)
235 | mock_factory.create_error_notification.assert_called_with("Error Title", "Error Message")
236 |
237 | result = show_notification("Success Title", "Success Message", "success")
238 | self.assertTrue(result)
239 | mock_factory.create_success_notification.assert_called_with("Success Title", "Success Message")
240 |
241 | result = show_notification("Invalid Title", "Invalid Message", "invalid")
242 | self.assertTrue(result)
243 | mock_factory.create_info_notification.assert_called_with("Invalid Title", "Invalid Message")
244 |
245 |
246 | if __name__ == "__main__":
247 | unittest.main()
248 |
```
--------------------------------------------------------------------------------
/src/server/connection.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Client connection management for toast-mcp-server.
3 |
4 | This module handles client connections, including connection establishment,
5 | message handling, and connection termination.
6 | """
7 |
8 | import asyncio
9 | import logging
10 | import json
11 | from typing import Dict, Set, Any, Optional, Callable, Awaitable, List
12 |
13 | from src.mcp.protocol import (
14 | MCPMessage, NotificationMessage, ResponseMessage, ErrorMessage,
15 | PingMessage, PongMessage, MessageType, parse_message
16 | )
17 | from src.mcp.validation import validate_message_format
18 | from src.notification.toast import show_notification
19 |
20 | logger = logging.getLogger(__name__)
21 |
22 |
23 | class ClientConnection:
24 | """
25 | Represents a connection to a client.
26 |
27 | This class handles the communication with a single client, including
28 | receiving and sending messages.
29 | """
30 |
31 | def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter,
32 | client_id: str, server: 'ConnectionManager'):
33 | """
34 | Initialize a new client connection.
35 |
36 | Args:
37 | reader: Stream reader for receiving data from the client
38 | writer: Stream writer for sending data to the client
39 | client_id: Unique identifier for the client
40 | server: Reference to the connection manager
41 | """
42 | self.reader = reader
43 | self.writer = writer
44 | self.client_id = client_id
45 | self.server = server
46 | self.connected = True
47 | self.addr = writer.get_extra_info('peername')
48 | logger.info(f"Client connected: {self.client_id} from {self.addr}")
49 |
50 | async def handle(self) -> None:
51 | """
52 | Handle the client connection.
53 |
54 | This method continuously reads messages from the client and processes them
55 | until the connection is closed.
56 | """
57 | try:
58 | while self.connected:
59 | data = await self.reader.readline()
60 | if not data:
61 | break
62 |
63 | await self._process_message(data.decode().strip())
64 |
65 | except asyncio.CancelledError:
66 | logger.info(f"Connection handling cancelled for client: {self.client_id}")
67 | except Exception as e:
68 | logger.error(f"Error handling client {self.client_id}: {str(e)}")
69 | finally:
70 | await self.close()
71 |
72 | async def _process_message(self, data: str) -> None:
73 | """
74 | Process a message received from the client.
75 |
76 | Args:
77 | data: JSON string containing the message data
78 | """
79 | logger.debug(f"Received message from {self.client_id}: {data}")
80 |
81 | try:
82 | message_data = json.loads(data)
83 | is_valid, error, msg_type = validate_message_format(message_data)
84 |
85 | if not is_valid:
86 | await self.send_error(400, f"Invalid message format: {error}")
87 | return
88 |
89 | if msg_type == MessageType.NOTIFICATION:
90 | await self._handle_notification(message_data)
91 | elif msg_type == MessageType.PING:
92 | await self._handle_ping()
93 | else:
94 | await self.send_error(400, f"Unsupported message type: {msg_type.value}")
95 |
96 | except json.JSONDecodeError:
97 | await self.send_error(400, "Invalid JSON format")
98 | except Exception as e:
99 | logger.error(f"Error processing message from {self.client_id}: {str(e)}")
100 | await self.send_error(500, "Internal server error")
101 |
102 | async def _handle_notification(self, message_data: Dict[str, Any]) -> None:
103 | """
104 | Handle a notification message.
105 |
106 | Args:
107 | message_data: Dictionary containing the notification message data
108 | """
109 | try:
110 | notification = NotificationMessage.from_dict(message_data)
111 |
112 | title = notification.data["title"]
113 | message = notification.data["message"]
114 | notification_type = notification.data.get("notification_type", "info")
115 | duration = notification.data.get("duration", 5)
116 |
117 | success = show_notification(title, message, notification_type, duration)
118 |
119 | if success:
120 | await self.send_response(True, "Notification displayed successfully")
121 | else:
122 | await self.send_response(False, "Failed to display notification")
123 |
124 | except Exception as e:
125 | logger.error(f"Error handling notification from {self.client_id}: {str(e)}")
126 | await self.send_error(500, f"Error handling notification: {str(e)}")
127 |
128 | async def _handle_ping(self) -> None:
129 | """Handle a ping message by sending a pong response."""
130 | try:
131 | pong = PongMessage()
132 | await self.send_message(pong)
133 |
134 | except Exception as e:
135 | logger.error(f"Error handling ping from {self.client_id}: {str(e)}")
136 | await self.send_error(500, f"Error handling ping: {str(e)}")
137 |
138 | async def send_message(self, message: MCPMessage) -> None:
139 | """
140 | Send a message to the client.
141 |
142 | Args:
143 | message: The message to send
144 | """
145 | try:
146 | json_str = message.to_json() + "\n"
147 | self.writer.write(json_str.encode())
148 | await self.writer.drain()
149 |
150 | logger.debug(f"Sent message to {self.client_id}: {message.msg_type.value}")
151 |
152 | except Exception as e:
153 | logger.error(f"Error sending message to {self.client_id}: {str(e)}")
154 | await self.close()
155 |
156 | async def send_response(self, success: bool, message: str = None, data: Dict[str, Any] = None) -> None:
157 | """
158 | Send a response message to the client.
159 |
160 | Args:
161 | success: Whether the operation was successful
162 | message: Optional message describing the result
163 | data: Optional additional data to include in the response
164 | """
165 | response = ResponseMessage(success, message, data)
166 | await self.send_message(response)
167 |
168 | async def send_error(self, error_code: int, error_message: str, details: Any = None) -> None:
169 | """
170 | Send an error message to the client.
171 |
172 | Args:
173 | error_code: Numeric error code
174 | error_message: Human-readable error message
175 | details: Optional additional error details
176 | """
177 | error = ErrorMessage(error_code, error_message, details)
178 | await self.send_message(error)
179 |
180 | async def close(self) -> None:
181 | """Close the client connection."""
182 | if not self.connected:
183 | return
184 |
185 | self.connected = False
186 |
187 | try:
188 | self.writer.close()
189 | await self.writer.wait_closed()
190 |
191 | self.server.remove_client(self.client_id)
192 |
193 | logger.info(f"Client disconnected: {self.client_id}")
194 |
195 | except Exception as e:
196 | logger.error(f"Error closing connection for {self.client_id}: {str(e)}")
197 |
198 |
199 | class ConnectionManager:
200 | """
201 | Manages client connections to the server.
202 |
203 | This class handles the creation and management of client connections,
204 | including accepting new connections and broadcasting messages to clients.
205 | """
206 |
207 | def __init__(self, host: str = "127.0.0.1", port: int = 8765):
208 | """
209 | Initialize the connection manager.
210 |
211 | Args:
212 | host: Host address to bind to
213 | port: Port to listen on
214 | """
215 | self.host = host
216 | self.port = port
217 | self.clients: Dict[str, ClientConnection] = {}
218 | self.server = None
219 | self.next_client_id = 1
220 | logger.info(f"Connection manager initialized with host={host}, port={port}")
221 |
222 | async def start(self) -> None:
223 | """Start the server and begin accepting connections."""
224 | try:
225 | self.server = await asyncio.start_server(
226 | self._handle_new_connection, self.host, self.port
227 | )
228 |
229 | addr = self.server.sockets[0].getsockname()
230 | logger.info(f"Server started on {addr}")
231 |
232 | async with self.server:
233 | await self.server.serve_forever()
234 |
235 | except Exception as e:
236 | logger.error(f"Error starting server: {str(e)}")
237 | raise
238 |
239 | async def _handle_new_connection(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
240 | """
241 | Handle a new client connection.
242 |
243 | Args:
244 | reader: Stream reader for receiving data from the client
245 | writer: Stream writer for sending data to the client
246 | """
247 | client_id = f"client_{self.next_client_id}"
248 | self.next_client_id += 1
249 |
250 | client = ClientConnection(reader, writer, client_id, self)
251 | self.clients[client_id] = client
252 |
253 | asyncio.create_task(client.handle())
254 |
255 | def remove_client(self, client_id: str) -> None:
256 | """
257 | Remove a client from the connection manager.
258 |
259 | Args:
260 | client_id: ID of the client to remove
261 | """
262 | if client_id in self.clients:
263 | del self.clients[client_id]
264 | logger.debug(f"Removed client: {client_id}")
265 |
266 | async def broadcast(self, message: MCPMessage, exclude: Optional[str] = None) -> None:
267 | """
268 | Broadcast a message to all connected clients.
269 |
270 | Args:
271 | message: The message to broadcast
272 | exclude: Optional client ID to exclude from the broadcast
273 | """
274 | for client_id, client in list(self.clients.items()):
275 | if exclude and client_id == exclude:
276 | continue
277 |
278 | try:
279 | await client.send_message(message)
280 | except Exception as e:
281 | logger.error(f"Error broadcasting to {client_id}: {str(e)}")
282 |
283 | async def stop(self) -> None:
284 | """Stop the server and close all client connections."""
285 | logger.info("Stopping server...")
286 |
287 | for client_id, client in list(self.clients.items()):
288 | try:
289 | await client.close()
290 | except Exception as e:
291 | logger.error(f"Error closing client {client_id}: {str(e)}")
292 |
293 | if self.server:
294 | self.server.close()
295 | await self.server.wait_closed()
296 | logger.info("Server stopped")
297 |
298 |
299 | async def run_server(host: str = "127.0.0.1", port: int = 8765) -> None:
300 | """
301 | Run the MCP server.
302 |
303 | Args:
304 | host: Host address to bind to
305 | port: Port to listen on
306 | """
307 | logging.basicConfig(
308 | level=logging.INFO,
309 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
310 | )
311 |
312 | manager = ConnectionManager(host, port)
313 | await manager.start()
314 |
```
--------------------------------------------------------------------------------
/tests/test_commands.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Tests for the command processing system.
3 | """
4 |
5 | import unittest
6 | import asyncio
7 | from unittest.mock import patch, MagicMock, AsyncMock
8 | from src.server.commands import (
9 | Command, CommandProcessor, process_command_message,
10 | handle_show_notification, handle_list_commands,
11 | validate_show_notification, DEFAULT_COMMANDS
12 | )
13 | from src.mcp.protocol import (
14 | ResponseMessage, ErrorMessage
15 | )
16 |
17 |
18 | class TestCommand(unittest.TestCase):
19 | """Test cases for the Command class."""
20 |
21 | async def test_execute_command(self):
22 | """Test executing a command."""
23 | mock_handler = AsyncMock()
24 | mock_handler.return_value = (True, "Success", {"data": "value"})
25 |
26 | command = Command(
27 | name="test_command",
28 | handler=mock_handler,
29 | description="Test command"
30 | )
31 |
32 | success, message, data = await command.execute({}, "test_client")
33 |
34 | mock_handler.assert_called_once_with({}, "test_client")
35 |
36 | self.assertTrue(success)
37 | self.assertEqual(message, "Success")
38 | self.assertEqual(data, {"data": "value"})
39 |
40 | async def test_execute_command_with_validator(self):
41 | """Test executing a command with a validator."""
42 | mock_handler = AsyncMock()
43 | mock_handler.return_value = (True, "Success", {"data": "value"})
44 |
45 | mock_validator = MagicMock()
46 | mock_validator.return_value = (True, None)
47 |
48 | command = Command(
49 | name="test_command",
50 | handler=mock_handler,
51 | validator=mock_validator,
52 | description="Test command"
53 | )
54 |
55 | success, message, data = await command.execute({"param": "value"}, "test_client")
56 |
57 | mock_validator.assert_called_once_with({"param": "value"})
58 |
59 | mock_handler.assert_called_once_with({"param": "value"}, "test_client")
60 |
61 | self.assertTrue(success)
62 | self.assertEqual(message, "Success")
63 | self.assertEqual(data, {"data": "value"})
64 |
65 | async def test_execute_command_with_invalid_params(self):
66 | """Test executing a command with invalid parameters."""
67 | mock_handler = AsyncMock()
68 |
69 | mock_validator = MagicMock()
70 | mock_validator.return_value = (False, "Invalid parameter")
71 |
72 | command = Command(
73 | name="test_command",
74 | handler=mock_handler,
75 | validator=mock_validator,
76 | description="Test command"
77 | )
78 |
79 | success, message, data = await command.execute({}, "test_client")
80 |
81 | mock_validator.assert_called_once_with({})
82 |
83 | mock_handler.assert_not_called()
84 |
85 | self.assertFalse(success)
86 | self.assertEqual(message, "Invalid parameters: Invalid parameter")
87 | self.assertIsNone(data)
88 |
89 | async def test_execute_command_with_exception(self):
90 | """Test executing a command that raises an exception."""
91 | mock_handler = AsyncMock()
92 | mock_handler.side_effect = Exception("Test exception")
93 |
94 | command = Command(
95 | name="test_command",
96 | handler=mock_handler,
97 | description="Test command"
98 | )
99 |
100 | success, message, data = await command.execute({}, "test_client")
101 |
102 | mock_handler.assert_called_once_with({}, "test_client")
103 |
104 | self.assertFalse(success)
105 | self.assertEqual(message, "Error executing command: Test exception")
106 | self.assertIsNone(data)
107 |
108 |
109 | class TestCommandProcessor(unittest.TestCase):
110 | """Test cases for the CommandProcessor class."""
111 |
112 | def setUp(self):
113 | """Set up test fixtures."""
114 | self.processor = CommandProcessor()
115 |
116 | def test_register_command(self):
117 | """Test registering a command."""
118 | command = Command(
119 | name="test_command",
120 | handler=AsyncMock(),
121 | description="Test command"
122 | )
123 |
124 | self.processor.register_command(command)
125 |
126 | self.assertIn("test_command", self.processor.commands)
127 | self.assertEqual(self.processor.commands["test_command"], command)
128 |
129 | def test_register_commands(self):
130 | """Test registering multiple commands."""
131 | command1 = Command(
132 | name="command1",
133 | handler=AsyncMock(),
134 | description="Command 1"
135 | )
136 |
137 | command2 = Command(
138 | name="command2",
139 | handler=AsyncMock(),
140 | description="Command 2"
141 | )
142 |
143 | self.processor.register_commands([command1, command2])
144 |
145 | self.assertIn("command1", self.processor.commands)
146 | self.assertIn("command2", self.processor.commands)
147 | self.assertEqual(self.processor.commands["command1"], command1)
148 | self.assertEqual(self.processor.commands["command2"], command2)
149 |
150 | async def test_process_command(self):
151 | """Test processing a command."""
152 | mock_handler = AsyncMock()
153 | mock_handler.return_value = (True, "Success", {"data": "value"})
154 |
155 | command = Command(
156 | name="test_command",
157 | handler=mock_handler,
158 | description="Test command"
159 | )
160 |
161 | self.processor.register_command(command)
162 |
163 | success, message, data = await self.processor.process_command(
164 | "test_command", {"param": "value"}, "test_client"
165 | )
166 |
167 | mock_handler.assert_called_once_with({"param": "value"}, "test_client")
168 |
169 | self.assertTrue(success)
170 | self.assertEqual(message, "Success")
171 | self.assertEqual(data, {"data": "value"})
172 |
173 | async def test_process_unknown_command(self):
174 | """Test processing an unknown command."""
175 | success, message, data = await self.processor.process_command(
176 | "unknown_command", {}, "test_client"
177 | )
178 |
179 | self.assertFalse(success)
180 | self.assertEqual(message, "Unknown command: unknown_command")
181 | self.assertIsNone(data)
182 |
183 | async def test_process_command_requiring_auth(self):
184 | """Test processing a command that requires authentication."""
185 | mock_handler = AsyncMock()
186 |
187 | command = Command(
188 | name="auth_command",
189 | handler=mock_handler,
190 | description="Auth command",
191 | requires_auth=True
192 | )
193 |
194 | self.processor.register_command(command)
195 |
196 | success, message, data = await self.processor.process_command(
197 | "auth_command", {}, "test_client"
198 | )
199 |
200 | mock_handler.assert_not_called()
201 |
202 | self.assertFalse(success)
203 | self.assertEqual(message, "Authentication required")
204 | self.assertIsNone(data)
205 |
206 | self.processor.authenticate_client("test_client")
207 |
208 | success, message, data = await self.processor.process_command(
209 | "auth_command", {}, "test_client"
210 | )
211 |
212 | mock_handler.assert_called_once_with({}, "test_client")
213 |
214 | def test_authenticate_deauthenticate_client(self):
215 | """Test authenticating and deauthenticating a client."""
216 | self.processor.authenticate_client("test_client")
217 |
218 | self.assertIn("test_client", self.processor.authenticated_clients)
219 |
220 | self.processor.authenticate_client("test_client")
221 |
222 | self.assertEqual(self.processor.authenticated_clients.count("test_client"), 1)
223 |
224 | self.processor.deauthenticate_client("test_client")
225 |
226 | self.assertNotIn("test_client", self.processor.authenticated_clients)
227 |
228 | self.processor.deauthenticate_client("non_existent_client")
229 |
230 |
231 | class TestCommandHandlers(unittest.TestCase):
232 | """Test cases for the command handlers."""
233 |
234 | @patch('src.server.commands.show_notification')
235 | async def test_handle_show_notification(self, mock_show_notification):
236 | """Test the show_notification command handler."""
237 | mock_show_notification.return_value = True
238 |
239 | success, message, data = await handle_show_notification(
240 | {
241 | "title": "Test Title",
242 | "message": "Test Message",
243 | "type": "info",
244 | "duration": 5
245 | },
246 | "test_client"
247 | )
248 |
249 | mock_show_notification.assert_called_once_with(
250 | "Test Title", "Test Message", "info", 5
251 | )
252 |
253 | self.assertTrue(success)
254 | self.assertEqual(message, "Notification displayed successfully")
255 | self.assertIsNone(data)
256 |
257 | mock_show_notification.reset_mock()
258 | mock_show_notification.return_value = False
259 |
260 | success, message, data = await handle_show_notification(
261 | {
262 | "title": "Test Title",
263 | "message": "Test Message"
264 | },
265 | "test_client"
266 | )
267 |
268 | self.assertFalse(success)
269 | self.assertEqual(message, "Failed to display notification")
270 | self.assertIsNone(data)
271 |
272 | async def test_handle_list_commands(self):
273 | """Test the list_commands command handler."""
274 | success, message, data = await handle_list_commands({}, "test_client")
275 |
276 | self.assertTrue(success)
277 | self.assertEqual(message, "Commands retrieved successfully")
278 | self.assertIsInstance(data, dict)
279 | self.assertIn("commands", data)
280 | self.assertIsInstance(data["commands"], list)
281 | self.assertTrue(len(data["commands"]) > 0)
282 |
283 | def test_validate_show_notification(self):
284 | """Test the show_notification validator."""
285 | is_valid, error = validate_show_notification({
286 | "title": "Test Title",
287 | "message": "Test Message",
288 | "type": "info",
289 | "duration": 5
290 | })
291 |
292 | self.assertTrue(is_valid)
293 | self.assertIsNone(error)
294 |
295 | is_valid, error = validate_show_notification({
296 | "message": "Test Message"
297 | })
298 |
299 | self.assertFalse(is_valid)
300 | self.assertEqual(error, "Missing required parameter: title")
301 |
302 | is_valid, error = validate_show_notification({
303 | "title": "Test Title"
304 | })
305 |
306 | self.assertFalse(is_valid)
307 | self.assertEqual(error, "Missing required parameter: message")
308 |
309 | is_valid, error = validate_show_notification({
310 | "title": "Test Title",
311 | "message": "Test Message",
312 | "type": "invalid"
313 | })
314 |
315 | self.assertFalse(is_valid)
316 | self.assertEqual(error, "Invalid notification type")
317 |
318 | is_valid, error = validate_show_notification({
319 | "title": "Test Title",
320 | "message": "Test Message",
321 | "duration": "5" # Should be an integer
322 | })
323 |
324 | self.assertFalse(is_valid)
325 | self.assertEqual(error, "Duration must be an integer")
326 |
327 |
328 | @patch('src.server.commands.command_processor')
329 | class TestProcessCommandMessage(unittest.TestCase):
330 | """Test cases for the process_command_message function."""
331 |
332 | async def test_process_command_message(self, mock_processor):
333 | """Test processing a command message."""
334 | mock_processor.process_command = AsyncMock()
335 | mock_processor.process_command.return_value = (True, "Success", {"data": "value"})
336 |
337 | message = await process_command_message(
338 | {
339 | "command": "test_command",
340 | "params": {"param": "value"}
341 | },
342 | "test_client"
343 | )
344 |
345 | mock_processor.process_command.assert_called_once_with(
346 | "test_command", {"param": "value"}, "test_client"
347 | )
348 |
349 | self.assertIsInstance(message, ResponseMessage)
350 | self.assertTrue(message.data["success"])
351 | self.assertEqual(message.data["message"], "Success")
352 | self.assertEqual(message.data["data"], {"data": "value"})
353 |
354 | async def test_process_command_message_failure(self, mock_processor):
355 | """Test processing a command message that fails."""
356 | mock_processor.process_command = AsyncMock()
357 | mock_processor.process_command.return_value = (False, "Failure", None)
358 |
359 | message = await process_command_message(
360 | {
361 | "command": "test_command",
362 | "params": {"param": "value"}
363 | },
364 | "test_client"
365 | )
366 |
367 | self.assertIsInstance(message, ErrorMessage)
368 | self.assertEqual(message.data["code"], 400)
369 | self.assertEqual(message.data["message"], "Failure")
370 |
371 | async def test_process_command_message_missing_command(self, mock_processor):
372 | """Test processing a command message with a missing command name."""
373 | message = await process_command_message(
374 | {
375 | "params": {"param": "value"}
376 | },
377 | "test_client"
378 | )
379 |
380 | mock_processor.process_command.assert_not_called()
381 |
382 | self.assertIsInstance(message, ErrorMessage)
383 | self.assertEqual(message.data["code"], 400)
384 | self.assertEqual(message.data["message"], "Missing command name")
385 |
386 |
387 | class TestDefaultCommands(unittest.TestCase):
388 | """Test cases for the default commands."""
389 |
390 | def test_default_commands(self):
391 | """Test that default commands are defined."""
392 | self.assertIsInstance(DEFAULT_COMMANDS, list)
393 |
394 | self.assertTrue(len(DEFAULT_COMMANDS) > 0)
395 |
396 | for command in DEFAULT_COMMANDS:
397 | self.assertIsInstance(command, Command)
398 |
399 |
400 | if __name__ == "__main__":
401 | unittest.main()
402 |
```