rootr-cli 0.18.0 → 0.20.0

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.
@@ -2,33 +2,73 @@ import { readFileSync } from 'node:fs';
2
2
 
3
3
  import { z } from 'zod';
4
4
 
5
- import { jsonResult, errorResult, makeClient, requireWorkspace, resolveOptionalNodeId, NODE_TARGET_HINT } from './shared.js';
5
+ import {
6
+ jsonResult,
7
+ errorResult,
8
+ makeClient,
9
+ requireWorkspace,
10
+ resolveOptionalNodeId,
11
+ NODE_TARGET_HINT,
12
+ assertLocalFilesAllowed,
13
+ isHostedMcp,
14
+ localFileArg,
15
+ } from './shared.js';
6
16
  import { resolveTarget, requireResolved } from '../resolve.js';
7
- import { RootrApiError } from '../client.js';
17
+ import { RootrApiError, imageMarkdown } from '../client.js';
18
+
19
+ // ISSUE-222 — 원격 커넥터에서 filePath 를 쓰려다 막힌 호출자가 막다른 길에 서지 않게, 거절하면서
20
+ // 새 길(주소를 받아 직접 올리기)을 같이 알려 준다. 큰 파일은 base64Data 로도 못 싣는다.
21
+ const PRESIGN_HINT =
22
+ '큰 파일은 rootr_create_upload_url 로 올리기 주소를 받아 파일 바이트를 그 주소에 직접 PUT 한 뒤 ' +
23
+ 'rootr_complete_upload 를 부르세요 (파일 내용이 도구 호출에 실리지 않습니다).';
8
24
 
9
25
  /** Reads file bytes from a local path or a base64 string; exactly one must be given. */
10
26
  function readAttachmentBytes({ filePath, base64Data }) {
27
+ // SEC-MCP-FILES — 원격 커넥터에서는 서버 파일을 읽게 되므로 읽기 전에 거절한다.
28
+ if (isHostedMcp() && filePath) {
29
+ throw new RootrApiError('원격 커넥터에서는 filePath 를 쓸 수 없습니다.', {
30
+ hint:
31
+ 'filePath 는 MCP 서버가 도는 기계의 파일을 가리킵니다. 원격 커넥터에서는 그 기계가 Rootr 서버라 ' +
32
+ `쓸 수 없습니다 — 작은 파일은 내용을 base64Data 로 보내세요. ${PRESIGN_HINT}`,
33
+ });
34
+ }
35
+ assertLocalFilesAllowed(filePath); // 위에서 걸렀지만 두 겹으로 둔다
11
36
  if (filePath) return readFileSync(filePath);
12
37
  if (base64Data) return Buffer.from(base64Data, 'base64');
38
+ if (isHostedMcp()) {
39
+ throw new RootrApiError('base64Data 가 필요합니다 (원격 커넥터에서는 filePath 를 쓸 수 없습니다).', {
40
+ hint: PRESIGN_HINT,
41
+ });
42
+ }
13
43
  throw new RootrApiError('filePath 또는 base64Data 중 하나가 필요합니다.');
14
44
  }
15
45
 
16
- const uploadInputSchema = {
46
+ /** 첨부 id 모양(UUID). 주소 칸에 들어가는 값이라 다른 모양은 서버에 보내지 않는다. */
47
+ const ATTACHMENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
48
+
49
+ /** 빈 값·공백뿐인 값은 받지 않는다(값 자체는 건드리지 않는다 — 앞뒤 공백도 이름의 일부일 수 있다). */
50
+ const nonBlank = (what) => [(v) => typeof v === 'string' && v.trim().length > 0, `${what} must not be empty`];
51
+
52
+ // 등록 시점(원격이면 요청마다)에 만든다 — 원격 커넥터에서는 filePath 칸이 스키마에서 빠진다.
53
+ const buildUploadInputSchema = () => ({
17
54
  filename: z.string().describe('File name to store, e.g. "screenshot.png"'),
18
55
  mimeType: z.string().describe('MIME type, e.g. "image/png"'),
19
- filePath: z
20
- .string()
21
- .optional()
22
- .describe('Local filesystem path to the file to upload (the MCP server reads this itself, e.g. "/tmp/screenshot.png")'),
56
+ ...localFileArg(
57
+ z
58
+ .string()
59
+ .optional()
60
+ .describe('Local filesystem path to the file to upload (the MCP server reads this itself, e.g. "/tmp/screenshot.png")'),
61
+ ),
23
62
  base64Data: z
24
63
  .string()
25
64
  .optional()
26
65
  .describe('Base64-encoded file bytes; use this when the caller has the file in memory instead of on disk'),
27
66
  nodeId: z.string().optional().describe(`Document/node id${NODE_TARGET_HINT} to associate the attachment with (permission-checked)`),
28
67
  workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config'),
29
- };
68
+ });
30
69
 
