rootly-mcp-server 2.2.20__tar.gz → 2.2.22__tar.gz

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.
Files changed (84) hide show
  1. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/PKG-INFO +42 -2
  2. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/README.md +41 -1
  3. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/pyproject.toml +1 -1
  4. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/__main__.py +46 -0
  5. rootly_mcp_server-2.2.22/src/rootly_mcp_server/audit.py +104 -0
  6. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/code_mode.py +4 -0
  7. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/server.py +101 -3
  8. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/server_defaults.py +84 -0
  9. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/spec_transform.py +36 -0
  10. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/tools/incidents.py +147 -144
  11. rootly_mcp_server-2.2.22/tests/integration/local/test_tool_allowlists.py +140 -0
  12. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/unit/test_code_mode_module.py +4 -0
  13. rootly_mcp_server-2.2.22/tests/unit/test_main_transport.py +135 -0
  14. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/unit/test_server.py +141 -7
  15. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/unit/test_server_defaults_module.py +15 -1
  16. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/unit/test_tools.py +18 -0
  17. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/uv.lock +1 -1
  18. rootly_mcp_server-2.2.20/tests/unit/test_main_transport.py +0 -31
  19. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/.dockerignore +0 -0
  20. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/.gitattributes +0 -0
  21. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/.github/dependabot.yml +0 -0
  22. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/.github/openapi-audit-remote-baseline.json +0 -0
  23. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/.github/workflows/ci.yml +0 -0
  24. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/.github/workflows/dependency-review.yml +0 -0
  25. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/.github/workflows/openapi-audit.yml +0 -0
  26. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/.github/workflows/pypi-release.yml +0 -0
  27. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/.gitignore +0 -0
  28. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/.semaphore/deploy-staging.yml +0 -0
  29. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/.semaphore/deploy.yml +0 -0
  30. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/.semaphore/semaphore.yml +0 -0
  31. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/.semaphore/update-task-definition.sh +0 -0
  32. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/CHANGELOG.md +0 -0
  33. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/CONTRIBUTING.md +0 -0
  34. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/Dockerfile +0 -0
  35. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/LICENSE +0 -0
  36. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/examples/skills/README.md +0 -0
  37. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/gemini-extension.json +0 -0
  38. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/rootly-mcp-server-demo.gif +0 -0
  39. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/rootly_openapi.json +0 -0
  40. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/scripts/audit_openapi.py +0 -0
  41. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/scripts/setup-hooks.sh +0 -0
  42. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/server.json +0 -0
  43. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/__init__.py +0 -0
  44. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/client.py +0 -0
  45. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/data/__init__.py +0 -0
  46. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/exceptions.py +0 -0
  47. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/legacy_server.py +0 -0
  48. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/mcp_error.py +0 -0
  49. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/monitoring.py +0 -0
  50. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/och_client.py +0 -0
  51. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/pagination.py +0 -0
  52. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/payload_stripping.py +0 -0
  53. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/security.py +0 -0
  54. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/smart_utils.py +0 -0
  55. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/tools/__init__.py +0 -0
  56. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/tools/alerts.py +0 -0
  57. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/tools/oncall.py +0 -0
  58. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/tools/resources.py +0 -0
  59. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/transport.py +0 -0
  60. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/utils.py +0 -0
  61. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/src/rootly_mcp_server/validators.py +0 -0
  62. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/README.md +0 -0
  63. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/conftest.py +0 -0
  64. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/integration/local/test_basic.py +0 -0
  65. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/integration/local/test_smart_tools.py +0 -0
  66. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/integration/remote/test_essential.py +0 -0
  67. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/test_client.py +0 -0
  68. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/unit/test_alert_stripping.py +0 -0
  69. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/unit/test_authentication.py +0 -0
  70. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/unit/test_exceptions.py +0 -0
  71. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/unit/test_http_headers.py +0 -0
  72. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/unit/test_legacy_server_module.py +0 -0
  73. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/unit/test_mcp_error_module.py +0 -0
  74. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/unit/test_och_client.py +0 -0
  75. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/unit/test_oncall_handoff.py +0 -0
  76. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/unit/test_oncall_metrics.py +0 -0
  77. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/unit/test_oncall_new_tools.py +0 -0
  78. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/unit/test_oncall_severity_normalization.py +0 -0
  79. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/unit/test_payload_stripping_module.py +0 -0
  80. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/unit/test_security.py +0 -0
  81. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/unit/test_smart_utils.py +0 -0
  82. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/unit/test_transport_module.py +0 -0
  83. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/unit/test_utils.py +0 -0
  84. {rootly_mcp_server-2.2.20 → rootly_mcp_server-2.2.22}/tests/unit/test_validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rootly-mcp-server
