mcpbrowser 0.3.35 → 0.3.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,256 @@
1
+ /**
2
+ * cli/registry.js — Single source of truth for all CLI commands.
3
+ *
4
+ * To add a new CLI command:
5
+ * 1. Import the MCP TOOL definition and action function
6
+ * 2. Add an entry to CLI_REGISTRY below
7
+ * 3. Done — help, routing, flag mapping, coercion all auto-update
8
+ */
9
+
10
+ import { writeFileSync } from 'fs';
11
+
12
+ import { fetchPage, FETCH_WEBPAGE_TOOL } from '../actions/fetch-page.js';
13
+ import { clickElement, CLICK_ELEMENT_TOOL } from '../actions/click-element.js';
14
+ import { typeText, TYPE_TEXT_TOOL } from '../actions/type-text.js';
15
+ import { executeJavascript, EXECUTE_JAVASCRIPT_TOOL } from '../actions/execute-javascript.js';
16
+ import { getCurrentHtml, GET_CURRENT_HTML_TOOL } from '../actions/get-current-html.js';
17
+ import { takeScreenshot, TAKE_SCREENSHOT_TOOL } from '../actions/take-screenshot.js';
18
+ import { scrollPage, SCROLL_PAGE_TOOL } from '../actions/scroll-page.js';
19
+ import { navigateHistory, NAVIGATE_HISTORY_TOOL } from '../actions/navigate-history.js';
20
+ import { closeTab, CLOSE_TAB_TOOL } from '../actions/close-tab.js';
21
+
22
+ import { htmlToText, getStructured, getPrimaryText } from './utils.js';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Factory for back/forward (deduplicated)
26
+ // ---------------------------------------------------------------------------
27
+
28
+ function makeHistoryCommand(direction) {
29
+ return {
30
+ cmd: direction,
31
+ tool: NAVIGATE_HISTORY_TOOL,
32
+ action: navigateHistory,
33
+ requiresFetch: true,
34
+ flagMap: { raw: '_raw' },
35
+ flagDefaults: {},
36
+ buildParams: (url, flags) => ({
37
+ url,
38
+ direction,
39
+ returnHtml: true,
40
+ removeUnnecessaryHTML: !flags.raw,
41
+ }),
42
+ formatOutput: (mcp, flags) => {
43
+ const s = getStructured(mcp);
44
+ const out = {};
45
+ if (s.previousUrl || s.currentUrl) {
46
+ out.stderr = `${s.previousUrl || '?'} → ${s.currentUrl || '?'}`;
47
+ }
48
+ if (s.html) out.stdout = flags.raw ? s.html : htmlToText(s.html);
49
+ return out;
50
+ },
51
+ examples: [`mcpbrowser ${direction} https://example.com`],
52
+ };
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // CLI_REGISTRY
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export const CLI_REGISTRY = [
60
+ {
61
+ cmd: 'fetch',
62
+ tool: FETCH_WEBPAGE_TOOL,
63
+ action: fetchPage,
64
+ requiresFetch: false,
65
+ flagMap: { wait: 'postLoadWait', raw: '_raw' },
66
+ flagDefaults: {},
67
+ buildParams: (url, flags) => ({
68
+ url,
69
+ browser: flags.browser || '',
70
+ removeUnnecessaryHTML: !flags.raw,
71
+ postLoadWait: flags.wait ? Number(flags.wait) : 0
72
+ }),
73
+ formatOutput: (mcp, flags) => {
74
+ const html = getStructured(mcp).html || getPrimaryText(mcp);
75
+ return { stdout: flags.raw ? html : htmlToText(html) };
76
+ },
77
+ examples: [
78
+ 'mcpbrowser fetch https://eng.ms/docs/my-page',
79
+ 'mcpbrowser fetch https://portal.azure.com --browser edge --wait 5000',
80
+ 'mcpbrowser fetch https://github.com --raw',
81
+ ],
82
+ cliNote: '--raw Output full HTML instead of extracted text',
83
+ },
84
+
85
+ {
86
+ cmd: 'screenshot',
87
+ tool: TAKE_SCREENSHOT_TOOL,
88
+ action: takeScreenshot,
89
+ requiresFetch: true,
90
+ autoFetch: true, // screenshot auto-fetches the page first
91
+ flagMap: { 'full-page': 'fullPage' },
92
+ buildParams: (url, flags) => ({
93
+ url,
94
+ fullPage: !!flags['full-page']
95
+ }),
96
+ formatOutput: (mcp, flags) => {
97
+ const base64 = getStructured(mcp).screenshotBase64;
98
+ if (!base64) return { error: 'No screenshot data returned' };
99
+ const outFile = flags.output || 'screenshot.png';
100
+ writeFileSync(outFile, Buffer.from(base64, 'base64'));
101
+ return { stderr: `Screenshot saved to ${outFile}` };
102
+ },
103
+ examples: [
104
+ 'mcpbrowser screenshot https://example.com --output page.png',
105
+ 'mcpbrowser screenshot https://dashboard.corp.com --full-page',
106
+ ],
107
+ cliNote: '--output <path> File path to save (default: screenshot.png)',
108
+ },
109
+
110
+ {
111
+ cmd: 'click',
112
+ tool: CLICK_ELEMENT_TOOL,
113
+ action: clickElement,
114
+ requiresFetch: true,
115
+ flagMap: {},
116
+ buildParams: (url, flags) => ({
117
+ url,
118
+ selector: flags.selector || undefined,
119
+ text: flags.text || undefined,
120
+ returnHtml: flags.returnHtml !== 'false',
121
+ removeUnnecessaryHTML: true,
122
+ postClickWait: flags.postClickWait ? Number(flags.postClickWait) : 1000,
123
+ }),
124
+ validate: (flags) => {
125
+ if (!flags.selector && !flags.text) return '--selector or --text is required for click';
126
+ },
127
+ formatOutput: (mcp) => {
128
+ const html = getStructured(mcp).html;
129
+ return { stdout: html ? htmlToText(html) : getPrimaryText(mcp) };
130
+ },
131
+ examples: [
132
+ 'mcpbrowser click https://example.com --selector "#login-btn"',
133
+ 'mcpbrowser click https://example.com --text "Sign In"',
134
+ ],
135
+ },
136
+
137
+ {
138
+ cmd: 'type',
139
+ tool: TYPE_TEXT_TOOL,
140
+ action: typeText,
141
+ requiresFetch: true,
142
+ flagMap: {},
143
+ buildParams: (url, flags) => ({
144
+ url,
145
+ fields: [{ selector: flags.selector, text: flags.text }],
146
+ returnHtml: false
147
+ }),
148
+ validate: (flags) => {
149
+ if (!flags.selector || !flags.text) return '--selector and --text are required for type';
150
+ },
151
+ formatOutput: (mcp) => ({ stdout: getPrimaryText(mcp) }),
152
+ examples: [
153
+ 'mcpbrowser type https://example.com --selector "#search" --text "query"',
154
+ 'mcpbrowser type https://login.com --selector "input[name=email]" --text "user@corp.com"',
155
+ ],
156
+ cliNote: 'CLI shorthand: --selector + --text fills a single field',
157
+ },
158
+
159
+ {
160
+ cmd: 'exec',
161
+ tool: EXECUTE_JAVASCRIPT_TOOL,
162
+ action: executeJavascript,
163
+ requiresFetch: true,
164
+ flagMap: {},
165
+ buildParams: (url, flags) => ({
166
+ url,
167
+ script: flags.script,
168
+ timeoutMs: flags.timeoutMs ? Number(flags.timeoutMs) : 30000,
169
+ returnType: flags.returnType || 'json',
170
+ }),
171
+ validate: (flags) => {
172
+ if (!flags.script) return '--script is required for exec';
173
+ },
174
+ formatOutput: (mcp) => {
175
+ const r = getStructured(mcp).result;
176
+ if (r !== undefined && r !== null) {
177
+ return { stdout: typeof r === 'string' ? r : JSON.stringify(r, null, 2) };
178
+ }
179
+ return { stdout: getPrimaryText(mcp) };
180
+ },
181
+ examples: [
182
+ 'mcpbrowser exec https://example.com --script "document.title"',
183
+ 'mcpbrowser exec https://mail.google.com --script "[...document.querySelectorAll(\'.zA\')].map(r=>r.textContent)"',
184
+ ],
185
+ },
186
+
187
+ {
188
+ cmd: 'html',
189
+ tool: GET_CURRENT_HTML_TOOL,
190
+ action: getCurrentHtml,
191
+ requiresFetch: true,
192
+ flagMap: { raw: '_raw' },
193
+ buildParams: (url, flags) => ({
194
+ url,
195
+ removeUnnecessaryHTML: !flags.raw,
196
+ }),
197
+ formatOutput: (mcp) => ({ stdout: getStructured(mcp).html || getPrimaryText(mcp) }),
198
+ examples: [
199
+ 'mcpbrowser html https://example.com',
200
+ 'mcpbrowser html https://example.com --raw',
201
+ ],
202
+ cliNote: '--raw Output raw HTML without cleanup',
203
+ },
204
+
205
+ {
206
+ cmd: 'scroll',
207
+ tool: SCROLL_PAGE_TOOL,
208
+ action: scrollPage,
209
+ requiresFetch: true,
210
+ flagMap: {},
211
+ buildParams: (url, flags) => {
212
+ const params = { url };
213
+ if (flags.selector) { params.selector = flags.selector; }
214
+ else if (flags.x !== undefined || flags.y !== undefined) {
215
+ if (flags.x !== undefined) params.x = Number(flags.x);
216
+ if (flags.y !== undefined) params.y = Number(flags.y);
217
+ } else {
218
+ params.direction = flags.direction || 'down';
219
+ if (flags.amount) params.amount = Number(flags.amount);
220
+ }
221
+ return params;
222
+ },
223
+ formatOutput: (mcp) => {
224
+ const s = getStructured(mcp);
225
+ return {
226
+ stdout: JSON.stringify({
227
+ scrollX: s.scrollX, scrollY: s.scrollY,
228
+ pageWidth: s.pageWidth, pageHeight: s.pageHeight,
229
+ viewportWidth: s.viewportWidth, viewportHeight: s.viewportHeight
230
+ }, null, 2)
231
+ };
232
+ },
233
+ examples: [
234
+ 'mcpbrowser scroll https://example.com --direction down --amount 1000',
235
+ 'mcpbrowser scroll https://example.com --selector "#footer"',
236
+ 'mcpbrowser scroll https://example.com --x 0 --y 0',
237
+ ],
238
+ },
239
+
240
+ makeHistoryCommand('back'),
241
+ makeHistoryCommand('forward'),
242
+
243
+ {
244
+ cmd: 'close',
245
+ tool: CLOSE_TAB_TOOL,
246
+ action: closeTab,
247
+ requiresFetch: false,
248
+ flagMap: {},
249
+ buildParams: (url) => ({ url }),
250
+ formatOutput: (mcp) => ({ stdout: getPrimaryText(mcp) }),
251
+ examples: ['mcpbrowser close https://example.com'],
252
+ },
253
+ ];
254
+
255
+ // Lookup map for O(1) command resolution
256
+ export const CMD_MAP = new Map(CLI_REGISTRY.map(entry => [entry.cmd, entry]));
@@ -0,0 +1,39 @@
1
+ /**
2
+ * cli/utils.js — Shared utilities for CLI output formatting and safe data access.
3
+ */
4
+
5
+ /**
6
+ * Convert HTML to readable terminal text (lossy preview).
7
+ */
8
+ export function htmlToText(html) {
9
+ return html
10
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
11
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
12
+ .replace(/<br\s*\/?>/gi, '\n')
13
+ .replace(/<\/p>/gi, '\n\n')
14
+ .replace(/<\/div>/gi, '\n')
15
+ .replace(/<\/li>/gi, '\n')
16
+ .replace(/<\/h[1-6]>/gi, '\n\n')
17
+ .replace(/<[^>]+>/g, '')
18
+ .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
19
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, ' ')
20
+ .replace(/[ \t]+/g, ' ')
21
+ .replace(/\n{3,}/g, '\n\n')
22
+ .trim();
23
+ }
24
+
25
+ /**
26
+ * Safely get the primary text content from an MCP result.
27
+ * Falls back to empty string if content is missing.
28
+ */
29
+ export function getPrimaryText(mcp) {
30
+ return mcp?.content?.[0]?.text ?? '';
31
+ }
32
+
33
+ /**
34
+ * Safely get structuredContent from an MCP result.
35
+ * Returns an empty object if missing, so callers can destructure safely.
36
+ */
37
+ export function getStructured(mcp) {
38
+ return mcp?.structuredContent ?? {};
39
+ }
@@ -263,14 +263,12 @@ export function detectPlugins(url, html) {
263
263
  if (match && match.matched) {
264
264
  const confidence = typeof match.confidence === 'number' ? match.confidence : 1.0;
265
265
 
266
- // Build nextSteps from plugin info
266
+ // Build nextSteps from plugin info — concise, actionable
267
267
  const info = plugin.getInfo();
268
- const topActions = (info.actions || []).slice(0, 3);
269
- const actionSummary = topActions.map(a => a.name).join(', ');
268
+ const recommendationText = info.recommendation || info.description || 'Site-specific automation available.';
270
269
 
271
270
  const nextSteps = [
272
- `Plugin "${name}" detected use plugin_info({ plugin: '${name}' }) to see all available actions`,
273
- ...(actionSummary ? [`Top actions: ${actionSummary}. Use plugin_action({ plugin: '${name}', action: '<name>' }) to execute`] : [])
271
+ `Recommended: use "${name}" plugin to ${recommendationText.charAt(0).toLowerCase() + recommendationText.slice(1).replace(/\.$/, '')}. See recommendedPlugins for available actions.`
274
272
  ];
275
273
 
276
274
  results.push({ pluginName: name, confidence, nextSteps });
@@ -301,6 +299,29 @@ export function getPluginNextSteps(url, html) {
301
299
  return steps;
302
300
  }
303
301
 
302
+ /**
303
+ * Build the recommendedPlugins payload for structuredContent.
304
+ * Returns full plugin metadata including action catalog with params,
305
+ * so agents can call actions directly without needing plugin_info.
306
+ * @param {string} url - Current page URL
307
+ * @param {string} html - Extracted page HTML
308
+ * @returns {Array<{ plugin: string, recommendation: string, actions: Array, usage: string }>}
309
+ */
310
+ export function getRecommendedPlugins(url, html) {
311
+ const detections = detectPlugins(url, html);
312
+ return detections.map(d => {
313
+ const plugin = loadedPlugins.get(d.pluginName);
314
+ const info = plugin.getInfo();
315
+ const recommendationText = info.recommendation || info.description || 'Site-specific automation available.';
316
+ return {
317
+ plugin: d.pluginName,
318
+ recommendation: recommendationText,
319
+ actions: info.actions || [],
320
+ usage: `plugin_action({ plugin: '${d.pluginName}', action: '<name>', params: {...} })`
321
+ };
322
+ });
323
+ }
324
+
304
325
  // ============================================================================
305
326
  // ACCESSORS
306
327
  // ============================================================================
@@ -12,6 +12,9 @@ import { fileURLToPath } from 'url';
12
12
  import { readFileSync } from 'fs';
13
13
  import { dirname, join } from 'path';
14
14
 
15
+ // Import CLI mode
16
+ import { isCliMode, runCli } from './cli/index.js';
17
+
15
18
  // Import response classes
16
19
  import { ErrorResponse } from './core/responses.js';
17
20
  import logger, { attachServer as attachLoggerServer } from './core/logger.js';
@@ -203,6 +206,9 @@ export {
203
206
  scrollPage,
204
207
  navigateHistory,
205
208
  handleAcceptEula,
209
+ // CLI exports
210
+ isCliMode,
211
+ runCli,
206
212
  // Plugin system exports
207
213
  loadPlugins,
208
214
  getLoadedPlugins,
@@ -216,8 +222,20 @@ export {
216
222
  // Run the MCP server only if this is the main module (not imported for testing)
217
223
  if (import.meta.url === new URL(process.argv[1], 'file://').href ||
218
224
  fileURLToPath(import.meta.url) === process.argv[1]) {
219
- main().catch((err) => {
220
- logger.error(`Server failed: ${err.message}`);
221
- process.exit(1);
222
- });
225
+ const argv = process.argv.slice(2);
226
+ if (isCliMode(argv)) {
227
+ // CLI mode: run command and exit
228
+ runCli(argv).then((code) => {
229
+ process.exit(code);
230
+ }).catch((err) => {
231
+ process.stderr.write(`Error: ${err.message}\n`);
232
+ process.exit(1);
233
+ });
234
+ } else {
235
+ // MCP server mode (default): stdin/stdout JSON-RPC
236
+ main().catch((err) => {
237
+ logger.error(`Server failed: ${err.message}`);
238
+ process.exit(1);
239
+ });
240
+ }
223
241
  }
@@ -131,6 +131,7 @@ export function getActions() {
131
131
  */
132
132
  export function getInfo() {
133
133
  return {
134
+ recommendation: "Interact with example.test pages — list items and view item details.",
134
135
  description: "Example stub plugin for testing MCPBrowser's plugin system. Lists items and retrieves item details from example.test pages.",
135
136
  targetPages: ["Example test page (example.test)"],
136
137
  authFlow: "No authentication required — example.test is a test domain",
@@ -0,0 +1,185 @@
1
+ /**
2
+ * check-availability.js — Check whether a time slot is free or busy.
3
+ *
4
+ * Tier usage:
5
+ * T1: calendarNavigate + buildViewPath to navigate to day view for the date
6
+ * T3: extractVisibleEvents for event data extraction
7
+ * T4: EVENT_CHIP selector via waitForCalendar
8
+ */
9
+
10
+ import { ErrorResponse } from '../../../core/responses.js';
11
+ import logger from '../../../core/logger.js';
12
+ import {
13
+ checkPrecondition,
14
+ calendarNavigate,
15
+ buildViewPath,
16
+ waitForCalendar,
17
+ extractVisibleEvents,
18
+ GCalActionResponse
19
+ } from '../helpers.js';
20
+ import { EVENT_CHIP } from '../selectors.js';
21
+
22
+ /**
23
+ * Parse HH:MM time string to total minutes since midnight.
24
+ * @param {string} timeStr - Time in HH:MM format
25
+ * @returns {number} Minutes since midnight
26
+ */
27
+ function timeToMinutes(timeStr) {
28
+ const [h, m] = timeStr.split(':').map(Number);
29
+ return h * 60 + m;
30
+ }
31
+
32
+ /**
33
+ * Format minutes since midnight back to HH:MM.
34
+ * @param {number} minutes
35
+ * @returns {string}
36
+ */
37
+ function minutesToTime(minutes) {
38
+ const h = Math.floor(minutes / 60);
39
+ const m = minutes % 60;
40
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
41
+ }
42
+
43
+ /**
44
+ * Check availability for a given date and time window.
45
+ * @param {object} opts
46
+ * @param {import('puppeteer-core').Page} opts.page
47
+ * @param {object} opts.params
48
+ * @param {string} opts.params.date - ISO date to check (required)
49
+ * @param {string} opts.params.startTime - Window start time in HH:MM (required)
50
+ * @param {string} opts.params.endTime - Window end time in HH:MM (required)
51
+ * @returns {Promise<GCalActionResponse|ErrorResponse>}
52
+ */
53
+ export async function checkAvailability({ page, params }) {
54
+ // Validate required params
55
+ if (!params.date) {
56
+ return new ErrorResponse(
57
+ 'The "date" parameter is required to check availability.',
58
+ ['Provide a date: check_availability({ date: "2026-04-10", startTime: "09:00", endTime: "17:00" })']
59
+ );
60
+ }
61
+ if (!params.startTime) {
62
+ return new ErrorResponse(
63
+ 'The "startTime" parameter is required to check availability.',
64
+ ['Provide a start time: check_availability({ date: "2026-04-10", startTime: "09:00", endTime: "17:00" })']
65
+ );
66
+ }
67
+ if (!params.endTime) {
68
+ return new ErrorResponse(
69
+ 'The "endTime" parameter is required to check availability.',
70
+ ['Provide an end time: check_availability({ date: "2026-04-10", startTime: "09:00", endTime: "17:00" })']
71
+ );
72
+ }
73
+
74
+ // Validate startTime < endTime
75
+ const windowStart = timeToMinutes(params.startTime);
76
+ const windowEnd = timeToMinutes(params.endTime);
77
+ if (windowStart >= windowEnd) {
78
+ return new ErrorResponse(
79
+ `startTime (${params.startTime}) must be earlier than endTime (${params.endTime}).`,
80
+ ['Ensure the start time is before the end time, e.g. startTime: "09:00", endTime: "17:00"']
81
+ );
82
+ }
83
+
84
+ // Precondition: must be on Google Calendar
85
+ const pre = await checkPrecondition(page, 'on_calendar');
86
+ if (!pre.met) {
87
+ return new ErrorResponse(pre.error, [
88
+ pre.suggestion || "Use fetch_webpage({ url: 'https://calendar.google.com' }) to open Google Calendar first."
89
+ ]);
90
+ }
91
+
92
+ // T1: Navigate to the day view for the specified date
93
+ const path = buildViewPath('day', params.date);
94
+ await calendarNavigate(page, path);
95
+ logger.debug(`checkAvailability: navigated to day view for ${params.date}`);
96
+
97
+ // Wait for event chips (or empty day)
98
+ let events = [];
99
+ try {
100
+ await waitForCalendar(page, EVENT_CHIP);
101
+ events = await extractVisibleEvents(page, 100);
102
+ } catch {
103
+ // No events — the entire window is free
104
+ logger.debug('checkAvailability: no events found, entire window is free');
105
+ }
106
+
107
+ // Filter events that overlap with the requested time window
108
+ const busySlots = [];
109
+ for (const event of events) {
110
+ if (event.allDay) {
111
+ // All-day events occupy the full day but don't block time slots
112
+ continue;
113
+ }
114
+
115
+ // Parse event times (best-effort from extracted data)
116
+ if (event.startTime && event.endTime) {
117
+ const eventStart = timeToMinutes(event.startTime);
118
+ const eventEnd = timeToMinutes(event.endTime);
119
+
120
+ // Check overlap: event overlaps window if event starts before window ends
121
+ // AND event ends after window starts
122
+ if (eventStart < windowEnd && eventEnd > windowStart) {
123
+ busySlots.push({
124
+ title: event.title,
125
+ startTime: event.startTime,
126
+ endTime: event.endTime
127
+ });
128
+ }
129
+ }
130
+ }
131
+
132
+ // Compute free slots within the window
133
+ const freeSlots = [];
134
+ // Sort busy slots by start time
135
+ busySlots.sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime));
136
+
137
+ let cursor = windowStart;
138
+ for (const busy of busySlots) {
139
+ const busyStart = timeToMinutes(busy.startTime);
140
+ const busyEnd = timeToMinutes(busy.endTime);
141
+
142
+ if (cursor < busyStart) {
143
+ freeSlots.push({
144
+ startTime: minutesToTime(cursor),
145
+ endTime: minutesToTime(Math.min(busyStart, windowEnd)),
146
+ durationMinutes: Math.min(busyStart, windowEnd) - cursor
147
+ });
148
+ }
149
+ cursor = Math.max(cursor, busyEnd);
150
+ }
151
+
152
+ // Final free slot after all busy periods
153
+ if (cursor < windowEnd) {
154
+ freeSlots.push({
155
+ startTime: minutesToTime(cursor),
156
+ endTime: minutesToTime(windowEnd),
157
+ durationMinutes: windowEnd - cursor
158
+ });
159
+ }
160
+
161
+ const isFree = busySlots.length === 0;
162
+ const summary = isFree
163
+ ? `${params.date} from ${params.startTime} to ${params.endTime} is completely free.`
164
+ : `${params.date} from ${params.startTime} to ${params.endTime} has ${busySlots.length} conflicting event(s) and ${freeSlots.length} free slot(s).`;
165
+
166
+ return new GCalActionResponse(
167
+ {
168
+ date: params.date,
169
+ windowStart: params.startTime,
170
+ windowEnd: params.endTime,
171
+ isFree,
172
+ busySlots,
173
+ freeSlots,
174
+ conflictCount: busySlots.length
175
+ },
176
+ summary,
177
+ isFree
178
+ ? [`Use create_event({ date: "${params.date}", startTime: "${params.startTime}" }) to book this slot`]
179
+ : [
180
+ 'Use create_event to book one of the free slots',
181
+ 'Use check_availability with a different time range to find open slots',
182
+ 'Use list_events to see all events on this date'
183
+ ]
184
+ );
185
+ }