diff --git a/CLAUDE.md b/CLAUDE.md index 8cc412c..bbb5369 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:// per +request.) A wrong or missing value logs `ui.domain validation failed for connector ""` 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 diff --git a/INSTALLATION.md b/INSTALLATION.md index 12ea01e..525a5d5 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -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. diff --git a/README.md b/README.md index 60e5f23..ea0d03b 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ | Gemini | Self-host: `./install.sh --tool gemini` _(a public Cloud FinOps Gem is on the Roadmap)_ | | Cursor Windsurf Codex Aider Copilot Kiro IDE Gemini CLI | One-liner: `curl -sL https://raw.githubusercontent.com/OptimNow/cloud-finops-skills/main/install.sh \| bash -s -- --tool ` | | Auto-detect | `curl -sL https://raw.githubusercontent.com/OptimNow/cloud-finops-skills/main/install.sh \| bash` | -| MCP hosted | 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 | +| MCP hosted | 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) | | MCP package | `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). @@ -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 @@ -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, diff --git a/mcp_server/README.md b/mcp_server/README.md index 092582c..52443ef 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -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: @@ -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: diff --git a/mcp_server/src/cloud_finops_mcp/server.py b/mcp_server/src/cloud_finops_mcp/server.py index 2a31e14..0a82d84 100644 --- a/mcp_server/src/cloud_finops_mcp/server.py +++ b/mcp_server/src/cloud_finops_mcp/server.py @@ -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()[:32] + ".claudemcpcontent.com". A wrong or missing value -# logs "ui.domain validation failed for connector " 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()[:32] + ".claudemcpcontent.com". A wrong or +# missing value logs 'ui.domain validation failed for connector ""' +# 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:// 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" @@ -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``, @@ -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. @@ -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), ) @@ -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 @@ -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. @@ -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 @@ -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), ) @@ -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: diff --git a/mcp_server/tests/test_ui.py b/mcp_server/tests/test_ui.py index d2a2a4e..2263356 100644 --- a/mcp_server/tests/test_ui.py +++ b/mcp_server/tests/test_ui.py @@ -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()[: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()[: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://, 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"