3
- Version: 2.2.20
3
+ Version: 2.2.22
4
4
  Summary: Secure Model Context Protocol server for Rootly APIs with AI SRE capabilities, comprehensive error handling, and input validation
5
5
  Project-URL: Homepage, https://github.com/Rootly-AI-Labs/Rootly-MCP-server
6
6
  Project-URL: Issues, https://github.com/Rootly-AI-Labs/Rootly-MCP-server/issues
@@ -334,7 +334,8 @@ A **Global API Key** is recommended for organization-wide queries and for action
334
334
  "rootly-mcp-server"
335
335
  ],
336
336
  "env": {
337
- "ROOTLY_API_TOKEN": "<YOUR_ROOTLY_API_TOKEN>"
337
+ "ROOTLY_API_TOKEN": "<YOUR_ROOTLY_API_TOKEN>",
338
+ "ROOTLY_MCP_ENABLE_WRITE_TOOLS": "true"
338
339
  }
339
340
  }
340
341
  }
@@ -349,12 +350,51 @@ Choose one transport per server process:
349
350
  - **SSE** endpoint path: `/sse`
350
351
  - **Code Mode (experimental)** endpoint path: `/mcp-codemode` in hosted dual-transport mode
351
352
 
353
+ Both hosted and self-hosted deployments expose all tools by default (read and write) for consistency. To restrict to read-only tools, start the server with `--no-enable-write-tools` or set `ROOTLY_MCP_ENABLE_WRITE_TOOLS=false`.
354
+
355
+ To expose only a specific subset of MCP tools on a self-hosted deployment, set `ROOTLY_MCP_ENABLED_TOOLS` (or pass `--enabled-tools`) with a comma-separated allowlist of exact tool names, for example `list_incidents,getIncident,get_server_version`.
356
+
357
+ To discover the exact tool names available under your current self-hosted configuration, run:
358
+
359
+ ```bash
360
+ ROOTLY_API_TOKEN=<YOUR_ROOTLY_API_TOKEN> \
361
+ uv run python -m rootly_mcp_server --list-tools
362
+ ```
363
+
364
+ This prints the effective MCP tool names after applying your current settings, including `ROOTLY_MCP_ENABLE_WRITE_TOOLS` and `ROOTLY_MCP_ENABLED_TOOLS`.
365
+
366
+ Smoke-test a self-hosted allowlist:
367
+
368
+ ```bash
369
+ ROOTLY_API_TOKEN=<YOUR_ROOTLY_API_TOKEN> \
370
+ ROOTLY_MCP_ENABLED_TOOLS=list_incidents,getIncident,get_server_version \
371
+ uv run python -m rootly_mcp_server --transport streamable-http --log-level ERROR
372
+ ```
373
+
374
+ Then connect an MCP client to `http://127.0.0.1:8000/mcp` and verify `tools/list` returns only:
375
+
376
+ ```text
377
+ get_server_version
378
+ getIncident
379
+ list_incidents
380
+ ```
381
+
382
+ To include specific write tools for self-hosted testing, add both the write flag and the allowlist:
383
+
384
+ ```bash
385
+ ROOTLY_API_TOKEN=<YOUR_ROOTLY_API_TOKEN> \
386
+ ROOTLY_MCP_ENABLE_WRITE_TOOLS=true \
387
+ ROOTLY_MCP_ENABLED_TOOLS=createIncident,createWorkflowTask,listTeams \
388
+ uv run python -m rootly_mcp_server --transport streamable-http --log-level ERROR
389
+ ```
390
+
352
391
  Example Docker run (Streamable HTTP):
353
392
 
354
393
  ```bash
355
394
  docker run -p 8000:8000 \
356
395
  -e ROOTLY_TRANSPORT=streamable-http \
357
396
  -e ROOTLY_API_TOKEN=<YOUR_ROOTLY_API_TOKEN> \
397
+ -e ROOTLY_MCP_ENABLE_WRITE_TOOLS=true \
358
398
  rootly-mcp-server
359
399
  ```
360
400
 
@@ -290,7 +290,8 @@ A **Global API Key** is recommended for organization-wide queries and for action
290
290
  "rootly-mcp-server"
291
291
  ],
292
292
  "env": {
293
- "ROOTLY_API_TOKEN": "<YOUR_ROOTLY_API_TOKEN>"
293
+ "ROOTLY_API_TOKEN": "<YOUR_ROOTLY_API_TOKEN>",
294
+ "ROOTLY_MCP_ENABLE_WRITE_TOOLS": "true"
294
295
  }
