@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.
- package/README.md +26 -8
- package/dist/plugin.js +133 -23
- 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}
|
|
8
|
-
|
|
9
|
-
|
|
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`
|
|
22
|
-
|
|
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
|
|
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
|
|
159
|
-
|
|
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
|
-
|
|
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
|
|
338
|
+
deps.log.error(`edit ${requestId} (of ${messageRef}) failed: ${describeError(err)}`);
|
|
306
339
|
return { state: "failed", code: "UPSTREAM_UNAVAILABLE" };
|
|
307
340
|
}
|
|
308
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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[
|
|
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
|
|
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
|
|
664
|
-
const
|
|
665
|
-
|
|
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
|
-
|
|
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
|
+
"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 (
|
|
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": [
|