gotify-mcp 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gotify_mcp/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Gotify MCP Server — FastMCP server for Gotify push notifications."""
gotify_mcp/server.py ADDED
@@ -0,0 +1,459 @@
1
+ """
2
+ Gotify MCP Server — two-tool design with BearerAuth middleware.
3
+
4
+ Tools:
5
+ gotify — all operations via action + subaction routing
6
+ gotify_help — returns markdown help for all actions
7
+
8
+ Transport: GOTIFY_MCP_TRANSPORT=http (default) | stdio
9
+ Auth: GOTIFY_MCP_TOKEN (required for HTTP; skipped for stdio)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hmac
15
+ import json
16
+ import logging
17
+ import logging.handlers
18
+ import os
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import Any, Literal
22
+
23
+ from dotenv import load_dotenv
24
+ from fastmcp import FastMCP
25
+ from starlette.middleware.base import BaseHTTPMiddleware
26
+ from starlette.requests import Request
27
+ from starlette.responses import JSONResponse
28
+
29
+ from gotify_mcp.services.gotify import GotifyClient
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Environment loading
33
+ # ---------------------------------------------------------------------------
34
+ PACKAGE_DIR = Path(__file__).resolve().parent
35
+ REPO_ROOT = PACKAGE_DIR.parent
36
+ load_dotenv(dotenv_path=REPO_ROOT / ".env")
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Logging
40
+ # ---------------------------------------------------------------------------
41
+ GOTIFY_LOG_LEVEL_STR = os.getenv(
42
+ "GOTIFY_LOG_LEVEL", os.getenv("LOG_LEVEL", "INFO")
43
+ ).upper()
44
+ NUMERIC_LOG_LEVEL = getattr(logging, GOTIFY_LOG_LEVEL_STR, logging.INFO)
45
+
46
+ logger = logging.getLogger("GotifyMCPServer")
47
+ logger.setLevel(NUMERIC_LOG_LEVEL)
48
+ logger.propagate = False
49
+
50
+ _console = logging.StreamHandler(sys.stdout)
51
+ _console.setLevel(NUMERIC_LOG_LEVEL)
52
+ _console.setFormatter(
53
+ logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
54
+ )
55
+ if not any(isinstance(h, logging.StreamHandler) for h in logger.handlers):
56
+ logger.addHandler(_console)
57
+
58
+ _logs_dir = REPO_ROOT / "logs"
59
+ _logs_dir.mkdir(exist_ok=True)
60
+ _file_handler = logging.handlers.RotatingFileHandler(
61
+ _logs_dir / "gotify_mcp.log",
62
+ maxBytes=5 * 1024 * 1024,
63
+ backupCount=3,
64
+ encoding="utf-8",
65
+ )
66
+ _file_handler.setLevel(NUMERIC_LOG_LEVEL)
67
+ _file_handler.setFormatter(
68
+ logging.Formatter(
69
+ "%(asctime)s - %(name)s - %(levelname)s - %(module)s - %(funcName)s - %(lineno)d - %(message)s"
70
+ )
71
+ )
72
+ if not any(
73
+ isinstance(h, logging.handlers.RotatingFileHandler) for h in logger.handlers
74
+ ):
75
+ logger.addHandler(_file_handler)
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Configuration & startup validation
79
+ # ---------------------------------------------------------------------------
80
+ GOTIFY_URL = os.getenv("GOTIFY_URL", "")
81
+ GOTIFY_CLIENT_TOKEN = os.getenv("GOTIFY_CLIENT_TOKEN")
82
+ GOTIFY_MCP_TOKEN = os.getenv("GOTIFY_MCP_TOKEN")
83
+ GOTIFY_MCP_NO_AUTH = os.getenv("GOTIFY_MCP_NO_AUTH", "").lower() in ("true", "1", "yes")
84
+ GOTIFY_MCP_TRANSPORT = os.getenv("GOTIFY_MCP_TRANSPORT", "http").lower()
85
+ GOTIFY_MCP_HOST = os.getenv("GOTIFY_MCP_HOST", "0.0.0.0")
86
+ GOTIFY_MCP_PORT = int(os.getenv("GOTIFY_MCP_PORT", "8084"))
87
+
88
+ # Destructive operations gates
89
+ ALLOW_DESTRUCTIVE = os.getenv("ALLOW_DESTRUCTIVE", "false").lower() == "true"
90
+ ALLOW_YOLO = os.getenv("ALLOW_YOLO", "false").lower() == "true"
91
+
92
+ if not GOTIFY_URL:
93
+ print("CRITICAL: GOTIFY_URL must be set in the environment.", file=sys.stderr)
94
+ sys.exit(1)
95
+
96
+ if GOTIFY_MCP_TRANSPORT != "stdio" and not GOTIFY_MCP_NO_AUTH and not GOTIFY_MCP_TOKEN:
97
+ print(
98
+ "CRITICAL: GOTIFY_MCP_TOKEN is not set.\n"
99
+ "Set GOTIFY_MCP_TOKEN to a secure random token, or set GOTIFY_MCP_NO_AUTH=true\n"
100
+ "to disable auth (only appropriate when secured at the network/proxy level).\n\n"
101
+ "Generate a token with: openssl rand -hex 32",
102
+ file=sys.stderr,
103
+ )
104
+ sys.exit(1)
105
+
106
+ if not GOTIFY_CLIENT_TOKEN:
107
+ logger.warning("GOTIFY_CLIENT_TOKEN is not set. Management actions will fail.")
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Response size cap
111
+ # ---------------------------------------------------------------------------
112
+ MAX_RESPONSE_SIZE = 512 * 1024 # 512 KB
113
+
114
+
115
+ def truncate_response(text: str) -> str:
116
+ if len(text.encode()) > MAX_RESPONSE_SIZE:
117
+ return text[:MAX_RESPONSE_SIZE] + "\n... [truncated]"
118
+ return text
119
+
120
+
121
+ def _json(obj: Any) -> str:
122
+ return truncate_response(json.dumps(obj, default=str))
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Gotify API client (singleton)
127
+ # ---------------------------------------------------------------------------
128
+ _client = GotifyClient(base_url=GOTIFY_URL, client_token=GOTIFY_CLIENT_TOKEN)
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # FastMCP server + BearerAuth middleware
132
+ # ---------------------------------------------------------------------------
133
+ mcp = FastMCP(
134
+ name="Gotify MCP Server",
135
+ instructions=(
136
+ "Interact with Gotify push notification server. "
137
+ "Call gotify_help to see all available actions."
138
+ ),
139
+ )
140
+
141
+
142
+ class BearerAuthMiddleware(BaseHTTPMiddleware):
143
+ async def dispatch(self, request: Request, call_next):
144
+ if GOTIFY_MCP_NO_AUTH:
145
+ return await call_next(request)
146
+ # /health is always unauthenticated
147
+ if request.url.path in ("/health",):
148
+ return await call_next(request)
149
+ auth = request.headers.get("Authorization", "")
150
+ if not auth.startswith("Bearer "):
151
+ return JSONResponse({"error": "Unauthorized"}, status_code=401)
152
+ provided = auth[7:]
153
+ # Timing-safe comparison
154
+ if not hmac.compare_digest(provided, GOTIFY_MCP_TOKEN or ""):
155
+ return JSONResponse({"error": "Unauthorized"}, status_code=401)
156
+ return await call_next(request)
157
+
158
+
159
+ # ---------------------------------------------------------------------------
160
+ # Destructive ops gate
161
+ # ---------------------------------------------------------------------------
162
+
163
+
164
+ def _destructive_gate(confirm: bool) -> str | None:
165
+ """Return an error string if the destructive op should be blocked, else None."""
166
+ if ALLOW_YOLO or ALLOW_DESTRUCTIVE:
167
+ return None
168
+ if not confirm:
169
+ return "Destructive operation. Pass confirm=True to proceed."
170
+ return None
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Tool: gotify
175
+ # ---------------------------------------------------------------------------
176
+
177
+ GOTIFY_ACTIONS = Literal[
178
+ "send_message",
179
+ "list_messages",
180
+ "delete_message",
181
+ "delete_all_messages",
182
+ "list_applications",
183
+ "create_application",
184
+ "update_application",
185
+ "delete_application",
186
+ "list_clients",
187
+ "create_client",
188
+ "delete_client",
189
+ "health",
190
+ "version",
191
+ "current_user",
192
+ ]
193
+
194
+
195
+ @mcp.tool()
196
+ async def gotify(
197
+ action: GOTIFY_ACTIONS,
198
+ subaction: str = "",
199
+ # Message params
200
+ app_token: str = "",
201
+ message: str = "",
202
+ title: str = "",
203
+ priority: int | None = None,
204
+ message_id: int | None = None,
205
+ extras: dict[str, Any] | None = None,
206
+ # Application params
207
+ app_id: int | None = None,
208
+ name: str = "",
209
+ description: str = "",
210
+ default_priority: int | None = None,
211
+ # Client params
212
+ client_id: int | None = None,
213
+ # Pagination / filtering
214
+ offset: int = 0,
215
+ limit: int = 50,
216
+ sort_by: str = "id",
217
+ sort_order: str = "desc",
218
+ query: str = "",
219
+ # Destructive gate
220
+ confirm: bool = False,
221
+ ) -> str:
222
+ """Gotify MCP tool — routes all actions to the Gotify push notification API.
223
+
224
+ Actions:
225
+ send_message — send a notification (requires app_token, message)
226
+ list_messages — list messages (optional: app_id, offset, limit, sort_by, sort_order, query)
227
+ delete_message — delete one message (requires message_id) [destructive]
228
+ delete_all_messages — delete all messages [destructive]
229
+ list_applications — list apps (optional: offset, limit, query)
230
+ create_application — create app (requires name; optional: description, default_priority)
231
+ update_application — update app (requires app_id; optional: name, description, default_priority)
232
+ delete_application — delete app (requires app_id) [destructive]
233
+ list_clients — list clients (optional: offset, limit, query)
234
+ create_client — create client (requires name)
235
+ delete_client — delete client (requires client_id) [destructive]
236
+ health — Gotify server health check
237
+ version — Gotify server version
238
+ current_user — current authenticated user info
239
+ """
240
+ match action:
241
+ case "send_message":
242
+ if not app_token:
243
+ return _json({"error": "app_token is required for send_message"})
244
+ if not message:
245
+ return _json({"error": "message is required for send_message"})
246
+ result = await _client.send_message(
247
+ app_token=app_token,
248
+ message=message,
249
+ title=title or None,
250
+ priority=priority,
251
+ extras=extras,
252
+ )
253
+
254
+ case "list_messages":
255
+ result = await _client.list_messages(
256
+ app_id=app_id,
257
+ offset=offset,
258
+ limit=limit,
259
+ sort_by=sort_by,
260
+ sort_order=sort_order,
261
+ query=query,
262
+ )
263
+
264
+ case "delete_message":
265
+ if (err := _destructive_gate(confirm)) is not None:
266
+ return err
267
+ if message_id is None:
268
+ return _json({"error": "message_id is required for delete_message"})
269
+ result = await _client.delete_message(message_id)
270
+
271
+ case "delete_all_messages":
272
+ if (err := _destructive_gate(confirm)) is not None:
273
+ return err
274
+ result = await _client.delete_all_messages()
275
+
276
+ case "list_applications":
277
+ result = await _client.list_applications(
278
+ offset=offset, limit=limit, query=query
279
+ )
280
+
281
+ case "create_application":
282
+ if not name:
283
+ return _json({"error": "name is required for create_application"})
284
+ result = await _client.create_application(
285
+ name=name,
286
+ description=description or None,
287
+ default_priority=default_priority,
288
+ )
289
+
290
+ case "update_application":
291
+ if app_id is None:
292
+ return _json({"error": "app_id is required for update_application"})
293
+ result = await _client.update_application(
294
+ app_id=app_id,
295
+ name=name or None,
296
+ description=description or None,
297
+ default_priority=default_priority,
298
+ )
299
+
300
+ case "delete_application":
301
+ if (err := _destructive_gate(confirm)) is not None:
302
+ return err
303
+ if app_id is None:
304
+ return _json({"error": "app_id is required for delete_application"})
305
+ result = await _client.delete_application(app_id)
306
+
307
+ case "list_clients":
308
+ result = await _client.list_clients(offset=offset, limit=limit, query=query)
309
+
310
+ case "create_client":
311
+ if not name:
312
+ return _json({"error": "name is required for create_client"})
313
+ result = await _client.create_client(name)
314
+
315
+ case "delete_client":
316
+ if (err := _destructive_gate(confirm)) is not None:
317
+ return err
318
+ if client_id is None:
319
+ return _json({"error": "client_id is required for delete_client"})
320
+ result = await _client.delete_client(client_id)
321
+
322
+ case "health":
323
+ result = await _client.get_health()
324
+
325
+ case "version":
326
+ result = await _client.get_version()
327
+
328
+ case "current_user":
329
+ result = await _client.get_current_user()
330
+
331
+ case _:
332
+ return _json(
333
+ {"error": f"Unknown action: {action}. Call gotify_help for reference."}
334
+ )
335
+
336
+ return _json(result)
337
+
338
+
339
+ # ---------------------------------------------------------------------------
340
+ # Tool: gotify_help
341
+ # ---------------------------------------------------------------------------
342
+
343
+ _HELP_TEXT = """# Gotify MCP Server
344
+
345
+ Interact with a Gotify push notification server.
346
+
347
+ ## Tool: `gotify`
348
+
349
+ Single entry point for all operations. Use `action` to select the operation.
350
+
351
+ ### Actions
352
+
353
+ | Action | Description | Required params | Destructive |
354
+ |--------|-------------|-----------------|-------------|
355
+ | `send_message` | Send a push notification | `app_token`, `message` | no |
356
+ | `list_messages` | List messages | — | no |
357
+ | `delete_message` | Delete one message | `message_id` | yes |
358
+ | `delete_all_messages` | Delete all messages | — | yes |
359
+ | `list_applications` | List applications | — | no |
360
+ | `create_application` | Create an application | `name` | no |
361
+ | `update_application` | Update an application | `app_id` | no |
362
+ | `delete_application` | Delete an application | `app_id` | yes |
363
+ | `list_clients` | List clients | — | no |
364
+ | `create_client` | Create a client | `name` | no |
365
+ | `delete_client` | Delete a client | `client_id` | yes |
366
+ | `health` | Gotify server health | — | no |
367
+ | `version` | Gotify server version | — | no |
368
+ | `current_user` | Current user info | — | no |
369
+
370
+ ### Pagination parameters (list actions)
371
+
372
+ | Param | Default | Description |
373
+ |-------|---------|-------------|
374
+ | `offset` | `0` | Number of items to skip |
375
+ | `limit` | `50` | Max items to return |
376
+ | `sort_by` | `"id"` | Field to sort by |
377
+ | `sort_order` | `"desc"` | `"asc"` or `"desc"` |
378
+ | `query` | `""` | Filter by text match |
379
+
380
+ ### Destructive operations
381
+
382
+ Pass `confirm=True` to execute destructive actions (delete_message, delete_all_messages,
383
+ delete_application, delete_client).
384
+
385
+ Set `ALLOW_DESTRUCTIVE=true` or `ALLOW_YOLO=true` in the server environment to skip
386
+ the confirmation gate entirely.
387
+
388
+ ### Examples
389
+
390
+ ```
391
+ gotify(action="send_message", app_token="AbCdEf", message="Hello", title="Test")
392
+ gotify(action="list_messages", limit=10, sort_order="desc")
393
+ gotify(action="list_messages", app_id=3, query="error")
394
+ gotify(action="delete_message", message_id=42, confirm=True)
395
+ gotify(action="list_applications")
396
+ gotify(action="create_application", name="MyApp", description="Test app")
397
+ gotify(action="health")
398
+ ```
399
+ """
400
+
401
+
402
+ @mcp.tool()
403
+ async def gotify_help() -> str:
404
+ """Returns markdown help for all Gotify MCP actions and parameters."""
405
+ return _HELP_TEXT
406
+
407
+
408
+ # ---------------------------------------------------------------------------
409
+ # /health endpoint (unauthenticated)
410
+ # ---------------------------------------------------------------------------
411
+
412
+
413
+ @mcp.custom_route("/health", methods=["GET"])
414
+ async def mcp_server_health(request: Request) -> JSONResponse:
415
+ """MCP server health check."""
416
+ if not GOTIFY_URL:
417
+ return JSONResponse(
418
+ {"status": "error", "reason": "GOTIFY_URL not configured."},
419
+ status_code=500,
420
+ )
421
+ health = await _client.get_health()
422
+ if "error" in health:
423
+ return JSONResponse(
424
+ {"status": "error", "reason": health["error"]},
425
+ status_code=503,
426
+ )
427
+ return JSONResponse({"status": "ok", "gotify": health})
428
+
429
+
430
+ # ---------------------------------------------------------------------------
431
+ # Entry point
432
+ # ---------------------------------------------------------------------------
433
+
434
+
435
+ def main() -> None:
436
+ logger.info(
437
+ "Starting Gotify MCP Server — transport=%s host=%s port=%s auth=%s",
438
+ GOTIFY_MCP_TRANSPORT,
439
+ GOTIFY_MCP_HOST,
440
+ GOTIFY_MCP_PORT,
441
+ "disabled" if GOTIFY_MCP_NO_AUTH else "bearer",
442
+ )
443
+
444
+ if GOTIFY_MCP_TRANSPORT == "stdio":
445
+ mcp.run(transport="stdio")
446
+ else:
447
+ # BearerAuth skipped when GOTIFY_MCP_NO_AUTH=true (OAuth handled at gateway)
448
+ if not GOTIFY_MCP_NO_AUTH:
449
+ mcp.app.add_middleware(BearerAuthMiddleware)
450
+ mcp.run(
451
+ transport="streamable-http",
452
+ host=GOTIFY_MCP_HOST,
453
+ port=GOTIFY_MCP_PORT,
454
+ log_level=GOTIFY_LOG_LEVEL_STR.lower(),
455
+ )
456
+
457
+
458
+ if __name__ == "__main__":
459
+ main()
@@ -0,0 +1 @@
1
+ # Gotify MCP services layer