Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions skills/bholagabbar/granola/SKILL.md
Original file line number Diff line number Diff line change
@@ -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=<string> [document_ids=<uuid[]>]
granola.list_meetings [time_range=this_week|last_week|last_30_days|custom] [custom_start=<ISO>] [custom_end=<ISO>]
granola.get_meetings meeting_ids=<uuid[]> (max 10)
granola.get_meeting_transcript meeting_id=<uuid>
```

## 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)
51 changes: 51 additions & 0 deletions skills/bholagabbar/granola/scripts/refresh_token.sh
Original file line number Diff line number Diff line change
@@ -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"
101 changes: 101 additions & 0 deletions skills/bholagabbar/granola/scripts/setup_oauth.sh
Original file line number Diff line number Diff line change
@@ -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'<h1>Granola auth complete! You can close this tab.</h1>')
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"