Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,8 +386,13 @@ maintainer's control and on an unpublished timeline.
**Update (2026-08-19): the gate now has an observable mechanism.** Claude
Desktop's `mcp-ext-apps-host` attempts to set up MCP Apps for custom
connectors and validates a `ui.domain` on the widget resource:
sha256(connector URL as entered in Settings, no trailing slash)[:32] +
`.claudemcpcontent.com`. A wrong or missing value logs
sha256(connector URL exactly as the host displays it in its error log -
for a root-entered connector that is the ROOT url WITH its trailing
slash)[:32] + `.claudemcpcontent.com`. (Corrected 2026-08-20: the
original "no trailing slash" note here was wrong - hashing `/mcp` while
the host saw `https://.../` reproduced the failure on this repo's own
connector; Skybridge, which passes, hashes https://<host><pathname> per
request.) A wrong or missing value logs
`ui.domain validation failed for connector "<url>"` in the Desktop logs
(observed live for ai-pricing-hub on 2026-08-19, including the fix-it
one-liner the error prints). So the 2026-08-16 claim that a custom
Expand Down
9 changes: 6 additions & 3 deletions INSTALLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,12 +293,15 @@ There are two ways to reach it.
#### Hosted (nothing to install)

```bash
claude mcp add --transport http cloud-finops https://cloud-finops-skills-590a051d.alpic.live/mcp
claude mcp add --transport http cloud-finops https://cloud-finops-skills-590a051d.alpic.live/
```

For Claude.ai / Claude Desktop, **Settings -> Connectors -> Add custom connector** and
paste the same URL. Cursor, Windsurf, VS Code and ChatGPT take an HTTP MCP server entry
pointing at it.
paste exactly `https://cloud-finops-skills-590a051d.alpic.live/` - trailing slash
included. The widget sandbox domain (MCP Apps) is derived from the URL as entered, so
a variant form (`/mcp`, no slash) connects fine but silently disables widget rendering.
Cursor, Windsurf, VS Code and ChatGPT take an HTTP MCP server entry pointing at the
same URL.

This is the same code as the package below, deployed on Alpic and tracking `main`. Prefer
it unless you need the server to run offline or pinned to a version.
Expand Down
43 changes: 35 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
| <img src="https://img.shields.io/badge/-Gemini-4285F4?logo=googlegemini&logoColor=white" alt="Gemini" height="22"/> | Self-host: `./install.sh --tool gemini` _(a public Cloud FinOps Gem is on the Roadmap)_ |
| <img src="https://img.shields.io/badge/-Cursor-000000?logo=cursor&logoColor=white" alt="Cursor" height="22"/> <img src="https://img.shields.io/badge/-Windsurf-3DDC91?logoColor=white" alt="Windsurf" height="22"/> <img src="https://img.shields.io/badge/-Codex-412991?logo=openai&logoColor=white" alt="Codex" height="22"/> <img src="https://img.shields.io/badge/-Aider-0F172A?logoColor=white" alt="Aider" height="22"/> <img src="https://img.shields.io/badge/-Copilot-181717?logo=githubcopilot&logoColor=white" alt="Copilot" height="22"/> <img src="https://img.shields.io/badge/-Kiro%20IDE-FF6F00?logoColor=white" alt="Kiro IDE" height="22"/> <img src="https://img.shields.io/badge/-Gemini%20CLI-4285F4?logo=googlegemini&logoColor=white" alt="Gemini CLI" height="22"/> | One-liner: `curl -sL https://raw.githubusercontent.com/OptimNow/cloud-finops-skills/main/install.sh \| bash -s -- --tool <name>` |
| <img src="https://img.shields.io/badge/-Auto--detect-555555?logo=gnubash&logoColor=white" alt="Auto-detect" height="22"/> | `curl -sL https://raw.githubusercontent.com/OptimNow/cloud-finops-skills/main/install.sh \| bash` |
| <img src="https://img.shields.io/badge/-MCP%20hosted-7C3AED?logoColor=white" alt="MCP hosted" height="22"/> | Nothing to install: `claude mcp add --transport http cloud-finops https://cloud-finops-skills-590a051d.alpic.live/mcp`. For Claude.ai / Desktop, **Settings -> Connectors -> Add custom connector** with the same URL |
| <img src="https://img.shields.io/badge/-MCP%20hosted-7C3AED?logoColor=white" alt="MCP hosted" height="22"/> | Nothing to install: `claude mcp add --transport http cloud-finops https://cloud-finops-skills-590a051d.alpic.live/`. For Claude.ai / Desktop, **Settings -> Connectors -> Add custom connector** with exactly that URL (trailing slash included - the widget sandbox domain is derived from it) |
| <img src="https://img.shields.io/badge/-MCP%20package-7C3AED?logoColor=white" alt="MCP package" height="22"/> | `pip install cloud-finops-mcp` then add to your MCP client config (Claude Code / Cursor / Codex / Windsurf / Cline). Snippets: `./install.sh --tool mcp`. Six tools - faceted retrieval over the reference library and named-pattern playbooks. |

