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.
- mcp_pavoot-0.1.0/.env.example +21 -0
- mcp_pavoot-0.1.0/.gitignore +8 -0
- mcp_pavoot-0.1.0/.python-version +1 -0
- mcp_pavoot-0.1.0/PKG-INFO +8 -0
- mcp_pavoot-0.1.0/README.md +185 -0
- mcp_pavoot-0.1.0/publish.sh +80 -0
- mcp_pavoot-0.1.0/pyproject.toml +20 -0
- mcp_pavoot-0.1.0/redeploy.sh +69 -0
- mcp_pavoot-0.1.0/run_mcp.sh +6 -0
- mcp_pavoot-0.1.0/src/mcp_pavoot/__init__.py +8 -0
- mcp_pavoot-0.1.0/src/mcp_pavoot/__main__.py +12 -0
- mcp_pavoot-0.1.0/src/mcp_pavoot/formatters.py +224 -0
- mcp_pavoot-0.1.0/src/mcp_pavoot/http.py +175 -0
- mcp_pavoot-0.1.0/src/mcp_pavoot/server.py +1358 -0
|
@@ -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 @@
|
|
|
1
|
+
3.12
|
|
@@ -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,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
|