← Public packages
@charlesmatte/granola
Search and read Granola meeting notes, summaries and transcripts from any agent; manage webhooks and legal holds safely.
src/lib/mcp.ts
98 lines · 4.7 KB · TypeScriptimport { kody } from 'kody:runtime'
export const GRANOLA_MCP_URL = 'https://mcp.granola.ai/mcp'
export const DEFAULT_MCP_SERVER = 'granola'
export const KODY_MCP_PAGE = 'https://kody.codes/account/mcp-servers'
/** Tools documented at https://docs.granola.ai/help-center/sharing/integrations/mcp (all read-only). */
export const GRANOLA_MCP_TOOLS = [
'query_granola_meetings',
'list_meetings',
'get_meetings',
'list_meeting_folders',
'get_meeting_transcript',
'get_account_info',
] as const
export const MCP_SERVER_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/
type ServerInfo = { id?: string; name?: string; url?: string; state?: string; status?: string; authUrl?: string | null; tools?: Array<unknown>; toolNames?: Array<string>; error?: string | null } & Record<string, unknown>
export async function findMcpServer(name: string): Promise<ServerInfo | null> {
const listed = (await kody.mcpServerList({})) as unknown
const servers: Array<ServerInfo> = Array.isArray(listed)
? (listed as Array<ServerInfo>)
: listed && typeof listed === 'object' && Array.isArray((listed as { servers?: unknown }).servers)
? (listed as { servers: Array<ServerInfo> }).servers
: []
return servers.find((s) => s.name === name) ?? null
}
export function toolNamesOf(server: ServerInfo): Array<string> {
if (Array.isArray(server.toolNames)) return server.toolNames.map(String)
if (Array.isArray(server.tools)) return server.tools.map((t) => (typeof t === 'string' ? t : String((t as { name?: string })?.name ?? ''))).filter(Boolean)
return []
}
function notConnectedError(server: string, tool: string, cause?: unknown): Error {
const why = cause ? ` (Kody said: ${String((cause as Error)?.message ?? cause).slice(0, 200)})` : ''
return new Error(
`MCP server "${server}" is not connected in this Kody account (or has no tool "${tool}")${why}. One-time setup: open ${KODY_MCP_PAGE}, add a server named ` +
`"${server}" with URL ${GRANOLA_MCP_URL}, and finish the Granola sign-in from the authorization link (or run ./mcp/connect({ confirm: true }) and open its authUrl). Check status with ./mcp/tools.`,
)
}
/** Kody's MCP hub reports a missing server or tool with messages like 'Unknown MCP server "x"'. */
const NOT_CONNECTED = /unknown mcp server|unknown tool|no such (mcp )?server|not connected|no mcp server/i
const NEEDS_AUTH = /authoriz|re-?auth|oauth|disconnected|refresh token/i
export async function callMcpTool(server: string, tool: string, args: Record<string, unknown>): Promise<unknown> {
const hub = (kody as unknown as { mcp?: Record<string, Record<string, (a: unknown) => Promise<unknown>>> }).mcp
let fn: unknown
try {
fn = hub?.[server]?.[tool]
} catch (e) {
throw notConnectedError(server, tool, e)
}
if (typeof fn !== 'function') throw notConnectedError(server, tool)
try {
return await (fn as (a: unknown) => Promise<unknown>).call(hub?.[server], args)
} catch (e) {
const msg = String((e as Error)?.message ?? e)
if (NOT_CONNECTED.test(msg)) throw notConnectedError(server, tool, e)
if (NEEDS_AUTH.test(msg)) {
throw new Error(
`Granola MCP server "${server}" needs you to sign in again (Kody said: ${msg.slice(0, 200)}). Reconnect it from ${KODY_MCP_PAGE} (open the server, Reconnect, then sign in to Granola), then retry.`,
)
}
throw e
}
}
/** Flatten an MCP tool result (`{ content: [{ type: 'text', text }], isError }`, structured, or plain) to text. */
export function mcpText(result: unknown): { text: string; isError: boolean } {
if (typeof result === 'string') return { text: result, isError: false }
if (result && typeof result === 'object') {
const r = result as { content?: Array<{ type?: string; text?: string }>; isError?: boolean; structuredContent?: unknown; result?: unknown }
if (Array.isArray(r.content)) {
const text = r.content.filter((c) => c?.type === 'text' || typeof c?.text === 'string').map((c) => c.text ?? '').join('\n')
return { text, isError: Boolean(r.isError) }
}
if (r.result !== undefined) return mcpText(r.result)
if (r.structuredContent !== undefined) return { text: JSON.stringify(r.structuredContent), isError: Boolean(r.isError) }
}
return { text: JSON.stringify(result ?? null), isError: false }
}
/** Citation links Granola puts in answers, e.g. `[[0]](https://notes.granola.ai/d/…)` or `[0](…)`. */
export function extractCitations(text: string): Array<{ ref: string; url: string }> {
const out: Array<{ ref: string; url: string }> = []
const seen = new Set<string>()
const re = /\[\[?(\d+)\]?\]\((https?:\/\/[^)\s]+)\)/g
let m: RegExpExecArray | null
while ((m = re.exec(text))) {
const key = `${m[1]}|${m[2]}`
if (seen.has(key)) continue
seen.add(key)
out.push({ ref: m[1] as string, url: m[2] as string })
}
return out
}