opsinsight-mcp 0.1.0__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.
- opsinsight_mcp-0.1.0/.dockerignore +18 -0
- opsinsight_mcp-0.1.0/.env.example +40 -0
- opsinsight_mcp-0.1.0/.github/workflows/ci.yml +43 -0
- opsinsight_mcp-0.1.0/.github/workflows/deploy.yml +52 -0
- opsinsight_mcp-0.1.0/.github/workflows/publish.yml +66 -0
- opsinsight_mcp-0.1.0/.gitignore +20 -0
- opsinsight_mcp-0.1.0/.python-version +1 -0
- opsinsight_mcp-0.1.0/CLAUDE.md +161 -0
- opsinsight_mcp-0.1.0/CONTRIBUTING.md +69 -0
- opsinsight_mcp-0.1.0/Dockerfile +20 -0
- opsinsight_mcp-0.1.0/LICENSE +21 -0
- opsinsight_mcp-0.1.0/PKG-INFO +279 -0
- opsinsight_mcp-0.1.0/README.md +255 -0
- opsinsight_mcp-0.1.0/docs/agensi-skill/LISTING.md +33 -0
- opsinsight_mcp-0.1.0/docs/agensi-skill/support-ticket-investigation/SKILL.md +70 -0
- opsinsight_mcp-0.1.0/docs/agensi-skill/support-ticket-investigation/reference/query-cookbook.md +77 -0
- opsinsight_mcp-0.1.0/docs/agensi-skill/support-ticket-investigation/reference/setup.md +38 -0
- opsinsight_mcp-0.1.0/docs/agensi-skill/support-ticket-investigation/reference/tools.md +34 -0
- opsinsight_mcp-0.1.0/docs/agensi-skill/support-ticket-investigation/scripts/env.example +26 -0
- opsinsight_mcp-0.1.0/docs/agensi-skill/support-ticket-investigation/scripts/mcp-config.example.json +8 -0
- opsinsight_mcp-0.1.0/docs/architecture.svg +62 -0
- opsinsight_mcp-0.1.0/docs/design.md +446 -0
- opsinsight_mcp-0.1.0/docs/logo-512.png +0 -0
- opsinsight_mcp-0.1.0/docs/logo-64.png +0 -0
- opsinsight_mcp-0.1.0/docs/logo.jpg +0 -0
- opsinsight_mcp-0.1.0/docs/logo.png +0 -0
- opsinsight_mcp-0.1.0/docs/logo.svg +153 -0
- opsinsight_mcp-0.1.0/docs/packaging-plan.md +153 -0
- opsinsight_mcp-0.1.0/docs/tool-reference.md +882 -0
- opsinsight_mcp-0.1.0/pyproject.toml +53 -0
- opsinsight_mcp-0.1.0/src/core/__init__.py +0 -0
- opsinsight_mcp-0.1.0/src/core/app.py +18 -0
- opsinsight_mcp-0.1.0/src/core/cache.py +84 -0
- opsinsight_mcp-0.1.0/src/core/config.py +146 -0
- opsinsight_mcp-0.1.0/src/core/container.py +85 -0
- opsinsight_mcp-0.1.0/src/core/errors.py +42 -0
- opsinsight_mcp-0.1.0/src/core/field_heuristics.py +8 -0
- opsinsight_mcp-0.1.0/src/core/logging_config.py +71 -0
- opsinsight_mcp-0.1.0/src/core/models.py +12 -0
- opsinsight_mcp-0.1.0/src/core/redaction.py +101 -0
- opsinsight_mcp-0.1.0/src/data/query_cookbook.yaml +205 -0
- opsinsight_mcp-0.1.0/src/integrations/__init__.py +0 -0
- opsinsight_mcp-0.1.0/src/integrations/cloudwatch/__init__.py +0 -0
- opsinsight_mcp-0.1.0/src/integrations/cloudwatch/client.py +70 -0
- opsinsight_mcp-0.1.0/src/integrations/cloudwatch/constants.py +17 -0
- opsinsight_mcp-0.1.0/src/integrations/cloudwatch/guardrail.py +126 -0
- opsinsight_mcp-0.1.0/src/integrations/cloudwatch/models.py +83 -0
- opsinsight_mcp-0.1.0/src/integrations/cloudwatch/queries.py +7 -0
- opsinsight_mcp-0.1.0/src/integrations/database/__init__.py +0 -0
- opsinsight_mcp-0.1.0/src/integrations/database/constants.py +42 -0
- opsinsight_mcp-0.1.0/src/integrations/database/engine.py +73 -0
- opsinsight_mcp-0.1.0/src/integrations/database/guardrail.py +152 -0
- opsinsight_mcp-0.1.0/src/integrations/database/introspect.py +477 -0
- opsinsight_mcp-0.1.0/src/integrations/database/models.py +111 -0
- opsinsight_mcp-0.1.0/src/integrations/database/queries.py +76 -0
- opsinsight_mcp-0.1.0/src/integrations/newrelic/__init__.py +0 -0
- opsinsight_mcp-0.1.0/src/integrations/newrelic/client.py +57 -0
- opsinsight_mcp-0.1.0/src/integrations/newrelic/constants.py +16 -0
- opsinsight_mcp-0.1.0/src/integrations/newrelic/guardrail.py +46 -0
- opsinsight_mcp-0.1.0/src/integrations/newrelic/models.py +40 -0
- opsinsight_mcp-0.1.0/src/integrations/newrelic/queries.py +23 -0
- opsinsight_mcp-0.1.0/src/resources/__init__.py +1 -0
- opsinsight_mcp-0.1.0/src/resources/cookbook.py +27 -0
- opsinsight_mcp-0.1.0/src/resources/logs.py +11 -0
- opsinsight_mcp-0.1.0/src/resources/schema.py +21 -0
- opsinsight_mcp-0.1.0/src/server.py +50 -0
- opsinsight_mcp-0.1.0/src/service/__init__.py +5 -0
- opsinsight_mcp-0.1.0/src/service/base.py +31 -0
- opsinsight_mcp-0.1.0/src/service/cloudwatch_service.py +427 -0
- opsinsight_mcp-0.1.0/src/service/database_service.py +132 -0
- opsinsight_mcp-0.1.0/src/service/newrelic_service.py +130 -0
- opsinsight_mcp-0.1.0/src/tools/__init__.py +5 -0
- opsinsight_mcp-0.1.0/src/tools/cloudwatch/__init__.py +15 -0
- opsinsight_mcp-0.1.0/src/tools/cloudwatch/cw_describe_log_fields.py +13 -0
- opsinsight_mcp-0.1.0/src/tools/cloudwatch/cw_filter_events.py +9 -0
- opsinsight_mcp-0.1.0/src/tools/cloudwatch/cw_get_metric_stats.py +18 -0
- opsinsight_mcp-0.1.0/src/tools/cloudwatch/cw_get_trace_events.py +22 -0
- opsinsight_mcp-0.1.0/src/tools/cloudwatch/cw_list_log_groups.py +8 -0
- opsinsight_mcp-0.1.0/src/tools/cloudwatch/cw_run_insights_query.py +27 -0
- opsinsight_mcp-0.1.0/src/tools/database/__init__.py +17 -0
- opsinsight_mcp-0.1.0/src/tools/database/db_describe_table.py +9 -0
- opsinsight_mcp-0.1.0/src/tools/database/db_explain_query.py +8 -0
- opsinsight_mcp-0.1.0/src/tools/database/db_list_tables.py +8 -0
- opsinsight_mcp-0.1.0/src/tools/database/db_resolve_store.py +15 -0
- opsinsight_mcp-0.1.0/src/tools/database/db_run_query.py +8 -0
- opsinsight_mcp-0.1.0/src/tools/database/db_sample_rows.py +8 -0
- opsinsight_mcp-0.1.0/src/tools/database/db_search_by_identifier.py +17 -0
- opsinsight_mcp-0.1.0/src/tools/newrelic/__init__.py +9 -0
- opsinsight_mcp-0.1.0/src/tools/newrelic/nr_describe_log_fields.py +14 -0
- opsinsight_mcp-0.1.0/src/tools/newrelic/nr_list_event_types.py +12 -0
- opsinsight_mcp-0.1.0/src/tools/newrelic/nr_run_nrql_query.py +9 -0
- opsinsight_mcp-0.1.0/tests/__init__.py +0 -0
- opsinsight_mcp-0.1.0/tests/conftest.py +71 -0
- opsinsight_mcp-0.1.0/tests/integration/__init__.py +0 -0
- opsinsight_mcp-0.1.0/tests/integration/test_db_tools.py +69 -0
- opsinsight_mcp-0.1.0/tests/unit/__init__.py +0 -0
- opsinsight_mcp-0.1.0/tests/unit/test_cache.py +20 -0
- opsinsight_mcp-0.1.0/tests/unit/test_cloudwatch_tools.py +206 -0
- opsinsight_mcp-0.1.0/tests/unit/test_config.py +49 -0
- opsinsight_mcp-0.1.0/tests/unit/test_container.py +59 -0
- opsinsight_mcp-0.1.0/tests/unit/test_cw_guardrail.py +139 -0
- opsinsight_mcp-0.1.0/tests/unit/test_database_engine.py +21 -0
- opsinsight_mcp-0.1.0/tests/unit/test_database_queries.py +24 -0
- opsinsight_mcp-0.1.0/tests/unit/test_db_guardrail.py +122 -0
- opsinsight_mcp-0.1.0/tests/unit/test_db_introspect.py +32 -0
- opsinsight_mcp-0.1.0/tests/unit/test_logging.py +53 -0
- opsinsight_mcp-0.1.0/tests/unit/test_newrelic_guardrail.py +67 -0
- opsinsight_mcp-0.1.0/tests/unit/test_newrelic_tools.py +83 -0
- opsinsight_mcp-0.1.0/tests/unit/test_redaction.py +81 -0
- opsinsight_mcp-0.1.0/tests/unit/test_schema_resources.py +37 -0
- opsinsight_mcp-0.1.0/uv.lock +1900 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Copy to .env and fill in your PostgreSQL connection details.
|
|
2
|
+
|
|
3
|
+
# Database — provide your full PostgreSQL connection URL (asyncpg driver)
|
|
4
|
+
DB_URL=postgresql+asyncpg://user:password@host:5432/dbname
|
|
5
|
+
DB_MAX_ROWS=500
|
|
6
|
+
DB_STATEMENT_TIMEOUT_MS=15000
|
|
7
|
+
DB_IDLE_TXN_TIMEOUT_MS=30000
|
|
8
|
+
DB_MAX_CELL_BYTES=4096
|
|
9
|
+
DB_SCHEMA_CACHE_TTL_S=600
|
|
10
|
+
|
|
11
|
+
# AWS / CloudWatch — CLOUDWATCH_REGION is required (there is no AWS_REGION
|
|
12
|
+
# fallback; plain AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY in .env are NOT
|
|
13
|
+
# read by this app either). Authenticate via either AWS_PROFILE (a boto3
|
|
14
|
+
# profile name) or CLOUDWATCH_ACCESS_KEY_ID + CLOUDWATCH_SECRET_ACCESS_KEY.
|
|
15
|
+
CLOUDWATCH_REGION=ap-south-1
|
|
16
|
+
AWS_PROFILE=mcp-readonly
|
|
17
|
+
# CLOUDWATCH_ACCESS_KEY_ID=
|
|
18
|
+
# CLOUDWATCH_SECRET_ACCESS_KEY=
|
|
19
|
+
# CLOUDWATCH_ALLOWED_LOG_GROUP= # comma-separated list of allowed log group name(s)
|
|
20
|
+
CW_MAX_WINDOW_HOURS=168
|
|
21
|
+
CW_DEFAULT_WINDOW_HOURS=24
|
|
22
|
+
CW_MAX_BYTES_SCANNED=5000000000
|
|
23
|
+
CW_POLL_MAX_WAIT_S=60
|
|
24
|
+
|
|
25
|
+
# New Relic (NerdGraph / NRQL) — User API key (NRAK-...), not an ingest/license key
|
|
26
|
+
NEW_RELIC_API_KEY=
|
|
27
|
+
NEW_RELIC_ACCOUNT_ID=
|
|
28
|
+
NEW_RELIC_REGION=us
|
|
29
|
+
NR_MAX_WINDOW_HOURS=168
|
|
30
|
+
NR_DEFAULT_WINDOW_HOURS=24
|
|
31
|
+
NR_MAX_ROWS=500
|
|
32
|
+
|
|
33
|
+
# Server
|
|
34
|
+
# Loopback by default because the MCP HTTP transport has no built-in authentication.
|
|
35
|
+
# Only bind externally when access is protected by an authenticated proxy or firewall.
|
|
36
|
+
SERVER_HOST=127.0.0.1
|
|
37
|
+
SERVER_PORT=8001 # ignored if $PORT is set by the host platform (Render/Railway/Fly) and
|
|
38
|
+
# this var is left unset — see core/config.py
|
|
39
|
+
SERVER_PATH=/mcp
|
|
40
|
+
PII_REDACTION=true
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main, dev]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
lint-and-test:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- name: Install uv
|
|
18
|
+
uses: astral-sh/setup-uv@v3
|
|
19
|
+
with:
|
|
20
|
+
enable-cache: true
|
|
21
|
+
|
|
22
|
+
- name: Set up Python
|
|
23
|
+
run: uv python install 3.13
|
|
24
|
+
|
|
25
|
+
- name: Install dependencies
|
|
26
|
+
run: uv sync --all-extras --dev
|
|
27
|
+
|
|
28
|
+
- name: Lint (ruff)
|
|
29
|
+
run: uv run ruff check .
|
|
30
|
+
|
|
31
|
+
- name: Unit tests
|
|
32
|
+
run: uv run pytest tests/unit -q
|
|
33
|
+
|
|
34
|
+
- name: Integration tests (auto-skip if no reachable Postgres)
|
|
35
|
+
run: uv run pytest tests/integration -m integration -q
|
|
36
|
+
|
|
37
|
+
docker-build:
|
|
38
|
+
runs-on: ubuntu-latest
|
|
39
|
+
steps:
|
|
40
|
+
- uses: actions/checkout@v4
|
|
41
|
+
|
|
42
|
+
- name: Build Docker image
|
|
43
|
+
run: docker build -t investigation-mcp .
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
name: Deploy
|
|
2
|
+
|
|
3
|
+
# Publishes the Docker image to GitHub Container Registry on every merge to main.
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
branches: [main]
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
concurrency:
|
|
10
|
+
group: deploy-${{ github.ref }}
|
|
11
|
+
cancel-in-progress: false
|
|
12
|
+
|
|
13
|
+
env:
|
|
14
|
+
REGISTRY: ghcr.io
|
|
15
|
+
IMAGE_NAME: ${{ github.repository }}
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
publish-image:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
permissions:
|
|
21
|
+
contents: read
|
|
22
|
+
packages: write
|
|
23
|
+
steps:
|
|
24
|
+
- uses: actions/checkout@v4
|
|
25
|
+
|
|
26
|
+
- uses: docker/setup-buildx-action@v3
|
|
27
|
+
|
|
28
|
+
- name: Log in to GHCR
|
|
29
|
+
uses: docker/login-action@v3
|
|
30
|
+
with:
|
|
31
|
+
registry: ${{ env.REGISTRY }}
|
|
32
|
+
username: ${{ github.actor }}
|
|
33
|
+
password: ${{ secrets.GITHUB_TOKEN }}
|
|
34
|
+
|
|
35
|
+
- name: Image metadata
|
|
36
|
+
id: meta
|
|
37
|
+
uses: docker/metadata-action@v5
|
|
38
|
+
with:
|
|
39
|
+
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
40
|
+
tags: |
|
|
41
|
+
type=raw,value=latest
|
|
42
|
+
type=sha,prefix=sha-
|
|
43
|
+
|
|
44
|
+
- name: Build and push
|
|
45
|
+
uses: docker/build-push-action@v6
|
|
46
|
+
with:
|
|
47
|
+
context: .
|
|
48
|
+
push: true
|
|
49
|
+
tags: ${{ steps.meta.outputs.tags }}
|
|
50
|
+
labels: ${{ steps.meta.outputs.labels }}
|
|
51
|
+
cache-from: type=gha
|
|
52
|
+
cache-to: type=gha,mode=max
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Publishes the `opsinsight-mcp` package to PyPI when a version tag (v*) is pushed.
|
|
4
|
+
# Uses PyPI trusted publishing (OIDC) — no API token is stored in this repo. The
|
|
5
|
+
# trusted publisher must be registered on PyPI for this repo, this workflow file
|
|
6
|
+
# (`publish.yml`) and the `pypi` environment before the first run.
|
|
7
|
+
on:
|
|
8
|
+
push:
|
|
9
|
+
tags: ["v*"]
|
|
10
|
+
|
|
11
|
+
concurrency:
|
|
12
|
+
group: publish-${{ github.ref }}
|
|
13
|
+
cancel-in-progress: false
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
build:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
permissions:
|
|
19
|
+
contents: read
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
|
|
23
|
+
- name: Install uv
|
|
24
|
+
uses: astral-sh/setup-uv@v3
|
|
25
|
+
|
|
26
|
+
- name: Set up Python
|
|
27
|
+
run: uv python install 3.13
|
|
28
|
+
|
|
29
|
+
- name: Check tag matches package version
|
|
30
|
+
run: |
|
|
31
|
+
version=$(uv run --no-project python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
|
|
32
|
+
if [ "v${version}" != "${GITHUB_REF_NAME}" ]; then
|
|
33
|
+
echo "Tag ${GITHUB_REF_NAME} does not match pyproject.toml version ${version}" >&2
|
|
34
|
+
exit 1
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
- name: Lint and unit tests
|
|
38
|
+
run: |
|
|
39
|
+
uv sync --all-extras --dev
|
|
40
|
+
uv run ruff check .
|
|
41
|
+
uv run pytest tests/unit -q
|
|
42
|
+
|
|
43
|
+
- name: Build sdist and wheel
|
|
44
|
+
run: uv build
|
|
45
|
+
|
|
46
|
+
- uses: actions/upload-artifact@v4
|
|
47
|
+
with:
|
|
48
|
+
name: dist
|
|
49
|
+
path: dist/
|
|
50
|
+
|
|
51
|
+
publish:
|
|
52
|
+
needs: build
|
|
53
|
+
runs-on: ubuntu-latest
|
|
54
|
+
environment:
|
|
55
|
+
name: pypi
|
|
56
|
+
url: https://pypi.org/project/opsinsight-mcp/
|
|
57
|
+
permissions:
|
|
58
|
+
id-token: write
|
|
59
|
+
steps:
|
|
60
|
+
- uses: actions/download-artifact@v4
|
|
61
|
+
with:
|
|
62
|
+
name: dist
|
|
63
|
+
path: dist/
|
|
64
|
+
|
|
65
|
+
- name: Publish to PyPI
|
|
66
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Python-generated files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[oc]
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
wheels/
|
|
7
|
+
*.egg-info
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv
|
|
11
|
+
|
|
12
|
+
# Local secrets / runtime output
|
|
13
|
+
.env
|
|
14
|
+
audit.jsonl
|
|
15
|
+
|
|
16
|
+
# Editor / AI-tool local config
|
|
17
|
+
.claude/
|
|
18
|
+
.cursor/
|
|
19
|
+
.vscode/
|
|
20
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## What this is
|
|
6
|
+
|
|
7
|
+
A [FastMCP](https://gofastmcp.com) server exposing safe, **read-only** tools for investigating
|
|
8
|
+
support tickets: schema discovery + query execution against Postgres, log/metric access
|
|
9
|
+
against AWS CloudWatch, and log/event access against New Relic (NRQL). It's meant to be
|
|
10
|
+
driven by a client model (Claude Desktop, Cursor) that writes queries in natural-language
|
|
11
|
+
conversation — the server's job is to supply enough grounding context (schema, field
|
|
12
|
+
discovery, a query cookbook) that the model generates correct queries, and to validate +
|
|
13
|
+
execute them safely. See `docs/design.md` for the as-built design (§3.1 explains why
|
|
14
|
+
query generation is client-side, not server-side; §4.5 lists items from the original draft
|
|
15
|
+
that are still not implemented).
|
|
16
|
+
|
|
17
|
+
Cross-source correlation (DB + CloudWatch + New Relic) is currently entirely manual — the
|
|
18
|
+
client model fans out to each source's tools and merges results itself, with no server-side
|
|
19
|
+
guidance beyond the query cookbook. Composite tools `investigation_start` / `trace_request`
|
|
20
|
+
and MCP prompts (`investigate_incident` / `trace_user_journey` / `slow_endpoint_rca`) are
|
|
21
|
+
**not implemented**; don't assume they exist.
|
|
22
|
+
|
|
23
|
+
## Commands
|
|
24
|
+
|
|
25
|
+
```powershell
|
|
26
|
+
uv sync # install deps
|
|
27
|
+
copy .env.example .env # then fill in DB_URL, AWS_*, NEW_RELIC_*
|
|
28
|
+
uv run pytest tests/unit # guardrail/redaction tests, no external deps
|
|
29
|
+
uv run pytest tests/unit/test_db_guardrail.py -q # run a single test file
|
|
30
|
+
uv run pytest tests/unit -k test_limit_clamped # run a single test by name
|
|
31
|
+
uv run pytest tests/integration -m integration # requires a reachable Postgres; auto-skips otherwise
|
|
32
|
+
uv run ruff check . # lint
|
|
33
|
+
uv run investigation-server # start the MCP server (streamable HTTP, :8000/mcp by default)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`asyncio_mode = "auto"` is set in `pyproject.toml` — async test functions run without needing
|
|
37
|
+
`@pytest.mark.asyncio`.
|
|
38
|
+
|
|
39
|
+
## Architecture
|
|
40
|
+
|
|
41
|
+
**Layering is strict and one-directional**: `tools/*` (thin `@mcp.tool()`-decorated wrappers)
|
|
42
|
+
→ `service/*` (business logic, one class per source) → `integrations/<source>/guardrail.py`
|
|
43
|
+
(validate/reject, pure functions, no I/O) → `integrations/<source>/client.py` or `engine.py`
|
|
44
|
+
(actual network/DB I/O). Guardrail functions never touch the network; service methods never
|
|
45
|
+
build a query without passing it through the guardrail first.
|
|
46
|
+
|
|
47
|
+
**`src/service/` holds one class per source** — `DatabaseService`, `CloudWatchService`,
|
|
48
|
+
`NewRelicService` (`service/database_service.py`, `service/cloudwatch_service.py`,
|
|
49
|
+
`service/newrelic_service.py`), all inheriting `BaseService` (`service/base.py`) for the
|
|
50
|
+
shared `Settings` handle and the `ok(data, meta)` success-envelope helper. This is where a
|
|
51
|
+
tool's actual logic (validate → execute → redact → structured response) lives; error-response
|
|
52
|
+
translation for that source's exception family (`SQLAlchemyError` /
|
|
53
|
+
`BotoCoreError`+`ClientError` / `httpx.HTTPError`) is a private method on the same class
|
|
54
|
+
(`_sqlalchemy_error_response`, `_aws_error_response`, `_httpx_error_response`) rather than a
|
|
55
|
+
shared abstract method, since the three sources don't share an exception type — forcing one
|
|
56
|
+
would violate interface segregation for no benefit. Cross-tool logic that one source's several
|
|
57
|
+
tools share (e.g. CloudWatch's `run_insights_query`, used by both `cw_run_insights_query` and
|
|
58
|
+
`cw_get_trace_events`) is just another method on that source's service class, called from the
|
|
59
|
+
other public methods — composition over duplicating the polling/cost-ceiling logic per tool.
|
|
60
|
+
Network/DB clients are **constructor-injected** on `CloudWatchService`
|
|
61
|
+
(`logs_client_factory`/`metrics_client_factory`, default the real boto3 factories) and
|
|
62
|
+
`NewRelicService` (`run_nrql_fn`, defaults the real NerdGraph client) — dependency inversion,
|
|
63
|
+
so unit tests pass a fake client into the constructor instead of monkeypatching module state;
|
|
64
|
+
`DatabaseService` has no such seam because its only unit-level tests are integration tests
|
|
65
|
+
against a real Postgres (see `tests/integration/test_db_tools.py`).
|
|
66
|
+
|
|
67
|
+
**`tools/*` are thin MCP wrappers, nothing else**: each source is its own package
|
|
68
|
+
(`tools/database/`, `tools/cloudwatch/`, `tools/newrelic/`) with **one file per tool**, named
|
|
69
|
+
after the tool itself (e.g. `tools/database/db_resolve_store.py`). A tool file's only job is
|
|
70
|
+
the `@mcp.tool()` decorator, the docstring the client model reads, and the parameter schema —
|
|
71
|
+
the body is one line pulling that source's service off the container and calling the matching
|
|
72
|
+
method (see `tools/newrelic/nr_list_event_types.py` for the smallest complete example). Do not
|
|
73
|
+
put logic in a tool file; if you're tempted to, it belongs on the service class instead. The
|
|
74
|
+
one deliberate exception is inventory tools (`db_list_tables`, `cw_list_log_groups`,
|
|
75
|
+
`nr_list_event_types`): their service methods send a hardcoded, parameter-free (or
|
|
76
|
+
window-clamped) query straight to the client, bypassing the query guardrail, since there's no
|
|
77
|
+
user-supplied query text to validate — `nr_list_event_types` in particular sends `SHOW EVENT
|
|
78
|
+
TYPES`, which isn't a `SELECT` and would otherwise be rejected by `validate_nrql`.
|
|
79
|
+
|
|
80
|
+
**`core/container.py` is the composition root** — the one place that constructs services and
|
|
81
|
+
wires their dependencies. `Container` lazily builds and caches (singleton-scoped, per
|
|
82
|
+
container instance) `database_service`/`cloudwatch_service`/`newrelic_service`, each backed by
|
|
83
|
+
`self.settings` (which resolves through `get_settings()` unless a `Settings` override was
|
|
84
|
+
passed to the constructor). Every dependency — `settings`, or a fully pre-built service — can
|
|
85
|
+
also be injected via `Container(...)` keyword arguments; tests that want an isolated object
|
|
86
|
+
graph build their own `Container` rather than reaching for the process-wide one.
|
|
87
|
+
`get_container()` returns the process-wide singleton that `tools/*` call
|
|
88
|
+
(`get_container().database_service.list_tables(...)`); `reset_container()` drops it so the
|
|
89
|
+
next `get_container()` rebuilds against current `Settings` — call it after
|
|
90
|
+
`get_settings.cache_clear()` in any test that routes through the container, the same way
|
|
91
|
+
`reset_engine()` is called for the DB engine (`tests/conftest.py`'s `settings_override` does
|
|
92
|
+
both). This container is *not* the seam individual service unit tests use — those construct
|
|
93
|
+
the service directly with an injected fake client (see below) since that's simpler when you
|
|
94
|
+
only need one service; the container exists for wiring the full tools → services graph in
|
|
95
|
+
`server.py` and for tests that want that whole graph pre-assembled.
|
|
96
|
+
|
|
97
|
+
**Three independent data sources, one shared pattern**: `database` (SQLAlchemy/asyncpg +
|
|
98
|
+
`sqlglot` AST validation), `cloudwatch` (boto3 Logs Insights + a log-group allowlist + a
|
|
99
|
+
bytes-scanned cost ceiling), `newrelic` (NerdGraph GraphQL + a regex-based NRQL guardrail,
|
|
100
|
+
since NRQL has no AST parser like sqlglot). Each source has a `*_list_*` inventory tool
|
|
101
|
+
(`db_list_tables`, `cw_list_log_groups`, `nr_list_event_types`) and a `*_describe_*`
|
|
102
|
+
grounding tool (`db_describe_table`, `cw_describe_log_fields`, `nr_describe_log_fields`, which
|
|
103
|
+
uses NRQL's `keyset()` and separately flags likely trace/correlation-id attributes) that
|
|
104
|
+
should be called before writing a query against unfamiliar fields. Skipping the
|
|
105
|
+
grounding step is the main cause of queries that silently return zero rows because a field
|
|
106
|
+
name was guessed wrong.
|
|
107
|
+
|
|
108
|
+
**`core/app.py` holds the single shared `mcp = FastMCP(...)` instance in its own module**,
|
|
109
|
+
imported by every tool/resource/prompt module as `from core.app import mcp`. This exists
|
|
110
|
+
specifically so `server.py` (which is also the entry point run via `-m`/`investigation-server`)
|
|
111
|
+
never gets circularly imported — see the comment in `core/app.py` if touching this.
|
|
112
|
+
|
|
113
|
+
**Registration is import-side-effect based**: `src/tools/__init__.py` imports the three
|
|
114
|
+
source packages (`cloudwatch`, `database`, `newrelic`); each package's own `__init__.py` in
|
|
115
|
+
turn imports every tool file in it purely for the `@mcp.tool()` decorator to run, and
|
|
116
|
+
re-exports the tool function so it can also be imported as `from tools.<source> import
|
|
117
|
+
<tool_name>`. `src/resources/__init__.py` does the same for `@mcp.resource()`. `server.py`
|
|
118
|
+
imports both top-level `__init__` modules before calling `mcp.run()`. A new tool file must be
|
|
119
|
+
added to its source package's `__init__.py` import list (and, transitively, the source
|
|
120
|
+
package must already be listed in `src/tools/__init__.py`) or it will never register.
|
|
121
|
+
|
|
122
|
+
**Errors are structured and self-correcting by design**: every guardrail raises a `ToolError`
|
|
123
|
+
subclass (`GuardrailError`, `CWGuardrailError`, `NewRelicGuardrailError` in `core/errors.py`)
|
|
124
|
+
carrying a machine-readable `rule` plus a `detail` string explaining what to change — never a
|
|
125
|
+
bare "denied". Every `@mcp.tool()` function catches its own `ToolError`s and converts them via
|
|
126
|
+
`.to_response()` into `{"ok": False, "error": {...}}`; successful results always follow
|
|
127
|
+
`{"ok": True, "data": {...}, "meta": {...}}`. Preserve this shape in any new tool.
|
|
128
|
+
|
|
129
|
+
**Settings are one `pydantic-settings` object** (`core/config.py`), loaded once via
|
|
130
|
+
`@lru_cache def get_settings()`. Tests that change env vars must call
|
|
131
|
+
`get_settings.cache_clear()` (and, for DB tests, `await reset_engine()` from
|
|
132
|
+
`integrations/database/engine.py`) — see `tests/conftest.py`'s `settings_override` fixture
|
|
133
|
+
and `tests/unit/test_newrelic_tools.py` for the pattern. Unit tests construct the service
|
|
134
|
+
class directly (`CloudWatchService(get_settings(), logs_client_factory=lambda s: fake_client)`,
|
|
135
|
+
`NewRelicService(get_settings(), run_nrql_fn=fake_run_nrql)`) rather than monkeypatching —
|
|
136
|
+
prefer that constructor-injection seam over `monkeypatch.setattr` when a new service method
|
|
137
|
+
needs a fake client/network call in tests.
|
|
138
|
+
|
|
139
|
+
**Redaction is centralized and applied at the tool boundary**, not deeper in the stack:
|
|
140
|
+
`core/redaction.py` masks PII by column name (`PII_COLUMN_NAMES`) and by regex pattern
|
|
141
|
+
(email/card/Aadhaar/PAN/phone) applied to both DB row values and raw log message text.
|
|
142
|
+
Every tool that returns rows/log content calls `redact_rows`/`redact_log_event` right before
|
|
143
|
+
building its response — do the same in new tools rather than relying on upstream masking.
|
|
144
|
+
|
|
145
|
+
**The query cookbook (`src/data/query_cookbook.yaml`, exposed as the `docs://query-cookbook`
|
|
146
|
+
resource) is a deliberate accuracy lever**, not just documentation — per design doc §4.4,
|
|
147
|
+
giving the client model curated example queries measurably improves generation accuracy over
|
|
148
|
+
schema alone. When a query pattern proves useful or a source's field-naming quirk causes
|
|
149
|
+
repeated bad queries, add/update a cookbook entry rather than only fixing it in prose.
|
|
150
|
+
|
|
151
|
+
**CloudWatch cost/window safety**: `cw_run_insights_query` enforces a time-window cap
|
|
152
|
+
(`clamp_window`), polls with exponential backoff (`poll_query_with_backoff`), and aborts +
|
|
153
|
+
reports if `bytesScanned` exceeds `CW_MAX_BYTES_SCANNED` — bytes scanned is always logged and
|
|
154
|
+
returned in `meta`, since Logs Insights bills per GB scanned.
|
|
155
|
+
|
|
156
|
+
**DB safety is defense-in-depth**: `sqlglot`-based AST validation (single `SELECT`/`WITH...SELECT`
|
|
157
|
+
only, blocked node types, blocked dangerous functions, LIMIT injection/clamping) in
|
|
158
|
+
`integrations/database/guardrail.py`, *plus* session-level hardening applied on every new
|
|
159
|
+
physical connection in `integrations/database/engine.py` (`SET default_transaction_read_only
|
|
160
|
+
= on`, statement timeout, idle-in-transaction timeout) — the DB role itself is meant to be the
|
|
161
|
+
last line of defense, not the application layer alone.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for considering a contribution to the Investigation MCP Server.
|
|
4
|
+
|
|
5
|
+
## Before you start
|
|
6
|
+
|
|
7
|
+
This is a **read-only, guardrail-first** MCP server — every change should
|
|
8
|
+
preserve that property. If a change would let a tool write to the database,
|
|
9
|
+
bypass a guardrail, or return unredacted data, it needs an explicit
|
|
10
|
+
discussion first (open an issue), not just a PR.
|
|
11
|
+
|
|
12
|
+
## Setup
|
|
13
|
+
|
|
14
|
+
```powershell
|
|
15
|
+
uv sync
|
|
16
|
+
copy .env.example .env
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
You don't need every backend configured to contribute — Postgres, CloudWatch,
|
|
20
|
+
and New Relic are independent (see the README's
|
|
21
|
+
[What each source needs](README.md#what-each-source-needs)). Unit tests don't
|
|
22
|
+
require any of them; only `tests/integration/` needs a reachable Postgres,
|
|
23
|
+
and it auto-skips otherwise.
|
|
24
|
+
|
|
25
|
+
## Before opening a PR
|
|
26
|
+
|
|
27
|
+
```powershell
|
|
28
|
+
uv run ruff check . # lint — must be clean
|
|
29
|
+
uv run pytest tests/unit -q # must pass
|
|
30
|
+
uv run pytest tests/integration -m integration # if you touched DB code and have Postgres available
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
CI (`.github/workflows/ci.yml`) runs the same checks on every push/PR — a red
|
|
34
|
+
check means the PR isn't mergeable as-is.
|
|
35
|
+
|
|
36
|
+
## Architecture and conventions
|
|
37
|
+
|
|
38
|
+
Read [`CLAUDE.md`](CLAUDE.md) first — it's the authoritative description of
|
|
39
|
+
this repo's layering (`tools/* → service/* → integrations/<source>/guardrail.py
|
|
40
|
+
→ client.py|engine.py`) and the conventions each layer follows. Highlights:
|
|
41
|
+
|
|
42
|
+
- **One file per tool** in `tools/<source>/`, named after the tool. The file
|
|
43
|
+
is just the `@mcp.tool()` decorator, the docstring, and one call into the
|
|
44
|
+
service layer — no logic there.
|
|
45
|
+
- **Guardrails are pure functions, no I/O.** A service method never executes
|
|
46
|
+
a user-supplied query without passing it through the guardrail first.
|
|
47
|
+
- **A new tool file must be registered** in its source package's `__init__.py`
|
|
48
|
+
(see `src/tools/database/__init__.py` etc.) or it silently never loads.
|
|
49
|
+
- **Errors are structured**, not bare rejections — every guardrail raises a
|
|
50
|
+
`ToolError` subclass with a machine-readable `rule` and a `detail` string
|
|
51
|
+
explaining what to change, so the calling model can self-correct.
|
|
52
|
+
|
|
53
|
+
For the step-by-step of adding a new tool, see
|
|
54
|
+
[`docs/tool-reference.md`](docs/tool-reference.md) → **How To Update A Tool**
|
|
55
|
+
and **Where To Add Tests**.
|
|
56
|
+
|
|
57
|
+
## Commit style
|
|
58
|
+
|
|
59
|
+
Recent history mostly follows [Conventional Commits](https://www.conventionalcommits.org/)
|
|
60
|
+
(`feat(scope): ...`, `fix(scope): ...`, `test: ...`, `docs: ...`) — please
|
|
61
|
+
follow the same pattern for new commits.
|
|
62
|
+
|
|
63
|
+
## What to add to the query cookbook
|
|
64
|
+
|
|
65
|
+
If a query pattern proves useful, or a source's field-naming quirk causes
|
|
66
|
+
repeated bad queries from the client model, add/update an entry in
|
|
67
|
+
[`src/data/query_cookbook.yaml`](src/data/query_cookbook.yaml) rather than
|
|
68
|
+
only fixing it in prose — see `CLAUDE.md` for why this matters more than it
|
|
69
|
+
looks like it should.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
FROM python:3.13-slim
|
|
2
|
+
|
|
3
|
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
|
4
|
+
|
|
5
|
+
WORKDIR /app
|
|
6
|
+
|
|
7
|
+
# Install dependencies first (separate layer, cached unless deps change)
|
|
8
|
+
COPY pyproject.toml uv.lock ./
|
|
9
|
+
RUN uv sync --frozen --no-dev --no-install-project
|
|
10
|
+
|
|
11
|
+
# Then install the project itself
|
|
12
|
+
COPY src ./src
|
|
13
|
+
COPY README.md LICENSE ./
|
|
14
|
+
RUN uv sync --frozen --no-dev
|
|
15
|
+
|
|
16
|
+
ENV PATH="/app/.venv/bin:$PATH"
|
|
17
|
+
|
|
18
|
+
EXPOSE 8000
|
|
19
|
+
|
|
20
|
+
CMD ["investigation-server"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vignesh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|