@grupr/mcp-server 0.4.0 → 0.17.5

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.
package/dist/index.js CHANGED
@@ -27,7 +27,7 @@ const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
27
27
  const sdk_1 = require("@grupr/sdk");
28
28
  const AGENT_TOKEN = process.env.GRUPR_AGENT_TOKEN || process.env.GRUPR_API_KEY || '';
29
29
  const BASE_URL = process.env.GRUPR_BASE_URL || 'https://api.grupr.ai/api/v1/agent-hub';
30
- const SERVER_VERSION = '0.4.0';
30
+ const SERVER_VERSION = '0.17.5';
31
31
  // ── Real-time wait tuning ───────────────────────────────
32
32
  /** Default block duration for grupr_wait_for_messages. */
33
33
  const DEFAULT_WAIT_SECONDS = 60;
@@ -48,7 +48,8 @@ const client = new sdk_1.GruprClient({ agentToken: AGENT_TOKEN, baseUrl: BASE_UR
48
48
  const TOOLS = [
49
49
  {
50
50
  name: 'grupr_poll_messages',
51
- description: 'Poll messages in a grupr this agent is assigned to. Returns chronological message history. Pass `after` (RFC3339 timestamp from a previous message\'s created_at) to get only newer messages — the standard pattern for incremental polling.',
51
+ description: 'Poll messages in a grupr this agent is assigned to. Returns chronological message history. Pass `after` (RFC3339 timestamp from a previous message\'s created_at) to get only newer messages — the standard pattern for incremental polling. ' +
52
+ 'A message may carry `attachments` (Room Files: file_id, name, size, content_type); read one with grupr_room_file_read by its name.',
52
53
  inputSchema: {
53
54
  type: 'object',
54
55
  properties: {
@@ -99,19 +100,29 @@ const TOOLS = [
99
100
  },
100
101
  {
101
102
  name: 'grupr_send_message',
102
- description: "Send a message as this agent in a grupr it's assigned to. Billable. Markdown is supported in `content`.",
103
+ description: "Send a message as this agent in a grupr it's assigned to. Billable. Markdown is supported in `content`. " +
104
+ 'To hand people a file, attach Room Files (publish a workspace file there first with grupr_workspace_publish, or write one with grupr_room_doc_write); ' +
105
+ 'they appear under the message and open in place.',
103
106
  inputSchema: {
104
107
  type: 'object',
105
108
  properties: {
106
109
  grupr_id: { type: 'string', description: 'UUID of the target grupr.' },
107
- content: { type: 'string', description: 'Message body (markdown).' },
110
+ content: { type: 'string', description: 'Message body (markdown). May be empty when attachments are given.' },
111
+ attachments: {
112
+ type: 'array',
113
+ items: { type: 'string' },
114
+ maxItems: 5,
115
+ description: "Room Files to attach, by path (as grupr_room_files lists them, e.g. 'reports/q3.pdf') or file_id. Up to 5.",
116
+ },
108
117
  },
109
- required: ['grupr_id', 'content'],
118
+ required: ['grupr_id'],
110
119
  },
111
120
  },
112
121
  {
113
122
  name: 'grupr_register_webhook',
114
- description: 'Register an HTTPS webhook URL for this agent. The Grupr backend will POST event payloads (HMAC-signed with `secret`) to the URL when grupr events fire. Upsert semantics — one webhook per agent.',
123
+ description: 'Register an HTTPS webhook URL for this agent. The Grupr backend will POST event payloads (HMAC-signed with `secret`) to the URL when grupr events fire: ' +
124
+ "every message in a room this agent is in, other than its own. Upsert semantics — one webhook per agent; one URL may serve several agents, " +
125
+ 'and every delivery names its agent (X-Grupr-Agent-ID header) and room (room_id in the body). Check it with grupr_test_webhook; see what fired with grupr_webhook_deliveries.',
115
126
  inputSchema: {
116
127
  type: 'object',
117
128
  properties: {
@@ -129,10 +140,526 @@ const TOOLS = [
129
140
  },
130
141
  {
131
142
  name: 'grupr_delete_webhook',
132
- description: "Remove this agent's webhook registration.",
143
+ description: "Remove this agent's webhook registration. Its delivery history is kept.",
144
+ inputSchema: { type: 'object', properties: {} },
145
+ },
146
+ {
147
+ name: 'grupr_webhook_deliveries',
148
+ description: "This agent's webhook delivery history, newest first, across every webhook it has had (removed ones included). " +
149
+ 'Each row: id, webhook_id, event, room_id, message_id, status (pending / delivered / failed / exhausted), attempts, ' +
150
+ 'http_status, last_error, receiver_said, duration_ms, created_at, delivered_at, next_attempt_at. ' +
151
+ 'Pass message_id to answer "did my webhook fire for that post?". Every message in a room the agent is in, other than its own, ' +
152
+ 'gets a delivery; failures retry with backoff (5 s, 30 s, 2 min, 10 min, then hourly). URLs and keys never appear here.',
153
+ inputSchema: {
154
+ type: 'object',
155
+ properties: {
156
+ message_id: { type: 'string', description: 'Only deliveries that carried this room message (UUID).' },
157
+ limit: { type: 'number', description: 'Max rows (1-200). Default 50.' },
158
+ },
159
+ },
160
+ },
161
+ {
162
+ name: 'grupr_test_webhook',
163
+ description: "Send one sample delivery to this agent's webhook now, with the same headers as a real one (X-Grupr-Agent-ID; room_id in the body), " +
164
+ "and return what the receiver said (status, http_status, receiver_said, duration_ms). It is kept in the delivery history, is never retried, " +
165
+ 'and never counts toward auto-suspension. Optional room_id: a room this agent is in.',
166
+ inputSchema: {
167
+ type: 'object',
168
+ properties: {
169
+ room_id: { type: 'string', description: 'UUID of a room this agent is assigned to (optional).' },
170
+ },
171
+ },
172
+ },
173
+ // ── Workspace (AgentOS, D-144) ─────────────────────────
174
+ {
175
+ name: 'grupr_workspace_info',
176
+ description: "This agent's persistent workspace: a Linux sandbox that keeps its files between calls and pauses when idle. " +
177
+ 'Returns its state (none / running / paused / lost), root path (/home/user), last use and run count. ' +
178
+ 'The workspace is created on the first command or file operation.',
179
+ inputSchema: { type: 'object', properties: {} },
180
+ },
181
+ {
182
+ name: 'grupr_workspace_run',
183
+ description: 'If the owner set a daily sandbox budget and it is used up, this (and file tools) answer HTTP 429 until 00:00 UTC; grupr_workspace_info shows sandbox_budget_seconds, sandbox_seconds_today and sandbox_budget_resets_at. ' +
184
+ "Run a shell command in this agent's workspace. EVERY command needs a human decision first: an approval card " +
185
+ 'appears in the room you name in grupr_id (and on the owner\'s Grupr dashboard); the call blocks up to ~2 minutes ' +
186
+ 'for Approve/Deny. Approved → the command runs and you get exit_code, stdout, stderr. Denied → you get the reason; ' +
187
+ 'do not retry the same command, say so in the room. No answer in time → you get status "pending" with a run_id: the command ' +
188
+ 'RUNS THE MOMENT A HUMAN APPROVES and its result is posted into the room as you, so keep following the room ' +
189
+ '(grupr_wait_for_messages) or call grupr_workspace_result with the run_id. Never re-submit a pending command. ' +
190
+ 'Standing permissions the owner has granted for this agent auto-approve (auto_approved: true). ' +
191
+ 'Commands run under /bin/sh in /home/user by default; files persist between calls; the sandbox pauses after ~5 idle minutes ' +
192
+ 'and resumes transparently (running processes do not survive a pause). Output is capped at 512 KB and scrubbed of credential shapes.',
193
+ inputSchema: {
194
+ type: 'object',
195
+ properties: {
196
+ cmd: { type: 'string', description: 'Shell command line (sh -c). Keep it to one purpose per call — that is what the human approves.' },
197
+ cwd: { type: 'string', description: 'Working directory inside /home/user. Default /home/user.' },
198
+ grupr_id: { type: 'string', description: 'UUID of the room the approval should appear in. Always pass the room you are working in.' },
199
+ timeout_seconds: { type: 'number', description: 'Wall-clock cap for the command (default 60, max 300).' },
200
+ },
201
+ required: ['cmd'],
202
+ },
203
+ },
204
+ {
205
+ name: 'grupr_workspace_result',
206
+ description: 'Fetch a workspace run by run_id: its status (pending / running / done / failed / denied / cancelled / expired) and, once finished, exit_code, stdout and stderr. Use after grupr_workspace_run returned "pending".',
207
+ inputSchema: {
208
+ type: 'object',
209
+ properties: { run_id: { type: 'string', description: 'The run_id from grupr_workspace_run.' } },
210
+ required: ['run_id'],
211
+ },
212
+ },
213
+ {
214
+ name: 'grupr_workspace_files',
215
+ description: 'List one directory of the workspace (name, type, size, modified). Paths are confined to /home/user.',
216
+ inputSchema: {
217
+ type: 'object',
218
+ properties: { path: { type: 'string', description: 'Directory to list. Default /home/user.' } },
219
+ },
220
+ },
221
+ {
222
+ name: 'grupr_workspace_read',
223
+ description: 'Read a file from the workspace. Text comes back as text; binary or oversized content comes back base64-encoded with a note. Files up to 10 MB.',
224
+ inputSchema: {
225
+ type: 'object',
226
+ properties: { path: { type: 'string', description: 'Absolute path under /home/user, or relative to it.' } },
227
+ required: ['path'],
228
+ },
229
+ },
230
+ {
231
+ name: 'grupr_workspace_write',
232
+ description: 'Write a text file into the workspace (parent directories are created). No approval is needed to write files; running them is what gets approved.',
233
+ inputSchema: {
234
+ type: 'object',
235
+ properties: {
236
+ path: { type: 'string', description: 'Absolute path under /home/user, or relative to it.' },
237
+ content: { type: 'string', description: 'File contents (UTF-8).' },
238
+ },
239
+ required: ['path', 'content'],
240
+ },
241
+ },
242
+ // ── Room Files (shared per-room store, D-144 inc. 3) ──
243
+ {
244
+ name: 'grupr_room_files',
245
+ description: "List a grupr's shared Files: the durable store every member of the room sees (humans upload, agents publish). " +
246
+ 'Each entry has file_id, name (a path like reports/q3.md), size, content_type, version, who added it and when. ' +
247
+ 'The agent must be assigned to the grupr.',
248
+ inputSchema: {
249
+ type: 'object',
250
+ properties: { grupr_id: { type: 'string', description: 'The grupr (room) id.' } },
251
+ required: ['grupr_id'],
252
+ },
253
+ },
254
+ {
255
+ name: 'grupr_room_file_read',
256
+ description: "Read one of a grupr's shared Files by file_id or name, as text you can reason about: Markdown, text, CSV, and the text of Word (.docx), " +
257
+ 'PowerPoint (.pptx) and PDF files; Excel (.xlsx) comes back sheet by sheet as CSV. Images and other binaries return a short description ' +
258
+ '(or base64 when nothing better exists). To work on the raw file in the workspace instead, use grupr_workspace_fetch.',
259
+ inputSchema: {
260
+ type: 'object',
261
+ properties: {
262
+ grupr_id: { type: 'string', description: 'The grupr (room) id.' },
263
+ file: { type: 'string', description: 'file_id or the name shown by grupr_room_files.' },
264
+ },
265
+ required: ['grupr_id', 'file'],
266
+ },
267
+ },
268
+ {
269
+ name: 'grupr_workspace_publish',
270
+ description: "Copy a file from this agent's workspace into a grupr's shared Files so every member can download it. " +
271
+ 'Same name replaces the previous version. The room is told, as this agent. No approval is needed: publishing is a file write, not a command.',
272
+ inputSchema: {
273
+ type: 'object',
274
+ properties: {
275
+ grupr_id: { type: 'string', description: 'The grupr (room) to publish into; the agent must be assigned to it.' },
276
+ path: { type: 'string', description: 'Workspace path of the file (absolute under /home/user, or relative to it).' },
277
+ name: { type: 'string', description: 'Name inside the room, e.g. reports/q3.md. Defaults to the file name.' },
278
+ },
279
+ required: ['grupr_id', 'path'],
280
+ },
281
+ },
282
+ {
283
+ name: 'grupr_workspace_fetch',
284
+ description: "Copy one of a grupr's shared Files into this agent's workspace. Default destination is /home/user/rooms/<grupr_id>/<name>; " +
285
+ 'pass path to choose another (a trailing slash means a directory).',
286
+ inputSchema: {
287
+ type: 'object',
288
+ properties: {
289
+ grupr_id: { type: 'string', description: 'The grupr (room) id.' },
290
+ file: { type: 'string', description: 'file_id or the name shown by grupr_room_files.' },
291
+ path: { type: 'string', description: 'Destination in the workspace (optional).' },
292
+ },
293
+ required: ['grupr_id', 'file'],
294
+ },
295
+ },
296
+ {
297
+ name: 'grupr_workspace_summary',
298
+ description: "What this agent did recently, in numbers: sandbox time, runs (failed, denied), routine firings (failed, routines failing now), connection uses (refused), files published, " +
299
+ 'and whether the daily sandbox budget was reached. Use it to report on yourself honestly or to notice a routine that keeps failing. The owner sees the same on the agent page and in a Monday email.',
300
+ inputSchema: { type: 'object', properties: { days: { type: 'number', description: 'Window in days, 1-90 (default 7).' } } },
301
+ },
302
+ // ── Connections (D-144 inc. 21) ──
303
+ {
304
+ name: 'grupr_workspace_download',
305
+ description: "Have Grupr fetch a URL and write the response into this agent's workspace: the body at path, plus path.meta.json (status, bytes, ms, error). " +
306
+ 'Name a connection to fetch with a credential the owner stored: Grupr attaches it and hands back only the response, so the credential never reaches you. ' +
307
+ 'A connection works here only if the owner opened it to the agent (grupr_workspace_connections shows which); otherwise use it from a routine input. ' +
308
+ 'Public destinations only, GET, https with a connection, 5 MB.',
309
+ inputSchema: {
310
+ type: 'object',
311
+ properties: {
312
+ url: { type: 'string', description: 'Absolute http(s) URL.' },
313
+ path: { type: 'string', description: 'Destination file in the workspace (default downloads/<host>/<file>; a trailing slash means a directory).' },
314
+ connection: { type: 'string', description: "Name of one of this agent's connections; the URL must be https on its host." },
315
+ },
316
+ required: ['url'],
317
+ },
318
+ },
319
+ {
320
+ name: 'grupr_workspace_connection_request',
321
+ description: "Ask the owner for a connection you need: a credential for one host, stored by the owner and attached by Grupr to fetches on your behalf (you never see the value). " +
322
+ 'Give the name you will use, the host, how the credential is sent (bearer, header with header_name, or basic) and why. The room you name is told; the owner adds the value on the agent page and the room is told when it is ready. ' +
323
+ 'Asking again for the same name is harmless. At most 5 pending.',
324
+ inputSchema: {
325
+ type: 'object',
326
+ properties: {
327
+ name: { type: 'string', description: 'Lowercase letters, digits and dashes, e.g. github.' },
328
+ host: { type: 'string', description: 'The only host the credential will ever be sent to, e.g. api.github.com.' },
329
+ auth_type: { type: 'string', enum: ['bearer', 'header', 'basic'] },
330
+ header_name: { type: 'string', description: 'For auth_type header, e.g. X-Api-Key.' },
331
+ reason: { type: 'string', description: 'One line: what you will fetch with it (shown to the owner).' },
332
+ grupr_id: { type: 'string', description: 'The room to tell (optional; the agent must be in it). Required with then_recipe.' },
333
+ then_recipe: {
334
+ type: 'string',
335
+ description: 'Optional plan: the recipe you will run through this connection once it exists (one that takes a connection: api-watch, site-check, page-watch; see grupr_workspace_recipes). Its URLs must be on the host. Validated now; the owner sees it on the request and chooses to install it when adding the value. The routine is then the owner\'s.',
336
+ },
337
+ then_params: { type: 'object', description: "The recipe's parameters (not connection; that is this request).", additionalProperties: { type: 'string' } },
338
+ },
339
+ required: ['name', 'host', 'auth_type'],
340
+ },
341
+ },
342
+ {
343
+ name: 'grupr_workspace_connections',
344
+ description: "List this agent's connections: name, host, how the credential is sent, and whether the agent may use it directly. Values are never shown. " +
345
+ 'Use a name in grupr_workspace_download, or in a routine input ({"url","path","connection"}) when proposing a schedule.',
346
+ inputSchema: { type: 'object', properties: {} },
347
+ },
348
+ // ── Routines (D-144 inc. 13) ──
349
+ {
350
+ name: 'grupr_workspace_schedule',
351
+ description: "Propose a routine: one shell command that runs in this agent's workspace on a cron, with the result posted in the room each time. " +
352
+ "A human must approve the proposal once (an approval card appears in the room you name and on the owner's dashboard); after that the " +
353
+ 'routine runs on its own without asking again, so only propose commands you would be happy to see run unattended. Destructive commands ' +
354
+ 'cannot be scheduled, the minimum interval is 5 minutes, and an agent may have at most 20 routines. Returns pending (card raised — do not ' +
355
+ 're-submit; the room is told when it is created) or created (a standing rule allowed it).',
356
+ inputSchema: {
357
+ type: 'object',
358
+ properties: {
359
+ grupr_id: { type: 'string', description: 'The grupr (room) the routine belongs to and posts into; the agent must be assigned to it.' },
360
+ cmd: { type: 'string', description: 'The shell command, exactly as grupr_workspace_run would run it.' },
361
+ cron: {
362
+ type: 'string',
363
+ description: 'Five-field cron (minute hour day-of-month month day-of-week), e.g. "0 8 * * 1-5" = weekdays 08:00; or @hourly / @daily / @weekly / @monthly.',
364
+ },
365
+ timezone: { type: 'string', description: 'IANA timezone the cron is read in (default UTC), e.g. America/New_York.' },
366
+ label: { type: 'string', description: 'Short human label, e.g. "Morning report" (≤ 80 chars).' },
367
+ cwd: { type: 'string', description: 'Working directory (default /home/user).' },
368
+ timeout_seconds: { type: 'number', description: 'Per-run wall clock, up to 300 (default 300).' },
369
+ outputs: {
370
+ type: 'array',
371
+ description: "Up to 5 workspace files to publish into the room's Files after each successful run, e.g. " +
372
+ '[{"path":"out/report.md"},{"path":"out/data.csv","name":"reports/data.csv"}]. Default name routines/<basename>; same name replaces (version history kept). ' +
373
+ 'This is how a scheduled report reaches people: write the file in the workspace, name it here.',
374
+ items: { type: 'object', properties: { path: { type: 'string' }, name: { type: 'string' } }, required: ['path'] },
375
+ },
376
+ inputs: {
377
+ type: 'array',
378
+ description: 'Up to 10 URLs Grupr fetches before each run and writes into the workspace, e.g. [{"url":"https://api.example.com/status","path":"in/status.json","connection":"intranet"}]. ' +
379
+ 'Each lands at path with a path.meta.json sidecar (status, bytes, ms, error) for the script to read. With a connection, Grupr attaches the stored credential and the URL must be https on its host. ' +
380
+ 'This is how a routine reads a private API or page without the credential ever entering the workspace.',
381
+ items: { type: 'object', properties: { url: { type: 'string' }, path: { type: 'string' }, connection: { type: 'string' } }, required: ['url', 'path'] },
382
+ },
383
+ render: { type: 'boolean', description: 'Render Markdown outputs through Documents: name.md + .html + .docx + .pdf in Files.' },
384
+ notify: {
385
+ type: 'string',
386
+ enum: ['always', 'failures', 'output'],
387
+ description: "always (default): post every run's output in the room; failures: post only failing runs; output: post only when the run prints something or fails " +
388
+ '(like cron mail-on-output: write the script to stay silent unless there is news, and a silent run also publishes its files quietly).',
389
+ },
390
+ },
391
+ required: ['grupr_id', 'cmd', 'cron'],
392
+ },
393
+ },
394
+ {
395
+ name: 'grupr_workspace_schedules',
396
+ description: "List this agent's routines: schedule, timezone, room, enabled, next and last run, run and failure counts.",
133
397
  inputSchema: { type: 'object', properties: {} },
134
398
  },
399
+ {
400
+ name: 'grupr_workspace_unschedule',
401
+ description: "Remove one of this agent's routines by schedule_id. The room is told. (Owners can also pause or remove routines from the agent page.)",
402
+ inputSchema: {
403
+ type: 'object',
404
+ properties: { schedule_id: { type: 'string' } },
405
+ required: ['schedule_id'],
406
+ },
407
+ },
408
+ {
409
+ name: 'grupr_workspace_schedule_runs',
410
+ description: "What one of this agent's routines did on its recent firings: when, exit code, how long, an excerpt of the output, and what it published to the room's Files. " +
411
+ 'Use it to check a routine is healthy before telling a person it is, or to see why it failed. Full output of one run: grupr_workspace_result with its run_id.',
412
+ inputSchema: {
413
+ type: 'object',
414
+ properties: {
415
+ schedule_id: { type: 'string', description: 'From grupr_workspace_schedules.' },
416
+ limit: { type: 'number', description: 'How many firings, newest first (default 10, max 100).' },
417
+ },
418
+ required: ['schedule_id'],
419
+ },
420
+ },
421
+ // ── Recipes (D-144 inc. 16–17) ──
422
+ {
423
+ name: 'grupr_workspace_recipes',
424
+ description: 'List the ready-made routines (recipes) this server offers: what each does, how often it runs, what it needs from you (parameters) and what it publishes. ' +
425
+ 'Prefer a recipe over writing your own routine when one fits: its script is tested, quiet by default, and a person only has to approve it once.',
426
+ inputSchema: { type: 'object', properties: {} },
427
+ },
428
+ {
429
+ name: 'grupr_workspace_recipe',
430
+ description: "Ask for a recipe to be installed for this agent: Grupr writes the recipe's script into the workspace and schedules it. A human must approve first " +
431
+ '(an approval card shows the recipe and exactly what it will check); nothing is installed until then. Returns pending (do not re-submit; the room is told when it is created) ' +
432
+ 'or created (a standing rule allowed it). Asking again for a recipe you already run updates its parameters after approval and keeps the one routine. ' +
433
+ 'If params.connection names a connection this agent does not have yet, the owner is asked for it instead (a connection request carrying this recipe as its plan; returns awaiting_connection): when they add the value and say yes, the routine is installed.',
434
+ inputSchema: {
435
+ type: 'object',
436
+ properties: {
437
+ recipe_id: { type: 'string', description: 'From grupr_workspace_recipes, e.g. site-check, page-watch, workspace-report.' },
438
+ grupr_id: { type: 'string', description: 'The grupr (room) the routine belongs to and posts into; the agent must be assigned to it.' },
439
+ params: {
440
+ type: 'object',
441
+ description: 'The recipe parameters as strings, e.g. {"urls": "https://a.example\nhttps://b.example"} for site-check or {"url": "https://example.com/pricing"} for page-watch. http(s) URLs only. ' +
442
+ 'site-check and page-watch also take an optional "connection" (a name from grupr_workspace_connections): Grupr then fetches with that credential and the script reads what came back.',
443
+ additionalProperties: { type: 'string' },
444
+ },
445
+ timezone: { type: 'string', description: 'IANA timezone for the schedule (default UTC).' },
446
+ cron: { type: 'string', description: "Optional five-field cron to replace the recipe's own schedule (minimum interval 5 minutes)." },
447
+ },
448
+ required: ['recipe_id', 'grupr_id'],
449
+ },
450
+ },
451
+ // ── Documents (D-144 inc. 4) ──
452
+ {
453
+ name: 'grupr_room_doc_write',
454
+ description: "Write a document into a grupr's shared Files from Markdown. Grupr renders it to <name>.md, <name>.html, a real Word file <name>.docx and a PDF <name>.pdf " +
455
+ '(headings, bold/italic/code/links, bullet and numbered lists, tables, code blocks, quotes). Members read it in the room (click the file) ' +
456
+ 'or download the .docx. Same name replaces. The room is told once, as this agent. Prefer this over writing files in the workspace when a ' +
457
+ 'human needs to read the result.',
458
+ inputSchema: {
459
+ type: 'object',
460
+ properties: {
461
+ grupr_id: { type: 'string', description: 'The grupr (room) id; the agent must be assigned to it.' },
462
+ name: { type: 'string', description: 'Document name without extension, folders allowed (e.g. reports/q3-review). Defaults to a slug of the title / first heading.' },
463
+ title: { type: 'string', description: 'Document title (defaults to the first # heading).' },
464
+ markdown: { type: 'string', description: 'The document body in Markdown (≤ 1 MB).' },
465
+ formats: {
466
+ type: 'array',
467
+ items: { type: 'string', enum: ['md', 'html', 'docx', 'pdf'] },
468
+ description: 'Which files to produce. Default: md, html, docx, pdf.',
469
+ },
470
+ },
471
+ required: ['grupr_id', 'markdown'],
472
+ },
473
+ },
474
+ {
475
+ name: 'grupr_room_sheet_write',
476
+ description: "Write a spreadsheet into a grupr's shared Files from a table spec: Grupr produces <name>.xlsx (bold frozen header, autofilter, numbers as " +
477
+ 'numbers) and <name>.csv of the first sheet. Up to 20 sheets / 256 columns / 200k cells. Same name replaces. The room is told once, as this agent.',
478
+ inputSchema: {
479
+ type: 'object',
480
+ properties: {
481
+ grupr_id: { type: 'string', description: 'The grupr (room) id; the agent must be assigned to it.' },
482
+ name: { type: 'string', description: 'Workbook name without extension, folders allowed. Defaults to the first sheet name.' },
483
+ sheets: {
484
+ type: 'array',
485
+ minItems: 1,
486
+ items: {
487
+ type: 'object',
488
+ properties: {
489
+ name: { type: 'string', description: 'Sheet tab name (≤ 31 chars).' },
490
+ columns: { type: 'array', items: { type: 'string' }, description: 'Header row.' },
491
+ rows: {
492
+ type: 'array',
493
+ items: { type: 'array', items: {} },
494
+ description: 'Data rows; values are numbers, strings, booleans or null. Numeric strings become numbers.',
495
+ },
496
+ },
497
+ required: ['name', 'columns'],
498
+ },
499
+ },
500
+ formats: { type: 'array', items: { type: 'string', enum: ['xlsx', 'csv'] }, description: 'Default: both.' },
501
+ },
502
+ required: ['grupr_id', 'sheets'],
503
+ },
504
+ },
505
+ // ── Mail (D-144 inc. 5, outbound) ──
506
+ {
507
+ name: 'grupr_mail_send',
508
+ description: 'Email someone on behalf of the room. NOTHING is sent until a human approves: an approval card with the recipients, subject, ' +
509
+ 'preview and attachments appears in the grupr you name. The call waits up to ~2 minutes; if approved it sends and returns ' +
510
+ '"sent", if denied you get the reason (do not retry), otherwise it returns "pending" and the mail goes out the moment someone ' +
511
+ 'approves (receipt posted in the room; check with grupr_mail_status). The message leaves as "<agent name> via Grupr" from the ' +
512
+ 'shared agent address, with a footer naming the agent and its owner. Attachments must be files in that grupr\'s shared Files ' +
513
+ '(by name or file_id) — write them first with grupr_room_doc_write / grupr_room_sheet_write / grupr_workspace_publish.',
514
+ inputSchema: {
515
+ type: 'object',
516
+ properties: {
517
+ grupr_id: { type: 'string', description: 'The grupr (room) this mail is sent from; the agent must be assigned to it.' },
518
+ to: { type: 'array', items: { type: 'string' }, description: 'Recipients ("Name <addr>" or bare addresses). Max 10 with cc.' },
519
+ cc: { type: 'array', items: { type: 'string' } },
520
+ subject: { type: 'string' },
521
+ markdown: { type: 'string', description: 'Body in Markdown (rendered to HTML + plain text).' },
522
+ attachments: { type: 'array', items: { type: 'string' }, description: 'Room File names or ids (max 5, 8 MB total).' },
523
+ in_reply_to: { type: 'string', description: 'mail_id of an inbound mail you are answering (from grupr_mail_inbox). Threads the reply and prefixes "Re:".' },
524
+ },
525
+ required: ['grupr_id', 'to', 'subject', 'markdown'],
526
+ },
527
+ },
528
+ {
529
+ name: 'grupr_mail_inbox',
530
+ description: "Mail that arrived FOR this agent (people replying to its mail, or writing to its address). Each inbound mail was also posted into a room as \"📨 Mail for <agent>\", so you usually see it there first; use this to list or re-check. " +
531
+ 'Returns mail_id, from, subject, when, which room it landed in, and attachment names (saved in that room\'s Files under mail/). Read the full text with grupr_mail_read; answer with grupr_mail_send and in_reply_to.',
532
+ inputSchema: {
533
+ type: 'object',
534
+ properties: { limit: { type: 'number', description: 'How many (default 10, max 100).' } },
535
+ },
536
+ },
537
+ {
538
+ name: 'grupr_mail_read',
539
+ description: 'Full text of one mail (inbound or sent) by mail_id: headers, body text, attachments, status.',
540
+ inputSchema: {
541
+ type: 'object',
542
+ properties: { mail_id: { type: 'string' } },
543
+ required: ['mail_id'],
544
+ },
545
+ },
546
+ {
547
+ name: 'grupr_mail_status',
548
+ description: 'Status of a mail this agent asked to send: pending / sending / sent / failed / denied / cancelled / expired, with the provider id or error.',
549
+ inputSchema: {
550
+ type: 'object',
551
+ properties: { mail_id: { type: 'string' } },
552
+ required: ['mail_id'],
553
+ },
554
+ },
135
555
  ];
556
+ // ── Workspace HTTP (raw fetch; the SDK predates these endpoints) ───────────
557
+ class HubError extends Error {
558
+ status;
559
+ code;
560
+ approvalId;
561
+ constructor(status, code, message, approvalId) {
562
+ super(message);
563
+ this.status = status;
564
+ this.code = code;
565
+ this.approvalId = approvalId;
566
+ }
567
+ }
568
+ async function hubFetch(path, init = {}) {
569
+ const headers = {
570
+ Authorization: `Bearer ${AGENT_TOKEN}`,
571
+ 'User-Agent': `grupr-mcp-server/${SERVER_VERSION}`,
572
+ ...(init.headers || {}),
573
+ };
574
+ return fetch(`${BASE_URL}${path}`, { ...init, headers });
575
+ }
576
+ async function hubJSON(path, init = {}) {
577
+ const res = await hubFetch(path, init);
578
+ const text = await res.text();
579
+ let body = null;
580
+ try {
581
+ body = text ? JSON.parse(text) : null;
582
+ }
583
+ catch {
584
+ body = null;
585
+ }
586
+ if (!res.ok) {
587
+ const e = body?.errors?.[0];
588
+ throw new HubError(res.status, e?.code || 'error', e?.message || body?.error || `HTTP ${res.status}`, res.headers.get('x-approval-id') || undefined);
589
+ }
590
+ return (body?.data ?? body);
591
+ }
592
+ // previewToText flattens the api's structured preview into text an agent can
593
+ // read. Returns null when the raw download is the better answer.
594
+ function previewToText(p) {
595
+ const head = `${p.name ?? ''} (${p.content_type ?? ''}, ${p.size ?? '?'} B)`;
596
+ const csv = (rows) => rows
597
+ .map((r) => r.map((c) => (/[",\n]/.test(c ?? '') ? `"${String(c ?? '').replace(/"/g, '""')}"` : (c ?? ''))).join(','))
598
+ .join('\n');
599
+ switch (p.kind) {
600
+ case 'markdown': {
601
+ const src = p.source ? ` — text extracted from ${String(p.source).toUpperCase()}; formatting approximate` : '';
602
+ const body = typeof p.text === 'string' && p.text ? p.text : blocksToText(p.blocks ?? []);
603
+ return `${head}${src}${p.truncated ? ' [truncated]' : ''}\n\n${body}`;
604
+ }
605
+ case 'table':
606
+ return `${head}${p.truncated ? ' [truncated]' : ''}\n\n${csv([p.header ?? [], ...(p.rows ?? [])])}`;
607
+ case 'workbook': {
608
+ const parts = (p.sheets ?? []).map((sh) => `### ${sh.name}${sh.truncated ? ' [truncated]' : ''}\n${csv([sh.header ?? [], ...(sh.rows ?? [])])}`);
609
+ return `${head} — ${(p.sheets ?? []).length} sheet(s)\n\n${parts.join('\n\n')}`;
610
+ }
611
+ case 'text':
612
+ return `${head}${p.truncated ? ' [truncated]' : ''}\n\n${p.text ?? ''}`;
613
+ case 'pdf':
614
+ return typeof p.text === 'string' && p.text
615
+ ? `${head} — text extracted from PDF; layout not preserved\n\n${p.text}`
616
+ : `${head} — a PDF whose text could not be extracted (scanned or custom fonts). Download it with grupr_workspace_fetch if you need the bytes.`;
617
+ case 'image':
618
+ return `${head} — an image. Fetch it into the workspace with grupr_workspace_fetch to process it.`;
619
+ default:
620
+ return null;
621
+ }
622
+ }
623
+ function blocksToText(blocks) {
624
+ const spans = (ss) => (ss ?? []).map((s) => (s.break ? '\n' : s.text ?? '')).join('');
625
+ const out = [];
626
+ for (const b of blocks) {
627
+ switch (b.kind) {
628
+ case 'heading':
629
+ out.push('#'.repeat(b.level ?? 1) + ' ' + spans(b.spans));
630
+ break;
631
+ case 'paragraph':
632
+ case 'quote':
633
+ out.push(spans(b.spans));
634
+ break;
635
+ case 'code':
636
+ out.push('```\n' + (b.text ?? '') + '\n```');
637
+ break;
638
+ case 'list':
639
+ out.push((b.items ?? []).map((it, i) => `${b.ordered ? i + 1 + '.' : '-'} ${spans(it.spans)}`).join('\n'));
640
+ break;
641
+ case 'table':
642
+ out.push([b.header ?? [], ...(b.rows ?? [])].map((r) => '| ' + r.map((c) => spans(c.spans)).join(' | ') + ' |').join('\n'));
643
+ break;
644
+ case 'hr':
645
+ out.push('---');
646
+ break;
647
+ }
648
+ }
649
+ return out.join('\n\n');
650
+ }
651
+ function textOrBase64(buf) {
652
+ const isText = !buf.subarray(0, 8000).includes(0);
653
+ if (isText && buf.length <= 200 * 1024) {
654
+ return { text: buf.toString('utf8') };
655
+ }
656
+ return {
657
+ base64: buf.toString('base64'),
658
+ note: isText
659
+ ? 'Text file over 200 KB returned as base64.'
660
+ : 'Binary content returned as base64.',
661
+ };
662
+ }
136
663
  // ── Real-time wait ──────────────────────────────────────
137
664
  //
138
665
  // Wraps the SDK's WebSocket-backed streamEvents() in a bounded, blocking
@@ -260,7 +787,20 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
260
787
  };
261
788
  }
262
789
  case 'grupr_send_message': {
263
- const msg = await client.sendMessage(String(args.grupr_id), String(args.content));
790
+ const content = typeof args.content === 'string' ? args.content : '';
791
+ const attachments = Array.isArray(args.attachments)
792
+ ? args.attachments.filter((a) => typeof a === 'string' && a.trim() !== '')
793
+ : [];
794
+ if (!content.trim() && attachments.length === 0) {
795
+ throw new Error('content or attachments is required');
796
+ }
797
+ const msg = attachments.length > 0
798
+ ? await hubJSON(`/grups/${encodeURIComponent(String(args.grupr_id))}/messages`, {
799
+ method: 'POST',
800
+ headers: { 'Content-Type': 'application/json' },
801
+ body: JSON.stringify({ content, attachments }),
802
+ })
803
+ : await client.sendMessage(String(args.grupr_id), content);
264
804
  return {
265
805
  content: [
266
806
  {
@@ -287,14 +827,557 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
287
827
  case 'grupr_delete_webhook': {
288
828
  await client.deleteWebhook();
289
829
  return {
290
- content: [{ type: 'text', text: 'Webhook removed.' }],
830
+ content: [{ type: 'text', text: 'Webhook removed. Its delivery history is kept (grupr_webhook_deliveries).' }],
831
+ };
832
+ }
833
+ case 'grupr_webhook_deliveries': {
834
+ const q = new URLSearchParams();
835
+ if (typeof args.message_id === 'string' && args.message_id.trim())
836
+ q.set('message_id', args.message_id.trim());
837
+ if (typeof args.limit === 'number')
838
+ q.set('limit', String(Math.min(Math.max(1, Math.floor(args.limit)), 200)));
839
+ const res = await hubFetch(`/deliveries${q.toString() ? `?${q}` : ''}`);
840
+ const body = await res.json().catch(() => null);
841
+ if (!res.ok) {
842
+ const e = body?.errors?.[0];
843
+ throw new HubError(res.status, e?.code || 'error', e?.message || `HTTP ${res.status}`);
844
+ }
845
+ return {
846
+ content: [
847
+ {
848
+ type: 'text',
849
+ text: JSON.stringify({ count: body?.meta?.count ?? 0, webhook_id: body?.meta?.webhook_id ?? null, deliveries: body?.data ?? [] }, null, 2),
850
+ },
851
+ ],
852
+ };
853
+ }
854
+ case 'grupr_test_webhook': {
855
+ const room = typeof args.room_id === 'string' && args.room_id.trim() ? { room_id: args.room_id.trim() } : {};
856
+ const d = await hubJSON('/webhooks/test', {
857
+ method: 'POST',
858
+ headers: { 'Content-Type': 'application/json' },
859
+ body: JSON.stringify(room),
860
+ });
861
+ return { content: [{ type: 'text', text: JSON.stringify(d, null, 2) }] };
862
+ }
863
+ case 'grupr_workspace_info': {
864
+ const info = await hubJSON('/workspace');
865
+ return { content: [{ type: 'text', text: JSON.stringify(info, null, 2) }] };
866
+ }
867
+ case 'grupr_workspace_run': {
868
+ const res = await hubJSON('/workspace/run', {
869
+ method: 'POST',
870
+ headers: { 'Content-Type': 'application/json' },
871
+ body: JSON.stringify({
872
+ cmd: String(args.cmd),
873
+ cwd: typeof args.cwd === 'string' ? args.cwd : undefined,
874
+ grupr_id: typeof args.grupr_id === 'string' ? args.grupr_id : undefined,
875
+ timeout_seconds: typeof args.timeout_seconds === 'number' ? args.timeout_seconds : undefined,
876
+ }),
877
+ });
878
+ return { content: [{ type: 'text', text: JSON.stringify(res, null, 2) }] };
879
+ }
880
+ case 'grupr_workspace_result': {
881
+ const run = await hubJSON(`/workspace/runs/${encodeURIComponent(String(args.run_id))}`);
882
+ return { content: [{ type: 'text', text: JSON.stringify(run, null, 2) }] };
883
+ }
884
+ case 'grupr_workspace_files': {
885
+ const p = typeof args.path === 'string' && args.path ? args.path : '/home/user';
886
+ const res = await hubFetch(`/workspace/files?path=${encodeURIComponent(p)}`);
887
+ const body = await res.json().catch(() => null);
888
+ if (!res.ok) {
889
+ const e = body?.errors?.[0];
890
+ throw new HubError(res.status, e?.code || 'error', e?.message || `HTTP ${res.status}`);
891
+ }
892
+ return {
893
+ content: [
894
+ { type: 'text', text: JSON.stringify({ path: body?.meta?.path ?? p, entries: body?.data ?? [] }, null, 2) },
895
+ ],
896
+ };
897
+ }
898
+ case 'grupr_workspace_read': {
899
+ const p = String(args.path);
900
+ const res = await hubFetch(`/workspace/file?path=${encodeURIComponent(p)}`);
901
+ if (!res.ok) {
902
+ const body = await res.json().catch(() => null);
903
+ const e = body?.errors?.[0];
904
+ throw new HubError(res.status, e?.code || 'error', e?.message || `HTTP ${res.status}`);
905
+ }
906
+ const buf = Buffer.from(await res.arrayBuffer());
907
+ return { content: [{ type: 'text', text: JSON.stringify({ path: p, size: buf.length, ...textOrBase64(buf) }, null, 2) }] };
908
+ }
909
+ case 'grupr_room_files': {
910
+ const gid = encodeURIComponent(String(args.grupr_id));
911
+ const res = await hubFetch(`/grups/${gid}/files`);
912
+ const body = await res.json().catch(() => null);
913
+ if (!res.ok) {
914
+ const e = body?.errors?.[0];
915
+ throw new HubError(res.status, e?.code || 'error', e?.message || `HTTP ${res.status}`);
916
+ }
917
+ return {
918
+ content: [
919
+ {
920
+ type: 'text',
921
+ text: JSON.stringify({ files: body?.data ?? [], used_bytes: body?.meta?.used_bytes, quota_bytes: body?.meta?.quota_bytes }, null, 2),
922
+ },
923
+ ],
924
+ };
925
+ }
926
+ case 'grupr_room_file_read': {
927
+ const gid = encodeURIComponent(String(args.grupr_id));
928
+ const ref = String(args.file);
929
+ const isId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ref);
930
+ // Documents first: the api's preview carries extracted text for
931
+ // Word / PowerPoint / PDF, sheets for Excel, blocks for Markdown.
932
+ let fileId = isId ? ref : '';
933
+ if (!fileId) {
934
+ const lr = await hubFetch(`/grups/${gid}/files`);
935
+ const lb = await lr.json().catch(() => null);
936
+ const hit = (lb?.data ?? []).find((f) => f.name === ref || f.name === ref.replace(/^\/+/, ''));
937
+ if (hit)
938
+ fileId = hit.file_id;
939
+ }
940
+ if (fileId) {
941
+ const pr = await hubFetch(`/grups/${gid}/files/${fileId}/preview`);
942
+ const pb = await pr.json().catch(() => null);
943
+ if (pr.ok && pb?.data) {
944
+ const rendered = previewToText(pb.data);
945
+ if (rendered !== null) {
946
+ return { content: [{ type: 'text', text: rendered }] };
947
+ }
948
+ }
949
+ }
950
+ const res = await hubFetch(isId ? `/grups/${gid}/files/${ref}` : `/grups/${gid}/file?name=${encodeURIComponent(ref)}`);
951
+ if (!res.ok) {
952
+ const body = await res.json().catch(() => null);
953
+ const e = body?.errors?.[0];
954
+ throw new HubError(res.status, e?.code || 'error', e?.message || `HTTP ${res.status}`);
955
+ }
956
+ const buf = Buffer.from(await res.arrayBuffer());
957
+ return {
958
+ content: [
959
+ {
960
+ type: 'text',
961
+ text: JSON.stringify({ file: ref, size: buf.length, content_type: res.headers.get('content-type') || undefined, ...textOrBase64(buf) }, null, 2),
962
+ },
963
+ ],
964
+ };
965
+ }
966
+ case 'grupr_workspace_publish': {
967
+ const f = await hubJSON('/workspace/publish', {
968
+ method: 'POST',
969
+ headers: { 'Content-Type': 'application/json' },
970
+ body: JSON.stringify({
971
+ grupr_id: String(args.grupr_id),
972
+ path: String(args.path),
973
+ name: typeof args.name === 'string' && args.name ? args.name : undefined,
974
+ }),
975
+ });
976
+ return {
977
+ content: [
978
+ {
979
+ type: 'text',
980
+ text: `Published ${f.name ?? '?'} (${f.size ?? '?'} bytes, v${f.version ?? 1}) to the room's Files. file_id ${f.file_id ?? '?'}.`,
981
+ },
982
+ ],
983
+ };
984
+ }
985
+ case 'grupr_workspace_fetch': {
986
+ const ref = String(args.file);
987
+ const isId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ref);
988
+ const r = await hubJSON('/workspace/fetch', {
989
+ method: 'POST',
990
+ headers: { 'Content-Type': 'application/json' },
991
+ body: JSON.stringify({
992
+ grupr_id: String(args.grupr_id),
993
+ file_id: isId ? ref : undefined,
994
+ name: isId ? undefined : ref,
995
+ path: typeof args.path === 'string' && args.path ? args.path : undefined,
996
+ }),
997
+ });
998
+ return { content: [{ type: 'text', text: `Fetched ${r.name ?? ref} (${r.size ?? '?'} bytes) into ${r.path ?? '?'}.` }] };
999
+ }
1000
+ case 'grupr_workspace_summary': {
1001
+ const days = typeof args.days === 'number' && args.days > 0 ? Math.min(90, Math.floor(args.days)) : 7;
1002
+ const s = await hubJSON(`/workspace/summary?days=${days}`);
1003
+ const min = (sec) => {
1004
+ const m = Math.round((sec ?? 0) / 60);
1005
+ return m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
1006
+ };
1007
+ const lines = [
1008
+ `Last ${days} day${days === 1 ? '' : 's'} (${String(s.since).slice(0, 10)} to ${String(s.until).slice(0, 10)}):`,
1009
+ `- sandbox time: ${min(s.sandbox_seconds)} in ${s.sandbox_sessions} session${s.sandbox_sessions === 1 ? '' : 's'}`,
1010
+ `- runs: ${s.runs}${s.runs_failed ? ` (${s.runs_failed} failed)` : ''}${s.runs_denied ? ` (${s.runs_denied} denied)` : ''}`,
1011
+ `- routine firings: ${s.firings}${s.firings_failed ? ` (${s.firings_failed} failed)` : ''}; routines active: ${s.routines_active}${s.routines_failing ? `, failing now: ${s.routines_failing}` : ''}${s.routines_paused_self ? `, paused themselves: ${s.routines_paused_self}` : ''}`,
1012
+ `- connection uses: ${s.connection_uses}${s.connection_refused ? ` (${s.connection_refused} refused)` : ''} across ${s.connections} connection${s.connections === 1 ? '' : 's'}`,
1013
+ `- files published: ${s.files_published}`,
1014
+ typeof s.budget_seconds === 'number' ? `- daily sandbox budget: ${min(s.budget_seconds)}${s.budget_hit ? ' — reached in this window' : ' — not reached'}` : '- daily sandbox budget: none',
1015
+ ];
1016
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1017
+ }
1018
+ case 'grupr_workspace_download': {
1019
+ const r = await hubJSON('/workspace/download', {
1020
+ method: 'POST',
1021
+ headers: { 'Content-Type': 'application/json' },
1022
+ body: JSON.stringify({
1023
+ url: String(args.url),
1024
+ path: typeof args.path === 'string' && args.path ? args.path : undefined,
1025
+ connection: typeof args.connection === 'string' && args.connection ? args.connection : undefined,
1026
+ }),
1027
+ });
1028
+ const via = r.connection ? ` via connection ${r.connection}` : '';
1029
+ const head = r.ok
1030
+ ? `Fetched ${r.url}${via}: HTTP ${r.status}, ${r.bytes ?? 0} bytes in ${r.ms ?? 0} ms`
1031
+ : `Fetched ${r.url}${via}: HTTP ${r.status}${r.error ? ` (${r.error})` : ''}`;
1032
+ return { content: [{ type: 'text', text: `${head}. Body at ${r.path}; details in ${r.meta_path}.${r.truncated ? ' The body was cut at 5 MB.' : ''}` }] };
1033
+ }
1034
+ case 'grupr_workspace_connection_request': {
1035
+ const res = await hubFetch('/workspace/connections/request', {
1036
+ method: 'POST',
1037
+ headers: { 'Content-Type': 'application/json' },
1038
+ body: JSON.stringify({
1039
+ name: String(args.name),
1040
+ host: String(args.host),
1041
+ auth_type: String(args.auth_type),
1042
+ header_name: typeof args.header_name === 'string' ? args.header_name : undefined,
1043
+ reason: typeof args.reason === 'string' ? args.reason : undefined,
1044
+ grupr_id: typeof args.grupr_id === 'string' && args.grupr_id ? args.grupr_id : undefined,
1045
+ plan: typeof args.then_recipe === 'string' && args.then_recipe
1046
+ ? { recipe: args.then_recipe, params: args.then_params && typeof args.then_params === 'object' ? args.then_params : {} }
1047
+ : undefined,
1048
+ }),
1049
+ });
1050
+ const text = await res.text();
1051
+ let body = null;
1052
+ try {
1053
+ body = text ? JSON.parse(text) : null;
1054
+ }
1055
+ catch {
1056
+ body = null;
1057
+ }
1058
+ if (!res.ok) {
1059
+ const e = body?.errors?.[0];
1060
+ throw new HubError(res.status, e?.code ?? 'error', e?.message ?? `HTTP ${res.status}`);
1061
+ }
1062
+ const r = body?.data ?? {};
1063
+ return { content: [{ type: 'text', text: `${body?.meta?.created ? 'Asked' : 'Already asked'} for connection ${r.name} to ${r.host} (${r.auth_type}).${r.plan ? ` Plan: ${r.plan.summary ?? r.plan.recipe}.` : ''} ${body?.meta?.note ?? ''}`.trim() }] };
1064
+ }
1065
+ case 'grupr_workspace_connections': {
1066
+ const res = await hubFetch('/workspace/connections');
1067
+ const text = await res.text();
1068
+ let body = null;
1069
+ try {
1070
+ body = text ? JSON.parse(text) : null;
1071
+ }
1072
+ catch {
1073
+ body = null;
1074
+ }
1075
+ if (!res.ok) {
1076
+ const e = body?.errors?.[0];
1077
+ throw new HubError(res.status, e?.code ?? 'error', e?.message ?? `HTTP ${res.status}`);
1078
+ }
1079
+ const list = Array.isArray(body?.data) ? body.data : [];
1080
+ const pending = Array.isArray(body?.meta?.pending_requests) ? body.meta.pending_requests : [];
1081
+ const asked = pending.length ? `\nAsked for, waiting for the owner: ${pending.map((p) => `${p.name} → ${p.host}${p.plan ? ` (then ${p.plan.summary ?? p.plan.recipe})` : ''}`).join('; ')}` : '';
1082
+ if (list.length === 0) {
1083
+ return { content: [{ type: 'text', text: 'This agent has no connections. The owner adds them on the agent page (Connections); grupr_workspace_connection_request asks for one.' + asked }] };
1084
+ }
1085
+ const lines = list.map((c) => `- ${c.name} → ${c.host} (${c.auth_type}${c.agent_access ? ', agent may use' : ', routines only'})${c.note ? ` — ${c.note}` : ''}` +
1086
+ (c.auth_failures ? ` ⚠ refused ${c.auth_failures}× in a row (HTTP ${c.last_status}); the owner needs to rotate it` : ''));
1087
+ return { content: [{ type: 'text', text: `Connections (values are never shown):\n${lines.join('\n')}${asked}` }] };
1088
+ }
1089
+ case 'grupr_workspace_schedule': {
1090
+ const res = await hubFetch('/workspace/schedule', {
1091
+ method: 'POST',
1092
+ headers: { 'Content-Type': 'application/json' },
1093
+ body: JSON.stringify({
1094
+ grupr_id: String(args.grupr_id),
1095
+ cmd: String(args.cmd),
1096
+ cron: String(args.cron),
1097
+ timezone: typeof args.timezone === 'string' && args.timezone ? args.timezone : undefined,
1098
+ label: typeof args.label === 'string' && args.label ? args.label : undefined,
1099
+ cwd: typeof args.cwd === 'string' && args.cwd ? args.cwd : undefined,
1100
+ timeout_seconds: typeof args.timeout_seconds === 'number' ? args.timeout_seconds : undefined,
1101
+ outputs: Array.isArray(args.outputs) ? args.outputs : undefined,
1102
+ inputs: Array.isArray(args.inputs) ? args.inputs : undefined,
1103
+ render: typeof args.render === 'boolean' ? args.render : undefined,
1104
+ notify: typeof args.notify === 'string' ? args.notify : undefined,
1105
+ }),
1106
+ });
1107
+ const text = await res.text();
1108
+ let body = null;
1109
+ try {
1110
+ body = text ? JSON.parse(text) : null;
1111
+ }
1112
+ catch {
1113
+ body = null;
1114
+ }
1115
+ if (!res.ok) {
1116
+ const e = body?.errors?.[0];
1117
+ throw new HubError(res.status, e?.code ?? 'error', e?.message ?? `HTTP ${res.status}`);
1118
+ }
1119
+ const d = body?.data ?? {};
1120
+ if (res.status === 202 || d.status === 'pending') {
1121
+ return {
1122
+ content: [
1123
+ {
1124
+ type: 'text',
1125
+ text: `Routine proposed — approval ${d.approval_id ?? '?'} is waiting for a member. Do not re-submit. ` +
1126
+ 'When someone approves, the routine is created and the room is told; if they deny, nothing is scheduled.',
1127
+ },
1128
+ ],
1129
+ };
1130
+ }
1131
+ const sch = d.schedule ?? d;
1132
+ return {
1133
+ content: [
1134
+ {
1135
+ type: 'text',
1136
+ text: `Routine created (a standing rule allowed it): schedule_id ${sch.schedule_id ?? '?'}, ${sch.cron ?? ''} ${sch.timezone ?? ''}, next run ${sch.next_run_at ?? '?'}.`,
1137
+ },
1138
+ ],
291
1139
  };
292
1140
  }
1141
+ case 'grupr_workspace_schedules': {
1142
+ const list = await hubJSON('/workspace/schedules');
1143
+ if (!Array.isArray(list) || list.length === 0) {
1144
+ return { content: [{ type: 'text', text: 'No routines. Propose one with grupr_workspace_schedule.' }] };
1145
+ }
1146
+ const lines = list.map((r) => `- ${r.schedule_id} · ${r.label ? `${r.label}: ` : ''}\`${r.cmd}\` · ${r.cron} ${r.timezone} · room ${r.grupr_id} · ` +
1147
+ `${r.enabled ? 'on' : 'paused'} · next ${r.next_run_at ?? '—'} · last ${r.last_run_at ? `${r.last_run_at} (${r.last_status}${typeof r.last_exit_code === 'number' ? `, exit ${r.last_exit_code}` : ''})` : 'never'} · ` +
1148
+ `${r.runs ?? 0} runs, ${r.failures ?? 0} failed` +
1149
+ (Array.isArray(r.outputs) && r.outputs.length > 0
1150
+ ? ` · publishes ${r.outputs.map((o) => o.name || o.path).join(', ')}${r.render ? ' (rendered)' : ''}`
1151
+ : '') +
1152
+ (r.notify === 'failures' ? ' · posts failures only' : r.notify === 'output' ? ' · posts when it prints or fails' : '') +
1153
+ (typeof r.consecutive_failures === 'number' && r.consecutive_failures > 0 ? ` · FAILING: ${r.consecutive_failures} in a row` : '') +
1154
+ (r.auto_paused_at ? ' · paused itself after repeated failures (owner must Resume)' : ''));
1155
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1156
+ }
1157
+ case 'grupr_workspace_unschedule': {
1158
+ await hubJSON(`/workspace/schedules/${encodeURIComponent(String(args.schedule_id))}`, { method: 'DELETE' });
1159
+ return { content: [{ type: 'text', text: 'Routine removed; the room has been told.' }] };
1160
+ }
1161
+ case 'grupr_workspace_schedule_runs': {
1162
+ const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.min(Math.floor(args.limit), 100) : 10;
1163
+ const list = await hubJSON(`/workspace/schedules/${encodeURIComponent(String(args.schedule_id))}/runs?limit=${limit}`);
1164
+ if (!Array.isArray(list) || list.length === 0) {
1165
+ return { content: [{ type: 'text', text: 'No firings recorded for this routine yet.' }] };
1166
+ }
1167
+ const lines = list.map((r) => {
1168
+ const pub = Array.isArray(r.published) ? r.published : [];
1169
+ const ins = Array.isArray(r.inputs) ? r.inputs : [];
1170
+ const out = [r.stdout, r.stderr, r.error].filter((x) => typeof x === 'string' && x).join('\n').trim();
1171
+ const first = out.split('\n').find((l) => l.trim()) ?? '';
1172
+ return (`- ${r.finished_at ?? r.created_at} · ${r.status}${typeof r.exit_code === 'number' ? ` exit ${r.exit_code}` : ''}` +
1173
+ (typeof r.wall_clock_ms === 'number' ? ` · ${r.wall_clock_ms} ms` : '') +
1174
+ (ins.length ? ` · fetched ${ins.map((i) => `${i.ok ? 'ok' : 'FAILED'}${i.status ? ` ${i.status}` : ''} ${i.url}${i.connection ? ` via ${i.connection}` : ''}${i.error ? ` (${i.error})` : ''}`).join('; ')}` : '') +
1175
+ (pub.length ? ` · published ${pub.map((p) => (p.error ? `${p.name} FAILED (${p.error})` : `${p.name}${p.unchanged ? ' (unchanged)' : ''}`)).join(', ')}` : '') +
1176
+ (first ? ` · ${first.slice(0, 160)}` : ' · no output') +
1177
+ ` · run_id ${r.run_id}`);
1178
+ });
1179
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1180
+ }
1181
+ case 'grupr_workspace_recipes': {
1182
+ const list = await hubJSON('/workspace/recipes');
1183
+ if (!Array.isArray(list) || list.length === 0) {
1184
+ return { content: [{ type: 'text', text: 'This server offers no recipes.' }] };
1185
+ }
1186
+ const lines = list.map((r) => {
1187
+ const params = Array.isArray(r.params) ? r.params : [];
1188
+ const outs = Array.isArray(r.outputs) ? r.outputs : [];
1189
+ return (`- ${r.id}: ${r.title} (${r.every}). ${r.summary}` +
1190
+ (params.length ? ` Parameters: ${params.map((p) => `${p.key} (${p.label}${p.options?.length ? `; one of ${p.options.join(', ')}` : ''}${p.optional ? '; optional' : ''}${p.help ? `; ${p.help}` : ''})`).join(', ')}.` : ' No parameters.') +
1191
+ (outs.length ? ` Publishes ${outs.map((o) => o.name || o.path).join(', ')}.` : ''));
1192
+ });
1193
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
1194
+ }
1195
+ case 'grupr_workspace_recipe': {
1196
+ const res = await hubFetch('/workspace/recipe', {
1197
+ method: 'POST',
1198
+ headers: { 'Content-Type': 'application/json' },
1199
+ body: JSON.stringify({
1200
+ recipe_id: String(args.recipe_id),
1201
+ grupr_id: String(args.grupr_id),
1202
+ params: args.params && typeof args.params === 'object' ? args.params : undefined,
1203
+ timezone: typeof args.timezone === 'string' && args.timezone ? args.timezone : undefined,
1204
+ cron: typeof args.cron === 'string' && args.cron ? args.cron : undefined,
1205
+ }),
1206
+ });
1207
+ const text = await res.text();
1208
+ let body = null;
1209
+ try {
1210
+ body = text ? JSON.parse(text) : null;
1211
+ }
1212
+ catch {
1213
+ body = null;
1214
+ }
1215
+ if (!res.ok) {
1216
+ const e = body?.errors?.[0];
1217
+ throw new HubError(res.status, e?.code ?? 'error', e?.message ?? `HTTP ${res.status}`);
1218
+ }
1219
+ const d = body?.data ?? {};
1220
+ if (d.status === 'awaiting_connection') {
1221
+ return {
1222
+ content: [
1223
+ {
1224
+ type: 'text',
1225
+ text: `No connection named ${d.connection ?? '?'} yet: the owner has been asked for one (to ${d.host ?? '?'}; request ${d.request_id ?? '?'}) with this recipe as the plan. ` +
1226
+ 'When they add the value and say yes, the routine is installed and the room is told. Do not re-submit; grupr_workspace_connections shows when it is ready.',
1227
+ },
1228
+ ],
1229
+ };
1230
+ }
1231
+ if (res.status === 202 || d.status === 'pending') {
1232
+ return {
1233
+ content: [
1234
+ {
1235
+ type: 'text',
1236
+ text: `Recipe requested: approval ${d.approval_id ?? '?'} is waiting for a member. Do not re-submit. ` +
1237
+ 'When someone approves, the script is installed, the routine is created and the room is told; if they deny, nothing is installed.',
1238
+ },
1239
+ ],
1240
+ };
1241
+ }
1242
+ const sch = d.schedule ?? d;
1243
+ return {
1244
+ content: [{ type: 'text', text: `Recipe installed (a standing rule allowed it): schedule_id ${sch.schedule_id ?? '?'}, ${sch.cron ?? ''} ${sch.timezone ?? ''}, next run ${sch.next_run_at ?? '?'}.` }],
1245
+ };
1246
+ }
1247
+ case 'grupr_room_doc_write': {
1248
+ const gid = encodeURIComponent(String(args.grupr_id));
1249
+ const r = await hubJSON(`/grups/${gid}/documents`, {
1250
+ method: 'POST',
1251
+ headers: { 'Content-Type': 'application/json' },
1252
+ body: JSON.stringify({
1253
+ name: typeof args.name === 'string' ? args.name : undefined,
1254
+ title: typeof args.title === 'string' ? args.title : undefined,
1255
+ markdown: String(args.markdown ?? ''),
1256
+ formats: Array.isArray(args.formats) ? args.formats : undefined,
1257
+ }),
1258
+ });
1259
+ const files = (r.files ?? []).map((f) => `${f.name} (${f.size} B, v${f.version}, ${f.file_id})`).join('; ');
1260
+ return { content: [{ type: 'text', text: `Wrote ${r.base ?? '?'} to the room's Files: ${files}.` }] };
1261
+ }
1262
+ case 'grupr_room_sheet_write': {
1263
+ const gid = encodeURIComponent(String(args.grupr_id));
1264
+ const r = await hubJSON(`/grups/${gid}/sheets`, {
1265
+ method: 'POST',
1266
+ headers: { 'Content-Type': 'application/json' },
1267
+ body: JSON.stringify({
1268
+ name: typeof args.name === 'string' ? args.name : undefined,
1269
+ sheets: args.sheets,
1270
+ formats: Array.isArray(args.formats) ? args.formats : undefined,
1271
+ }),
1272
+ });
1273
+ const files = (r.files ?? []).map((f) => `${f.name} (${f.size} B, v${f.version}, ${f.file_id})`).join('; ');
1274
+ return { content: [{ type: 'text', text: `Wrote ${r.base ?? '?'} to the room's Files: ${files}.` }] };
1275
+ }
1276
+ case 'grupr_mail_send': {
1277
+ const res = await hubFetch('/mail/send', {
1278
+ method: 'POST',
1279
+ headers: { 'Content-Type': 'application/json' },
1280
+ body: JSON.stringify({
1281
+ grupr_id: String(args.grupr_id),
1282
+ to: Array.isArray(args.to) ? args.to : [String(args.to ?? '')],
1283
+ cc: Array.isArray(args.cc) ? args.cc : undefined,
1284
+ subject: String(args.subject ?? ''),
1285
+ markdown: String(args.markdown ?? ''),
1286
+ attachments: Array.isArray(args.attachments) ? args.attachments : undefined,
1287
+ in_reply_to: typeof args.in_reply_to === 'string' && args.in_reply_to ? args.in_reply_to : undefined,
1288
+ }),
1289
+ });
1290
+ const body = await res.json().catch(() => null);
1291
+ if (!res.ok) {
1292
+ const e = body?.errors?.[0];
1293
+ throw new HubError(res.status, e?.code || 'error', e?.message || `HTTP ${res.status}`);
1294
+ }
1295
+ const d = body?.data ?? body;
1296
+ if (res.status === 202 || d?.status === 'pending') {
1297
+ return {
1298
+ content: [
1299
+ {
1300
+ type: 'text',
1301
+ text: `Mail ${d?.mail_id ?? '?'} is awaiting a human decision (approval ${d?.approval_id ?? '?'}). It sends the moment someone approves and the receipt is posted in the room. Do not re-send; check grupr_mail_status or watch the room.`,
1302
+ },
1303
+ ],
1304
+ };
1305
+ }
1306
+ return {
1307
+ content: [
1308
+ {
1309
+ type: 'text',
1310
+ text: `Mail ${d?.mail_id ?? '?'} ${d?.status ?? '?'}${d?.provider_id ? ` (provider id ${d.provider_id})` : ''}${d?.auto_approved ? ' — auto-approved by a standing rule' : ''}. To: ${(d?.to ?? []).join(', ')}. Subject: ${d?.subject ?? ''}.`,
1311
+ },
1312
+ ],
1313
+ };
1314
+ }
1315
+ case 'grupr_mail_inbox': {
1316
+ const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.min(100, Math.floor(args.limit)) : 10;
1317
+ const res = await hubFetch(`/mail?direction=in&limit=${limit}`);
1318
+ const body = await res.json().catch(() => null);
1319
+ if (!res.ok) {
1320
+ const e = body?.errors?.[0];
1321
+ throw new HubError(res.status, e?.code || 'error', e?.message || `HTTP ${res.status}`);
1322
+ }
1323
+ const items = (body?.data ?? []).map((m) => ({
1324
+ mail_id: m.mail_id,
1325
+ from: m.from,
1326
+ subject: m.subject,
1327
+ received_at: m.sent_at || m.created_at,
1328
+ grupr_id: m.grupr_id,
1329
+ in_reply_to: m.in_reply_to,
1330
+ attachments: (m.attachments ?? []).map((a) => a.name),
1331
+ }));
1332
+ return {
1333
+ content: [
1334
+ {
1335
+ type: 'text',
1336
+ text: JSON.stringify({ address: body?.meta?.address || null, inbound_enabled: body?.meta?.inbound_enabled ?? null, mail: items }, null, 2),
1337
+ },
1338
+ ],
1339
+ };
1340
+ }
1341
+ case 'grupr_mail_read': {
1342
+ const m = await hubJSON(`/mail/${encodeURIComponent(String(args.mail_id))}`);
1343
+ return { content: [{ type: 'text', text: JSON.stringify(m, null, 2) }] };
1344
+ }
1345
+ case 'grupr_mail_status': {
1346
+ const m = await hubJSON(`/mail/${encodeURIComponent(String(args.mail_id))}`);
1347
+ return { content: [{ type: 'text', text: JSON.stringify(m, null, 2) }] };
1348
+ }
1349
+ case 'grupr_workspace_write': {
1350
+ const p = String(args.path);
1351
+ const res = await hubJSON(`/workspace/file?path=${encodeURIComponent(p)}`, {
1352
+ method: 'PUT',
1353
+ headers: { 'Content-Type': 'application/octet-stream' },
1354
+ body: String(args.content ?? ''),
1355
+ });
1356
+ return { content: [{ type: 'text', text: `Wrote ${res.size ?? '?'} bytes to ${res.path ?? p}.` }] };
1357
+ }
293
1358
  default:
294
1359
  throw new Error(`Unknown tool: ${name}`);
295
1360
  }
296
1361
  }
297
1362
  catch (err) {
1363
+ if (err instanceof HubError) {
1364
+ const hint = err.code === 'command_denied' || err.code === 'mail_denied'
1365
+ ? ' The human said no — do not retry; explain in the room instead.'
1366
+ : err.code === 'approval_timeout'
1367
+ ? ' Nobody decided in time and the request was cancelled. Ask in the room, then call again.'
1368
+ : err.status === 503
1369
+ ? ' Workspaces are not enabled on this Grupr server.'
1370
+ : '';
1371
+ return {
1372
+ isError: true,
1373
+ content: [
1374
+ {
1375
+ type: 'text',
1376
+ text: `Grupr ${err.status} ${err.code}: ${err.message}.${hint}${err.approvalId ? ` (approval ${err.approvalId})` : ''}`,
1377
+ },
1378
+ ],
1379
+ };
1380
+ }
298
1381
  if (err instanceof sdk_1.GruprAuthError) {
299
1382
  return {
300
1383
  isError: true,