295
296
  }
296
297
  }
@@ -305,12 +306,51 @@ Choose one transport per server process:
305
306
  - **SSE** endpoint path: `/sse`
306
307
  - **Code Mode (experimental)** endpoint path: `/mcp-codemode` in hosted dual-transport mode
307
308
 
309
+ Both hosted and self-hosted deployments expose all tools by default (read and write) for consistency. To restrict to read-only tools, start the server with `--no-enable-write-tools` or set `ROOTLY_MCP_ENABLE_WRITE_TOOLS=false`.
310
+
311
+ To expose only a specific subset of MCP tools on a self-hosted deployment, set `ROOTLY_MCP_ENABLED_TOOLS` (or pass `--enabled-tools`) with a comma-separated allowlist of exact tool names, for example `list_incidents,getIncident,get_server_version`.
312
+
313
+ To discover the exact tool names available under your current self-hosted configuration, run:
314
+
315
+ ```bash
316
+ ROOTLY_API_TOKEN=<YOUR_ROOTLY_API_TOKEN> \
317
+ uv run python -m rootly_mcp_server --list-tools
318
+ ```
319
+
320
+ This prints the effective MCP tool names after applying your current settings, including `ROOTLY_MCP_ENABLE_WRITE_TOOLS` and `ROOTLY_MCP_ENABLED_TOOLS`.
321
+
322
+ Smoke-test a self-hosted allowlist:
323
+
324
+ ```bash
325
+ ROOTLY_API_TOKEN=<YOUR_ROOTLY_API_TOKEN> \
326
+ ROOTLY_MCP_ENABLED_TOOLS=list_incidents,getIncident,get_server_version \
327
+ uv run python -m rootly_mcp_server --transport streamable-http --log-level ERROR
328
+ ```
329
+
330
+ Then connect an MCP client to `http://127.0.0.1:8000/mcp` and verify `tools/list` returns only:
331
+
332
+ ```text
333
+ get_server_version
334
+ getIncident
335
+ list_incidents
336
+ ```
337
+
338
+ To include specific write tools for self-hosted testing, add both the write flag and the allowlist:
339
+
340
+ ```bash
341
+ ROOTLY_API_TOKEN=<YOUR_ROOTLY_API_TOKEN> \
342
+ ROOTLY_MCP_ENABLE_WRITE_TOOLS=true \
343
+ ROOTLY_MCP_ENABLED_TOOLS=createIncident,createWorkflowTask,listTeams \
344
+ uv run python -m rootly_mcp_server --transport streamable-http --log-level ERROR
345
+ ```
346
+
308
347
  Example Docker run (Streamable HTTP):
309
348
 
310
349
  ```bash
311
350
  docker run -p 8000:8000 \
312
351
  -e ROOTLY_TRANSPORT=streamable-http \
313
352
  -e ROOTLY_API_TOKEN=<YOUR_ROOTLY_API_TOKEN> \
353
+ -e ROOTLY_MCP_ENABLE_WRITE_TOOLS=true \
314
354
  rootly-mcp-server
315
355
  ```
316
356
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rootly-mcp-server"
3
- version = "2.2.20"
3
+ version = "2.2.22"
4
4
  description = "Secure Model Context Protocol server for Rootly APIs with AI SRE capabilities, comprehensive error handling, and input validation"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -6,6 +6,7 @@ This module provides the main entry point for the Rootly MCP Server.
