diff --git a/apps/controller-ext/src/actions/bookmark/UpdateBookmarkAction.ts b/apps/controller-ext/src/actions/bookmark/UpdateBookmarkAction.ts new file mode 100644 index 00000000..7739087c --- /dev/null +++ b/apps/controller-ext/src/actions/bookmark/UpdateBookmarkAction.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { z } from 'zod' +import { BookmarkAdapter } from '@/adapters/BookmarkAdapter' +import { ActionHandler } from '../ActionHandler' + +// Input schema +const UpdateBookmarkInputSchema = z.object({ + id: z.string().describe('Bookmark ID to update'), + title: z.string().optional().describe('New bookmark title'), + url: z.string().url().optional().describe('New bookmark URL'), +}) + +// Output schema +const UpdateBookmarkOutputSchema = z.object({ + id: z.string().describe('Bookmark ID'), + title: z.string().describe('Updated bookmark title'), + url: z.string().optional().describe('Updated bookmark URL'), +}) + +type UpdateBookmarkInput = z.infer +type UpdateBookmarkOutput = z.infer + +/** + * UpdateBookmarkAction - Update a bookmark's title or URL + * + * Updates an existing bookmark with new title and/or URL. + * + * Input: + * - id: Bookmark ID to update + * - title (optional): New title for the bookmark + * - url (optional): New URL for the bookmark + * + * Output: + * - id: Bookmark ID + * - title: Updated title + * - url: Updated URL + * + * Usage: + * Update a bookmark's title or URL (at least one must be provided). + * + * Example: + * { + * "id": "123", + * "title": "New Title", + * "url": "https://www.example.com" + * } + * // Returns: { id: "123", title: "New Title", url: "https://www.example.com" } + */ +export class UpdateBookmarkAction extends ActionHandler< + UpdateBookmarkInput, + UpdateBookmarkOutput +> { + readonly inputSchema = UpdateBookmarkInputSchema + private bookmarkAdapter = new BookmarkAdapter() + + async execute(input: UpdateBookmarkInput): Promise { + const changes: { title?: string; url?: string } = {} + + if (input.title !== undefined) { + changes.title = input.title + } + if (input.url !== undefined) { + changes.url = input.url + } + + if (Object.keys(changes).length === 0) { + throw new Error('At least one of title or url must be provided') + } + + const updated = await this.bookmarkAdapter.updateBookmark(input.id, changes) + + return { + id: updated.id, + title: updated.title, + url: updated.url, + } + } +} diff --git a/apps/controller-ext/src/background/BrowserOSController.ts b/apps/controller-ext/src/background/BrowserOSController.ts index 5a59cd25..f023588a 100644 --- a/apps/controller-ext/src/background/BrowserOSController.ts +++ b/apps/controller-ext/src/background/BrowserOSController.ts @@ -11,6 +11,7 @@ import { GetBookmarksAction } from '@/actions/bookmark/GetBookmarksAction' import { MoveBookmarkAction } from '@/actions/bookmark/MoveBookmarkAction' import { RemoveBookmarkAction } from '@/actions/bookmark/RemoveBookmarkAction' import { RemoveBookmarkTreeAction } from '@/actions/bookmark/RemoveBookmarkTreeAction' +import { UpdateBookmarkAction } from '@/actions/bookmark/UpdateBookmarkAction' import { CaptureScreenshotAction } from '@/actions/browser/CaptureScreenshotAction' import { CaptureScreenshotPointerAction } from '@/actions/browser/CaptureScreenshotPointerAction' import { ClearAction } from '@/actions/browser/ClearAction' @@ -204,6 +205,7 @@ export class BrowserOSController { this.actionRegistry.register('getBookmarks', new GetBookmarksAction()) this.actionRegistry.register('createBookmark', new CreateBookmarkAction()) this.actionRegistry.register('removeBookmark', new RemoveBookmarkAction()) + this.actionRegistry.register('updateBookmark', new UpdateBookmarkAction()) this.actionRegistry.register( 'createBookmarkFolder', new CreateBookmarkFolderAction(), diff --git a/apps/server/src/agent/prompt.ts b/apps/server/src/agent/prompt.ts index 8c2ec72b..052fe69d 100644 --- a/apps/server/src/agent/prompt.ts +++ b/apps/server/src/agent/prompt.ts @@ -135,10 +135,25 @@ When user asks to "organize tabs", "group tabs", or "clean up tabs": Use when built-in tools cannot accomplish the task. -## Bookmarks & History -- \`browser_get_bookmarks(folderId?)\` - Get bookmarks -- \`browser_create_bookmark(title, url, parentId?)\` - Create bookmark +## Bookmarks +- \`browser_get_bookmarks(folderId?)\` - Get all bookmarks or from specific folder +- \`browser_create_bookmark(title, url, parentId?)\` - Create bookmark (use parentId to place in folder) +- \`browser_update_bookmark(bookmarkId, title?, url?)\` - Edit bookmark title or URL - \`browser_remove_bookmark(bookmarkId)\` - Delete bookmark +- \`browser_create_bookmark_folder(title, parentId?)\` - Create folder (returns folderId to use as parentId) +- \`browser_get_bookmark_children(folderId)\` - Get contents of a folder +- \`browser_move_bookmark(bookmarkId, parentId?, index?)\` - Move bookmark or folder to new location +- \`browser_remove_bookmark_tree(folderId, confirm)\` - Delete folder and all contents + +**Organizing bookmarks into folders:** +\`\`\` +1. browser_create_bookmark_folder("Work") → folderId: "123" +2. browser_create_bookmark("Docs", "https://docs.google.com", parentId="123") +3. browser_move_bookmark(existingBookmarkId, parentId="123") +\`\`\` +Use \`browser_get_bookmarks\` to find existing folder IDs, or create new folders with \`browser_create_bookmark_folder\`. + +## History - \`browser_search_history(query, maxResults?)\` - Search history - \`browser_get_recent_history(count?)\` - Recent history diff --git a/apps/server/src/tools/controller-based/registry.ts b/apps/server/src/tools/controller-based/registry.ts index 56235627..ca488906 100644 --- a/apps/server/src/tools/controller-based/registry.ts +++ b/apps/server/src/tools/controller-based/registry.ts @@ -20,6 +20,7 @@ export { moveBookmark, removeBookmark, removeBookmarkTree, + updateBookmark, } from './tools/bookmarks' // Content Extraction export { getPageContent } from './tools/content' @@ -77,6 +78,7 @@ import { moveBookmark, removeBookmark, removeBookmarkTree, + updateBookmark, } from './tools/bookmarks' import { getPageContent } from './tools/content' import { clickCoordinates, typeAtCoordinates } from './tools/coordinates' @@ -106,7 +108,7 @@ import { } from './tools/tab-management' import { createWindow } from './tools/window-management' -// Array export for convenience (36 tools total) +// Array export for convenience (37 tools total) export const allControllerTools = [ getActiveTab, listTabs, @@ -142,6 +144,7 @@ export const allControllerTools = [ getBookmarkChildren, moveBookmark, removeBookmarkTree, + updateBookmark, searchHistory, getRecentHistory, createWindow, diff --git a/apps/server/src/tools/controller-based/tools/bookmarks.ts b/apps/server/src/tools/controller-based/tools/bookmarks.ts index d108f016..fda5bd46 100644 --- a/apps/server/src/tools/controller-based/tools/bookmarks.ts +++ b/apps/server/src/tools/controller-based/tools/bookmarks.ts @@ -60,7 +60,8 @@ export const getBookmarks = defineTool({ export const createBookmark = defineTool({ name: 'browser_create_bookmark', - description: 'Create a new bookmark', + description: + 'Create a new bookmark. Use parentId to place it inside an existing folder or a newly created one.', annotations: { category: ToolCategories.BOOKMARKS, readOnlyHint: false, @@ -68,7 +69,12 @@ export const createBookmark = defineTool({ schema: { title: z.string().describe('Bookmark title'), url: z.string().describe('URL to bookmark'), - parentId: z.string().optional().describe('Optional parent folder ID'), + parentId: z + .string() + .optional() + .describe( + 'Folder ID to create bookmark in (from browser_get_bookmarks or browser_create_bookmark_folder)', + ), windowId: z.number().optional().describe('Window ID for routing'), }, handler: async (request, response, context) => { @@ -116,13 +122,51 @@ export const removeBookmark = defineTool({ }, }) +export const updateBookmark = defineTool({ + name: 'browser_update_bookmark', + description: 'Update a bookmark title or URL', + annotations: { + category: ToolCategories.BOOKMARKS, + readOnlyHint: false, + }, + schema: { + bookmarkId: z.string().describe('Bookmark ID to update'), + title: z.string().optional().describe('New title for the bookmark'), + url: z.string().url().optional().describe('New URL for the bookmark'), + windowId: z.number().optional().describe('Window ID for routing'), + }, + handler: async (request, response, context) => { + const { bookmarkId, title, url, windowId } = request.params as { + bookmarkId: string + title?: string + url?: string + windowId?: number + } + + const result = await context.executeAction('updateBookmark', { + id: bookmarkId, + title, + url, + windowId, + }) + const data = result as { id: string; title: string; url?: string } + + response.appendResponseLine(`Updated bookmark: ${data.title}`) + if (data.url) { + response.appendResponseLine(`URL: ${data.url}`) + } + response.appendResponseLine(`ID: ${data.id}`) + }, +}) + export const createBookmarkFolder = defineTool< z.ZodRawShape, Context, Response >({ name: 'browser_create_bookmark_folder', - description: 'Create a new bookmark folder', + description: + 'Create a new bookmark folder. Returns folderId to use as parentId when creating or moving bookmarks into this folder.', annotations: { category: ToolCategories.BOOKMARKS, readOnlyHint: false, @@ -214,14 +258,20 @@ export const getBookmarkChildren = defineTool( export const moveBookmark = defineTool({ name: 'browser_move_bookmark', - description: 'Move a bookmark or folder to a new location', + description: + 'Move a bookmark or folder into a different folder (existing or newly created).', annotations: { category: ToolCategories.BOOKMARKS, readOnlyHint: false, }, schema: { bookmarkId: z.string().describe('Bookmark or folder ID to move'), - parentId: z.string().optional().describe('New parent folder ID'), + parentId: z + .string() + .optional() + .describe( + 'Destination folder ID (from browser_get_bookmarks or browser_create_bookmark_folder)', + ), index: z .number() .int() diff --git a/apps/server/tests/tools/controller-based/bookmarks.test.ts b/apps/server/tests/tools/controller-based/bookmarks.test.ts index 0666fa9f..87d985dc 100644 --- a/apps/server/tests/tools/controller-based/bookmarks.test.ts +++ b/apps/server/tests/tools/controller-based/bookmarks.test.ts @@ -366,6 +366,190 @@ describe('MCP Controller Bookmark Tools', () => { }, 30000) }) + describe('browser_update_bookmark - Success Cases', () => { + it('tests that updating bookmark title succeeds', async () => { + await withMcpServer(async (client) => { + // First create a bookmark + const createResult = await client.callTool({ + name: 'browser_create_bookmark', + arguments: { + title: 'Original Title', + url: 'https://update.example.com', + }, + }) + + const createText = createResult.content.find((c) => c.type === 'text') + const idMatch = createText.text.match(/ID: (\d+)/) + const bookmarkId = idMatch ? idMatch[1] : '1' + + // Update the title + const result = await client.callTool({ + name: 'browser_update_bookmark', + arguments: { + bookmarkId, + title: 'Updated Title', + }, + }) + + console.log('\n=== Update Bookmark Title Response ===') + console.log(JSON.stringify(result, null, 2)) + + assert.ok(!result.isError, 'Should succeed') + + const textContent = result.content.find((c) => c.type === 'text') + assert.ok(textContent, 'Should have text content') + assert.ok( + textContent.text.includes('Updated bookmark'), + 'Should confirm update', + ) + assert.ok( + textContent.text.includes('Updated Title'), + 'Should include new title', + ) + + // Cleanup + await client.callTool({ + name: 'browser_remove_bookmark', + arguments: { bookmarkId }, + }) + }) + }, 30000) + + it('tests that updating bookmark URL succeeds', async () => { + await withMcpServer(async (client) => { + // First create a bookmark + const createResult = await client.callTool({ + name: 'browser_create_bookmark', + arguments: { + title: 'URL Test', + url: 'https://old-url.example.com', + }, + }) + + const createText = createResult.content.find((c) => c.type === 'text') + const idMatch = createText.text.match(/ID: (\d+)/) + const bookmarkId = idMatch ? idMatch[1] : '1' + + // Update the URL + const result = await client.callTool({ + name: 'browser_update_bookmark', + arguments: { + bookmarkId, + url: 'https://new-url.example.com', + }, + }) + + console.log('\n=== Update Bookmark URL Response ===') + console.log(JSON.stringify(result, null, 2)) + + assert.ok(!result.isError, 'Should succeed') + + const textContent = result.content.find((c) => c.type === 'text') + assert.ok(textContent, 'Should have text content') + assert.ok( + textContent.text.includes('https://new-url.example.com'), + 'Should include new URL', + ) + + // Cleanup + await client.callTool({ + name: 'browser_remove_bookmark', + arguments: { bookmarkId }, + }) + }) + }, 30000) + + it('tests that updating both title and URL succeeds', async () => { + await withMcpServer(async (client) => { + // First create a bookmark + const createResult = await client.callTool({ + name: 'browser_create_bookmark', + arguments: { + title: 'Original', + url: 'https://original.example.com', + }, + }) + + const createText = createResult.content.find((c) => c.type === 'text') + const idMatch = createText.text.match(/ID: (\d+)/) + const bookmarkId = idMatch ? idMatch[1] : '1' + + // Update both + const result = await client.callTool({ + name: 'browser_update_bookmark', + arguments: { + bookmarkId, + title: 'New Title', + url: 'https://new.example.com', + }, + }) + + console.log('\n=== Update Bookmark Both Response ===') + console.log(JSON.stringify(result, null, 2)) + + assert.ok(!result.isError, 'Should succeed') + + const textContent = result.content.find((c) => c.type === 'text') + assert.ok(textContent, 'Should have text content') + assert.ok( + textContent.text.includes('New Title'), + 'Should include new title', + ) + assert.ok( + textContent.text.includes('https://new.example.com'), + 'Should include new URL', + ) + + // Cleanup + await client.callTool({ + name: 'browser_remove_bookmark', + arguments: { bookmarkId }, + }) + }) + }, 30000) + }) + + describe('browser_update_bookmark - Error Handling', () => { + it('tests that missing bookmarkId is rejected', async () => { + await withMcpServer(async (client) => { + try { + await client.callTool({ + name: 'browser_update_bookmark', + arguments: { title: 'Test' }, + }) + assert.fail('Should have thrown validation error') + } catch (error) { + console.log('\n=== Update Bookmark Missing ID Error ===') + console.log(error.message) + + assert.ok( + error.message.includes('Invalid arguments') || + error.message.includes('Required'), + 'Should reject with validation error', + ) + } + }) + }, 30000) + + it('tests that invalid bookmarkId is handled', async () => { + await withMcpServer(async (client) => { + const result = await client.callTool({ + name: 'browser_update_bookmark', + arguments: { + bookmarkId: '999999999', + title: 'Test', + }, + }) + + console.log('\n=== Update Invalid Bookmark Response ===') + console.log(JSON.stringify(result, null, 2)) + + // Should error with invalid ID + assert.ok(result, 'Should return a result') + }) + }, 30000) + }) + describe('Bookmark Tools - Response Structure Validation', () => { it('tests that bookmark tools return valid MCP response structure', async () => { await withMcpServer(async (client) => {