@rackbops/plugin-mcp 0.3.2 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +26 -8
  2. package/dist/plugin.js +133 -23
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -4,9 +4,11 @@ The bridge plugin for the Discord delivery MCP ([`Rackbops/discord-mcp`](https:/
4
4
  [Rackbops/Tooling#742](https://github.com/Rackbops/Tooling/issues/742)). It implements the service
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
- `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)):
7
+ `POST /deliveries`, `GET /deliveries/{request_id}`, `GET /recipients` and
8
+ `GET /recipients/{user_id}` -- so the MCP service can
9
+ hand it a message and get one Discord post, DM or edit out of it: a retry of a request that is
10
+ delivered or in flight never delivers again (a retry of a failed or unknown one, and the restart
11
+ 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
12
  the `/agent` slash command, and `POST /pair/redeem` + `GET /registration/{user_id}` under the same
11
13
  `/mcp/` path.
12
14
 
@@ -18,8 +20,13 @@ command's own replies name whichever form the user invoked.
18
20
 
19
21
  - Accepts a delivery request, answers `202` immediately, and delivers to Discord in the background.
20
22
  - 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.
23
+ restart mid-delivery) so a retry with the same `request_id` of a `delivered` or `pending` request
24
+ never delivers again.
25
+ - A retry of a `failed` or `unknown` request resets it to `pending` and delivers again. A tick
26
+ re-drives any delivery a restart left `unknown`, and prunes records older than 8
27
+ days. Delivery is therefore at-least-once: a restart between the Discord call and the state
28
+ write can post a second copy. A state write that fails without a restart does not: the outcome is
29
+ held in memory, answered from there, and the tick retries the write alone (that memory is lost on a restart).
23
30
  - `kind: "post"` goes through `host.post` (or `host.announce` when the host predates it); `kind: "dm"`
24
31
  goes through `host.dm`, to a registered user only; `kind: "edit"` goes through `host.edit`, applied
25
32
  against the stored delivery `message_ref` names. `GET /capabilities` reports `dm`/`edit` as `true`
@@ -84,6 +91,17 @@ most 100, each `display_name` truncated to 100 characters (same bearer auth; any
84
91
  200: {"items":[]}
85
92
  ```
86
93
 
94
+ `GET /recipients/{user_id}` -- one registered user, in the same shape as a `GET /recipients` item
95
+ and with the same display-name truncation, but with no cap: a user registered after the first 100
96
+ still finds their own entry here. `user_id` must be a snowflake (`[1-9][0-9]{16,19}`); a malformed
97
+ one falls through to the generic `404 {"error":"not found"}`. Same bearer auth; any other method is
98
+ `405`:
99
+
100
+ ```
101
+ 200: {"user_id":"123456789012345678","display_name":"Roshne"}
102
+ 404: {"error":"not registered"}
103
+ ```
104
+
87
105
  `GET /capabilities` now reflects the host's real `dm`/`edit` support:
88
106
 
89
107
  ```
@@ -124,7 +142,7 @@ stale `generation` for a user has been revoked and re-paired since, not merely r
124
142
 
125
143
  | Key | Required | Secret | What it does |
126
144
  |---|---|---|---|
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 '/+' '_-'`. |
145
+ | `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
146
 
129
147
  ## Destinations
130
148
 
@@ -155,5 +173,5 @@ that verifies.
155
173
 
156
174
  `docs/bridge-protocol.md` in [`Rackbops/discord-mcp`](https://github.com/Rackbops/discord-mcp) is the
157
175
  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.
176
+ document wins (this plugin is the implementation, not the source of truth). See the header comment of
177
+ each file under `src/` for the file-by-file breakdown and the state-machine gotchas.
package/dist/plugin.js CHANGED
@@ -257,13 +257,46 @@ function toWireState(stored) {
257
257
  }
258
258
  function recipientsResponse(entries) {
259
259
  const sorted = [...entries].sort((a, b) => a.registeredAt.localeCompare(b.registeredAt) || a.userId.localeCompare(b.userId));
260
- return {
261
- items: sorted.slice(0, RECIPIENTS_MAX).map((e) => ({ user_id: e.userId, display_name: e.displayName.slice(0, DISPLAY_NAME_MAX) }))
262
- };
260
+ return { items: sorted.slice(0, RECIPIENTS_MAX).map(recipientResponse) };
261
+ }
262
+ function recipientResponse(entry) {
263
+ return { user_id: entry.userId, display_name: entry.displayName.slice(0, DISPLAY_NAME_MAX) };
263
264
  }
264
265
 
265
266
  // plugins/mcp/src/drain.ts
267
+ function describeError(err) {
268
+ if (!(err instanceof Error))
269
+ return "non-Error rejection";
270
+ const { status, code } = err;
271
+ const parts = [err.name];
272
+ if (typeof status === "number")
273
+ parts.push(`status ${status}`);
274
+ if (typeof code === "number" || typeof code === "string")
275
+ parts.push(`code ${code}`);
276
+ return parts.join(", ");
277
+ }
266
278
  var RECIPIENT_UNREACHABLE_MESSAGE = "recipient cannot be messaged";
279
+ var UNPERSISTED_MAX_AGE_MS = 8 * 24 * 60 * 60 * 1000;
280
+ function createUnpersistedOutcomes() {
281
+ const held = new Map;
282
+ return {
283
+ get: (requestId) => held.get(requestId),
284
+ set: (requestId, outcome) => void held.set(requestId, outcome),
285
+ delete: (requestId) => void held.delete(requestId),
286
+ entries: () => [...held.entries()],
287
+ prune(now) {
288
+ const cutoff = now().getTime() - UNPERSISTED_MAX_AGE_MS;
289
+ const dropped = [];
290
+ for (const [requestId, outcome] of held) {
291
+ if (new Date(outcome.createdAt).getTime() <= cutoff) {
292
+ held.delete(requestId);
293
+ dropped.push(requestId);
294
+ }
295
+ }
296
+ return dropped;
297
+ }
298
+ };
299
+ }
267
300
  function createEditQueue() {
268
301
  const chains = new Map;
269
302
  return {
@@ -302,10 +335,14 @@ async function attemptEdit(deps, requestId, target, body) {
302
335
  try {
303
336
  await deps.host.edit(delivered.delivery, body);
304
337
  } catch (err) {
305
- deps.log.error(`edit ${requestId} (of ${messageRef}) failed`, err);
338
+ deps.log.error(`edit ${requestId} (of ${messageRef}) failed: ${describeError(err)}`);
306
339
  return { state: "failed", code: "UPSTREAM_UNAVAILABLE" };
307
340
  }
308
- await deps.store.recordEditSeq(messageRef, seq);
341
+ try {
342
+ await deps.store.recordEditSeq(messageRef, seq);
343
+ } catch (err) {
344
+ deps.log.error(`recording edit seq ${seq} on ${messageRef} failed: ${describeError(err)}`);
345
+ }
309
346
  return { state: "delivered", messageRef: delivered.messageRef, url: delivered.url, delivery: delivered.delivery, applied: true };
310
347
  });
311
348
  }
@@ -329,7 +366,7 @@ async function attemptDm(deps, requestId, target, body) {
329
366
  if (err instanceof Error && err.message === RECIPIENT_UNREACHABLE_MESSAGE) {
330
367
  return { state: "failed", code: "RECIPIENT_UNREACHABLE" };
331
368
  }
332
- deps.log.error(`delivery ${requestId} failed`, err);
369
+ deps.log.error(`delivery ${requestId} failed: ${describeError(err)}`);
333
370
  return { state: "failed", code: "UPSTREAM_UNAVAILABLE" };
334
371
  }
335
372
  }
@@ -353,7 +390,7 @@ async function attemptDelivery(deps, requestId, entry) {
353
390
  const url = `https://discord.com/channels/${delivery.guildId ?? "@me"}/${delivery.channelId}/${delivery.messageId}`;
354
391
  return { state: "delivered", messageRef: requestId, url, delivery };
355
392
  } catch (err) {
356
- log.error(`delivery ${requestId} failed`, err);
393
+ log.error(`delivery ${requestId} failed: ${describeError(err)}`);
357
394
  return { state: "failed", code: "UPSTREAM_UNAVAILABLE" };
358
395
  }
359
396
  }
@@ -364,7 +401,7 @@ async function attemptDelivery(deps, requestId, entry) {
364
401
  await host.announce(body.content ?? "", target.destination);
365
402
  return { state: "delivered", messageRef: null, url: null, delivery: null };
366
403
  } catch (err) {
367
- log.error(`delivery ${requestId} failed`, err);
404
+ log.error(`delivery ${requestId} failed: ${describeError(err)}`);
368
405
  return { state: "failed", code: "UPSTREAM_UNAVAILABLE" };
369
406
  }
370
407
  }
