Skip to content
← 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 · TypeScript
Raw
import { 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
}