{
  "name": "Pinako",
  "description": "Remote MCP server for Pinako, the browser tab, session, and bookmark organizer. Read and reorganize the user’s live tab tree, libraries, notes, and browser bookmarks. OAuth 2.0 (PKCE); connect from Claude, ChatGPT, or any Streamable HTTP MCP client. Setup guide: https://pinako.pro/docs/mcp-setup/",
  "version": "1.3.7",
  "serverUrl": "https://connect.pinako.pro/mcp",
  "transport": "streamable-http",
  "authentication": {
    "type": "oauth2",
    "authorizationServerMetadata": "https://pinako.pro/.well-known/oauth-authorization-server",
    "scopes": {
      "read": "View tabs, libraries, notes, and bookmarks.",
      "write": "Create, edit, move, and delete them. The user chooses Full access or Read-only on the consent screen."
    }
  },
  "homepage": "https://pinako.pro",
  "documentation": "https://pinako.pro/docs/mcp-setup/",
  "tools": [
    {
      "name": "list_libraries",
      "description": "List the user's Pinako libraries, library groups, and the panel order. Default returns metadata only per library (id, title, description, tabCount, note id+title) plus groups (id, title, description, library_ids) and panel_order. Pass include_tabs:true to embed each library's children tree (use for cross-library searches). Linked libraries (shared from another user) carry linked:true and are read-only. The returned panel_order array is the required input for reorder_library_panel."
    },
    {
      "name": "get_tree",
      "description": "Get the user's live Pinako tab tree (Window Groups, windows, folders, tabs) as a hierarchical node structure. type='tabgroup' container nodes mirror the browser's own Tab Groups: they sit under a window and hold their member tabs as children (tabs nested under a member are members too), and that containment IS the membership (see move_node). Defaults to a minimal shape (id, type, title, url, ghost flag per node); pass include_*:true flags (or full:true for everything) to add tags, memos, openedDate, collapsed flags, browser Tab Group metadata, or visual fields. include_ghost_tabs:false excludes closed (ghost) tabs. For counts or a structural overview of a large tree, prefer get_tree_summary first; for a substring lookup, prefer search_pinako (cheaper than enumerating the whole tree)."
    },
    {
      "name": "get_tree_summary",
      "description": "Cheap structural overview of a surface — node count, url-bearing-node count, max depth, top 15 domains, and up to 20 sample titles. Ideal FIRST call before deciding whether to read a big surface deeper. Does NOT return individual nodes (use get_tree / get_library for that)."
    },
    {
      "name": "search_tabs",
      "description": "LITERAL substring search across main-tree TAB nodes only (matches title, URL, memo text, and tags). Legacy/tab-only — prefer search_pinako for broader literal search that also covers groups, folders, libraries, bookmarks, and note content. Use this only when the user names a literal substring and specifically means live tabs."
    },
    {
      "name": "search_pinako",
      "description": "Omnibus LITERAL substring search across Pinako data surfaces — the broad replacement for search_tabs. No LLM; a direct scan. Use this WHENEVER the user names a literal substring that may match non-tab nodes (groups, folders, library titles), non-tree surfaces (libraries, bookmarks, notes), or wants exact-tag semantics. Avoids the fetch-and-filter anti-pattern (list_libraries with include_tabs then grep in your own head), which costs tokens proportional to data size.\n\nSCOPE SELECTION:\n  - 'tree' (default) — live tab tree. Default match_fields: title, url, tags, memo, flag. Returns non-tab nodes too when their title / tag / memo / flag label matches.\n  - 'library' — one specific library (requires library_id). Default match_fields: title, url, tags, memo, flag.\n  - 'libraries-all' — UNION across every library. Each hit is tagged with sourceLibraryId. The right scope for 'find tabs tagged X across all my libraries'.\n  - 'bookmarks' — the browser bookmark tree. Default match_fields: title, url, flag (bookmarks carry Pinako stars, so flag labels match; bookmark items carry no tags/memos).\n  - 'notes' — Pinako notes (BOTH library notes AND Main Notes). Default match_fields: title, content. Each hit returns a 200-char snippet centered on the first content match.\n  - 'all' — union of tree + every library + notes, plus bookmarks ONLY when the bookmark tree is small (≤600 links) or include_bookmarks:true. On a larger tree, 'all' skips bookmarks and the response carries a bookmarksSkipped notice — surface it and ask before including them.\n\nTAG MATCHING: substring by default ('foo' matches 'food', 'footnote'). Pass exact_tag:true to require the tag to EQUAL the query (case-insensitive) — MUST set it when the user names a specific tag.\n\nLIMIT: default 200 results (max 2000). Response includes truncated:true when the limit was hit."
    },
    {
      "name": "get_library",
      "description": "Returns one library's full contents: metadata, the children tree (windows/groups/folders/tabgroups/tabs), and its notes. Notes return id + title only unless include_note_content:true (then the rich-text Tiptap HTML bodies are included). Shape opts (minimal, include_*) tune the children tree. include_ghost_tabs:false excludes closed tabs. Linked libraries carry linked:true and are read-only."
    },
    {
      "name": "get_main_tree_notes",
      "description": "Returns the Main Notes — rich-text documents attached to the user's main tree (as opposed to notes attached to a specific library). Cloud-synced, identical across browsers. include_content:false returns note id + title only; default true returns the full bodies."
    },
    {
      "name": "get_bookmarks",
      "description": "Returns the user's browser bookmark tree (chrome.bookmarks). Use it to discover bookmark node ids before add_to_library {sourceScope:'bookmarks'} / add_to_bookmarks. For large trees (10K+ entries) use the composable opts or pagination rather than reading the whole tree.\n\nRESPONSE SHAPES:\n  - Default (no params): full nested tree under `bookmarks`.\n  - parent / parent_id / leaves_only / folders_only: a flat items[] list (filtered).\n  - after / limit: a flat DFS-pre-order paginated items[] with nextCursor + totalItems.\n\nOPTS:\n  - parent: bookmark folder id OR title (case-insensitive). Returns ONLY direct children. Resolution: id → top-level root title → nested folder title.\n  - parent_id: strict-id alias for parent (no title fallback).\n  - leaves_only: emit only URL leaves. folders_only: emit only folders (always with a `path` breadcrumb). Mutually exclusive.\n  - include_date_added: add dateAdded (Unix ms). minimal: lighter payload."
    },
    {
      "name": "find_duplicates",
      "description": "Exact-URL duplicate detection within a scope. scope: 'tree', 'bookmarks', 'library' (library_id required), or 'cross-scope' (unions tree + bookmarks + all libraries by default; narrow via crossScopes). Returns duplicate sets grouped by URL. For cross-scope, each set indicates which surface(s) each instance lives on so you can route follow-up write ops per source."
    },
    {
      "name": "search_docs",
      "description": "Search Pinako's bundled user guide (token-overlap scored). Use it to look up Pinako's product-specific meanings — 'group', 'folder', 'memo', 'ghost tab', 'library group', 'snapshot', etc. differ from generic tab-manager intuition — before guessing from the term alone. Local, fast, no internet. Two RESERVED single-word queries address agent-only material and match nothing else in the corpus: \"orientation\" returns the two-part capabilities map (what Pinako is and which anchor documents what — the right first call in a cold conversation), and \"playbook\" returns the two-part bookmark playbook (run it before any broad bookmark survey or reorganization). Pair either with full_sections:true and one call returns both halves whole. Cite returned anchor ids to the user EXCEPT ids starting with \"agent-\", which exist only in this corpus and are a dead link in their copy of the guide."
    },
    {
      "name": "list_browsers",
      "description": "Lists the Pinako browsers currently connected to this account (each a live extension). Per entry: browserBrand (\"Chrome\", \"Brave\", ...), browserId (stable per-install id), deviceName (the user's name for it in Pinako's Synced Devices, if set), lastActiveAt (ISO timestamp of that browser's most recent activity), and subscriptionTier (0-4: 0=Free, 1=Pro, 2=Pro+, 3=Premium, 4=Enterprise). Sorted most-recently-active first, so entry [0] is the best recency hint when asking the user which browser to use. Pass an entry's browserBrand or browserId as the `browser` argument to other tools to target that browser when more than one is connected. A browser must have its Pinako popup open to run a command; if a call returns POPUP_NOT_OPEN, ask the user to open Pinako on that browser."
    },
    {
      "name": "set_title",
      "description": "Sets a custom title on a tab, window, Window Group (type='group'), or folder node. Trimmed; max 200 chars. Sets customTitle=true so the title persists across browser restarts. Rejects tabgroup nodes with INVALID_TARGET (a Tab Group name is Chrome-coupled identity, not a customTitle overlay — use update_tab_group) and rejects the library container (use set_library_title)."
    },
    {
      "name": "set_memo",
      "description": "Sets the memo (short plain-text annotation, max 2500 chars) on a node. Pass empty string to clear. Memos are per-node and concise; for richer rich-text documents use create_note / set_note_content (which target a library or the Main Notes, not individual nodes). The memo content field is named \"text\" in this tool; \"memo\" is also accepted as an alias for resilience (if both are present, \"text\" wins)."
    },
    {
      "name": "set_tags",
      "description": "REPLACES the entire tag array on a node. Pass an empty array to clear all tags. Use add_tags / remove_tags for delta updates that preserve existing tags. Constraints: each tag max 50 chars; max 50 tags per node."
    },
    {
      "name": "create_group",
      "description": "Creates a new Window Group node (type='group' — Pinako's organizational row for nesting windows). Window Groups can contain other Window Groups and windows but NOT tabs directly (tabs always live under a window, a tabgroup node, or another tab). This does NOT create a browser Tab Group — use create_tab_group for those; tabs join an EXISTING browser Tab Group via move_node. Position defaults to TOP of the destination siblings (matches the manual UI)."
    },
    {
      "name": "create_window",
      "description": "Creates a new window node by RELOCATING one or more existing tabs into it (tabIds, at least one; an empty window is invalid). Bundles loose tabs into a window in ONE atomic step instead of moving tabs to root one at a time. Scope \"tree\" (default) or \"library\" (libraryId required); NOT bookmarks. parentId may be a group, folder, or library-folder, or omitted/null for the scope root. In the main tree, relocating LIVE tabs opens a real browser window containing them (created in the background, so focus is not stolen); ghost/saved and library tabs stay saved-only. Default position appends. Tabs are MOVED, not copied."
    },
    {
      "name": "create_tab_group",
      "description": "Creates a NEW browser Tab Group (the colored, named chip in the browser tab strip) by RELOCATING one or more existing tabs into a new type=\"tabgroup\" node — create_window's contract one level down. The group forms in the FIRST listed tab's window, at that tab's position; tabs listed from OTHER windows are gathered there (they physically move in the strip; a window emptied by the gather closes). Tabs are MOVED, not copied, grouped in the given order; a listed tab's nested subtree rides along. Live tabs produce a real strip group immediately (title + color applied); all-ghost tabIds produce a CLOSED group that materializes on reopen. collapsed:true is applied after creation and Chrome may refuse it for the group holding the active tab — the result then carries collapseWarning, not an error. Tree scope only. To add tabs to an EXISTING group use move_node into the tabgroup node; for Pinako's organizational Window Group rows use create_group instead."
    },
    {
      "name": "update_tab_group",
      "description": "Edits a browser Tab Group's identity: title (rename; empty string \"\" clears back to untitled), color (Chrome's 9-color enum), and/or collapsed. At least one field required. This is the ONLY tool for tabgroup identity — set_title and set_row_color reject tabgroup nodes by design. LIVE groups: the change is pushed to the browser FIRST; a title/color refusal fails the whole op (CHROME_TAB_GROUP_UPDATE_FAILED — e.g. saved groups are not editable; TAB_GROUP_SHARED for shared groups, which are hands-off), while a COLLAPSE refusal (Chrome keeps the active tab's group expanded) is soft: title/color still apply and the result carries collapseWarning — unless collapsed was the only field, which returns TAB_GROUP_COLLAPSE_REFUSED. Closed (all-ghost) groups and library copies: edits the stored identity, applied when the group reopens. Membership is NOT edited here — move_node joins/leaves, ungroup_tab_group dissolves."
    },
    {
      "name": "ungroup_tab_group",
      "description": "Dissolves a browser Tab Group: member tabs stay open (or stay saved, for a closed group) and keep their tree position — promoted in place where the group row was — and the group node is removed. Mirrors the UI's \"Ungroup (keep tabs open)\". NOT a delete: nothing closes and no data is lost, so no confirmation flag; to close or remove the group AND its tabs use ghost_node / delete_node / delete_live_node on the tabgroup node instead. Live groups are ungrouped in the browser too (Chrome auto-deletes the emptied chip). Retry after success returns NODE_NOT_FOUND — treat as done."
    },
    {
      "name": "add_tags",
      "description": "APPENDS tags to a node, deduping and preserving order of existing tags. Use this when the user says \"tag X with Y\" or \"also add Z\" — it preserves prior tags. Use set_tags for full replacement, remove_tags for deletion. Constraints: each tag max 50 chars; max 50 tags per node total (existing + appended)."
    },
    {
      "name": "remove_tags",
      "description": "Filters specific tags off a node, preserving the rest. No-op for tags not present. Use this for \"untag X from Y\" requests. Constraints: each tag max 50 chars."
    },
    {
      "name": "set_star_color",
      "description": "Sets the row star color on a node. Accepts a named color (red, orange, yellow, green, blue, purple) OR its exact hex equivalent (#ff4d4f red, #fa8c16 orange, #fadb14 yellow, #52c41a green, #1890ff blue, #722ed1 purple). Arbitrary hex codes are NOT accepted — only the six pre-defined hex values. Pass null to clear."
    },
    {
      "name": "set_row_color",
      "description": "Sets the rowColor of a Window Group node or Folder node. NOT related to browser Tab Group color (a Tab Group's color is Chrome identity — use update_tab_group for that; tabgroup nodes are rejected here with INVALID_NODE_TYPE). Accepts: \"accent2\" (theme-tracking default), a named color (\"blue\", \"red\", \"green\", \"purple\", \"yellow\", \"orange\", \"pink\", \"cyan\", \"grey\"), an explicit 6-digit hex (\"#1890ff\"), or null (reset to \"accent2\"). Rejects on non-group/non-folder node types (including tabgroup) with INVALID_NODE_TYPE."
    },
    {
      "name": "move_node",
      "description": "Moves a node (and its full subtree) under newParentId at an optional position. SUBTREE SEMANTICS: all descendants come along. To move ONLY the node WITHOUT its children (e.g., \"move tab X but leave the nested tabs\"), use the outdent-first-child pattern: outdent the node's first child first (sibling-adoption pulls the rest under it), then move the now-empty target. Or wrap both ops in a single bulk_apply for atomicity. Pass newParentId=null to move to root (auto-wraps tabs into a new window). VALID PARENT BY MOVED-NODE TYPE (engine rejects others with INVALID_PARENT): tabs → ROOT or window or folder or tabgroup; windows → ROOT or group or folder; groups → ROOT or group; folders → ROOT or folder; tabgroup nodes → window only. Critically: a tab CANNOT be moved directly under a Window Group node — create or move into a window first if you want tabs gathered under a Window Group row. (Tab-under-tab nesting is a designed Pinako shape but is reachable only via indent_node, not move_node — move_node's DND-semantic rules intentionally exclude it.) BROWSER TAB GROUP behavior (move_node is the only membership lever — there is no separate join tool). Membership follows CONTAINMENT — move the tab INTO the tabgroup node to join, OUT of it (to the window, another tabgroup, anywhere else) to leave. Position within the branch does not affect membership; only the branch does. Moving the tabgroup node itself carries the whole group; its only valid parent is a window. Create / rename / recolor / dissolve have dedicated tools: create_tab_group, update_tab_group, ungroup_tab_group. BOOKMARK-SCOPE NOTE: scope:\"bookmarks\" moves go through chrome.bookmarks.move and are NOT Pinako-undoable (the bookmark tree has no Ctrl+Z coverage). Less severe than delete (data isn't lost, just relocated), but for batch reorganization (more than a few items) suggest the user back up bookmarks first via the import/export button on the Bookmarks panel."
    },
    {
      "name": "move_node_to_library",
      "description": "Moves a node and its FULL subtree OUT of one Library and INTO another (libraryId -> targetLibraryId). This is the ONLY tool that crosses a Library boundary in one step; move_node with scope:\"library\" moves within a single library and cannot reach a foreign destination. Both libraries change atomically, so the node is never in two libraries or in neither, and ONE undo reverts the whole move. The node keeps its id. Everything else matches move_node: the same VALID PARENT BY MOVED-NODE TYPE rules (tabs -> ROOT or window or folder or tabgroup; windows -> ROOT or group or folder; groups -> ROOT or group; folders -> ROOT or folder; tabgroup nodes -> window only), the same tab-to-ROOT auto-wrap into a new window node, and the same position clamping. newParentId is resolved INSIDE targetLibraryId only. Windows and tab-group shells the move empties in the source library are pruned. To COPY instead of move (keep it in both libraries), use add_to_library with sourceScope:\"library\"."
    },
    {
      "name": "indent_node",
      "description": "Nests a node under its previous sibling (one level deeper). Rejects when the node has no prior sibling (INDENT_NO_PREV_SIBLING). Auto-expands the new parent. Works across tree, library, and bookmark scopes — a common pattern for quickly de-nesting then re-organizing tabs. For scope=\"bookmarks\", the parent change syncs to chrome.bookmarks.move (when the new parent is a folder) or to Pinako's tab-under-tab override (when the new parent is a tab/bookmark — chrome.bookmarks can't represent that natively)."
    },
    {
      "name": "outdent_node",
      "description": "Promotes a node to its grandparent's level (one level shallower). Sibling-adoption preserves layout: the outdented node's younger siblings become its children, so visual row order is preserved. CHILD-EXTRACTION PATTERN: outdent the FIRST child of a target to free the target solo (target becomes empty, all children become adopted under the outdented first child). Works across tree, library, and bookmark scopes. For scope=\"bookmarks\", the parent change (and each adopted sibling's new parent) syncs to chrome.bookmarks.move or to Pinako's tab-under-tab override depending on the new parent's type."
    },
    {
      "name": "delete_node",
      "description": "DESTRUCTIVE — permanently removes a GHOST node (chromeId=null) and its metadata (tags, memos, star color, custom title). For scope=\"bookmarks\", removes the bookmark from the browser via chrome.bookmarks.remove. REJECTS subtrees that contain any live tab (chromeId set) with LIVE_NODE_REFUSED — for live tabs, use ghost_node first (closes the browser tab, preserves the tree record) then delete_node, OR use delete_live_node which does both in one shot. REQUIRES EXPLICIT USER APPROVAL: set confirmedByUser:true ONLY after the user has confirmed THIS specific deletion. Once deleted, only Chrome history retains the URL — Pinako-specific metadata is gone permanently. Idempotent-on-retry: NODE_NOT_FOUND on a retry typically means the previous call succeeded but the response was lost; treat as success rather than re-asking the user. BOOKMARK-SCOPE RECOVERY ASYMMETRY (IMPORTANT): scope:\"tree\" and scope:\"library\" deletes ARE recoverable via Pinako's Ctrl+Z undo stack. scope:\"bookmarks\" deletes are NOT — chrome.bookmarks.remove has no Pinako undo coverage; recovery requires the user's pre-edit backup. BEFORE the FIRST bookmark-scope delete in a session AND before any non-trivial batch (any time more than ~3 bookmarks are affected), you MUST surface this warning to the user verbatim and obtain explicit fresh confirmation: \"Unlike deletions in your main tree or Pinako Libraries, deletions of bookmarks cannot be undone. Out of precaution, you can use the import/export button on the Bookmarks panel to create a backup before allowing AI to edit them.\" This warning is NOT optional and is NOT satisfied by an earlier in-session confirmation of a different scope. After the warning + user confirmation, proceed without re-warning for subsequent contiguous bookmark deletes in the same workflow."
    },
    {
      "name": "ghost_node",
      "description": "Closes the live browser tab(s) for this node and all live descendants, while preserving the tree node with chromeId=null on every ghosted node. Mirrors the manual \"X\" button. REVERSIBLE: the user can re-open from the tree later (URLs and metadata stay in the tree). Use this for \"close these tabs but keep them saved\" intents — end-of-day cleanup, freeing memory, archiving research. No confirmedByUser required (2026-05-11): the tree record is preserved so an erroneous ghost is undoable by re-opening. The browser-tab close is still visible, so narrate the intent before invoking (\"I'll close these but they'll stay saved in your tree\"). Returns NODE_NOT_LIVE if nothing in the subtree is live. Idempotent-on-retry: NODE_NOT_LIVE on retry typically means the previous call already ghosted everything; treat as success."
    },
    {
      "name": "delete_live_node",
      "description": "DESTRUCTIVE — closes the live browser tab(s) AND removes the tree node entirely (compound of ghost_node + delete_node, but bypasses delete_node's LIVE_NODE_REFUSED). Use when the user wants both the browser tabs gone AND the saved tree node gone. Mirrors the manual trash button on live nodes. SCOPE NOTE: bookmark items are never \"live\" (no chromeId), so delete_live_node should NOT be used on scope:\"bookmarks\" — use delete_node with scope:\"bookmarks\" instead (and surface the bookmark non-undoability warning from delete_node's description before doing so). REQUIRES EXPLICIT USER APPROVAL: set confirmedByUser:true ONLY after the user has confirmed THIS specific deletion — do not set it as a default. The engine and bridge both enforce this; missing the flag returns CONFIRMATION_REQUIRED."
    },
    {
      "name": "create_library",
      "description": "Creates a new empty library with an auto-seeded \"Notes\" note. Returns createdLibraryId and createdNoteId in the result. Use add_to_library afterwards to populate. For just creating an organizational umbrella over EXISTING libraries, use create_library_group instead. NOT IDEMPOTENT: each call creates a new library. On transient failures (EDIT_TIMEOUT, FORWARDER_DISCONNECTED, LEADER_CHANGED, NM_WRITE_FAILED), DO NOT auto-retry — call list_libraries to check whether the previous attempt succeeded before retrying, otherwise you may create duplicates."
    },
    {
      "name": "add_to_library",
      "description": "Clones TREE nodes (windows, tabs, groups, folders) from a source surface into a library. NOT for library-scope notes — pass only ids from the source's tree (e.g. `children[]` returned by get_library), never note ids from `notes[]`. To duplicate a library-scope note, use `create_note` with the destination library and the note's title+content instead. INCLUDECHILDREN GUIDANCE: default true (subtree comes along, matching manual DND). Set FALSE when adding individual tabs (\"add tab X to library\") to avoid bundling unrelated nested children; keep TRUE for windows/groups/explicit \"add subtree\" requests. SOURCESCOPE: \"tree\" (default — main tab tree), \"library\" (cross-library copy; sourceLibraryId required), \"bookmarks\" (clone from bookmark tree), or \"sync\" (clone from a connected device — another Pinako install on the same account, or a Chrome-synced mobile device; sourceDeviceId required). For \"sync\" sources, mobile devices resolve instantly (eager-loaded); PC/browser devices may add ~50-300ms latency on first use as the device tree fetches from Pinako cloud (cached for the rest of the session). Engine auto-wraps tab clones into ONE new window in the destination (libraries require tabs to have a window/tab/folder parent). Max 100 source ids per call. UNRESOLVED IDS: ids that no longer resolve (a tab closed, or its id changed because it reloaded / woke from hibernation since your read) are skipped and returned in result.skippedNodeIds rather than failing the whole batch — if it comes back non-empty, re-match those items by title/URL and retry just them; do not drop them or narrow to a \"safe\" subset."
    },
    {
      "name": "add_to_bookmarks",
      "description": "Inverse direction of add_to_library: clones tree or library nodes INTO the browser's native bookmark tree (chrome.bookmarks). VERB MAPPING: \"save tab X as a bookmark\", \"back up these tabs to bookmarks\", \"add library Y to bookmarks folder Z\" → use this tool. \"MOVE to bookmarks\" → call this tool first, then delete_node on the source after success (two-step, agent-managed; there is no atomic move_to_bookmarks for v1). SOURCESCOPE: \"tree\" (default — main tab tree) or \"library\" (sourceLibraryId required). TYPE CONVERSION (automatic): tab source → bookmark leaf (preserves url/title), window/group/folder source → bookmark folder (the bookmark tree has no concept of window or group; conversion mirrors the manual drag-to-bookmarks behavior). PARENT: pass parentBookmarkFolderId pointing at a folder node id from get_bookmarks; omit to default to the first root (\"Bookmarks Bar\"). Returns addedBookmarkNodeIds (Pinako internal ids of the inserted bookmark nodes). NOT IDEMPOTENT — each call creates new Chrome bookmarks. On transient failures, DO NOT auto-retry; call get_bookmarks first to check whether the previous attempt succeeded. UNDO COVERAGE (2026-05-24): Ctrl+Z now fully reverses add_to_bookmarks — removes both the Pinako bookmark tree entries AND the Chrome bookmarks created. Redo recreates them. Earlier versions left Chrome bookmarks orphaned on undo; that limitation is fixed. For LARGE bulk add operations (50+ items), still suggest the user back up bookmarks first via the import/export button on the Bookmarks panel — the recovery path is now built in but a manual backup is cheap insurance against unexpected Chrome API errors mid-batch. Max 100 source ids per call."
    },
    {
      "name": "delete_library",
      "description": "Permanently deletes a single library and ALL its content (tabs, notes, tags, memos, child windows/groups/folders). Removes the library from any group it belongs to AND from the panel order. DESTRUCTIVE: cannot be fully undone — Ctrl+Z restores the libraryData entry but does NOT restore the group/panelOrder cleanup and does NOT recreate the cloud row. Use this instead of the hack of wrapping the library in a temporary group and cascade-deleting the group; that workaround required two MCP roundtrips and a visible UI artifact. confirmedByUser:true is REQUIRED — obtain explicit user approval for THIS specific library deletion (not as a default, not on retry). Returns LIBRARY_NOT_FOUND for unknown ids. To remove a library from a group without deleting its content, use remove_library_from_group instead."
    },
    {
      "name": "set_note_content",
      "description": "Updates an existing note's content. MODE GUIDANCE: \"replace\" (default) overwrites — use for \"update note X with Y\", \"replace note X\". \"append\" concatenates after existing content — use for \"add Y to note X\", \"note down that ...\". For prepend, read existing content first then call replace with the combined string. Note char limit is tier-gated (50K Pro / 150K Pro+ / 250K Premium / 500K Enterprise); for append mode the FINAL length is what's gated. Note content is sanitized at write time (HTML allowlist; <script>, on* event handlers, javascript: URLs are stripped) — write valid Tiptap-compatible HTML or plain text. PASS HTML RAW: the `content` value is a JSON string the engine stores literally then renders; do NOT entity-escape `<` `>` `&` (they are valid unescaped inside a JSON string). Writing `&lt;p&gt;...&lt;/p&gt;` stores literal entity text that renders as visible `&lt;p&gt;` rather than a `<p>` element. Idempotent on retry for replace mode; append mode on retry would double-append, so DO NOT auto-retry append on transient failures — re-read first. CONCURRENCY (LWW): notes carry an optional last_modified millisecond timestamp. For read-then-write flows (read note → reason about content → edit), capture note.last_modified from the get_tree response and pass it as expected_last_modified to guard against another path (portal collaboration session, another agent round, another device) modifying the note between your read and your write. If the engine sees a newer note.last_modified than your expected token, it rejects with NOTE_STALE — re-read the note and retry. Omit expected_last_modified for blind overwrites (e.g. user says \"replace the whole note with X\")."
    },
    {
      "name": "create_note",
      "description": "Creates a new note in a library or in the Main Notes. Use this when the user says \"create a note about X\", \"save these findings as a new note\", etc. For UPDATING an existing note, use set_note_content. Returns createdNoteId. Char limit is tier-gated. Note content is sanitized at write time (HTML allowlist; <script>, on* event handlers, javascript: URLs are stripped) — write valid Tiptap-compatible HTML or plain text. PASS HTML RAW: the `content` value is a JSON string the engine stores literally then renders; do NOT entity-escape `<` `>` `&` (they are valid unescaped inside a JSON string). Writing `&lt;p&gt;...&lt;/p&gt;` stores literal entity text that renders as visible `&lt;p&gt;` rather than a `<p>` element. NOT IDEMPOTENT: each call creates a new note. On transient failures, DO NOT auto-retry — call get_library or get_main_tree_notes to check whether the previous attempt succeeded before retrying."
    },
    {
      "name": "delete_note",
      "description": "Permanently deletes a note from a library or the main tree. Cloud-side delete is automatic on next persist (the per-scope notes sync diffs current ids against existing Supabase rows and removes missing ones). LAST-NOTE BEHAVIOR: this op allows deleting any note including the only note in a scope. Pinako auto-reseeds an empty notes array with a default \"Notes\" note on next access — matches manual UI semantics. confirmedByUser:true is REQUIRED — destructive op, obtain explicit user approval for THIS specific deletion (not as a default, not on retry). Returns NOTE_NOT_FOUND for unknown ids. To CLEAR a note's content without deleting the note record, use set_note_content with empty string."
    },
    {
      "name": "create_library_group",
      "description": "Creates a new library group (an organizational umbrella over multiple libraries). Returns createdGroupId. After creating, use add_library_to_group to add member libraries. NOT IDEMPOTENT: each call creates a new group. On transient failures, DO NOT auto-retry — call list_libraries to inspect existing groups before retrying."
    },
    {
      "name": "delete_library_group",
      "description": "Removes a library group. TWO MODES via cascadeMembers: (1) DEFAULT (cascadeMembers omitted/false) — DISSOLVE: member libraries are KEPT and re-appear in the standalone library card list at the position the group occupied. Safe; non-destructive. (2) cascadeMembers:true — DESTRUCTIVE: also deletes each member library (owned libraries are deleted from cloud; linked libraries are unlinked from this account). REQUIRES EXPLICIT USER APPROVAL when cascadeMembers:true: set confirmedByUser:true ONLY after the user has confirmed they want to lose the libraries' content. Cascade is one-way — undo restores group structure but NOT the cascaded libraries' content. Engine + bridge both enforce confirmedByUser when cascading."
    },
    {
      "name": "add_library_to_group",
      "description": "Adds an existing library to an existing group. A library can belong to at most one group; rejects with LIBRARY_ALREADY_IN_GROUP / LIBRARY_IN_OTHER_GROUP if it's already assigned somewhere."
    },
    {
      "name": "remove_library_from_group",
      "description": "Removes a library from a group, returning it to the standalone library card list right after the group. The library itself is preserved. No-op if the library wasn't in the group (removing a stale ref is valid cleanup)."
    },
    {
      "name": "set_library_group_title",
      "description": "Renames a library group. Trimmed, non-empty, max 200 chars."
    },
    {
      "name": "set_library_group_description",
      "description": "Updates a library group's description. Empty string clears it. Max 1000 chars."
    },
    {
      "name": "set_library_title",
      "description": "Renames a library. Trimmed, non-empty, max 200 chars. THIS IS THE ONLY rename path for a library (the container) — set_title rejects the library container with INVALID_TARGET (the library root is type 'library', not a renamable tree node; in-library FOLDER nodes, type 'library-folder', ARE renamable via set_title). MUST use this tool, not set_title, when the user asks to \"rename library X to Y\" or fix a library name typo. Mirrors set_library_group_title for the umbrella-group case."
    },
    {
      "name": "set_library_description",
      "description": "Updates a library's description (shown beneath the title on library cards). Empty string clears it. Max 1000 chars. Mirrors set_library_group_description for the umbrella-group case."
    },
    {
      "name": "create_folder",
      "description": "Creates a new folder node in a library or in the browser bookmarks. NOT for the main tab tree (the main tree uses windows + groups, not folders). Required scope: \"library\" (with libraryId) or \"bookmarks\". Default position is TOP of the parent (matches manual UI). For bookmarks, the folder is also created in the browser's native bookmark tree via chrome.bookmarks.create — synced automatically. parentId omitted/null places the new folder at the scope root."
    },
    {
      "name": "reorder_library_panel",
      "description": "Reorders the cards in the library panel (standalone library cards + library group cards). Pass the COMPLETE current list of entries in the desired order. Each entry is {type:\"library\"|\"group\", id:<id>}. ORDER ONLY — every existing entry must be present (rejects with PANEL_ORDER_MISMATCH if count differs, PANEL_ORDER_UNKNOWN_ENTRY if an unknown id is introduced). Use create_library / delete_library_group / etc. to change membership; this op cannot add or remove cards. Always call list_libraries first to fetch the current panel_order array — never construct the entries array blindly; group ids and panel positions must come from a fresh list_libraries call (the panel_order field in its response maps 1:1 to this op's entries arg). Max 200 entries."
    },
    {
      "name": "reorder_libraries_in_group",
      "description": "Reorders the libraries within a single library group. Pass the COMPLETE current list of member library ids in the desired order. ORDER ONLY — every current member must be present (rejects with LIBRARY_ORDER_MISMATCH if count differs, LIBRARY_ORDER_UNKNOWN_MEMBER if an unknown id is introduced, LIBRARY_ORDER_DUPLICATE if duplicates). Use add_library_to_group / remove_library_from_group to change membership. Max 200."
    },
    {
      "name": "bulk_apply",
      "description": "Atomically applies up to 250 sub-ops as a SINGLE undoable unit. TWO USE CASES: (1) multi-step reorganizations — \"move these 12 tabs into a new library called Research\"; (2) batch-applying the same op to many targets — \"tag these 8 nodes 'archived'\", \"rename each of these tabs\", \"set the same memo on these 5 items\". COST/LATENCY GUIDE — FOLLOW EXACTLY: at 1, 2, or 3 targets you MUST use the underlying tool directly (individually), not bulk_apply — composing an ops array for so few items has reasoning overhead that is not worth the atomic-undo benefit at that size. **Use bulk_apply ONLY when applying the same op to 4 OR MORE targets, OR for any heterogeneous multi-step reorganization (regardless of count) where the user explicitly wants one-click atomic undo.** This 4-target threshold is not a soft preference; it is the rule. ENVELOPE SCOPE — STRICT MATCH: every sub-op's `scope` must equal the bulk's envelope `scope` (omitting sub-op scope makes it inherit). ENVELOPE LIBRARYID — SCOPE-DEPENDENT: for in-library scopes (`library` tree mutations, `library-notes` note ops) the envelope libraryId picks WHICH library's state the bulk operates on, and every sub-op libraryId must match it. For collection scopes (`library-list`, `library-groups`) the envelope holds the whole collection as the draft and each sub-op's libraryId / groupId is an independent TARGET — cross-library / cross-group batches are SUPPORTED. CONCRETE ENVELOPE SHAPES: for tree mutations set envelope `scope:'tree'` (default), no libraryId. For library-list ops (delete_library, set_library_title, set_library_description, add_to_library) set envelope `scope:'library-list'`, no envelope libraryId; each sub-op carries its own target libraryId — batch as many DIFFERENT libraries as you want. For library-internal tree mutations (move_node within one library, set_tags on library children) set envelope `scope:'library' + libraryId:<that library>` and sub-ops share that one library. For note ops (delete_note, set_note_content): EVERY sub-op must carry its own explicit `scope` (`library-notes` or `main-tree-notes`); envelope scope is NOT auto-filled — set envelope `scope` to match. For library-group ops set envelope `scope:'library-groups'`. For bookmark ops set envelope `scope:'bookmarks'`. CROSS-LIBRARY WORKFLOWS — USE scope:'library-list': \"delete N libraries\", \"rename N libraries\", \"describe N libraries\" all express as ONE bulk_apply with `scope:'library-list'` and sub-op libraryIds as the per-target ids. Whole batch is one Ctrl+Z. NOT supported as one bulk: cross-library tree-internal moves (one library is source, another is destination — split into separate add_to_library calls). NESTING: bulk_apply cannot contain another bulk_apply. PER-SUB-OP CONFIRMATION: each destructive sub-op (delete_node, delete_live_node, delete_note, delete_library, delete_library_group with cascadeMembers:true) requires its OWN confirmedByUser:true field — the bulk_apply wrapper does NOT confer confirmation to sub-ops; obtain user approval for each destructive action individually. ERROR LOCATION: on failure, error.context.subOpIndex (and a \"Sub-op N:\" prefix in the message) identifies the failing sub-op — correct and resubmit just that one in a new bulk_apply, or fix and resubmit the whole batch."
    }
  ]
}
