@rackbops/plugin-mcp 0.3.2 → 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.
- package/README.md +13 -7
- package/dist/plugin.js +109 -20
- package/package.json +2 -2
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
|
|
9
|
-
|
|
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`
|
|
22
|
-
|
|
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
|
|
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
|
|
159
|
-
|
|
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
|
|
337
|
+
deps.log.error(`edit ${requestId} (of ${messageRef}) failed: ${describeError(err)}`);
|
|
306
338
|
return { state: "failed", code: "UPSTREAM_UNAVAILABLE" };
|
|
307
339
|
}
|
|
308
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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[
|
|
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
|
|
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
|
|
664
|
-
const
|
|
665
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|
|
@@ -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 (
|
|
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": [
|