This is page 1 of 2. Use http://codebase.md/weirdbrains/onesignal-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .env.example
├── .github
│ └── workflows
│ └── ci.yml
├── .gitignore
├── check_loaded_key.py
├── CONTRIBUTING.md
├── debug_api_key.py
├── examples
│ ├── send_invite_email.py
│ └── send_notification.py
├── implementation_examples.md
├── LICENSE
├── missing_endpoints_analysis.md
├── onesignal_refactored
│ ├── __init__.py
│ ├── api_client.py
│ ├── config.py
│ ├── server.py
│ └── tools
│ ├── __init__.py
│ ├── analytics.py
│ ├── live_activities.py
│ ├── messages.py
│ └── templates.py
├── onesignal_refactoring_summary.md
├── onesignal_server.py
├── onesignal_tools_list.md
├── README.md
├── requirements.txt
├── setup.py
├── test_api_key_validity.py
├── test_auth_fix.py
├── test_onesignal_mcp.py
├── test_segments_debug.py
└── tests
├── __init__.py
└── test_onesignal_server.py
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Environment variables
2 | .env
3 |
4 | # Python
5 | __pycache__/
6 | *.py[cod]
7 | *$py.class
8 | *.so
9 | .Python
10 | env/
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | *.egg-info/
23 | .installed.cfg
24 | *.egg
25 |
26 | # Virtual Environment
27 | venv/
28 | ENV/
29 |
30 | # IDE files
31 | .idea/
32 | .vscode/
33 | *.swp
34 | *.swo
35 |
```
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
```
1 | # App-specific credentials
2 | # Mandible app
3 | ONESIGNAL_MANDIBLE_APP_ID=your_mandible_app_id_here
4 | ONESIGNAL_MANDIBLE_API_KEY=your_mandible_api_key_here
5 |
6 | # Weird Brains app
7 | ONESIGNAL_WEIRDBRAINS_APP_ID=your_weirdbrains_app_id_here
8 | ONESIGNAL_WEIRDBRAINS_API_KEY=your_weirdbrains_api_key_here
9 |
10 | # Default app credentials (will be used if app-specific credentials not found)
11 | ONESIGNAL_APP_ID=your_default_app_id_here
12 | ONESIGNAL_API_KEY=your_default_api_key_here
13 |
14 | # Organization API key (used for organization-level operations)
15 | ONESIGNAL_ORG_API_KEY=your_organization_api_key_here
16 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # OneSignal MCP Server
2 |
3 | A comprehensive Model Context Protocol (MCP) server for interacting with the OneSignal API. This server provides a complete interface for managing push notifications, emails, SMS, users, devices, segments, templates, analytics, and more through OneSignal's REST API.
4 |
5 | [](https://opensource.org/licenses/MIT)
6 | [](https://github.com/weirdbrains/onesignal-mcp)
7 | [](https://github.com/weirdbrains/onesignal-mcp)
8 |
9 | ## Overview
10 |
11 | This MCP server provides comprehensive access to the [OneSignal REST API](https://documentation.onesignal.com/reference/rest-api-overview), offering **57 tools** that cover all major OneSignal operations:
12 |
13 | ### 🚀 Key Features
14 |
15 | - **Multi-channel Messaging**: Send push notifications, emails, SMS, and transactional messages
16 | - **User & Device Management**: Complete CRUD operations for users, devices, and subscriptions
17 | - **Advanced Segmentation**: Create and manage user segments with complex filters
18 | - **Template System**: Create, update, and manage message templates
19 | - **iOS Live Activities**: Full support for iOS Live Activities
20 | - **Analytics & Export**: View outcomes data and export to CSV
21 | - **Multi-App Support**: Manage multiple OneSignal applications seamlessly
22 | - **API Key Management**: Create, update, rotate, and delete API keys
23 | - **Organization-level Operations**: Manage apps across your entire organization
24 |
25 | ## Requirements
26 |
27 | - Python 3.7 or higher
28 | - `python-dotenv` package
29 | - `requests` package
30 | - `mcp` package
31 | - OneSignal account with API credentials
32 |
33 | ## Installation
34 |
35 | ### Option 1: Clone from GitHub
36 |
37 | ```bash
38 | # Clone the repository
39 | git clone https://github.com/weirdbrains/onesignal-mcp.git
40 | cd onesignal-mcp
41 |
42 | # Install dependencies
43 | pip install -r requirements.txt
44 | ```
45 |
46 | ### Option 2: Install as a Package (Coming Soon)
47 |
48 | ```bash
49 | pip install onesignal-mcp
50 | ```
51 |
52 | ## Configuration
53 |
54 | 1. Create a `.env` file in the root directory with your OneSignal credentials:
55 | ```
56 | # Default app credentials (optional, you can also add apps via the API)
57 | ONESIGNAL_APP_ID=your_app_id_here
58 | ONESIGNAL_API_KEY=your_rest_api_key_here
59 |
60 | # Organization API key (for org-level operations)
61 | ONESIGNAL_ORG_API_KEY=your_organization_api_key_here
62 |
63 | # Optional: Multiple app configurations
64 | ONESIGNAL_MANDIBLE_APP_ID=mandible_app_id
65 | ONESIGNAL_MANDIBLE_API_KEY=mandible_api_key
66 |
67 | ONESIGNAL_WEIRDBRAINS_APP_ID=weirdbrains_app_id
68 | ONESIGNAL_WEIRDBRAINS_API_KEY=weirdbrains_api_key
69 |
70 | # Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
71 | LOG_LEVEL=INFO
72 | ```
73 |
74 | 2. Find your OneSignal credentials:
75 | - **App ID**: Settings > Keys & IDs > OneSignal App ID
76 | - **REST API Key**: Settings > Keys & IDs > REST API Key
77 | - **Organization API Key**: Organization Settings > API Keys
78 |
79 | ## Usage
80 |
81 | ### Running the Server
82 |
83 | ```bash
84 | python onesignal_server.py
85 | ```
86 |
87 | The server will start and register itself with the MCP system, making all 57 tools available for use.
88 |
89 | ## Complete Tools Reference (57 Tools)
90 |
91 | ### 📱 App Management (5 tools)
92 | - `list_apps` - List all configured OneSignal apps
93 | - `add_app` - Add a new OneSignal app configuration locally
94 | - `update_local_app_config` - Update an existing local app configuration
95 | - `remove_app` - Remove a local OneSignal app configuration
96 | - `switch_app` - Switch the current app to use for API requests
97 |
98 | ### 📨 Messaging (8 tools)
99 | - `send_push_notification` - Send a push notification
100 | - `send_email` - Send an email through OneSignal
101 | - `send_sms` - Send an SMS/MMS through OneSignal
102 | - `send_transactional_message` - Send immediate delivery messages
103 | - `view_messages` - View recent messages sent
104 | - `view_message_details` - Get detailed information about a message
105 | - `view_message_history` - View message history/recipients
106 | - `cancel_message` - Cancel a scheduled message
107 |
108 | ### 📱 Devices/Players (6 tools)
109 | - `view_devices` - View devices subscribed to your app
110 | - `view_device_details` - Get detailed information about a device
111 | - `add_player` - Add a new player/device
112 | - `edit_player` - Edit an existing player/device
113 | - `delete_player` - Delete a player/device record
114 | - `edit_tags_with_external_user_id` - Bulk edit tags by external ID
115 |
116 | ### 🎯 Segments (3 tools)
117 | - `view_segments` - List all segments
118 | - `create_segment` - Create a new segment
119 | - `delete_segment` - Delete a segment
120 |
121 | ### 📄 Templates (6 tools)
122 | - `view_templates` - List all templates
123 | - `view_template_details` - Get template details
124 | - `create_template` - Create a new template
125 | - `update_template` - Update an existing template
126 | - `delete_template` - Delete a template
127 | - `copy_template_to_app` - Copy template to another app
128 |
129 | ### 🏢 Apps (6 tools)
130 | - `view_app_details` - Get details about configured app
131 | - `view_apps` - List all organization apps
132 | - `create_app` - Create a new OneSignal application
133 | - `update_app` - Update an existing application
134 | - `view_app_api_keys` - View API keys for an app
135 | - `create_app_api_key` - Create a new API key
136 |
137 | ### 🔑 API Key Management (3 tools)
138 | - `delete_app_api_key` - Delete an API key
139 | - `update_app_api_key` - Update an API key
140 | - `rotate_app_api_key` - Rotate an API key
141 |
142 | ### 👤 Users (6 tools)
143 | - `create_user` - Create a new user
144 | - `view_user` - View user details
145 | - `update_user` - Update user information
146 | - `delete_user` - Delete a user
147 | - `view_user_identity` - Get user identity information
148 | - `view_user_identity_by_subscription` - Get identity by subscription
149 |
150 | ### 🏷️ Aliases (3 tools)
151 | - `create_or_update_alias` - Create or update user alias
152 | - `delete_alias` - Delete a user alias
153 | - `create_alias_by_subscription` - Create alias by subscription ID
154 |
155 | ### 📬 Subscriptions (5 tools)
156 | - `create_subscription` - Create a new subscription
157 | - `update_subscription` - Update a subscription
158 | - `delete_subscription` - Delete a subscription
159 | - `transfer_subscription` - Transfer subscription between users
160 | - `unsubscribe_email` - Unsubscribe using email token
161 |
162 | ### 🎯 Live Activities (3 tools)
163 | - `start_live_activity` - Start iOS Live Activity
164 | - `update_live_activity` - Update iOS Live Activity
165 | - `end_live_activity` - End iOS Live Activity
166 |
167 | ### 📊 Analytics & Export (3 tools)
168 | - `view_outcomes` - View outcomes/conversion data
169 | - `export_players_csv` - Export player data to CSV
170 | - `export_messages_csv` - Export messages to CSV
171 |
172 | ## Usage Examples
173 |
174 | ### Multi-Channel Messaging
175 |
176 | ```python
177 | # Send a push notification
178 | await send_push_notification(
179 | title="Hello World",
180 | message="This is a test notification",
181 | segments=["Subscribed Users"]
182 | )
183 |
184 | # Send an email
185 | await send_email(
186 | subject="Welcome!",
187 | body="Thank you for joining us",
188 | email_body="<html><body><h1>Welcome!</h1></body></html>",
189 | include_emails=["[email protected]"]
190 | )
191 |
192 | # Send an SMS
193 | await send_sms(
194 | message="Your verification code is 12345",
195 | phone_numbers=["+15551234567"]
196 | )
197 |
198 | # Send a transactional message
199 | await send_transactional_message(
200 | channel="email",
201 | content={"subject": "Order Confirmation", "body": "Your order has been confirmed"},
202 | recipients={"include_external_user_ids": ["user123"]}
203 | )
204 | ```
205 |
206 | ### User and Device Management
207 |
208 | ```python
209 | # Create a user
210 | user = await create_user(
211 | name="John Doe",
212 | email="[email protected]",
213 | external_id="user123",
214 | tags={"plan": "premium", "joined": "2024-01-01"}
215 | )
216 |
217 | # Add a device
218 | device = await add_player(
219 | device_type=1, # Android
220 | identifier="device_token_here",
221 | language="en",
222 | tags={"app_version": "1.0.0"}
223 | )
224 |
225 | # Update user tags across all devices
226 | await edit_tags_with_external_user_id(
227 | external_user_id="user123",
228 | tags={"last_active": "2024-01-15", "purchases": "5"}
229 | )
230 | ```
231 |
232 | ### iOS Live Activities
233 |
234 | ```python
235 | # Start a Live Activity
236 | await start_live_activity(
237 | activity_id="delivery_123",
238 | push_token="live_activity_push_token",
239 | subscription_id="user_subscription_id",
240 | activity_attributes={"order_number": "12345"},
241 | content_state={"status": "preparing", "eta": "15 mins"}
242 | )
243 |
244 | # Update the Live Activity
245 | await update_live_activity(
246 | activity_id="delivery_123",
247 | name="delivery_update",
248 | event="update",
249 | content_state={"status": "on_the_way", "eta": "5 mins"}
250 | )
251 | ```
252 |
253 | ### Analytics and Export
254 |
255 | ```python
256 | # View conversion outcomes
257 | outcomes = await view_outcomes(
258 | outcome_names=["purchase", "session_duration"],
259 | outcome_time_range="7d",
260 | outcome_platforms=["ios", "android"]
261 | )
262 |
263 | # Export player data
264 | export = await export_players_csv(
265 | start_date="2024-01-01T00:00:00Z",
266 | end_date="2024-01-31T23:59:59Z",
267 | segment_names=["Active Users"]
268 | )
269 | ```
270 |
271 | ## Testing
272 |
273 | The server includes a comprehensive test suite. To run tests:
274 |
275 | ```bash
276 | # Run the test script
277 | python test_onesignal_mcp.py
278 |
279 | # Or use unittest
280 | python -m unittest discover tests
281 | ```
282 |
283 | ## Error Handling
284 |
285 | The server provides consistent error handling:
286 | - All errors are returned in a standardized format
287 | - Detailed error messages help identify issues
288 | - Automatic retry logic for transient failures
289 | - Proper authentication error messages
290 |
291 | ## Rate Limiting
292 |
293 | OneSignal enforces rate limits on API requests:
294 | - Standard limit: 10 requests per second
295 | - Bulk operations: May have lower limits
296 | - The server includes guidance on handling rate limits
297 |
298 | ## Contributing
299 |
300 | We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
301 |
302 | ## License
303 |
304 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
305 |
306 | ## Acknowledgements
307 |
308 | - [OneSignal](https://onesignal.com/) for their excellent notification service
309 | - The MCP community for the Model Context Protocol
310 | - All contributors to this project
311 |
```
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
```markdown
1 | # Contributing to OneSignal MCP Server
2 |
3 | Thank you for considering contributing to the OneSignal MCP Server! This document provides guidelines and instructions for contributing to this project.
4 |
5 | ## Code of Conduct
6 |
7 | Please be respectful and considerate of others when contributing to this project. We aim to foster an inclusive and welcoming community.
8 |
9 | ## How to Contribute
10 |
11 | ### Reporting Bugs
12 |
13 | If you find a bug, please create an issue with the following information:
14 | - A clear, descriptive title
15 | - Steps to reproduce the bug
16 | - Expected behavior
17 | - Actual behavior
18 | - Any relevant logs or error messages
19 | - Your environment (OS, Python version, etc.)
20 |
21 | ### Suggesting Features
22 |
23 | If you have an idea for a new feature, please create an issue with:
24 | - A clear, descriptive title
25 | - A detailed description of the feature
26 | - Any relevant examples or use cases
27 | - Why this feature would be beneficial
28 |
29 | ### Pull Requests
30 |
31 | 1. Fork the repository
32 | 2. Create a new branch for your changes
33 | 3. Make your changes
34 | 4. Run tests to ensure your changes don't break existing functionality
35 | 5. Submit a pull request
36 |
37 | ### Development Setup
38 |
39 | 1. Clone the repository
40 | 2. Install dependencies:
41 | ```
42 | pip install -r requirements.txt
43 | ```
44 | 3. Create a `.env` file with your OneSignal credentials (see `.env.example`)
45 |
46 | ## Coding Standards
47 |
48 | - Follow PEP 8 style guidelines
49 | - Write docstrings for all functions, classes, and modules
50 | - Include type hints where appropriate
51 | - Write tests for new functionality
52 |
53 | ## Testing
54 |
55 | Before submitting a pull request, please ensure that all tests pass. You can run tests with:
56 |
57 | ```
58 | # TODO: Add testing instructions once tests are implemented
59 | ```
60 |
61 | ## Documentation
62 |
63 | Please update documentation when making changes:
64 | - Update docstrings for modified functions
65 | - Update the README.md if necessary
66 | - Add examples for new functionality
67 |
68 | ## License
69 |
70 | By contributing to this project, you agree that your contributions will be licensed under the project's [MIT License](LICENSE).
71 |
```
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
```python
1 |
2 |
```
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
```
1 | python-dotenv>=1.0.0
2 | requests>=2.31.0
3 |
```
--------------------------------------------------------------------------------
/onesignal_refactored/tools/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """OneSignal MCP Server Tools - API endpoint implementations."""
2 | from . import messages
3 | from . import templates
4 | from . import live_activities
5 | from . import analytics
6 |
7 | __all__ = [
8 | "messages",
9 | "templates",
10 | "live_activities",
11 | "analytics"
12 | ]
```
--------------------------------------------------------------------------------
/onesignal_refactored/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """OneSignal MCP Server - Refactored Implementation."""
2 | from .config import app_manager, AppConfig
3 | from .api_client import api_client, OneSignalAPIError
4 | from .server import mcp, __version__
5 |
6 | __all__ = [
7 | "app_manager",
8 | "AppConfig",
9 | "api_client",
10 | "OneSignalAPIError",
11 | "mcp",
12 | "__version__"
13 | ]
```
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
```python
1 | from setuptools import setup, find_packages
2 |
3 | with open("README.md", "r", encoding="utf-8") as fh:
4 | long_description = fh.read()
5 |
6 | setup(
7 | name="onesignal-mcp",
8 | version="1.0.0",
9 | author="Weirdbrains",
10 | author_email="[email protected]",
11 | description="A Model Context Protocol (MCP) server for interacting with the OneSignal API",
12 | long_description=long_description,
13 | long_description_content_type="text/markdown",
14 | url="https://github.com/weirdbrains/onesignal-mcp",
15 | packages=find_packages(),
16 | classifiers=[
17 | "Programming Language :: Python :: 3",
18 | "License :: OSI Approved :: MIT License",
19 | "Operating System :: OS Independent",
20 | ],
21 | python_requires=">=3.7",
22 | install_requires=[
23 | "python-dotenv>=1.0.0",
24 | "requests>=2.31.0",
25 | ],
26 | include_package_data=True,
27 | )
28 |
```
--------------------------------------------------------------------------------
/examples/send_notification.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python
2 | """
3 | Example script demonstrating how to send a notification using the OneSignal MCP server.
4 | """
5 | import asyncio
6 | import sys
7 | import os
8 |
9 | # Add the parent directory to the path so we can import the server module
10 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
11 |
12 | # Import the server module
13 | from onesignal_server import send_notification
14 |
15 | async def main():
16 | """Send a test notification to all subscribed users."""
17 | print("Sending a test notification...")
18 |
19 | result = await send_notification(
20 | title="Hello from OneSignal MCP",
21 | message="This is a test notification sent from the example script.",
22 | segment="Subscribed Users",
23 | data={"custom_key": "custom_value"}
24 | )
25 |
26 | if "error" in result:
27 | print(f"Error: {result['error']}")
28 | else:
29 | print(f"Success! Notification ID: {result.get('id')}")
30 | print(f"Recipients: {result.get('recipients')}")
31 |
32 | if __name__ == "__main__":
33 | asyncio.run(main())
34 |
```
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | jobs:
10 | lint:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v3
14 | - name: Set up Python
15 | uses: actions/setup-python@v4
16 | with:
17 | python-version: '3.9'
18 | - name: Install dependencies
19 | run: |
20 | python -m pip install --upgrade pip
21 | pip install flake8 black isort
22 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
23 | - name: Lint with flake8
24 | run: |
25 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
26 | - name: Check formatting with black
27 | run: |
28 | black --check .
29 | - name: Check imports with isort
30 | run: |
31 | isort --check-only --profile black .
32 |
33 | test:
34 | runs-on: ubuntu-latest
35 | steps:
36 | - uses: actions/checkout@v3
37 | - name: Set up Python
38 | uses: actions/setup-python@v4
39 | with:
40 | python-version: '3.9'
41 | - name: Install dependencies
42 | run: |
43 | python -m pip install --upgrade pip
44 | pip install pytest pytest-cov
45 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
46 | - name: Test with pytest
47 | run: |
48 | # Uncomment when tests are added
49 | # pytest --cov=. --cov-report=xml
50 | echo "Tests will be added in a future update"
51 |
```
--------------------------------------------------------------------------------
/check_loaded_key.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python3
2 | """Check what API key is currently loaded from .env"""
3 |
4 | import os
5 | from dotenv import load_dotenv
6 |
7 | # Load environment variables
8 | load_dotenv()
9 |
10 | # Get the current API key
11 | api_key = os.getenv("ONESIGNAL_MANDIBLE_API_KEY")
12 |
13 | print("Currently loaded Mandible API key:")
14 | print(f"Length: {len(api_key) if api_key else 'None'}")
15 | print(f"Prefix: {api_key[:20] if api_key else 'None'}...")
16 | print(f"Suffix: ...{api_key[-10:] if api_key else 'None'}")
17 |
18 | # Also check if we can read the .env file directly
19 | print("\nReading .env file directly:")
20 | try:
21 | with open('.env', 'r') as f:
22 | for line in f:
23 | if 'ONESIGNAL_MANDIBLE_API_KEY' in line and not line.strip().startswith('#'):
24 | key_from_file = line.split('=', 1)[1].strip().strip('"').strip("'")
25 | print(f"Length: {len(key_from_file)}")
26 | print(f"Prefix: {key_from_file[:20]}...")
27 | print(f"Suffix: ...{key_from_file[-10:]}")
28 |
29 | if api_key != key_from_file:
30 | print("\n⚠️ WARNING: The loaded key differs from what's in .env!")
31 | print(" You need to restart the MCP server to load the new key.")
32 | else:
33 | print("\n✅ The loaded key matches what's in .env")
34 | break
35 | except Exception as e:
36 | print(f"Error reading .env file: {e}")
```
--------------------------------------------------------------------------------
/test_segments_debug.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python3
2 | """Debug script to test the segments endpoint with detailed output"""
3 |
4 | import os
5 | import requests
6 | from dotenv import load_dotenv
7 |
8 | # Load environment variables
9 | load_dotenv()
10 |
11 | # Get credentials
12 | app_id = os.getenv("ONESIGNAL_MANDIBLE_APP_ID")
13 | api_key = os.getenv("ONESIGNAL_MANDIBLE_API_KEY")
14 |
15 | print(f"App ID: {app_id}")
16 | print(f"API Key type: {'v2' if api_key.startswith('os_v2_') else 'v1'}")
17 | print(f"API Key prefix: {api_key[:15]}...")
18 |
19 | # Try different authentication methods
20 | url = f"https://api.onesignal.com/apps/{app_id}/segments"
21 | print(f"\nTesting URL: {url}")
22 |
23 | # Test 1: Using 'Key' authorization for v2 API key
24 | headers1 = {
25 | "Authorization": f"Key {api_key}",
26 | "Accept": "application/json",
27 | "Content-Type": "application/json"
28 | }
29 |
30 | print("\n1. Testing with 'Key' authorization header...")
31 | try:
32 | response = requests.get(url, headers=headers1)
33 | print(f"Status: {response.status_code}")
34 | print(f"Response: {response.text[:200]}")
35 | except Exception as e:
36 | print(f"Error: {e}")
37 |
38 | # Test 2: Using 'Basic' authorization
39 | headers2 = {
40 | "Authorization": f"Basic {api_key}",
41 | "Accept": "application/json",
42 | "Content-Type": "application/json"
43 | }
44 |
45 | print("\n2. Testing with 'Basic' authorization header...")
46 | try:
47 | response = requests.get(url, headers=headers2)
48 | print(f"Status: {response.status_code}")
49 | print(f"Response: {response.text[:200]}")
50 | except Exception as e:
51 | print(f"Error: {e}")
52 |
53 | # Test 3: Without app_id in URL path (using query param)
54 | url2 = "https://api.onesignal.com/segments"
55 | params = {"app_id": app_id}
56 |
57 | print(f"\n3. Testing with app_id as query param: {url2}")
58 | print(f"Params: {params}")
59 | try:
60 | response = requests.get(url2, headers=headers1, params=params)
61 | print(f"Status: {response.status_code}")
62 | print(f"Response: {response.text[:200]}")
63 | except Exception as e:
64 | print(f"Error: {e}")
```
--------------------------------------------------------------------------------
/examples/send_invite_email.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python
2 | """
3 | Example script demonstrating how to send invitation emails using the OneSignal MCP server.
4 | This showcases the functionality that replaces SendGrid's invitation system.
5 | """
6 | import asyncio
7 | import sys
8 | import os
9 |
10 | # Add the parent directory to the path so we can import the server module
11 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
12 |
13 | # Import the server module
14 | from onesignal_server import send_invite_email, send_bulk_invites
15 |
16 | async def send_single_invite():
17 | """Send a single invitation email."""
18 | print("Sending a single invitation email...")
19 |
20 | result = await send_invite_email(
21 | email="[email protected]",
22 | first_name="John",
23 | invite_url="https://yourapp.com/invite/abc123",
24 | inviter_name="Jane Smith",
25 | app_name="Your Amazing App",
26 | expiry_days=7
27 | )
28 |
29 | if "error" in result:
30 | print(f"Error: {result['error']}")
31 | else:
32 | print(f"Success! Email sent to [email protected]")
33 | print(f"Details: {result}")
34 |
35 | async def send_multiple_invites():
36 | """Send multiple invitation emails at once."""
37 | print("\nSending multiple invitation emails...")
38 |
39 | invites = [
40 | {
41 | "email": "[email protected]",
42 | "first_name": "User",
43 | "invite_url": "https://yourapp.com/invite/user1",
44 | "inviter_name": "Team Admin"
45 | },
46 | {
47 | "email": "[email protected]",
48 | "first_name": "Another",
49 | "invite_url": "https://yourapp.com/invite/user2",
50 | "inviter_name": "Team Admin"
51 | }
52 | ]
53 |
54 | results = await send_bulk_invites(
55 | invites=invites,
56 | app_name="Your Amazing App",
57 | expiry_days=7
58 | )
59 |
60 | print(f"Sent {len(results)} invitation emails")
61 | for i, result in enumerate(results):
62 | if "error" in result:
63 | print(f"Error sending to {invites[i]['email']}: {result['error']}")
64 | else:
65 | print(f"Successfully sent to {invites[i]['email']}")
66 |
67 | async def main():
68 | """Run both examples."""
69 | await send_single_invite()
70 | await send_multiple_invites()
71 |
72 | if __name__ == "__main__":
73 | asyncio.run(main())
74 |
```
--------------------------------------------------------------------------------
/missing_endpoints_analysis.md:
--------------------------------------------------------------------------------
```markdown
1 | # OneSignal MCP Server - Missing Endpoints Analysis
2 |
3 | ## High Priority Missing Endpoints
4 |
5 | ### 1. Messaging Endpoints
6 | - **Email-specific endpoint** (`/notifications` with email channel)
7 | - Currently only generic push notifications are supported
8 | - **SMS-specific endpoint** (`/notifications` with SMS channel)
9 | - No dedicated SMS sending functionality
10 | - **Transactional Messages** (`/notifications` with specific flags)
11 | - Critical for automated/triggered messages
12 |
13 | ### 2. Live Activities (iOS)
14 | - **Start Live Activity** (`/live_activities/{activity_id}/start`)
15 | - **Update Live Activity** (`/live_activities/{activity_id}/update`)
16 | - **End Live Activity** (`/live_activities/{activity_id}/end`)
17 |
18 | ### 3. Template Management
19 | - **Update Template** (`PATCH /templates/{template_id}`)
20 | - **Delete Template** (`DELETE /templates/{template_id}`)
21 | - **Copy Template to Another App** (`POST /templates/{template_id}/copy`)
22 |
23 | ### 4. API Key Management
24 | - **Delete API Key** (`DELETE /apps/{app_id}/auth/tokens/{token_id}`)
25 | - **Update API Key** (`PATCH /apps/{app_id}/auth/tokens/{token_id}`)
26 | - **Rotate API Key** (`POST /apps/{app_id}/auth/tokens/{token_id}/rotate`)
27 |
28 | ### 5. Analytics/Outcomes
29 | - **View Outcomes** (`GET /apps/{app_id}/outcomes`)
30 | - Essential for tracking conversion metrics
31 |
32 | ### 6. Export Functionality
33 | - **Export Subscriptions CSV** (`POST /players/csv_export`)
34 | - **Export Audience Activity CSV** (`POST /notifications/csv_export`)
35 |
36 | ### 7. Player/Device Management (Legacy but still used)
37 | - **Add a Player** (`POST /players`)
38 | - **Edit Player** (`PUT /players/{player_id}`)
39 | - **Edit Tags with External User ID** (`PUT /users/{external_user_id}`)
40 | - **Delete Player Record** (`DELETE /players/{player_id}`)
41 |
42 | ### 8. Additional User/Subscription Endpoints
43 | - **View User Identity by Subscription** (`GET /apps/{app_id}/subscriptions/{subscription_id}/identity`)
44 | - **Create Alias by Subscription** (`PATCH /apps/{app_id}/subscriptions/{subscription_id}/identity`)
45 |
46 | ## Medium Priority Missing Features
47 |
48 | ### 1. In-App Messages
49 | - No endpoints for managing in-app messages
50 |
51 | ### 2. Webhooks Management
52 | - No endpoints for configuring webhooks
53 |
54 | ### 3. Journey/Automation APIs
55 | - No support for automated messaging journeys
56 |
57 | ## Implementation Priority
58 |
59 | 1. **Email and SMS endpoints** - Critical for multi-channel messaging
60 | 2. **Transactional Messages** - Essential for automated notifications
61 | 3. **Template management completeness** - Update and delete operations
62 | 4. **Export functionality** - Important for data management
63 | 5. **Analytics/Outcomes** - Necessary for measuring effectiveness
```
--------------------------------------------------------------------------------
/test_auth_fix.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python3
2 | """
3 | Test script to verify the authentication fix for OneSignal MCP server
4 | """
5 |
6 | import asyncio
7 | import json
8 | import logging
9 | import os
10 | from onesignal_server import (
11 | view_segments,
12 | view_templates,
13 | get_current_app,
14 | app_configs,
15 | make_onesignal_request,
16 | logger
17 | )
18 |
19 | # Enable DEBUG logging
20 | logging.basicConfig(level=logging.DEBUG)
21 | logger.setLevel(logging.DEBUG)
22 |
23 | async def test_direct_api_call():
24 | """Test direct API call to debug the issue"""
25 | print("\nTesting direct API call...")
26 | current_app = get_current_app()
27 |
28 | # Test direct segments call
29 | endpoint = f"apps/{current_app.app_id}/segments"
30 | print(f"Endpoint: {endpoint}")
31 | print(f"App ID: {current_app.app_id}")
32 | print(f"API Key length: {len(current_app.api_key)}")
33 |
34 | result = await make_onesignal_request(endpoint, method="GET", use_org_key=False)
35 | print(f"Direct API result: {json.dumps(result, indent=2)}")
36 |
37 | async def test_endpoints():
38 | """Test the fixed endpoints"""
39 | print("Testing OneSignal Authentication Fix")
40 | print("=" * 50)
41 |
42 | # Check current app configuration
43 | current_app = get_current_app()
44 | if not current_app:
45 | print("❌ No app configured. Please ensure your .env file has:")
46 | print(" ONESIGNAL_APP_ID=your_app_id_here")
47 | print(" ONESIGNAL_API_KEY=your_rest_api_key_here")
48 | return
49 |
50 | print(f"✅ Current app: {current_app.name} (ID: {current_app.app_id})")
51 | print(f" API Key: {'*' * 20}{current_app.api_key[-10:]}")
52 | print()
53 |
54 | # Test segments endpoint
55 | print("Testing view_segments()...")
56 | try:
57 | segments_result = await view_segments()
58 | if "Error retrieving segments:" in segments_result:
59 | print(f"❌ Segments test failed: {segments_result}")
60 | else:
61 | print("✅ Segments endpoint is working!")
62 | print(f" Result preview: {segments_result[:200]}...")
63 | except Exception as e:
64 | print(f"❌ Segments test error: {str(e)}")
65 |
66 | print()
67 |
68 | # Test templates endpoint
69 | print("Testing view_templates()...")
70 | try:
71 | templates_result = await view_templates()
72 | if "Error retrieving templates:" in templates_result:
73 | print(f"❌ Templates test failed: {templates_result}")
74 | else:
75 | print("✅ Templates endpoint is working!")
76 | print(f" Result preview: {templates_result[:200]}...")
77 | except Exception as e:
78 | print(f"❌ Templates test error: {str(e)}")
79 |
80 | # Test direct API call for debugging
81 | await test_direct_api_call()
82 |
83 | print()
84 | print("Test completed!")
85 |
86 | if __name__ == "__main__":
87 | asyncio.run(test_endpoints())
```
--------------------------------------------------------------------------------
/debug_api_key.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python3
2 | """
3 | Debug script to check API key format and test authentication
4 | """
5 |
6 | import os
7 | import requests
8 | from dotenv import load_dotenv
9 |
10 | # Load environment variables
11 | load_dotenv()
12 |
13 | # Get the API credentials
14 | app_id = os.getenv("ONESIGNAL_MANDIBLE_APP_ID", "")
15 | api_key = os.getenv("ONESIGNAL_MANDIBLE_API_KEY", "")
16 | org_key = os.getenv("ONESIGNAL_ORG_API_KEY", "")
17 |
18 | print("API Key Debugging")
19 | print("=" * 50)
20 | print(f"App ID: {app_id}")
21 | print(f"REST API Key length: {len(api_key)}")
22 | print(f"REST API Key prefix: {api_key[:10] if api_key else 'None'}")
23 | print(f"Org API Key length: {len(org_key)}")
24 | print(f"Org API Key prefix: {org_key[:10] if org_key else 'None'}")
25 |
26 | # Check for common issues
27 | if api_key:
28 | if api_key.startswith('"') or api_key.endswith('"'):
29 | print("⚠️ WARNING: API key contains quotes")
30 | if ' ' in api_key:
31 | print("⚠️ WARNING: API key contains spaces")
32 | if '\n' in api_key or '\r' in api_key:
33 | print("⚠️ WARNING: API key contains newlines")
34 | if api_key.startswith('Basic '):
35 | print("⚠️ WARNING: API key already contains 'Basic ' prefix")
36 |
37 | # Test segments endpoint with REST API key
38 | print("\n1. Testing segments endpoint with REST API key...")
39 | url = f"https://api.onesignal.com/apps/{app_id}/segments"
40 | headers = {
41 | "Authorization": f"Basic {api_key}",
42 | "Content-Type": "application/json",
43 | "Accept": "application/json"
44 | }
45 |
46 | print(f"URL: {url}")
47 |
48 | try:
49 | response = requests.get(url, headers=headers, timeout=10)
50 | print(f"Response status: {response.status_code}")
51 | print(f"Response: {response.text[:500] if response.text else 'Empty response'}")
52 |
53 | if response.status_code == 200:
54 | print("✅ Segments endpoint working with REST API key!")
55 | else:
56 | print("❌ Segments endpoint failed with REST API key")
57 | except Exception as e:
58 | print(f"❌ Request failed: {str(e)}")
59 |
60 | # Test app details endpoint with Org API key
61 | print("\n2. Testing app details endpoint with Org API key...")
62 | url = f"https://api.onesignal.com/apps/{app_id}"
63 | headers = {
64 | "Authorization": f"Basic {org_key}",
65 | "Content-Type": "application/json",
66 | "Accept": "application/json"
67 | }
68 |
69 | print(f"URL: {url}")
70 |
71 | try:
72 | response = requests.get(url, headers=headers, timeout=10)
73 | print(f"Response status: {response.status_code}")
74 |
75 | if response.status_code == 200:
76 | print("✅ App details endpoint working with Org API key!")
77 | else:
78 | print(f"❌ App details endpoint failed: {response.text[:200]}")
79 | except Exception as e:
80 | print(f"❌ Request failed: {str(e)}")
81 |
82 | # Test templates endpoint
83 | print("\n3. Testing templates endpoint with REST API key...")
84 | url = f"https://api.onesignal.com/apps/{app_id}/templates"
85 | headers = {
86 | "Authorization": f"Basic {api_key}",
87 | "Content-Type": "application/json",
88 | "Accept": "application/json"
89 | }
90 |
91 | print(f"URL: {url}")
92 |
93 | try:
94 | response = requests.get(url, headers=headers, timeout=10)
95 | print(f"Response status: {response.status_code}")
96 |
97 | if response.status_code == 200:
98 | print("✅ Templates endpoint working with REST API key!")
99 | else:
100 | print(f"❌ Templates endpoint failed: {response.text[:200]}")
101 | except Exception as e:
102 | print(f"❌ Request failed: {str(e)}")
```
--------------------------------------------------------------------------------
/onesignal_refactored/tools/live_activities.py:
--------------------------------------------------------------------------------
```python
1 | """Live Activities management tools for OneSignal MCP server."""
2 | from typing import Dict, Any, Optional
3 | from ..api_client import api_client
4 |
5 |
6 | async def start_live_activity(
7 | activity_id: str,
8 | push_token: str,
9 | subscription_id: str,
10 | activity_attributes: Dict[str, Any],
11 | content_state: Dict[str, Any],
12 | **kwargs
13 | ) -> Dict[str, Any]:
14 | """
15 | Start a new Live Activity for iOS.
16 |
17 | Args:
18 | activity_id: Unique identifier for the activity
19 | push_token: Push token for the Live Activity
20 | subscription_id: Subscription ID for the user
21 | activity_attributes: Static attributes for the activity
22 | content_state: Initial dynamic content state
23 | **kwargs: Additional parameters
24 | """
25 | data = {
26 | "activity_id": activity_id,
27 | "push_token": push_token,
28 | "subscription_id": subscription_id,
29 | "activity_attributes": activity_attributes,
30 | "content_state": content_state
31 | }
32 |
33 | data.update(kwargs)
34 |
35 | return await api_client.request(
36 | f"live_activities/{activity_id}/start",
37 | method="POST",
38 | data=data
39 | )
40 |
41 |
42 | async def update_live_activity(
43 | activity_id: str,
44 | name: str,
45 | event: str,
46 | content_state: Dict[str, Any],
47 | dismissal_date: Optional[int] = None,
48 | priority: Optional[int] = None,
49 | sound: Optional[str] = None,
50 | **kwargs
51 | ) -> Dict[str, Any]:
52 | """
53 | Update an existing Live Activity.
54 |
55 | Args:
56 | activity_id: ID of the activity to update
57 | name: Name identifier for the update
58 | event: Event type ("update" or "end")
59 | content_state: Updated dynamic content state
60 | dismissal_date: Unix timestamp for automatic dismissal
61 | priority: Notification priority (5-10)
62 | sound: Sound file name for the update
63 | **kwargs: Additional parameters
64 | """
65 | data = {
66 | "name": name,
67 | "event": event,
68 | "content_state": content_state
69 | }
70 |
71 | if dismissal_date:
72 | data["dismissal_date"] = dismissal_date
73 | if priority:
74 | data["priority"] = priority
75 | if sound:
76 | data["sound"] = sound
77 |
78 | data.update(kwargs)
79 |
80 | return await api_client.request(
81 | f"live_activities/{activity_id}/update",
82 | method="POST",
83 | data=data
84 | )
85 |
86 |
87 | async def end_live_activity(
88 | activity_id: str,
89 | subscription_id: str,
90 | dismissal_date: Optional[int] = None,
91 | priority: Optional[int] = None,
92 | **kwargs
93 | ) -> Dict[str, Any]:
94 | """
95 | End a Live Activity.
96 |
97 | Args:
98 | activity_id: ID of the activity to end
99 | subscription_id: Subscription ID associated with the activity
100 | dismissal_date: Unix timestamp for dismissal
101 | priority: Notification priority (5-10)
102 | **kwargs: Additional parameters
103 | """
104 | data = {
105 | "subscription_id": subscription_id,
106 | "event": "end"
107 | }
108 |
109 | if dismissal_date:
110 | data["dismissal_date"] = dismissal_date
111 | if priority:
112 | data["priority"] = priority
113 |
114 | data.update(kwargs)
115 |
116 | return await api_client.request(
117 | f"live_activities/{activity_id}/end",
118 | method="POST",
119 | data=data
120 | )
121 |
122 |
123 | async def get_live_activity_status(
124 | activity_id: str,
125 | subscription_id: str
126 | ) -> Dict[str, Any]:
127 | """
128 | Get the status of a Live Activity.
129 |
130 | Args:
131 | activity_id: ID of the activity
132 | subscription_id: Subscription ID associated with the activity
133 | """
134 | params = {"subscription_id": subscription_id}
135 |
136 | return await api_client.request(
137 | f"live_activities/{activity_id}/status",
138 | method="GET",
139 | params=params
140 | )
```
--------------------------------------------------------------------------------
/onesignal_refactored/tools/analytics.py:
--------------------------------------------------------------------------------
```python
1 | """Analytics and outcomes tools for OneSignal MCP server."""
2 | from typing import Dict, Any, Optional, List
3 | from ..api_client import api_client
4 | from ..config import app_manager
5 |
6 |
7 | async def view_outcomes(
8 | outcome_names: List[str],
9 | outcome_time_range: Optional[str] = None,
10 | outcome_platforms: Optional[List[str]] = None,
11 | outcome_attribution: Optional[str] = None,
12 | **kwargs
13 | ) -> Dict[str, Any]:
14 | """
15 | View outcomes data for your OneSignal app.
16 |
17 | Args:
18 | outcome_names: List of outcome names to fetch data for
19 | outcome_time_range: Time range for data (e.g., "1d", "1mo")
20 | outcome_platforms: Filter by platforms (e.g., ["ios", "android"])
21 | outcome_attribution: Attribution model ("direct" or "influenced")
22 | **kwargs: Additional parameters
23 | """
24 | app_config = app_manager.get_current_app()
25 | if not app_config:
26 | raise ValueError("No app currently selected")
27 |
28 | params = {
29 | "outcome_names": outcome_names
30 | }
31 |
32 | if outcome_time_range:
33 | params["outcome_time_range"] = outcome_time_range
34 | if outcome_platforms:
35 | params["outcome_platforms"] = outcome_platforms
36 | if outcome_attribution:
37 | params["outcome_attribution"] = outcome_attribution
38 |
39 | params.update(kwargs)
40 |
41 | return await api_client.request(
42 | f"apps/{app_config.app_id}/outcomes",
43 | method="GET",
44 | params=params
45 | )
46 |
47 |
48 | async def export_players_csv(
49 | start_date: Optional[str] = None,
50 | end_date: Optional[str] = None,
51 | segment_names: Optional[List[str]] = None,
52 | **kwargs
53 | ) -> Dict[str, Any]:
54 | """
55 | Export player/subscription data to CSV.
56 |
57 | Args:
58 | start_date: Start date for export (ISO 8601 format)
59 | end_date: End date for export (ISO 8601 format)
60 | segment_names: List of segment names to export
61 | **kwargs: Additional export parameters
62 | """
63 | data = {}
64 |
65 | if start_date:
66 | data["start_date"] = start_date
67 | if end_date:
68 | data["end_date"] = end_date
69 | if segment_names:
70 | data["segment_names"] = segment_names
71 |
72 | data.update(kwargs)
73 |
74 | return await api_client.request(
75 | "players/csv_export",
76 | method="POST",
77 | data=data,
78 | use_org_key=True
79 | )
80 |
81 |
82 | async def export_audience_activity_csv(
83 | start_date: Optional[str] = None,
84 | end_date: Optional[str] = None,
85 | event_types: Optional[List[str]] = None,
86 | **kwargs
87 | ) -> Dict[str, Any]:
88 | """
89 | Export audience activity events to CSV.
90 |
91 | Args:
92 | start_date: Start date for export (ISO 8601 format)
93 | end_date: End date for export (ISO 8601 format)
94 | event_types: List of event types to export
95 | **kwargs: Additional export parameters
96 | """
97 | data = {}
98 |
99 | if start_date:
100 | data["start_date"] = start_date
101 | if end_date:
102 | data["end_date"] = end_date
103 | if event_types:
104 | data["event_types"] = event_types
105 |
106 | data.update(kwargs)
107 |
108 | return await api_client.request(
109 | "notifications/csv_export",
110 | method="POST",
111 | data=data,
112 | use_org_key=True
113 | )
114 |
115 |
116 | def format_outcomes_response(outcomes: Dict[str, Any]) -> str:
117 | """Format outcomes response for display."""
118 | if not outcomes or "outcomes" not in outcomes:
119 | return "No outcomes data available."
120 |
121 | output = "Outcomes Report:\n\n"
122 |
123 | for outcome in outcomes.get("outcomes", []):
124 | output += f"Outcome: {outcome.get('id')}\n"
125 | output += f"Total Count: {outcome.get('aggregation', {}).get('count', 0)}\n"
126 | output += f"Total Value: {outcome.get('aggregation', {}).get('sum', 0)}\n"
127 |
128 | # Platform breakdown
129 | platforms = outcome.get('platforms', {})
130 | if platforms:
131 | output += "Platform Breakdown:\n"
132 | for platform, data in platforms.items():
133 | output += f" {platform}: Count={data.get('count', 0)}, Value={data.get('sum', 0)}\n"
134 |
135 | output += "\n"
136 |
137 | return output
```
--------------------------------------------------------------------------------
/onesignal_tools_list.md:
--------------------------------------------------------------------------------
```markdown
1 | # Complete OneSignal MCP Tools List
2 |
3 | The OneSignal MCP server now includes **57 tools** covering all major OneSignal API functionality:
4 |
5 | ## App Management (5 tools)
6 | 1. **list_apps** - List all configured OneSignal apps
7 | 2. **add_app** - Add a new OneSignal app configuration locally
8 | 3. **update_local_app_config** - Update an existing local app configuration
9 | 4. **remove_app** - Remove a local OneSignal app configuration
10 | 5. **switch_app** - Switch the current app to use for API requests
11 |
12 | ## Messaging (8 tools)
13 | 6. **send_push_notification** - Send a push notification
14 | 7. **send_email** *(NEW)* - Send an email through OneSignal
15 | 8. **send_sms** *(NEW)* - Send an SMS/MMS through OneSignal
16 | 9. **send_transactional_message** *(NEW)* - Send immediate delivery messages
17 | 10. **view_messages** - View recent messages sent
18 | 11. **view_message_details** - Get detailed information about a message
19 | 12. **view_message_history** - View message history/recipients
20 | 13. **cancel_message** - Cancel a scheduled message
21 |
22 | ## Devices/Players (6 tools)
23 | 14. **view_devices** - View devices subscribed to your app
24 | 15. **view_device_details** - Get detailed information about a device
25 | 16. **add_player** *(NEW)* - Add a new player/device
26 | 17. **edit_player** *(NEW)* - Edit an existing player/device
27 | 18. **delete_player** *(NEW)* - Delete a player/device record
28 | 19. **edit_tags_with_external_user_id** *(NEW)* - Bulk edit tags by external ID
29 |
30 | ## Segments (3 tools)
31 | 20. **view_segments** - List all segments
32 | 21. **create_segment** - Create a new segment
33 | 22. **delete_segment** - Delete a segment
34 |
35 | ## Templates (6 tools)
36 | 23. **view_templates** - List all templates
37 | 24. **view_template_details** - Get template details
38 | 25. **create_template** - Create a new template
39 | 26. **update_template** *(NEW)* - Update an existing template
40 | 27. **delete_template** *(NEW)* - Delete a template
41 | 28. **copy_template_to_app** *(NEW)* - Copy template to another app
42 |
43 | ## Apps (6 tools)
44 | 29. **view_app_details** - Get details about configured app
45 | 30. **view_apps** - List all organization apps
46 | 31. **create_app** - Create a new OneSignal application
47 | 32. **update_app** - Update an existing application
48 | 33. **view_app_api_keys** - View API keys for an app
49 | 34. **create_app_api_key** - Create a new API key
50 |
51 | ## API Key Management (3 tools) *(NEW)*
52 | 35. **delete_app_api_key** - Delete an API key
53 | 36. **update_app_api_key** - Update an API key
54 | 37. **rotate_app_api_key** - Rotate an API key
55 |
56 | ## Users (6 tools)
57 | 38. **create_user** - Create a new user
58 | 39. **view_user** - View user details
59 | 40. **update_user** - Update user information
60 | 41. **delete_user** - Delete a user
61 | 42. **view_user_identity** - Get user identity information
62 | 43. **view_user_identity_by_subscription** *(NEW)* - Get identity by subscription
63 |
64 | ## Aliases (3 tools)
65 | 44. **create_or_update_alias** - Create or update user alias
66 | 45. **delete_alias** - Delete a user alias
67 | 46. **create_alias_by_subscription** *(NEW)* - Create alias by subscription ID
68 |
69 | ## Subscriptions (5 tools)
70 | 47. **create_subscription** - Create a new subscription
71 | 48. **update_subscription** - Update a subscription
72 | 49. **delete_subscription** - Delete a subscription
73 | 50. **transfer_subscription** - Transfer subscription between users
74 | 51. **unsubscribe_email** - Unsubscribe using email token
75 |
76 | ## Live Activities (3 tools) *(NEW)*
77 | 52. **start_live_activity** - Start iOS Live Activity
78 | 53. **update_live_activity** - Update iOS Live Activity
79 | 54. **end_live_activity** - End iOS Live Activity
80 |
81 | ## Analytics & Export (3 tools) *(NEW)*
82 | 55. **view_outcomes** - View outcomes/conversion data
83 | 56. **export_players_csv** - Export player data to CSV
84 | 57. **export_messages_csv** - Export messages to CSV
85 |
86 | ## Summary by Category
87 | - **App Management**: 5 tools
88 | - **Messaging**: 8 tools (3 new)
89 | - **Devices/Players**: 6 tools (4 new)
90 | - **Segments**: 3 tools
91 | - **Templates**: 6 tools (3 new)
92 | - **Apps**: 6 tools
93 | - **API Keys**: 3 tools (all new)
94 | - **Users**: 6 tools (1 new)
95 | - **Aliases**: 3 tools (1 new)
96 | - **Subscriptions**: 5 tools
97 | - **Live Activities**: 3 tools (all new)
98 | - **Analytics**: 3 tools (all new)
99 |
100 | **Total**: 57 tools (21 newly added)
```
--------------------------------------------------------------------------------
/onesignal_refactored/tools/templates.py:
--------------------------------------------------------------------------------
```python
1 | """Template management tools for OneSignal MCP server."""
2 | from typing import Dict, Any, Optional
3 | from ..api_client import api_client
4 | from ..config import app_manager
5 |
6 |
7 | async def create_template(
8 | name: str,
9 | title: str,
10 | message: str,
11 | **kwargs
12 | ) -> Dict[str, Any]:
13 | """
14 | Create a new template in your OneSignal app.
15 |
16 | Args:
17 | name: Name of the template
18 | title: Title/heading of the template
19 | message: Content/message of the template
20 | **kwargs: Additional template parameters
21 | """
22 | app_config = app_manager.get_current_app()
23 | if not app_config:
24 | raise ValueError("No app currently selected")
25 |
26 | data = {
27 | "app_id": app_config.app_id,
28 | "name": name,
29 | "headings": {"en": title},
30 | "contents": {"en": message}
31 | }
32 |
33 | data.update(kwargs)
34 |
35 | return await api_client.request("templates", method="POST", data=data)
36 |
37 |
38 | async def update_template(
39 | template_id: str,
40 | name: Optional[str] = None,
41 | title: Optional[str] = None,
42 | message: Optional[str] = None,
43 | **kwargs
44 | ) -> Dict[str, Any]:
45 | """
46 | Update an existing template.
47 |
48 | Args:
49 | template_id: ID of the template to update
50 | name: New name for the template
51 | title: New title/heading for the template
52 | message: New content/message for the template
53 | **kwargs: Additional template parameters
54 | """
55 | data = {}
56 |
57 | if name:
58 | data["name"] = name
59 | if title:
60 | data["headings"] = {"en": title}
61 | if message:
62 | data["contents"] = {"en": message}
63 |
64 | data.update(kwargs)
65 |
66 | if not data:
67 | raise ValueError("No update parameters provided")
68 |
69 | return await api_client.request(
70 | f"templates/{template_id}",
71 | method="PATCH",
72 | data=data
73 | )
74 |
75 |
76 | async def view_templates() -> Dict[str, Any]:
77 | """List all templates available in your OneSignal app."""
78 | return await api_client.request("templates", method="GET")
79 |
80 |
81 | async def view_template_details(template_id: str) -> Dict[str, Any]:
82 | """
83 | Get detailed information about a specific template.
84 |
85 | Args:
86 | template_id: The ID of the template to retrieve
87 | """
88 | app_config = app_manager.get_current_app()
89 | if not app_config:
90 | raise ValueError("No app currently selected")
91 |
92 | params = {"app_id": app_config.app_id}
93 | return await api_client.request(
94 | f"templates/{template_id}",
95 | method="GET",
96 | params=params
97 | )
98 |
99 |
100 | async def delete_template(template_id: str) -> Dict[str, Any]:
101 | """
102 | Delete a template from your OneSignal app.
103 |
104 | Args:
105 | template_id: ID of the template to delete
106 | """
107 | return await api_client.request(
108 | f"templates/{template_id}",
109 | method="DELETE"
110 | )
111 |
112 |
113 | async def copy_template_to_app(
114 | template_id: str,
115 | target_app_id: str,
116 | new_name: Optional[str] = None
117 | ) -> Dict[str, Any]:
118 | """
119 | Copy a template to another OneSignal app.
120 |
121 | Args:
122 | template_id: ID of the template to copy
123 | target_app_id: ID of the app to copy the template to
124 | new_name: Optional new name for the copied template
125 | """
126 | data = {"app_id": target_app_id}
127 |
128 | if new_name:
129 | data["name"] = new_name
130 |
131 | return await api_client.request(
132 | f"templates/{template_id}/copy",
133 | method="POST",
134 | data=data
135 | )
136 |
137 |
138 | def format_template_list(templates_response: Dict[str, Any]) -> str:
139 | """Format template list response for display."""
140 | templates = templates_response.get("templates", [])
141 |
142 | if not templates:
143 | return "No templates found."
144 |
145 | output = "Templates:\n\n"
146 |
147 | for template in templates:
148 | output += f"ID: {template.get('id')}\n"
149 | output += f"Name: {template.get('name')}\n"
150 | output += f"Created: {template.get('created_at')}\n"
151 | output += f"Updated: {template.get('updated_at')}\n\n"
152 |
153 | return output
154 |
155 |
156 | def format_template_details(template: Dict[str, Any]) -> str:
157 | """Format template details for display."""
158 | heading = template.get("headings", {}).get("en", "No heading")
159 | content = template.get("contents", {}).get("en", "No content")
160 |
161 | details = [
162 | f"ID: {template.get('id')}",
163 | f"Name: {template.get('name')}",
164 | f"Title: {heading}",
165 | f"Message: {content}",
166 | f"Platform: {template.get('platform')}",
167 | f"Created: {template.get('created_at')}"
168 | ]
169 |
170 | return "\n".join(details)
```
--------------------------------------------------------------------------------
/test_api_key_validity.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python3
2 | """Test script to verify OneSignal API key validity"""
3 |
4 | import os
5 | import requests
6 | from dotenv import load_dotenv
7 |
8 | # Load environment variables
9 | load_dotenv()
10 |
11 | # Get credentials
12 | app_id = os.getenv("ONESIGNAL_MANDIBLE_APP_ID")
13 | api_key = os.getenv("ONESIGNAL_MANDIBLE_API_KEY")
14 |
15 | print("OneSignal API Key Validation Test")
16 | print("=" * 50)
17 |
18 | if not app_id or not api_key:
19 | print("❌ ERROR: Missing credentials!")
20 | print(" Make sure your .env file contains:")
21 | print(" ONESIGNAL_MANDIBLE_APP_ID=your_app_id")
22 | print(" ONESIGNAL_MANDIBLE_API_KEY=your_api_key")
23 | exit(1)
24 |
25 | print(f"App ID: {app_id}")
26 | print(f"API Key length: {len(api_key)}")
27 | print(f"API Key prefix: {api_key[:15]}...")
28 |
29 | # Determine API key type
30 | is_v2_key = api_key.startswith("os_v2_")
31 | print(f"API Key type: {'v2' if is_v2_key else 'v1'}")
32 |
33 | # Set up headers based on key type
34 | headers = {
35 | "Accept": "application/json",
36 | "Content-Type": "application/json"
37 | }
38 |
39 | if is_v2_key:
40 | headers["Authorization"] = f"Key {api_key}"
41 | print("Using Authorization: Key <api_key>")
42 | else:
43 | headers["Authorization"] = f"Basic {api_key}"
44 | print("Using Authorization: Basic <api_key>")
45 |
46 | print("\n" + "=" * 50)
47 | print("Testing API Key validity...")
48 | print("=" * 50)
49 |
50 | # Test 1: Get app details (requires valid API key)
51 | print("\n1. Testing app details endpoint (requires valid app-specific API key)...")
52 | url = f"https://api.onesignal.com/api/v1/apps/{app_id}"
53 | params = {} # No params needed for this endpoint
54 |
55 | try:
56 | response = requests.get(url, headers=headers, params=params)
57 | print(f" URL: {url}")
58 | print(f" Status: {response.status_code}")
59 |
60 | if response.status_code == 200:
61 | print(" ✅ SUCCESS: API key is valid!")
62 | data = response.json()
63 | print(f" App Name: {data.get('name', 'N/A')}")
64 | print(f" Created: {data.get('created_at', 'N/A')}")
65 | elif response.status_code == 401:
66 | print(" ❌ FAILED: Authentication error - API key is invalid or doesn't have permission")
67 | print(f" Response: {response.text}")
68 | elif response.status_code == 403:
69 | print(" ❌ FAILED: Forbidden - API key doesn't have permission for this app")
70 | print(f" Response: {response.text}")
71 | else:
72 | print(f" ❌ FAILED: Unexpected status code")
73 | print(f" Response: {response.text}")
74 | except Exception as e:
75 | print(f" ❌ ERROR: {e}")
76 |
77 | # Test 2: List notifications (requires valid API key with proper permissions)
78 | print("\n2. Testing notifications endpoint...")
79 | url = "https://api.onesignal.com/api/v1/notifications"
80 | params = {"app_id": app_id, "limit": 1}
81 |
82 | try:
83 | response = requests.get(url, headers=headers, params=params)
84 | print(f" URL: {url}")
85 | print(f" Params: {params}")
86 | print(f" Status: {response.status_code}")
87 |
88 | if response.status_code == 200:
89 | print(" ✅ SUCCESS: Can list notifications")
90 | data = response.json()
91 | print(f" Total notifications: {data.get('total_count', 0)}")
92 | else:
93 | print(f" ❌ FAILED: Status {response.status_code}")
94 | print(f" Response: {response.text[:200]}...")
95 | except Exception as e:
96 | print(f" ❌ ERROR: {e}")
97 |
98 | # Test 3: Check segments endpoint (the one causing issues)
99 | print("\n3. Testing segments endpoint (the problematic one)...")
100 | url = f"https://api.onesignal.com/api/v1/apps/{app_id}/segments"
101 |
102 | try:
103 | response = requests.get(url, headers=headers)
104 | print(f" URL: {url}")
105 | print(f" Status: {response.status_code}")
106 |
107 | if response.status_code == 200:
108 | print(" ✅ SUCCESS: Can access segments")
109 | data = response.json()
110 | print(f" Segments found: {len(data) if isinstance(data, list) else 'N/A'}")
111 | else:
112 | print(f" ❌ FAILED: Status {response.status_code}")
113 | print(f" Response: {response.text}")
114 | except Exception as e:
115 | print(f" ❌ ERROR: {e}")
116 |
117 | print("\n" + "=" * 50)
118 | print("Summary:")
119 | print("=" * 50)
120 |
121 | if api_key.startswith("os_v2_"):
122 | print("You're using a v2 API key (starts with 'os_v2_')")
123 | print("Make sure this key has the necessary permissions in OneSignal dashboard:")
124 | print("- View App Details")
125 | print("- View Notifications")
126 | print("- View Segments")
127 | print("\nTo check/update permissions:")
128 | print("1. Go to OneSignal Dashboard")
129 | print("2. Navigate to Settings > Keys & IDs")
130 | print("3. Find your REST API Key")
131 | print("4. Check the permissions assigned to it")
132 | else:
133 | print("You're using a v1 API key")
134 | print("Consider upgrading to a v2 API key for better security and permissions control")
```
--------------------------------------------------------------------------------
/onesignal_refactored/config.py:
--------------------------------------------------------------------------------
```python
1 | """Configuration management for OneSignal MCP server."""
2 | import os
3 | import logging
4 | from typing import Dict, Optional
5 | from dataclasses import dataclass
6 | from dotenv import load_dotenv
7 |
8 | # Load environment variables
9 | load_dotenv()
10 |
11 | # Configure logger
12 | logger = logging.getLogger("onesignal-mcp.config")
13 |
14 | # API Configuration
15 | ONESIGNAL_API_URL = "https://api.onesignal.com/api/v1"
16 | ONESIGNAL_ORG_API_KEY = os.getenv("ONESIGNAL_ORG_API_KEY", "")
17 |
18 |
19 | @dataclass
20 | class AppConfig:
21 | """Configuration for a OneSignal application."""
22 | app_id: str
23 | api_key: str
24 | name: str
25 |
26 | def __str__(self):
27 | return f"{self.name} ({self.app_id})"
28 |
29 |
30 | class AppManager:
31 | """Manages OneSignal app configurations."""
32 |
33 | def __init__(self):
34 | self.app_configs: Dict[str, AppConfig] = {}
35 | self.current_app_key: Optional[str] = None
36 | self._load_from_environment()
37 |
38 | def _load_from_environment(self):
39 | """Load app configurations from environment variables."""
40 | # Mandible app configuration
41 | mandible_app_id = os.getenv("ONESIGNAL_MANDIBLE_APP_ID", "") or os.getenv("ONESIGNAL_APP_ID", "")
42 | mandible_api_key = os.getenv("ONESIGNAL_MANDIBLE_API_KEY", "") or os.getenv("ONESIGNAL_API_KEY", "")
43 | if mandible_app_id and mandible_api_key:
44 | self.add_app("mandible", mandible_app_id, mandible_api_key, "Mandible")
45 | self.current_app_key = "mandible"
46 | logger.info(f"Mandible app configured with ID: {mandible_app_id}")
47 |
48 | # Weird Brains app configuration
49 | weirdbrains_app_id = os.getenv("ONESIGNAL_WEIRDBRAINS_APP_ID", "")
50 | weirdbrains_api_key = os.getenv("ONESIGNAL_WEIRDBRAINS_API_KEY", "")
51 | if weirdbrains_app_id and weirdbrains_api_key:
52 | self.add_app("weirdbrains", weirdbrains_app_id, weirdbrains_api_key, "Weird Brains")
53 | if not self.current_app_key:
54 | self.current_app_key = "weirdbrains"
55 | logger.info(f"Weird Brains app configured with ID: {weirdbrains_app_id}")
56 |
57 | # Fallback for default app configuration
58 | if not self.app_configs:
59 | default_app_id = os.getenv("ONESIGNAL_APP_ID", "")
60 | default_api_key = os.getenv("ONESIGNAL_API_KEY", "")
61 | if default_app_id and default_api_key:
62 | self.add_app("default", default_app_id, default_api_key, "Default App")
63 | self.current_app_key = "default"
64 | logger.info(f"Default app configured with ID: {default_app_id}")
65 | else:
66 | logger.warning("No app configurations found. Use add_app to add an app configuration.")
67 |
68 | def add_app(self, key: str, app_id: str, api_key: str, name: Optional[str] = None) -> None:
69 | """Add a new app configuration."""
70 | self.app_configs[key] = AppConfig(app_id, api_key, name or key)
71 | logger.info(f"Added app configuration '{key}' with ID: {app_id}")
72 |
73 | def update_app(self, key: str, app_id: Optional[str] = None,
74 | api_key: Optional[str] = None, name: Optional[str] = None) -> bool:
75 | """Update an existing app configuration."""
76 | if key not in self.app_configs:
77 | return False
78 |
79 | app = self.app_configs[key]
80 | if app_id:
81 | app.app_id = app_id
82 | if api_key:
83 | app.api_key = api_key
84 | if name:
85 | app.name = name
86 |
87 | logger.info(f"Updated app configuration '{key}'")
88 | return True
89 |
90 | def remove_app(self, key: str) -> bool:
91 | """Remove an app configuration."""
92 | if key not in self.app_configs:
93 | return False
94 |
95 | if self.current_app_key == key:
96 | # Switch to another app if available
97 | other_keys = [k for k in self.app_configs.keys() if k != key]
98 | self.current_app_key = other_keys[0] if other_keys else None
99 |
100 | del self.app_configs[key]
101 | logger.info(f"Removed app configuration '{key}'")
102 | return True
103 |
104 | def set_current_app(self, key: str) -> bool:
105 | """Set the current app to use for API requests."""
106 | if key in self.app_configs:
107 | self.current_app_key = key
108 | logger.info(f"Switched to app '{key}'")
109 | return True
110 | return False
111 |
112 | def get_current_app(self) -> Optional[AppConfig]:
113 | """Get the current app configuration."""
114 | if self.current_app_key and self.current_app_key in self.app_configs:
115 | return self.app_configs[self.current_app_key]
116 | return None
117 |
118 | def get_app(self, key: str) -> Optional[AppConfig]:
119 | """Get a specific app configuration."""
120 | return self.app_configs.get(key)
121 |
122 | def list_apps(self) -> Dict[str, AppConfig]:
123 | """Get all app configurations."""
124 | return self.app_configs.copy()
125 |
126 |
127 | # Global app manager instance
128 | app_manager = AppManager()
129 |
130 |
131 | def requires_org_api_key(endpoint: str) -> bool:
132 | """Determine if an endpoint requires the Organization API Key."""
133 | org_level_endpoints = [
134 | "apps", # Managing apps
135 | "players/csv_export", # Export users
136 | "notifications/csv_export" # Export notifications
137 | ]
138 |
139 | return any(endpoint == ep or endpoint.startswith(f"{ep}/") for ep in org_level_endpoints)
```
--------------------------------------------------------------------------------
/onesignal_refactoring_summary.md:
--------------------------------------------------------------------------------
```markdown
1 | # OneSignal MCP Server Refactoring Summary
2 |
3 | ## Overview
4 | This document summarizes the analysis of the OneSignal MCP server implementation against the official OneSignal REST API documentation and provides a comprehensive refactoring plan.
5 |
6 | ## 1. Missing API Endpoints Analysis
7 |
8 | ### High Priority Missing Endpoints
9 |
10 | #### Messaging
11 | - **Email-specific endpoint** - Send emails with HTML content and templates
12 | - **SMS-specific endpoint** - Send SMS/MMS messages
13 | - **Transactional Messages** - Immediate delivery messages without scheduling
14 |
15 | #### Live Activities (iOS)
16 | - **Start Live Activity** - Initialize iOS Live Activities
17 | - **Update Live Activity** - Update running Live Activities
18 | - **End Live Activity** - Terminate Live Activities
19 |
20 | #### Template Management
21 | - **Update Template** - Modify existing templates
22 | - **Delete Template** - Remove templates
23 | - **Copy Template** - Duplicate templates across apps
24 |
25 | #### API Key Management
26 | - **Delete API Key** - Remove API keys
27 | - **Update API Key** - Modify API key permissions
28 | - **Rotate API Key** - Generate new key while maintaining permissions
29 |
30 | #### Analytics & Export
31 | - **View Outcomes** - Track conversion metrics
32 | - **Export Subscriptions CSV** - Export user data
33 | - **Export Audience Activity CSV** - Export event data
34 |
35 | #### Player/Device Management (Legacy)
36 | - **Add Player** - Register new devices
37 | - **Edit Player** - Update device information
38 | - **Edit Tags with External User ID** - Bulk tag updates
39 | - **Delete Player Record** - Remove device records
40 |
41 | ## 2. Refactored Architecture
42 |
43 | ### New Module Structure
44 | ```
45 | onesignal_refactored/
46 | ├── __init__.py
47 | ├── config.py # App configuration management
48 | ├── api_client.py # API request handling
49 | ├── tools/
50 | │ ├── __init__.py
51 | │ ├── messages.py # All messaging endpoints
52 | │ ├── templates.py # Template management
53 | │ ├── live_activities.py # iOS Live Activities
54 | │ ├── analytics.py # Outcomes and exports
55 | │ ├── users.py # User management
56 | │ ├── devices.py # Device/player management
57 | │ ├── segments.py # Segment management
58 | │ ├── apps.py # App management
59 | │ └── subscriptions.py # Subscription management
60 | └── server.py # Main MCP server entry point
61 | ```
62 |
63 | ### Key Improvements
64 |
65 | #### 1. Centralized Configuration (`config.py`)
66 | - `AppConfig` dataclass for app configurations
67 | - `AppManager` class for managing multiple apps
68 | - Environment variable loading
69 | - Automatic organization API key detection
70 |
71 | #### 2. Unified API Client (`api_client.py`)
72 | - `OneSignalAPIClient` class with centralized request handling
73 | - Automatic authentication method selection
74 | - Consistent error handling with custom exceptions
75 | - Request/response logging
76 |
77 | #### 3. Modular Tool Organization
78 | Each module focuses on a specific API domain:
79 | - Better code organization
80 | - Easier maintenance
81 | - Clear separation of concerns
82 | - Reduced code duplication
83 |
84 | ### 3. New Features Implementation
85 |
86 | #### Email Sending
87 | ```python
88 | async def send_email(
89 | subject: str,
90 | body: str,
91 | email_body: Optional[str] = None,
92 | segments: Optional[List[str]] = None,
93 | include_emails: Optional[List[str]] = None,
94 | external_ids: Optional[List[str]] = None,
95 | template_id: Optional[str] = None
96 | ) -> Dict[str, Any]
97 | ```
98 |
99 | #### SMS Sending
100 | ```python
101 | async def send_sms(
102 | message: str,
103 | phone_numbers: Optional[List[str]] = None,
104 | segments: Optional[List[str]] = None,
105 | external_ids: Optional[List[str]] = None,
106 | media_url: Optional[str] = None
107 | ) -> Dict[str, Any]
108 | ```
109 |
110 | #### Transactional Messages
111 | ```python
112 | async def send_transactional_message(
113 | channel: str,
114 | content: Dict[str, str],
115 | recipients: Dict[str, Any],
116 | template_id: Optional[str] = None,
117 | custom_data: Optional[Dict[str, Any]] = None
118 | ) -> Dict[str, Any]
119 | ```
120 |
121 | #### Live Activities
122 | ```python
123 | async def start_live_activity(...)
124 | async def update_live_activity(...)
125 | async def end_live_activity(...)
126 | ```
127 |
128 | #### Analytics & Outcomes
129 | ```python
130 | async def view_outcomes(...)
131 | async def export_players_csv(...)
132 | async def export_audience_activity_csv(...)
133 | ```
134 |
135 | ## 4. Migration Guide
136 |
137 | ### Step 1: Create New Directory Structure
138 | ```bash
139 | mkdir -p onesignal_refactored/tools
140 | ```
141 |
142 | ### Step 2: Implement Core Modules
143 | 1. Copy `config.py` for app configuration
144 | 2. Copy `api_client.py` for API requests
145 | 3. Implement tool modules based on provided templates
146 |
147 | ### Step 3: Update MCP Server Registration
148 | ```python
149 | # In server.py
150 | from mcp.server.fastmcp import FastMCP
151 | from .tools import messages, templates, live_activities, analytics
152 |
153 | mcp = FastMCP("onesignal-server")
154 |
155 | # Register all tools
156 | @mcp.tool()
157 | async def send_email(...):
158 | return await messages.send_email(...)
159 |
160 | # Continue for all tools...
161 | ```
162 |
163 | ### Step 4: Test Implementation
164 | 1. Test each new endpoint individually
165 | 2. Verify error handling
166 | 3. Check authentication switching
167 | 4. Validate response formatting
168 |
169 | ## 5. Benefits of Refactoring
170 |
171 | 1. **Better Maintainability** - Modular structure makes updates easier
172 | 2. **Reduced Duplication** - Shared API client eliminates repeated code
173 | 3. **Enhanced Error Handling** - Consistent error messages and logging
174 | 4. **Feature Completeness** - Support for all major OneSignal features
175 | 5. **Improved Testing** - Easier to unit test individual modules
176 | 6. **Better Documentation** - Clear module boundaries and responsibilities
177 |
178 | ## 6. Future Enhancements
179 |
180 | 1. **Async/Await Optimization** - Better concurrency handling
181 | 2. **Response Caching** - Cache frequently accessed data
182 | 3. **Batch Operations** - Support bulk operations where applicable
183 | 4. **Webhook Support** - Add webhook configuration endpoints
184 | 5. **In-App Messaging** - Support for in-app message management
185 | 6. **Rate Limiting** - Implement client-side rate limiting
186 | 7. **Retry Logic** - Automatic retry for failed requests
187 |
188 | ## Implementation Priority
189 |
190 | 1. **Phase 1** - Core refactoring (config, api_client)
191 | 2. **Phase 2** - Missing messaging endpoints (email, SMS, transactional)
192 | 3. **Phase 3** - Template and Live Activity completion
193 | 4. **Phase 4** - Analytics and export functionality
194 | 5. **Phase 5** - Legacy player/device endpoints
195 |
196 | This refactoring will transform the OneSignal MCP server into a comprehensive, maintainable solution that supports all major OneSignal API features.
```
--------------------------------------------------------------------------------
/onesignal_refactored/api_client.py:
--------------------------------------------------------------------------------
```python
1 | """API client for OneSignal REST API requests."""
2 | import logging
3 | import requests
4 | from typing import Dict, Any, Optional
5 | from .config import (
6 | ONESIGNAL_API_URL,
7 | ONESIGNAL_ORG_API_KEY,
8 | app_manager,
9 | requires_org_api_key
10 | )
11 |
12 | logger = logging.getLogger("onesignal-mcp.api_client")
13 |
14 |
15 | class OneSignalAPIError(Exception):
16 | """Custom exception for OneSignal API errors."""
17 | pass
18 |
19 |
20 | class OneSignalAPIClient:
21 | """Client for making requests to the OneSignal API."""
22 |
23 | def __init__(self):
24 | self.api_url = ONESIGNAL_API_URL
25 | self.timeout = 30
26 |
27 | async def request(
28 | self,
29 | endpoint: str,
30 | method: str = "GET",
31 | data: Optional[Dict[str, Any]] = None,
32 | params: Optional[Dict[str, Any]] = None,
33 | use_org_key: Optional[bool] = None,
34 | app_key: Optional[str] = None
35 | ) -> Dict[str, Any]:
36 | """
37 | Make a request to the OneSignal API with proper authentication.
38 |
39 | Args:
40 | endpoint: API endpoint path
41 | method: HTTP method (GET, POST, PUT, DELETE, PATCH)
42 | data: Request body for POST/PUT/PATCH requests
43 | params: Query parameters for GET requests
44 | use_org_key: Whether to use the organization API key
45 | app_key: The key of the app configuration to use
46 |
47 | Returns:
48 | API response as dictionary
49 |
50 | Raises:
51 | OneSignalAPIError: If the API request fails
52 | """
53 | headers = {
54 | "Content-Type": "application/json",
55 | "Accept": "application/json",
56 | }
57 |
58 | # Determine authentication method
59 | if use_org_key is None:
60 | use_org_key = requires_org_api_key(endpoint)
61 |
62 | # Set authentication header
63 | if use_org_key:
64 | if not ONESIGNAL_ORG_API_KEY:
65 | raise OneSignalAPIError(
66 | "Organization API Key not configured. "
67 | "Set the ONESIGNAL_ORG_API_KEY environment variable."
68 | )
69 | headers["Authorization"] = f"Basic {ONESIGNAL_ORG_API_KEY}"
70 | else:
71 | # Get app configuration
72 | app_config = None
73 | if app_key:
74 | app_config = app_manager.get_app(app_key)
75 | else:
76 | app_config = app_manager.get_current_app()
77 |
78 | if not app_config:
79 | raise OneSignalAPIError(
80 | "No app configuration available. "
81 | "Use set_current_app or specify app_key."
82 | )
83 |
84 | headers["Authorization"] = f"Basic {app_config.api_key}"
85 |
86 | # Add app_id to params/data if needed
87 | if params is None:
88 | params = {}
89 | if "app_id" not in params and not endpoint.startswith("apps/"):
90 | params["app_id"] = app_config.app_id
91 |
92 | if data is not None and method in ["POST", "PUT", "PATCH"]:
93 | if "app_id" not in data and not endpoint.startswith("apps/"):
94 | data["app_id"] = app_config.app_id
95 |
96 | url = f"{self.api_url}/{endpoint}"
97 |
98 | try:
99 | logger.debug(f"Making {method} request to {url}")
100 | logger.debug(f"Using {'Organization API Key' if use_org_key else 'App REST API Key'}")
101 |
102 | response = self._make_request(method, url, headers, params, data)
103 | response.raise_for_status()
104 |
105 | return response.json() if response.text else {}
106 |
107 | except requests.exceptions.HTTPError as e:
108 | error_message = self._extract_error_message(e)
109 | logger.error(f"API request failed: {error_message}")
110 | raise OneSignalAPIError(error_message) from e
111 | except requests.exceptions.RequestException as e:
112 | error_message = f"Request failed: {str(e)}"
113 | logger.error(error_message)
114 | raise OneSignalAPIError(error_message) from e
115 | except Exception as e:
116 | error_message = f"Unexpected error: {str(e)}"
117 | logger.exception(error_message)
118 | raise OneSignalAPIError(error_message) from e
119 |
120 | def _make_request(
121 | self,
122 | method: str,
123 | url: str,
124 | headers: Dict[str, str],
125 | params: Optional[Dict[str, Any]],
126 | data: Optional[Dict[str, Any]]
127 | ) -> requests.Response:
128 | """Make the actual HTTP request."""
129 | method = method.upper()
130 |
131 | if method == "GET":
132 | return requests.get(url, headers=headers, params=params, timeout=self.timeout)
133 | elif method == "POST":
134 | return requests.post(url, headers=headers, json=data, timeout=self.timeout)
135 | elif method == "PUT":
136 | return requests.put(url, headers=headers, json=data, timeout=self.timeout)
137 | elif method == "DELETE":
138 | return requests.delete(url, headers=headers, timeout=self.timeout)
139 | elif method == "PATCH":
140 | return requests.patch(url, headers=headers, json=data, timeout=self.timeout)
141 | else:
142 | raise ValueError(f"Unsupported HTTP method: {method}")
143 |
144 | def _extract_error_message(self, error: requests.exceptions.HTTPError) -> str:
145 | """Extract a meaningful error message from the HTTP error."""
146 | try:
147 | if hasattr(error, 'response') and error.response is not None:
148 | error_data = error.response.json()
149 | if isinstance(error_data, dict):
150 | # Try different error message formats
151 | if 'errors' in error_data:
152 | errors = error_data['errors']
153 | if isinstance(errors, list) and errors:
154 | return f"Error: {errors[0]}"
155 | elif isinstance(errors, str):
156 | return f"Error: {errors}"
157 | elif 'error' in error_data:
158 | return f"Error: {error_data['error']}"
159 | elif 'message' in error_data:
160 | return f"Error: {error_data['message']}"
161 | return f"Error: {error.response.reason} (Status: {error.response.status_code})"
162 | except Exception:
163 | pass
164 | return f"Error: {str(error)}"
165 |
166 |
167 | # Global API client instance
168 | api_client = OneSignalAPIClient()
```
--------------------------------------------------------------------------------
/tests/test_onesignal_server.py:
--------------------------------------------------------------------------------
```python
1 | import unittest
2 | from unittest.mock import patch, MagicMock
3 | import os
4 | import sys
5 | import json
6 | import asyncio
7 |
8 | # Add the parent directory to sys.path to import the server module
9 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
10 |
11 | # Import the server module
12 | import onesignal_server
13 |
14 | class TestOneSignalServer(unittest.TestCase):
15 | """Test cases for the OneSignal MCP server."""
16 |
17 | def setUp(self):
18 | """Set up test environment before each test."""
19 | # Mock environment variables
20 | self.env_patcher = patch.dict('os.environ', {
21 | 'ONESIGNAL_APP_ID': 'test-app-id',
22 | 'ONESIGNAL_API_KEY': 'test-api-key',
23 | 'ONESIGNAL_ORG_API_KEY': 'test-org-api-key'
24 | })
25 | self.env_patcher.start()
26 |
27 | # Reset app configurations for each test
28 | onesignal_server.app_configs = {}
29 | onesignal_server.current_app_key = None
30 |
31 | # Initialize with test app
32 | onesignal_server.add_app_config('test', 'test-app-id', 'test-api-key', 'Test App')
33 | onesignal_server.current_app_key = 'test'
34 |
35 | def tearDown(self):
36 | """Clean up after each test."""
37 | self.env_patcher.stop()
38 |
39 | def test_app_config(self):
40 | """Test AppConfig class."""
41 | app = onesignal_server.AppConfig('app-id', 'api-key', 'App Name')
42 | self.assertEqual(app.app_id, 'app-id')
43 | self.assertEqual(app.api_key, 'api-key')
44 | self.assertEqual(app.name, 'App Name')
45 | self.assertEqual(str(app), 'App Name (app-id)')
46 |
47 | def test_add_app_config(self):
48 | """Test adding app configurations."""
49 | onesignal_server.add_app_config('new-app', 'new-app-id', 'new-api-key', 'New App')
50 | self.assertIn('new-app', onesignal_server.app_configs)
51 | self.assertEqual(onesignal_server.app_configs['new-app'].app_id, 'new-app-id')
52 | self.assertEqual(onesignal_server.app_configs['new-app'].api_key, 'new-api-key')
53 | self.assertEqual(onesignal_server.app_configs['new-app'].name, 'New App')
54 |
55 | def test_set_current_app(self):
56 | """Test setting the current app."""
57 | # Add a second app
58 | onesignal_server.add_app_config('second', 'second-app-id', 'second-api-key')
59 |
60 | # Test switching to an existing app
61 | result = onesignal_server.set_current_app('second')
62 | self.assertTrue(result)
63 | self.assertEqual(onesignal_server.current_app_key, 'second')
64 |
65 | # Test switching to a non-existent app
66 | result = onesignal_server.set_current_app('non-existent')
67 | self.assertFalse(result)
68 | self.assertEqual(onesignal_server.current_app_key, 'second') # Should not change
69 |
70 | def test_get_current_app(self):
71 | """Test getting the current app configuration."""
72 | current_app = onesignal_server.get_current_app()
73 | self.assertIsNotNone(current_app)
74 | self.assertEqual(current_app.app_id, 'test-app-id')
75 | self.assertEqual(current_app.api_key, 'test-api-key')
76 |
77 | # Test with no current app
78 | onesignal_server.current_app_key = None
79 | current_app = onesignal_server.get_current_app()
80 | self.assertIsNone(current_app)
81 |
82 | @patch('requests.get')
83 | def test_make_onesignal_request_get(self, mock_get):
84 | """Test making a GET request to the OneSignal API."""
85 | # Mock the response
86 | mock_response = MagicMock()
87 | mock_response.json.return_value = {'success': True}
88 | mock_response.text = json.dumps({'success': True})
89 | mock_get.return_value = mock_response
90 |
91 | # Make the request and run it through the event loop
92 | loop = asyncio.new_event_loop()
93 | asyncio.set_event_loop(loop)
94 | try:
95 | result = loop.run_until_complete(
96 | onesignal_server.make_onesignal_request('notifications', 'GET', params={'limit': 10})
97 | )
98 |
99 | # Check that the request was made correctly
100 | mock_get.assert_called_once()
101 | args, kwargs = mock_get.call_args
102 | self.assertEqual(kwargs['headers']['Authorization'], 'Key test-api-key')
103 | self.assertEqual(kwargs['params']['app_id'], 'test-app-id')
104 | self.assertEqual(kwargs['params']['limit'], 10)
105 |
106 | # Check the result
107 | self.assertEqual(result, {'success': True})
108 | finally:
109 | loop.close()
110 |
111 | @patch('requests.post')
112 | def test_make_onesignal_request_post(self, mock_post):
113 | """Test making a POST request to the OneSignal API."""
114 | # Mock the response
115 | mock_response = MagicMock()
116 | mock_response.json.return_value = {'id': 'notification-id'}
117 | mock_response.text = json.dumps({'id': 'notification-id'})
118 | mock_post.return_value = mock_response
119 |
120 | # Make the request
121 | data = {'contents': {'en': 'Test message'}}
122 | loop = asyncio.new_event_loop()
123 | asyncio.set_event_loop(loop)
124 | try:
125 | result = loop.run_until_complete(
126 | onesignal_server.make_onesignal_request('notifications', 'POST', data=data)
127 | )
128 |
129 | # Check that the request was made correctly
130 | mock_post.assert_called_once()
131 | args, kwargs = mock_post.call_args
132 | self.assertEqual(kwargs['headers']['Authorization'], 'Key test-api-key')
133 | self.assertEqual(kwargs['json']['app_id'], 'test-app-id')
134 | self.assertEqual(kwargs['json']['contents']['en'], 'Test message')
135 |
136 | # Check the result
137 | self.assertEqual(result, {'id': 'notification-id'})
138 | finally:
139 | loop.close()
140 |
141 | @patch('requests.get')
142 | @patch('onesignal_server.ONESIGNAL_ORG_API_KEY', 'test-org-api-key')
143 | def test_make_onesignal_request_with_org_key(self, mock_get):
144 | """Test making a request with the organization API key."""
145 | # Mock the response
146 | mock_response = MagicMock()
147 | mock_response.json.return_value = {'apps': []}
148 | mock_response.text = json.dumps({'apps': []})
149 | mock_get.return_value = mock_response
150 |
151 | # Make the request
152 | loop = asyncio.new_event_loop()
153 | asyncio.set_event_loop(loop)
154 | try:
155 | result = loop.run_until_complete(
156 | onesignal_server.make_onesignal_request('apps', 'GET', use_org_key=True)
157 | )
158 |
159 | # Check that the request was made correctly
160 | mock_get.assert_called_once()
161 | args, kwargs = mock_get.call_args
162 | self.assertEqual(kwargs['headers']['Authorization'], 'Key test-org-api-key')
163 |
164 | # Check the result
165 | self.assertEqual(result, {'apps': []})
166 | finally:
167 | loop.close()
168 |
169 | @patch('requests.get')
170 | def test_make_onesignal_request_error_handling(self, mock_get):
171 | """Test error handling in make_onesignal_request."""
172 | # Mock a request exception
173 | mock_get.side_effect = Exception('Test error')
174 |
175 | # Make the request
176 | loop = asyncio.new_event_loop()
177 | asyncio.set_event_loop(loop)
178 | try:
179 | result = loop.run_until_complete(
180 | onesignal_server.make_onesignal_request('notifications')
181 | )
182 |
183 | # Check the result
184 | self.assertIn('error', result)
185 | self.assertEqual(result['error'], 'Unexpected error: Test error')
186 | finally:
187 | loop.close()
188 |
189 | if __name__ == '__main__':
190 | unittest.main()
191 |
```
--------------------------------------------------------------------------------
/onesignal_refactored/tools/messages.py:
--------------------------------------------------------------------------------
```python
1 | """Message management tools for OneSignal MCP server."""
2 | import json
3 | from typing import List, Dict, Any, Optional
4 | from ..api_client import api_client, OneSignalAPIError
5 | from ..config import app_manager
6 |
7 |
8 | async def send_push_notification(
9 | title: str,
10 | message: str,
11 | segments: Optional[List[str]] = None,
12 | include_player_ids: Optional[List[str]] = None,
13 | external_ids: Optional[List[str]] = None,
14 | data: Optional[Dict[str, Any]] = None,
15 | **kwargs
16 | ) -> Dict[str, Any]:
17 | """
18 | Send a push notification through OneSignal.
19 |
20 | Args:
21 | title: Notification title
22 | message: Notification message content
23 | segments: List of segments to include
24 | include_player_ids: List of specific player IDs to target
25 | external_ids: List of external user IDs to target
26 | data: Additional data to include with the notification
27 | **kwargs: Additional notification parameters
28 | """
29 | notification_data = {
30 | "contents": {"en": message},
31 | "headings": {"en": title},
32 | "target_channel": "push"
33 | }
34 |
35 | # Set targeting
36 | if not any([segments, include_player_ids, external_ids]):
37 | segments = ["Subscribed Users"]
38 |
39 | if segments:
40 | notification_data["included_segments"] = segments
41 | if include_player_ids:
42 | notification_data["include_player_ids"] = include_player_ids
43 | if external_ids:
44 | notification_data["include_external_user_ids"] = external_ids
45 |
46 | if data:
47 | notification_data["data"] = data
48 |
49 | # Add any additional parameters
50 | notification_data.update(kwargs)
51 |
52 | return await api_client.request("notifications", method="POST", data=notification_data)
53 |
54 |
55 | async def send_email(
56 | subject: str,
57 | body: str,
58 | email_body: Optional[str] = None,
59 | segments: Optional[List[str]] = None,
60 | include_emails: Optional[List[str]] = None,
61 | external_ids: Optional[List[str]] = None,
62 | template_id: Optional[str] = None,
63 | **kwargs
64 | ) -> Dict[str, Any]:
65 | """
66 | Send an email through OneSignal.
67 |
68 | Args:
69 | subject: Email subject line
70 | body: Plain text email content
71 | email_body: HTML email content (optional)
72 | segments: List of segments to include
73 | include_emails: List of specific email addresses to target
74 | external_ids: List of external user IDs to target
75 | template_id: Email template ID to use
76 | **kwargs: Additional email parameters
77 | """
78 | email_data = {
79 | "email_subject": subject,
80 | "email_body": email_body or body,
81 | "target_channel": "email"
82 | }
83 |
84 | # Set targeting
85 | if include_emails:
86 | email_data["include_emails"] = include_emails
87 | elif external_ids:
88 | email_data["include_external_user_ids"] = external_ids
89 | elif segments:
90 | email_data["included_segments"] = segments
91 | else:
92 | email_data["included_segments"] = ["Subscribed Users"]
93 |
94 | if template_id:
95 | email_data["template_id"] = template_id
96 |
97 | # Add any additional parameters
98 | email_data.update(kwargs)
99 |
100 | return await api_client.request("notifications", method="POST", data=email_data)
101 |
102 |
103 | async def send_sms(
104 | message: str,
105 | phone_numbers: Optional[List[str]] = None,
106 | segments: Optional[List[str]] = None,
107 | external_ids: Optional[List[str]] = None,
108 | media_url: Optional[str] = None,
109 | **kwargs
110 | ) -> Dict[str, Any]:
111 | """
112 | Send an SMS through OneSignal.
113 |
114 | Args:
115 | message: SMS message content
116 | phone_numbers: List of phone numbers in E.164 format
117 | segments: List of segments to include
118 | external_ids: List of external user IDs to target
119 | media_url: URL for MMS media attachment
120 | **kwargs: Additional SMS parameters
121 | """
122 | sms_data = {
123 | "contents": {"en": message},
124 | "target_channel": "sms"
125 | }
126 |
127 | # Set targeting
128 | if phone_numbers:
129 | sms_data["include_phone_numbers"] = phone_numbers
130 | elif external_ids:
131 | sms_data["include_external_user_ids"] = external_ids
132 | elif segments:
133 | sms_data["included_segments"] = segments
134 | else:
135 | raise OneSignalAPIError(
136 | "SMS requires phone_numbers, external_ids, or segments to be specified"
137 | )
138 |
139 | if media_url:
140 | sms_data["mms_media_url"] = media_url
141 |
142 | # Add any additional parameters
143 | sms_data.update(kwargs)
144 |
145 | return await api_client.request("notifications", method="POST", data=sms_data)
146 |
147 |
148 | async def send_transactional_message(
149 | channel: str,
150 | content: Dict[str, str],
151 | recipients: Dict[str, Any],
152 | template_id: Optional[str] = None,
153 | custom_data: Optional[Dict[str, Any]] = None,
154 | **kwargs
155 | ) -> Dict[str, Any]:
156 | """
157 | Send a transactional message (immediate delivery, no scheduling).
158 |
159 | Args:
160 | channel: Channel to send on ("push", "email", "sms")
161 | content: Message content (format depends on channel)
162 | recipients: Targeting information
163 | template_id: Template ID to use
164 | custom_data: Custom data to include
165 | **kwargs: Additional parameters
166 | """
167 | message_data = {
168 | "target_channel": channel,
169 | "is_transactional": True
170 | }
171 |
172 | # Set content based on channel
173 | if channel == "email":
174 | message_data["email_subject"] = content.get("subject", "")
175 | message_data["email_body"] = content.get("body", "")
176 | else:
177 | message_data["contents"] = content
178 |
179 | # Set recipients
180 | message_data.update(recipients)
181 |
182 | if template_id:
183 | message_data["template_id"] = template_id
184 |
185 | if custom_data:
186 | message_data["data"] = custom_data
187 |
188 | # Add any additional parameters
189 | message_data.update(kwargs)
190 |
191 | return await api_client.request("notifications", method="POST", data=message_data)
192 |
193 |
194 | async def view_messages(
195 | limit: int = 20,
196 | offset: int = 0,
197 | kind: Optional[int] = None
198 | ) -> Dict[str, Any]:
199 | """
200 | View recent messages sent through OneSignal.
201 |
202 | Args:
203 | limit: Maximum number of messages to return (max: 50)
204 | offset: Result offset for pagination
205 | kind: Filter by message type (0=Dashboard, 1=API, 3=Automated)
206 | """
207 | params = {"limit": min(limit, 50), "offset": offset}
208 | if kind is not None:
209 | params["kind"] = kind
210 |
211 | return await api_client.request("notifications", method="GET", params=params)
212 |
213 |
214 | async def view_message_details(message_id: str) -> Dict[str, Any]:
215 | """Get detailed information about a specific message."""
216 | return await api_client.request(f"notifications/{message_id}", method="GET")
217 |
218 |
219 | async def cancel_message(message_id: str) -> Dict[str, Any]:
220 | """Cancel a scheduled message that hasn't been delivered yet."""
221 | return await api_client.request(f"notifications/{message_id}", method="DELETE")
222 |
223 |
224 | async def view_message_history(message_id: str, event: str) -> Dict[str, Any]:
225 | """
226 | View the history/recipients of a message based on events.
227 |
228 | Args:
229 | message_id: The ID of the message
230 | event: The event type to track (e.g., 'sent', 'clicked')
231 | """
232 | app_config = app_manager.get_current_app()
233 | if not app_config:
234 | raise OneSignalAPIError("No app currently selected")
235 |
236 | data = {
237 | "app_id": app_config.app_id,
238 | "events": event,
239 | "email": f"{app_config.name}[email protected]"
240 | }
241 |
242 | return await api_client.request(
243 | f"notifications/{message_id}/history",
244 | method="POST",
245 | data=data
246 | )
247 |
248 |
249 | async def export_messages_csv(
250 | start_date: Optional[str] = None,
251 | end_date: Optional[str] = None,
252 | **kwargs
253 | ) -> Dict[str, Any]:
254 | """
255 | Export messages to CSV.
256 |
257 | Args:
258 | start_date: Start date for export (ISO 8601 format)
259 | end_date: End date for export (ISO 8601 format)
260 | **kwargs: Additional export parameters
261 | """
262 | data = {}
263 | if start_date:
264 | data["start_date"] = start_date
265 | if end_date:
266 | data["end_date"] = end_date
267 |
268 | data.update(kwargs)
269 |
270 | return await api_client.request(
271 | "notifications/csv_export",
272 | method="POST",
273 | data=data,
274 | use_org_key=True
275 | )
```
--------------------------------------------------------------------------------
/test_onesignal_mcp.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python3
2 | """
3 | Test script for OneSignal MCP Server
4 | This script tests all the available functions in the OneSignal MCP server.
5 |
6 | Usage:
7 | 1. Set up your environment variables in .env file:
8 | - ONESIGNAL_APP_ID
9 | - ONESIGNAL_API_KEY
10 | - ONESIGNAL_ORG_API_KEY
11 |
12 | 2. Run the OneSignal MCP server:
13 | python onesignal_server.py
14 |
15 | 3. In another terminal, run this test script:
16 | python test_onesignal_mcp.py
17 | """
18 |
19 | import asyncio
20 | import json
21 | import sys
22 | from datetime import datetime
23 | from typing import Dict, Any, List
24 |
25 | # Test configuration
26 | TEST_CONFIG = {
27 | "test_email": "[email protected]",
28 | "test_phone": "+15551234567", # E.164 format
29 | "test_external_id": "test_user_123",
30 | "test_player_id": None, # Will be populated during tests
31 | "test_template_id": None, # Will be populated during tests
32 | "test_user_id": None, # Will be populated during tests
33 | "test_subscription_id": None, # Will be populated during tests
34 | }
35 |
36 | # Test results tracking
37 | test_results = {
38 | "passed": 0,
39 | "failed": 0,
40 | "skipped": 0,
41 | "errors": []
42 | }
43 |
44 | def print_test_header(test_name: str):
45 | """Print a formatted test header."""
46 | print(f"\n{'='*60}")
47 | print(f"Testing: {test_name}")
48 | print(f"{'='*60}")
49 |
50 | def print_result(success: bool, message: str, result: Any = None):
51 | """Print test result with formatting."""
52 | if success:
53 | print(f"✅ {message}")
54 | test_results["passed"] += 1
55 | else:
56 | print(f"❌ {message}")
57 | test_results["failed"] += 1
58 | if result:
59 | test_results["errors"].append({
60 | "test": message,
61 | "error": result
62 | })
63 |
64 | if result and isinstance(result, dict):
65 | print(f" Response: {json.dumps(result, indent=2)}")
66 |
67 | async def test_app_management():
68 | """Test app management functions."""
69 | print_test_header("App Management")
70 |
71 | # Test listing apps
72 | print("\n1. Testing list_apps...")
73 | # Simulate function call - in real MCP, this would be via the MCP protocol
74 | # For testing, you'll need to call these through the MCP client
75 | print(" ⚠️ App management tests require MCP client implementation")
76 | test_results["skipped"] += 1
77 |
78 | async def test_messaging():
79 | """Test messaging functions."""
80 | print_test_header("Messaging Functions")
81 |
82 | tests = [
83 | {
84 | "name": "Send Push Notification",
85 | "function": "send_push_notification",
86 | "params": {
87 | "title": "Test Push",
88 | "message": "This is a test push notification",
89 | "segments": ["Subscribed Users"]
90 | }
91 | },
92 | {
93 | "name": "Send Email",
94 | "function": "send_email",
95 | "params": {
96 | "subject": "Test Email",
97 | "body": "This is a test email",
98 | "include_emails": [TEST_CONFIG["test_email"]]
99 | }
100 | },
101 | {
102 | "name": "Send SMS",
103 | "function": "send_sms",
104 | "params": {
105 | "message": "Test SMS message",
106 | "phone_numbers": [TEST_CONFIG["test_phone"]]
107 | }
108 | },
109 | {
110 | "name": "Send Transactional Message",
111 | "function": "send_transactional_message",
112 | "params": {
113 | "channel": "push",
114 | "content": {"en": "Transactional test"},
115 | "recipients": {"include_external_user_ids": [TEST_CONFIG["test_external_id"]]}
116 | }
117 | }
118 | ]
119 |
120 | for test in tests:
121 | print(f"\n{test['name']}...")
122 | print(f" Function: {test['function']}")
123 | print(f" Params: {json.dumps(test['params'], indent=6)}")
124 | print(" ⚠️ Requires MCP client to execute")
125 | test_results["skipped"] += 1
126 |
127 | async def test_templates():
128 | """Test template management functions."""
129 | print_test_header("Template Management")
130 |
131 | # Create template test
132 | print("\n1. Create Template")
133 | create_params = {
134 | "name": f"Test Template {datetime.now().strftime('%Y%m%d_%H%M%S')}",
135 | "title": "Test Template Title",
136 | "message": "Test template message content"
137 | }
138 | print(f" Params: {json.dumps(create_params, indent=6)}")
139 |
140 | # Update template test
141 | print("\n2. Update Template")
142 | if TEST_CONFIG["test_template_id"]:
143 | update_params = {
144 | "template_id": TEST_CONFIG["test_template_id"],
145 | "name": "Updated Test Template",
146 | "title": "Updated Title"
147 | }
148 | print(f" Params: {json.dumps(update_params, indent=6)}")
149 | else:
150 | print(" ⚠️ Skipped: No template ID available")
151 |
152 | # Delete template test
153 | print("\n3. Delete Template")
154 | print(" ⚠️ Skipped: Preserving test template")
155 |
156 | test_results["skipped"] += 3
157 |
158 | async def test_live_activities():
159 | """Test iOS Live Activities functions."""
160 | print_test_header("iOS Live Activities")
161 |
162 | activity_tests = [
163 | {
164 | "name": "Start Live Activity",
165 | "params": {
166 | "activity_id": "test_activity_123",
167 | "push_token": "test_push_token",
168 | "subscription_id": "test_subscription",
169 | "activity_attributes": {"event": "Test Event"},
170 | "content_state": {"status": "active"}
171 | }
172 | },
173 | {
174 | "name": "Update Live Activity",
175 | "params": {
176 | "activity_id": "test_activity_123",
177 | "name": "test_update",
178 | "event": "update",
179 | "content_state": {"status": "updated"}
180 | }
181 | },
182 | {
183 | "name": "End Live Activity",
184 | "params": {
185 | "activity_id": "test_activity_123",
186 | "subscription_id": "test_subscription"
187 | }
188 | }
189 | ]
190 |
191 | for test in activity_tests:
192 | print(f"\n{test['name']}...")
193 | print(f" Params: {json.dumps(test['params'], indent=6)}")
194 | print(" ⚠️ Requires iOS app with Live Activities support")
195 | test_results["skipped"] += 1
196 |
197 | async def test_analytics():
198 | """Test analytics and export functions."""
199 | print_test_header("Analytics & Export")
200 |
201 | # View outcomes
202 | print("\n1. View Outcomes")
203 | outcomes_params = {
204 | "outcome_names": ["session_duration", "purchase"],
205 | "outcome_time_range": "1d",
206 | "outcome_platforms": ["ios", "android"]
207 | }
208 | print(f" Params: {json.dumps(outcomes_params, indent=6)}")
209 |
210 | # Export functions
211 | export_tests = [
212 | {
213 | "name": "Export Players CSV",
214 | "params": {
215 | "start_date": "2024-01-01T00:00:00Z",
216 | "end_date": "2024-12-31T23:59:59Z"
217 | }
218 | },
219 | {
220 | "name": "Export Messages CSV",
221 | "params": {
222 | "start_date": "2024-01-01T00:00:00Z",
223 | "end_date": "2024-12-31T23:59:59Z"
224 | }
225 | }
226 | ]
227 |
228 | for test in export_tests:
229 | print(f"\n{test['name']}...")
230 | print(f" Params: {json.dumps(test['params'], indent=6)}")
231 | print(" ⚠️ Requires Organization API Key")
232 | test_results["skipped"] += 1
233 |
234 | async def test_user_management():
235 | """Test user management functions."""
236 | print_test_header("User Management")
237 |
238 | # Create user
239 | print("\n1. Create User")
240 | create_user_params = {
241 | "name": "Test User",
242 | "email": TEST_CONFIG["test_email"],
243 | "external_id": TEST_CONFIG["test_external_id"],
244 | "tags": {"test": "true", "created": datetime.now().isoformat()}
245 | }
246 | print(f" Params: {json.dumps(create_user_params, indent=6)}")
247 |
248 | # Other user operations
249 | user_tests = [
250 | "View User",
251 | "Update User",
252 | "View User Identity",
253 | "Create/Update Alias",
254 | "Delete Alias"
255 | ]
256 |
257 | for test in user_tests:
258 | print(f"\n{test}")
259 | print(f" ⚠️ Requires valid user_id from create_user")
260 | test_results["skipped"] += 1
261 |
262 | async def test_player_management():
263 | """Test player/device management functions."""
264 | print_test_header("Player/Device Management")
265 |
266 | # Add player
267 | print("\n1. Add Player")
268 | add_player_params = {
269 | "device_type": 1, # Android
270 | "identifier": "test_device_token",
271 | "language": "en",
272 | "tags": {"test_device": "true"}
273 | }
274 | print(f" Params: {json.dumps(add_player_params, indent=6)}")
275 |
276 | # Other player operations
277 | player_tests = [
278 | "Edit Player",
279 | "Delete Player",
280 | "Edit Tags with External User ID"
281 | ]
282 |
283 | for test in player_tests:
284 | print(f"\n{test}")
285 | print(f" ⚠️ Requires valid player_id")
286 | test_results["skipped"] += 1
287 |
288 | async def test_subscription_management():
289 | """Test subscription management functions."""
290 | print_test_header("Subscription Management")
291 |
292 | subscription_tests = [
293 | {
294 | "name": "Create Subscription",
295 | "params": {
296 | "user_id": "test_user_id",
297 | "subscription_type": "email",
298 | "identifier": TEST_CONFIG["test_email"]
299 | }
300 | },
301 | {
302 | "name": "Update Subscription",
303 | "params": {
304 | "user_id": "test_user_id",
305 | "subscription_id": "test_subscription_id",
306 | "enabled": False
307 | }
308 | },
309 | {
310 | "name": "Transfer Subscription",
311 | "params": {
312 | "user_id": "test_user_id",
313 | "subscription_id": "test_subscription_id",
314 | "new_user_id": "new_test_user_id"
315 | }
316 | },
317 | {
318 | "name": "Delete Subscription",
319 | "params": {
320 | "user_id": "test_user_id",
321 | "subscription_id": "test_subscription_id"
322 | }
323 | }
324 | ]
325 |
326 | for test in subscription_tests:
327 | print(f"\n{test['name']}...")
328 | print(f" Params: {json.dumps(test['params'], indent=6)}")
329 | print(" ⚠️ Requires valid user_id and subscription_id")
330 | test_results["skipped"] += 1
331 |
332 | async def test_api_key_management():
333 | """Test API key management functions."""
334 | print_test_header("API Key Management")
335 |
336 | print("\n⚠️ API Key management requires Organization API Key")
337 | print(" and should be tested carefully to avoid breaking access")
338 |
339 | api_key_tests = [
340 | "View App API Keys",
341 | "Create App API Key",
342 | "Update App API Key",
343 | "Rotate App API Key",
344 | "Delete App API Key"
345 | ]
346 |
347 | for test in api_key_tests:
348 | print(f"\n{test}")
349 | print(" ⚠️ Skipped for safety")
350 | test_results["skipped"] += 1
351 |
352 | def print_summary():
353 | """Print test summary."""
354 | print(f"\n\n{'='*60}")
355 | print("TEST SUMMARY")
356 | print(f"{'='*60}")
357 | print(f"✅ Passed: {test_results['passed']}")
358 | print(f"❌ Failed: {test_results['failed']}")
359 | print(f"⚠️ Skipped: {test_results['skipped']}")
360 |
361 | if test_results["errors"]:
362 | print(f"\n\nERRORS:")
363 | for error in test_results["errors"]:
364 | print(f"\n- {error['test']}")
365 | print(f" Error: {error['error']}")
366 |
367 | async def main():
368 | """Run all tests."""
369 | print("OneSignal MCP Server Test Suite")
370 | print("================================")
371 | print("\nNOTE: This test script shows the structure of all available functions.")
372 | print("To actually execute these tests, you need to:")
373 | print("1. Run the OneSignal MCP server")
374 | print("2. Use an MCP client to connect and call the functions")
375 | print("3. Have valid OneSignal API credentials in your .env file")
376 |
377 | # Run test categories
378 | await test_app_management()
379 | await test_messaging()
380 | await test_templates()
381 | await test_live_activities()
382 | await test_analytics()
383 | await test_user_management()
384 | await test_player_management()
385 | await test_subscription_management()
386 | await test_api_key_management()
387 |
388 | # Print summary
389 | print_summary()
390 |
391 | if __name__ == "__main__":
392 | asyncio.run(main())
```
--------------------------------------------------------------------------------
/implementation_examples.md:
--------------------------------------------------------------------------------
```markdown
1 | # Implementation Examples for Missing OneSignal API Endpoints
2 |
3 | This document provides concrete examples of how to add the missing API endpoints to the current `onesignal_server.py` file without requiring a full refactor.
4 |
5 | ## 1. Email Sending Endpoint
6 |
7 | Add this function after the existing `send_push_notification` function:
8 |
9 | ```python
10 | @mcp.tool()
11 | async def send_email(subject: str, body: str, email_body: str = None,
12 | include_emails: List[str] = None, segments: List[str] = None,
13 | external_ids: List[str] = None, template_id: str = None) -> Dict[str, Any]:
14 | """Send an email through OneSignal.
15 |
16 | Args:
17 | subject: Email subject line
18 | body: Plain text email content
19 | email_body: HTML email content (optional)
20 | include_emails: List of specific email addresses to target
21 | segments: List of segments to include
22 | external_ids: List of external user IDs to target
23 | template_id: Email template ID to use
24 | """
25 | app_config = get_current_app()
26 | if not app_config:
27 | return {"error": "No app currently selected. Use switch_app to select an app."}
28 |
29 | email_data = {
30 | "app_id": app_config.app_id,
31 | "email_subject": subject,
32 | "email_body": email_body or body,
33 | "target_channel": "email"
34 | }
35 |
36 | # Set targeting
37 | if include_emails:
38 | email_data["include_emails"] = include_emails
39 | elif external_ids:
40 | email_data["include_external_user_ids"] = external_ids
41 | elif segments:
42 | email_data["included_segments"] = segments
43 | else:
44 | email_data["included_segments"] = ["Subscribed Users"]
45 |
46 | if template_id:
47 | email_data["template_id"] = template_id
48 |
49 | result = await make_onesignal_request("notifications", method="POST", data=email_data)
50 | return result
51 | ```
52 |
53 | ## 2. SMS Sending Endpoint
54 |
55 | ```python
56 | @mcp.tool()
57 | async def send_sms(message: str, phone_numbers: List[str] = None,
58 | segments: List[str] = None, external_ids: List[str] = None,
59 | media_url: str = None) -> Dict[str, Any]:
60 | """Send an SMS/MMS through OneSignal.
61 |
62 | Args:
63 | message: SMS message content
64 | phone_numbers: List of phone numbers in E.164 format
65 | segments: List of segments to include
66 | external_ids: List of external user IDs to target
67 | media_url: URL for MMS media attachment
68 | """
69 | app_config = get_current_app()
70 | if not app_config:
71 | return {"error": "No app currently selected. Use switch_app to select an app."}
72 |
73 | sms_data = {
74 | "app_id": app_config.app_id,
75 | "contents": {"en": message},
76 | "target_channel": "sms"
77 | }
78 |
79 | if phone_numbers:
80 | sms_data["include_phone_numbers"] = phone_numbers
81 | elif external_ids:
82 | sms_data["include_external_user_ids"] = external_ids
83 | elif segments:
84 | sms_data["included_segments"] = segments
85 | else:
86 | return {"error": "SMS requires phone_numbers, external_ids, or segments"}
87 |
88 | if media_url:
89 | sms_data["mms_media_url"] = media_url
90 |
91 | result = await make_onesignal_request("notifications", method="POST", data=sms_data)
92 | return result
93 | ```
94 |
95 | ## 3. Template Management Completions
96 |
97 | ### Update Template
98 | ```python
99 | @mcp.tool()
100 | async def update_template(template_id: str, name: str = None,
101 | title: str = None, message: str = None) -> Dict[str, Any]:
102 | """Update an existing template.
103 |
104 | Args:
105 | template_id: ID of the template to update
106 | name: New name for the template
107 | title: New title/heading for the template
108 | message: New content/message for the template
109 | """
110 | data = {}
111 |
112 | if name:
113 | data["name"] = name
114 | if title:
115 | data["headings"] = {"en": title}
116 | if message:
117 | data["contents"] = {"en": message}
118 |
119 | if not data:
120 | return {"error": "No update parameters provided"}
121 |
122 | result = await make_onesignal_request(f"templates/{template_id}",
123 | method="PATCH", data=data)
124 | return result
125 | ```
126 |
127 | ### Delete Template
128 | ```python
129 | @mcp.tool()
130 | async def delete_template(template_id: str) -> Dict[str, Any]:
131 | """Delete a template from your OneSignal app.
132 |
133 | Args:
134 | template_id: ID of the template to delete
135 | """
136 | result = await make_onesignal_request(f"templates/{template_id}",
137 | method="DELETE")
138 | if "error" not in result:
139 | return {"success": f"Template '{template_id}' deleted successfully"}
140 | return result
141 | ```
142 |
143 | ## 4. Live Activities
144 |
145 | ### Start Live Activity
146 | ```python
147 | @mcp.tool()
148 | async def start_live_activity(activity_id: str, push_token: str,
149 | subscription_id: str, activity_attributes: Dict[str, Any],
150 | content_state: Dict[str, Any]) -> Dict[str, Any]:
151 | """Start a new iOS Live Activity.
152 |
153 | Args:
154 | activity_id: Unique identifier for the activity
155 | push_token: Push token for the Live Activity
156 | subscription_id: Subscription ID for the user
157 | activity_attributes: Static attributes for the activity
158 | content_state: Initial dynamic content state
159 | """
160 | data = {
161 | "activity_id": activity_id,
162 | "push_token": push_token,
163 | "subscription_id": subscription_id,
164 | "activity_attributes": activity_attributes,
165 | "content_state": content_state
166 | }
167 |
168 | result = await make_onesignal_request(f"live_activities/{activity_id}/start",
169 | method="POST", data=data)
170 | return result
171 | ```
172 |
173 | ### Update Live Activity
174 | ```python
175 | @mcp.tool()
176 | async def update_live_activity(activity_id: str, name: str, event: str,
177 | content_state: Dict[str, Any],
178 | dismissal_date: int = None) -> Dict[str, Any]:
179 | """Update an existing iOS Live Activity.
180 |
181 | Args:
182 | activity_id: ID of the activity to update
183 | name: Name identifier for the update
184 | event: Event type ("update" or "end")
185 | content_state: Updated dynamic content state
186 | dismissal_date: Unix timestamp for automatic dismissal
187 | """
188 | data = {
189 | "name": name,
190 | "event": event,
191 | "content_state": content_state
192 | }
193 |
194 | if dismissal_date:
195 | data["dismissal_date"] = dismissal_date
196 |
197 | result = await make_onesignal_request(f"live_activities/{activity_id}/update",
198 | method="POST", data=data)
199 | return result
200 | ```
201 |
202 | ## 5. Analytics & Outcomes
203 |
204 | ### View Outcomes
205 | ```python
206 | @mcp.tool()
207 | async def view_outcomes(outcome_names: List[str],
208 | outcome_time_range: str = None,
209 | outcome_platforms: List[str] = None) -> Dict[str, Any]:
210 | """View outcomes data for your OneSignal app.
211 |
212 | Args:
213 | outcome_names: List of outcome names to fetch data for
214 | outcome_time_range: Time range for data (e.g., "1d", "1mo")
215 | outcome_platforms: Filter by platforms (e.g., ["ios", "android"])
216 | """
217 | app_config = get_current_app()
218 | if not app_config:
219 | return {"error": "No app currently selected. Use switch_app to select an app."}
220 |
221 | params = {"outcome_names": outcome_names}
222 |
223 | if outcome_time_range:
224 | params["outcome_time_range"] = outcome_time_range
225 | if outcome_platforms:
226 | params["outcome_platforms"] = outcome_platforms
227 |
228 | result = await make_onesignal_request(f"apps/{app_config.app_id}/outcomes",
229 | method="GET", params=params)
230 | return result
231 | ```
232 |
233 | ## 6. Export Functions
234 |
235 | ### Export Players CSV
236 | ```python
237 | @mcp.tool()
238 | async def export_players_csv(start_date: str = None, end_date: str = None,
239 | segment_names: List[str] = None) -> Dict[str, Any]:
240 | """Export player/subscription data to CSV.
241 |
242 | Args:
243 | start_date: Start date for export (ISO 8601 format)
244 | end_date: End date for export (ISO 8601 format)
245 | segment_names: List of segment names to export
246 | """
247 | data = {}
248 |
249 | if start_date:
250 | data["start_date"] = start_date
251 | if end_date:
252 | data["end_date"] = end_date
253 | if segment_names:
254 | data["segment_names"] = segment_names
255 |
256 | result = await make_onesignal_request("players/csv_export",
257 | method="POST", data=data, use_org_key=True)
258 | return result
259 | ```
260 |
261 | ## 7. API Key Management
262 |
263 | ### Delete API Key
264 | ```python
265 | @mcp.tool()
266 | async def delete_app_api_key(app_id: str, key_id: str) -> Dict[str, Any]:
267 | """Delete an API key from a specific OneSignal app.
268 |
269 | Args:
270 | app_id: The ID of the app
271 | key_id: The ID of the API key to delete
272 | """
273 | result = await make_onesignal_request(f"apps/{app_id}/auth/tokens/{key_id}",
274 | method="DELETE", use_org_key=True)
275 | if "error" not in result:
276 | return {"success": f"API key '{key_id}' deleted successfully"}
277 | return result
278 | ```
279 |
280 | ### Update API Key
281 | ```python
282 | @mcp.tool()
283 | async def update_app_api_key(app_id: str, key_id: str, name: str = None,
284 | scopes: List[str] = None) -> Dict[str, Any]:
285 | """Update an API key for a specific OneSignal app.
286 |
287 | Args:
288 | app_id: The ID of the app
289 | key_id: The ID of the API key to update
290 | name: New name for the API key
291 | scopes: New list of permission scopes
292 | """
293 | data = {}
294 |
295 | if name:
296 | data["name"] = name
297 | if scopes:
298 | data["scopes"] = scopes
299 |
300 | if not data:
301 | return {"error": "No update parameters provided"}
302 |
303 | result = await make_onesignal_request(f"apps/{app_id}/auth/tokens/{key_id}",
304 | method="PATCH", data=data, use_org_key=True)
305 | return result
306 | ```
307 |
308 | ## 8. Player/Device Management
309 |
310 | ### Add a Player
311 | ```python
312 | @mcp.tool()
313 | async def add_player(device_type: int, identifier: str = None,
314 | language: str = None, tags: Dict[str, str] = None) -> Dict[str, Any]:
315 | """Add a new player/device to OneSignal.
316 |
317 | Args:
318 | device_type: Device type (0=iOS, 1=Android, etc.)
319 | identifier: Push token or player ID
320 | language: Language code (e.g., "en")
321 | tags: Dictionary of tags to assign
322 | """
323 | app_config = get_current_app()
324 | if not app_config:
325 | return {"error": "No app currently selected. Use switch_app to select an app."}
326 |
327 | data = {
328 | "app_id": app_config.app_id,
329 | "device_type": device_type
330 | }
331 |
332 | if identifier:
333 | data["identifier"] = identifier
334 | if language:
335 | data["language"] = language
336 | if tags:
337 | data["tags"] = tags
338 |
339 | result = await make_onesignal_request("players", method="POST", data=data)
340 | return result
341 | ```
342 |
343 | ### Edit Player
344 | ```python
345 | @mcp.tool()
346 | async def edit_player(player_id: str, tags: Dict[str, str] = None,
347 | external_user_id: str = None, language: str = None) -> Dict[str, Any]:
348 | """Edit an existing player/device.
349 |
350 | Args:
351 | player_id: The player ID to edit
352 | tags: Dictionary of tags to update
353 | external_user_id: External user ID to assign
354 | language: Language code to update
355 | """
356 | app_config = get_current_app()
357 | if not app_config:
358 | return {"error": "No app currently selected. Use switch_app to select an app."}
359 |
360 | data = {"app_id": app_config.app_id}
361 |
362 | if tags:
363 | data["tags"] = tags
364 | if external_user_id:
365 | data["external_user_id"] = external_user_id
366 | if language:
367 | data["language"] = language
368 |
369 | if len(data) == 1: # Only app_id
370 | return {"error": "No update parameters provided"}
371 |
372 | result = await make_onesignal_request(f"players/{player_id}",
373 | method="PUT", data=data)
374 | return result
375 | ```
376 |
377 | ## Implementation Notes
378 |
379 | 1. **Error Handling**: All functions should return consistent error formats
380 | 2. **Authentication**: Use `use_org_key=True` for organization-level endpoints
381 | 3. **Validation**: Add input validation where necessary
382 | 4. **Documentation**: Include clear docstrings with parameter descriptions
383 | 5. **Testing**: Test each endpoint with valid OneSignal credentials
384 |
385 | These implementations can be added directly to the existing `onesignal_server.py` file to provide immediate support for the missing API endpoints.
```
--------------------------------------------------------------------------------
/onesignal_refactored/server.py:
--------------------------------------------------------------------------------
```python
1 | """OneSignal MCP Server - Refactored implementation."""
2 | import os
3 | import logging
4 | from mcp.server.fastmcp import FastMCP, Context
5 | from .config import app_manager
6 | from .api_client import OneSignalAPIError
7 | from .tools import (
8 | messages,
9 | templates,
10 | live_activities,
11 | analytics,
12 | )
13 |
14 | # Version information
15 | __version__ = "2.0.0"
16 |
17 | # Configure logging
18 | logging.basicConfig(
19 | level=logging.INFO,
20 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
21 | handlers=[logging.StreamHandler()]
22 | )
23 | logger = logging.getLogger("onesignal-mcp")
24 |
25 | # Get log level from environment
26 | log_level_str = os.getenv("LOG_LEVEL", "INFO").upper()
27 | valid_log_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
28 | if log_level_str not in valid_log_levels:
29 | logger.warning(f"Invalid LOG_LEVEL '{log_level_str}'. Using INFO instead.")
30 | log_level_str = "INFO"
31 |
32 | logger.setLevel(log_level_str)
33 |
34 | # Initialize MCP server
35 | mcp = FastMCP("onesignal-server", settings={"log_level": log_level_str})
36 | logger.info(f"OneSignal MCP server v{__version__} initialized")
37 |
38 | # === Configuration Resource ===
39 |
40 | @mcp.resource("onesignal://config")
41 | def get_onesignal_config() -> str:
42 | """Get information about the OneSignal configuration."""
43 | current_app = app_manager.get_current_app()
44 | app_list = "\n".join([
45 | f"- {key}: {app}" for key, app in app_manager.list_apps().items()
46 | ])
47 |
48 | return f"""
49 | OneSignal Server Configuration:
50 | Version: {__version__}
51 | Current App: {current_app.name if current_app else 'None'}
52 |
53 | Available Apps:
54 | {app_list or "No apps configured"}
55 |
56 | This refactored MCP server provides comprehensive tools for:
57 | - Multi-channel messaging (Push, Email, SMS)
58 | - Transactional messages
59 | - Template management
60 | - Live Activities (iOS)
61 | - Analytics and outcomes
62 | - User and subscription management
63 | - App configuration management
64 | - Data export functionality
65 | """
66 |
67 | # === App Management Tools ===
68 |
69 | @mcp.tool()
70 | async def list_apps() -> str:
71 | """List all configured OneSignal apps."""
72 | apps = app_manager.list_apps()
73 | if not apps:
74 | return "No apps configured. Use add_app to add a new app configuration."
75 |
76 | current_app = app_manager.get_current_app()
77 | result = ["Configured OneSignal Apps:"]
78 |
79 | for key, app in apps.items():
80 | current_marker = " (current)" if current_app and key == app_manager.current_app_key else ""
81 | result.append(f"- {key}: {app.name} (App ID: {app.app_id}){current_marker}")
82 |
83 | return "\n".join(result)
84 |
85 | @mcp.tool()
86 | async def add_app(key: str, app_id: str, api_key: str, name: str = None) -> str:
87 | """Add a new OneSignal app configuration locally."""
88 | if not all([key, app_id, api_key]):
89 | return "Error: All parameters (key, app_id, api_key) are required."
90 |
91 | if key in app_manager.list_apps():
92 | return f"Error: App key '{key}' already exists."
93 |
94 | app_manager.add_app(key, app_id, api_key, name)
95 |
96 | if len(app_manager.list_apps()) == 1:
97 | app_manager.set_current_app(key)
98 |
99 | return f"Successfully added app '{key}' with name '{name or key}'."
100 |
101 | @mcp.tool()
102 | async def switch_app(key: str) -> str:
103 | """Switch the current app to use for API requests."""
104 | if app_manager.set_current_app(key):
105 | app = app_manager.get_app(key)
106 | return f"Switched to app '{key}' ({app.name})."
107 | else:
108 | available = ", ".join(app_manager.list_apps().keys()) or "None"
109 | return f"Error: App key '{key}' not found. Available apps: {available}"
110 |
111 | # === Messaging Tools ===
112 |
113 | @mcp.tool()
114 | async def send_push_notification(
115 | title: str,
116 | message: str,
117 | segments: list = None,
118 | include_player_ids: list = None,
119 | external_ids: list = None,
120 | data: dict = None
121 | ) -> dict:
122 | """Send a push notification through OneSignal."""
123 | try:
124 | return await messages.send_push_notification(
125 | title=title,
126 | message=message,
127 | segments=segments,
128 | include_player_ids=include_player_ids,
129 | external_ids=external_ids,
130 | data=data
131 | )
132 | except OneSignalAPIError as e:
133 | return {"error": str(e)}
134 |
135 | @mcp.tool()
136 | async def send_email(
137 | subject: str,
138 | body: str,
139 | include_emails: list = None,
140 | segments: list = None,
141 | external_ids: list = None,
142 | template_id: str = None
143 | ) -> dict:
144 | """Send an email through OneSignal."""
145 | try:
146 | return await messages.send_email(
147 | subject=subject,
148 | body=body,
149 | include_emails=include_emails,
150 | segments=segments,
151 | external_ids=external_ids,
152 | template_id=template_id
153 | )
154 | except OneSignalAPIError as e:
155 | return {"error": str(e)}
156 |
157 | @mcp.tool()
158 | async def send_sms(
159 | message: str,
160 | phone_numbers: list = None,
161 | segments: list = None,
162 | external_ids: list = None,
163 | media_url: str = None
164 | ) -> dict:
165 | """Send an SMS/MMS through OneSignal."""
166 | try:
167 | return await messages.send_sms(
168 | message=message,
169 | phone_numbers=phone_numbers,
170 | segments=segments,
171 | external_ids=external_ids,
172 | media_url=media_url
173 | )
174 | except OneSignalAPIError as e:
175 | return {"error": str(e)}
176 |
177 | @mcp.tool()
178 | async def send_transactional_message(
179 | channel: str,
180 | content: dict,
181 | recipients: dict,
182 | template_id: str = None,
183 | custom_data: dict = None
184 | ) -> dict:
185 | """Send a transactional message (immediate delivery)."""
186 | try:
187 | return await messages.send_transactional_message(
188 | channel=channel,
189 | content=content,
190 | recipients=recipients,
191 | template_id=template_id,
192 | custom_data=custom_data
193 | )
194 | except OneSignalAPIError as e:
195 | return {"error": str(e)}
196 |
197 | @mcp.tool()
198 | async def view_messages(limit: int = 20, offset: int = 0, kind: int = None) -> dict:
199 | """View recent messages sent through OneSignal."""
200 | try:
201 | return await messages.view_messages(limit=limit, offset=offset, kind=kind)
202 | except OneSignalAPIError as e:
203 | return {"error": str(e)}
204 |
205 | @mcp.tool()
206 | async def view_message_details(message_id: str) -> dict:
207 | """Get detailed information about a specific message."""
208 | try:
209 | return await messages.view_message_details(message_id)
210 | except OneSignalAPIError as e:
211 | return {"error": str(e)}
212 |
213 | @mcp.tool()
214 | async def cancel_message(message_id: str) -> dict:
215 | """Cancel a scheduled message."""
216 | try:
217 | return await messages.cancel_message(message_id)
218 | except OneSignalAPIError as e:
219 | return {"error": str(e)}
220 |
221 | @mcp.tool()
222 | async def export_messages_csv(start_date: str = None, end_date: str = None) -> dict:
223 | """Export messages to CSV (requires Organization API Key)."""
224 | try:
225 | return await messages.export_messages_csv(
226 | start_date=start_date,
227 | end_date=end_date
228 | )
229 | except OneSignalAPIError as e:
230 | return {"error": str(e)}
231 |
232 | # === Template Tools ===
233 |
234 | @mcp.tool()
235 | async def create_template(name: str, title: str, message: str) -> dict:
236 | """Create a new template."""
237 | try:
238 | result = await templates.create_template(name=name, title=title, message=message)
239 | return {"success": f"Template '{name}' created with ID: {result.get('id')}"}
240 | except Exception as e:
241 | return {"error": str(e)}
242 |
243 | @mcp.tool()
244 | async def update_template(
245 | template_id: str,
246 | name: str = None,
247 | title: str = None,
248 | message: str = None
249 | ) -> dict:
250 | """Update an existing template."""
251 | try:
252 | await templates.update_template(
253 | template_id=template_id,
254 | name=name,
255 | title=title,
256 | message=message
257 | )
258 | return {"success": f"Template '{template_id}' updated successfully"}
259 | except Exception as e:
260 | return {"error": str(e)}
261 |
262 | @mcp.tool()
263 | async def view_templates() -> str:
264 | """List all templates."""
265 | try:
266 | result = await templates.view_templates()
267 | return templates.format_template_list(result)
268 | except Exception as e:
269 | return f"Error: {str(e)}"
270 |
271 | @mcp.tool()
272 | async def view_template_details(template_id: str) -> str:
273 | """Get template details."""
274 | try:
275 | result = await templates.view_template_details(template_id)
276 | return templates.format_template_details(result)
277 | except Exception as e:
278 | return f"Error: {str(e)}"
279 |
280 | @mcp.tool()
281 | async def delete_template(template_id: str) -> dict:
282 | """Delete a template."""
283 | try:
284 | await templates.delete_template(template_id)
285 | return {"success": f"Template '{template_id}' deleted successfully"}
286 | except Exception as e:
287 | return {"error": str(e)}
288 |
289 | @mcp.tool()
290 | async def copy_template_to_app(
291 | template_id: str,
292 | target_app_id: str,
293 | new_name: str = None
294 | ) -> dict:
295 | """Copy a template to another app."""
296 | try:
297 | result = await templates.copy_template_to_app(
298 | template_id=template_id,
299 | target_app_id=target_app_id,
300 | new_name=new_name
301 | )
302 | return {"success": f"Template copied successfully. New ID: {result.get('id')}"}
303 | except Exception as e:
304 | return {"error": str(e)}
305 |
306 | # === Live Activities Tools ===
307 |
308 | @mcp.tool()
309 | async def start_live_activity(
310 | activity_id: str,
311 | push_token: str,
312 | subscription_id: str,
313 | activity_attributes: dict,
314 | content_state: dict
315 | ) -> dict:
316 | """Start a new iOS Live Activity."""
317 | try:
318 | return await live_activities.start_live_activity(
319 | activity_id=activity_id,
320 | push_token=push_token,
321 | subscription_id=subscription_id,
322 | activity_attributes=activity_attributes,
323 | content_state=content_state
324 | )
325 | except OneSignalAPIError as e:
326 | return {"error": str(e)}
327 |
328 | @mcp.tool()
329 | async def update_live_activity(
330 | activity_id: str,
331 | name: str,
332 | event: str,
333 | content_state: dict,
334 | dismissal_date: int = None,
335 | priority: int = None,
336 | sound: str = None
337 | ) -> dict:
338 | """Update an iOS Live Activity."""
339 | try:
340 | return await live_activities.update_live_activity(
341 | activity_id=activity_id,
342 | name=name,
343 | event=event,
344 | content_state=content_state,
345 | dismissal_date=dismissal_date,
346 | priority=priority,
347 | sound=sound
348 | )
349 | except OneSignalAPIError as e:
350 | return {"error": str(e)}
351 |
352 | @mcp.tool()
353 | async def end_live_activity(
354 | activity_id: str,
355 | subscription_id: str,
356 | dismissal_date: int = None,
357 | priority: int = None
358 | ) -> dict:
359 | """End an iOS Live Activity."""
360 | try:
361 | return await live_activities.end_live_activity(
362 | activity_id=activity_id,
363 | subscription_id=subscription_id,
364 | dismissal_date=dismissal_date,
365 | priority=priority
366 | )
367 | except OneSignalAPIError as e:
368 | return {"error": str(e)}
369 |
370 | # === Analytics Tools ===
371 |
372 | @mcp.tool()
373 | async def view_outcomes(
374 | outcome_names: list,
375 | outcome_time_range: str = None,
376 | outcome_platforms: list = None,
377 | outcome_attribution: str = None
378 | ) -> str:
379 | """View outcomes data for your app."""
380 | try:
381 | result = await analytics.view_outcomes(
382 | outcome_names=outcome_names,
383 | outcome_time_range=outcome_time_range,
384 | outcome_platforms=outcome_platforms,
385 | outcome_attribution=outcome_attribution
386 | )
387 | return analytics.format_outcomes_response(result)
388 | except Exception as e:
389 | return f"Error: {str(e)}"
390 |
391 | @mcp.tool()
392 | async def export_players_csv(
393 | start_date: str = None,
394 | end_date: str = None,
395 | segment_names: list = None
396 | ) -> dict:
397 | """Export player data to CSV (requires Organization API Key)."""
398 | try:
399 | return await analytics.export_players_csv(
400 | start_date=start_date,
401 | end_date=end_date,
402 | segment_names=segment_names
403 | )
404 | except OneSignalAPIError as e:
405 | return {"error": str(e)}
406 |
407 | @mcp.tool()
408 | async def export_audience_activity_csv(
409 | start_date: str = None,
410 | end_date: str = None,
411 | event_types: list = None
412 | ) -> dict:
413 | """Export audience activity to CSV (requires Organization API Key)."""
414 | try:
415 | return await analytics.export_audience_activity_csv(
416 | start_date=start_date,
417 | end_date=end_date,
418 | event_types=event_types
419 | )
420 | except OneSignalAPIError as e:
421 | return {"error": str(e)}
422 |
423 | # Run the server
424 | if __name__ == "__main__":
425 | mcp.run()
```