mcp-pavoot 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.
@@ -0,0 +1,21 @@
1
+ # ─── Runtime config (every user of the MCP) ─────────────────────────────────
2
+
3
+ # The base URL of the Pavoot API. Use the production URL by default; switch
4
+ # to http://localhost:8000 (or wherever yc-pavoot-api is running) for local
5
+ # testing.
6
+ PAVOOT_API_URL=https://yc-api.pavoot.com
7
+
8
+ # Your personal API key. Mint one with:
9
+ # POST /users/api-keys body {"name": "Claude Desktop"}
10
+ # The raw key is shown ONCE on creation — paste it here and never check it in.
11
+ PAVOOT_API_KEY=pvt_live_replace_me
12
+
13
+
14
+ # ─── Maintainer-only (only needed if you publish to PyPI) ───────────────────
15
+ # These are NOT required to run the MCP — they're only read by publish.sh
16
+ # and redeploy.sh. Users installing `mcp-pavoot` from PyPI ignore them.
17
+
18
+ # PyPI API token from https://pypi.org/manage/account/token/
19
+ # Scope: "Entire account" for the first publish, then narrow to project-only.
20
+ # The token starts with `pypi-`. Treat it like a password.
21
+ PYPI_API_TOKEN=pypi-replace_me
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ .env
3
+ __pycache__/
4
+ *.egg-info/
5
+ *.pyc
6
+ .DS_Store
7
+ build/
8
+ dist/
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-pavoot
3
+ Version: 0.1.0
4
+ Summary: MCP server that exposes the Pavoot event-planning API
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: httpx>=0.27
7
+ Requires-Dist: mcp[cli]>=1.6.0
8
+ Requires-Dist: python-dotenv>=1.0
@@ -0,0 +1,185 @@
1
+ # mcp-pavoot
2
+
3
+ A [Model Context Protocol](https://modelcontextprotocol.io) server that
4
+ exposes the Pavoot event-planning API to MCP hosts (Claude Desktop,
5
+ Cursor, etc.). Once connected, an MCP host can do everything you can do
6
+ in the web app: list your events, drive the planning chat, refine the
7
+ proposed attendees, edit invitation drafts, manage RSVPs and check-ins,
8
+ and generate post-event follow-up emails — all scoped to your account
9
+ via a personal API key.
10
+
11
+ ## Setup
12
+
13
+ ### 1. Mint a Pavoot API key
14
+
15
+ API keys are scoped per-user. Sign in to Pavoot in the browser to get a
16
+ Clerk session token, then call the API directly:
17
+
18
+ ```bash
19
+ # Replace <CLERK_JWT> with a fresh token from the browser's network tab.
20
+ curl -X POST https://yc-api.pavoot.com/users/api-keys \
21
+ -H "Authorization: Bearer <CLERK_JWT>" \
22
+ -H "Content-Type: application/json" \
23
+ -d '{"name": "Claude Desktop"}'
24
+ ```
25
+
26
+ The response includes a `key` field starting with `pvt_live_…`. **Copy it
27
+ now — it's shown exactly once.** Lose it and you'll have to revoke +
28
+ recreate. The server only stores the SHA-256 hash.
29
+
30
+ To list your active keys later:
31
+
32
+ ```bash
33
+ curl https://yc-api.pavoot.com/users/api-keys \
34
+ -H "Authorization: Bearer <CLERK_JWT>"
35
+ ```
36
+
37
+ To revoke one:
38
+
39
+ ```bash
40
+ curl -X DELETE https://yc-api.pavoot.com/users/api-keys/<KEY_ID> \
41
+ -H "Authorization: Bearer <CLERK_JWT>"
42
+ ```
43
+
44
+ ### 2. Install the MCP server
45
+
46
+ ```bash
47
+ cd mcp-pavoot
48
+ python3.12 -m venv .venv
49
+ .venv/bin/pip install -e .
50
+ cp .env.example .env # then paste your real PAVOOT_API_KEY
51
+ ```
52
+
53
+ ### 3. Wire it into your MCP host
54
+
55
+ #### Claude Desktop
56
+
57
+ Open `~/Library/Application Support/Claude/claude_desktop_config.json`
58
+ and add:
59
+
60
+ ```json
61
+ {
62
+ "mcpServers": {
63
+ "pavoot": {
64
+ "command": "/Users/ana/Documents/event-new-app/mcp-pavoot/.venv/bin/python",
65
+ "args": ["-m", "mcp_pavoot"],
66
+ "env": {
67
+ "PAVOOT_API_URL": "https://yc-api.pavoot.com",
68
+ "PAVOOT_API_KEY": "pvt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
69
+ }
70
+ }
71
+ }
72
+ }
73
+ ```
74
+
75
+ Restart Claude Desktop. The Pavoot tools should appear under the 🔌 icon.
76
+
77
+ #### Cursor
78
+
79
+ Add to `~/.cursor/mcp.json`:
80
+
81
+ ```json
82
+ {
83
+ "mcpServers": {
84
+ "pavoot": {
85
+ "command": "/Users/ana/Documents/event-new-app/mcp-pavoot/.venv/bin/python",
86
+ "args": ["-m", "mcp_pavoot"],
87
+ "env": {
88
+ "PAVOOT_API_URL": "https://yc-api.pavoot.com",
89
+ "PAVOOT_API_KEY": "pvt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
90
+ }
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ #### Standalone (interactive testing)
97
+
98
+ ```bash
99
+ .venv/bin/mcp dev src/mcp_pavoot/server.py
100
+ ```
101
+
102
+ This opens the MCP Inspector in your browser so you can call each tool
103
+ directly without a host.
104
+
105
+ ### 4. Smoke-test
106
+
107
+ In your MCP host, try:
108
+
109
+ > List my Pavoot events.
110
+
111
+ The host should call `list_my_events`, then `whoami` to confirm the
112
+ account. From there you can:
113
+
114
+ > For event "AI Founders Mixer", show me the proposed attendees as a markdown table.
115
+ >
116
+ > Generate invitation drafts for everyone, then show me the first three.
117
+ >
118
+ > Change my pre-event chat — tell it to focus more on AI infra founders.
119
+
120
+ ## Tools
121
+
122
+ | Domain | Tools |
123
+ |---|---|
124
+ | Util | `whoami`, `health_check` |
125
+ | Events | `list_my_events`, `create_event`, `delete_event`, `get_event_state`, `reset_event`, `duplicate_event`, `regenerate_public_token`, `finalize_event`, `get_finalized`, `quick_setup` |
126
+ | Entry flow | `get_entry_flow`, `answer_entry_flow`, `skip_entry_flow` |
127
+ | Target group | `set_target_group_source`, `get_target_group`, `refine_target_group`, `confirm_target_group` |
128
+ | Concept | `get_event_suggestions`, `select_event_suggestion`, `tweak_event` |
129
+ | Date / location / details | `get_date_location_options`, `set_date_location`, `set_event_details`, `set_event_settings` |
130
+ | Attendees | `get_proposed_attendees`, `refine_attendees`, `review_attendee`, `decline_attendee`, `regenerate_attendees`, `confirm_attendees` |
131
+ | Chat | `pre_event_agent`, `pre_event_chat` |
132
+ | Notes | `get_pre_event_notes`, `set_pre_event_notes`, `get_post_event_notes`, `set_post_event_notes` |
133
+ | Notetaker | `list_notetaker_events`, `list_notetaker_attendees`, `list_attendee_notes`, `add_attendee_note`, `update_attendee_note`, `get_all_event_notes` |
134
+ | Live event | `get_event_dashboard`, `list_rsvps`, `add_rsvp`, `approve_rsvp`, `decline_rsvp`, `check_in_attendee`, `reset_rsvp`, `mark_rsvp`, `check_in_by_qr_token`, `close_registration`, `open_registration` |
135
+ | Invite drafts | `get_invite_template`, `set_invite_template`, `generate_invite_drafts`, `list_invite_drafts`, `update_invite_draft`, `regenerate_invite_draft`, `send_invite_draft`, `send_all_invite_drafts` |
136
+ | CRM | `list_companies`, `list_people`, `get_person`, `enrich_people`, `suggest_events`, `get_attendee_suggestions` |
137
+ | Post-event | `get_event_analytics`, `list_followup_drafts`, `generate_followup_emails`, `update_followup_draft`, `list_attendee_followups`, `generate_attendee_followups`, `update_attendee_followup`, `get_event_report_url`, `create_report_share_link`, `list_followup_templates`, `seed_default_templates`, `create_followup_template`, `update_followup_template`, `delete_followup_template` |
138
+
139
+ Read-heavy tools (`list_my_events`, `list_people`, `list_rsvps`,
140
+ `list_invite_drafts`, etc.) accept a `format` argument:
141
+
142
+ - `"json"` (default) — full structured payload
143
+ - `"markdown"` — table or key/value list, good for direct chat display
144
+ - `"csv"` — paste-into-spreadsheet
145
+ - `"summary"` — one-line headline ("12 RSVPs — 8 yes, 2 waitlist, 2 pending")
146
+
147
+ ## Security
148
+
149
+ - The raw API key is stored ONLY in your MCP host config (and optionally in
150
+ `mcp-pavoot/.env` — gitignored). The server stores only `sha256(key)`.
151
+ - Each request hits an indexed lookup; `last_used_at` is updated atomically.
152
+ - API keys can NOT mint, list, or revoke other API keys — that surface
153
+ requires a Clerk browser session (`require_clerk_jwt` dependency).
154
+ - Every mutating request is logged in `request_logs` with the resolved
155
+ `user_id` and a `X-Request-Source: mcp-pavoot` tag so MCP traffic is
156
+ distinguishable from browser traffic on the same account.
157
+
158
+ ## Architecture
159
+
160
+ ```
161
+ src/mcp_pavoot/
162
+ __main__.py # `python -m mcp_pavoot` entry point
163
+ server.py # all @mcp.tool() definitions (83 tools)
164
+ http.py # shared httpx.AsyncClient, auth header, error translation
165
+ formatters.py # json | markdown | csv | summary
166
+ ```
167
+
168
+ Auth on the Pavoot API end:
169
+
170
+ - `auth/clerk.py::authenticate_clerk_request` detects `Authorization:
171
+ Bearer pvt_live_…` and routes to a fast DB lookup (one indexed query,
172
+ no network). Anything else falls through to the Clerk JWT verifier.
173
+ - `auth/clerk.py::require_clerk_jwt` is the same as `get_current_user_id`
174
+ but rejects API-key auth with 403 — used by `/users/api-keys` endpoints.
175
+
176
+ ## Development
177
+
178
+ ```bash
179
+ .venv/bin/mcp dev src/mcp_pavoot/server.py # interactive inspector
180
+ .venv/bin/python -c "from mcp_pavoot.server import mcp" # syntax check
181
+ ```
182
+
183
+ When updating the API, add the corresponding tool to `server.py`, then
184
+ update the table in this README. The path strings in `server.py` should
185
+ exactly match the routes in `yc-pavoot-api/*/router.py`.
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bash
2
+ # Publish `mcp-pavoot` to PyPI.
3
+ #
4
+ # Reads PYPI_API_TOKEN from .env (or the environment). Uploads the version
5
+ # currently declared in pyproject.toml — does NOT bump it. Use this for
6
+ # your first publish; for subsequent releases use ./redeploy.sh which bumps
7
+ # the patch version first.
8
+ #
9
+ # Safety:
10
+ # - Refuses to upload if the version already exists on PyPI (PyPI rejects
11
+ # re-uploads of the same version, but we check first so the failure mode
12
+ # is a clean error instead of a half-built dist/).
13
+ # - Wipes dist/ before building so old wheels don't get re-uploaded.
14
+ # - Uses the project's .venv if it exists; falls back to system python.
15
+
16
+ set -euo pipefail
17
+
18
+ cd "$(dirname "$0")"
19
+
20
+ # ── Load .env (if present) without exporting unrelated vars ─────────────────
21
+ if [[ -f .env ]]; then
22
+ set -o allexport
23
+ # shellcheck disable=SC1091
24
+ source .env
25
+ set +o allexport
26
+ fi
27
+
28
+ if [[ -z "${PYPI_API_TOKEN:-}" || "$PYPI_API_TOKEN" == "pypi-replace_me" ]]; then
29
+ echo "✗ PYPI_API_TOKEN is not set."
30
+ echo " 1) Create a token at https://pypi.org/manage/account/token/"
31
+ echo " 2) Paste it into mcp-pavoot/.env as PYPI_API_TOKEN=pypi-..."
32
+ exit 1
33
+ fi
34
+
35
+ # ── Pick interpreter (prefer the project venv) ──────────────────────────────
36
+ PY=".venv/bin/python"
37
+ if [[ ! -x "$PY" ]]; then
38
+ PY="$(command -v python3 || command -v python)"
39
+ fi
40
+ echo "→ Using interpreter: $PY"
41
+
42
+ # ── Ensure build + twine are installed ──────────────────────────────────────
43
+ "$PY" -m pip install --quiet --upgrade build twine
44
+
45
+ # ── Read the version we're about to ship ────────────────────────────────────
46
+ VERSION="$(
47
+ "$PY" -c "
48
+ import re, pathlib
49
+ m = re.search(r'^version\s*=\s*\"([^\"]+)\"', pathlib.Path('pyproject.toml').read_text(), re.M)
50
+ print(m.group(1) if m else '')"
51
+ )"
52
+ if [[ -z "$VERSION" ]]; then
53
+ echo "✗ Couldn't find a version in pyproject.toml. Aborting."
54
+ exit 1
55
+ fi
56
+ echo "→ Publishing mcp-pavoot version $VERSION"
57
+
58
+ # ── Refuse to re-upload an existing version ─────────────────────────────────
59
+ STATUS="$(curl -s -o /dev/null -w '%{http_code}' "https://pypi.org/pypi/mcp-pavoot/$VERSION/json" || echo 000)"
60
+ if [[ "$STATUS" == "200" ]]; then
61
+ echo "✗ Version $VERSION already exists on PyPI. Bump the version in"
62
+ echo " pyproject.toml (or run ./redeploy.sh) and try again."
63
+ exit 1
64
+ fi
65
+
66
+ # ── Clean + build ───────────────────────────────────────────────────────────
67
+ echo "→ Cleaning dist/ and rebuilding..."
68
+ rm -rf dist/ build/ ./*.egg-info src/*.egg-info
69
+ "$PY" -m build
70
+
71
+ # ── Upload ──────────────────────────────────────────────────────────────────
72
+ echo "→ Uploading to PyPI..."
73
+ TWINE_USERNAME="__token__" \
74
+ TWINE_PASSWORD="$PYPI_API_TOKEN" \
75
+ "$PY" -m twine upload --non-interactive dist/*
76
+
77
+ echo
78
+ echo "✓ Published mcp-pavoot $VERSION"
79
+ echo " Browse: https://pypi.org/project/mcp-pavoot/$VERSION/"
80
+ echo " Install: uvx mcp-pavoot"
@@ -0,0 +1,20 @@
1
+ [project]
2
+ name = "mcp-pavoot"
3
+ version = "0.1.0"
4
+ description = "MCP server that exposes the Pavoot event-planning API"
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "mcp[cli]>=1.6.0",
8
+ "httpx>=0.27",
9
+ "python-dotenv>=1.0",
10
+ ]
11
+
12
+ [project.scripts]
13
+ mcp-pavoot = "mcp_pavoot.__main__:main"
14
+
15
+ [build-system]
16
+ requires = ["hatchling"]
17
+ build-backend = "hatchling.build"
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/mcp_pavoot"]
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env bash
2
+ # Bump the patch version in pyproject.toml and publish to PyPI.
3
+ #
4
+ # Use this after making changes to mcp-pavoot. It:
5
+ # 1) Bumps the patch version (e.g. 0.1.0 → 0.1.1) in pyproject.toml
6
+ # 2) Calls ./publish.sh, which builds + uploads
7
+ #
8
+ # Override the bump kind with the first arg:
9
+ # ./redeploy.sh # patch bump (default)
10
+ # ./redeploy.sh patch # 0.1.0 -> 0.1.1
11
+ # ./redeploy.sh minor # 0.1.5 -> 0.2.0
12
+ # ./redeploy.sh major # 0.9.3 -> 1.0.0
13
+ # ./redeploy.sh 1.2.3 # set explicit version
14
+ #
15
+ # PyPI rejects re-uploading the same version, so every redeploy MUST bump.
16
+
17
+ set -euo pipefail
18
+
19
+ cd "$(dirname "$0")"
20
+
21
+ BUMP="${1:-patch}"
22
+
23
+ PY=".venv/bin/python"
24
+ if [[ ! -x "$PY" ]]; then
25
+ PY="$(command -v python3 || command -v python)"
26
+ fi
27
+
28
+ # ── Bump pyproject.toml in place ────────────────────────────────────────────
29
+ NEW_VERSION="$(
30
+ "$PY" - "$BUMP" <<'PY_BUMP'
31
+ import pathlib, re, sys
32
+
33
+ bump = sys.argv[1]
34
+ path = pathlib.Path("pyproject.toml")
35
+ text = path.read_text()
36
+ m = re.search(r'^(version\s*=\s*")(\d+)\.(\d+)\.(\d+)(")', text, re.M)
37
+ if not m:
38
+ sys.exit("✗ Couldn't find version = \"x.y.z\" in pyproject.toml")
39
+
40
+ major, minor, patch = int(m.group(2)), int(m.group(3)), int(m.group(4))
41
+
42
+ if bump == "patch":
43
+ patch += 1
44
+ elif bump == "minor":
45
+ minor += 1
46
+ patch = 0
47
+ elif bump == "major":
48
+ major += 1
49
+ minor = 0
50
+ patch = 0
51
+ elif re.match(r"^\d+\.\d+\.\d+$", bump):
52
+ major, minor, patch = (int(x) for x in bump.split("."))
53
+ else:
54
+ sys.exit(f"✗ Unknown bump kind: {bump!r}. Use patch/minor/major or x.y.z.")
55
+
56
+ new = f"{major}.{minor}.{patch}"
57
+ new_text = text[:m.start(2)] + new + text[m.end(4):]
58
+ path.write_text(new_text)
59
+ print(new)
60
+ PY_BUMP
61
+ )"
62
+
63
+ OLD_VERSION="$(git diff -U0 pyproject.toml 2>/dev/null | grep -E '^-version\s*=' | sed -E 's/.*"([^"]+)".*/\1/' | head -1 || true)"
64
+
65
+ echo "→ Version bumped${OLD_VERSION:+ from $OLD_VERSION} to $NEW_VERSION (in pyproject.toml)"
66
+ echo
67
+
68
+ # ── Hand off to publish.sh ──────────────────────────────────────────────────
69
+ exec ./publish.sh
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+ # Launch the Pavoot MCP server. Invoked by MCP hosts (Claude Desktop, Cursor)
3
+ # via the `command` field in their config — see README.md for the full block.
4
+ set -euo pipefail
5
+ cd "$(dirname "$0")"
6
+ exec .venv/bin/python -m mcp_pavoot
@@ -0,0 +1,8 @@
1
+ """Pavoot MCP server package.
2
+
3
+ Exposes the Pavoot event-management API as a set of MCP tools so an MCP
4
+ host (Claude Desktop, Cursor, etc.) can plan events end-to-end for a
5
+ single authenticated user.
6
+ """
7
+
8
+ __version__ = "0.1.0"
@@ -0,0 +1,12 @@
1
+ """Entry point: `python -m mcp_pavoot` boots the MCP server over stdio."""
2
+ from __future__ import annotations
3
+
4
+ from mcp_pavoot.server import mcp
5
+
6
+
7
+ def main() -> None:
8
+ mcp.run()
9
+
10
+
11
+ if __name__ == "__main__":
12
+ main()
@@ -0,0 +1,224 @@
1
+ """Response formatters for the MCP tools.
2
+
3
+ The API always returns JSON; the MCP wraps read-heavy responses so a tool
4
+ can be asked for `markdown` (good for direct chat display), `csv` (good
5
+ for paste-into-spreadsheet), or `summary` (a one-line "here's what's in
6
+ this" line). The default `json` returns the payload untouched.
7
+
8
+ We keep this layer thin on purpose: don't try to be too clever about
9
+ which columns to surface; pick a handful of the most useful ones per
10
+ `kind`. If the LLM wants more it can re-call with format='json'.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import csv
15
+ import io
16
+ from typing import Any
17
+
18
+ Format = str # "json" | "markdown" | "csv" | "summary"
19
+
20
+ # Per-kind column hints for tabular formats. Empty list = "show every key".
21
+ _COLUMNS: dict[str, list[str]] = {
22
+ "events": ["id", "name", "starts_at", "active_task", "finalized", "status"],
23
+ "people": ["id", "name", "title", "company_name", "enrich_status"],
24
+ "companies": ["name", "one_liner", "industries", "location", "website"],
25
+ "attendees": ["id", "name", "title", "company", "city", "email"],
26
+ "rsvps": ["contact_id", "name", "company", "status", "email"],
27
+ "invite_drafts": ["id", "recipient_name", "recipient_email", "subject", "status"],
28
+ "followup_drafts": ["segment", "subject"],
29
+ "attendee_followups": ["contact_id", "name", "segment", "subject", "note_count"],
30
+ "templates": ["id", "name", "is_default", "rule_definition"],
31
+ "notes": ["created_at", "author_name", "method", "important", "content"],
32
+ "notetaker_attendees": ["id", "display_name", "company", "title", "note_count", "my_note_count", "latest_note_at"],
33
+ "notetaker_events": ["id", "name", "starts_at", "status", "attendee_count", "invited_count"],
34
+ }
35
+
36
+
37
+ def _truncate(s: Any, limit: int = 120) -> str:
38
+ text = str(s) if s is not None else ""
39
+ if len(text) <= limit:
40
+ return text
41
+ return text[: limit - 1].rstrip() + "…"
42
+
43
+
44
+ def _md_escape(s: Any) -> str:
45
+ return _truncate(s).replace("|", "\\|").replace("\n", " ")
46
+
47
+
48
+ def _pick_columns(kind: str, row: dict[str, Any]) -> list[str]:
49
+ cols = _COLUMNS.get(kind) or []
50
+ if cols:
51
+ return [c for c in cols if c in row] + [k for k in row if k not in cols]
52
+ return list(row.keys())
53
+
54
+
55
+ def _markdown_table(rows: list[dict[str, Any]], kind: str) -> str:
56
+ if not rows:
57
+ return "_(no rows)_"
58
+ cols = _pick_columns(kind, rows[0])
59
+ header = "| " + " | ".join(cols) + " |"
60
+ sep = "| " + " | ".join("---" for _ in cols) + " |"
61
+ body = "\n".join(
62
+ "| " + " | ".join(_md_escape(r.get(c, "")) for c in cols) + " |"
63
+ for r in rows
64
+ )
65
+ return "\n".join([header, sep, body])
66
+
67
+
68
+ def _markdown_kv(d: dict[str, Any]) -> str:
69
+ return "\n".join(f"- **{k}**: {_truncate(v)}" for k, v in d.items())
70
+
71
+
72
+ def _to_csv(rows: list[dict[str, Any]], kind: str) -> str:
73
+ if not rows:
74
+ return ""
75
+ cols = _pick_columns(kind, rows[0])
76
+ buf = io.StringIO()
77
+ writer = csv.writer(buf)
78
+ writer.writerow(cols)
79
+ for r in rows:
80
+ writer.writerow([r.get(c, "") for c in cols])
81
+ return buf.getvalue()
82
+
83
+
84
+ def _summary(data: Any, kind: str) -> str:
85
+ """Single line that captures the headline for this kind of payload."""
86
+ if kind == "events" and isinstance(data, list):
87
+ finalized = sum(1 for e in data if e.get("finalized"))
88
+ return f"{len(data)} events ({finalized} finalized)."
89
+ if kind == "people" and isinstance(data, list):
90
+ enriched = sum(1 for p in data if p.get("enrich_status") == "enriched")
91
+ return f"{len(data)} people ({enriched} enriched)."
92
+ if kind == "attendees" and isinstance(data, dict):
93
+ proposed = data.get("proposed") or []
94
+ confirmed = data.get("confirmed") or []
95
+ return f"{len(proposed)} proposed, {len(confirmed)} confirmed."
96
+ if kind == "rsvps" and isinstance(data, dict):
97
+ rsvps = data.get("rsvps") or data.get("attendees") or []
98
+ counts = data.get("counts") or {}
99
+ if counts:
100
+ pretty = ", ".join(f"{v} {k}" for k, v in counts.items() if v and k != "total")
101
+ return f"{len(rsvps)} attendees — {pretty}."
102
+ return f"{len(rsvps)} attendees."
103
+ if kind == "dashboard" and isinstance(data, dict):
104
+ counts = data.get("counts") or {}
105
+ pretty = ", ".join(f"{v} {k}" for k, v in counts.items() if v)
106
+ return pretty or "no activity yet."
107
+ if kind == "invite_drafts" and isinstance(data, dict):
108
+ counts = data.get("counts") or {}
109
+ return ", ".join(f"{v} {k}" for k, v in counts.items()) or "no drafts."
110
+ if kind == "followup_drafts" and isinstance(data, dict):
111
+ drafts = data.get("drafts") or []
112
+ return f"{len(drafts)} follow-up drafts."
113
+ if kind == "attendee_followups" and isinstance(data, dict):
114
+ drafts = data.get("drafts") or []
115
+ return f"{len(drafts)} per-attendee drafts."
116
+ if kind == "notes" and isinstance(data, list):
117
+ important = sum(1 for n in data if n.get("important"))
118
+ return f"{len(data)} notes ({important} important)."
119
+ if kind == "notetaker_attendees" and isinstance(data, list):
120
+ with_notes = sum(1 for a in data if (a.get("note_count") or 0) > 0)
121
+ return f"{len(data)} checked in ({with_notes} have notes)."
122
+ if kind == "notetaker_events" and isinstance(data, list):
123
+ return f"{len(data)} events."
124
+ if kind == "event_notes_bundle" and isinstance(data, dict):
125
+ attendees = data.get("attendees") or []
126
+ total = data.get("total_notes") or 0
127
+ with_notes = sum(1 for a in attendees if (a.get("notes") or []))
128
+ return f"{total} notes across {with_notes}/{len(attendees)} attendees."
129
+ if kind == "analytics" and isinstance(data, dict):
130
+ funnel = data.get("funnel") or {}
131
+ if funnel:
132
+ return " → ".join(f"{k}:{v}" for k, v in funnel.items())
133
+ if isinstance(data, list):
134
+ return f"{len(data)} items."
135
+ return "(summary unavailable for this payload)"
136
+
137
+
138
+ # Common shapes the API returns for lists-inside-a-dict. The formatter
139
+ # unwraps these so users don't have to know the envelope name.
140
+ _LIST_KEYS = ("rsvps", "drafts", "attendees", "people", "events", "keys", "templates")
141
+
142
+
143
+ def _extract_rows(data: Any) -> list[dict[str, Any]] | None:
144
+ if isinstance(data, list):
145
+ return data
146
+ if isinstance(data, dict):
147
+ for k in _LIST_KEYS:
148
+ v = data.get(k)
149
+ if isinstance(v, list):
150
+ return v
151
+ # Special case: proposed-attendees view has {proposed, confirmed}.
152
+ if "proposed" in data and isinstance(data["proposed"], list):
153
+ return data["proposed"]
154
+ return None
155
+
156
+
157
+ def _markdown_event_notes_bundle(data: dict[str, Any]) -> str:
158
+ """One-section-per-attendee transcript view of every note for an event."""
159
+ attendees = data.get("attendees") or []
160
+ if not attendees:
161
+ return "_(no checked-in attendees)_"
162
+ parts: list[str] = []
163
+ for a in attendees:
164
+ header = a.get("display_name") or a.get("name") or a.get("id") or "Unknown"
165
+ sub = []
166
+ if a.get("title"): sub.append(str(a["title"]))
167
+ if a.get("company"): sub.append(str(a["company"]))
168
+ suffix = f" — {' @ '.join(sub)}" if sub else ""
169
+ parts.append(f"### {header}{suffix}")
170
+ notes = a.get("notes") or []
171
+ if not notes:
172
+ parts.append("_(no notes)_")
173
+ continue
174
+ for n in notes:
175
+ ts = n.get("created_at", "")
176
+ star = "⭐ " if n.get("important") else ""
177
+ author = n.get("author_name") or ""
178
+ method = n.get("method") or ""
179
+ head = f"- **{ts}** {star}_{method}_"
180
+ if author:
181
+ head += f" · {author}"
182
+ parts.append(head)
183
+ content = _truncate(n.get("content"), 4000)
184
+ for line in content.splitlines() or [""]:
185
+ parts.append(f" {line}")
186
+ parts.append("")
187
+ return "\n".join(parts).rstrip()
188
+
189
+
190
+ def format_response(data: Any, format: Format, kind: str) -> Any:
191
+ """Shape a tool response for the model.
192
+
193
+ - `json` → passthrough.
194
+ - `summary` → a one-line description.
195
+ - `markdown` → table for list-shaped payloads, key/value list for dicts.
196
+ - `csv` → standard CSV for list-shaped payloads; falls back to JSON
197
+ for dicts (we don't try to flatten arbitrary objects).
198
+ """
199
+ fmt = (format or "json").lower()
200
+ if fmt == "json":
201
+ return data
202
+ if fmt == "summary":
203
+ return _summary(data, kind)
204
+
205
+ rows = _extract_rows(data)
206
+
207
+ if fmt == "markdown":
208
+ if kind == "event_notes_bundle" and isinstance(data, dict):
209
+ return _markdown_event_notes_bundle(data)
210
+ if rows is not None:
211
+ return _markdown_table(rows, kind)
212
+ if isinstance(data, dict):
213
+ return _markdown_kv(data)
214
+ return _truncate(data, 2000)
215
+
216
+ if fmt == "csv":
217
+ if rows is not None:
218
+ return _to_csv(rows, kind)
219
+ # CSV doesn't really fit dicts; punt back to JSON rather than
220
+ # producing something misleading.
221
+ return data
222
+
223
+ # Unknown format — fail soft and return JSON.
224
+ return data