@@ -401,7 +438,13 @@ async function drainAndPersist(deps, requestId, entry) {
401
438
  delivery: outcome.delivery,
402
439
  ...outcome.applied !== undefined ? { applied: outcome.applied } : {}
403
440
  } : { ...entry, state: "failed", code: outcome.code };
404
- await deps.store.set(requestId, next);
441
+ try {
442
+ await deps.store.set(requestId, next);
443
+ } catch (err) {
444
+ deps.unpersisted.set(requestId, next);
445
+ throw err;
446
+ }
447
+ deps.unpersisted.delete(requestId);
405
448
  }
406
449
 
407
450
  // plugins/mcp/src/http.ts
@@ -411,6 +454,7 @@ function json(status, body) {
411
454
  async function startDrain(deps, requestId, entry) {
412
455
  if (!deps.drainLock.tryStart(requestId))
413
456
  return false;
457
+ deps.unpersisted.delete(requestId);
414
458
  try {
415
459
  await deps.store.set(requestId, entry);
416
460
  } catch (err) {
@@ -449,7 +493,9 @@ async function handleCreateDelivery(request, deps) {
449
493
  }
450
494
  return json(202, { state: toWireState(value), existing });
451
495
  }
452
- const { value, existing } = await deps.store.reserve(requestId, kind, target, body, deps.now);
496
+ const reserved = await deps.store.reserve(requestId, kind, target, body, deps.now);
497
+ const { existing } = reserved;
498
+ const value = (existing ? deps.unpersisted.get(requestId) : undefined) ?? reserved.value;
453
499
  if (!existing) {
454
500
  await startDrain(deps, requestId, value);
455
501
  return json(202, { state: toWireState(value), existing });
@@ -465,10 +511,10 @@ async function handleCreateDelivery(request, deps) {
465
511
  createdAt: value.createdAt
466
512
  };
467
513
  const started = await startDrain(deps, requestId, reset);
468
- return json(202, { state: toWireState(started ? reset : value), existing });
514
+ return json(202, { state: toWireState(started || value.state === "failed" ? reset : value), existing });
469
515
  }
470
516
  async function handleGetDelivery(requestId, deps) {
471
- const value = await deps.store.get(requestId);
517
+ const value = deps.unpersisted.get(requestId) ?? await deps.store.get(requestId);
472
518
  if (value === undefined)
473
519
  return json(404, { error: "unknown request_id" });
474
520
  return json(200, toWireState(value));
@@ -504,8 +550,15 @@ async function handleGetRecipients(deps) {
504
550
  const entries = await deps.registry.listRecipients();
505
551
  return json(200, recipientsResponse(entries));
506
552
  }
553
+ async function handleGetRecipient(userId, deps) {
554
+ const entry = await deps.registry.recipientOf(userId);
555
+ if (entry === undefined)
556
+ return json(404, { error: "not registered" });
557
+ return json(200, recipientResponse(entry));
558
+ }
507
559
  var DELIVERY_ID_RE = /^\/deliveries\/([0-9a-f]{64})$/;
508
560
  var REGISTRATION_ID_RE = /^\/registration\/([1-9][0-9]{16,19})$/;
561
+ var RECIPIENT_ID_RE = /^\/recipients\/([1-9][0-9]{16,19})$/;
509
562
  async function handleMcpHttp(request, info, deps) {
510
563
  if (deps.token === undefined)
511
564
  return json(503, { error: "bridge not configured" });
@@ -548,6 +601,12 @@ async function handleMcpHttp(request, info, deps) {
548
601
  return json(405, { error: "method not allowed" });
549
602
  return handleGetRecipients(deps);
550
603
  }
604
+ const recipientMatch = RECIPIENT_ID_RE.exec(info.path);
605
+ if (recipientMatch) {
606
+ if (request.method !== "GET")
607
+ return json(405, { error: "method not allowed" });
608
+ return handleGetRecipient(recipientMatch[1], deps);
609
+ }
551
610
  return json(404, { error: "not found" });
552
611
  }
553
612
 
@@ -557,6 +616,20 @@ import { join } from "path";
557
616
  function freshRegistry() {
558
617
  return { users: {} };
559
618
  }
619
+ function isPlainObject(value) {
620
+ return typeof value === "object" && value !== null && !Array.isArray(value);
621
+ }
622
+ function isEntry(value) {
623
+ if (!isPlainObject(value))
624
+ return false;
625
+ const { generation, displayName, registeredAt, codes } = value;
626
+ 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");
627
+ }
628
+ function normalizeRegistry(raw) {
629
+ if (!isPlainObject(raw) || !isPlainObject(raw.users))
630
+ return freshRegistry();
631
+ return { users: Object.fromEntries(Object.entries(raw.users).filter((pair) => isEntry(pair[1]))) };
632
+ }
560
633
  var MAX_CODES = 5;
561
634
  var CODE_TTL_MS = 10 * 60 * 1000;
562
635
  function generateGeneration() {
@@ -564,10 +637,11 @@ function generateGeneration() {
564
637
  }
565
638
  var CODE_ALPHABET = "ABCDEFGHJKMNPQRSTVWXYZ23456789";
566
639
  var CODE_LENGTH = 27;
567
- function generateCode() {
640
+ var defaultDraw = randomInt;
641
+ function generateCode(draw = defaultDraw) {
568
642
  let code = "";
569
643
  for (let i = 0;i < CODE_LENGTH; i++)
570
- code += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
644
+ code += CODE_ALPHABET[draw(CODE_ALPHABET.length)];
571
645
  return code;
572
646
  }
573
647
  function hashCode(code) {
@@ -615,7 +689,7 @@ function unregisterUser(registry, userId) {
615
689
  delete users[userId];
616
690
  return { registry: { ...registry, users }, changed: true };
617
691
  }
618
- function redeemCode(registry, code, now) {
692
+ function redeemCode(registry, code) {
619
693
  const hash = hashCode(code);
620
694
  for (const userId of Object.keys(registry.users)) {
621
695
  const entry = registry.users[userId];
@@ -640,7 +714,7 @@ function createRegistryStore(dataDir, storage) {
640
714
  async function mutate(now, op) {
641
715
  let result;
642
716
  await mutator.update(path, freshRegistry, (current) => {
643
- const pruned = pruneRegistry(current, now());
717
+ const pruned = pruneRegistry(normalizeRegistry(current), now());
644
718
  const outcome = op(pruned);
645
719
  result = outcome.result;
646
720
  return outcome.registry;
@@ -660,20 +734,32 @@ function createRegistryStore(dataDir, storage) {
660
734
  const { registry, changed } = unregisterUser(pruned, userId);
661
735
  return { registry, result: { changed } };
662
736
  }),
663
- redeem: (code, now) => mutate(now, (pruned) => {
664
- const { registry, outcome } = redeemCode(pruned, code, now());
665
- return { registry, result: outcome };
666
- }),
737
+ async redeem(code, now) {
738
+ const current = pruneRegistry(normalizeRegistry(await storage.readJsonOrFresh(path, freshRegistry, "mcp-registry")), now());
739
+ if (!redeemCode(current, code).outcome.ok)
740
+ return { ok: false };
741
+ return mutate(now, (pruned) => {
742
+ const { registry, outcome } = redeemCode(pruned, code);
743
+ return { registry, result: outcome };
744
+ });
745
+ },
667
746
  async generationOf(userId) {
668
- const current = await storage.readJsonOrFresh(path, freshRegistry, "mcp-registry");
747
+ const current = normalizeRegistry(await storage.readJsonOrFresh(path, freshRegistry, "mcp-registry"));
669
748
  return generationOf(current, userId);
670
749
  },
671
750
  async listRecipients() {
672
- const current = await storage.readJsonOrFresh(path, freshRegistry, "mcp-registry");
751
+ const current = normalizeRegistry(await storage.readJsonOrFresh(path, freshRegistry, "mcp-registry"));
673
752
  return Object.keys(current.users).map((userId) => {
674
753
  const entry = current.users[userId];
675
754
  return { userId, displayName: entry.displayName, registeredAt: entry.registeredAt };
676
755
  });
756
+ },
757
+ async recipientOf(userId) {
758
+ const current = normalizeRegistry(await storage.readJsonOrFresh(path, freshRegistry, "mcp-registry"));
759
+ const entry = entryOf(current, userId);
760
+ if (entry === undefined)
761
+ return;
762
+ return { userId, displayName: entry.displayName, registeredAt: entry.registeredAt };
677
763
  }
678
764
  };
679
765
  }
@@ -779,13 +865,15 @@ function createPlugin(host) {
779
865
  const limiter = createRateLimiter();
780
866
  const drainLock = createDrainLock();
781
867
  const editQueue = createEditQueue();
868
+ const unpersisted = createUnpersistedOutcomes();
782
869
  let disposed = false;
783
870
  const httpDeps = {
784
871
  host,
785
872
  store,
786
873
  registry,
787
874
  editQueue,
788
- token: host.env.MCP_BRIDGE_TOKEN,
875
+ unpersisted,
876
+ token: host.env.MCP_BRIDGE_TOKEN?.trim() || undefined,
789
877
  limiter,
790
878
  drainLock,
791
879
  now: () => new Date,
@@ -800,9 +888,29 @@ function createPlugin(host) {
800
888
  {
801
889
  name: "redrive",
802
890
  async run(signal) {
891
+ for (const [requestId] of unpersisted.entries()) {
892
+ if (disposed || signal?.aborted === true)
893
+ return;
894
+ if (!drainLock.tryStart(requestId))
895
+ continue;
896
+ try {
897
+ const held = unpersisted.get(requestId);
898
+ if (held === undefined)
899
+ continue;
900
+ await store.set(requestId, held);
901
+ unpersisted.delete(requestId);
902
+ } catch (err) {
903
+ host.log.error(`persisting the outcome of delivery ${requestId} failed again`, err);
904
+ } finally {
905
+ drainLock.finish(requestId);
906
+ }
907
+ }
908
+ unpersisted.prune(() => new Date);
803
909
  for (const requestId of await store.list()) {
804
910
  if (disposed || signal?.aborted === true)
805
911
  return;
912
+ if (unpersisted.get(requestId) !== undefined)
913
+ continue;
806
914
  try {
807
915
  const current = await store.get(requestId);
808
916
  if (current === undefined || current.state !== "unknown")
@@ -810,6 +918,8 @@ function createPlugin(host) {
810
918
  if (!drainLock.tryStart(requestId))
811
919
  continue;
812
920
  try {
921
+ if (unpersisted.get(requestId) !== undefined)
922
+ continue;
813
923
  await drainAndPersist(httpDeps, requestId, current);
814
924
  } finally {
815
925
  drainLock.finish(requestId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rackbops/plugin-mcp",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
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",
@@ -23,7 +23,7 @@
23
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": [