6
6
  """
7
7
 
8
8
  import argparse
9
+ import asyncio
9
10
  import logging
10
11
  import os
11
12
  import sys
@@ -22,6 +23,7 @@ from .code_mode import (
22
23
  from .exceptions import RootlyConfigurationError, RootlyMCPError
23
24
  from .security import validate_api_token
24
25
  from .server import create_rootly_mcp_server, get_hosted_auth_middleware
26
+ from .server_defaults import enabled_tools_from_env, write_tools_enabled_from_env
25
27
 
26
28
  TransportName = Literal["stdio", "sse", "streamable-http", "both"]
27
29
  TRANSPORT_ALIASES: dict[str, TransportName] = {
@@ -113,6 +115,23 @@ def parse_args():
113
115
  action="store_true",
114
116
  help="Expose a separate hosted Code Mode endpoint (HTTP only)",
115
117
  )
118
+ parser.add_argument(
119
+ "--no-enable-write-tools",
120
+ dest="enable_write_tools",
121
+ action="store_false",
122
+ default=True,
123
+ help="Disable write tools to expose read-only operations",
124
+ )
125
+ parser.add_argument(
126
+ "--enabled-tools",
127
+ type=str,
128
+ help="Comma-separated allowlist of exact MCP tool names to expose",
129
+ )
130
+ parser.add_argument(
131
+ "--list-tools",
132
+ action="store_true",
133
+ help="Print the exact MCP tool names exposed by the current configuration, then exit",
134
+ )
116
135
  parser.add_argument(
117
136
  "--code-mode-path",
118
137
  type=str,
@@ -183,6 +202,8 @@ def get_server():
183
202
  hosted = os.getenv("ROOTLY_HOSTED", "false").lower() in ("true", "1", "yes")
184
203
  base_url = os.getenv("ROOTLY_BASE_URL")
185
204
  transport = normalize_transport_or_default(os.getenv("ROOTLY_TRANSPORT", "stdio"))
205
+ enable_write_tools = write_tools_enabled_from_env(default=True)
206
+ enabled_tools = enabled_tools_from_env()
186
207
 
187
208
  # Parse allowed paths from environment variable
188
209
  allowed_paths = None
@@ -198,9 +219,17 @@ def get_server():
198
219
  hosted=hosted,
199
220
  base_url=base_url,
200
221
  transport=transport,
222
+ enable_write_tools=enable_write_tools,
223
+ enabled_tools=enabled_tools,
201
224
  )
202
225
 
203
226
 
227
+ async def _get_sorted_tool_names(server) -> list[str]:
228
+ """Return the effective MCP tool names for the provided server."""
229
+ tools = await server.list_tools()
230
+ return sorted(tool.name for tool in tools)
231
+
232
+
204
233
  # Create the server instance for FastMCP CLI (follows quickstart pattern).
205
234
  # Avoid eager construction when executing `python -m rootly_mcp_server`, because
206
235
  # CLI flags like `--hosted` and `--transport` are parsed later in `main()`.
@@ -368,11 +397,19 @@ def main():
368
397
  allowed_paths = None
369
398
  if args.allowed_paths:
370
399
  allowed_paths = [path.strip() for path in args.allowed_paths.split(",")]
400
+ enabled_tools = (
401
+ {tool.strip() for tool in args.enabled_tools.split(",") if tool.strip()}
402
+ if args.enabled_tools
403
+ else enabled_tools_from_env()
404
+ )
371
405
 
372
406
  logger.info(f"Initializing server with name: {args.name}")
373
407
  # argparse already normalizes/validates --transport via type=normalize_transport
374
408
  normalized_transport = args.transport
375
409
  code_mode_enabled = args.enable_code_mode or code_mode_enabled_from_env(default=True)
410
+ enable_write_tools = args.enable_write_tools or write_tools_enabled_from_env(
411
+ default=hosted_mode
412
+ )
376
413
  code_mode_path = (
377
414
  normalize_code_mode_path(args.code_mode_path)
378
415
  if args.code_mode_path
@@ -385,8 +422,15 @@ def main():
385
422
  hosted=hosted_mode,
386
423
  base_url=args.base_url,
387
424
  transport=normalized_transport,
425
+ enable_write_tools=enable_write_tools,
426
+ enabled_tools=enabled_tools,
388
427
  )
389
428
 
429
+ if args.list_tools:
430
+ for tool_name in asyncio.run(_get_sorted_tool_names(server)):
431
+ print(tool_name)
432
+ return
433
+
390
434
  code_mode_server = None
391
435
  if code_mode_enabled:
392
436
  if not hosted_mode:
@@ -403,6 +447,8 @@ def main():
403
447
  allowed_paths=allowed_paths,
404
448
  hosted=hosted_mode,
405
449
  base_url=args.base_url,
450
+ enable_write_tools=enable_write_tools,
451
+ enabled_tools=enabled_tools,
406
452
  )
407
453
  logger.info("Code Mode enabled at path: %s", code_mode_path)
408
454
 
@@ -0,0 +1,104 @@
1
+ """Security audit logging for Rootly MCP Server."""
2
+
3
+ import json
4
+ import logging
5
+ import time
6
+ from contextvars import ContextVar
7
+ from typing import Any
8
+
9
+ # Context variables for tracking session info
10
+ current_session: ContextVar[str | None] = ContextVar("current_session", default=None)
11
+ current_user: ContextVar[str | None] = ContextVar("current_user", default=None)
12
+
13
+
14
+ class AuditLogger:
15
+ """Structured audit logger for security-relevant events."""
16
+
17
+ def __init__(self, logger_name: str = "rootly_mcp_server.audit"):
18
+ self.logger = logging.getLogger(logger_name)
19
+ self.logger.setLevel(logging.INFO)
20
+
21
+ # JSON formatter for structured logs
22
+ formatter = logging.Formatter(
23
+ '{"timestamp": "%(asctime)s", "level": "%(levelname)s", "audit_event": %(message)s}'
24
+ )
25
+
26
+ # Add console handler if none exists
27
+ if not self.logger.handlers:
28
+ handler = logging.StreamHandler()
29
+ handler.setFormatter(formatter)
30
+ self.logger.addHandler(handler)
31
+
32
+ def log_permission_change(self, action: str, details: dict[str, Any]):
33
+ """Log permission configuration changes."""
34
+ event = {
35
+ "event_type": "permission_change",
36
+ "action": action,
37
+ "session_id": current_session.get(),
38
+ "user_id": current_user.get(),
39
+ "timestamp": time.time(),
40
+ **details,
41
+ }
42
+ self.logger.info(json.dumps(event))
43
+
44
+ def log_tool_validation(
45
+ self, enabled_tools: set[str], valid_tools: set[str], invalid_tools: list[str]
46
+ ):
47
+ """Log tool name validation results."""
48
+ event = {
49
+ "event_type": "tool_validation",
50
+ "requested_tools_count": len(enabled_tools),
51
+ "valid_tools_count": len(valid_tools),
52
+ "invalid_tools_count": len(invalid_tools),
53
+ "invalid_tools": invalid_tools,
54
+ "session_id": current_session.get(),
55
+ "timestamp": time.time(),
56
+ }
57
+ self.logger.info(json.dumps(event))
58
+
59
+ def log_server_start(self, config: dict[str, Any]):
60
+ """Log server startup with security-relevant configuration."""
61
+ event = {
62
+ "event_type": "server_start",
63
+ "write_tools_enabled": config.get("enable_write_tools", True),
64
+ "tool_count": config.get("tool_count", 0),
65
+ "hosted_mode": config.get("hosted", False),
66
+ "allowlist_enabled": bool(config.get("enabled_tools")),
67
+ "transport": config.get("transport", "unknown"),
68
+ "timestamp": time.time(),
69
+ }
70
+ self.logger.info(json.dumps(event))
71
+
72
+ def log_tool_access_attempt(
73
+ self, tool_name: str, method: str, success: bool, details: dict[str, Any] | None = None
74
+ ):
75
+ """Log individual tool access attempts."""
76
+ event = {
77
+ "event_type": "tool_access",
78
+ "tool_name": tool_name,
79
+ "http_method": method,
80
+ "success": success,
81
+ "session_id": current_session.get(),
82
+ "user_id": current_user.get(),
83
+ "timestamp": time.time(),
84
+ **(details or {}),
85
+ }
86
+ self.logger.info(json.dumps(event))
87
+
88
+ def log_configuration_error(
89
+ self, error_type: str, message: str, details: dict[str, Any] | None = None
90
+ ):
91
+ """Log configuration validation errors."""
92
+ event = {
93
+ "event_type": "configuration_error",
94
+ "error_type": error_type,
95
+ "message": message,
96
+ "session_id": current_session.get(),
97
+ "timestamp": time.time(),
98
+ **(details or {}),
99
+ }
100
+ self.logger.warning(json.dumps(event))
101
+
102
+
103
+ # Global audit logger instance
104
+ audit = AuditLogger()
@@ -280,6 +280,8 @@ def create_rootly_codemode_server(
280
280
  allowed_paths: list[str] | None = None,
281
281
  hosted: bool = False,
282
282
  base_url: str | None = None,
283
+ enable_write_tools: bool | None = None,
284
+ enabled_tools: set[str] | None = None,
283
285
  ) -> FastMCP:
284
286
  """Create a Rootly MCP server instance wrapped with Code Mode."""
285
287
  mcp: FastMCP = create_rootly_mcp_server(
@@ -289,6 +291,8 @@ def create_rootly_codemode_server(
289
291
  hosted=hosted,
290
292
  base_url=base_url,
291
293
  transport="streamable-http",
294
+ enable_write_tools=enable_write_tools,
295
+ enabled_tools=enabled_tools,
292
296
  )
293
297
  mcp.add_transform(build_code_mode_transform())
294
298
  return mcp
@@ -17,7 +17,7 @@ import fastmcp.server.middleware as fastmcp_middleware
17
17
  import mcp.types as mt
18
18
  from fastmcp import FastMCP
19
19
 
20
- from . import legacy_server, payload_stripping, server_defaults, spec_transform, transport
20
+ from . import audit, legacy_server, payload_stripping, server_defaults, spec_transform, transport
21
21
  from .exceptions import RootlyAuthenticationError
22
22
  from .mcp_error import MCPError
23
23
  from .security import mask_sensitive_data, sanitize_error_message
@@ -66,6 +66,7 @@ strip_heavy_nested_data = payload_stripping.strip_heavy_nested_data
66
66
  _generate_recommendation = server_defaults._generate_recommendation
67
67
  DEFAULT_ALLOWED_PATHS = server_defaults.DEFAULT_ALLOWED_PATHS
68
68
  DEFAULT_DELETE_ALLOWED_PATHS = server_defaults.DEFAULT_DELETE_ALLOWED_PATHS
69
+ DEFAULT_WRITE_ALLOWED_PATHS = server_defaults.DEFAULT_WRITE_ALLOWED_PATHS
69
70
  RootlyMCPServer = legacy_server.RootlyMCPServer
70
71
 
71
72
 
@@ -375,6 +376,9 @@ def create_rootly_mcp_server(
375
376
  base_url: str | None = None,
376
377
  transport: str = "stdio",
377
378
  delete_allowed_paths: list[str] | None = None,
379
+ enable_write_tools: bool | None = None,
380
+ write_allowed_paths: list[str] | None = None,
381
+ enabled_tools: set[str] | None = None,
378
382
  ) -> FastMCP:
379
383
  """