Full options, troubleshooting, and the model-agnostic API loader: see [INSTALLATION.md](./INSTALLATION.md).
Expand Down Expand Up @@ -377,13 +377,40 @@ A version-tagged release zip (`cloud-finops-vX.Y.Z.zip`) is attached to every
[GitHub release](https://github.com/OptimNow/cloud-finops-skills/releases) for
Claude Desktop / claude.ai users who prefer downloading over building locally.

### Skill or MCP, or both?

Same content, two delivery mechanisms - and they behave differently, because of
how models use them. Field-tested conclusion (same battery of practitioner
questions through both channels):

- **The skill is pushed into context.** The guidance is already there when the
model reasons, so it grounds *advisory* answers - commitment sizing,
chargeback design, allocation methodology - without the model having to
decide anything. This is the primary channel on surfaces that support
skills (Claude Code, Claude Desktop, claude.ai, Kiro).
- **The MCP server is retrieval on demand.** The model must decide to call a
tool, which it reliably does for lookup questions ("show me the idle waste
runbooks") and much less for advisory ones, where it tends to answer from
its own knowledge. Its strengths are distribution (paste one URL, nothing
to install - the right path for non-technical users and for hosts without
skill support), faceted queries over the library's metadata, and
interactive widgets on hosts that render MCP Apps.
- **Recommended setup on Claude:** install the skill, and add the
[AI Pricing Hub](https://optimtoken.optimnow.io) connector next to it - the
skill carries the doctrine, the hub serves live prices when the skill
routes a pricing question to it. Add the cloud-finops connector too if you
want the widgets or work across hosts; skip it if the skill is already
loaded and you only need text answers.
- **No skill support in your tool?** Use the MCP server alone - it is the
same library behind six read-only tools.

### MCP server (cross-tool, search-style retrieval)

For agents that want tool-style retrieval rather than full-context injection, the skill
is also an MCP server. Hosted, or as a PyPI package:

```bash
claude mcp add --transport http cloud-finops https://cloud-finops-skills-590a051d.alpic.live/mcp
claude mcp add --transport http cloud-finops https://cloud-finops-skills-590a051d.alpic.live/
```

```bash
Expand All @@ -400,18 +427,18 @@ patterns.

| Tool | What it answers |
|---|---|
| `list_references()` | What is in the library? Every reference with its FinOps Framework metadata |
| `get_reference(name)` | Give me the full text of one |
| `find_references(domain?, capability?, phase?, persona?, maturity?, persona_primary_only?)` | Which references serve Rate Optimization, at Walk maturity, written for Engineering? (`persona_primary_only` cuts to the primary audience - broad personas collaborate on nearly everything) |
| `list_references()` | What guidance exists? The full catalogue with its FinOps Framework metadata |
| `get_reference(name)` | The full guide on one topic - mechanics, decision rules, worked examples |
| `find_references(domain?, capability?, phase?, persona?, maturity?, persona_primary_only?)` | "How should we size Savings Plans?" "What must be true before chargeback?" - routes a FinOps question to the guides that serve it (`persona_primary_only` cuts to the primary audience) |

**Playbooks** - small named-pattern runbooks, one waste pattern each. Reach for these for
"how do I detect and fix this specific thing".

| Tool | What it answers |
|---|---|
| `list_playbooks()` | Which named patterns exist? |
| `get_playbook(name)` | The runbook: symptoms, detection query, fix, anti-pattern |
| `find_playbooks(scope?, service?, waste_category?, confidence?)` | Which AWS playbooks cover orphaned resources at obvious confidence? |
| `list_playbooks()` | What cloud waste can we hunt with a ready-made runbook? |
| `get_playbook(name)` | The step-by-step runbook: symptoms, detection queries, fix, anti-pattern |
| `find_playbooks(scope?, service?, waste_category?, confidence?)` | "Which VMs run for nothing?" "Why is the NAT bill so high?" - finds the runbook for a specific waste suspicion |

The faceted queries are the reason this is a server and not just a folder of markdown.
Every file carries YAML frontmatter mapping it to a FinOps Framework Capability, phase,
Expand Down
12 changes: 6 additions & 6 deletions mcp_server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ Six tools, all read-only, split across two surfaces.

| Tool | Purpose |
|---|---|
| `list_references()` | List all references with their FCP metadata. |
| `get_reference(name)` | Fetch the full markdown body of one reference. |
| `find_references(domain?, capability?, phase?, persona?, maturity?, persona_primary_only?)` | Faceted query over the FinOps Capability/Phase frontmatter. |
| `list_references()` | Browse the knowledge library: what guidance exists, with its FinOps Framework metadata. |
| `get_reference(name)` | Read the full guide on one topic - mechanics, decision rules, worked examples. |
| `find_references(domain?, capability?, phase?, persona?, maturity?, persona_primary_only?)` | Route a FinOps question (commitment sizing, chargeback design, ...) to the guides that serve it, by FinOps Framework facet. |

The reference faceted query supports any combination of:

Expand All @@ -38,9 +38,9 @@ The reference faceted query supports any combination of:

| Tool | Purpose |
|---|---|
| `list_playbooks()` | List all named-pattern playbooks with their metadata. |
| `get_playbook(name)` | Fetch the full markdown body of one playbook. |
| `find_playbooks(scope?, service?, waste_category?, confidence?)` | Faceted query over the playbook frontmatter. |
| `list_playbooks()` | Browse the waste runbooks: which patterns of idle, orphaned, overprovisioned or leaking spend have a ready-made runbook. |
| `get_playbook(name)` | Read one runbook: symptoms, detection queries, fix, anti-pattern. |
| `find_playbooks(scope?, service?, waste_category?, confidence?)` | "We are wasting money on X - how do I find and fix it?" - filter runbooks by provider, service, waste category, confidence. |

The playbook faceted query supports:

Expand Down
91 changes: 56 additions & 35 deletions mcp_server/src/cloud_finops_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,16 +54,22 @@

# The claude.ai / Claude Desktop MCP Apps host only mounts a widget iframe
# when the ui:// resource declares the sandbox domain it will be served from:
# sha256(<connector URL exactly as the user entered it in Settings, no
# trailing slash>)[:32] + ".claudemcpcontent.com". A wrong or missing value
# logs "ui.domain validation failed for connector <url>" in the host log
# (mcp-ext-apps-host) and leaves a dead blank frame - observed live on
# 2026-08-19 for both this server and ai-pricing-hub. The hash is DERIVED
# from the canonical URL rather than pasted, so the URL constant is the only
# thing that has to stay true; hashing an internal path instead of the
# public URL is exactly the mistake that broke ai-pricing-hub.
# sha256(<connector URL exactly as the host displays it in its error log,
# trailing slash included>)[:32] + ".claudemcpcontent.com". A wrong or
# missing value logs 'ui.domain validation failed for connector "<url>"'
# in the Desktop log (mcp-ext-apps-host) and leaves a dead blank frame.
# Observed live on 2026-08-20 for THIS server: the host quoted the
# connector as "https://cloud-finops-skills-590a051d.alpic.live/" - the
# ROOT url with its trailing slash - while the hash here was computed over
# "/mcp". Skybridge, whose connectors render, hashes
# https://<host><request-pathname> per request, which for a root-entered
# connector is exactly the root-with-slash form. Alpic serves the MCP at
# the root as well as /mcp, so the documented connector URL (README,
# INSTALLATION.md) is the root form and this constant must match it
# byte-for-byte; hashing an internal path or the wrong variant is exactly
# the mistake that broke ai-pricing-hub and then this server.
CANONICAL_CONNECTOR_ORIGIN = "https://cloud-finops-skills-590a051d.alpic.live"
CANONICAL_CONNECTOR_URL = CANONICAL_CONNECTOR_ORIGIN + "/mcp"
CANONICAL_CONNECTOR_URL = CANONICAL_CONNECTOR_ORIGIN + "/"
UI_DOMAIN = (
hashlib.sha256(CANONICAL_CONNECTOR_URL.encode("utf-8")).hexdigest()[:32]
+ ".claudemcpcontent.com"
Expand Down Expand Up @@ -169,17 +175,19 @@ def _load_ui(filename: str) -> str:


@mcp.tool(
title="List FinOps references",
title="Browse the FinOps knowledge library",
annotations=_READ_ONLY,
meta=_ui_tool_meta(REFERENCE_BROWSER_URI),
)
def list_references() -> dict[str, Any]:
"""List every bundled FinOps reference with its FCP metadata.
"""See what FinOps guidance is available: billing mechanics, commitment
strategy, allocation and chargeback, AI cost management, and per-provider
cost handbooks (AWS, Azure, GCP, OCI, Databricks, Snowflake, ...).

Use this to discover what the reference library covers before deciding
what to fetch. When the question already names a FinOps domain, phase,
persona or maturity, call ``find_references`` instead of scanning this
full list.
Use this to discover what the library covers before deciding what to
fetch. When the question already names a FinOps domain, phase, persona
or maturity, call ``find_references`` instead of scanning this full
list.

Returns a dict shaped ``{"references": [...], "total": N}`` where each
entry includes ``name``, ``description``, FCP fields (``fcp_domain``,
Expand All @@ -188,13 +196,16 @@ def list_references() -> dict[str, Any]:
return _tools.list_references()


@mcp.tool(title="Get a FinOps reference", annotations=_READ_ONLY)
@mcp.tool(title="Read one FinOps guide", annotations=_READ_ONLY)
def get_reference(name: str) -> dict[str, Any]:
"""Fetch the full markdown content of one reference by name.
"""Fetch the full guidance on one FinOps topic - the billing mechanics,
decision rules and worked examples behind a defensible answer.

Use this when you need the actual billing-mechanics content of one
known reference - after ``list_references`` or ``find_references`` told
you which one serves the question.
Use this when you need the actual content of one known reference -
after ``list_references`` or ``find_references`` told you which one
serves the question, and ALWAYS before answering an advisory question
(commitment sizing, chargeback design, allocation methodology) the
library covers.

Args:
name: Reference name as returned by ``list_references`` (e.g.
Expand All @@ -209,7 +220,7 @@ def get_reference(name: str) -> dict[str, Any]:


@mcp.tool(
title="Find FinOps references by facet",
title="Find the right FinOps guide",
annotations=_READ_ONLY,
meta=_ui_tool_meta(REFERENCE_BROWSER_URI),
)
Expand All @@ -221,10 +232,13 @@ def find_references(
maturity: str | None = None,
persona_primary_only: bool = False,
) -> dict[str, Any]:
"""Filter references by FinOps Capability/Phase (FCP) frontmatter.
"""Find which guidance serves a FinOps question - how to commit, size,
allocate, charge back, forecast, or govern cloud and AI spend.

Use this when the question maps to FinOps Framework facets - a domain,
capability, phase, persona, or maturity stage - and you want only the
Use this for questions like "how should we size Savings Plans",
"what should Finance own in chargeback", "what does a Crawl-stage org
tackle first" - anything that maps to FinOps Framework facets (domain,
capability, phase, persona, maturity) - and you want only the
references that serve it, instead of scanning the full list.

All filters are optional and combine with AND semantics. String matching
Expand Down Expand Up @@ -268,14 +282,16 @@ def find_references(


@mcp.tool(
title="List waste playbooks",
title="Browse the cloud waste runbooks",
annotations=_READ_ONLY,
meta=_ui_tool_meta(PLAYBOOK_EXPLORER_URI),
)
def list_playbooks() -> dict[str, Any]:
"""List every bundled named-pattern playbook.
"""See every ready-made runbook for finding and fixing cloud waste:
idle, orphaned and overprovisioned resources, egress surprises,
schedule blindness and AI/ML inefficiency across AWS, Azure and GCP.

Use this to discover which named waste patterns exist. When the
Use this to discover which waste patterns have a runbook. When the
question already names a provider, waste category, or confidence tier,
call ``find_playbooks`` instead.

Expand All @@ -290,12 +306,14 @@ def list_playbooks() -> dict[str, Any]:


@mcp.tool(
title="Get a waste playbook",
title="Read one waste runbook",
annotations=_READ_ONLY,
meta=_ui_tool_meta(PLAYBOOK_VIEWER_URI),
)
def get_playbook(name: str) -> dict[str, Any]:
"""Fetch the full markdown content of one playbook by slug.
"""Fetch the step-by-step runbook for one specific waste pattern:
symptoms, the detection queries to run, the fix, and the anti-pattern
to avoid.

Use this when the user asks how to detect, confirm, or fix one specific
named waste pattern (zombie NAT gateway, snapshot sprawl, idle SageMaker
Expand Down Expand Up @@ -411,7 +429,7 @@ def reference_browser_ui_apps_sdk() -> str:


@mcp.tool(
title="Find waste playbooks by facet",
title="Find a waste runbook",
annotations=_READ_ONLY,
meta=_ui_tool_meta(PLAYBOOK_EXPLORER_URI),
)
Expand All @@ -421,11 +439,14 @@ def find_playbooks(
waste_category: str | None = None,
confidence: str | None = None,
) -> dict[str, Any]:
"""Filter playbooks by their pattern frontmatter.

Use this when the user asks for waste patterns of a given provider,
waste category, or detection confidence - e.g. "the idle-resource
playbooks", "obvious AWS waste", "cross-cloud patterns".
"""Answer "we are wasting money on X - how do I find and fix it?":
filter the runbooks by provider, service, waste category or detection
confidence.

Use this for questions like "which VMs are running for nothing",
"why is our NAT bill so high", "what waste can we clean up safely
without review" - anything that names a provider, a waste category, or
how confident the detection needs to be before acting.

All filters are optional and combine with AND semantics. String matching
is case-insensitive and exact. Examples:
Expand Down
16 changes: 9 additions & 7 deletions mcp_server/tests/test_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,16 +153,18 @@ async def test_tools_link_their_widgets() -> None:
def test_ui_domain_is_derived_from_the_canonical_connector_url() -> None:
"""The sandbox-domain hash must follow the URL constant, never drift.

The claude host validates sha256(<connector URL as entered, no trailing
slash>)[:32] + ".claudemcpcontent.com" against the resource meta; a
pasted hash that stops matching the URL is the ai-pricing-hub failure
mode.
The claude host validates sha256(<connector URL as it displays it -
for a root-entered connector that is the ROOT url WITH its trailing
slash>)[:32] + ".claudemcpcontent.com" against the resource meta.
Observed live on 2026-08-20: hashing "/mcp" while the host saw the
root url logged 'ui.domain validation failed'. Skybridge hashes
https://<host><request-pathname>, i.e. root-with-slash.
"""
import hashlib

assert not server.CANONICAL_CONNECTOR_URL.endswith("/"), (
"the connector URL is hashed as entered - no trailing slash"
)
assert server.CANONICAL_CONNECTOR_URL == (
server.CANONICAL_CONNECTOR_ORIGIN + "/"
), "the connector URL is the root form, trailing slash included"
expected = (
hashlib.sha256(server.CANONICAL_CONNECTOR_URL.encode("utf-8")).hexdigest()[:32]
+ ".claudemcpcontent.com"
Expand Down
Loading