finops-mcp 0.3.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.
- finops_mcp-0.3.0/.env.example +74 -0
- finops_mcp-0.3.0/.gitignore +51 -0
- finops_mcp-0.3.0/PKG-INFO +30 -0
- finops_mcp-0.3.0/claude_mcp_config.json +12 -0
- finops_mcp-0.3.0/finopsmcp/.env.example +74 -0
- finops_mcp-0.3.0/finopsmcp/.gitignore +51 -0
- finops_mcp-0.3.0/finopsmcp/claude_mcp_config.json +12 -0
- finops_mcp-0.3.0/finopsmcp/pyproject.toml +45 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/__init__.py +0 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/anomaly/__init__.py +0 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/anomaly/detector.py +170 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/anomaly/seasonality.py +131 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/attribution/__init__.py +0 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/attribution/fetcher.py +211 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/attribution/mapper.py +160 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/connectors/__init__.py +0 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/connectors/aws.py +176 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/connectors/azure.py +142 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/connectors/base.py +54 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/connectors/gcp.py +165 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/connectors/invoice/__init__.py +0 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/connectors/invoice/parser.py +324 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/connectors/saas/__init__.py +0 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/connectors/saas/cloudflare.py +102 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/connectors/saas/datadog.py +97 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/connectors/saas/github.py +133 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/connectors/saas/mongodb_atlas.py +105 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/connectors/saas/new_relic.py +129 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/connectors/saas/pagerduty.py +74 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/connectors/saas/snowflake.py +139 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/connectors/saas/stripe.py +101 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/connectors/saas/twilio.py +93 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/connectors/saas/vercel.py +113 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/integrations/__init__.py +0 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/integrations/ticketing.py +286 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/license.py +177 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/notifications/__init__.py +0 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/notifications/email_digest.py +176 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/notifications/slack.py +142 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/notifications/teams.py +163 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/recommendations/__init__.py +0 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/recommendations/commitments.py +275 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/recommendations/rightsizing.py +236 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/scheduler/__init__.py +0 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/scheduler/jobs.py +386 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/security/__init__.py +0 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/security/env.py +52 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/security/oauth/__init__.py +0 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/security/oauth/aws.py +121 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/security/oauth/azure.py +86 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/security/oauth/gcp.py +100 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/security/vault.py +262 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/server.py +1056 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/setup_wizard.py +337 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/storage/__init__.py +0 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/storage/db.py +108 -0
- finops_mcp-0.3.0/finopsmcp/src/finops/storage/snapshots.py +153 -0
- finops_mcp-0.3.0/finopsmcp/web/docs.html +798 -0
- finops_mcp-0.3.0/finopsmcp/web/index.html +847 -0
- finops_mcp-0.3.0/pyproject.toml +45 -0
- finops_mcp-0.3.0/src/finops/__init__.py +0 -0
- finops_mcp-0.3.0/src/finops/anomaly/__init__.py +0 -0
- finops_mcp-0.3.0/src/finops/anomaly/detector.py +170 -0
- finops_mcp-0.3.0/src/finops/anomaly/seasonality.py +131 -0
- finops_mcp-0.3.0/src/finops/attribution/__init__.py +0 -0
- finops_mcp-0.3.0/src/finops/attribution/fetcher.py +211 -0
- finops_mcp-0.3.0/src/finops/attribution/mapper.py +160 -0
- finops_mcp-0.3.0/src/finops/connectors/__init__.py +0 -0
- finops_mcp-0.3.0/src/finops/connectors/aws.py +176 -0
- finops_mcp-0.3.0/src/finops/connectors/azure.py +142 -0
- finops_mcp-0.3.0/src/finops/connectors/base.py +54 -0
- finops_mcp-0.3.0/src/finops/connectors/gcp.py +165 -0
- finops_mcp-0.3.0/src/finops/connectors/invoice/__init__.py +0 -0
- finops_mcp-0.3.0/src/finops/connectors/invoice/parser.py +324 -0
- finops_mcp-0.3.0/src/finops/connectors/saas/__init__.py +0 -0
- finops_mcp-0.3.0/src/finops/connectors/saas/cloudflare.py +102 -0
- finops_mcp-0.3.0/src/finops/connectors/saas/datadog.py +97 -0
- finops_mcp-0.3.0/src/finops/connectors/saas/github.py +133 -0
- finops_mcp-0.3.0/src/finops/connectors/saas/mongodb_atlas.py +105 -0
- finops_mcp-0.3.0/src/finops/connectors/saas/new_relic.py +129 -0
- finops_mcp-0.3.0/src/finops/connectors/saas/pagerduty.py +74 -0
- finops_mcp-0.3.0/src/finops/connectors/saas/snowflake.py +139 -0
- finops_mcp-0.3.0/src/finops/connectors/saas/stripe.py +101 -0
- finops_mcp-0.3.0/src/finops/connectors/saas/twilio.py +93 -0
- finops_mcp-0.3.0/src/finops/connectors/saas/vercel.py +113 -0
- finops_mcp-0.3.0/src/finops/integrations/__init__.py +0 -0
- finops_mcp-0.3.0/src/finops/integrations/ticketing.py +286 -0
- finops_mcp-0.3.0/src/finops/license.py +209 -0
- finops_mcp-0.3.0/src/finops/notifications/__init__.py +0 -0
- finops_mcp-0.3.0/src/finops/notifications/email_digest.py +176 -0
- finops_mcp-0.3.0/src/finops/notifications/slack.py +142 -0
- finops_mcp-0.3.0/src/finops/notifications/teams.py +163 -0
- finops_mcp-0.3.0/src/finops/recommendations/__init__.py +0 -0
- finops_mcp-0.3.0/src/finops/recommendations/commitments.py +275 -0
- finops_mcp-0.3.0/src/finops/recommendations/rightsizing.py +236 -0
- finops_mcp-0.3.0/src/finops/scheduler/__init__.py +0 -0
- finops_mcp-0.3.0/src/finops/scheduler/jobs.py +386 -0
- finops_mcp-0.3.0/src/finops/security/__init__.py +0 -0
- finops_mcp-0.3.0/src/finops/security/env.py +52 -0
- finops_mcp-0.3.0/src/finops/security/oauth/__init__.py +0 -0
- finops_mcp-0.3.0/src/finops/security/oauth/aws.py +121 -0
- finops_mcp-0.3.0/src/finops/security/oauth/azure.py +86 -0
- finops_mcp-0.3.0/src/finops/security/oauth/gcp.py +100 -0
- finops_mcp-0.3.0/src/finops/security/vault.py +262 -0
- finops_mcp-0.3.0/src/finops/server.py +1060 -0
- finops_mcp-0.3.0/src/finops/setup_wizard.py +337 -0
- finops_mcp-0.3.0/src/finops/storage/__init__.py +0 -0
- finops_mcp-0.3.0/src/finops/storage/db.py +108 -0
- finops_mcp-0.3.0/src/finops/storage/snapshots.py +153 -0
- finops_mcp-0.3.0/web/docs.html +798 -0
- finops_mcp-0.3.0/web/index.html +820 -0
- finops_mcp-0.3.0/web/vercel.json +5 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
# FinOps MCP — Environment Variables
|
|
3
|
+
# Copy to .env and fill in the credentials for the providers you use.
|
|
4
|
+
# Unconfigured providers are silently skipped.
|
|
5
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
6
|
+
|
|
7
|
+
# ── AWS ────────────────────────────────────────────────────────────────────────
|
|
8
|
+
AWS_ACCESS_KEY_ID=
|
|
9
|
+
AWS_SECRET_ACCESS_KEY=
|
|
10
|
+
AWS_DEFAULT_REGION=us-east-1
|
|
11
|
+
# Multi-account: comma-separated IAM role ARNs to assume
|
|
12
|
+
# AWS_ROLE_ARNS=arn:aws:iam::111111111111:role/FinOps,arn:aws:iam::222222222222:role/FinOps
|
|
13
|
+
|
|
14
|
+
# ── Azure ──────────────────────────────────────────────────────────────────────
|
|
15
|
+
AZURE_CLIENT_ID=
|
|
16
|
+
AZURE_CLIENT_SECRET=
|
|
17
|
+
AZURE_TENANT_ID=
|
|
18
|
+
AZURE_SUBSCRIPTION_IDS= # comma-separated
|
|
19
|
+
|
|
20
|
+
# ── GCP ────────────────────────────────────────────────────────────────────────
|
|
21
|
+
GCP_SERVICE_ACCOUNT_KEY_PATH=
|
|
22
|
+
GCP_BILLING_ACCOUNT_IDS= # format: XXXXXX-XXXXXX-XXXXXX, comma-separated
|
|
23
|
+
# Optional: BigQuery billing export table for full granularity
|
|
24
|
+
# GCP_BQ_BILLING_TABLE=myproject.my_dataset.gcp_billing_export_v1_XXXXXX
|
|
25
|
+
|
|
26
|
+
# ── Datadog ────────────────────────────────────────────────────────────────────
|
|
27
|
+
DATADOG_API_KEY=
|
|
28
|
+
DATADOG_APP_KEY=
|
|
29
|
+
# DATADOG_SITE=datadoghq.com # use datadoghq.eu for EU
|
|
30
|
+
|
|
31
|
+
# ── Snowflake ──────────────────────────────────────────────────────────────────
|
|
32
|
+
SNOWFLAKE_ACCOUNT= # e.g. xy12345.us-east-1
|
|
33
|
+
SNOWFLAKE_USER=
|
|
34
|
+
SNOWFLAKE_PASSWORD=
|
|
35
|
+
SNOWFLAKE_WAREHOUSE= # optional, uses default if unset
|
|
36
|
+
SNOWFLAKE_ROLE=ACCOUNTADMIN
|
|
37
|
+
# SNOWFLAKE_PRIVATE_KEY_PATH= # alternative to password auth
|
|
38
|
+
# SNOWFLAKE_CREDIT_PRICE=3.00 # USD per credit (override if on a committed contract)
|
|
39
|
+
|
|
40
|
+
# ── GitHub ─────────────────────────────────────────────────────────────────────
|
|
41
|
+
GITHUB_TOKEN= # fine-grained PAT with read:org and read:billing
|
|
42
|
+
GITHUB_ORGS= # comma-separated org names
|
|
43
|
+
|
|
44
|
+
# ── Stripe ─────────────────────────────────────────────────────────────────────
|
|
45
|
+
STRIPE_SECRET_KEY= # sk_live_...
|
|
46
|
+
|
|
47
|
+
# ── MongoDB Atlas ──────────────────────────────────────────────────────────────
|
|
48
|
+
MONGODB_ATLAS_PUBLIC_KEY=
|
|
49
|
+
MONGODB_ATLAS_PRIVATE_KEY=
|
|
50
|
+
MONGODB_ATLAS_ORG_IDS= # comma-separated org IDs
|
|
51
|
+
|
|
52
|
+
# ── Vercel ─────────────────────────────────────────────────────────────────────
|
|
53
|
+
VERCEL_TOKEN=
|
|
54
|
+
VERCEL_TEAM_ID= # optional, leave blank for personal accounts
|
|
55
|
+
|
|
56
|
+
# ── Cloudflare ─────────────────────────────────────────────────────────────────
|
|
57
|
+
CLOUDFLARE_API_TOKEN=
|
|
58
|
+
CLOUDFLARE_ACCOUNT_ID=
|
|
59
|
+
|
|
60
|
+
# ── PagerDuty ──────────────────────────────────────────────────────────────────
|
|
61
|
+
PAGERDUTY_API_KEY=
|
|
62
|
+
# PAGERDUTY_PRICE_PER_USER=21.00 # override if on a negotiated contract
|
|
63
|
+
|
|
64
|
+
# ── Twilio ─────────────────────────────────────────────────────────────────────
|
|
65
|
+
TWILIO_ACCOUNT_SID=
|
|
66
|
+
TWILIO_AUTH_TOKEN=
|
|
67
|
+
|
|
68
|
+
# ── New Relic ──────────────────────────────────────────────────────────────────
|
|
69
|
+
NEW_RELIC_API_KEY= # User API key (NRAK-...)
|
|
70
|
+
NEW_RELIC_ACCOUNT_ID=
|
|
71
|
+
# NEW_RELIC_INGEST_PRICE_PER_GB=0.35 # override if on a negotiated contract
|
|
72
|
+
|
|
73
|
+
# ── General ────────────────────────────────────────────────────────────────────
|
|
74
|
+
DEFAULT_LOOKBACK_DAYS=30
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.Python
|
|
7
|
+
*.egg-info/
|
|
8
|
+
dist/
|
|
9
|
+
build/
|
|
10
|
+
.eggs/
|
|
11
|
+
*.egg
|
|
12
|
+
|
|
13
|
+
# Virtual environments
|
|
14
|
+
.venv/
|
|
15
|
+
venv/
|
|
16
|
+
env/
|
|
17
|
+
|
|
18
|
+
# Environment & secrets — NEVER commit these
|
|
19
|
+
.env
|
|
20
|
+
*.env
|
|
21
|
+
.env.local
|
|
22
|
+
.env.*.local
|
|
23
|
+
finops_vault.db
|
|
24
|
+
finops.db
|
|
25
|
+
*.db
|
|
26
|
+
*.sqlite
|
|
27
|
+
|
|
28
|
+
# Credentials & keys
|
|
29
|
+
*.pem
|
|
30
|
+
*.key
|
|
31
|
+
*.p12
|
|
32
|
+
*.pfx
|
|
33
|
+
service_account*.json
|
|
34
|
+
*credentials*.json
|
|
35
|
+
|
|
36
|
+
# Claude / MCP local state
|
|
37
|
+
.claude/
|
|
38
|
+
|
|
39
|
+
# OS
|
|
40
|
+
.DS_Store
|
|
41
|
+
Thumbs.db
|
|
42
|
+
|
|
43
|
+
# IDE
|
|
44
|
+
.vscode/
|
|
45
|
+
.idea/
|
|
46
|
+
*.swp
|
|
47
|
+
*.swo
|
|
48
|
+
|
|
49
|
+
# Logs
|
|
50
|
+
*.log
|
|
51
|
+
logs/
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: finops-mcp
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Headless FinOps MCP server — cloud + SaaS cost intelligence for Claude
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: apscheduler>=3.10.0
|
|
7
|
+
Requires-Dist: azure-identity>=1.15.0
|
|
8
|
+
Requires-Dist: azure-mgmt-costmanagement>=4.0.0
|
|
9
|
+
Requires-Dist: boto3>=1.34.0
|
|
10
|
+
Requires-Dist: cryptography>=42.0.0
|
|
11
|
+
Requires-Dist: google-cloud-bigquery>=3.17.0
|
|
12
|
+
Requires-Dist: google-cloud-billing>=1.13.0
|
|
13
|
+
Requires-Dist: httpx>=0.27.0
|
|
14
|
+
Requires-Dist: mcp[cli]>=1.3.0
|
|
15
|
+
Requires-Dist: pydantic>=2.0.0
|
|
16
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
17
|
+
Requires-Dist: pyyaml>=6.0.0
|
|
18
|
+
Requires-Dist: sqlalchemy>=2.0.0
|
|
19
|
+
Provides-Extra: azure-oauth
|
|
20
|
+
Requires-Dist: msal>=1.28.0; extra == 'azure-oauth'
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
25
|
+
Provides-Extra: keyring
|
|
26
|
+
Requires-Dist: keyring>=25.0.0; extra == 'keyring'
|
|
27
|
+
Provides-Extra: pdf
|
|
28
|
+
Requires-Dist: pdfplumber>=0.10.0; extra == 'pdf'
|
|
29
|
+
Provides-Extra: snowflake
|
|
30
|
+
Requires-Dist: snowflake-connector-python>=3.6.0; extra == 'snowflake'
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
# FinOps MCP — Environment Variables
|
|
3
|
+
# Copy to .env and fill in the credentials for the providers you use.
|
|
4
|
+
# Unconfigured providers are silently skipped.
|
|
5
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
6
|
+
|
|
7
|
+
# ── AWS ────────────────────────────────────────────────────────────────────────
|
|
8
|
+
AWS_ACCESS_KEY_ID=
|
|
9
|
+
AWS_SECRET_ACCESS_KEY=
|
|
10
|
+
AWS_DEFAULT_REGION=us-east-1
|
|
11
|
+
# Multi-account: comma-separated IAM role ARNs to assume
|
|
12
|
+
# AWS_ROLE_ARNS=arn:aws:iam::111111111111:role/FinOps,arn:aws:iam::222222222222:role/FinOps
|
|
13
|
+
|
|
14
|
+
# ── Azure ──────────────────────────────────────────────────────────────────────
|
|
15
|
+
AZURE_CLIENT_ID=
|
|
16
|
+
AZURE_CLIENT_SECRET=
|
|
17
|
+
AZURE_TENANT_ID=
|
|
18
|
+
AZURE_SUBSCRIPTION_IDS= # comma-separated
|
|
19
|
+
|
|
20
|
+
# ── GCP ────────────────────────────────────────────────────────────────────────
|
|
21
|
+
GCP_SERVICE_ACCOUNT_KEY_PATH=
|
|
22
|
+
GCP_BILLING_ACCOUNT_IDS= # format: XXXXXX-XXXXXX-XXXXXX, comma-separated
|
|
23
|
+
# Optional: BigQuery billing export table for full granularity
|
|
24
|
+
# GCP_BQ_BILLING_TABLE=myproject.my_dataset.gcp_billing_export_v1_XXXXXX
|
|
25
|
+
|
|
26
|
+
# ── Datadog ────────────────────────────────────────────────────────────────────
|
|
27
|
+
DATADOG_API_KEY=
|
|
28
|
+
DATADOG_APP_KEY=
|
|
29
|
+
# DATADOG_SITE=datadoghq.com # use datadoghq.eu for EU
|
|
30
|
+
|
|
31
|
+
# ── Snowflake ──────────────────────────────────────────────────────────────────
|
|
32
|
+
SNOWFLAKE_ACCOUNT= # e.g. xy12345.us-east-1
|
|
33
|
+
SNOWFLAKE_USER=
|
|
34
|
+
SNOWFLAKE_PASSWORD=
|
|
35
|
+
SNOWFLAKE_WAREHOUSE= # optional, uses default if unset
|
|
36
|
+
SNOWFLAKE_ROLE=ACCOUNTADMIN
|
|
37
|
+
# SNOWFLAKE_PRIVATE_KEY_PATH= # alternative to password auth
|
|
38
|
+
# SNOWFLAKE_CREDIT_PRICE=3.00 # USD per credit (override if on a committed contract)
|
|
39
|
+
|
|
40
|
+
# ── GitHub ─────────────────────────────────────────────────────────────────────
|
|
41
|
+
GITHUB_TOKEN= # fine-grained PAT with read:org and read:billing
|
|
42
|
+
GITHUB_ORGS= # comma-separated org names
|
|
43
|
+
|
|
44
|
+
# ── Stripe ─────────────────────────────────────────────────────────────────────
|
|
45
|
+
STRIPE_SECRET_KEY= # sk_live_...
|
|
46
|
+
|
|
47
|
+
# ── MongoDB Atlas ──────────────────────────────────────────────────────────────
|
|
48
|
+
MONGODB_ATLAS_PUBLIC_KEY=
|
|
49
|
+
MONGODB_ATLAS_PRIVATE_KEY=
|
|
50
|
+
MONGODB_ATLAS_ORG_IDS= # comma-separated org IDs
|
|
51
|
+
|
|
52
|
+
# ── Vercel ─────────────────────────────────────────────────────────────────────
|
|
53
|
+
VERCEL_TOKEN=
|
|
54
|
+
VERCEL_TEAM_ID= # optional, leave blank for personal accounts
|
|
55
|
+
|
|
56
|
+
# ── Cloudflare ─────────────────────────────────────────────────────────────────
|
|
57
|
+
CLOUDFLARE_API_TOKEN=
|
|
58
|
+
CLOUDFLARE_ACCOUNT_ID=
|
|
59
|
+
|
|
60
|
+
# ── PagerDuty ──────────────────────────────────────────────────────────────────
|
|
61
|
+
PAGERDUTY_API_KEY=
|
|
62
|
+
# PAGERDUTY_PRICE_PER_USER=21.00 # override if on a negotiated contract
|
|
63
|
+
|
|
64
|
+
# ── Twilio ─────────────────────────────────────────────────────────────────────
|
|
65
|
+
TWILIO_ACCOUNT_SID=
|
|
66
|
+
TWILIO_AUTH_TOKEN=
|
|
67
|
+
|
|
68
|
+
# ── New Relic ──────────────────────────────────────────────────────────────────
|
|
69
|
+
NEW_RELIC_API_KEY= # User API key (NRAK-...)
|
|
70
|
+
NEW_RELIC_ACCOUNT_ID=
|
|
71
|
+
# NEW_RELIC_INGEST_PRICE_PER_GB=0.35 # override if on a negotiated contract
|
|
72
|
+
|
|
73
|
+
# ── General ────────────────────────────────────────────────────────────────────
|
|
74
|
+
DEFAULT_LOOKBACK_DAYS=30
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.Python
|
|
7
|
+
*.egg-info/
|
|
8
|
+
dist/
|
|
9
|
+
build/
|
|
10
|
+
.eggs/
|
|
11
|
+
*.egg
|
|
12
|
+
|
|
13
|
+
# Virtual environments
|
|
14
|
+
.venv/
|
|
15
|
+
venv/
|
|
16
|
+
env/
|
|
17
|
+
|
|
18
|
+
# Environment & secrets — NEVER commit these
|
|
19
|
+
.env
|
|
20
|
+
*.env
|
|
21
|
+
.env.local
|
|
22
|
+
.env.*.local
|
|
23
|
+
finops_vault.db
|
|
24
|
+
finops.db
|
|
25
|
+
*.db
|
|
26
|
+
*.sqlite
|
|
27
|
+
|
|
28
|
+
# Credentials & keys
|
|
29
|
+
*.pem
|
|
30
|
+
*.key
|
|
31
|
+
*.p12
|
|
32
|
+
*.pfx
|
|
33
|
+
service_account*.json
|
|
34
|
+
*credentials*.json
|
|
35
|
+
|
|
36
|
+
# Claude / MCP local state
|
|
37
|
+
.claude/
|
|
38
|
+
|
|
39
|
+
# OS
|
|
40
|
+
.DS_Store
|
|
41
|
+
Thumbs.db
|
|
42
|
+
|
|
43
|
+
# IDE
|
|
44
|
+
.vscode/
|
|
45
|
+
.idea/
|
|
46
|
+
*.swp
|
|
47
|
+
*.swo
|
|
48
|
+
|
|
49
|
+
# Logs
|
|
50
|
+
*.log
|
|
51
|
+
logs/
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "finops-mcp"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "Headless FinOps MCP server — cloud + SaaS cost intelligence for Claude"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"mcp[cli]>=1.3.0",
|
|
12
|
+
# cloud providers
|
|
13
|
+
"boto3>=1.34.0",
|
|
14
|
+
"azure-mgmt-costmanagement>=4.0.0",
|
|
15
|
+
"azure-identity>=1.15.0",
|
|
16
|
+
"google-cloud-billing>=1.13.0",
|
|
17
|
+
"google-cloud-bigquery>=3.17.0",
|
|
18
|
+
# storage + scheduling
|
|
19
|
+
"sqlalchemy>=2.0.0",
|
|
20
|
+
"apscheduler>=3.10.0",
|
|
21
|
+
# security
|
|
22
|
+
"cryptography>=42.0.0",
|
|
23
|
+
# shared utilities
|
|
24
|
+
"python-dotenv>=1.0.0",
|
|
25
|
+
"httpx>=0.27.0",
|
|
26
|
+
"pydantic>=2.0.0",
|
|
27
|
+
"pyyaml>=6.0.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
snowflake = ["snowflake-connector-python>=3.6.0"]
|
|
32
|
+
azure-oauth = ["msal>=1.28.0"]
|
|
33
|
+
keyring = ["keyring>=25.0.0"]
|
|
34
|
+
pdf = ["pdfplumber>=0.10.0"]
|
|
35
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "ruff>=0.4"]
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
finops-mcp = "finops.server:main"
|
|
39
|
+
finops = "finops.setup_wizard:main"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/finops"]
|
|
43
|
+
|
|
44
|
+
[tool.ruff]
|
|
45
|
+
line-length = 100
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import statistics
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import date, datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import and_, select
|
|
9
|
+
|
|
10
|
+
from ..storage.db import anomalies, get_engine
|
|
11
|
+
from ..storage.snapshots import get_history
|
|
12
|
+
|
|
13
|
+
_MIN_HISTORY_DAYS = 7 # need at least 7 data points
|
|
14
|
+
_MIN_SPEND_THRESHOLD = 5.0 # ignore noise below $5
|
|
15
|
+
_Z_SCORE_THRESHOLD = 2.0 # flag if |z| > 2.0
|
|
16
|
+
_PCT_THRESHOLD = 20.0 # AND |pct_change| > 20%
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class AnomalyResult:
|
|
21
|
+
provider: str
|
|
22
|
+
service: str
|
|
23
|
+
account_id: str
|
|
24
|
+
snapshot_date: date
|
|
25
|
+
severity: str # "high" | "medium" | "low"
|
|
26
|
+
direction: str # "spike" | "drop"
|
|
27
|
+
pct_change: float
|
|
28
|
+
z_score: float
|
|
29
|
+
baseline_mean: float
|
|
30
|
+
current_amount: float
|
|
31
|
+
is_new: bool = True
|
|
32
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
def summary(self) -> str:
|
|
35
|
+
arrow = "↑" if self.direction == "spike" else "↓"
|
|
36
|
+
return (
|
|
37
|
+
f"{self.provider.upper()} / {self.service}: "
|
|
38
|
+
f"{arrow} {abs(self.pct_change):.0f}% vs 28-day baseline "
|
|
39
|
+
f"(${self.current_amount:,.2f} vs avg ${self.baseline_mean:,.2f}) "
|
|
40
|
+
f"[{self.severity.upper()}]"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _severity(z: float, pct: float) -> str:
|
|
45
|
+
az, ap = abs(z), abs(pct)
|
|
46
|
+
if az >= 3.5 or ap >= 100:
|
|
47
|
+
return "high"
|
|
48
|
+
if az >= 2.5 or ap >= 50:
|
|
49
|
+
return "medium"
|
|
50
|
+
return "low"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def detect_for_series(
|
|
54
|
+
provider: str,
|
|
55
|
+
service: str,
|
|
56
|
+
account_id: str,
|
|
57
|
+
snapshot_date: date,
|
|
58
|
+
current_amount: float,
|
|
59
|
+
history_amounts: list[float],
|
|
60
|
+
) -> AnomalyResult | None:
|
|
61
|
+
if len(history_amounts) < _MIN_HISTORY_DAYS:
|
|
62
|
+
return None
|
|
63
|
+
if current_amount < _MIN_SPEND_THRESHOLD and max(history_amounts, default=0) < _MIN_SPEND_THRESHOLD:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
mean = statistics.mean(history_amounts)
|
|
67
|
+
if mean < _MIN_SPEND_THRESHOLD:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
stdev = statistics.stdev(history_amounts) if len(history_amounts) > 1 else 0.0
|
|
71
|
+
z_score = (current_amount - mean) / stdev if stdev > 0 else 0.0
|
|
72
|
+
pct_change = (current_amount - mean) / mean * 100
|
|
73
|
+
|
|
74
|
+
if abs(z_score) < _Z_SCORE_THRESHOLD or abs(pct_change) < _PCT_THRESHOLD:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
return AnomalyResult(
|
|
78
|
+
provider=provider,
|
|
79
|
+
service=service,
|
|
80
|
+
account_id=account_id,
|
|
81
|
+
snapshot_date=snapshot_date,
|
|
82
|
+
severity=_severity(z_score, pct_change),
|
|
83
|
+
direction="spike" if pct_change > 0 else "drop",
|
|
84
|
+
pct_change=round(pct_change, 2),
|
|
85
|
+
z_score=round(z_score, 3),
|
|
86
|
+
baseline_mean=round(mean, 4),
|
|
87
|
+
current_amount=round(current_amount, 4),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def detect_from_snapshot(
|
|
92
|
+
provider: str,
|
|
93
|
+
service: str,
|
|
94
|
+
account_id: str,
|
|
95
|
+
snapshot_date: date,
|
|
96
|
+
current_amount: float,
|
|
97
|
+
lookback_days: int = 28,
|
|
98
|
+
) -> AnomalyResult | None:
|
|
99
|
+
history = get_history(provider, service, account_id, days=lookback_days)
|
|
100
|
+
today_iso = snapshot_date.isoformat()
|
|
101
|
+
amounts = [
|
|
102
|
+
row["amount_usd"]
|
|
103
|
+
for row in history
|
|
104
|
+
if row["snapshot_date"] != today_iso and row["amount_usd"] > 0
|
|
105
|
+
]
|
|
106
|
+
return detect_for_series(provider, service, account_id, snapshot_date, current_amount, amounts)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def persist_anomaly(result: AnomalyResult) -> int:
|
|
110
|
+
engine = get_engine()
|
|
111
|
+
with engine.begin() as conn:
|
|
112
|
+
r = conn.execute(
|
|
113
|
+
anomalies.insert().values(
|
|
114
|
+
provider=result.provider,
|
|
115
|
+
service=result.service,
|
|
116
|
+
account_id=result.account_id,
|
|
117
|
+
detected_at=datetime.now(timezone.utc),
|
|
118
|
+
snapshot_date=result.snapshot_date.isoformat(),
|
|
119
|
+
severity=result.severity,
|
|
120
|
+
direction=result.direction,
|
|
121
|
+
pct_change=result.pct_change,
|
|
122
|
+
z_score=result.z_score,
|
|
123
|
+
baseline_mean=result.baseline_mean,
|
|
124
|
+
current_amount=result.current_amount,
|
|
125
|
+
acknowledged=False,
|
|
126
|
+
notified=False,
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
return r.lastrowid # type: ignore[return-value]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_active_anomalies(
|
|
133
|
+
provider: str | None = None,
|
|
134
|
+
severity: str | None = None,
|
|
135
|
+
limit: int = 50,
|
|
136
|
+
) -> list[dict[str, Any]]:
|
|
137
|
+
engine = get_engine()
|
|
138
|
+
query = (
|
|
139
|
+
select(anomalies)
|
|
140
|
+
.where(anomalies.c.acknowledged == False) # noqa: E712
|
|
141
|
+
.order_by(anomalies.c.detected_at.desc())
|
|
142
|
+
.limit(limit)
|
|
143
|
+
)
|
|
144
|
+
if provider:
|
|
145
|
+
query = query.where(anomalies.c.provider == provider)
|
|
146
|
+
if severity:
|
|
147
|
+
query = query.where(anomalies.c.severity == severity)
|
|
148
|
+
with engine.connect() as conn:
|
|
149
|
+
return [dict(r._mapping) for r in conn.execute(query).fetchall()]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def acknowledge_anomaly(anomaly_id: int) -> bool:
|
|
153
|
+
engine = get_engine()
|
|
154
|
+
with engine.begin() as conn:
|
|
155
|
+
result = conn.execute(
|
|
156
|
+
anomalies.update()
|
|
157
|
+
.where(anomalies.c.id == anomaly_id)
|
|
158
|
+
.values(acknowledged=True)
|
|
159
|
+
)
|
|
160
|
+
return result.rowcount > 0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def mark_notified(anomaly_id: int) -> None:
|
|
164
|
+
engine = get_engine()
|
|
165
|
+
with engine.begin() as conn:
|
|
166
|
+
conn.execute(
|
|
167
|
+
anomalies.update()
|
|
168
|
+
.where(anomalies.c.id == anomaly_id)
|
|
169
|
+
.values(notified=True)
|
|
170
|
+
)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Seasonality-aware anomaly detection.
|
|
3
|
+
|
|
4
|
+
Compares today against the same weekday over the prior N weeks, not a flat
|
|
5
|
+
rolling mean. This eliminates false positives from weekly patterns (e.g.
|
|
6
|
+
Monday batch jobs, weekend traffic drops) that naive z-score flags as anomalies.
|
|
7
|
+
|
|
8
|
+
Strategy:
|
|
9
|
+
1. Same-weekday baseline: compare Monday vs last 4 Mondays, etc.
|
|
10
|
+
2. If not enough same-weekday points, fall back to rolling-mean detection.
|
|
11
|
+
3. Combine both signals — flag only when both agree (reduces false positives).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import statistics
|
|
16
|
+
from datetime import date, timedelta
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from .detector import (
|
|
20
|
+
AnomalyResult,
|
|
21
|
+
_MIN_HISTORY_DAYS,
|
|
22
|
+
_MIN_SPEND_THRESHOLD,
|
|
23
|
+
_PCT_THRESHOLD,
|
|
24
|
+
_Z_SCORE_THRESHOLD,
|
|
25
|
+
_severity,
|
|
26
|
+
detect_for_series,
|
|
27
|
+
)
|
|
28
|
+
from ..storage.snapshots import get_history
|
|
29
|
+
|
|
30
|
+
_MIN_SAME_WEEKDAY_POINTS = 3 # need at least 3 same-weekday readings
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _same_weekday_amounts(
|
|
34
|
+
history: list[dict[str, Any]],
|
|
35
|
+
target_weekday: int, # 0=Monday … 6=Sunday
|
|
36
|
+
current_date_iso: str,
|
|
37
|
+
) -> list[float]:
|
|
38
|
+
"""Return amounts from history rows that fall on target_weekday, excluding today."""
|
|
39
|
+
out: list[float] = []
|
|
40
|
+
for row in history:
|
|
41
|
+
if row["snapshot_date"] == current_date_iso:
|
|
42
|
+
continue
|
|
43
|
+
try:
|
|
44
|
+
d = date.fromisoformat(row["snapshot_date"])
|
|
45
|
+
except ValueError:
|
|
46
|
+
continue
|
|
47
|
+
if d.weekday() == target_weekday and row["amount_usd"] > 0:
|
|
48
|
+
out.append(row["amount_usd"])
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def detect_with_seasonality(
|
|
53
|
+
provider: str,
|
|
54
|
+
service: str,
|
|
55
|
+
account_id: str,
|
|
56
|
+
snapshot_date: date,
|
|
57
|
+
current_amount: float,
|
|
58
|
+
lookback_days: int = 56, # 8 weeks — enough for 8 same-weekday samples
|
|
59
|
+
) -> AnomalyResult | None:
|
|
60
|
+
"""
|
|
61
|
+
Seasonality-aware detection. Lookback extended to 56 days (8 weeks) to
|
|
62
|
+
collect enough same-weekday readings; falls back to rolling mean if sparse.
|
|
63
|
+
"""
|
|
64
|
+
if current_amount < _MIN_SPEND_THRESHOLD:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
history = get_history(provider, service, account_id, days=lookback_days)
|
|
68
|
+
today_iso = snapshot_date.isoformat()
|
|
69
|
+
weekday = snapshot_date.weekday()
|
|
70
|
+
|
|
71
|
+
same_day_amounts = _same_weekday_amounts(history, weekday, today_iso)
|
|
72
|
+
|
|
73
|
+
if len(same_day_amounts) >= _MIN_SAME_WEEKDAY_POINTS:
|
|
74
|
+
result = _detect_against_baseline(
|
|
75
|
+
provider, service, account_id, snapshot_date, current_amount,
|
|
76
|
+
same_day_amounts, baseline_label="same-weekday"
|
|
77
|
+
)
|
|
78
|
+
if result is not None:
|
|
79
|
+
result.metadata["detection_method"] = "seasonality-aware (same-weekday)"
|
|
80
|
+
result.metadata["weekday_samples"] = len(same_day_amounts)
|
|
81
|
+
return result
|
|
82
|
+
else:
|
|
83
|
+
# Fallback: classic rolling-mean (28-day)
|
|
84
|
+
rolling_amounts = [
|
|
85
|
+
row["amount_usd"]
|
|
86
|
+
for row in history
|
|
87
|
+
if row["snapshot_date"] != today_iso and row["amount_usd"] > 0
|
|
88
|
+
]
|
|
89
|
+
result = detect_for_series(
|
|
90
|
+
provider, service, account_id, snapshot_date,
|
|
91
|
+
current_amount, rolling_amounts
|
|
92
|
+
)
|
|
93
|
+
if result is not None:
|
|
94
|
+
result.metadata["detection_method"] = "rolling-mean (insufficient same-weekday data)"
|
|
95
|
+
result.metadata["weekday_samples"] = len(same_day_amounts)
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _detect_against_baseline(
|
|
100
|
+
provider: str,
|
|
101
|
+
service: str,
|
|
102
|
+
account_id: str,
|
|
103
|
+
snapshot_date: date,
|
|
104
|
+
current_amount: float,
|
|
105
|
+
baseline_amounts: list[float],
|
|
106
|
+
baseline_label: str,
|
|
107
|
+
) -> AnomalyResult | None:
|
|
108
|
+
mean = statistics.mean(baseline_amounts)
|
|
109
|
+
if mean < _MIN_SPEND_THRESHOLD:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
stdev = statistics.stdev(baseline_amounts) if len(baseline_amounts) > 1 else 0.0
|
|
113
|
+
z_score = (current_amount - mean) / stdev if stdev > 0 else 0.0
|
|
114
|
+
pct_change = (current_amount - mean) / mean * 100
|
|
115
|
+
|
|
116
|
+
if abs(z_score) < _Z_SCORE_THRESHOLD or abs(pct_change) < _PCT_THRESHOLD:
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
return AnomalyResult(
|
|
120
|
+
provider=provider,
|
|
121
|
+
service=service,
|
|
122
|
+
account_id=account_id,
|
|
123
|
+
snapshot_date=snapshot_date,
|
|
124
|
+
severity=_severity(z_score, pct_change),
|
|
125
|
+
direction="spike" if pct_change > 0 else "drop",
|
|
126
|
+
pct_change=round(pct_change, 2),
|
|
127
|
+
z_score=round(z_score, 3),
|
|
128
|
+
baseline_mean=round(mean, 4),
|
|
129
|
+
current_amount=round(current_amount, 4),
|
|
130
|
+
metadata={"baseline": baseline_label},
|
|
131
|
+
)
|
|
File without changes
|