Vitally のアカウント health score、health コンポーネントの内訳、会話履歴への読み取り専用アクセスを Claude に提供する Model Context Protocol サーバーです。Claude Desktop または Claude Code に1度登録すれば、チームのどの CSM も「Acme Corp の health score はいくつで、なぜ赤なのか」「更新コール前にこのアカウントの直近5件の会話を見せて」と質問でき、Vitally からリアルタイムで取得した構造化された回答を別のタブを開かずに受け取れます。
ポートフォリオが 100 アカウントを超えており、リスクアカウントの完全なビューが必要な場合。list_at_risk_accounts ツールは1ページ分のアカウント(Vitally の API 上限に従い最大100件)を取得してローカルでフィルタリングします。アクティブアカウントが 100 件を超える org では、カーソルページネーションが実装されるまで(README TODO #2)部分的なビューしか得られません。大規模なポートフォリオの場合は、このサーバーにトリアージを依存するのではなく、Vitally の CSV セグメントをエクスポートして直接 Claude に渡してください。
Vitally が health の系統的な記録ではない場合。 CS チームの中には Gainsight、ChurnZero、または独自のスプレッドシートモデルで health score を管理し、要約メトリクスを Vitally に送り込んでいるチームもあります。信頼できる health データが別の場所にある場合、Claude は派生した古いコピーを読むことになります。MCP サーバーを実際のソースに向けてください。
データポリシーがアカウントデータをサードパーティの LLM に入れることを禁止している場合。 アカウント名、health score、会話の件名、メッセージ本文が Claude のコンテキストウィンドウを通過します。エンタープライズ顧客との契約でデータの AI 処理を制限している場合は、有効化する前に法的立場を確認してください。
環境変数を設定する。VITALLY_API_KEY、VITALLY_SUBDOMAIN(EU の場合は VITALLY_BASE_URL)、オプションで VITALLY_HEALTH_THRESHOLD(デフォルト 50)と VITALLY_PAGE_LIMIT(デフォルト 100、Vitally の API 上限)。
Claude Desktop または Claude Code に登録する。README.md の JSON ブロックを claude_desktop_config.json(Desktop)またはプロジェクトの settings.json(Code)に追加します。Claude Desktop を再起動します。
動作確認。 Claude に「アカウント ID <実際のアカウント ID> の health score を見せて」と聞き、Vitally の UI と回答を比較します。次に「40 以下のリスクアカウントをリストアップして」と聞き、返されたアカウントが Vitally でその範囲の health score を持つことを確認します。
何をするか — そしてなぜツールがこのような形になっているか
3つのツール、すべて読み取り専用です。
get_account_health(account_id) は Vitally に対して2つの連続した GET リクエストを行います。ベースアカウントレコードのための GET /resources/accounts/:id と、コンポーネントの内訳のための GET /resources/accounts/:id/healthScores です。これらを1つのレスポンスにマージし、全体の healthScore と、名前・スコア・ステータスを持つ health コンポーネントの配列を返します。1つではなく2つのリクエストが必要なのは、Vitally の REST API がベースアカウントレコードと health score の内訳を分離しているためです — 両方を返す単一のエンドポイントは存在しません。
API キーが期限切れまたは失効した場合。 Vitally の API キーには文書化された TTL がありませんが、後で無効化されたユーザーまたはロールが変更されたユーザーから生成されたキーは機能しなくなります。401 は状態 401 の httpx.HTTPStatusError として現れます。Guard: require_config() は各ツール呼び出しで実行され、キーが欠如している場合に再度例外を発生させます。失効しているが存在するケースでは、Vitally からの 401 が Claude のレスポンスでエラーメッセージとして伝播します。インテグレーションキーがまだアクティブであることを確認するための月次カレンダーリマインダーを設定してください。
代替手段との比較
Vitally のネイティブ AI 機能(Vitally Copilot / アプリ内 AI)。 Vitally はプラットフォーム内で AI 支援の要約と提案をネイティブに展開してきました。ファーストパーティで、設定不要、ホストする別プロセスも不要です。トレードオフは、AI が Vitally の中に存在するため、CSM が使用するには Vitally にいる必要があるということです。MCP サーバーパターンは Claude をすべてのツールにわたる単一のチャットサーフェスとして維持します — チームがすでにメール作成、コールの要約、成功計画に Claude を使っているなら、そこに Vitally のコンテキストを追加することは、コンテキストの切り替えを増やすのではなく、減らすことを意味します。
CSV エクスポート + 手動貼り付け。 Vitally のセグメントを CSV にエクスポートして Claude に貼り付けます。無料、設定不要、今日から使えます。トレードオフは、手動ステップ(エクスポート、ファイルを開く、貼り付け)が必要で、データがライブクエリではなくスナップショットであり、同じ Claude 会話内の他のツールとうまく組み合わせられないことです。MCP サーバーはレスポンスタイムでこれを上回り、チームの Claude 使用が増えるほどその差は大きくなります。
Claude Code スクリプトでの Vitally REST API の直接呼び出し。 Python に慣れた CSM は、数行の httpx で Claude Code セッションから直接 Vitally API を呼び出せます。トレードオフは、新しいセッションごとに再認証が必要で、コードが永続的な登録済みツールではなく一時的な会話に存在し、チームの他の CSM が同じ設定なしに再利用できないことです。MCP サーバーはツールを一度登録し、Claude Desktop または Code インテグレーションを持つ誰もが利用できるようにします。
# mcp-server-vitally-cs
An MCP server for Customer Success teams using Vitally. Exposes account reads, health score breakdowns, and conversation history — three tools designed for CSMs who want to pull Vitally context into Claude conversations without leaving the chat surface.
> **STATUS: scaffold — not runtime-tested.** The code below is structurally complete and follows the official `mcp` Python SDK conventions, but it has not been executed against a live Vitally tenant. Adapt the subdomain, field names, and any custom trait keys to your org before use.
## What it exposes
### Account reads (read-only)
- `get_account_health(account_id)` — fetches a single account plus its full health score breakdown via the `/accounts/:id/healthScores` endpoint
- `list_at_risk_accounts(health_threshold?, limit?)` — pages through accounts, filters to those whose `healthScore` is at or below the threshold, returns a ranked list
- `get_account_conversations(account_id, limit?)` — fetches the most recent conversations linked to an account, ordered newest-first
The server does **not** expose write endpoints. No note creation, no conversation mutations, no trait updates. Read-only by design so CSMs can drop this into Claude Desktop without a security review of write scope.
## Setup
### 1. Install
```bash
git clone <wherever you put this>
cd mcp-server-vitally-cs
python -m venv .venv
source .venv/bin/activate # or .venv\Scripts\activate on Windows
pip install -e .
```
### 2. Locate your Vitally API key
In Vitally: **Settings → Integrations → API**. Generate or copy an existing API key. Vitally authenticates via HTTP Basic Auth with the API key as the username and an empty password. The `Authorization` header value is `Basic <base64(apikey:)>`.
The server handles this encoding for you — you only need to supply the raw key as `VITALLY_API_KEY`.
### 3. Find your subdomain
Your Vitally base URL is `https://<subdomain>.rest.vitally.io`. The subdomain is the prefix of your Vitally workspace URL (e.g. if you log in at `acme.vitally.io`, your subdomain is `acme`). EU tenants use `rest.vitally-eu.io` — set `VITALLY_BASE_URL` accordingly.
### 4. Configure environment
```bash
export VITALLY_API_KEY="your_api_key_here"
export VITALLY_SUBDOMAIN="acme" # your workspace subdomain
export VITALLY_BASE_URL="https://acme.rest.vitally.io" # full base URL; auto-built from subdomain if omitted
export VITALLY_HEALTH_THRESHOLD="50" # default red/orange threshold for list_at_risk_accounts
export VITALLY_PAGE_LIMIT="100" # max records per page (Vitally caps at 100)
```
**`VITALLY_API_KEY`** — required. Raw API key from Vitally Settings → Integrations → API. Never commit this to source control.
**`VITALLY_SUBDOMAIN`** — required unless `VITALLY_BASE_URL` is set explicitly. Used to construct `https://{subdomain}.rest.vitally.io`.
**`VITALLY_BASE_URL`** — optional override. Set this explicitly for EU tenants: `https://rest.vitally-eu.io`. If set, it takes precedence over `VITALLY_SUBDOMAIN`.
**`VITALLY_HEALTH_THRESHOLD`** — optional, default `50`. Accounts whose overall health score is ≤ this value are returned by `list_at_risk_accounts`. Vitally health scores run 0–100; typical "red" band is 0–33, "orange" 34–66, "green" 67–100 — adjust to match your org's thresholds.
**`VITALLY_PAGE_LIMIT`** — optional, default `100`. Vitally's REST API caps list responses at 100 records per page. The scaffold respects this ceiling.
### 5. Register with Claude Desktop
Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
```json
{
"mcpServers": {
"vitally-cs": {
"command": "python",
"args": ["-m", "vitally_cs_mcp.server"],
"env": {
"VITALLY_API_KEY": "your_api_key_here",
"VITALLY_SUBDOMAIN": "acme",
"VITALLY_HEALTH_THRESHOLD": "50"
}
}
}
}
```
Restart Claude Desktop. You should see 3 tools registered under `vitally-cs`.
### 6. Register with Claude Code
Add to your project or user `settings.json` under `mcpServers`:
```json
{
"mcpServers": {
"vitally-cs": {
"command": "python",
"args": ["-m", "vitally_cs_mcp.server"],
"env": {
"VITALLY_API_KEY": "your_api_key_here",
"VITALLY_SUBDOMAIN": "acme"
}
}
}
}
```
### 7. Sanity check
Ask Claude: "Show me the health score for account ID `<any real account id>`." The response should include an overall health score and a breakdown of individual health components. Then ask: "List my at-risk accounts with a threshold of 40." Verify the returned accounts actually have health scores at or below 40 in the Vitally UI.
## Security model
- **Read-only by design.** The three tools call only `GET` endpoints. No write operations are included. This means there is nothing to scope-limit beyond read access to account, health, and conversation data — the entire risk surface is disclosure, not mutation.
- **API key scope.** Vitally API keys give read access to all objects the generating admin can see. There is no per-object scope at the API key level. Create a dedicated Vitally admin user for this integration, give it view-only access to the accounts and conversations your CSMs need, and generate the key from that user. Do not use a full-admin key.
- **Data in Claude's context window.** Account names, health scores, conversation subjects, and message bodies pass through Claude's context. Review your org's AI/data policy before enabling this for accounts that hold PII or contractually sensitive information. The server does not log requests or responses; what Claude does with the data depends on your Claude plan's data-retention settings.
- **Key storage.** The scaffold reads `VITALLY_API_KEY` from env. For team deployments, store the key in your secret manager (Vault, AWS Secrets Manager, 1Password CLI) and inject it at process start, rather than hardcoding it in the Claude Desktop config JSON.
## Known limits and TODOs (before production use)
1. [ ] Add OAuth or service-account key rotation — the current scaffold uses a static API key; implement a refresh path or a key-rotation reminder cron.
2. [ ] Implement cursor-based pagination in `list_at_risk_accounts` — the current scaffold fetches one page (up to 100 accounts) and filters locally; for orgs with >100 accounts, add a loop over the `next` cursor until exhausted or limit reached.
3. [ ] Add structured logging via `python-json-logger` — emit one JSON line per tool call with name, arguments hash, duration ms, and HTTP status code.
4. [ ] Wire optional Sentry / OpenTelemetry export for latency and error tracking.
5. [ ] Add integration tests against a Vitally sandbox or staging tenant.
6. [ ] Validate `VITALLY_SUBDOMAIN` at startup against the Vitally API (a lightweight `GET /resources/accounts?limit=1` can confirm the key and subdomain are valid before accepting tool calls).
7. [ ] Support EU data center (`rest.vitally-eu.io`) configuration with a clearer env var — currently handled via `VITALLY_BASE_URL` override, but a `VITALLY_REGION=eu` flag would be friendlier.
8. [ ] Add `list_at_risk_accounts` sort by health score ascending so the worst accounts surface first without the caller needing to sort client-side.
"""
vitally-cs-mcp — MCP server for Customer Success workflows on Vitally.
Exposes three read-only tools:
- get_account_health(account_id) — single account + health score breakdown
- list_at_risk_accounts(threshold, limit) — paginated list filtered by health score
- get_account_conversations(account_id, limit) — recent conversations for an account
All tools are read-only; no write operations are included. Authentication uses
Vitally's HTTP Basic Auth with the API key as the username (empty password).
STATUS: scaffold — not runtime-tested. Adapt the subdomain, field names, and any
custom trait keys to your org before use.
Run as: python -m vitally_cs_mcp.server
"""
from __future__ import annotations
import base64
import os
from typing import Any
import httpx
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import TextContent, Tool
# ----- Configuration (read from env at startup) -----
VITALLY_API_KEY = os.environ.get("VITALLY_API_KEY", "")
VITALLY_SUBDOMAIN = os.environ.get("VITALLY_SUBDOMAIN", "")
# VITALLY_BASE_URL can be set explicitly (useful for EU: https://rest.vitally-eu.io)
# or auto-built from VITALLY_SUBDOMAIN.
_base_url_env = os.environ.get("VITALLY_BASE_URL", "").rstrip("/")
VITALLY_BASE_URL: str = _base_url_env or (
f"https://{VITALLY_SUBDOMAIN}.rest.vitally.io" if VITALLY_SUBDOMAIN else ""
)
# Default health score threshold for list_at_risk_accounts.
# Vitally scores run 0–100; typical red band is 0–33, orange 34–66, green 67–100.
VITALLY_HEALTH_THRESHOLD: int = int(os.environ.get("VITALLY_HEALTH_THRESHOLD", "50"))
# Vitally caps list responses at 100 records per page.
VITALLY_PAGE_LIMIT: int = min(int(os.environ.get("VITALLY_PAGE_LIMIT", "100")), 100)
def require_config() -> None:
if not VITALLY_API_KEY:
raise RuntimeError("VITALLY_API_KEY env var is required")
if not VITALLY_BASE_URL:
raise RuntimeError(
"Either VITALLY_SUBDOMAIN or VITALLY_BASE_URL env var is required"
)
def auth_headers() -> dict[str, str]:
"""
Vitally uses HTTP Basic Auth with the API key as the username and an empty password.
The Authorization header value is: Basic base64("<apikey>:")
"""
token = base64.b64encode(f"{VITALLY_API_KEY}:".encode()).decode("ascii")
return {
"Authorization": f"Basic {token}",
"Content-Type": "application/json",
}
# ----- Vitally REST helpers -----
async def vitally_get(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
"""
Make an authenticated GET request to the Vitally REST API.
Path should start with /resources/...
"""
url = f"{VITALLY_BASE_URL}{path}"
async with httpx.AsyncClient(timeout=30.0) as client:
r = await client.get(url, headers=auth_headers(), params=params)
r.raise_for_status()
return r.json()
# ----- Server + tool registry -----
server = Server("vitally-cs")
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="get_account_health",
description=(
"Fetch a Vitally account by ID and its full health score breakdown. "
"Returns the account record and the healthScores array, which includes "
"each health component name, score, and status."
),
inputSchema={
"type": "object",
"properties": {
"account_id": {
"type": "string",
"description": (
"Vitally account ID or externalId. "
"Use the Vitally-assigned UUID or the externalId your system sent."
),
}
},
"required": ["account_id"],
},
),
Tool(
name="list_at_risk_accounts",
description=(
"List accounts whose overall health score is at or below a threshold. "
"Fetches one page of accounts (up to the limit), filters by health score, "
"and returns matches sorted by health score ascending (worst first). "
"For orgs with >100 accounts, only the first page is scanned — "
"see the TODO in the README for full pagination."
),
inputSchema={
"type": "object",
"properties": {
"health_threshold": {
"type": "integer",
"description": (
"Accounts with a health score at or below this value are returned. "
"Vitally scores run 0–100. Defaults to the VITALLY_HEALTH_THRESHOLD "
"env var (default 50)."
),
},
"limit": {
"type": "integer",
"description": "Max accounts to return in the response (after filtering). Default 20.",
"default": 20,
},
},
},
),
Tool(
name="get_account_conversations",
description=(
"Fetch recent conversations linked to a Vitally account. "
"Returns conversations in newest-first order with subject, message count, "
"and traits. Useful for reviewing recent CSM activity before a call."
),
inputSchema={
"type": "object",
"properties": {
"account_id": {
"type": "string",
"description": "Vitally account ID or externalId.",
},
"limit": {
"type": "integer",
"description": "Max conversations to return. Default 10, max 100.",
"default": 10,
},
},
"required": ["account_id"],
},
),
]
# ----- Tool dispatch -----
@server.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
require_config()
# ------------------------------------------------------------------
# get_account_health
# Fetches /resources/accounts/:id and /resources/accounts/:id/healthScores
# in two requests, then merges the results.
# ------------------------------------------------------------------
if name == "get_account_health":
account_id = arguments["account_id"]
# Fetch the base account record.
account = await vitally_get(f"/resources/accounts/{account_id}")
# Fetch the health score breakdown.
# Response: { "results": [ { "name": "...", "score": 72, "healthStatus": "healthy", ... }, ... ] }
health_data = await vitally_get(f"/resources/accounts/{account_id}/healthScores")
health_scores = health_data.get("results", [])
result = {
"account": {
"id": account.get("id"),
"externalId": account.get("externalId"),
"name": account.get("name"),
"healthScore": account.get("healthScore"),
"traits": account.get("traits", {}),
},
"healthScores": health_scores,
}
return [TextContent(type="text", text=str(result))]
# ------------------------------------------------------------------
# list_at_risk_accounts
# Fetches one page of accounts, filters to those at or below threshold.
# Returns sorted by healthScore ascending (worst first).
# ------------------------------------------------------------------
if name == "list_at_risk_accounts":
threshold = arguments.get("health_threshold", VITALLY_HEALTH_THRESHOLD)
return_limit = min(int(arguments.get("limit", 20)), 100)
# Fetch one full page of active accounts.
# Vitally's list-accounts endpoint: GET /resources/accounts?limit=100&status=active
page = await vitally_get(
"/resources/accounts",
params={"limit": VITALLY_PAGE_LIMIT, "status": "active"},
)
accounts = page.get("results", [])
# Filter: include only accounts where healthScore is defined and <= threshold.
# healthScore may be None if Vitally hasn't computed it yet (new accounts, missing data).
at_risk = [
{
"id": a.get("id"),
"externalId": a.get("externalId"),
"name": a.get("name"),
"healthScore": a.get("healthScore"),
"traits": a.get("traits", {}),
}
for a in accounts
if a.get("healthScore") is not None and a["healthScore"] <= threshold
]
# Sort by healthScore ascending so the worst accounts surface first.
at_risk.sort(key=lambda a: a["healthScore"])
# Trim to the requested return limit.
at_risk = at_risk[:return_limit]
result = {
"threshold": threshold,
"total_scanned": len(accounts),
"at_risk_count": len(at_risk),
"note": (
"Only the first page of accounts was scanned. "
"See README TODO #2 to add full cursor pagination."
)
if page.get("next")
else "All active accounts were scanned.",
"accounts": at_risk,
}
return [TextContent(type="text", text=str(result))]
# ------------------------------------------------------------------
# get_account_conversations
# Fetches /resources/accounts/:id/conversations?limit=N
# Conversations are returned by Vitally in newest-first order by default.
# ------------------------------------------------------------------
if name == "get_account_conversations":
account_id = arguments["account_id"]
limit = min(int(arguments.get("limit", 10)), 100)
data = await vitally_get(
f"/resources/accounts/{account_id}/conversations",
params={"limit": limit},
)
conversations = data.get("results", [])
# Trim each conversation to the fields most useful in a Claude context window.
trimmed = [
{
"id": c.get("id"),
"externalId": c.get("externalId"),
"subject": c.get("subject"),
"messageCount": len(c.get("messages", [])),
"firstMessage": (c.get("messages") or [{}])[0].get("body", ""),
"traits": c.get("traits", {}),
"createdAt": c.get("createdAt"),
"updatedAt": c.get("updatedAt"),
}
for c in conversations
]
result = {
"account_id": account_id,
"conversation_count": len(trimmed),
"conversations": trimmed,
}
return [TextContent(type="text", text=str(result))]
raise ValueError(f"Unknown tool: {name}")
# ----- Entrypoint -----
async def main() -> None:
require_config()
async with stdio_server() as (read, write):
await server.run(read, write, server.create_initialization_options())
if __name__ == "__main__":
import asyncio
asyncio.run(main())