380
384
  Create a Rootly MCP Server using FastMCP's OpenAPI integration.
@@ -384,10 +388,16 @@ def create_rootly_mcp_server(
384
388
  name: Name of the MCP server.
385
389
  allowed_paths: List of API paths to include. If None, includes default paths.
386
390
  delete_allowed_paths: Path templates where DELETE operations are exposed.
387
- If None, uses DEFAULT_DELETE_ALLOWED_PATHS.
391
+ If None, destructive delete tools remain disabled by default.
388
392
  hosted: Whether the server is hosted (affects authentication).
389
393
  base_url: Base URL for Rootly API. If None, uses ROOTLY_BASE_URL env var or default.
390
394
  transport: Transport protocol (stdio, sse, or streamable-http).
395
+ enable_write_tools: Whether non-destructive write tools are exposed.
396
+ If None, uses ROOTLY_MCP_ENABLE_WRITE_TOOLS.
397
+ write_allowed_paths: Path templates where POST/PUT/PATCH operations are exposed
398
+ when write tools are enabled. If None, uses DEFAULT_WRITE_ALLOWED_PATHS.
399
+ enabled_tools: Optional allowlist of exact MCP tool names to expose.
400
+ If None, uses ROOTLY_MCP_ENABLED_TOOLS when set.
391
401
 
392
402
  Returns:
393
403
  A FastMCP server instance.
@@ -395,8 +405,14 @@ def create_rootly_mcp_server(
395
405
  # Set default allowed paths if none provided
396
406
  if allowed_paths is None:
397
407
  allowed_paths = DEFAULT_ALLOWED_PATHS
408
+ if enable_write_tools is None:
409
+ enable_write_tools = server_defaults.write_tools_enabled_from_env(default=True)
410
+ if enabled_tools is None:
411
+ enabled_tools = server_defaults.enabled_tools_from_env()
398
412
  if delete_allowed_paths is None:
399
- delete_allowed_paths = DEFAULT_DELETE_ALLOWED_PATHS
413
+ delete_allowed_paths = []
414
+ if write_allowed_paths is None:
415
+ write_allowed_paths = DEFAULT_WRITE_ALLOWED_PATHS if enable_write_tools else []
400
416
 
401
417
  # Add /v1 prefix to paths if not present
402
418
  allowed_paths_v1 = [
@@ -405,6 +421,9 @@ def create_rootly_mcp_server(
405
421
  delete_allowed_paths_v1 = [
406
422
  f"/v1{path}" if not path.startswith("/v1") else path for path in delete_allowed_paths
407
423
  ]
424
+ write_allowed_paths_v1 = [
425
+ f"/v1{path}" if not path.startswith("/v1") else path for path in write_allowed_paths
426
+ ]
408
427
 
409
428
  logger.info(f"Creating Rootly MCP Server with allowed paths: {allowed_paths_v1}")
410
429
 
@@ -417,9 +436,73 @@ def create_rootly_mcp_server(
417
436
  swagger_spec,
418
437
  allowed_paths_v1,
419
438
  delete_allowed_paths=delete_allowed_paths_v1,
439
+ write_allowed_paths=write_allowed_paths_v1,
440
+ enable_write_tools=enable_write_tools,
441
+ enabled_operation_ids=enabled_tools,
420
442
  )
443
+
444
+ # Validate enabled tools against the filtered spec (after path filtering)
445
+ if enabled_tools:
446
+ valid_tools, invalid_tools = server_defaults.validate_tool_names(
447
+ enabled_tools, filtered_spec.get("paths", {})
448
+ )
449
+
450
+ if invalid_tools:
451
+ audit.audit.log_configuration_error(
452
+ "invalid_tool_names",
453
+ f"Invalid tool names in allowlist after filtering: {', '.join(invalid_tools)}",
454
+ {"invalid_tools": invalid_tools, "valid_tools": list(valid_tools)},
455
+ )
456
+ logger.warning(
457
+ "Invalid tool names in allowlist (will be ignored): %s. "
458
+ "Use --list-tools to see available options.",
459
+ ", ".join(sorted(invalid_tools)),
460
+ )
461
+
462
+ if not valid_tools and enabled_tools:
463
+ error_msg = "No valid tools found in allowlist after path filtering"
464
+ audit.audit.log_configuration_error(
465
+ "no_valid_tools", error_msg, {"requested_tools": list(enabled_tools)}
466
+ )
467
+ raise ValueError(error_msg)
468
+
469
+ # Log validation results
470
+ audit.audit.log_tool_validation(enabled_tools, valid_tools, invalid_tools)
471
+
472
+ # Update the filtered spec to only include valid tools
473
+ if valid_tools != enabled_tools:
474
+ filtered_spec = _filter_openapi_spec(
475
+ swagger_spec,
476
+ allowed_paths_v1,
477
+ delete_allowed_paths=delete_allowed_paths_v1,
478
+ write_allowed_paths=write_allowed_paths_v1,
479
+ enable_write_tools=enable_write_tools,
480
+ enabled_operation_ids=valid_tools,
481
+ )
421
482
  logger.info(f"Filtered spec to {len(filtered_spec.get('paths', {}))} allowed paths")
422
483
 
484
+ # Log server configuration for audit trail
485
+ config_info = {
486
+ "enable_write_tools": enable_write_tools,
487
+ "tool_count": len(filtered_spec.get("paths", {})),
488
+ "hosted": hosted,
489
+ "enabled_tools": list(enabled_tools) if enabled_tools else None,
490
+ "transport": transport,
491
+ "server_name": name,
492
+ }
493
+ audit.audit.log_server_start(config_info)
494
+
495
+ # Log permission changes
496
+ if enable_write_tools:
497
+ audit.audit.log_permission_change(
498
+ "write_tools_enabled",
499
+ {
500
+ "reason": "explicit_configuration",
501
+ "write_paths_count": len(write_allowed_paths_v1),
502
+ "hosted_mode": hosted,
503
+ },
504
+ )
505
+
423
506
  # Sanitize all parameter names in the filtered spec to be MCP-compliant
424
507
  parameter_mapping = sanitize_parameters_in_spec(filtered_spec)
425
508
  logger.info(
@@ -559,6 +642,7 @@ def create_rootly_mcp_server(
559
642
  strip_heavy_nested_data=strip_heavy_nested_data,
560
643
  mcp_error=MCPError,
561
644
  generate_recommendation=_generate_recommendation,
645
+ enable_write_tools=enable_write_tools,
562
646
  )
563
647
 
564
648
  register_oncall_tools(
@@ -580,6 +664,20 @@ def create_rootly_mcp_server(
580
664
  mcp_error=MCPError,
581
665
  )
582
666
 
667
+ if enabled_tools is not None:
668
+ component_names = [
669
+ component.name
670
+ for component_key, component in mcp._local_provider._components.items() # noqa: SLF001
671
+ if component_key.startswith("tool:") and getattr(component, "name", None)
672
+ ]
673
+ for tool_name in component_names:
674
+ if tool_name not in enabled_tools:
675
+ mcp.local_provider.remove_tool(tool_name)
676
+ logger.info(
677
+ "Applied MCP tool allowlist: %s",
678
+ ", ".join(sorted(enabled_tools)),
679
+ )
680
+
583
681
  # In hosted HTTP modes, configure ASGI middleware for auth token capture.
584
682
  # Callers retrieve via get_hosted_auth_middleware() and pass to server.run(middleware=...).
585
683
  global _hosted_auth_middleware
@@ -2,6 +2,36 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import logging
6
+ import os
7
+
8
+ # Set up logger
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ # Environment variable constants
13
+ class EnvVars:
14
+ """Centralized environment variable names for type safety and IDE support."""
15
+
16
+ API_TOKEN = "ROOTLY_API_TOKEN" # nosec B105 - Environment variable name, not password
17
+ BASE_URL = "ROOTLY_BASE_URL"
18
+ SERVER_NAME = "ROOTLY_SERVER_NAME"
19
+ HOSTED = "ROOTLY_HOSTED"
20
+ ENABLE_WRITE_TOOLS = "ROOTLY_MCP_ENABLE_WRITE_TOOLS"
21
+ ENABLED_TOOLS = "ROOTLY_MCP_ENABLED_TOOLS"
22
+ TRANSPORT = "ROOTLY_TRANSPORT"
23
+ ALLOWED_PATHS = "ROOTLY_ALLOWED_PATHS"
24
+ SWAGGER_PATH = "ROOTLY_SWAGGER_PATH"
25
+ LOG_LEVEL = "ROOTLY_LOG_LEVEL"
26
+
27
+
28
+ def _parse_csv_set(raw: str | None) -> set[str] | None:
29
+ """Parse a comma-separated environment value into a normalized set."""
30
+ if raw is None:
31
+ return None
32
+ parsed = {item.strip() for item in raw.split(",") if item.strip()}
33
+ return parsed or None
34
+
5
35
 
6
36
  def _generate_recommendation(solution_data: dict) -> str:
7
37
  """Generate a high-level recommendation based on solution analysis."""
@@ -41,8 +71,51 @@ def _generate_recommendation(solution_data: dict) -> str:
41
71
  )
42
72
 
43
73
 
74
+ def write_tools_enabled_from_env(default: bool = False) -> bool:
75
+ """Return whether non-destructive write tools should be exposed."""
76
+ raw = os.getenv(EnvVars.ENABLE_WRITE_TOOLS)
77
+ if raw is None:
78
+ return default
79
+ return raw.strip().lower() in ("1", "true", "yes", "on")
80
+
81
+
82
+ def validate_tool_names(
83
+ enabled_tools: set[str], available_operations: dict
84
+ ) -> tuple[set[str], list[str]]:
85
+ """
86
+ Validate tool names against available OpenAPI operations.
87
+
88
+ Args:
89
+ enabled_tools: Set of tool names to validate
90
+ available_operations: OpenAPI paths dict from swagger spec
91
+
92
+ Returns:
93
+ tuple: (valid_tools, invalid_tools)
94
+ """
95
+ if not enabled_tools:
96
+ return set(), []
97
+
98
+ # Extract all available operation IDs from OpenAPI spec
99
+ available_tool_names = set()
100
+ for path_data in available_operations.values():
101
+ for method_data in path_data.values():
102
+ if isinstance(method_data, dict) and (operation_id := method_data.get("operationId")):
103
+ available_tool_names.add(operation_id)
104
+
105
+ valid_tools = enabled_tools & available_tool_names
106
+ invalid_tools = list(enabled_tools - available_tool_names)
107
+
108
+ return valid_tools, invalid_tools
109
+
110
+
111
+ def enabled_tools_from_env() -> set[str] | None:
112
+ """Return an optional explicit allowlist of MCP tool names to expose."""
113
+ return _parse_csv_set(os.getenv(EnvVars.ENABLED_TOOLS))
114
+
115
+
44
116
  # Default allowed API paths
45
117
  DEFAULT_ALLOWED_PATHS = [
118
+ "/incidents",
46
119
  "/incidents/{incident_id}/alerts",
47
120
  "/alerts",
48
121
  "/alerts/{id}",
@@ -109,6 +182,17 @@ DEFAULT_ALLOWED_PATHS = [
109
182
  "/on_call_roles/{on_call_role_id}",
110
183
  ]
111
184
 
185
+ # Non-destructive write operations are only exposed for these path families when
186
+ # write tools are explicitly enabled. This keeps the default surface focused on
187
+ # read-only workflows and avoids exposing broader admin/config writes.
188
+ DEFAULT_WRITE_ALLOWED_PATHS = [
189
+ "/incidents/{incident_id}/action_items",
190
+ "/incidents/{incident_id}/form_field_selections",
191
+ "/incident_form_field_selections/{id}",
192
+ "/workflows/{workflow_id}/workflow_tasks",
193
+ "/workflow_tasks/{id}",
194
+ ]
195
+
112
196
  # DELETE operations are only exposed for these high-priority screenshot families.
113
197
  # All other DELETE operations remain disabled in MCP by default.
114
198
  DEFAULT_DELETE_ALLOWED_PATHS = [