fix: sig restructure and simplification

This commit is contained in:
ajspig 2026-03-12 17:11:15 -04:00
parent 0baa2eca86
commit 216c71bf1a
1 changed files with 132 additions and 286 deletions

View File

@ -6,7 +6,7 @@ A one-time migration script that fetches all meeting notes from Granola MCP
and stores them in Honcho for long-term memory and reasoning.
Requirements:
pip install mcp honcho-ai httpx
pip install honcho-ai httpx
Environment Variables:
HONCHO_API_KEY - Your Honcho API key (get from app.honcho.dev/api-keys)
@ -33,10 +33,10 @@ from typing import Any, TypedDict, cast
GRANOLA_MCP_URL = "https://mcp.granola.ai/mcp"
# OAuth configuration for Granola
# Using Dynamic Client Registration - no client_id/secret needed
OAUTH_REDIRECT_PORT = 8765
OAUTH_REDIRECT_URI = f"http://localhost:{OAUTH_REDIRECT_PORT}/callback"
class Participant(TypedDict):
name: str
email: str | None
@ -108,95 +108,30 @@ class OAuthCallbackHandler(BaseHTTPRequestHandler):
class GranolaMCPClient:
"""Client for interacting with Granola MCP server."""
# Granola OAuth endpoints
AUTH_BASE = "https://mcp-auth.granola.ai"
AUTHORIZATION_ENDPOINT = f"{AUTH_BASE}/oauth2/authorize"
TOKEN_ENDPOINT = f"{AUTH_BASE}/oauth2/token"
REGISTRATION_ENDPOINT = f"{AUTH_BASE}/oauth2/register"
def __init__(self):
self.access_token: str | None = None
self.http_client: httpx.AsyncClient = httpx.AsyncClient(timeout=60.0)
self.auth_metadata: dict[str, Any] = {}
self.resource_metadata: dict[str, Any] = {}
self.client_id: str | None = None
self.pkce_verifier: str | None = None
def _generate_pkce(self) -> tuple[str, str]:
@staticmethod
def _generate_pkce() -> tuple[str, str]:
"""Generate PKCE code verifier and challenge."""
import secrets
import hashlib
import base64
# Generate code verifier (43-128 characters)
verifier = secrets.token_urlsafe(32)
# Generate code challenge using S256
challenge_bytes = hashlib.sha256(verifier.encode()).digest()
challenge = base64.urlsafe_b64encode(challenge_bytes).rstrip(b'=').decode()
return verifier, challenge
async def discover_protected_resource_metadata(self) -> dict[str, Any]:
"""
Fetch Protected Resource Metadata per RFC 9728 / MCP spec.
This tells us which auth server to use and what scopes are supported.
"""
base_url = GRANOLA_MCP_URL.rsplit('/mcp', 1)[0]
# Try the well-known endpoint for protected resource metadata
prm_url = f"{base_url}/.well-known/oauth-protected-resource"
try:
response = await self.http_client.get(prm_url)
if response.status_code == 200:
self.resource_metadata = cast(dict[str, Any], response.json())
print(f" Found protected resource metadata")
return self.resource_metadata
except Exception as e:
print(f" Could not fetch PRM: {e}")
return self.resource_metadata
async def discover_oauth_metadata(self) -> dict[str, Any]:
"""Fetch OAuth Authorization Server metadata."""
# First get the protected resource metadata to find auth server
await self.discover_protected_resource_metadata()
# Get auth server URL from resource metadata or use known URL
auth_servers = self.resource_metadata.get("authorization_servers", [])
if auth_servers:
auth_server_url = auth_servers[0] if isinstance(auth_servers[0], str) else auth_servers[0].get("issuer")
else:
auth_server_url = "https://mcp-auth.granola.ai"
# Fetch authorization server metadata
discovery_urls = [
f"{auth_server_url}/.well-known/oauth-authorization-server",
f"{auth_server_url}/.well-known/openid-configuration",
]
for url in discovery_urls:
try:
response = await self.http_client.get(url)
if response.status_code == 200:
self.auth_metadata = cast(dict[str, Any], response.json())
print(f" Found auth server metadata at {url}")
return self.auth_metadata
except Exception:
continue
# Fallback to known Granola auth endpoints
self.auth_metadata = {
"authorization_endpoint": "https://mcp-auth.granola.ai/oauth2/authorize",
"token_endpoint": "https://mcp-auth.granola.ai/oauth2/token",
"registration_endpoint": "https://mcp-auth.granola.ai/oauth2/register",
}
return self.auth_metadata
async def register_client_dynamic(self) -> dict[str, Any]:
"""Register client using Dynamic Client Registration."""
reg_endpoint = self.auth_metadata.get("registration_endpoint")
if not reg_endpoint:
print(" No DCR endpoint found, will use existing client_id")
return {}
async def _register_client(self) -> str | None:
"""Register client via Dynamic Client Registration and return client_id."""
registration_data = {
"client_name": "Granola to Honcho Transfer",
"redirect_uris": [OAUTH_REDIRECT_URI],
@ -207,59 +142,39 @@ class GranolaMCPClient:
try:
response = await self.http_client.post(
reg_endpoint,
self.REGISTRATION_ENDPOINT,
json=registration_data,
headers={"Content-Type": "application/json"}
)
if response.status_code in (200, 201):
result = cast(dict[str, Any], response.json())
self.client_id = result.get("client_id")
print(f" Registered client: {self.client_id}")
return result
client_id = result.get("client_id")
print(f" Registered client: {client_id}")
return client_id
else:
print(f" DCR response: {response.status_code} - {response.text[:200]}")
except Exception as e:
print(f" DCR failed: {e}")
return {}
return None
async def authenticate(self) -> bool:
"""Perform OAuth authentication with Granola following MCP spec."""
"""Perform OAuth authentication with Granola."""
global auth_result
auth_result = {"code": None, "error": None}
print("\n🔐 Authenticating with Granola...")
# Step 1: Discover OAuth endpoints
await self.discover_oauth_metadata()
# Step 2: Try dynamic client registration
client_info = await self.register_client_dynamic()
client_id = client_info.get("client_id") or self.client_id
# Step 1: Register client (DCR) to get a client_id
client_id = await self._register_client()
if not client_id:
print("❌ No client_id obtained from DCR. Cannot authenticate.")
return False
# Step 3: Generate PKCE (required by OAuth 2.1 / MCP spec)
self.pkce_verifier, pkce_challenge = self._generate_pkce()
# Step 4: Determine scopes
# Use scopes from resource metadata, or omit to let server decide
supported_scopes = self.resource_metadata.get("scopes_supported", [])
if supported_scopes:
scope = " ".join(supported_scopes)
else:
# Don't specify scope — let Granola provide default scopes
scope = None
# Build authorization URL
auth_url = self.auth_metadata.get(
"authorization_endpoint",
"https://mcp-auth.granola.ai/oauth2/authorize"
)
# Step 2: Generate PKCE (required by OAuth 2.1)
pkce_verifier, pkce_challenge = self._generate_pkce()
# Step 3: Browser-based authorization
auth_params = {
"client_id": client_id,
"redirect_uri": OAUTH_REDIRECT_URI,
@ -269,19 +184,8 @@ class GranolaMCPClient:
"code_challenge_method": "S256",
}
# Only add scope if we know valid ones
if scope:
auth_params["scope"] = scope
full_auth_url = f"{self.AUTHORIZATION_ENDPOINT}?{urlencode(auth_params)}"
# Add resource indicator per RFC 8707 if we have it
resource_url = self.resource_metadata.get("resource")
if resource_url:
auth_params["resource"] = resource_url
query = urlencode(auth_params)
full_auth_url = f"{auth_url}?{query}"
# Start local server for callback
server = HTTPServer(("localhost", OAUTH_REDIRECT_PORT), OAuthCallbackHandler)
server_thread = threading.Thread(target=server.handle_request)
server_thread.start()
@ -290,7 +194,6 @@ class GranolaMCPClient:
print(" If the browser doesn't open automatically, re-run this script and ensure a browser is available.")
webbrowser.open(full_auth_url)
# Wait for callback
server_thread.join(timeout=120)
server.server_close()
@ -302,23 +205,18 @@ class GranolaMCPClient:
print("❌ Authentication timed out")
return False
# Step 5: Exchange code for token (with PKCE verifier)
token_url = self.auth_metadata.get(
"token_endpoint",
"https://mcp-auth.granola.ai/oauth2/token"
)
# Step 4: Exchange code for token
token_data = {
"grant_type": "authorization_code",
"code": auth_result["code"],
"redirect_uri": OAUTH_REDIRECT_URI,
"client_id": client_id,
"code_verifier": self.pkce_verifier,
"code_verifier": pkce_verifier,
}
try:
response = await self.http_client.post(
token_url,
self.TOKEN_ENDPOINT,
data=token_data,
headers={"Content-Type": "application/x-www-form-urlencoded"}
)
@ -337,6 +235,16 @@ class GranolaMCPClient:
print(f"❌ Token exchange error: {e}")
return False
@staticmethod
def _extract_mcp_text(result: dict[str, Any]) -> str:
"""Extract the first text content block from an MCP tool result."""
content = result.get("content", [])
if isinstance(content, list) and content:
first = content[0]
if isinstance(first, dict) and "text" in first:
return str(first["text"])
return ""
async def call_mcp_tool(
self, tool_name: str, arguments: dict[str, Any] | None = None
) -> dict[str, Any]:
@ -344,7 +252,6 @@ class GranolaMCPClient:
if not self.access_token:
raise ValueError("Not authenticated. Call authenticate() first.")
# MCP uses JSON-RPC 2.0 format
request_body = {
"jsonrpc": "2.0",
"id": 1,
@ -355,7 +262,6 @@ class GranolaMCPClient:
}
}
# Streamable HTTP transport requires accepting both JSON and SSE
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
@ -375,16 +281,14 @@ class GranolaMCPClient:
# Handle SSE (Server-Sent Events) response
if "text/event-stream" in content_type:
# Parse SSE format: lines starting with "data: " contain JSON
result: dict[str, Any] | None = None
for line in response.text.split("\n"):
line = line.strip()
if line.startswith("data: "):
data = line[6:] # Remove "data: " prefix
data = line[6:]
if data:
try:
parsed = cast(dict[str, Any], json.loads(data))
# Look for the final result (method response)
if "result" in parsed:
result = parsed
elif "error" in parsed:
@ -409,14 +313,7 @@ class GranolaMCPClient:
print("\n📋 Fetching meeting list from Granola...")
result = await self.call_mcp_tool("list_meetings", {"limit": limit})
# Extract the text content from MCP response
content = result.get("content", [])
text = ""
if isinstance(content, list) and content:
if isinstance(content[0], dict) and "text" in content[0]:
first_item = cast(dict[str, Any], content[0])
text = str(first_item["text"])
text = self._extract_mcp_text(result)
# Try JSON first
try:
@ -434,7 +331,6 @@ class GranolaMCPClient:
text
):
mid, title, date = match.groups()
# Extract participants for this meeting block
block_start = match.end()
block_end = text.find("</meeting>", block_start)
block = text[block_start:block_end] if block_end != -1 else ""
@ -458,14 +354,7 @@ class GranolaMCPClient:
async def get_meeting_details(self, meeting_id: str) -> dict[str, Any]:
"""Get full meeting details including notes."""
result = await self.call_mcp_tool("get_meetings", {"meeting_ids": [meeting_id]})
# Extract text from MCP response
content = result.get("content", [])
text = ""
if isinstance(content, list) and content:
if isinstance(content[0], dict) and "text" in content[0]:
first_item = cast(dict[str, Any], content[0])
text = str(first_item["text"])
text = self._extract_mcp_text(result)
# Try JSON first
try:
@ -481,32 +370,22 @@ class GranolaMCPClient:
except (json.JSONDecodeError, TypeError):
pass
# Return the raw text — it likely contains the notes in XML/markup format
# This is still valuable content to store in Honcho
return {"id": meeting_id, "raw_content": text}
async def get_meeting_transcript(self, meeting_id: str) -> str | None:
"""Get the raw transcript for a meeting (paid tiers only)."""
try:
result = await self.call_mcp_tool("get_meeting_transcript", {"meeting_id": meeting_id})
text = self._extract_mcp_text(result)
content = result.get("content", [])
if isinstance(content, list) and content:
if isinstance(content[0], dict) and "text" in content[0]:
first_item = cast(dict[str, Any], content[0])
text = str(first_item["text"])
# Check for empty/error responses
if text and "no transcript" not in text.lower() and len(text) > 50:
return text
else:
print(f" Transcript response too short or empty: {text[:100]}")
return None
if text and "no transcript" not in text.lower() and len(text) > 50:
return text
if text:
print(f" Transcript response too short or empty: {text[:100]}")
transcript = result.get("transcript")
if transcript:
return str(transcript)
return None
return str(transcript) if transcript else None
except Exception as e:
print(f" Transcript unavailable: {e}")
return None
@ -614,9 +493,8 @@ def parse_transcript_turns(transcript: str) -> list[TranscriptTurn]:
return turns
def analyze_transcript(transcript: str) -> dict[str, Any]:
"""Return stats about a transcript."""
turns = parse_transcript_turns(transcript)
def transcript_stats(turns: list[TranscriptTurn]) -> dict[str, int]:
"""Return stats about pre-parsed transcript turns."""
me_count = sum(1 for t in turns if t["speaker"] == "Me")
them_count = len(turns) - me_count
total_words = sum(len(t["text"].split()) for t in turns)
@ -628,62 +506,52 @@ def analyze_transcript(transcript: str) -> dict[str, Any]:
def extract_summary_from_xml(raw_content: str) -> str:
"""Pull the <summary> text out of Granola's XML-like response."""
"""Pull the <summary> text out of Granola's XML-like response.
Returns empty string if no recognized tags are found.
"""
match = re.search(r"<summary>\s*(.*?)\s*</summary>", raw_content, re.DOTALL)
if match:
return match.group(1).strip()
# Try <notes> as fallback
notes_match = re.search(r"<notes>\s*(.*?)\s*</notes>", raw_content, re.DOTALL)
if notes_match:
return notes_match.group(1).strip()
return raw_content
return ""
def extract_summary_from_meeting(meeting: dict[str, Any]) -> str:
"""Extract best available meeting summary text from any details shape."""
candidates: list[str] = []
# Common direct fields from parsed JSON payloads.
for key in ("summary", "notes", "note", "meeting_notes", "description"):
value = meeting.get(key)
if isinstance(value, str) and value.strip():
candidates.append(value.strip())
# Legacy/raw XML-ish payload used by this script.
raw_content = meeting.get("raw_content")
if isinstance(raw_content, str) and raw_content.strip():
candidates.append(raw_content.strip())
# MCP payloads often come back as {"content": [{"type":"text","text":"..."}]}.
content = meeting.get("content")
if isinstance(content, list):
for item in cast(list[object], content):
if isinstance(item, dict):
payload = cast(dict[str, Any], item)
text = payload.get("text")
if isinstance(text, str) and text.strip():
candidates.append(text.strip())
elif isinstance(content, str) and content.strip():
candidates.append(content.strip())
for candidate in candidates:
extracted = extract_summary_from_xml(candidate).strip()
if extracted:
return extracted
# Return the first non-empty candidate as-is if no XML tags matched
for candidate in candidates:
if candidate:
return candidate
return ""
def sanitize_content(text: str) -> str:
"""Remove null bytes and other characters that break server-side processing."""
# Remove null bytes
text = text.replace("\x00", "")
# Remove other control characters (keep newlines and tabs)
text = re.sub(r"[\x01-\x08\x0b\x0c\x0e-\x1f\x7f]", "", text)
return text
def to_honcho_peer_id(value: str, fallback: str = "peer") -> str:
"""Normalize user-provided identifiers into a Honcho-safe peer ID."""
normalized = value.strip().lower()
@ -731,7 +599,7 @@ class HonchoClient:
meeting: dict[str, Any],
me_peer_id: str,
them_peer_id: str,
transcript_text: str,
turns: list[TranscriptTurn],
me_email: str | None = None,
them_email: str | None = None,
) -> str:
@ -744,7 +612,6 @@ class HonchoClient:
session_id = f"meeting-{meeting_id}"
session = self.client.session(session_id)
turns = parse_transcript_turns(transcript_text)
metadata: dict[str, object] = {
"title": meeting.get("title", ""),
"date": meeting.get("date", ""),
@ -758,7 +625,6 @@ class HonchoClient:
if them_email:
metadata["them_email"] = them_email
# Batch turns into messages, respecting size limits
# Merge consecutive same-speaker turns
merged: list[TranscriptTurn] = []
for t in turns:
@ -773,11 +639,9 @@ class HonchoClient:
content = sanitize_content(t["text"])
if len(content) > self.MAX_MESSAGE_LEN:
content = content[: self.MAX_MESSAGE_LEN]
# Attach metadata to the first message only
msg_meta = metadata if j == 0 else None
messages.append(peer.message(content, metadata=msg_meta, created_at=created_at))
# add_messages has a 100 message batch limit
for i in range(0, len(messages), 100):
session.add_messages(messages[i : i + 100])
@ -794,15 +658,13 @@ class HonchoClient:
created_at = self._parse_date(meeting.get("date", ""))
me_peer = self.client.peer(me_peer_id)
# Best available content
content = meeting.get("transcript") or ""
if content:
content = extract_transcript_text(content)
# Prefer summary; only fall back to transcript for multi-person
summary = extract_summary_from_meeting(meeting)
# Prefer summary for multi-person; transcript is noisy with ambiguous "Them"
body = summary or content or "No content available"
if summary:
body = summary
else:
raw_transcript = meeting.get("transcript") or ""
body = extract_transcript_text(raw_transcript) if raw_transcript else "No content available"
session_id = f"meeting-{meeting_id}"
session = self.client.session(session_id)
@ -826,7 +688,6 @@ class HonchoClient:
metadata["note_creator_email"] = note_creator_email
full = sanitize_content(header + body)
# Chunk into messages that fit within the server-side content limit
messages: list[Any] = []
for start in range(0, len(full), self.MAX_MESSAGE_LEN):
chunk = full[start : start + self.MAX_MESSAGE_LEN]
@ -843,15 +704,54 @@ class HonchoClient:
# Interactive main
# ---------------------------------------------------------------------------
def prompt_choice(prompt_text: str, valid: list[str], default: str = "") -> str:
"""Prompt user for input, return lowered choice."""
def prompt_choice(prompt_text: str, valid: list[str]) -> str:
"""Prompt user for input, return lowered choice. Empty string is valid if in list."""
while True:
raw = input(prompt_text).strip().lower()
if not raw and default:
return default
if raw in valid:
return raw
print(f" Please enter one of: {', '.join(valid)}")
print(f" Please enter one of: {', '.join(repr(v) for v in valid)}")
def _register_peer(
peer_id: str,
participant: Participant,
confirmed_peers: dict[str, str],
) -> None:
"""Print and register a new peer if not already seen."""
if peer_id in confirmed_peers:
return
email = participant["email"]
label = participant["name"] + (f" <{email}>" if email else "")
print(f"\n New peer: {label} (peer_id: {peer_id})")
confirmed_peers[peer_id] = email or participant["name"]
def _import_two_person(
honcho: HonchoClient,
meeting: dict[str, Any],
me_peer_id: str,
them: Participant,
turns: list[TranscriptTurn],
creator_email: str | None,
confirmed_peers: dict[str, str],
) -> None:
"""Shared logic for importing a meeting as a two-person conversation."""
them_email = them["email"]
them_source = them_email or them["name"]
them_peer_id = to_honcho_peer_id(them_source)
_register_peer(them_peer_id, them, confirmed_peers)
honcho.store_two_person(
meeting,
me_peer_id,
them_peer_id,
turns,
me_email=creator_email,
them_email=them_email,
)
print(f" -> Imported as 2-person ({me_peer_id} + {them_peer_id})")
async def main():
@ -933,10 +833,11 @@ async def main():
creator = participants["note_creator"]
others = participants["others"]
# Analyze transcript if available
# Parse transcript once — reused for stats and storage
transcript_raw = m.get("transcript")
transcript_text = extract_transcript_text(transcript_raw) if transcript_raw else ""
stats = analyze_transcript(transcript_text) if transcript_text else None
turns = parse_transcript_turns(transcript_text) if transcript_text else []
stats = transcript_stats(turns) if turns else None
# Print meeting info
print(f"\n{'' * 60}")
@ -953,8 +854,7 @@ async def main():
if stats:
print(f" Transcript: {stats['me_count']} Me turns, {stats['them_count']} Them turns, ~{stats['total_words']} words")
else:
summary = extract_summary_from_meeting(m)
has_summary = bool(summary)
has_summary = bool(extract_summary_from_meeting(m))
print(f" Content: {'summary available' if has_summary else 'metadata only'}")
# Check for empty meetings
@ -965,26 +865,23 @@ async def main():
# ---- Ask user what to do ----
if len(others) == 1 and stats and stats["them_count"] > 0:
# Looks like a real two-person call
them = others[0]
them_label = f"{them['name']}" + (f" <{them['email']}>" if them['email'] else "")
print(f"\n Detected: 2-person call (you + {them_label})")
choice = prompt_choice(
" [Enter] import as 2-person / [s]ummary mode / [k] skip: ",
["", "s", "k"], default=""
["", "s", "k"],
)
elif len(others) > 1 and stats and stats["them_count"] > 0:
# Multi-person — but might actually be 2-person
print(f"\n {len(others)} participants listed")
choice = prompt_choice(
" [Enter] import as summary / [2] actually 2-person / [k] skip: ",
["", "2", "k"], default=""
["", "2", "k"],
)
else:
# No transcript or no other participants — offer summary or skip
choice = prompt_choice(
" [Enter] import as summary / [k] skip: ",
["", "k"], default=""
["", "k"],
)
if choice == "k":
@ -1001,46 +898,20 @@ async def main():
continue
me_peer_id = to_honcho_peer_id(me_source)
# ---- Register note creator peer ----
if me_peer_id not in confirmed_peers:
print(f"\n New peer: {me_source} (peer_id: {me_peer_id})")
confirmed_peers[me_peer_id] = me_source
_register_peer(
me_peer_id,
creator or {"name": me_source, "email": creator_email, "org": None},
confirmed_peers,
)
try:
if choice == "" and len(others) == 1 and stats and stats["them_count"] > 0:
# Two-person import
them = others[0]
them_email = them["email"]
them_source = them_email or them["name"]
them_peer_id = to_honcho_peer_id(them_source)
if them_peer_id not in confirmed_peers:
them_label = (
f"{them['name']}"
+ (
f" <{them_email}>"
if them_email
else f" (no email, using: {them_source})"
)
)
print(f"\n New peer: {them_label}")
print(f" peer_id: {them_peer_id}")
confirmed_peers[them_peer_id] = them_source
honcho.store_two_person(
m,
me_peer_id,
them_peer_id,
transcript_text,
me_email=creator_email,
them_email=them_email,
)
print(
f" -> Imported as 2-person ({me_peer_id} + {them_peer_id})"
_import_two_person(
honcho, m, me_peer_id, others[0], turns,
creator_email, confirmed_peers,
)
elif choice == "2":
# User says this multi-person listing is actually 2-person
print("\n Who is 'Them' in this call?")
for j, p in enumerate(others, 1):
email_str = f" <{p['email']}>" if p['email'] else ""
@ -1051,44 +922,19 @@ async def main():
them = others[idx]
except (ValueError, IndexError):
print(" Invalid choice, importing as summary instead.")
honcho.store_summary(
m,
me_peer_id,
note_creator_email=creator_email,
)
honcho.store_summary(m, me_peer_id, note_creator_email=creator_email)
results["imported"] += 1
print(f" -> Imported as summary")
print(" -> Imported as summary")
continue
them_email = them["email"]
them_source = them_email or them["name"]
them_peer_id = to_honcho_peer_id(them_source)
if them_peer_id not in confirmed_peers:
label = f"{them['name']}" + (f" <{them_email}>" if them_email else "")
print(f"\n New peer: {label}")
print(f" peer_id: {them_peer_id}")
confirmed_peers[them_peer_id] = them_source
honcho.store_two_person(
m,
me_peer_id,
them_peer_id,
transcript_text,
me_email=creator_email,
them_email=them_email,
)
print(
f" -> Imported as 2-person ({me_peer_id} + {them_peer_id})"
_import_two_person(
honcho, m, me_peer_id, them, turns,
creator_email, confirmed_peers,
)
else:
# Summary mode (default for multi-person and fallback)
honcho.store_summary(
m,
me_peer_id,
note_creator_email=creator_email,
)
print(f" -> Imported as summary")
honcho.store_summary(m, me_peer_id, note_creator_email=creator_email)
print(" -> Imported as summary")
results["imported"] += 1