@commonlyai/mcp 0.3.39 → 0.3.41

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/tools.js +48 -11
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@commonlyai/mcp",
3
3
  "mcpName": "io.github.Team-Commonly/commonly",
4
- "version": "0.3.39",
4
+ "version": "0.3.41",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
package/src/tools.js CHANGED
@@ -9,7 +9,8 @@
9
9
  * not a rewrite of every HEARTBEAT.md.
10
10
  *
11
11
  * Invariant #1: this module is a transport. No state, no business logic that
12
- * isn't a 1:1 wrap of one CAP / dual-auth route.
12
+ * isn't a 1:1 wrap of one CAP / dual-auth route. The one exception is the
13
+ * in-memory sequencing of a checkpoint after its posts (ADR-010, invariant 1).
13
14
  *
14
15
  * Invariant #2: every route here accepts `cm_agent_*` runtime tokens. CAP
15
16
  * routes (`/api/agents/runtime/*`), the dual-auth tasks surface
@@ -181,6 +182,31 @@ export const buildTools = (config) => {
181
182
  }
182
183
  };
183
184
 
185
+ // A checkpoint sent beside a post used to land even when the post came back
186
+ // held, and a model could stop there with its answer unsent. So it waits for
187
+ // those posts, and a held one turns the event back until its second try: the
188
+ // model may have read the hold and chosen to say nothing, and that can't be a
189
+ // trap. A newer hold turns it back again, since that one hasn't been read.
190
+ const postsInFlight = new Set();
191
+ const heldPods = new Set();
192
+ const turnedBack = new Set();
193
+ const trackPost = async (podId, posting) => {
194
+ postsInFlight.add(posting);
195
+ try {
196
+ const posted = await posting;
197
+ if (!posted?.skipped && !posted?.refused) heldPods.delete(podId);
198
+ return posted;
199
+ } catch (error) {
200
+ if (error instanceof HttpError && error.status === 409 && error.body?.code === 'unseen_messages') {
201
+ heldPods.add(podId);
202
+ turnedBack.clear();
203
+ }
204
+ throw error;
205
+ } finally {
206
+ postsInFlight.delete(posting);
207
+ }
208
+ };
209
+
184
210
  const tools = [
185
211
  {
186
212
  name: 'commonly_get_started',
@@ -231,11 +257,11 @@ export const buildTools = (config) => {
231
257
  messageType: { type: 'string', enum: ['text', 'card'] },
232
258
  payload: { type: 'object', description: 'UI card only: {kind:"ui",v:1,lang:"openui@1",catalogId,catalogRev,catalogHash,source}. Use catalogId:"commonly.ui", catalogRev:1, catalogHash:"sha256:bb8b4de07f90c06ee48f254d86478b5bd347c738b3e86cf65e2f32e26534db57". Requires messageType card and content text fallback; unknown catalogue revisions return 400. For the grammar and worked examples, call commonly_get_ui_card_catalogue.', additionalProperties: true },
233
259
  }, ['podId', 'content']),
234
- call: wrap(async ({ podId, content, replyToMessageId, threadRootId, mainChannel, metadata, continueAnyway, messageType, payload, summary, kind }) => request(config, {
260
+ call: wrap(async ({ podId, content, replyToMessageId, threadRootId, mainChannel, metadata, continueAnyway, messageType, payload, summary, kind }) => trackPost(podId, request(config, {
235
261
  method: 'POST',
236
262
  path: `/api/agents/runtime/pods/${encodeURIComponent(podId)}/messages`,
237
263
  body: { content, replyToMessageId, threadRootId, mainChannel, metadata, continueAnyway, messageType, payload, summary, kind },
238
- })),
264
+ }))),
239
265
  },
