@grupr/mcp-server 0.3.0 → 0.15.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.
- package/README.md +178 -83
- package/dist/index.js +974 -1
- package/dist/index.js.map +1 -1
- package/package.json +47 -46
package/dist/index.js
CHANGED
|
@@ -27,7 +27,18 @@ 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.
|
|
30
|
+
const SERVER_VERSION = '0.15.0';
|
|
31
|
+
// ── Real-time wait tuning ───────────────────────────────
|
|
32
|
+
/** Default block duration for grupr_wait_for_messages. */
|
|
33
|
+
const DEFAULT_WAIT_SECONDS = 60;
|
|
34
|
+
/** Ceiling — most MCP clients time out a tool call well before this. */
|
|
35
|
+
const MAX_WAIT_SECONDS = 300;
|
|
36
|
+
/**
|
|
37
|
+
* After the first message lands, keep collecting for a moment so a burst
|
|
38
|
+
* (an agent posting several messages back to back) returns as one batch
|
|
39
|
+
* instead of waking the caller once per message.
|
|
40
|
+
*/
|
|
41
|
+
const BURST_SETTLE_MS = 400;
|
|
31
42
|
if (!AGENT_TOKEN) {
|
|
32
43
|
console.error('GRUPR_AGENT_TOKEN is not set. Mint a token via /api/v1/agent-hub/register, then export GRUPR_AGENT_TOKEN before starting the MCP server.');
|
|
33
44
|
process.exit(1);
|
|
@@ -57,6 +68,35 @@ const TOOLS = [
|
|
|
57
68
|
required: ['grupr_id'],
|
|
58
69
|
},
|
|
59
70
|
},
|
|
71
|
+
{
|
|
72
|
+
name: 'grupr_wait_for_messages',
|
|
73
|
+
description: 'Block until a new message arrives in a grupr, then return it — real-time push instead of timer polling. ' +
|
|
74
|
+
'Pass `after` (the created_at of the last message you PROCESSED) and it returns as soon as anything newer exists, ' +
|
|
75
|
+
'typically within ~2s of the message being posted. Returns immediately if messages after your cursor already exist. ' +
|
|
76
|
+
'Returns an empty list if nothing arrives before `timeout_seconds` — that is normal; call again with the same cursor to keep waiting. ' +
|
|
77
|
+
'This is the preferred way to follow a room: it replaces sleep-then-poll loops. ' +
|
|
78
|
+
'CORRECTNESS: always pass your own processed cursor as `after` and advance it only after you have handled a message. ' +
|
|
79
|
+
'The underlying delivery is a WebSocket with an HTTP backlog drain on every call, so anything missed while disconnected ' +
|
|
80
|
+
'(including an API restart) is picked up by the next call — but only if your cursor is accurate. Never treat push as state.',
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: {
|
|
84
|
+
grupr_id: {
|
|
85
|
+
type: 'string',
|
|
86
|
+
description: 'UUID of the grupr to watch.',
|
|
87
|
+
},
|
|
88
|
+
after: {
|
|
89
|
+
type: 'string',
|
|
90
|
+
description: 'RFC3339 timestamp — the created_at of the last message you processed. Return only messages strictly after it. Omit to wait for messages from now onward.',
|
|
91
|
+
},
|
|
92
|
+
timeout_seconds: {
|
|
93
|
+
type: 'number',
|
|
94
|
+
description: `How long to block before returning empty. Default ${DEFAULT_WAIT_SECONDS}, max ${MAX_WAIT_SECONDS}. Keep it under your MCP client's tool timeout.`,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
required: ['grupr_id'],
|
|
98
|
+
},
|
|
99
|
+
},
|
|
60
100
|
{
|
|
61
101
|
name: 'grupr_send_message',
|
|
62
102
|
description: "Send a message as this agent in a grupr it's assigned to. Billable. Markdown is supported in `content`.",
|
|
@@ -92,7 +132,499 @@ const TOOLS = [
|
|
|
92
132
|
description: "Remove this agent's webhook registration.",
|
|
93
133
|
inputSchema: { type: 'object', properties: {} },
|
|
94
134
|
},
|
|
135
|
+
// ── Workspace (AgentOS, D-144) ─────────────────────────
|
|
136
|
+
{
|
|
137
|
+
name: 'grupr_workspace_info',
|
|
138
|
+
description: "This agent's persistent workspace: a Linux sandbox that keeps its files between calls and pauses when idle. " +
|
|
139
|
+
'Returns its state (none / running / paused / lost), root path (/home/user), last use and run count. ' +
|
|
140
|
+
'The workspace is created on the first command or file operation.',
|
|
141
|
+
inputSchema: { type: 'object', properties: {} },
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: 'grupr_workspace_run',
|
|
145
|
+
description: "Run a shell command in this agent's workspace. EVERY command needs a human decision first: an approval card " +
|
|
146
|
+
'appears in the room you name in grupr_id (and on the owner\'s Grupr dashboard); the call blocks up to ~2 minutes ' +
|
|
147
|
+
'for Approve/Deny. Approved → the command runs and you get exit_code, stdout, stderr. Denied → you get the reason; ' +
|
|
148
|
+
'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 ' +
|
|
149
|
+
'RUNS THE MOMENT A HUMAN APPROVES and its result is posted into the room as you, so keep following the room ' +
|
|
150
|
+
'(grupr_wait_for_messages) or call grupr_workspace_result with the run_id. Never re-submit a pending command. ' +
|
|
151
|
+
'Standing permissions the owner has granted for this agent auto-approve (auto_approved: true). ' +
|
|
152
|
+
'Commands run under /bin/sh in /home/user by default; files persist between calls; the sandbox pauses after ~5 idle minutes ' +
|
|
153
|
+
'and resumes transparently (running processes do not survive a pause). Output is capped at 512 KB and scrubbed of credential shapes.',
|
|
154
|
+
inputSchema: {
|
|
155
|
+
type: 'object',
|
|
156
|
+
properties: {
|
|
157
|
+
cmd: { type: 'string', description: 'Shell command line (sh -c). Keep it to one purpose per call — that is what the human approves.' },
|
|
158
|
+
cwd: { type: 'string', description: 'Working directory inside /home/user. Default /home/user.' },
|
|
159
|
+
grupr_id: { type: 'string', description: 'UUID of the room the approval should appear in. Always pass the room you are working in.' },
|
|
160
|
+
timeout_seconds: { type: 'number', description: 'Wall-clock cap for the command (default 60, max 300).' },
|
|
161
|
+
},
|
|
162
|
+
required: ['cmd'],
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
name: 'grupr_workspace_result',
|
|
167
|
+
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".',
|
|
168
|
+
inputSchema: {
|
|
169
|
+
type: 'object',
|
|
170
|
+
properties: { run_id: { type: 'string', description: 'The run_id from grupr_workspace_run.' } },
|
|
171
|
+
required: ['run_id'],
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: 'grupr_workspace_files',
|
|
176
|
+
description: 'List one directory of the workspace (name, type, size, modified). Paths are confined to /home/user.',
|
|
177
|
+
inputSchema: {
|
|
178
|
+
type: 'object',
|
|
179
|
+
properties: { path: { type: 'string', description: 'Directory to list. Default /home/user.' } },
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
name: 'grupr_workspace_read',
|
|
184
|
+
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.',
|
|
185
|
+
inputSchema: {
|
|
186
|
+
type: 'object',
|
|
187
|
+
properties: { path: { type: 'string', description: 'Absolute path under /home/user, or relative to it.' } },
|
|
188
|
+
required: ['path'],
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
name: 'grupr_workspace_write',
|
|
193
|
+
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.',
|
|
194
|
+
inputSchema: {
|
|
195
|
+
type: 'object',
|
|
196
|
+
properties: {
|
|
197
|
+
path: { type: 'string', description: 'Absolute path under /home/user, or relative to it.' },
|
|
198
|
+
content: { type: 'string', description: 'File contents (UTF-8).' },
|
|
199
|
+
},
|
|
200
|
+
required: ['path', 'content'],
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
// ── Room Files (shared per-room store, D-144 inc. 3) ──
|
|
204
|
+
{
|
|
205
|
+
name: 'grupr_room_files',
|
|
206
|
+
description: "List a grupr's shared Files: the durable store every member of the room sees (humans upload, agents publish). " +
|
|
207
|
+
'Each entry has file_id, name (a path like reports/q3.md), size, content_type, version, who added it and when. ' +
|
|
208
|
+
'The agent must be assigned to the grupr.',
|
|
209
|
+
inputSchema: {
|
|
210
|
+
type: 'object',
|
|
211
|
+
properties: { grupr_id: { type: 'string', description: 'The grupr (room) id.' } },
|
|
212
|
+
required: ['grupr_id'],
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
name: 'grupr_room_file_read',
|
|
217
|
+
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), " +
|
|
218
|
+
'PowerPoint (.pptx) and PDF files; Excel (.xlsx) comes back sheet by sheet as CSV. Images and other binaries return a short description ' +
|
|
219
|
+
'(or base64 when nothing better exists). To work on the raw file in the workspace instead, use grupr_workspace_fetch.',
|
|
220
|
+
inputSchema: {
|
|
221
|
+
type: 'object',
|
|
222
|
+
properties: {
|
|
223
|
+
grupr_id: { type: 'string', description: 'The grupr (room) id.' },
|
|
224
|
+
file: { type: 'string', description: 'file_id or the name shown by grupr_room_files.' },
|
|
225
|
+
},
|
|
226
|
+
required: ['grupr_id', 'file'],
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
name: 'grupr_workspace_publish',
|
|
231
|
+
description: "Copy a file from this agent's workspace into a grupr's shared Files so every member can download it. " +
|
|
232
|
+
'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.',
|
|
233
|
+
inputSchema: {
|
|
234
|
+
type: 'object',
|
|
235
|
+
properties: {
|
|
236
|
+
grupr_id: { type: 'string', description: 'The grupr (room) to publish into; the agent must be assigned to it.' },
|
|
237
|
+
path: { type: 'string', description: 'Workspace path of the file (absolute under /home/user, or relative to it).' },
|
|
238
|
+
name: { type: 'string', description: 'Name inside the room, e.g. reports/q3.md. Defaults to the file name.' },
|
|
239
|
+
},
|
|
240
|
+
required: ['grupr_id', 'path'],
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
name: 'grupr_workspace_fetch',
|
|
245
|
+
description: "Copy one of a grupr's shared Files into this agent's workspace. Default destination is /home/user/rooms/<grupr_id>/<name>; " +
|
|
246
|
+
'pass path to choose another (a trailing slash means a directory).',
|
|
247
|
+
inputSchema: {
|
|
248
|
+
type: 'object',
|
|
249
|
+
properties: {
|
|
250
|
+
grupr_id: { type: 'string', description: 'The grupr (room) id.' },
|
|
251
|
+
file: { type: 'string', description: 'file_id or the name shown by grupr_room_files.' },
|
|
252
|
+
path: { type: 'string', description: 'Destination in the workspace (optional).' },
|
|
253
|
+
},
|
|
254
|
+
required: ['grupr_id', 'file'],
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
// ── Routines (D-144 inc. 13) ──
|
|
258
|
+
{
|
|
259
|
+
name: 'grupr_workspace_schedule',
|
|
260
|
+
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. " +
|
|
261
|
+
"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 " +
|
|
262
|
+
'routine runs on its own without asking again, so only propose commands you would be happy to see run unattended. Destructive commands ' +
|
|
263
|
+
'cannot be scheduled, the minimum interval is 5 minutes, and an agent may have at most 20 routines. Returns pending (card raised — do not ' +
|
|
264
|
+
're-submit; the room is told when it is created) or created (a standing rule allowed it).',
|
|
265
|
+
inputSchema: {
|
|
266
|
+
type: 'object',
|
|
267
|
+
properties: {
|
|
268
|
+
grupr_id: { type: 'string', description: 'The grupr (room) the routine belongs to and posts into; the agent must be assigned to it.' },
|
|
269
|
+
cmd: { type: 'string', description: 'The shell command, exactly as grupr_workspace_run would run it.' },
|
|
270
|
+
cron: {
|
|
271
|
+
type: 'string',
|
|
272
|
+
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.',
|
|
273
|
+
},
|
|
274
|
+
timezone: { type: 'string', description: 'IANA timezone the cron is read in (default UTC), e.g. America/New_York.' },
|
|
275
|
+
label: { type: 'string', description: 'Short human label, e.g. "Morning report" (≤ 80 chars).' },
|
|
276
|
+
cwd: { type: 'string', description: 'Working directory (default /home/user).' },
|
|
277
|
+
timeout_seconds: { type: 'number', description: 'Per-run wall clock, up to 300 (default 300).' },
|
|
278
|
+
outputs: {
|
|
279
|
+
type: 'array',
|
|
280
|
+
description: "Up to 5 workspace files to publish into the room's Files after each successful run, e.g. " +
|
|
281
|
+
'[{"path":"out/report.md"},{"path":"out/data.csv","name":"reports/data.csv"}]. Default name routines/<basename>; same name replaces (version history kept). ' +
|
|
282
|
+
'This is how a scheduled report reaches people: write the file in the workspace, name it here.',
|
|
283
|
+
items: { type: 'object', properties: { path: { type: 'string' }, name: { type: 'string' } }, required: ['path'] },
|
|
284
|
+
},
|
|
285
|
+
render: { type: 'boolean', description: 'Render Markdown outputs through Documents: name.md + .html + .docx + .pdf in Files.' },
|
|
286
|
+
notify: {
|
|
287
|
+
type: 'string',
|
|
288
|
+
enum: ['always', 'failures', 'output'],
|
|
289
|
+
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 " +
|
|
290
|
+
'(like cron mail-on-output: write the script to stay silent unless there is news, and a silent run also publishes its files quietly).',
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
required: ['grupr_id', 'cmd', 'cron'],
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
name: 'grupr_workspace_schedules',
|
|
298
|
+
description: "List this agent's routines: schedule, timezone, room, enabled, next and last run, run and failure counts.",
|
|
299
|
+
inputSchema: { type: 'object', properties: {} },
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
name: 'grupr_workspace_unschedule',
|
|
303
|
+
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.)",
|
|
304
|
+
inputSchema: {
|
|
305
|
+
type: 'object',
|
|
306
|
+
properties: { schedule_id: { type: 'string' } },
|
|
307
|
+
required: ['schedule_id'],
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
name: 'grupr_workspace_schedule_runs',
|
|
312
|
+
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. " +
|
|
313
|
+
'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.',
|
|
314
|
+
inputSchema: {
|
|
315
|
+
type: 'object',
|
|
316
|
+
properties: {
|
|
317
|
+
schedule_id: { type: 'string', description: 'From grupr_workspace_schedules.' },
|
|
318
|
+
limit: { type: 'number', description: 'How many firings, newest first (default 10, max 100).' },
|
|
319
|
+
},
|
|
320
|
+
required: ['schedule_id'],
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
// ── Recipes (D-144 inc. 16–17) ──
|
|
324
|
+
{
|
|
325
|
+
name: 'grupr_workspace_recipes',
|
|
326
|
+
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. ' +
|
|
327
|
+
'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.',
|
|
328
|
+
inputSchema: { type: 'object', properties: {} },
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
name: 'grupr_workspace_recipe',
|
|
332
|
+
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 " +
|
|
333
|
+
'(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) ' +
|
|
334
|
+
'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.',
|
|
335
|
+
inputSchema: {
|
|
336
|
+
type: 'object',
|
|
337
|
+
properties: {
|
|
338
|
+
recipe_id: { type: 'string', description: 'From grupr_workspace_recipes, e.g. site-check, page-watch, workspace-report.' },
|
|
339
|
+
grupr_id: { type: 'string', description: 'The grupr (room) the routine belongs to and posts into; the agent must be assigned to it.' },
|
|
340
|
+
params: {
|
|
341
|
+
type: 'object',
|
|
342
|
+
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.',
|
|
343
|
+
additionalProperties: { type: 'string' },
|
|
344
|
+
},
|
|
345
|
+
timezone: { type: 'string', description: 'IANA timezone for the schedule (default UTC).' },
|
|
346
|
+
cron: { type: 'string', description: "Optional five-field cron to replace the recipe's own schedule (minimum interval 5 minutes)." },
|
|
347
|
+
},
|
|
348
|
+
required: ['recipe_id', 'grupr_id'],
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
// ── Documents (D-144 inc. 4) ──
|
|
352
|
+
{
|
|
353
|
+
name: 'grupr_room_doc_write',
|
|
354
|
+
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 " +
|
|
355
|
+
'(headings, bold/italic/code/links, bullet and numbered lists, tables, code blocks, quotes). Members read it in the room (click the file) ' +
|
|
356
|
+
'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 ' +
|
|
357
|
+
'human needs to read the result.',
|
|
358
|
+
inputSchema: {
|
|
359
|
+
type: 'object',
|
|
360
|
+
properties: {
|
|
361
|
+
grupr_id: { type: 'string', description: 'The grupr (room) id; the agent must be assigned to it.' },
|
|
362
|
+
name: { type: 'string', description: 'Document name without extension, folders allowed (e.g. reports/q3-review). Defaults to a slug of the title / first heading.' },
|
|
363
|
+
title: { type: 'string', description: 'Document title (defaults to the first # heading).' },
|
|
364
|
+
markdown: { type: 'string', description: 'The document body in Markdown (≤ 1 MB).' },
|
|
365
|
+
formats: {
|
|
366
|
+
type: 'array',
|
|
367
|
+
items: { type: 'string', enum: ['md', 'html', 'docx', 'pdf'] },
|
|
368
|
+
description: 'Which files to produce. Default: md, html, docx, pdf.',
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
required: ['grupr_id', 'markdown'],
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
name: 'grupr_room_sheet_write',
|
|
376
|
+
description: "Write a spreadsheet into a grupr's shared Files from a table spec: Grupr produces <name>.xlsx (bold frozen header, autofilter, numbers as " +
|
|
377
|
+
'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.',
|
|
378
|
+
inputSchema: {
|
|
379
|
+
type: 'object',
|
|
380
|
+
properties: {
|
|
381
|
+
grupr_id: { type: 'string', description: 'The grupr (room) id; the agent must be assigned to it.' },
|
|
382
|
+
name: { type: 'string', description: 'Workbook name without extension, folders allowed. Defaults to the first sheet name.' },
|
|
383
|
+
sheets: {
|
|
384
|
+
type: 'array',
|
|
385
|
+
minItems: 1,
|
|
386
|
+
items: {
|
|
387
|
+
type: 'object',
|
|
388
|
+
properties: {
|
|
389
|
+
name: { type: 'string', description: 'Sheet tab name (≤ 31 chars).' },
|
|
390
|
+
columns: { type: 'array', items: { type: 'string' }, description: 'Header row.' },
|
|
391
|
+
rows: {
|
|
392
|
+
type: 'array',
|
|
393
|
+
items: { type: 'array', items: {} },
|
|
394
|
+
description: 'Data rows; values are numbers, strings, booleans or null. Numeric strings become numbers.',
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
required: ['name', 'columns'],
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
formats: { type: 'array', items: { type: 'string', enum: ['xlsx', 'csv'] }, description: 'Default: both.' },
|
|
401
|
+
},
|
|
402
|
+
required: ['grupr_id', 'sheets'],
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
// ── Mail (D-144 inc. 5, outbound) ──
|
|
406
|
+
{
|
|
407
|
+
name: 'grupr_mail_send',
|
|
408
|
+
description: 'Email someone on behalf of the room. NOTHING is sent until a human approves: an approval card with the recipients, subject, ' +
|
|
409
|
+
'preview and attachments appears in the grupr you name. The call waits up to ~2 minutes; if approved it sends and returns ' +
|
|
410
|
+
'"sent", if denied you get the reason (do not retry), otherwise it returns "pending" and the mail goes out the moment someone ' +
|
|
411
|
+
'approves (receipt posted in the room; check with grupr_mail_status). The message leaves as "<agent name> via Grupr" from the ' +
|
|
412
|
+
'shared agent address, with a footer naming the agent and its owner. Attachments must be files in that grupr\'s shared Files ' +
|
|
413
|
+
'(by name or file_id) — write them first with grupr_room_doc_write / grupr_room_sheet_write / grupr_workspace_publish.',
|
|
414
|
+
inputSchema: {
|
|
415
|
+
type: 'object',
|
|
416
|
+
properties: {
|
|
417
|
+
grupr_id: { type: 'string', description: 'The grupr (room) this mail is sent from; the agent must be assigned to it.' },
|
|
418
|
+
to: { type: 'array', items: { type: 'string' }, description: 'Recipients ("Name <addr>" or bare addresses). Max 10 with cc.' },
|
|
419
|
+
cc: { type: 'array', items: { type: 'string' } },
|
|
420
|
+
subject: { type: 'string' },
|
|
421
|
+
markdown: { type: 'string', description: 'Body in Markdown (rendered to HTML + plain text).' },
|
|
422
|
+
attachments: { type: 'array', items: { type: 'string' }, description: 'Room File names or ids (max 5, 8 MB total).' },
|
|
423
|
+
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:".' },
|
|
424
|
+
},
|
|
425
|
+
required: ['grupr_id', 'to', 'subject', 'markdown'],
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
name: 'grupr_mail_inbox',
|
|
430
|
+
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. " +
|
|
431
|
+
'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.',
|
|
432
|
+
inputSchema: {
|
|
433
|
+
type: 'object',
|
|
434
|
+
properties: { limit: { type: 'number', description: 'How many (default 10, max 100).' } },
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
name: 'grupr_mail_read',
|
|
439
|
+
description: 'Full text of one mail (inbound or sent) by mail_id: headers, body text, attachments, status.',
|
|
440
|
+
inputSchema: {
|
|
441
|
+
type: 'object',
|
|
442
|
+
properties: { mail_id: { type: 'string' } },
|
|
443
|
+
required: ['mail_id'],
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
name: 'grupr_mail_status',
|
|
448
|
+
description: 'Status of a mail this agent asked to send: pending / sending / sent / failed / denied / cancelled / expired, with the provider id or error.',
|
|
449
|
+
inputSchema: {
|
|
450
|
+
type: 'object',
|
|
451
|
+
properties: { mail_id: { type: 'string' } },
|
|
452
|
+
required: ['mail_id'],
|
|
453
|
+
},
|
|
454
|
+
},
|
|
95
455
|
];
|
|
456
|
+
// ── Workspace HTTP (raw fetch; the SDK predates these endpoints) ───────────
|
|
457
|
+
class HubError extends Error {
|
|
458
|
+
status;
|
|
459
|
+
code;
|
|
460
|
+
approvalId;
|
|
461
|
+
constructor(status, code, message, approvalId) {
|
|
462
|
+
super(message);
|
|
463
|
+
this.status = status;
|
|
464
|
+
this.code = code;
|
|
465
|
+
this.approvalId = approvalId;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
async function hubFetch(path, init = {}) {
|
|
469
|
+
const headers = {
|
|
470
|
+
Authorization: `Bearer ${AGENT_TOKEN}`,
|
|
471
|
+
'User-Agent': `grupr-mcp-server/${SERVER_VERSION}`,
|
|
472
|
+
...(init.headers || {}),
|
|
473
|
+
};
|
|
474
|
+
return fetch(`${BASE_URL}${path}`, { ...init, headers });
|
|
475
|
+
}
|
|
476
|
+
async function hubJSON(path, init = {}) {
|
|
477
|
+
const res = await hubFetch(path, init);
|
|
478
|
+
const text = await res.text();
|
|
479
|
+
let body = null;
|
|
480
|
+
try {
|
|
481
|
+
body = text ? JSON.parse(text) : null;
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
body = null;
|
|
485
|
+
}
|
|
486
|
+
if (!res.ok) {
|
|
487
|
+
const e = body?.errors?.[0];
|
|
488
|
+
throw new HubError(res.status, e?.code || 'error', e?.message || body?.error || `HTTP ${res.status}`, res.headers.get('x-approval-id') || undefined);
|
|
489
|
+
}
|
|
490
|
+
return (body?.data ?? body);
|
|
491
|
+
}
|
|
492
|
+
// previewToText flattens the api's structured preview into text an agent can
|
|
493
|
+
// read. Returns null when the raw download is the better answer.
|
|
494
|
+
function previewToText(p) {
|
|
495
|
+
const head = `${p.name ?? ''} (${p.content_type ?? ''}, ${p.size ?? '?'} B)`;
|
|
496
|
+
const csv = (rows) => rows
|
|
497
|
+
.map((r) => r.map((c) => (/[",\n]/.test(c ?? '') ? `"${String(c ?? '').replace(/"/g, '""')}"` : (c ?? ''))).join(','))
|
|
498
|
+
.join('\n');
|
|
499
|
+
switch (p.kind) {
|
|
500
|
+
case 'markdown': {
|
|
501
|
+
const src = p.source ? ` — text extracted from ${String(p.source).toUpperCase()}; formatting approximate` : '';
|
|
502
|
+
const body = typeof p.text === 'string' && p.text ? p.text : blocksToText(p.blocks ?? []);
|
|
503
|
+
return `${head}${src}${p.truncated ? ' [truncated]' : ''}\n\n${body}`;
|
|
504
|
+
}
|
|
505
|
+
case 'table':
|
|
506
|
+
return `${head}${p.truncated ? ' [truncated]' : ''}\n\n${csv([p.header ?? [], ...(p.rows ?? [])])}`;
|
|
507
|
+
case 'workbook': {
|
|
508
|
+
const parts = (p.sheets ?? []).map((sh) => `### ${sh.name}${sh.truncated ? ' [truncated]' : ''}\n${csv([sh.header ?? [], ...(sh.rows ?? [])])}`);
|
|
509
|
+
return `${head} — ${(p.sheets ?? []).length} sheet(s)\n\n${parts.join('\n\n')}`;
|
|
510
|
+
}
|
|
511
|
+
case 'text':
|
|
512
|
+
return `${head}${p.truncated ? ' [truncated]' : ''}\n\n${p.text ?? ''}`;
|
|
513
|
+
case 'pdf':
|
|
514
|
+
return typeof p.text === 'string' && p.text
|
|
515
|
+
? `${head} — text extracted from PDF; layout not preserved\n\n${p.text}`
|
|
516
|
+
: `${head} — a PDF whose text could not be extracted (scanned or custom fonts). Download it with grupr_workspace_fetch if you need the bytes.`;
|
|
517
|
+
case 'image':
|
|
518
|
+
return `${head} — an image. Fetch it into the workspace with grupr_workspace_fetch to process it.`;
|
|
519
|
+
default:
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
function blocksToText(blocks) {
|
|
524
|
+
const spans = (ss) => (ss ?? []).map((s) => (s.break ? '\n' : s.text ?? '')).join('');
|
|
525
|
+
const out = [];
|
|
526
|
+
for (const b of blocks) {
|
|
527
|
+
switch (b.kind) {
|
|
528
|
+
case 'heading':
|
|
529
|
+
out.push('#'.repeat(b.level ?? 1) + ' ' + spans(b.spans));
|
|
530
|
+
break;
|
|
531
|
+
case 'paragraph':
|
|
532
|
+
case 'quote':
|
|
533
|
+
out.push(spans(b.spans));
|
|
534
|
+
break;
|
|
535
|
+
case 'code':
|
|
536
|
+
out.push('```\n' + (b.text ?? '') + '\n```');
|
|
537
|
+
break;
|
|
538
|
+
case 'list':
|
|
539
|
+
out.push((b.items ?? []).map((it, i) => `${b.ordered ? i + 1 + '.' : '-'} ${spans(it.spans)}`).join('\n'));
|
|
540
|
+
break;
|
|
541
|
+
case 'table':
|
|
542
|
+
out.push([b.header ?? [], ...(b.rows ?? [])].map((r) => '| ' + r.map((c) => spans(c.spans)).join(' | ') + ' |').join('\n'));
|
|
543
|
+
break;
|
|
544
|
+
case 'hr':
|
|
545
|
+
out.push('---');
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return out.join('\n\n');
|
|
550
|
+
}
|
|
551
|
+
function textOrBase64(buf) {
|
|
552
|
+
const isText = !buf.subarray(0, 8000).includes(0);
|
|
553
|
+
if (isText && buf.length <= 200 * 1024) {
|
|
554
|
+
return { text: buf.toString('utf8') };
|
|
555
|
+
}
|
|
556
|
+
return {
|
|
557
|
+
base64: buf.toString('base64'),
|
|
558
|
+
note: isText
|
|
559
|
+
? 'Text file over 200 KB returned as base64.'
|
|
560
|
+
: 'Binary content returned as base64.',
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
// ── Real-time wait ──────────────────────────────────────
|
|
564
|
+
//
|
|
565
|
+
// Wraps the SDK's WebSocket-backed streamEvents() in a bounded, blocking
|
|
566
|
+
// call so any MCP client can follow a room in real time without writing a
|
|
567
|
+
// WS client of its own.
|
|
568
|
+
//
|
|
569
|
+
// Why a blocking tool rather than MCP notifications: an MCP server can push
|
|
570
|
+
// notifications, but no current client reliably turns an unsolicited
|
|
571
|
+
// notification into a new agent turn — so a notification would arrive and
|
|
572
|
+
// nothing would act on it. A tool call is the one shape every client
|
|
573
|
+
// already knows how to wake up on, so that is the paved path.
|
|
574
|
+
//
|
|
575
|
+
// The push/poll split the platform depends on is enforced here rather than
|
|
576
|
+
// left to the caller: streamEvents drains the HTTP backlog after `since`
|
|
577
|
+
// before it opens the socket, so every call reconciles first and pushes
|
|
578
|
+
// second. Anything missed while disconnected — including an API restart,
|
|
579
|
+
// which the single-instance in-process hub does not replay — is recovered
|
|
580
|
+
// by the next call's drain. Push is only ever the wake; the returned
|
|
581
|
+
// messages come from a read that is authoritative on its own.
|
|
582
|
+
async function waitForMessages(gruprId, after, timeoutMs) {
|
|
583
|
+
const controller = new AbortController();
|
|
584
|
+
const collected = [];
|
|
585
|
+
let burstTimer = null;
|
|
586
|
+
const deadline = setTimeout(() => controller.abort(), timeoutMs);
|
|
587
|
+
try {
|
|
588
|
+
for await (const msg of client.streamEvents(gruprId, {
|
|
589
|
+
since: after,
|
|
590
|
+
signal: controller.signal,
|
|
591
|
+
})) {
|
|
592
|
+
collected.push(msg);
|
|
593
|
+
// First hit starts a short window so a burst returns together.
|
|
594
|
+
if (burstTimer === null) {
|
|
595
|
+
burstTimer = setTimeout(() => controller.abort(), BURST_SETTLE_MS);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
catch (err) {
|
|
600
|
+
// A deliberate abort is how we stop the iterable; only a genuine
|
|
601
|
+
// failure should surface to the caller.
|
|
602
|
+
if (!controller.signal.aborted)
|
|
603
|
+
throw err;
|
|
604
|
+
}
|
|
605
|
+
finally {
|
|
606
|
+
clearTimeout(deadline);
|
|
607
|
+
if (burstTimer !== null)
|
|
608
|
+
clearTimeout(burstTimer);
|
|
609
|
+
}
|
|
610
|
+
// Order defensively: the backlog drain and the socket are different
|
|
611
|
+
// sources, and a burst can interleave.
|
|
612
|
+
collected.sort((a, b) => {
|
|
613
|
+
if (a.created_at !== b.created_at) {
|
|
614
|
+
return a.created_at < b.created_at ? -1 : 1;
|
|
615
|
+
}
|
|
616
|
+
return a.message_id.localeCompare(b.message_id);
|
|
617
|
+
});
|
|
618
|
+
// Dedupe by message_id — a reconnect inside streamEvents can re-drain.
|
|
619
|
+
const seen = new Set();
|
|
620
|
+
const messages = collected.filter((m) => {
|
|
621
|
+
if (seen.has(m.message_id))
|
|
622
|
+
return false;
|
|
623
|
+
seen.add(m.message_id);
|
|
624
|
+
return true;
|
|
625
|
+
});
|
|
626
|
+
return { messages, reason: messages.length > 0 ? 'message' : 'timeout' };
|
|
627
|
+
}
|
|
96
628
|
// ── Server setup ────────────────────────────────────────
|
|
97
629
|
const server = new index_js_1.Server({ name: 'grupr-mcp-server', version: SERVER_VERSION }, { capabilities: { tools: {} } });
|
|
98
630
|
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
|
|
@@ -121,6 +653,39 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
121
653
|
],
|
|
122
654
|
};
|
|
123
655
|
}
|
|
656
|
+
case 'grupr_wait_for_messages': {
|
|
657
|
+
const gruprId = String(args.grupr_id);
|
|
658
|
+
const after = typeof args.after === 'string' ? args.after : undefined;
|
|
659
|
+
const requested = typeof args.timeout_seconds === 'number'
|
|
660
|
+
? args.timeout_seconds
|
|
661
|
+
: DEFAULT_WAIT_SECONDS;
|
|
662
|
+
const waitMs = Math.min(Math.max(1, requested), MAX_WAIT_SECONDS) * 1000;
|
|
663
|
+
const startedAt = Date.now();
|
|
664
|
+
const { messages, reason } = await waitForMessages(gruprId, after, waitMs);
|
|
665
|
+
const waitedMs = Date.now() - startedAt;
|
|
666
|
+
// next_cursor is a suggestion, not state: it is only safe to adopt
|
|
667
|
+
// once the caller has actually processed every message returned.
|
|
668
|
+
const nextCursor = messages.length > 0
|
|
669
|
+
? messages[messages.length - 1].created_at
|
|
670
|
+
: (after ?? null);
|
|
671
|
+
return {
|
|
672
|
+
content: [
|
|
673
|
+
{
|
|
674
|
+
type: 'text',
|
|
675
|
+
text: JSON.stringify({
|
|
676
|
+
count: messages.length,
|
|
677
|
+
reason,
|
|
678
|
+
waited_ms: waitedMs,
|
|
679
|
+
next_cursor: nextCursor,
|
|
680
|
+
messages,
|
|
681
|
+
note: messages.length === 0
|
|
682
|
+
? 'No new messages before timeout. Call again with the SAME cursor.'
|
|
683
|
+
: 'Advance your cursor to next_cursor only after processing every message above.',
|
|
684
|
+
}, null, 2),
|
|
685
|
+
},
|
|
686
|
+
],
|
|
687
|
+
};
|
|
688
|
+
}
|
|
124
689
|
case 'grupr_send_message': {
|
|
125
690
|
const msg = await client.sendMessage(String(args.grupr_id), String(args.content));
|
|
126
691
|
return {
|
|
@@ -152,11 +717,419 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
152
717
|
content: [{ type: 'text', text: 'Webhook removed.' }],
|
|
153
718
|
};
|
|
154
719
|
}
|
|
720
|
+
case 'grupr_workspace_info': {
|
|
721
|
+
const info = await hubJSON('/workspace');
|
|
722
|
+
return { content: [{ type: 'text', text: JSON.stringify(info, null, 2) }] };
|
|
723
|
+
}
|
|
724
|
+
case 'grupr_workspace_run': {
|
|
725
|
+
const res = await hubJSON('/workspace/run', {
|
|
726
|
+
method: 'POST',
|
|
727
|
+
headers: { 'Content-Type': 'application/json' },
|
|
728
|
+
body: JSON.stringify({
|
|
729
|
+
cmd: String(args.cmd),
|
|
730
|
+
cwd: typeof args.cwd === 'string' ? args.cwd : undefined,
|
|
731
|
+
grupr_id: typeof args.grupr_id === 'string' ? args.grupr_id : undefined,
|
|
732
|
+
timeout_seconds: typeof args.timeout_seconds === 'number' ? args.timeout_seconds : undefined,
|
|
733
|
+
}),
|
|
734
|
+
});
|
|
735
|
+
return { content: [{ type: 'text', text: JSON.stringify(res, null, 2) }] };
|
|
736
|
+
}
|
|
737
|
+
case 'grupr_workspace_result': {
|
|
738
|
+
const run = await hubJSON(`/workspace/runs/${encodeURIComponent(String(args.run_id))}`);
|
|
739
|
+
return { content: [{ type: 'text', text: JSON.stringify(run, null, 2) }] };
|
|
740
|
+
}
|
|
741
|
+
case 'grupr_workspace_files': {
|
|
742
|
+
const p = typeof args.path === 'string' && args.path ? args.path : '/home/user';
|
|
743
|
+
const res = await hubFetch(`/workspace/files?path=${encodeURIComponent(p)}`);
|
|
744
|
+
const body = await res.json().catch(() => null);
|
|
745
|
+
if (!res.ok) {
|
|
746
|
+
const e = body?.errors?.[0];
|
|
747
|
+
throw new HubError(res.status, e?.code || 'error', e?.message || `HTTP ${res.status}`);
|
|
748
|
+
}
|
|
749
|
+
return {
|
|
750
|
+
content: [
|
|
751
|
+
{ type: 'text', text: JSON.stringify({ path: body?.meta?.path ?? p, entries: body?.data ?? [] }, null, 2) },
|
|
752
|
+
],
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
case 'grupr_workspace_read': {
|
|
756
|
+
const p = String(args.path);
|
|
757
|
+
const res = await hubFetch(`/workspace/file?path=${encodeURIComponent(p)}`);
|
|
758
|
+
if (!res.ok) {
|
|
759
|
+
const body = await res.json().catch(() => null);
|
|
760
|
+
const e = body?.errors?.[0];
|
|
761
|
+
throw new HubError(res.status, e?.code || 'error', e?.message || `HTTP ${res.status}`);
|
|
762
|
+
}
|
|
763
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
764
|
+
return { content: [{ type: 'text', text: JSON.stringify({ path: p, size: buf.length, ...textOrBase64(buf) }, null, 2) }] };
|
|
765
|
+
}
|
|
766
|
+
case 'grupr_room_files': {
|
|
767
|
+
const gid = encodeURIComponent(String(args.grupr_id));
|
|
768
|
+
const res = await hubFetch(`/grups/${gid}/files`);
|
|
769
|
+
const body = await res.json().catch(() => null);
|
|
770
|
+
if (!res.ok) {
|
|
771
|
+
const e = body?.errors?.[0];
|
|
772
|
+
throw new HubError(res.status, e?.code || 'error', e?.message || `HTTP ${res.status}`);
|
|
773
|
+
}
|
|
774
|
+
return {
|
|
775
|
+
content: [
|
|
776
|
+
{
|
|
777
|
+
type: 'text',
|
|
778
|
+
text: JSON.stringify({ files: body?.data ?? [], used_bytes: body?.meta?.used_bytes, quota_bytes: body?.meta?.quota_bytes }, null, 2),
|
|
779
|
+
},
|
|
780
|
+
],
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
case 'grupr_room_file_read': {
|
|
784
|
+
const gid = encodeURIComponent(String(args.grupr_id));
|
|
785
|
+
const ref = String(args.file);
|
|
786
|
+
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);
|
|
787
|
+
// Documents first: the api's preview carries extracted text for
|
|
788
|
+
// Word / PowerPoint / PDF, sheets for Excel, blocks for Markdown.
|
|
789
|
+
let fileId = isId ? ref : '';
|
|
790
|
+
if (!fileId) {
|
|
791
|
+
const lr = await hubFetch(`/grups/${gid}/files`);
|
|
792
|
+
const lb = await lr.json().catch(() => null);
|
|
793
|
+
const hit = (lb?.data ?? []).find((f) => f.name === ref || f.name === ref.replace(/^\/+/, ''));
|
|
794
|
+
if (hit)
|
|
795
|
+
fileId = hit.file_id;
|
|
796
|
+
}
|
|
797
|
+
if (fileId) {
|
|
798
|
+
const pr = await hubFetch(`/grups/${gid}/files/${fileId}/preview`);
|
|
799
|
+
const pb = await pr.json().catch(() => null);
|
|
800
|
+
if (pr.ok && pb?.data) {
|
|
801
|
+
const rendered = previewToText(pb.data);
|
|
802
|
+
if (rendered !== null) {
|
|
803
|
+
return { content: [{ type: 'text', text: rendered }] };
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
const res = await hubFetch(isId ? `/grups/${gid}/files/${ref}` : `/grups/${gid}/file?name=${encodeURIComponent(ref)}`);
|
|
808
|
+
if (!res.ok) {
|
|
809
|
+
const body = await res.json().catch(() => null);
|
|
810
|
+
const e = body?.errors?.[0];
|
|
811
|
+
throw new HubError(res.status, e?.code || 'error', e?.message || `HTTP ${res.status}`);
|
|
812
|
+
}
|
|
813
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
814
|
+
return {
|
|
815
|
+
content: [
|
|
816
|
+
{
|
|
817
|
+
type: 'text',
|
|
818
|
+
text: JSON.stringify({ file: ref, size: buf.length, content_type: res.headers.get('content-type') || undefined, ...textOrBase64(buf) }, null, 2),
|
|
819
|
+
},
|
|
820
|
+
],
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
case 'grupr_workspace_publish': {
|
|
824
|
+
const f = await hubJSON('/workspace/publish', {
|
|
825
|
+
method: 'POST',
|
|
826
|
+
headers: { 'Content-Type': 'application/json' },
|
|
827
|
+
body: JSON.stringify({
|
|
828
|
+
grupr_id: String(args.grupr_id),
|
|
829
|
+
path: String(args.path),
|
|
830
|
+
name: typeof args.name === 'string' && args.name ? args.name : undefined,
|
|
831
|
+
}),
|
|
832
|
+
});
|
|
833
|
+
return {
|
|
834
|
+
content: [
|
|
835
|
+
{
|
|
836
|
+
type: 'text',
|
|
837
|
+
text: `Published ${f.name ?? '?'} (${f.size ?? '?'} bytes, v${f.version ?? 1}) to the room's Files. file_id ${f.file_id ?? '?'}.`,
|
|
838
|
+
},
|
|
839
|
+
],
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
case 'grupr_workspace_fetch': {
|
|
843
|
+
const ref = String(args.file);
|
|
844
|
+
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);
|
|
845
|
+
const r = await hubJSON('/workspace/fetch', {
|
|
846
|
+
method: 'POST',
|
|
847
|
+
headers: { 'Content-Type': 'application/json' },
|
|
848
|
+
body: JSON.stringify({
|
|
849
|
+
grupr_id: String(args.grupr_id),
|
|
850
|
+
file_id: isId ? ref : undefined,
|
|
851
|
+
name: isId ? undefined : ref,
|
|
852
|
+
path: typeof args.path === 'string' && args.path ? args.path : undefined,
|
|
853
|
+
}),
|
|
854
|
+
});
|
|
855
|
+
return { content: [{ type: 'text', text: `Fetched ${r.name ?? ref} (${r.size ?? '?'} bytes) into ${r.path ?? '?'}.` }] };
|
|
856
|
+
}
|
|
857
|
+
case 'grupr_workspace_schedule': {
|
|
858
|
+
const res = await hubFetch('/workspace/schedule', {
|
|
859
|
+
method: 'POST',
|
|
860
|
+
headers: { 'Content-Type': 'application/json' },
|
|
861
|
+
body: JSON.stringify({
|
|
862
|
+
grupr_id: String(args.grupr_id),
|
|
863
|
+
cmd: String(args.cmd),
|
|
864
|
+
cron: String(args.cron),
|
|
865
|
+
timezone: typeof args.timezone === 'string' && args.timezone ? args.timezone : undefined,
|
|
866
|
+
label: typeof args.label === 'string' && args.label ? args.label : undefined,
|
|
867
|
+
cwd: typeof args.cwd === 'string' && args.cwd ? args.cwd : undefined,
|
|
868
|
+
timeout_seconds: typeof args.timeout_seconds === 'number' ? args.timeout_seconds : undefined,
|
|
869
|
+
outputs: Array.isArray(args.outputs) ? args.outputs : undefined,
|
|
870
|
+
render: typeof args.render === 'boolean' ? args.render : undefined,
|
|
871
|
+
notify: typeof args.notify === 'string' ? args.notify : undefined,
|
|
872
|
+
}),
|
|
873
|
+
});
|
|
874
|
+
const text = await res.text();
|
|
875
|
+
let body = null;
|
|
876
|
+
try {
|
|
877
|
+
body = text ? JSON.parse(text) : null;
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
body = null;
|
|
881
|
+
}
|
|
882
|
+
if (!res.ok) {
|
|
883
|
+
const e = body?.errors?.[0];
|
|
884
|
+
throw new HubError(res.status, e?.code ?? 'error', e?.message ?? `HTTP ${res.status}`);
|
|
885
|
+
}
|
|
886
|
+
const d = body?.data ?? {};
|
|
887
|
+
if (res.status === 202 || d.status === 'pending') {
|
|
888
|
+
return {
|
|
889
|
+
content: [
|
|
890
|
+
{
|
|
891
|
+
type: 'text',
|
|
892
|
+
text: `Routine proposed — approval ${d.approval_id ?? '?'} is waiting for a member. Do not re-submit. ` +
|
|
893
|
+
'When someone approves, the routine is created and the room is told; if they deny, nothing is scheduled.',
|
|
894
|
+
},
|
|
895
|
+
],
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
const sch = d.schedule ?? d;
|
|
899
|
+
return {
|
|
900
|
+
content: [
|
|
901
|
+
{
|
|
902
|
+
type: 'text',
|
|
903
|
+
text: `Routine created (a standing rule allowed it): schedule_id ${sch.schedule_id ?? '?'}, ${sch.cron ?? ''} ${sch.timezone ?? ''}, next run ${sch.next_run_at ?? '?'}.`,
|
|
904
|
+
},
|
|
905
|
+
],
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
case 'grupr_workspace_schedules': {
|
|
909
|
+
const list = await hubJSON('/workspace/schedules');
|
|
910
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
911
|
+
return { content: [{ type: 'text', text: 'No routines. Propose one with grupr_workspace_schedule.' }] };
|
|
912
|
+
}
|
|
913
|
+
const lines = list.map((r) => `- ${r.schedule_id} · ${r.label ? `${r.label}: ` : ''}\`${r.cmd}\` · ${r.cron} ${r.timezone} · room ${r.grupr_id} · ` +
|
|
914
|
+
`${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'} · ` +
|
|
915
|
+
`${r.runs ?? 0} runs, ${r.failures ?? 0} failed` +
|
|
916
|
+
(Array.isArray(r.outputs) && r.outputs.length > 0
|
|
917
|
+
? ` · publishes ${r.outputs.map((o) => o.name || o.path).join(', ')}${r.render ? ' (rendered)' : ''}`
|
|
918
|
+
: '') +
|
|
919
|
+
(r.notify === 'failures' ? ' · posts failures only' : r.notify === 'output' ? ' · posts when it prints or fails' : ''));
|
|
920
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
921
|
+
}
|
|
922
|
+
case 'grupr_workspace_unschedule': {
|
|
923
|
+
await hubJSON(`/workspace/schedules/${encodeURIComponent(String(args.schedule_id))}`, { method: 'DELETE' });
|
|
924
|
+
return { content: [{ type: 'text', text: 'Routine removed; the room has been told.' }] };
|
|
925
|
+
}
|
|
926
|
+
case 'grupr_workspace_schedule_runs': {
|
|
927
|
+
const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.min(Math.floor(args.limit), 100) : 10;
|
|
928
|
+
const list = await hubJSON(`/workspace/schedules/${encodeURIComponent(String(args.schedule_id))}/runs?limit=${limit}`);
|
|
929
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
930
|
+
return { content: [{ type: 'text', text: 'No firings recorded for this routine yet.' }] };
|
|
931
|
+
}
|
|
932
|
+
const lines = list.map((r) => {
|
|
933
|
+
const pub = Array.isArray(r.published) ? r.published : [];
|
|
934
|
+
const out = [r.stdout, r.stderr, r.error].filter((x) => typeof x === 'string' && x).join('\n').trim();
|
|
935
|
+
const first = out.split('\n').find((l) => l.trim()) ?? '';
|
|
936
|
+
return (`- ${r.finished_at ?? r.created_at} · ${r.status}${typeof r.exit_code === 'number' ? ` exit ${r.exit_code}` : ''}` +
|
|
937
|
+
(typeof r.wall_clock_ms === 'number' ? ` · ${r.wall_clock_ms} ms` : '') +
|
|
938
|
+
(pub.length ? ` · published ${pub.map((p) => (p.error ? `${p.name} FAILED (${p.error})` : `${p.name}${p.unchanged ? ' (unchanged)' : ''}`)).join(', ')}` : '') +
|
|
939
|
+
(first ? ` · ${first.slice(0, 160)}` : ' · no output') +
|
|
940
|
+
` · run_id ${r.run_id}`);
|
|
941
|
+
});
|
|
942
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
943
|
+
}
|
|
944
|
+
case 'grupr_workspace_recipes': {
|
|
945
|
+
const list = await hubJSON('/workspace/recipes');
|
|
946
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
947
|
+
return { content: [{ type: 'text', text: 'This server offers no recipes.' }] };
|
|
948
|
+
}
|
|
949
|
+
const lines = list.map((r) => {
|
|
950
|
+
const params = Array.isArray(r.params) ? r.params : [];
|
|
951
|
+
const outs = Array.isArray(r.outputs) ? r.outputs : [];
|
|
952
|
+
return (`- ${r.id}: ${r.title} (${r.every}). ${r.summary}` +
|
|
953
|
+
(params.length ? ` Parameters: ${params.map((p) => `${p.key} (${p.label}${p.help ? `; ${p.help}` : ''})`).join(', ')}.` : ' No parameters.') +
|
|
954
|
+
(outs.length ? ` Publishes ${outs.map((o) => o.name || o.path).join(', ')}.` : ''));
|
|
955
|
+
});
|
|
956
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
957
|
+
}
|
|
958
|
+
case 'grupr_workspace_recipe': {
|
|
959
|
+
const res = await hubFetch('/workspace/recipe', {
|
|
960
|
+
method: 'POST',
|
|
961
|
+
headers: { 'Content-Type': 'application/json' },
|
|
962
|
+
body: JSON.stringify({
|
|
963
|
+
recipe_id: String(args.recipe_id),
|
|
964
|
+
grupr_id: String(args.grupr_id),
|
|
965
|
+
params: args.params && typeof args.params === 'object' ? args.params : undefined,
|
|
966
|
+
timezone: typeof args.timezone === 'string' && args.timezone ? args.timezone : undefined,
|
|
967
|
+
cron: typeof args.cron === 'string' && args.cron ? args.cron : undefined,
|
|
968
|
+
}),
|
|
969
|
+
});
|
|
970
|
+
const text = await res.text();
|
|
971
|
+
let body = null;
|
|
972
|
+
try {
|
|
973
|
+
body = text ? JSON.parse(text) : null;
|
|
974
|
+
}
|
|
975
|
+
catch {
|
|
976
|
+
body = null;
|
|
977
|
+
}
|
|
978
|
+
if (!res.ok) {
|
|
979
|
+
const e = body?.errors?.[0];
|
|
980
|
+
throw new HubError(res.status, e?.code ?? 'error', e?.message ?? `HTTP ${res.status}`);
|
|
981
|
+
}
|
|
982
|
+
const d = body?.data ?? {};
|
|
983
|
+
if (res.status === 202 || d.status === 'pending') {
|
|
984
|
+
return {
|
|
985
|
+
content: [
|
|
986
|
+
{
|
|
987
|
+
type: 'text',
|
|
988
|
+
text: `Recipe requested: approval ${d.approval_id ?? '?'} is waiting for a member. Do not re-submit. ` +
|
|
989
|
+
'When someone approves, the script is installed, the routine is created and the room is told; if they deny, nothing is installed.',
|
|
990
|
+
},
|
|
991
|
+
],
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
const sch = d.schedule ?? d;
|
|
995
|
+
return {
|
|
996
|
+
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 ?? '?'}.` }],
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
case 'grupr_room_doc_write': {
|
|
1000
|
+
const gid = encodeURIComponent(String(args.grupr_id));
|
|
1001
|
+
const r = await hubJSON(`/grups/${gid}/documents`, {
|
|
1002
|
+
method: 'POST',
|
|
1003
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1004
|
+
body: JSON.stringify({
|
|
1005
|
+
name: typeof args.name === 'string' ? args.name : undefined,
|
|
1006
|
+
title: typeof args.title === 'string' ? args.title : undefined,
|
|
1007
|
+
markdown: String(args.markdown ?? ''),
|
|
1008
|
+
formats: Array.isArray(args.formats) ? args.formats : undefined,
|
|
1009
|
+
}),
|
|
1010
|
+
});
|
|
1011
|
+
const files = (r.files ?? []).map((f) => `${f.name} (${f.size} B, v${f.version}, ${f.file_id})`).join('; ');
|
|
1012
|
+
return { content: [{ type: 'text', text: `Wrote ${r.base ?? '?'} to the room's Files: ${files}.` }] };
|
|
1013
|
+
}
|
|
1014
|
+
case 'grupr_room_sheet_write': {
|
|
1015
|
+
const gid = encodeURIComponent(String(args.grupr_id));
|
|
1016
|
+
const r = await hubJSON(`/grups/${gid}/sheets`, {
|
|
1017
|
+
method: 'POST',
|
|
1018
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1019
|
+
body: JSON.stringify({
|
|
1020
|
+
name: typeof args.name === 'string' ? args.name : undefined,
|
|
1021
|
+
sheets: args.sheets,
|
|
1022
|
+
formats: Array.isArray(args.formats) ? args.formats : undefined,
|
|
1023
|
+
}),
|
|
1024
|
+
});
|
|
1025
|
+
const files = (r.files ?? []).map((f) => `${f.name} (${f.size} B, v${f.version}, ${f.file_id})`).join('; ');
|
|
1026
|
+
return { content: [{ type: 'text', text: `Wrote ${r.base ?? '?'} to the room's Files: ${files}.` }] };
|
|
1027
|
+
}
|
|
1028
|
+
case 'grupr_mail_send': {
|
|
1029
|
+
const res = await hubFetch('/mail/send', {
|
|
1030
|
+
method: 'POST',
|
|
1031
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1032
|
+
body: JSON.stringify({
|
|
1033
|
+
grupr_id: String(args.grupr_id),
|
|
1034
|
+
to: Array.isArray(args.to) ? args.to : [String(args.to ?? '')],
|
|
1035
|
+
cc: Array.isArray(args.cc) ? args.cc : undefined,
|
|
1036
|
+
subject: String(args.subject ?? ''),
|
|
1037
|
+
markdown: String(args.markdown ?? ''),
|
|
1038
|
+
attachments: Array.isArray(args.attachments) ? args.attachments : undefined,
|
|
1039
|
+
in_reply_to: typeof args.in_reply_to === 'string' && args.in_reply_to ? args.in_reply_to : undefined,
|
|
1040
|
+
}),
|
|
1041
|
+
});
|
|
1042
|
+
const body = await res.json().catch(() => null);
|
|
1043
|
+
if (!res.ok) {
|
|
1044
|
+
const e = body?.errors?.[0];
|
|
1045
|
+
throw new HubError(res.status, e?.code || 'error', e?.message || `HTTP ${res.status}`);
|
|
1046
|
+
}
|
|
1047
|
+
const d = body?.data ?? body;
|
|
1048
|
+
if (res.status === 202 || d?.status === 'pending') {
|
|
1049
|
+
return {
|
|
1050
|
+
content: [
|
|
1051
|
+
{
|
|
1052
|
+
type: 'text',
|
|
1053
|
+
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.`,
|
|
1054
|
+
},
|
|
1055
|
+
],
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
return {
|
|
1059
|
+
content: [
|
|
1060
|
+
{
|
|
1061
|
+
type: 'text',
|
|
1062
|
+
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 ?? ''}.`,
|
|
1063
|
+
},
|
|
1064
|
+
],
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
case 'grupr_mail_inbox': {
|
|
1068
|
+
const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.min(100, Math.floor(args.limit)) : 10;
|
|
1069
|
+
const res = await hubFetch(`/mail?direction=in&limit=${limit}`);
|
|
1070
|
+
const body = await res.json().catch(() => null);
|
|
1071
|
+
if (!res.ok) {
|
|
1072
|
+
const e = body?.errors?.[0];
|
|
1073
|
+
throw new HubError(res.status, e?.code || 'error', e?.message || `HTTP ${res.status}`);
|
|
1074
|
+
}
|
|
1075
|
+
const items = (body?.data ?? []).map((m) => ({
|
|
1076
|
+
mail_id: m.mail_id,
|
|
1077
|
+
from: m.from,
|
|
1078
|
+
subject: m.subject,
|
|
1079
|
+
received_at: m.sent_at || m.created_at,
|
|
1080
|
+
grupr_id: m.grupr_id,
|
|
1081
|
+
in_reply_to: m.in_reply_to,
|
|
1082
|
+
attachments: (m.attachments ?? []).map((a) => a.name),
|
|
1083
|
+
}));
|
|
1084
|
+
return {
|
|
1085
|
+
content: [
|
|
1086
|
+
{
|
|
1087
|
+
type: 'text',
|
|
1088
|
+
text: JSON.stringify({ address: body?.meta?.address || null, inbound_enabled: body?.meta?.inbound_enabled ?? null, mail: items }, null, 2),
|
|
1089
|
+
},
|
|
1090
|
+
],
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
case 'grupr_mail_read': {
|
|
1094
|
+
const m = await hubJSON(`/mail/${encodeURIComponent(String(args.mail_id))}`);
|
|
1095
|
+
return { content: [{ type: 'text', text: JSON.stringify(m, null, 2) }] };
|
|
1096
|
+
}
|
|
1097
|
+
case 'grupr_mail_status': {
|
|
1098
|
+
const m = await hubJSON(`/mail/${encodeURIComponent(String(args.mail_id))}`);
|
|
1099
|
+
return { content: [{ type: 'text', text: JSON.stringify(m, null, 2) }] };
|
|
1100
|
+
}
|
|
1101
|
+
case 'grupr_workspace_write': {
|
|
1102
|
+
const p = String(args.path);
|
|
1103
|
+
const res = await hubJSON(`/workspace/file?path=${encodeURIComponent(p)}`, {
|
|
1104
|
+
method: 'PUT',
|
|
1105
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
1106
|
+
body: String(args.content ?? ''),
|
|
1107
|
+
});
|
|
1108
|
+
return { content: [{ type: 'text', text: `Wrote ${res.size ?? '?'} bytes to ${res.path ?? p}.` }] };
|
|
1109
|
+
}
|
|
155
1110
|
default:
|
|
156
1111
|
throw new Error(`Unknown tool: ${name}`);
|
|
157
1112
|
}
|
|
158
1113
|
}
|
|
159
1114
|
catch (err) {
|
|
1115
|
+
if (err instanceof HubError) {
|
|
1116
|
+
const hint = err.code === 'command_denied' || err.code === 'mail_denied'
|
|
1117
|
+
? ' The human said no — do not retry; explain in the room instead.'
|
|
1118
|
+
: err.code === 'approval_timeout'
|
|
1119
|
+
? ' Nobody decided in time and the request was cancelled. Ask in the room, then call again.'
|
|
1120
|
+
: err.status === 503
|
|
1121
|
+
? ' Workspaces are not enabled on this Grupr server.'
|
|
1122
|
+
: '';
|
|
1123
|
+
return {
|
|
1124
|
+
isError: true,
|
|
1125
|
+
content: [
|
|
1126
|
+
{
|
|
1127
|
+
type: 'text',
|
|
1128
|
+
text: `Grupr ${err.status} ${err.code}: ${err.message}.${hint}${err.approvalId ? ` (approval ${err.approvalId})` : ''}`,
|
|
1129
|
+
},
|
|
1130
|
+
],
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
160
1133
|
if (err instanceof sdk_1.GruprAuthError) {
|
|
161
1134
|
return {
|
|
162
1135
|
isError: true,
|