@rackbops/plugin-mcp 0.3.1 → 0.3.3

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 (3) hide show
  1. package/README.md +13 -7
  2. package/dist/plugin.js +109 -20
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -5,8 +5,9 @@ The bridge plugin for the Discord delivery MCP ([`Rackbops/discord-mcp`](https:/
5
5
  side of that repo's `docs/bridge-protocol.md` under `/mcp/` on the bot's own HTTP listener
6
6
  (rackbops-discord-bot's host-owned router, ADR-0007) -- `GET /capabilities`,
7
7
  `POST /deliveries`, `GET /deliveries/{request_id}` and `GET /recipients` -- so the MCP service can
8
- hand it a message and get exactly one Discord post, DM or edit out of it, however many times it
9
- retries. It also owns Discord-side agent identity ([Rackbops/Tooling#743](https://github.com/Rackbops/Tooling/issues/743)):
8
+ hand it a message and get one Discord post, DM or edit out of it: a retry of a request that is
9
+ delivered or in flight never delivers again (a retry of a failed or unknown one, and the restart
10
+ re-drive, deliver at least once -- see below). It also owns Discord-side agent identity ([Rackbops/Tooling#743](https://github.com/Rackbops/Tooling/issues/743)):
10
11
  the `/agent` slash command, and `POST /pair/redeem` + `GET /registration/{user_id}` under the same
11
12
  `/mcp/` path.
12
13
 
@@ -18,8 +19,13 @@ command's own replies name whichever form the user invoked.
18
19
 
19
20
  - Accepts a delivery request, answers `202` immediately, and delivers to Discord in the background.
20
21
  - Keeps one state file per `request_id` (`pending` -> `delivered`/`failed`, or `unknown` after a bot
21
- restart mid-delivery) so a retry with the same `request_id` never delivers twice.
22
- - A tick re-drives any delivery a restart left `unknown`, and prunes records older than 8 days.
22
+ restart mid-delivery) so a retry with the same `request_id` of a `delivered` or `pending` request
23
+ never delivers again.
24
+ - A retry of a `failed` or `unknown` request resets it to `pending` and delivers again. A tick
25
+ re-drives any delivery a restart left `unknown`, and prunes records older than 8
26
+ days. Delivery is therefore at-least-once: a restart between the Discord call and the state
27
+ write can post a second copy. A state write that fails without a restart does not: the outcome is
28
+ held in memory, answered from there, and the tick retries the write alone (that memory is lost on a restart).
23
29
  - `kind: "post"` goes through `host.post` (or `host.announce` when the host predates it); `kind: "dm"`
24
30
  goes through `host.dm`, to a registered user only; `kind: "edit"` goes through `host.edit`, applied
25
31
  against the stored delivery `message_ref` names. `GET /capabilities` reports `dm`/`edit` as `true`
@@ -124,7 +130,7 @@ stale `generation` for a user has been revoked and re-paired since, not merely r
124
130
 
125
131
  | Key | Required | Secret | What it does |
126
132
  |---|---|---|---|
127
- | `MCP_BRIDGE_TOKEN` | No | Yes | The bearer token the MCP service must present. Unset = every request gets `503 {"error":"bridge not configured"}`. At least 256 bits, base64url -- generate one with e.g. `openssl rand -base64 32 \| tr -d '=' \| tr '/+' '_-'`. |
133
+ | `MCP_BRIDGE_TOKEN` | No | Yes | The bearer token the MCP service must present. Unset = every request gets `503 {"error":"bridge not configured"}`. At least 43 printable ASCII characters (the manifest `format` checks only that, not entropy or alphabet); 256 bits of base64url is recommended -- generate one with e.g. `openssl rand -base64 32 \| tr -d '=' \| tr '/+' '_-'`. |
128
134
 
129
135
  ## Destinations
130
136
 
@@ -155,5 +161,5 @@ that verifies.
155
161
 
156
162
  `docs/bridge-protocol.md` in [`Rackbops/discord-mcp`](https://github.com/Rackbops/discord-mcp) is the
157
163
  protocol this plugin implements; where this README and that document ever disagree, the protocol
158
- document wins (this plugin is the implementation, not the source of truth). See `CONTEXT.md` in this
159
- repo for the file-by-file breakdown and the state-machine gotchas.
164
+ document wins (this plugin is the implementation, not the source of truth). See the header comment of
165
+ each file under `src/` for the file-by-file breakdown and the state-machine gotchas.
package/dist/plugin.js CHANGED
@@ -263,7 +263,39 @@ function recipientsResponse(entries) {
263
263
  }
264
264
 
265
265
  // plugins/mcp/src/drain.ts
266
+ function describeError(err) {
267
+ if (!(err instanceof Error))
268
+ return "non-Error rejection";
269
+ const { status, code } = err;
270
+ const parts = [err.name];
271
+ if (typeof status === "number")
272
+ parts.push(`status ${status}`);
273
+ if (typeof code === "number" || typeof code === "string")
274
+ parts.push(`code ${code}`);
275
+ return parts.join(", ");
276
+ }
266
277
  var RECIPIENT_UNREACHABLE_MESSAGE = "recipient cannot be messaged";
278
+ var UNPERSISTED_MAX_AGE_MS = 8 * 24 * 60 * 60 * 1000;
279
+ function createUnpersistedOutcomes() {
280
+ const held = new Map;
281
+ return {
282
+ get: (requestId) => held.get(requestId),
283
+ set: (requestId, outcome) => void held.set(requestId, outcome),
284
+ delete: (requestId) => void held.delete(requestId),
285
+ entries: () => [...held.entries()],
286
+ prune(now) {
287
+ const cutoff = now().getTime() - UNPERSISTED_MAX_AGE_MS;
288
+ const dropped = [];
289
+ for (const [requestId, outcome] of held) {
290
+ if (new Date(outcome.createdAt).getTime() <= cutoff) {
291
+ held.delete(requestId);
292
+ dropped.push(requestId);
293
+ }
294
+ }
295
+ return dropped;
296
+ }
297
+ };
298
+ }
267
299
  function createEditQueue() {
268
300
  const chains = new Map;
269
301
  return {
@@ -302,10 +334,14 @@ async function attemptEdit(deps, requestId, target, body) {
302
334
  try {
303
335
  await deps.host.edit(delivered.delivery, body);
304
336
  } catch (err) {
305
- deps.log.error(`edit ${requestId} (of ${messageRef}) failed`, err);
337
+ deps.log.error(`edit ${requestId} (of ${messageRef}) failed: ${describeError(err)}`);
306
338
  return { state: "failed", code: "UPSTREAM_UNAVAILABLE" };
307
339
  }
308
- await deps.store.recordEditSeq(messageRef, seq);
340
+ try {
341
+ await deps.store.recordEditSeq(messageRef, seq);
342
+ } catch (err) {
343
+ deps.log.error(`recording edit seq ${seq} on ${messageRef} failed: ${describeError(err)}`);
344
+ }
309
345
  return { state: "delivered", messageRef: delivered.messageRef, url: delivered.url, delivery: delivered.delivery, applied: true };
310
346
  });
311
347
  }
@@ -329,7 +365,7 @@ async function attemptDm(deps, requestId, target, body) {
329
365
  if (err instanceof Error && err.message === RECIPIENT_UNREACHABLE_MESSAGE) {
330
366
  return { state: "failed", code: "RECIPIENT_UNREACHABLE" };
331
367
  }
332
- deps.log.error(`delivery ${requestId} failed`, err);
368
+ deps.log.error(`delivery ${requestId} failed: ${describeError(err)}`);
333
369
  return { state: "failed", code: "UPSTREAM_UNAVAILABLE" };
334
370
  }
335
371
  }
@@ -353,7 +389,7 @@ async function attemptDelivery(deps, requestId, entry) {
353
389
  const url = `https://discord.com/channels/${delivery.guildId ?? "@me"}/${delivery.channelId}/${delivery.messageId}`;
354
390
  return { state: "delivered", messageRef: requestId, url, delivery };
355
391
  } catch (err) {
356
- log.error(`delivery ${requestId} failed`, err);
392
+ log.error(`delivery ${requestId} failed: ${describeError(err)}`);
357
393
  return { state: "failed", code: "UPSTREAM_UNAVAILABLE" };
358
394
  }
359
395
  }
@@ -364,7 +400,7 @@ async function attemptDelivery(deps, requestId, entry) {
364
400
  await host.announce(body.content ?? "", target.destination);
365
401
  return { state: "delivered", messageRef: null, url: null, delivery: null };
366
402
  } catch (err) {
367
- log.error(`delivery ${requestId} failed`, err);
403
+ log.error(`delivery ${requestId} failed: ${describeError(err)}`);
368
404
  return { state: "failed", code: "UPSTREAM_UNAVAILABLE" };
369
405
  }
370
406
  }
@@ -401,7 +437,13 @@ async function drainAndPersist(deps, requestId, entry) {
401
437
  delivery: outcome.delivery,
402
438
  ...outcome.applied !== undefined ? { applied: outcome.applied } : {}
403
439
  } : { ...entry, state: "failed", code: outcome.code };
404
- await deps.store.set(requestId, next);
440
+ try {
441
+ await deps.store.set(requestId, next);
442
+ } catch (err) {
443
+ deps.unpersisted.set(requestId, next);
444
+ throw err;
445
+ }
446
+ deps.unpersisted.delete(requestId);
405
447
  }
406
448
 
407
449
  // plugins/mcp/src/http.ts
@@ -411,6 +453,7 @@ function json(status, body) {
411
453
  async function startDrain(deps, requestId, entry) {
412
454
  if (!deps.drainLock.tryStart(requestId))
413
455
  return false;
456
+ deps.unpersisted.delete(requestId);
414
457
  try {
415
458
  await deps.store.set(requestId, entry);
416
459
  } catch (err) {
@@ -449,7 +492,9 @@ async function handleCreateDelivery(request, deps) {
449
492
  }
450
493
  return json(202, { state: toWireState(value), existing });
451
494
  }
452
- const { value, existing } = await deps.store.reserve(requestId, kind, target, body, deps.now);
495
+ const reserved = await deps.store.reserve(requestId, kind, target, body, deps.now);
496
+ const { existing } = reserved;
497
+ const value = (existing ? deps.unpersisted.get(requestId) : undefined) ?? reserved.value;
453
498
  if (!existing) {
454
499
  await startDrain(deps, requestId, value);
455
500
  return json(202, { state: toWireState(value), existing });
@@ -465,10 +510,10 @@ async function handleCreateDelivery(request, deps) {
465
510
  createdAt: value.createdAt
466
511
  };
467
512
  const started = await startDrain(deps, requestId, reset);
468
- return json(202, { state: toWireState(started ? reset : value), existing });
513
+ return json(202, { state: toWireState(started || value.state === "failed" ? reset : value), existing });
469
514
  }
470
515
  async function handleGetDelivery(requestId, deps) {
471
- const value = await deps.store.get(requestId);
516
+ const value = deps.unpersisted.get(requestId) ?? await deps.store.get(requestId);
472
517
  if (value === undefined)
473
518
  return json(404, { error: "unknown request_id" });
474
519
  return json(200, toWireState(value));
@@ -557,6 +602,20 @@ import { join } from "path";
557
602
  function freshRegistry() {
558
603
  return { users: {} };
559
604
  }
605
+ function isPlainObject(value) {
606
+ return typeof value === "object" && value !== null && !Array.isArray(value);
607
+ }
608
+ function isEntry(value) {
609
+ if (!isPlainObject(value))
610
+ return false;
611
+ const { generation, displayName, registeredAt, codes } = value;
612
+ return typeof generation === "string" && typeof displayName === "string" && typeof registeredAt === "string" && Array.isArray(codes) && codes.every((c) => isPlainObject(c) && typeof c.hash === "string" && typeof c.expiresAt === "string");
613
+ }
614
+ function normalizeRegistry(raw) {
615
+ if (!isPlainObject(raw) || !isPlainObject(raw.users))
616
+ return freshRegistry();
617
+ return { users: Object.fromEntries(Object.entries(raw.users).filter((pair) => isEntry(pair[1]))) };
618
+ }
560
619
  var MAX_CODES = 5;
561
620
  var CODE_TTL_MS = 10 * 60 * 1000;
562
621
  function generateGeneration() {
@@ -564,10 +623,11 @@ function generateGeneration() {
564
623
  }
565
624
  var CODE_ALPHABET = "ABCDEFGHJKMNPQRSTVWXYZ23456789";
566
625
  var CODE_LENGTH = 27;
567
- function generateCode() {
626
+ var defaultDraw = randomInt;
627
+ function generateCode(draw = defaultDraw) {
568
628
  let code = "";
569
629
  for (let i = 0;i < CODE_LENGTH; i++)
570
- code += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
630
+ code += CODE_ALPHABET[draw(CODE_ALPHABET.length)];
571
631
  return code;
572
632
  }
573
633
  function hashCode(code) {
@@ -615,7 +675,7 @@ function unregisterUser(registry, userId) {
615
675
  delete users[userId];
616
676
  return { registry: { ...registry, users }, changed: true };
617
677
  }
618
- function redeemCode(registry, code, now) {
678
+ function redeemCode(registry, code) {
619
679
  const hash = hashCode(code);
620
680
  for (const userId of Object.keys(registry.users)) {
621
681
  const entry = registry.users[userId];
@@ -640,7 +700,7 @@ function createRegistryStore(dataDir, storage) {
640
700
  async function mutate(now, op) {
641
701
  let result;
642
702
  await mutator.update(path, freshRegistry, (current) => {
643
- const pruned = pruneRegistry(current, now());
703
+ const pruned = pruneRegistry(normalizeRegistry(current), now());
644
704
  const outcome = op(pruned);
645
705
  result = outcome.result;
646
706
  return outcome.registry;
@@ -660,16 +720,21 @@ function createRegistryStore(dataDir, storage) {
660
720
  const { registry, changed } = unregisterUser(pruned, userId);
661
721
  return { registry, result: { changed } };
662
722
  }),
663
- redeem: (code, now) => mutate(now, (pruned) => {
664
- const { registry, outcome } = redeemCode(pruned, code, now());
665
- return { registry, result: outcome };
666
- }),
723
+ async redeem(code, now) {
724
+ const current = pruneRegistry(normalizeRegistry(await storage.readJsonOrFresh(path, freshRegistry, "mcp-registry")), now());
725
+ if (!redeemCode(current, code).outcome.ok)
726
+ return { ok: false };
727
+ return mutate(now, (pruned) => {
728
+ const { registry, outcome } = redeemCode(pruned, code);
729
+ return { registry, result: outcome };
730
+ });
731
+ },
667
732
  async generationOf(userId) {
668
- const current = await storage.readJsonOrFresh(path, freshRegistry, "mcp-registry");
733
+ const current = normalizeRegistry(await storage.readJsonOrFresh(path, freshRegistry, "mcp-registry"));
669
734
  return generationOf(current, userId);
670
735
  },
671
736
  async listRecipients() {
672
- const current = await storage.readJsonOrFresh(path, freshRegistry, "mcp-registry");
737
+ const current = normalizeRegistry(await storage.readJsonOrFresh(path, freshRegistry, "mcp-registry"));
673
738
  return Object.keys(current.users).map((userId) => {
674
739
  const entry = current.users[userId];
675
740
  return { userId, displayName: entry.displayName, registeredAt: entry.registeredAt };
@@ -779,13 +844,15 @@ function createPlugin(host) {
779
844
  const limiter = createRateLimiter();
780
845
  const drainLock = createDrainLock();
781
846
  const editQueue = createEditQueue();
847
+ const unpersisted = createUnpersistedOutcomes();
782
848
  let disposed = false;
783
849
  const httpDeps = {
784
850
  host,
785
851
  store,
786
852
  registry,
787
853
  editQueue,
788
- token: host.env.MCP_BRIDGE_TOKEN,
854
+ unpersisted,
855
+ token: host.env.MCP_BRIDGE_TOKEN?.trim() || undefined,
789
856
  limiter,
790
857
  drainLock,
791
858
  now: () => new Date,
@@ -800,9 +867,29 @@ function createPlugin(host) {
800
867
  {
801
868
  name: "redrive",
802
869
  async run(signal) {
870
+ for (const [requestId] of unpersisted.entries()) {
871
+ if (disposed || signal?.aborted === true)
872
+ return;
873
+ if (!drainLock.tryStart(requestId))
874
+ continue;
875
+ try {
876
+ const held = unpersisted.get(requestId);
877
+ if (held === undefined)
878
+ continue;
879
+ await store.set(requestId, held);
880
+ unpersisted.delete(requestId);
881
+ } catch (err) {
882
+ host.log.error(`persisting the outcome of delivery ${requestId} failed again`, err);
883
+ } finally {
884
+ drainLock.finish(requestId);
885
+ }
886
+ }
887
+ unpersisted.prune(() => new Date);
803
888
  for (const requestId of await store.list()) {
804
889
  if (disposed || signal?.aborted === true)
805
890
  return;
891
+ if (unpersisted.get(requestId) !== undefined)
892
+ continue;
806
893
  try {
807
894
  const current = await store.get(requestId);
808
895
  if (current === undefined || current.state !== "unknown")
@@ -810,6 +897,8 @@ function createPlugin(host) {
810
897
  if (!drainLock.tryStart(requestId))
811
898
  continue;
812
899
  try {
900
+ if (unpersisted.get(requestId) !== undefined)
901
+ continue;
813
902
  await drainAndPersist(httpDeps, requestId, current);
814
903
  } finally {
815
904
  drainLock.finish(requestId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rackbops/plugin-mcp",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Bridge plugin for the Discord delivery MCP (Rackbops/discord-mcp): the service side of docs/bridge-protocol.md, plus /agent registration and pairing",
5
5
  "type": "module",
6
6
  "main": "dist/plugin.js",
@@ -20,10 +20,10 @@
20
20
  "env": [
21
21
  {
22
22
  "key": "MCP_BRIDGE_TOKEN",
23
- "format": "^\\S{43,}$",
23
+ "format": "^[!-~]{43,}$",
24
24
  "required": false,
25
25
  "secret": true,
26
- "description": "Shared secret the Discord MCP service presents (>= 256 bits, base64url). Unset = the bridge answers 503."
26
+ "description": "Shared secret the Discord MCP service presents (at least 43 printable ASCII characters; 256 bits of base64url recommended). Unset = the bridge answers 503."
27
27
  }
28
28
  ],
29
29
  "destinations": [