From d45915b6c4b28c8fe748123e6674cd0ebe99a649 Mon Sep 17 00:00:00 2001 From: baudbot-agent Date: Fri, 27 Feb 2026 01:50:32 +0000 Subject: [PATCH] extensions: add Notion integration for read-only workspace access Add notion.ts extension providing read-only access to Notion workspaces via the Notion REST API. Enables agents to search for pages/databases, retrieve full page content with nested blocks, query database entries with filters, and inspect database schemas. Features: - Search: Find pages and databases by text query or type filter - Get: Retrieve complete page content formatted as markdown, including paragraphs, headings, lists, code blocks, callouts, and nested content (up to 1 level deep) - List: Query database entries with JSON filters and sorting - Database: Inspect schema and property types Configuration: - NOTION_API_KEY (internal integration token) in ~/.config/.env - Pages/databases must be explicitly shared with the integration - Read-only access (no write, update, or delete capabilities) Documentation: - Updated .env.schema with NOTION_API_KEY configuration - Updated CONFIGURATION.md with setup instructions and capabilities - Added comprehensive docs/notion-integration.md covering setup, usage examples, use cases, limitations, security, and troubleshooting Use cases: - Documentation lookup during task execution - Specification and ADR retrieval for dev-agents - Project database queries for task context The integration follows the same pattern as existing extensions (linear.ts, sentry-monitor.ts) and includes proper error handling, type safety, and security considerations (token stored in 600-perms env file, read-only access enforced by API capabilities). Tested with TypeScript compilation (no errors) and biome linting (clean). --- .env.schema | 7 + CONFIGURATION.md | 17 ++ docs/notion-integration.md | 374 +++++++++++++++++++++++++++++++++++ package-lock.json | 3 - pi/extensions/notion.ts | 394 +++++++++++++++++++++++++++++++++++++ 5 files changed, 792 insertions(+), 3 deletions(-) create mode 100644 docs/notion-integration.md create mode 100644 pi/extensions/notion.ts diff --git a/.env.schema b/.env.schema index cb71f5d..6faa79a 100644 --- a/.env.schema +++ b/.env.schema @@ -182,6 +182,13 @@ SLACK_BROKER_DEDUPE_TTL_MS=1200000 # @docs(https://linear.app/settings/api) LINEAR_API_KEY= +# ── Notion (optional) ──────────────────────────────────────────────────────── + +# Notion integration secret (internal integration token) +# @type=string +# @docs(https://www.notion.so/my-integrations) +NOTION_API_KEY= + # ── Tool Guard ─────────────────────────────────────────────────────────────── # Unix username of the agent diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 6546cb3..7ae8e2d 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -84,6 +84,23 @@ The `linear` extension provides a tool for interacting with the Linear issue tra |----------|-------------|---------------| | `LINEAR_API_KEY` | Linear API key (personal or OAuth token) | Go to [Linear Settings → API](https://linear.app/settings/api), create a **Personal API key**. For workspace-wide access, create an OAuth application instead. The key needs read/write access to issues and comments. | +### Notion Integration + +The `notion` extension provides a read-only tool for accessing your Notion workspace. The agent can search for pages and databases, retrieve full page content (including nested blocks), query database entries, and inspect database schemas. + +| Variable | Description | How to get it | +|----------|-------------|---------------| +| `NOTION_API_KEY` | Notion integration secret (internal integration token) | Go to [Notion → My integrations](https://www.notion.so/my-integrations), create a new **Internal Integration**. Copy the **Internal Integration Token** (starts with `secret_`). After creating the integration, share the pages/databases you want the agent to access by clicking **"•••"** → **Add connections** → select your integration. The integration can only read content explicitly shared with it. | + +**Capabilities:** +- `search` — Find pages and databases by text query or type filter +- `get` — Read full page content with all blocks (paragraphs, headings, lists, code, callouts, etc.) +- `list` — Query database entries with filters and sorting +- `database` — Inspect database schema and property types + +**Permissions:** +The integration token only provides read access to pages/databases explicitly shared with the integration. It cannot create, update, or delete content. + ### Slack Channels | Variable | Description | How to get it | diff --git a/docs/notion-integration.md b/docs/notion-integration.md new file mode 100644 index 0000000..2c87199 --- /dev/null +++ b/docs/notion-integration.md @@ -0,0 +1,374 @@ +# Notion Integration + +The Notion extension provides read-only access to your Notion workspace via the Notion REST API. This allows Baudbot agents to search for documentation, retrieve specifications, and query project databases during development work. + +## Setup + +### 1. Create a Notion Integration + +1. Go to [Notion → My integrations](https://www.notion.so/my-integrations) +2. Click **"+ New integration"** +3. Give it a name (e.g., "Baudbot Agent") +4. Select the workspace where your documentation lives +5. Set capabilities to **Read content** only (no write access needed) +6. Submit and copy the **Internal Integration Token** (starts with `secret_`) + +### 2. Share Pages with the Integration + +The integration can only access pages and databases explicitly shared with it: + +1. Navigate to the page or database you want the agent to access +2. Click **"•••"** (top right) → **Add connections** +3. Select your integration from the list +4. The integration now has read access to that page and all its child pages + +Tip: Share a top-level workspace page to give access to all nested documentation. + +### 3. Configure Baudbot + +Add the integration token to `~/.config/.env`: + +```bash +NOTION_API_KEY=secret_abc123... +``` + +Restart the control-agent to load the extension: + +```bash +sudo baudbot stop +sudo baudbot start +``` + +## Usage + +The `notion` tool provides four actions: + +### Search for pages and databases + +Find content by text query or filter by type: + +```typescript +notion({ + action: "search", + query: "deployment", // optional search text + filter: "page", // optional: "page" or "database" + limit: 20 // optional, default 20, max 100 +}) +``` + +Returns a list of matching pages/databases with titles, URLs, and last-edited dates. + +**Example output:** +``` +Found 3 result(s): + +📄 How to deploy a new service + URL: https://notion.so/How-to-deploy-... + Last edited: 2026-02-15 + +🗂️ Deployment Tracker + URL: https://notion.so/Deployment-Tracker-... + Last edited: 2026-02-20 + +📄 CI/CD Pipeline Overview + URL: https://notion.so/CI-CD-Pipeline-... + Last edited: 2026-01-10 +``` + +### Get full page content + +Retrieve complete page content with all blocks formatted as markdown: + +```typescript +notion({ + action: "get", + page_id: "303d77b00f4480f9973fdcdd869caa94" // from URL or search results +}) +``` + +**Page ID extraction:** +- URL: `https://notion.so/Page-Title-303d77b00f4480f9973fdcdd869caa94` +- Page ID: `303d77b00f4480f9973fdcdd869caa94` (last 32 hex characters) + +The tool accepts IDs with or without hyphens. + +**Supported block types:** +- Text blocks: paragraphs, headings (H1-H3), quotes +- Lists: bulleted, numbered, to-do (with checkboxes) +- Code blocks with syntax highlighting +- Callouts with emoji icons +- Toggles, dividers, breadcrumbs +- Child pages and databases (linked) +- Media: images, videos, files, PDFs, bookmarks, embeds +- Nested content (fetched up to 1 level deep) + +**Example output:** +``` +# API Authentication Guide +URL: https://notion.so/... +Last edited: 2026-02-20T15:30:00.000Z + +## Overview + +Our API uses JWT bearer tokens for authentication. + +## Getting a Token + +1. Log in to the dashboard +2. Navigate to Settings → API Keys +3. Click "Generate New Key" + +⚠️ Keep your API key secret. Never commit it to version control. + +## Making Requests + +```bash +curl -H "Authorization: Bearer YOUR_TOKEN" \ + https://api.example.com/v1/resource +``` + +## Rate Limits + +- 1000 requests per hour per token +- 10 requests per second burst limit +``` + +### Query database entries + +List database rows with filtering and sorting: + +```typescript +notion({ + action: "list", + database_id: "abc123...", + filter: '{"property": "Status", "status": {"equals": "In Progress"}}', // optional JSON + sorts: '[{"property": "Created", "direction": "descending"}]', // optional JSON + limit: 20 +}) +``` + +The filter and sorts parameters accept JSON strings matching [Notion's database query format](https://developers.notion.com/reference/post-database-query). + +**Example output:** +``` +5 entries: + +📄 Fix login timeout bug + Status: In Progress | Priority: High | Assignee: Alice + URL: https://notion.so/... + +📄 Add export feature + Status: In Progress | Priority: Medium | Assignee: Bob + URL: https://notion.so/... + +📄 Update documentation + Status: In Progress | Priority: Low | Assignee: Carol + URL: https://notion.so/... +``` + +### Get database schema + +Inspect database structure and property types: + +```typescript +notion({ + action: "database", + database_id: "abc123..." +}) +``` + +**Example output:** +``` +🗂️ Project Tasks +URL: https://notion.so/... + +Properties: + - Name (title) + - Status (status) + - Priority (select) + - Assignee (people) + - Due Date (date) + - Tags (multi_select) + - Completed (checkbox) +``` + +## Use Cases + +### Documentation Lookup + +Control-agent can retrieve setup guides, API references, and runbooks during task execution: + +```typescript +// Agent searches for deployment docs +notion({ action: "search", query: "kubernetes deployment" }) + +// Retrieves the specific guide +notion({ action: "get", page_id: "..." }) +``` + +### Project Context + +Dev-agents can read specifications and architecture decision records: + +```typescript +// Find the PRD for the feature being worked on +notion({ action: "search", query: "user authentication PRD" }) + +// Read the full specification +notion({ action: "get", page_id: "..." }) +``` + +### Task Tracking + +Query project databases for context on work items: + +```typescript +// Check current sprint tasks +notion({ + action: "list", + database_id: "...", + filter: '{"property": "Sprint", "select": {"equals": "Sprint 23"}}' +}) + +// Get database structure for custom queries +notion({ action: "database", database_id: "..." }) +``` + +## Limitations + +### Read-Only Access + +The integration provides read access only. The agent cannot: +- Create new pages or databases +- Update existing content +- Delete pages +- Add comments or mentions + +This is intentional — documentation changes should go through human review. + +### API Rate Limits + +Notion's API has rate limits: +- 3 requests per second per integration +- Burst allowance for occasional spikes + +The extension does not implement request throttling. If you hit rate limits, the tool will return an error. Control-agent should wait and retry. + +### Pagination + +Query results are limited to 100 items per request. The extension does not handle pagination automatically. For large databases: +- Use filters to narrow results +- Or make multiple queries with different filter criteria + +### Block Nesting Depth + +Child blocks are fetched only 1 level deep. If your pages have deeply nested content (e.g., toggles within toggles within toggles), the deepest levels won't be included. + +To retrieve deep content: +- Flatten your documentation structure +- Or make multiple `get` calls for child pages + +### Content Shared with Integration + +The integration can only see pages and databases explicitly shared with it. If the agent can't find a page: +1. Verify the page exists and isn't archived +2. Check that the integration has been added to the page via **Add connections** +3. Check parent page permissions (child pages inherit access) + +## Security + +### Token Storage + +The `NOTION_API_KEY` is stored in `~/.config/.env` with `600` permissions (readable only by the `baudbot_agent` user and root). + +### Principle of Least Privilege + +Only share the minimum necessary documentation with the integration: +- Don't share your entire workspace unless needed +- Share specific docs or a dedicated "Engineering Docs" section +- Review shared pages periodically + +### Audit Log + +Notion's workspace settings include an audit log showing all integration access. Review this regularly to ensure the agent is only accessing expected pages. + +## Troubleshooting + +### "NOTION_API_KEY not set" + +The environment variable is missing or empty. Verify: +1. Token is in `~/.config/.env` +2. No typos in the variable name +3. Agent was restarted after adding the token + +### "Notion API 401: Unauthorized" + +The token is invalid or expired. Generate a new integration token and update `.env`. + +### "Notion API 404: Not Found" + +The page or database ID is incorrect, or the integration doesn't have access. Verify: +1. Page ID is correct (32 hex characters from the URL) +2. Page isn't archived +3. Integration has been added to the page + +### "Notion API 429: Rate Limited" + +Too many requests in a short period. The agent should wait 60 seconds before retrying. + +### Empty search results + +The integration can't see the pages. Make sure: +1. Pages are shared with the integration +2. Pages aren't in the trash +3. Search query matches page titles or content + +## API Reference + +For advanced use cases, refer to [Notion's official API documentation](https://developers.notion.com/reference/intro). + +## Examples + +### Load API documentation during a task + +**Scenario:** Dev-agent needs to understand authentication before implementing a feature. + +```typescript +// Search for auth docs +notion({ action: "search", query: "API authentication" }) + +// Read the guide +notion({ action: "get", page_id: "abc123..." }) +``` + +### Check if a feature is already documented + +**Scenario:** Before implementing, verify the feature isn't already described elsewhere. + +```typescript +// Search for existing work +notion({ action: "search", query: "payment processing" }) + +// Review each result +notion({ action: "get", page_id: "..." }) +``` + +### Query project roadmap database + +**Scenario:** Control-agent checks upcoming priorities before allocating work. + +```typescript +// Get all high-priority items +notion({ + action: "list", + database_id: "...", + filter: '{"property": "Priority", "select": {"equals": "High"}}', + sorts: '[{"property": "Due Date", "direction": "ascending"}]' +}) +``` + +## Contributing + +If you add capabilities to the Notion extension (e.g., write operations, pagination, deeper nesting), update this documentation and submit a PR. + +See [CONTRIBUTING.md](../CONTRIBUTING.md) for guidelines. diff --git a/package-lock.json b/package-lock.json index 65022e3..a1e1105 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4882,7 +4882,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5680,7 +5679,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5756,7 +5754,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", diff --git a/pi/extensions/notion.ts b/pi/extensions/notion.ts new file mode 100644 index 0000000..11271aa --- /dev/null +++ b/pi/extensions/notion.ts @@ -0,0 +1,394 @@ +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import { StringEnum } from "@mariozechner/pi-ai"; + +/** + * Notion Extension + * + * Provides a `notion` tool for reading from Notion workspace via REST API. + * Supports searching pages, getting page content, querying databases, and + * inspecting database schemas. + * + * Requires: + * NOTION_API_KEY — Notion integration secret (internal integration token) + */ + +// ── Config ──────────────────────────────────────────────────────────────────── + +const NOTION_API_KEY = process.env.NOTION_API_KEY || ""; +const NOTION_API_URL = "https://api.notion.com/v1"; +const NOTION_VERSION = "2022-06-28"; + +// ── API helper ──────────────────────────────────────────────────────────────── + +async function notionRequest(endpoint: string, options: RequestInit = {}): Promise { + if (!NOTION_API_KEY) { + throw new Error("NOTION_API_KEY not set. Add it to ~/.config/.env and restart."); + } + + const url = `${NOTION_API_URL}${endpoint}`; + const res = await fetch(url, { + ...options, + headers: { + Authorization: `Bearer ${NOTION_API_KEY}`, + "Notion-Version": NOTION_VERSION, + "Content-Type": "application/json", + ...options.headers, + }, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Notion API ${res.status}: ${text}`); + } + + return await res.json(); +} + +// ── Content extractors ──────────────────────────────────────────────────────── + +function extractPlainText(richText: any[]): string { + if (!richText || !Array.isArray(richText)) return ""; + return richText.map((rt: any) => rt.plain_text || "").join(""); +} + +function extractPageTitle(page: any): string { + const props = page.properties || {}; + const titleProp = Object.values(props).find((p: any) => p.type === "title") as any; + if (!titleProp?.title) return "(untitled)"; + return extractPlainText(titleProp.title); +} + +function formatBlockContent(block: any, indent = 0): string { + const prefix = " ".repeat(indent); + const type = block.type; + const content = block[type]; + + if (!content) return ""; + + // Extract text from rich_text array + let text = ""; + if (content.rich_text) { + text = extractPlainText(content.rich_text); + } + + switch (type) { + case "paragraph": + return text ? `${prefix}${text}` : ""; + case "heading_1": + return `${prefix}# ${text}`; + case "heading_2": + return `${prefix}## ${text}`; + case "heading_3": + return `${prefix}### ${text}`; + case "bulleted_list_item": + return `${prefix}- ${text}`; + case "numbered_list_item": + return `${prefix}1. ${text}`; + case "to_do": { + const checked = content.checked ? "x" : " "; + return `${prefix}- [${checked}] ${text}`; + } + case "toggle": + return `${prefix}▸ ${text}`; + case "quote": + return `${prefix}> ${text}`; + case "code": { + const lang = content.language || ""; + return `${prefix}\`\`\`${lang}\n${text}\n${prefix}\`\`\``; + } + case "callout": { + const emoji = content.icon?.emoji || "ℹ️"; + return `${prefix}${emoji} ${text}`; + } + case "divider": + return `${prefix}---`; + case "table_of_contents": + return `${prefix}[Table of Contents]`; + case "breadcrumb": + return `${prefix}[Breadcrumb]`; + case "column_list": + case "column": + return ""; // Handled by child blocks + case "child_page": + return `${prefix}📄 ${content.title}`; + case "child_database": + return `${prefix}🗂️ ${content.title}`; + case "embed": + case "image": + case "video": + case "file": + case "pdf": { + const url = content.url || content.file?.url || content.external?.url || ""; + return `${prefix}[${type}: ${url}]`; + } + case "bookmark": + return `${prefix}🔖 ${content.url}`; + case "equation": + return `${prefix}[equation: ${content.expression}]`; + case "link_preview": + return `${prefix}🔗 ${content.url}`; + default: + return `${prefix}[${type}]`; + } +} + +// ── Action handlers ─────────────────────────────────────────────────────────── + +async function handleSearch(params: any): Promise { + const query = params.query || ""; + const limit = Math.min(params.limit || 20, 100); + + const body: any = { + page_size: limit, + }; + + if (query) { + body.query = query; + } + + if (params.filter) { + body.filter = { property: "object", value: params.filter }; + } + + const data = await notionRequest("/search", { + method: "POST", + body: JSON.stringify(body), + }); + + const results = data.results || []; + if (results.length === 0) { + return query ? `No results found for query: "${query}"` : "No pages found in workspace."; + } + + const lines = results.map((item: any) => { + const type = item.object; + const title = + type === "page" ? extractPageTitle(item) : item.title?.[0]?.plain_text || "(untitled)"; + const url = item.url; + const lastEdited = item.last_edited_time?.split("T")[0] || "unknown"; + const icon = type === "database" ? "🗂️" : "📄"; + return `${icon} ${title}\n URL: ${url}\n Last edited: ${lastEdited}`; + }); + + return `Found ${results.length} result(s):\n\n${lines.join("\n\n")}`; +} + +async function handleGet(params: any): Promise { + if (!params.page_id) return "❌ page_id is required for get action."; + + // Remove hyphens from page_id if present (Notion accepts both formats) + const pageId = params.page_id.replace(/-/g, ""); + + // Get page metadata + const page = await notionRequest(`/pages/${pageId}`); + const title = extractPageTitle(page); + const url = page.url; + const lastEdited = page.last_edited_time; + + // Get page content (blocks) + const blocksData = await notionRequest(`/blocks/${pageId}/children?page_size=100`); + const blocks = blocksData.results || []; + + const contentLines: string[] = []; + + for (const block of blocks) { + const line = formatBlockContent(block); + if (line) contentLines.push(line); + + // If block has children, fetch them (up to 1 level deep for now) + if (block.has_children && block.type !== "child_page" && block.type !== "child_database") { + try { + const childData = await notionRequest(`/blocks/${block.id}/children?page_size=100`); + const children = childData.results || []; + for (const child of children) { + const childLine = formatBlockContent(child, 1); + if (childLine) contentLines.push(childLine); + } + } catch (_e) { + // Skip if we can't fetch children + } + } + } + + const content = contentLines.length > 0 ? contentLines.join("\n") : "(empty page)"; + + const output = [`# ${title}`, `URL: ${url}`, `Last edited: ${lastEdited}`, "", content].join( + "\n", + ); + + return output; +} + +async function handleList(params: any): Promise { + if (!params.database_id) return "❌ database_id is required for list action."; + + const databaseId = params.database_id.replace(/-/g, ""); + const limit = Math.min(params.limit || 20, 100); + + const body: any = { + page_size: limit, + }; + + if (params.filter) { + try { + body.filter = JSON.parse(params.filter); + } catch (_e) { + return "❌ filter must be valid JSON (Notion filter object)"; + } + } + + if (params.sorts) { + try { + body.sorts = JSON.parse(params.sorts); + } catch (_e) { + return "❌ sorts must be valid JSON array"; + } + } + + const data = await notionRequest(`/databases/${databaseId}/query`, { + method: "POST", + body: JSON.stringify(body), + }); + + const results = data.results || []; + if (results.length === 0) return "No entries found in database."; + + const lines = results.map((page: any) => { + const title = extractPageTitle(page); + const url = page.url; + + // Extract a few key properties + const props = page.properties || {}; + const propLines: string[] = []; + + for (const [name, prop] of Object.entries(props) as [string, any][]) { + if (prop.type === "title") continue; // Already shown + + let value = ""; + switch (prop.type) { + case "rich_text": + value = extractPlainText(prop.rich_text); + break; + case "number": + value = String(prop.number || ""); + break; + case "select": + value = prop.select?.name || ""; + break; + case "multi_select": + value = (prop.multi_select || []).map((s: any) => s.name).join(", "); + break; + case "date": + value = prop.date?.start || ""; + break; + case "checkbox": + value = prop.checkbox ? "✓" : "✗"; + break; + case "url": + value = prop.url || ""; + break; + case "email": + value = prop.email || ""; + break; + case "phone_number": + value = prop.phone_number || ""; + break; + case "status": + value = prop.status?.name || ""; + break; + } + + if (value && propLines.length < 3) { + // Limit to 3 properties for brevity + propLines.push(`${name}: ${value}`); + } + } + + return `📄 ${title}\n ${propLines.join(" | ")}\n URL: ${url}`; + }); + + return `${results.length} entries:\n\n${lines.join("\n\n")}`; +} + +async function handleDatabase(params: any): Promise { + if (!params.database_id) return "❌ database_id is required for database action."; + + const databaseId = params.database_id.replace(/-/g, ""); + + const db = await notionRequest(`/databases/${databaseId}`); + + const title = db.title?.[0]?.plain_text || "(untitled database)"; + const url = db.url; + const props = db.properties || {}; + + const propLines = Object.entries(props).map(([name, prop]: [string, any]) => { + return ` - ${name} (${prop.type})`; + }); + + const output = [`🗂️ ${title}`, `URL: ${url}`, "", "Properties:", ...propLines].join("\n"); + + return output; +} + +// ── Extension ───────────────────────────────────────────────────────────────── + +export default function (pi: ExtensionAPI) { + pi.registerTool({ + name: "notion", + label: "Notion", + description: + "Read from Notion workspace. " + + "Actions: search (find pages/databases by query), get (read page content by ID), " + + "list (query database entries), database (get database schema).", + parameters: Type.Object({ + action: StringEnum(["search", "get", "list", "database"] as const), + query: Type.Optional(Type.String({ description: "Search query text (for search)" })), + page_id: Type.Optional( + Type.String({ + description: "Page ID (UUID from URL, with or without hyphens) (for get)", + }), + ), + database_id: Type.Optional( + Type.String({ description: "Database ID (UUID from URL) (for list/database)" }), + ), + filter: Type.Optional( + Type.String({ + description: + "Filter: 'page' or 'database' (for search), or JSON filter object (for list)", + }), + ), + sorts: Type.Optional(Type.String({ description: "JSON array of sort objects (for list)" })), + limit: Type.Optional( + Type.Number({ description: "Max results to return (default 20, max 100)" }), + ), + }), + + async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + let text: string; + + try { + switch (params.action) { + case "search": + text = await handleSearch(params); + break; + case "get": + text = await handleGet(params); + break; + case "list": + text = await handleList(params); + break; + case "database": + text = await handleDatabase(params); + break; + default: + text = `Unknown action: ${(params as any).action}`; + } + } catch (e: any) { + text = `❌ ${e.message}`; + } + + return { content: [{ type: "text" as const, text }], details: undefined }; + }, + }); +}