From 07a7e4fa623df68b6aa28675c22bf3053deef862 Mon Sep 17 00:00:00 2001 From: Shreyans' Mac Mini Date: Mon, 23 Feb 2026 10:06:34 -0500 Subject: [PATCH] feat: add granola MCP skill with OAuth setup and auto-refresh Replaces the outdated CSV-based granola-notes skill with a proper MCP integration using Granola's official Streamable HTTP endpoint (https://mcp.granola.ai/mcp). Includes: - Full OAuth 2.0 PKCE setup script with dynamic client registration - Token auto-refresh script (6h token lifetime, 5h cron recommended) - 4 MCP tools: query, list, get, and transcript - Uses mcporter for MCP transport --- skills/bholagabbar/granola/SKILL.md | 78 ++++++++++++++ .../granola/scripts/refresh_token.sh | 51 +++++++++ .../granola/scripts/setup_oauth.sh | 101 ++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 skills/bholagabbar/granola/SKILL.md create mode 100755 skills/bholagabbar/granola/scripts/refresh_token.sh create mode 100755 skills/bholagabbar/granola/scripts/setup_oauth.sh diff --git a/skills/bholagabbar/granola/SKILL.md b/skills/bholagabbar/granola/SKILL.md new file mode 100644 index 00000000000..bff5b87906a --- /dev/null +++ b/skills/bholagabbar/granola/SKILL.md @@ -0,0 +1,78 @@ +--- +name: granola +description: Access Granola AI meeting notes via MCP using mcporter. Query meetings with natural language, list by date range, get full details, and pull verbatim transcripts. Use when the user asks about meeting notes, what was discussed, action items, decisions, follow-ups, or anything from their Granola meetings. Requires mcporter CLI and a Granola account with OAuth authentication. +--- + +# Granola MCP + +Connect to [Granola](https://granola.ai) meeting notes via their official MCP server using mcporter. + +## Setup + +### 1. Configure the MCP server + +```bash +mcporter config add granola --url https://mcp.granola.ai/mcp +``` + +### 2. Authenticate via OAuth + +Granola uses browser-based OAuth 2.0 (PKCE). Run the setup script to complete the flow: + +```bash +bash {baseDir}/scripts/setup_oauth.sh +``` + +This will: +- Register a dynamic OAuth client with Granola +- Open the browser for sign-in +- Capture tokens and save to `config/granola_oauth.json` +- Update `config/mcporter.json` with the bearer token + +### 3. Set up auto-refresh (recommended) + +Tokens expire every 6 hours. Add a cron job to refresh every 5 hours: + +```bash +REFRESH_SCRIPT="{baseDir}/scripts/refresh_token.sh" +(crontab -l 2>/dev/null | grep -v granola_refresh; echo "0 */5 * * * $REFRESH_SCRIPT >> /tmp/granola_refresh.log 2>&1") | crontab - +``` + +## Tools + +``` +granola.query_granola_meetings query= [document_ids=] +granola.list_meetings [time_range=this_week|last_week|last_30_days|custom] [custom_start=] [custom_end=] +granola.get_meetings meeting_ids= (max 10) +granola.get_meeting_transcript meeting_id= +``` + +## Usage + +- For open-ended questions ("what did we discuss about X?"), use `query_granola_meetings` +- For listing meetings in a range, use `list_meetings` +- For full details on specific meetings, use `get_meetings` with IDs from list results +- For exact quotes or verbatim content, use `get_meeting_transcript` + +Prefer `query_granola_meetings` over list+get for natural language questions. + +Responses include citation links (e.g. `[[0]](url)`). Always preserve these in replies so the user can click through to original notes. + +## Auth Recovery + +If a call fails with 401/auth error: + +```bash +bash {baseDir}/scripts/refresh_token.sh +``` + +If refresh also fails (expired refresh token), re-run the full OAuth setup: + +```bash +bash {baseDir}/scripts/setup_oauth.sh +``` + +## Config Files + +- `config/mcporter.json` — MCP server config with bearer token +- `config/granola_oauth.json` — OAuth credentials (client_id, refresh_token, access_token, token_endpoint) diff --git a/skills/bholagabbar/granola/scripts/refresh_token.sh b/skills/bholagabbar/granola/scripts/refresh_token.sh new file mode 100755 index 00000000000..5a92174660b --- /dev/null +++ b/skills/bholagabbar/granola/scripts/refresh_token.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Granola MCP OAuth token refresh script +# Reads granola_oauth.json, refreshes the token, updates mcporter.json +set -e + +CONFIG_DIR="$(cd "$(dirname "$0")/.." && pwd)/../../config" +OAUTH_FILE="$CONFIG_DIR/granola_oauth.json" +MCPORTER_FILE="$CONFIG_DIR/mcporter.json" + +if [ ! -f "$OAUTH_FILE" ]; then + echo "ERROR: $OAUTH_FILE not found. Run setup_oauth.sh first." + exit 1 +fi + +CLIENT_ID=$(python3 -c "import json; print(json.load(open('$OAUTH_FILE'))['client_id'])") +REFRESH_TOKEN=$(python3 -c "import json; print(json.load(open('$OAUTH_FILE'))['refresh_token'])") +TOKEN_ENDPOINT=$(python3 -c "import json; print(json.load(open('$OAUTH_FILE'))['token_endpoint'])") + +RESPONSE=$(curl -sf -X POST "$TOKEN_ENDPOINT" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=refresh_token&refresh_token=${REFRESH_TOKEN}&client_id=${CLIENT_ID}") + +ERROR=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('error',''))" 2>/dev/null) +if [ -n "$ERROR" ]; then + echo "ERROR: Token refresh failed: $ERROR" + echo "$RESPONSE" + exit 1 +fi + +NEW_ACCESS=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") +NEW_REFRESH=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('refresh_token', ''))") +EXPIRES_IN=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('expires_in', 21600))") + +python3 -c " +import json +data = json.load(open('$OAUTH_FILE')) +data['access_token'] = '$NEW_ACCESS' +if '$NEW_REFRESH': + data['refresh_token'] = '$NEW_REFRESH' +data['expires_in'] = $EXPIRES_IN +json.dump(data, open('$OAUTH_FILE', 'w'), indent=2) +" + +python3 -c " +import json +mc = json.load(open('$MCPORTER_FILE')) +mc['mcpServers']['granola']['headers']['Authorization'] = 'Bearer $NEW_ACCESS' +json.dump(mc, open('$MCPORTER_FILE', 'w'), indent=2) +" + +echo "OK: Token refreshed, expires in ${EXPIRES_IN}s" diff --git a/skills/bholagabbar/granola/scripts/setup_oauth.sh b/skills/bholagabbar/granola/scripts/setup_oauth.sh new file mode 100755 index 00000000000..7a19c22f8ec --- /dev/null +++ b/skills/bholagabbar/granola/scripts/setup_oauth.sh @@ -0,0 +1,101 @@ +#!/bin/bash +# Granola MCP OAuth setup script +# Performs full OAuth 2.0 PKCE flow with dynamic client registration +set -e + +CONFIG_DIR="$(cd "$(dirname "$0")/.." && pwd)/../../config" +mkdir -p "$CONFIG_DIR" + +OAUTH_FILE="$CONFIG_DIR/granola_oauth.json" +MCPORTER_FILE="$CONFIG_DIR/mcporter.json" +REDIRECT_URI="http://127.0.0.1:9876/callback" +SCOPE="openid email profile offline_access" + +echo "[granola] Starting OAuth setup..." + +# Discover OAuth endpoints +METADATA=$(curl -sf https://mcp.granola.ai/.well-known/oauth-authorization-server) +AUTH_ENDPOINT=$(echo "$METADATA" | python3 -c "import sys,json; print(json.load(sys.stdin)['authorization_endpoint'])") +TOKEN_ENDPOINT=$(echo "$METADATA" | python3 -c "import sys,json; print(json.load(sys.stdin)['token_endpoint'])") +REGISTER_ENDPOINT=$(echo "$METADATA" | python3 -c "import sys,json; print(json.load(sys.stdin)['registration_endpoint'])") + +# Generate PKCE +CODE_VERIFIER=$(python3 -c "import secrets; print(secrets.token_urlsafe(64)[:128])") +CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=') + +# Dynamic client registration +REG_RESPONSE=$(curl -sf -X POST "$REGISTER_ENDPOINT" \ + -H "Content-Type: application/json" \ + -d "{\"client_name\":\"OpenClaw Granola\",\"redirect_uris\":[\"$REDIRECT_URI\"],\"grant_types\":[\"authorization_code\",\"refresh_token\"],\"response_types\":[\"code\"],\"token_endpoint_auth_method\":\"none\"}") + +CLIENT_ID=$(echo "$REG_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['client_id'])") +echo "[granola] Registered client: $CLIENT_ID" + +STATE=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))") + +AUTH_URL="${AUTH_ENDPOINT}?response_type=code&client_id=${CLIENT_ID}&redirect_uri=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$REDIRECT_URI'))")&scope=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$SCOPE'))")&state=${STATE}&code_challenge=${CODE_CHALLENGE}&code_challenge_method=S256" + +echo "[granola] Opening browser for sign-in..." +open "$AUTH_URL" 2>/dev/null || xdg-open "$AUTH_URL" 2>/dev/null || echo "Open this URL: $AUTH_URL" + +echo "[granola] Waiting for callback on port 9876..." +RESPONSE=$(python3 -c " +import http.server, urllib.parse, sys, json + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) + code = params.get('code', [None])[0] + self.send_response(200) + self.send_header('Content-Type','text/html') + self.end_headers() + self.wfile.write(b'

Granola auth complete! You can close this tab.

') + print(json.dumps({'code': code})) + sys.stdout.flush() + def log_message(self, *a): pass + +s = http.server.HTTPServer(('127.0.0.1', 9876), Handler) +s.handle_request() +") + +CODE=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['code'])") +echo "[granola] Got auth code, exchanging for token..." + +# Exchange for token +TOKEN_RESPONSE=$(curl -sf -X POST "$TOKEN_ENDPOINT" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=authorization_code&code=${CODE}&redirect_uri=${REDIRECT_URI}&client_id=${CLIENT_ID}&code_verifier=${CODE_VERIFIER}") + +ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") +REFRESH_TOKEN=$(echo "$TOKEN_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('refresh_token',''))") +EXPIRES_IN=$(echo "$TOKEN_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('expires_in', 21600))") + +# Save OAuth credentials +python3 -c " +import json +creds = { + 'client_id': '$CLIENT_ID', + 'token_endpoint': '$TOKEN_ENDPOINT', + 'refresh_token': '$REFRESH_TOKEN', + 'access_token': '$ACCESS_TOKEN', + 'expires_in': $EXPIRES_IN +} +with open('$OAUTH_FILE', 'w') as f: + json.dump(creds, f, indent=2) +" + +# Update mcporter config +python3 -c " +import json, os +path = '$MCPORTER_FILE' +mc = json.load(open(path)) if os.path.exists(path) else {'mcpServers': {}, 'imports': []} +mc['mcpServers']['granola'] = { + 'baseUrl': 'https://mcp.granola.ai/mcp', + 'headers': {'Authorization': 'Bearer $ACCESS_TOKEN'} +} +json.dump(mc, open(path, 'w'), indent=2) +" + +echo "[granola] Setup complete! Token expires in ${EXPIRES_IN}s." +echo "[granola] Config saved to $MCPORTER_FILE" +echo "[granola] OAuth creds saved to $OAUTH_FILE"