240
266
  {
241
267
  name: 'commonly_claim_message',
@@ -263,18 +289,29 @@ export const buildTools = (config) => {
263
289
  },
264
290
  {
265
291
  name: 'commonly_checkpoint_event',
266
- description: 'Checkpoint ONE inbox event only after tool-side work is committed (including a reply posted through a tool), or a deliberate NO_REPLY. Never checkpoint an answer held for final CLI output: the wrapper must post that reply before acknowledging the item. This durably acknowledges that event now, so a later turn timeout does not replay it. Use only the eventId and deliveryId supplied together in the current inbox item. Never checkpoint unseen or unfinished work. Release any message claim with its correct completed/declined outcome first. A failed call is not a checkpoint; leave the item pending. This posts no chat message and does not complete a task.',
292
+ description: 'Checkpoint ONE inbox event only after tool-side work is committed (including a reply posted through a tool), or a deliberate NO_REPLY. Never checkpoint an answer held for final CLI output: the wrapper must post that reply before acknowledging the item. A reply in several posts is finished only after the last one, and a post is committed only once its result comes back, so never send this in the same step as a post. This durably acknowledges that event now, so a later turn timeout does not replay it. Use only the eventId and deliveryId supplied together in the current inbox item. Never checkpoint unseen or unfinished work. Release any message claim with its correct completed/declined outcome first. A failed call is not a checkpoint; leave the item pending, unless the error says a held post no longer needs saying. This posts no chat message and does not complete a task.',
267
293
  inputSchema: reqWith({ eventId: { type: 'string', minLength: 1 }, deliveryId: { type: 'string', minLength: 1 } }, ['eventId', 'deliveryId']),
268
294
  call: wrap(async ({ eventId, deliveryId }) => {
269
295
  if (typeof eventId !== 'string' || !eventId.trim()
270
296
  || typeof deliveryId !== 'string' || !deliveryId.trim()) {
271
297
  throw new Error('eventId and deliveryId from the current inbox item are required');
272
298
  }
273
- return request(config, {
299
+ while (postsInFlight.size) await Promise.allSettled([...postsInFlight]);
300
+ let lettingGo = [];
301
+ if (heldPods.size) {
302
+ if (!turnedBack.has(eventId)) {
303
+ turnedBack.add(eventId);
304
+ throw new Error('Not checkpointed: a post of yours came back held, so it never reached the pod. Read, then post it again before you checkpoint. If it no longer needs saying, checkpoint this event again.');
305
+ }
306
+ lettingGo = [...heldPods];
307
+ }
308
+ const acked = await request(config, {
274
309
  method: 'POST',
275
310
  path: `/api/agents/runtime/events/${encodeURIComponent(eventId)}/ack`,
276
311
  body: { deliveryId, result: { outcome: 'acknowledged', reason: 'handled-checkpoint' } },
277
312
  });
313
+ lettingGo.forEach((podId) => heldPods.delete(podId));
314
+ return acked;
278
315
  }),
279
316
  },
280
317
  {
@@ -354,7 +391,7 @@ export const buildTools = (config) => {
354
391
  name: 'commonly_attach_file',
355
392
  description: 'Attach a file from THIS machine into a pod so humans and other agents can see and read it — a report you wrote, a diff, a generated CSV/deck, etc. Pass `filePath` (a path on the local filesystem). It uploads the file and posts it into the pod as a file card; add `message` for a line of context alongside it. If a 409 reports unseen messages, read them and retry, or pass continueAnyway:true. If the card is held after upload, the error returns the existing fileName so you can post its directive after reading without reuploading. Returns the uploaded file metadata (its `fileName` is what commonly_read_file takes).',
356
393
  inputSchema: reqWith({ podId: STRING, filePath: STRING, message: STRING, continueAnyway: { type: 'boolean' } }, ['podId', 'filePath']),
357
- call: wrap(async ({ podId, filePath, message, continueAnyway }) => {
394
+ call: wrap(async ({ podId, filePath, message, continueAnyway }) => (await trackPost(podId, (async () => {
358
395
  const podPath = `/api/agents/runtime/pods/${encodeURIComponent(podId)}`;
359
396
  if (continueAnyway !== true) {
360
397
  await request(config, { method: 'GET', path: `${podPath}/send-freshness` });
@@ -376,11 +413,12 @@ export const buildTools = (config) => {
376
413
  const directive = `[[upload:${u.fileName || name}|${u.originalName || name}|${u.size || buffer.length}|${u.kind || 'document'}]]`;
377
414
  const content = message ? `${message} ${directive}` : directive;
378
415
  try {
379
- await request(config, {
416
+ const card = await request(config, {
380
417
  method: 'POST',
381
418
  path: `${podPath}/messages`,
382
419
  body: { content, continueAnyway },
383
420
  });
421
+ return { ...card, uploaded };
384
422
  } catch (postError) {
385
423
  // The upload is a separate pod artifact. A 409 means this card was
386
424
  // held, but another post may already reference the same file. Keep
@@ -393,8 +431,7 @@ export const buildTools = (config) => {
393
431
  }
394
432
  throw postError;
395
433
  }
396
- return uploaded;
397
- }),
434
+ })())).uploaded),
398
435
  },
399
436
  {
400
437
  name: 'commonly_get_posts',
@@ -672,13 +709,13 @@ export const buildTools = (config) => {
672
709
  }, ['podId', 'decisionClass', 'title', 'question', 'options']),
673
710
  call: wrap(async ({
674
711
  podId, decisionClass, title, question, options, threadRootId, context,
675
- }) => request(config, {
712
+ }) => trackPost(podId, request(config, {
676
713
  method: 'POST',
677
714
  path: '/api/agents/runtime/decisions',
678
715
  body: {
679
716
  podId, decisionClass, title, question, options, threadRootId, context,
680
717
  },
681
- })),
718
+ }))),
682
719
  },
683
720
  {
684
721
  name: 'commonly_respond_to_ask',