31
70
  export function registerAttachmentTools(server) {
71
+ const uploadInputSchema = buildUploadInputSchema();
32
72
  server.registerTool(
33
73
  'rootr_upload_attachment',
34
74
  {
@@ -40,14 +80,19 @@ export function registerAttachmentTools(server) {
40
80
  '{ id, url, markdown, filename, mimeType, size } — `url` is the public, unauthenticated ' +
41
81
  '/attachments/{id}/raw link (renders in <img> tags and on published /p/ documents without login), and ' +
42
82
  '`markdown` is ready to paste into a document body (![alt](url)). For the common case of inserting an ' +
43
- 'image straight into a document, prefer rootr_attach_image_to_document instead.',
83
+ 'image straight into a document, prefer rootr_attach_image_to_document instead. For a large file (or ' +
84
+ 'over the remote connector, where filePath is not available), use rootr_create_upload_url → PUT → ' +
85
+ 'rootr_complete_upload instead of inlining the bytes as base64Data.',
44
86
  annotations: { readOnlyHint: false, destructiveHint: false },
45
87
  inputSchema: uploadInputSchema,
46
88
  },
47
89
  async ({ filename, mimeType, filePath, base64Data, nodeId, workspace }) => {
48
90
  try {
49
- const client = makeClient();
91
+ // ISSUE-61 — workspace 를 먼저 정하고 그 워크스페이스의 키로 클라이언트를 만든다. 인자 없이
92
+ // makeClient() 를 부르면 기본 키가 골라져, 키를 워크스페이스별로 둔 구성에서 403 이 났다.
93
+ // (원격 커넥터는 makeClient 가 연결된 워크스페이스와 다른 값을 거절한다.)
50
94
  const ws = requireWorkspace(workspace);
95
+ const client = makeClient(ws);
51
96
  nodeId = await resolveOptionalNodeId(client, nodeId);
52
97
  const data = readAttachmentBytes({ filePath, base64Data });
53
98
  const { id } = await client.uploadAttachment(ws, { filename, mimeType, nodeId, data });
@@ -55,7 +100,7 @@ export function registerAttachmentTools(server) {
55
100
  return jsonResult({
56
101
  id,
57
102
  url,
58
- markdown: `![${filename}](${url})`,
103
+ markdown: imageMarkdown(filename, url),
59
104
  filename,
60
105
  mimeType,
61
106
  size: data.length,
@@ -81,7 +126,7 @@ export function registerAttachmentTools(server) {
81
126
  target: z.string().describe('Document path (starting with "/") or node id to insert the image into'),
82
127
  filename: uploadInputSchema.filename,
83
128
  mimeType: uploadInputSchema.mimeType,
84
- filePath: uploadInputSchema.filePath,
129
+ ...(uploadInputSchema.filePath ? { filePath: uploadInputSchema.filePath } : {}),
85
130
  base64Data: uploadInputSchema.base64Data,
86
131
  alt: z.string().optional().describe('Alt text / caption for the image (defaults to filename)'),
87
132
  underHeading: z
@@ -93,14 +138,14 @@ export function registerAttachmentTools(server) {
93
138
  },
94
139
  async ({ target, filename, mimeType, filePath, base64Data, alt, underHeading, workspace }) => {
95
140
  try {
96
- const client = makeClient();
97
- const ws = requireWorkspace(workspace);
141
+ const ws = requireWorkspace(workspace); // ISSUE-61 — 위와 같다: 워크스페이스 → 그 키
142
+ const client = makeClient(ws);
98
143
  const resolved = await resolveTarget(client, target);
99
144
  const nodeId = requireResolved(resolved);
100
145
  const data = readAttachmentBytes({ filePath, base64Data });
101
146
  const { id } = await client.uploadAttachment(ws, { filename, mimeType, nodeId, data });
102
147
  const url = client.attachmentRawUrl(id);
103
- const markdown = `![${alt || filename}](${url})`;
148
+ const markdown = imageMarkdown(alt || filename, url);
104
149
  const result = await client.appendDocument(nodeId, { content: markdown, underHeading });
105
150
  return jsonResult({ id, url, markdown, appended: true, etag: result.etag });
106
151
  } catch (err) {
@@ -108,4 +153,97 @@ export function registerAttachmentTools(server) {
108
153
  }
109
154
  },
110
155
  );
156
+
157
+ // ISSUE-222 — rootr_upload_attachment/rootr_attach_image_to_document require
158
+ // the full file as base64Data over the remote connector (it can't read
159
+ // filePath), which breaks down for larger images. This gives the caller a
160
+ // short-lived presigned S3 URL instead: PUT the bytes there directly (works
161
+ // from anywhere with local disk access, no MCP message-size cost), then call
162
+ // rootr_complete_upload with the returned id.
163
+ server.registerTool(
164
+ 'rootr_create_upload_url',
165
+ {
166
+ title: 'Get a presigned URL to upload a file/image as a Rootr attachment',
167
+ description:
168
+ 'Get a short-lived presigned URL for uploading a file (screenshot, photo, diagram export, ...) to the ' +
169
+ 'Rootr (루터) workspace as an attachment. PUT the raw file bytes to `uploadUrl` yourself (Content-Type ' +
170
+ 'must match the `mimeType` you gave here) — this is the preferred path for larger files, since no file ' +
171
+ 'bytes travel through this tool call. After the PUT succeeds, call rootr_complete_upload with the ' +
172
+ 'returned `id` to mark the attachment ready and get its `url`/`markdown`.',
173
+ annotations: { readOnlyHint: false, destructiveHint: false },
174
+ inputSchema: {
175
+ filename: z.string().refine(...nonBlank('filename')).describe('File name to store, e.g. "diagram.png"'),
176
+ mimeType: z.string().refine(...nonBlank('mimeType')).describe('MIME type, e.g. "image/png"'),
177
+ size: z
178
+ .number()
179
+ .int()
180
+ .positive()
181
+ .describe('File size in bytes (max 75MB). The stored size is whatever you actually PUT to uploadUrl.'),
182
+ nodeId: z.string().optional().describe(`Document/node id${NODE_TARGET_HINT} to associate the attachment with (permission-checked)`),
183
+ workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config'),
184
+ },
185
+ },
186
+ async ({ filename, mimeType, size, nodeId, workspace }) => {
187
+ try {
188
+ const ws = requireWorkspace(workspace);
189
+ const client = makeClient(ws);
190
+ nodeId = await resolveOptionalNodeId(client, nodeId);
191
+ const { id, uploadUrl, expiresIn } = await client.presignAttachment(ws, { filename, mimeType, size, nodeId });
192
+ return jsonResult({
193
+ id,
194
+ uploadUrl,
195
+ expiresIn,
196
+ instructions: `PUT the raw file bytes to uploadUrl with header Content-Type: ${mimeType}, then call rootr_complete_upload with id "${id}".`,
197
+ });
198
+ } catch (err) {
199
+ return errorResult(err);
200
+ }
201
+ },
202
+ );
203
+
204
+ server.registerTool(
205
+ 'rootr_complete_upload',
206
+ {
207
+ title: 'Finish a presigned attachment upload',
208
+ description:
209
+ 'Call this after PUTting file bytes to the `uploadUrl` from rootr_create_upload_url. Verifies the ' +
210
+ 'upload landed in storage and marks the attachment READY. Returns { id, url, markdown } — `url` is the ' +
211
+ 'public /attachments/{id}/raw link and `markdown` is ready to paste into a document body.',
212
+ annotations: { readOnlyHint: false, destructiveHint: false },
213
+ inputSchema: {
214
+ // id 는 주소의 한 칸으로 들어간다. `../workspaces` 같은 값은 인코딩해 보내도 운영의 프록시가
215
+ // 풀어서 다른 길(/v1/workspaces/complete)로 보내고, 호출한 쪽은 "Cannot POST …" 라는 날것의
216
+ // 라우팅 오류를 받는다(운영 실측 2026-10-09). 받은 id 모양이 아니면 보내기 전에 거절한다.
217
+ id: z
218
+ .string()
219
+ .refine(...nonBlank('id'))
220
+ .refine(
221
+ (v) => typeof v !== 'string' || v.trim().length === 0 || ATTACHMENT_ID.test(v),
222
+ 'id must be the attachment id returned by rootr_create_upload_url (a UUID) — not a path, URL or filename',
223
+ )
224
+ .describe('The attachment id returned by rootr_create_upload_url'),
225
+ filename: z.string().optional().describe('Filename to use in the markdown alt text (defaults to the one given at presign time)'),
226
+ workspace: z.string().optional().describe('Workspace id; defaults to ROOTR_WORKSPACE/config'),
227
+ },
228
+ },
229
+ async ({ id, filename, workspace }) => {
230
+ try {
231
+ const ws = requireWorkspace(workspace);
232
+ const client = makeClient(ws);
233
+ const attachment = await client.completeAttachment(id);
234
+ const url = client.attachmentRawUrl(id);
235
+ const name = filename || attachment.filename;
236
+ return jsonResult({
237
+ id,
238
+ url,
239
+ markdown: imageMarkdown(name, url),
240
+ filename: name,
241
+ mimeType: attachment.mimeType,
242
+ size: attachment.size,
243
+ });
244
+ } catch (err) {
245
+ return errorResult(err);
246
+ }
247
+ },
248
+ );
111
249
  }
@@ -0,0 +1,403 @@
1
+ import { z } from 'zod';
2
+
3
+ import { jsonResult, errorResult, makeClient, resolveNodeId, NODE_TARGET_HINT } from './shared.js';
4
+
5
+ /**
6
+ * BOOKING — 공개한 표를 **예약 페이지**로 쓰는 도구들(창업자 지시 2026-09-23).
7
+ *
8
+ * 화면에서 하던 것을 에이전트도 그대로 할 수 있어야 한다는 요구다. 그래서 여기에는
9
+ * 켜기·규칙·확정 방식·물어볼 칸·구글 캘린더 고르기, 그리고 **방문자가 보는 빈 시간**까지
10
+ * 다 있다 — 마지막 것이 없으면 에이전트는 자기가 건 규칙이 실제로 어떻게 보이는지 모른 채
11
+ * "설정했습니다"라고만 말하게 된다.
12
+ *
13
+ * 구글 연결(동의)은 여기에 없다. 구글이 **브라우저에서 사람 손으로 한 번** 허용받기를
14
+ * 요구하기 때문이다. 대신 `rootr_connect_google_calendar` 가 그 주소를 만들어 주고,
15
+ * 허용한 뒤로는 목록 보기·고르기가 전부 도구로 된다.
16
+ */
17
+
18
+ const WEEKDAY_HINT =
19
+ '0=Sunday … 6=Saturday. Weekdays only is [1,2,3,4,5].';
20
+
21
+ const ASK_FIELD = z.object({
22
+ propId: z
23
+ .string()
24
+ .describe('Column id, or the column NAME as shown in the table (names are resolved for you).'),
25
+ required: z.boolean().optional().describe('Must the visitor fill this in? Default false.'),
26
+ });
27
+
28
+ /**
29
+ * 열어 둔 시간 중 **못 잡게 막는 구간**. 하루를 통째로 쉬는 것은 blockedDates 로 두고,
30
+ * 여기는 "매일 점심", "매주 월요일 오전", "10월 5일 오후만" 같은 시간 구간을 다룬다.
31
+ */
32
+ const BLOCK = z.object({
33
+ kind: z
34
+ .enum(['date', 'daily', 'weekly'])
35
+ .describe('date = that one day · daily = every day · weekly = every week on that weekday.'),
36
+ date: z.string().optional().describe('kind="date": the day, "YYYY-MM-DD".'),
37
+ weekday: z.number().optional().describe(`kind="weekly": which day. ${WEEKDAY_HINT}`),
38
+ start: z.string().optional().describe('"HH:mm". Leave out to block from the start of the day.'),
39
+ end: z.string().optional().describe('"HH:mm". Leave out to block until the end of the day.'),
40
+ label: z
41
+ .string()
42
+ .optional()
43
+ .describe('Your own note (e.g. "lunch"). Only the owner sees it — visitors never do.'),
44
+ });
45
+
46
+ const GOOGLE_SCHEMA = z.object({
47
+ connectionId: z
48
+ .string()
49
+ .describe('Connection id from rootr_list_google_calendars. The connection belongs to ONE person.'),
50
+ busyCalendarIds: z
51
+ .array(z.string())
52
+ .optional()
53
+ .describe('Calendars whose busy times block bookings. Only free/busy is read — never event titles.'),
54
+ writeCalendarId: z
55
+ .string()
56
+ .nullable()
57
+ .optional()
58
+ .describe('Calendar that receives confirmed bookings. null = do not write anything to Google.'),
59
+ meet: z
60
+ .boolean()
61
+ .optional()
62
+ .describe(
63
+ 'Also create a Google Meet room on each booking. Needs writeCalendarId. No extra permission is needed. ' +
64
+ 'Rootr puts the link in a "회의 링크" column (created for you) and in both confirmation emails.',
65
+ ),
66
+ });
67
+
68
+ export function registerBookingTools(server) {
69
+ server.registerTool(
70
+ 'rootr_get_booking',
71
+ {
72
+ title: 'Read a table\'s visitor-booking settings',
73
+ description:
74
+ 'Read the visitor booking (scheduling) setup of a Rootr (루터) DATABASE: whether it is on, the rules ' +
75
+ '(days, hours, slot length, gap, notice, how far ahead, per-day cap, days off, blocked times, timezone), whether bookings ' +
76
+ 'are confirmed instantly or after approval, who gets notified by email, whether a Google Meet room is created, ' +
77
+ 'which columns visitors are asked for, and which Google Calendar ' +
78
+ 'account/calendars are wired up. Also reports whether the table is published (booking only works on a ' +
79
+ 'published table) and whether any booking failed to reach Google. Use this before changing anything.',
80
+ annotations: { readOnlyHint: true, destructiveHint: false },
81
+ inputSchema: {
82
+ target: z.string().describe(`DATABASE node id${NODE_TARGET_HINT}`),
83
+ },
84
+ },
85
+ async ({ target }) => {
86
+ try {
87
+ const client = makeClient();
88
+ const id = await resolveNodeId(client, target);
89
+ return jsonResult(await client.getBooking(id));
90
+ } catch (err) {
91
+ return errorResult(err);
92
+ }
93
+ },
94
+ );
95
+
96
+ server.registerTool(
97
+ 'rootr_set_booking',
98
+ {
99
+ title: 'Turn on / configure visitor booking on a table',
100
+ description:
101
+ 'Turn a published Rootr (루터) DATABASE into a booking page and set its rules. Visitors open the public ' +
102
+ 'link, pick a date, pick an open time, and their booking lands as ONE ROW in the table. ' +
103
+ 'ONLY the fields you pass change; everything else is left alone. ' +
104
+ 'The table must be published first (rootr_publish_node) — otherwise this returns an error saying so. ' +
105
+ 'Columns you do not have are CREATED for you: a date column, an email column, and (for approval mode) a ' +
106
+ 'status column with 승인 대기/확정/거절. ' +
107
+ 'Visitors never see other people\'s bookings: rows are stripped from the public payload and cloning is ' +
108
+ 'blocked while booking is on. Two people clicking the same slot: only one gets it. ' +
109
+ 'Google Calendar is OPTIONAL — pass `google` only after rootr_list_google_calendars shows a connection ' +
110
+ '(picking a connection requires a human account, not a bare API key).',
111
+ annotations: { readOnlyHint: false, destructiveHint: false },
112
+ inputSchema: {
113
+ target: z.string().describe(`DATABASE node id${NODE_TARGET_HINT}`),
114
+ enabled: z.boolean().optional().describe('Turn visitor booking on or off.'),
115
+ weekdays: z.array(z.number()).optional().describe(`Days that accept bookings. ${WEEKDAY_HINT}`),
116
+ startTime: z.string().optional().describe('First bookable time, "HH:mm" (e.g. "10:00"). Owner timezone.'),
117
+ endTime: z.string().optional().describe('Last bookable time ends by this, "HH:mm" (e.g. "18:00").'),
118
+ durationMin: z
119
+ .number()
120
+ .optional()
121
+ .describe('Length of one booking in minutes: 15, 20, 30, 45, 60, 90, 120, 180 or 240.'),
122
+ bufferMin: z.number().optional().describe('Gap kept before AND after each booking, in minutes.'),
123
+ minNoticeHours: z.number().optional().describe('No bookings closer than this many hours from now.'),
124
+ maxDaysAhead: z.number().optional().describe('How far ahead visitors may book, in days.'),
125
+ maxPerDay: z.number().optional().describe('Max bookings per day. 0 = unlimited.'),
126
+ blockedDates: z
127
+ .array(z.string())
128
+ .optional()
129
+ .describe('Days off, "YYYY-MM-DD". A date-only entry already in the table also blocks that day.'),
130
+ blockedTimes: z
131
+ .array(BLOCK)
132
+ .optional()
133
+ .describe(
134
+ 'Times that stay closed inside the open hours. Replaces the whole list — send every rule you want kept. ' +
135
+ 'Leave out both times to block the whole day: {"kind":"weekly","weekday":5} closes every Friday. ' +
136
+ 'Examples: one afternoon {"kind":"date","date":"2026-10-05","start":"13:00","end":"15:00"}; ' +
137
+ 'lunch every day {"kind":"daily","start":"12:00","end":"13:00"}; ' +
138
+ 'Monday mornings {"kind":"weekly","weekday":1,"start":"09:00","end":"11:00"}.',
139
+ ),
140
+ timezone: z.string().optional().describe('IANA timezone the rules and the visitor page use (e.g. "Asia/Seoul").'),
141
+ approval: z
142
+ .enum(['instant', 'manual'])
143
+ .optional()
144
+ .describe(
145
+ 'instant = confirmed the moment the visitor books (and written to Google right away). ' +
146
+ 'manual = arrives as "승인 대기"; it still HOLDS the slot, and you confirm or decline in the table.',
147
+ ),
148
+ notice: z.string().optional().describe('Short note shown at the top of the visitor page.'),
149
+ askFields: z
150
+ .array(ASK_FIELD)
151
+ .optional()
152
+ .describe(
153
+ 'Extra columns to ask the visitor for. Name and email are ALWAYS asked, so do not list them. ' +
154
+ 'Only text/number/select/multi_select/checkbox/url/email/phone columns can be asked.',
155
+ ),
156
+ google: GOOGLE_SCHEMA.optional().describe('Google Calendar wiring. Omit to leave it untouched.'),
157
+ notifyVisitor: z
158
+ .boolean()
159
+ .optional()
160
+ .describe('Email the visitor when their booking is confirmed, still pending, or declined. On by default.'),
161
+ allowVisitorCancel: z
162
+ .boolean()
163
+ .optional()
164
+ .describe(
165
+ 'Put a cancel link in the visitor\'s email so they can cancel without an account. ' +
166
+ 'The link is a secret only that one booking\'s email carries. On by default; turning it off also ' +
167
+ 'refuses links already sent.',
168
+ ),
169
+ types: z
170
+ .array(
171
+ z.object({
172
+ id: z.string().optional().describe('Short label used in the ?type= link. Derived from the name when omitted.'),
173
+ name: z.string(),
174
+ durationMin: z.number().int(),
175
+ description: z.string().optional(),
176
+ }),
177
+ )
178
+ .optional()
179
+ .describe(
180
+ 'Several kinds of appointment at ONE address, e.g. a 15-minute call and a 60-minute demo. ' +
181
+ 'Replaces the whole list; send [] for a single kind using durationMin. The visitor picks one before ' +
182
+ 'the calendar appears, and ?type=<id> links straight to one. All kinds share the same busy time, ' +
183
+ 'which is why this beats making one table per kind. A column records which kind was booked.',
184
+ ),
185
+ hosts: z
186
+ .array(
187
+ z.object({
188
+ id: z.string().optional(),
189
+ name: z.string(),
190
+ email: z.string(),
191
+ google: GOOGLE_SCHEMA.optional().nullable().describe("That host's own Google Calendar wiring, so their busy time blocks their slots."),
192
+ }),
193
+ )
194
+ .optional()
195
+ .describe(
196
+ 'The people who take these appointments. Replaces the whole list; send [] to drop the idea of hosts. ' +
197
+ 'A column records who got each booking, and each host is emailed their own bookings.',
198
+ ),
199
+ assign: z
200
+ .enum(['none', 'roundRobin', 'collective'])
201
+ .optional()
202
+ .describe(
203
+ 'How the hosts are used. roundRobin: ONE host takes it — a slot is open when ANY host is free, and the ' +
204
+ 'booking goes to whoever has taken the fewest so far. collective: EVERYONE joins — a slot is open only ' +
205
+ 'when ALL hosts are free. none (default): no hosts. Ignored unless `hosts` is non-empty.',
206
+ ),
207
+ allowGuests: z
208
+ .boolean()
209
+ .optional()
210
+ .describe('Let the visitor add other email addresses. Guests get the same emails and the same calendar invite.'),
211
+ maxGuests: z.number().int().optional().describe('How many guests a visitor may add (default 5). 0 turns it off.'),
212
+ redirectUrl: z
213
+ .string()
214
+ .optional()
215
+ .describe(
216
+ 'Where to send the visitor after they book — your own thank-you page. http/https only; anything else is ' +
217
+ 'dropped. Empty keeps them on the Rootr confirmation screen.',
218
+ ),
219
+ allowVisitorReschedule: z
220
+ .boolean()
221
+ .optional()
222
+ .describe(
223
+ 'Let the visitor move their booking to another time from the same emailed link, without an account. ' +
224
+ 'One locked check picks the new slot, so there is no gap where someone else can take it. On by default.',
225
+ ),
226
+ reminders: z
227
+ .array(z.number().int())
228
+ .optional()
229
+ .describe(
230
+ 'Minutes before the meeting to email both sides a reminder, e.g. [1440, 60] for a day and an hour before. ' +
231
+ 'Up to 4 values; [] stops reminders. Defaults to [1440]. Only confirmed bookings are reminded, and a ' +
232
+ 'reminder whose moment already passed when the booking was made is skipped instead of firing at once.',
233
+ ),
234
+ notifyEmails: z
235
+ .array(z.string())
236
+ .optional()
237
+ .describe(
238
+ 'Who on the owner side gets an email for each new booking. Replaces the whole list; send [] to stop. ' +
239
+ 'Defaults to the account that first set booking up.',
240
+ ),
241
+ },
242
+ },
243
+ async ({ target, askFields, ...rest }) => {
244
+ try {
245
+ const client = makeClient();
246
+ const id = await resolveNodeId(client, target);
247
+ const body = { ...rest };
248
+ if (askFields) {
249
+ // 칸 이름으로 골라도 받는다 — 사람도 에이전트도 칸 id 를 외우고 있지 않다.
250
+ const db = await client.getDatabase(id);
251
+ const props = db.properties || [];
252
+ const ids = new Set(props.map((p) => p.id));
253
+ const byName = new Map(props.map((p) => [p.name, p.id]));
254
+ body.askFields = askFields.map((a) => {
255
+ if (ids.has(a.propId)) return a;
256
+ const found = byName.get(a.propId);
257
+ if (!found) {
258
+ throw new Error(
259
+ `No column "${a.propId}" in this table. Columns: ${props.map((p) => p.name).join(', ')}`,
260
+ );
261
+ }
262
+ return { ...a, propId: found };
263
+ });
264
+ }
265
+ return jsonResult(await client.setBooking(id, body));
266
+ } catch (err) {
267
+ return errorResult(err);
268
+ }
269
+ },
270
+ );
271
+
272
+ server.registerTool(
273
+ 'rootr_booking_slots',
274
+ {
275
+ title: 'See the open times visitors see',
276
+ description:
277
+ 'List the times a visitor could actually book on a Rootr (루터) booking page, day by day. This is the ' +
278
+ 'SAME calculation the public page and the server use, so it is the way to check that the rules you set ' +
279
+ 'do what you meant — including which days are closed and which times the owner\'s Google Calendar blocks. ' +
280
+ 'Days with nothing open are omitted entirely. If Google Calendar is wired up but unreachable, this errors ' +
281
+ 'rather than showing times that might already be taken.',
282
+ annotations: { readOnlyHint: true, destructiveHint: false },
283
+ inputSchema: {
284
+ target: z.string().describe(`DATABASE node id${NODE_TARGET_HINT}`),
285
+ from: z.string().optional().describe('First day to look at, "YYYY-MM-DD". Defaults to today.'),
286
+ to: z.string().optional().describe('Last day, "YYYY-MM-DD". At most 62 days after `from`.'),
287
+ type: z
288
+ .string()
289
+ .optional()
290
+ .describe(
291
+ 'Which kind of appointment to look at, when the page takes several (the `id` from `types`). Each kind ' +
292
+ 'has its own length, so the open times differ. Omitted or unknown falls back to the first kind.',
293
+ ),
294
+ },
295
+ },
296
+ async ({ target, from, to, type }) => {
297
+ try {
298
+ const client = makeClient();
299
+ const id = await resolveNodeId(client, target);
300
+ const status = await client.nodePublicStatus(id);
301
+ if (!status.slug) {
302
+ throw new Error('This table is not published yet — publish it first (rootr_publish_node).');
303
+ }
304
+ const today = new Date(Date.now() + 9 * 3600e3).toISOString().slice(0, 10);
305
+ const start = from || today;
306
+ const end = to || new Date(Date.parse(`${start}T00:00:00Z`) + 30 * 864e5).toISOString().slice(0, 10);
307
+ return jsonResult(await client.bookingSlots(status.slug, { from: start, to: end, type }));
308
+ } catch (err) {
309
+ return errorResult(err);
310
+ }
311
+ },
312
+ );
313
+
314
+ server.registerTool(
315
+ 'rootr_list_google_calendars',
316
+ {
317
+ title: 'List connected Google accounts and their calendars',
318
+ description:
319
+ 'List the Google accounts this PERSON has connected to Rootr (루터) for booking, and — when you pass a ' +
320
+ 'connectionId — the calendars on that account with whether each one can be written to. ' +
321
+ 'Use the ids from here in rootr_set_booking `google`. ' +
322
+ 'A Google connection belongs to one person, so this needs a human account: a bare API key (legacy or the ' +
323
+ 'hosted MCP connector) gets a clear error instead of an empty list. If `available` is false, the server ' +
324
+ 'itself has no Google setup yet and booking simply runs without Google.',
325
+ annotations: { readOnlyHint: true, destructiveHint: false },
326
+ inputSchema: {
327
+ connectionId: z
328
+ .string()
329
+ .optional()
330
+ .describe('Also list the calendars on this connection. Omit to just list the connected accounts.'),
331
+ },
332
+ },
333
+ async ({ connectionId }) => {
334
+ try {
335
+ const client = makeClient();
336
+ const connections = await client.googleCalendarConnections();
337
+ if (!connectionId) return jsonResult(connections);
338
+ const calendars = await client.googleCalendars(connectionId);
339
+ return jsonResult({ ...connections, ...calendars });
340
+ } catch (err) {
341
+ return errorResult(err);
342
+ }
343
+ },
344
+ );
345
+
346
+ server.registerTool(
347
+ 'rootr_connect_google_calendar',
348
+ {
349
+ title: 'Start connecting a Google Calendar account',
350
+ description:
351
+ 'Get the Google consent URL to connect a Google Calendar account for booking. ' +
352
+ 'You cannot finish this for the user: Google requires a PERSON to open that URL in a browser and approve ' +
353
+ 'the calendar permissions once. Hand them the URL and say so. After they approve, everything else ' +
354
+ '(listing calendars, wiring them to a table) works through the tools. ' +
355
+ 'Rootr asks only for: the list of their calendars, free/busy times, and permission to manage the events it ' +
356
+ 'creates — never the contents of their existing events. Needs a human account, not a bare API key.',
357
+ annotations: { readOnlyHint: false, destructiveHint: false },
358
+ inputSchema: {
359
+ returnTo: z
360
+ .string()
361
+ .optional()
362
+ .describe('In-app path to come back to after approving, e.g. "/ko". Must start with "/".'),
363
+ },
364
+ },
365
+ async ({ returnTo }) => {
366
+ try {
367
+ const client = makeClient();
368
+ const res = await client.googleCalendarAuthorizeUrl(returnTo || '/');
369
+ return jsonResult({
370
+ ...res,
371
+ next: 'Ask the person to open `url` in a browser and approve. Then call rootr_list_google_calendars.',
372
+ });
373
+ } catch (err) {
374
+ return errorResult(err);
375
+ }
376
+ },
377
+ );
378
+
379
+ server.registerTool(
380
+ 'rootr_disconnect_google_calendar',
381
+ {
382
+ title: 'Disconnect a Google Calendar account',
383
+ description:
384
+ 'Disconnect a Google Calendar account from Rootr (루터) and revoke Rootr\'s access on Google\'s side. ' +
385
+ 'Tables using that connection stop blocking busy times and stop writing new bookings to Google; bookings ' +
386
+ 'already written stay in the calendar. Ask the person before doing this — it affects every table wired to ' +
387
+ 'that account.',
388
+ annotations: { readOnlyHint: false, destructiveHint: true },
389
+ inputSchema: {
390
+ connectionId: z.string().describe('Connection id from rootr_list_google_calendars.'),
391
+ },
392
+ },
393
+ async ({ connectionId }) => {
394
+ try {
395
+ const client = makeClient();
396
+ await client.disconnectGoogleCalendar(connectionId);
397
+ return jsonResult({ ok: true, connectionId });
398
+ } catch (err) {
399
+ return errorResult(err);
400
+ }
401
+ },
402
+ );
403
+ }