@rackbops/plugin-mcp 0.0.0 → 0.3.1
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 +158 -2
- package/dist/plugin.js +839 -0
- package/package.json +28 -4
package/README.md
CHANGED
|
@@ -1,3 +1,159 @@
|
|
|
1
|
-
# @rackbops/plugin-mcp
|
|
1
|
+
# @rackbops/plugin-mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The bridge plugin for the Discord delivery MCP ([`Rackbops/discord-mcp`](https://github.com/Rackbops/discord-mcp),
|
|
4
|
+
[Rackbops/Tooling#742](https://github.com/Rackbops/Tooling/issues/742)). It implements the service
|
|
5
|
+
side of that repo's `docs/bridge-protocol.md` under `/mcp/` on the bot's own HTTP listener
|
|
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)):
|
|
10
|
+
the `/agent` slash command, and `POST /pair/redeem` + `GET /registration/{user_id}` under the same
|
|
11
|
+
`/mcp/` path.
|
|
12
|
+
|
|
13
|
+
The host prefixes every plugin command with the instance's `COMMAND_PREFIX` (`/pipagent` on a bot
|
|
14
|
+
with `COMMAND_PREFIX=pip`), so `/agent` below reads literally only on an unprefixed bot; the
|
|
15
|
+
command's own replies name whichever form the user invoked.
|
|
16
|
+
|
|
17
|
+
## What it does
|
|
18
|
+
|
|
19
|
+
- Accepts a delivery request, answers `202` immediately, and delivers to Discord in the background.
|
|
20
|
+
- 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
|
+
- `kind: "post"` goes through `host.post` (or `host.announce` when the host predates it); `kind: "dm"`
|
|
24
|
+
goes through `host.dm`, to a registered user only; `kind: "edit"` goes through `host.edit`, applied
|
|
25
|
+
against the stored delivery `message_ref` names. `GET /capabilities` reports `dm`/`edit` as `true`
|
|
26
|
+
exactly when the host provides `host.dm`/`host.edit` -- a host without either still gets a clean
|
|
27
|
+
`failed{CAPABILITY_UNAVAILABLE}` for that kind, the same shape a missing `host.post` has always
|
|
28
|
+
gotten.
|
|
29
|
+
- `/agent register` mints a Discord user a `generation` id; `/agent pair` issues a single-use,
|
|
30
|
+
10-minute pairing code (at least 128 bits, 27 characters; at most 5 live at once -- issuing a 6th
|
|
31
|
+
drops the oldest); a connector exchanges that code for the user's id and generation via
|
|
32
|
+
`POST /pair/redeem`; `/agent unregister` deletes the registration and every outstanding code in
|
|
33
|
+
one write.
|
|
34
|
+
|
|
35
|
+
## DM, edit and recipients
|
|
36
|
+
|
|
37
|
+
Example ids used throughout: user `123456789012345678`, guild `234567890123456789`, channel
|
|
38
|
+
`345678901234567890`, message `456789012345678901`; an original send's `request_id` is 64 x `a`, an
|
|
39
|
+
edit's is 64 x `b`.
|
|
40
|
+
|
|
41
|
+
`POST /deliveries`, `kind: "dm"` -- `target.user_id` must match `^[1-9][0-9]{16,19}$`; `body` is
|
|
42
|
+
validated as for a post:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
request: {"request_id":"<64 hex>","kind":"dm","target":{"user_id":"123456789012345678"},"body":{"content":"hi"}}
|
|
46
|
+
202: {"state":{"state":"pending","kind":"dm","target":{"user_id":"123456789012345678"},"body":{"content":"hi"},"created_at":"2026-09-25T00:00:00.000Z"},"existing":false}
|
|
47
|
+
GET once delivered:
|
|
48
|
+
{"state":"delivered","kind":"dm","target":{"user_id":"123456789012345678"},"body":{"content":"hi"},"created_at":"2026-09-25T00:00:00.000Z","message_ref":"<the dm's own request_id>","url":"https://discord.com/channels/@me/345678901234567890/456789012345678901"}
|
|
49
|
+
GET when the recipient isn't registered, or has closed DMs:
|
|
50
|
+
{"state":"failed","kind":"dm","target":{"user_id":"123456789012345678"},"body":{"content":"hi"},"created_at":"2026-09-25T00:00:00.000Z","code":"RECIPIENT_UNREACHABLE"}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`POST /deliveries`, `kind: "edit"` -- `target.message_ref` is the original post's or dm's own
|
|
54
|
+
`request_id` and must match `^[0-9a-f]{64}$`; `target.seq` is the service's own per-message edit
|
|
55
|
+
counter, an integer from 1 to 2147483647; `body` is partial -- at least one of `content` (1-2000
|
|
56
|
+
characters when present), `card` or `links`:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
request: {"request_id":"bbbb...(64)","kind":"edit","target":{"message_ref":"aaaa...(64)","seq":2},"body":{"content":"v2"}}
|
|
60
|
+
GET once applied:
|
|
61
|
+
{"state":"delivered","kind":"edit","target":{"message_ref":"aaaa...(64)","seq":2},"body":{"content":"v2"},"created_at":"2026-09-25T00:00:00.000Z","message_ref":"aaaa...(64)","url":"https://discord.com/channels/234567890123456789/345678901234567890/456789012345678901","applied":true}
|
|
62
|
+
GET once superseded by a higher seq already applied (reported as unchanged, not an error):
|
|
63
|
+
{ ...same..., "applied":false }
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`failed` codes for an edit:
|
|
67
|
+
|
|
68
|
+
| Code | When |
|
|
69
|
+
| --- | --- |
|
|
70
|
+
| `NOT_FOUND` | No record for `message_ref`, the record isn't `delivered`, or it is itself an edit. |
|
|
71
|
+
| `CAPABILITY_UNAVAILABLE` | `host.edit` is absent, or the original was delivered by `announce` (`delivery: null`). |
|
|
72
|
+
| `RECIPIENT_UNREACHABLE` | The original was a dm and its recipient is no longer registered. |
|
|
73
|
+
| `UPSTREAM_UNAVAILABLE` | `host.edit` rejected. |
|
|
74
|
+
|
|
75
|
+
`applied` appears only on `kind: "edit"` records that are `delivered`. `state` stays
|
|
76
|
+
`pending|delivered|failed|unknown` -- there is no new state.
|
|
77
|
+
|
|
78
|
+
`GET /recipients` -- every registered user, ordered by registration time ascending then `user_id`, at
|
|
79
|
+
most 100, each `display_name` truncated to 100 characters (same bearer auth; any other method is
|
|
80
|
+
`405`):
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
200: {"items":[{"user_id":"123456789012345678","display_name":"Roshne"}]}
|
|
84
|
+
200: {"items":[]}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`GET /capabilities` now reflects the host's real `dm`/`edit` support:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
{"dm":<typeof host.dm === "function">,"targeted_post":<has post>,"cards":<has post>,"edit":<typeof host.edit === "function">,"destinations":[...unchanged...]}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
A dm/edit's `target` 400s use the existing `{"error":"<reason>"}` shape (`target.user_id`/
|
|
94
|
+
`target.message_ref` must be a snowflake/64-hex-string; `target.seq` must be an integer in range;
|
|
95
|
+
the edit body must include at least one of `content`, `card` or `links`).
|
|
96
|
+
|
|
97
|
+
A record created before this version (`kind: "dm"`/`"edit"` with the old coerced target) is never
|
|
98
|
+
delivered -- a dm gets `failed{RECIPIENT_UNREACHABLE}`, an edit gets `failed{NOT_FOUND}`, both with
|
|
99
|
+
no Discord call. No migration runs; the mismatch between the record's `kind` and its actual target
|
|
100
|
+
shape is what a redrive recognizes.
|
|
101
|
+
|
|
102
|
+
## Registration and pairing
|
|
103
|
+
|
|
104
|
+
`POST /pair/redeem` -- exchanges a code from `/agent pair` for the Discord user id and generation:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
request: {"code":"ABCDEFGHJKMNPQRSTVWXYZ23456"}
|
|
108
|
+
200: {"discord_user_id":"123456789012345678","generation":"q1w2e3r4t5y6u7i8o9p0aZ"}
|
|
109
|
+
404: {"error":"invalid or expired code"} (unknown, expired, already used, or issued before an unregister -- one answer for all)
|
|
110
|
+
400: {"error":"malformed request"} (not JSON, no string `code`, or `code` not matching ^[A-Z2-9]{27}$)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`GET /registration/{user_id}` -- `{user_id}` must match `^[1-9][0-9]{16,19}$`, else `404`:
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
200: {"generation":"q1w2e3r4t5y6u7i8o9p0aZ"}
|
|
117
|
+
404: {"error":"not registered"}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
A `generation` changes only across a full unregister-then-register-again -- a connector holding a
|
|
121
|
+
stale `generation` for a user has been revoked and re-paired since, not merely re-issued a code.
|
|
122
|
+
|
|
123
|
+
## Env
|
|
124
|
+
|
|
125
|
+
| Key | Required | Secret | What it does |
|
|
126
|
+
|---|---|---|---|
|
|
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 '/+' '_-'`. |
|
|
128
|
+
|
|
129
|
+
## Destinations
|
|
130
|
+
|
|
131
|
+
Declared so the operator can map each to a channel per server in the admin panel (the same
|
|
132
|
+
`destinations` mechanism every plugin's `HostApi.announce` uses):
|
|
133
|
+
|
|
134
|
+
| Name | What it's for |
|
|
135
|
+
|---|---|
|
|
136
|
+
| `alerts` | Time-sensitive findings and warnings |
|
|
137
|
+
| `deals` | Notable deals or opportunities |
|
|
138
|
+
| `digest` | Periodic summaries |
|
|
139
|
+
| `ops` | Operational status and infrastructure notices |
|
|
140
|
+
|
|
141
|
+
## Operator steps (not done by this plugin)
|
|
142
|
+
|
|
143
|
+
1. Set `PLUGINS=...,mcp` and `MCP_BRIDGE_TOKEN` on the bot instance.
|
|
144
|
+
2. Map at least one destination to a channel, in exactly one server, from the admin panel.
|
|
145
|
+
3. Confirm how the bot's HTTP listener is exposed; if `/mcp/` is not already behind Cloudflare
|
|
146
|
+
Access, front it with an Access application using the existing machine-access service-token
|
|
147
|
+
policy -- the bridge secret above is the second factor, not a substitute for Access.
|
|
148
|
+
4. Configure the MCP service (`Rackbops/discord-mcp`) with this bridge's URL and the same token.
|
|
149
|
+
|
|
150
|
+
None of this runs the plugin's first live end-to-end check by itself -- see
|
|
151
|
+
[Rackbops/Tooling#742](https://github.com/Rackbops/Tooling/issues/742)'s acceptance bullets for what
|
|
152
|
+
that verifies.
|
|
153
|
+
|
|
154
|
+
## Design
|
|
155
|
+
|
|
156
|
+
`docs/bridge-protocol.md` in [`Rackbops/discord-mcp`](https://github.com/Rackbops/discord-mcp) is the
|
|
157
|
+
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.
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// plugins/mcp/src/auth.ts
|
|
3
|
+
import { createHash, timingSafeEqual } from "crypto";
|
|
4
|
+
var WINDOW_MS = 60000;
|
|
5
|
+
var MAX_FAILURES = 10;
|
|
6
|
+
function createRateLimiter() {
|
|
7
|
+
const windows = new Map;
|
|
8
|
+
return {
|
|
9
|
+
isLocked(clientIp, now) {
|
|
10
|
+
const entry = windows.get(clientIp);
|
|
11
|
+
if (entry === undefined)
|
|
12
|
+
return false;
|
|
13
|
+
if (now.getTime() - entry.windowStart >= WINDOW_MS)
|
|
14
|
+
return false;
|
|
15
|
+
return entry.count > MAX_FAILURES;
|
|
16
|
+
},
|
|
17
|
+
recordFailure(clientIp, now) {
|
|
18
|
+
const entry = windows.get(clientIp);
|
|
19
|
+
if (entry === undefined || now.getTime() - entry.windowStart >= WINDOW_MS) {
|
|
20
|
+
windows.set(clientIp, { windowStart: now.getTime(), count: 1 });
|
|
21
|
+
} else {
|
|
22
|
+
entry.count += 1;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function constantTimeEqual(given, expected) {
|
|
28
|
+
const a = createHash("sha256").update(given).digest();
|
|
29
|
+
const b = createHash("sha256").update(expected).digest();
|
|
30
|
+
return timingSafeEqual(a, b);
|
|
31
|
+
}
|
|
32
|
+
function checkAuth(request, clientIp, expectedToken, limiter, now, log) {
|
|
33
|
+
const at = now();
|
|
34
|
+
if (limiter.isLocked(clientIp, at))
|
|
35
|
+
return { ok: false, status: 429 };
|
|
36
|
+
const header = request.headers.get("authorization") ?? "";
|
|
37
|
+
const given = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : undefined;
|
|
38
|
+
if (given === undefined || given.length === 0 || !constantTimeEqual(given, expectedToken)) {
|
|
39
|
+
limiter.recordFailure(clientIp, at);
|
|
40
|
+
log.warn(`auth failed from ${clientIp}`);
|
|
41
|
+
return { ok: false, status: 401 };
|
|
42
|
+
}
|
|
43
|
+
return { ok: true };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// plugins/mcp/src/commands.ts
|
|
47
|
+
import { MessageFlags } from "discord.js";
|
|
48
|
+
async function replyEphemeral(interaction, content) {
|
|
49
|
+
await interaction.reply({ content, flags: MessageFlags.Ephemeral });
|
|
50
|
+
}
|
|
51
|
+
function subcommandRef(interaction, sub) {
|
|
52
|
+
return `\`/${interaction.commandName} ${sub}\``;
|
|
53
|
+
}
|
|
54
|
+
function displayNameOf(interaction) {
|
|
55
|
+
return interaction.user.globalName ?? interaction.user.username;
|
|
56
|
+
}
|
|
57
|
+
async function handleRegister(interaction, store) {
|
|
58
|
+
const outcome = await store.register(interaction.user.id, displayNameOf(interaction), () => new Date);
|
|
59
|
+
if (!outcome.changed) {
|
|
60
|
+
await replyEphemeral(interaction, "Already registered \u2014 nothing changed.");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
await replyEphemeral(interaction, `Registered. Run ${subcommandRef(interaction, "pair")} to generate a pairing code for your agent.`);
|
|
64
|
+
}
|
|
65
|
+
async function handlePair(interaction, store) {
|
|
66
|
+
const outcome = await store.pair(interaction.user.id, displayNameOf(interaction), () => new Date);
|
|
67
|
+
if (!outcome.ok) {
|
|
68
|
+
await replyEphemeral(interaction, `Run ${subcommandRef(interaction, "register")} first.`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
await replyEphemeral(interaction, `Your pairing code: **${outcome.code}**
|
|
72
|
+
|
|
73
|
+
` + "Valid 10 minutes, single use. Enter it only in `discord-mcp pair` or your connector's authorization page \u2014 never paste it anywhere else.");
|
|
74
|
+
}
|
|
75
|
+
async function handleUnregister(interaction, store) {
|
|
76
|
+
const { changed } = await store.unregister(interaction.user.id, () => new Date);
|
|
77
|
+
await replyEphemeral(interaction, changed ? "Unregistered: your agents' tokens and DMs are revoked." : "You weren't registered.");
|
|
78
|
+
}
|
|
79
|
+
function agentCommand(store) {
|
|
80
|
+
return {
|
|
81
|
+
name: "agent",
|
|
82
|
+
build: (builder) => builder.setDescription("Manage your Discord MCP agent access").addSubcommand((s) => s.setName("register").setDescription("Register this Discord account for agent access")).addSubcommand((s) => s.setName("pair").setDescription("Generate a one-time pairing code for your agent")).addSubcommand((s) => s.setName("unregister").setDescription("Revoke your agent access")),
|
|
83
|
+
async handle(interaction) {
|
|
84
|
+
const subcommand = interaction.options.getSubcommand();
|
|
85
|
+
if (subcommand === "register")
|
|
86
|
+
return handleRegister(interaction, store);
|
|
87
|
+
if (subcommand === "pair")
|
|
88
|
+
return handlePair(interaction, store);
|
|
89
|
+
if (subcommand === "unregister")
|
|
90
|
+
return handleUnregister(interaction, store);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// plugins/mcp/src/protocol.ts
|
|
96
|
+
var REQUEST_ID_RE = /^[0-9a-f]{64}$/;
|
|
97
|
+
var SNOWFLAKE_RE = /^[0-9]{5,25}$/;
|
|
98
|
+
var DISCORD_USER_ID_RE = /^[1-9][0-9]{16,19}$/;
|
|
99
|
+
var MAX_BODY_BYTES = 64 * 1024;
|
|
100
|
+
var CONTENT_MIN = 1;
|
|
101
|
+
var CONTENT_MAX = 2000;
|
|
102
|
+
var SEQ_MIN = 1;
|
|
103
|
+
var SEQ_MAX = 2147483647;
|
|
104
|
+
var RECIPIENTS_MAX = 100;
|
|
105
|
+
var DISPLAY_NAME_MAX = 100;
|
|
106
|
+
var PAIR_CODE_RE = /^[A-Z2-9]{27}$/;
|
|
107
|
+
var DESTINATIONS = [
|
|
108
|
+
{ name: "alerts", description: "Time-sensitive findings and warnings" },
|
|
109
|
+
{ name: "deals", description: "Notable deals or opportunities" },
|
|
110
|
+
{ name: "digest", description: "Periodic summaries" },
|
|
111
|
+
{ name: "ops", description: "Operational status and infrastructure notices" }
|
|
112
|
+
];
|
|
113
|
+
var DESTINATION_NAMES = DESTINATIONS.map((d) => d.name);
|
|
114
|
+
function isPostTarget(target) {
|
|
115
|
+
return "guildId" in target && "destination" in target;
|
|
116
|
+
}
|
|
117
|
+
function isDmTarget(target) {
|
|
118
|
+
return "userId" in target;
|
|
119
|
+
}
|
|
120
|
+
function isEditTarget(target) {
|
|
121
|
+
return "messageRef" in target && "seq" in target;
|
|
122
|
+
}
|
|
123
|
+
function isRecord(value) {
|
|
124
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
125
|
+
}
|
|
126
|
+
function validateBody(raw, opts) {
|
|
127
|
+
if (!isRecord(raw))
|
|
128
|
+
return { ok: false, reason: "body must be an object" };
|
|
129
|
+
const value = {};
|
|
130
|
+
if (raw.content !== undefined) {
|
|
131
|
+
if (typeof raw.content !== "string" || raw.content.length < CONTENT_MIN || raw.content.length > CONTENT_MAX) {
|
|
132
|
+
return { ok: false, reason: `body.content must be ${CONTENT_MIN}..${CONTENT_MAX} characters` };
|
|
133
|
+
}
|
|
134
|
+
value.content = raw.content;
|
|
135
|
+
} else if (opts.requireContent) {
|
|
136
|
+
return { ok: false, reason: `body.content must be ${CONTENT_MIN}..${CONTENT_MAX} characters` };
|
|
137
|
+
}
|
|
138
|
+
if (raw.card !== undefined) {
|
|
139
|
+
if (!isRecord(raw.card))
|
|
140
|
+
return { ok: false, reason: "body.card must be an object" };
|
|
141
|
+
value.card = raw.card;
|
|
142
|
+
}
|
|
143
|
+
if (raw.links !== undefined) {
|
|
144
|
+
if (!Array.isArray(raw.links))
|
|
145
|
+
return { ok: false, reason: "body.links must be an array" };
|
|
146
|
+
value.links = raw.links;
|
|
147
|
+
}
|
|
148
|
+
if (!opts.requireContent && value.content === undefined && value.card === undefined && value.links === undefined) {
|
|
149
|
+
return { ok: false, reason: "body must include at least one of content, card or links" };
|
|
150
|
+
}
|
|
151
|
+
return { ok: true, value };
|
|
152
|
+
}
|
|
153
|
+
function validateCreateRequest(raw) {
|
|
154
|
+
if (!isRecord(raw))
|
|
155
|
+
return { ok: false, reason: "request body must be an object" };
|
|
156
|
+
const requestId = raw.request_id;
|
|
157
|
+
if (typeof requestId !== "string" || !REQUEST_ID_RE.test(requestId)) {
|
|
158
|
+
return { ok: false, reason: "request_id must be a lowercase 64-character hex string" };
|
|
159
|
+
}
|
|
160
|
+
const kind = raw.kind;
|
|
161
|
+
if (kind !== "post" && kind !== "dm" && kind !== "edit") {
|
|
162
|
+
return { ok: false, reason: 'kind must be "post", "dm" or "edit"' };
|
|
163
|
+
}
|
|
164
|
+
if (kind === "post") {
|
|
165
|
+
const bodyResult = validateBody(raw.body, { requireContent: true });
|
|
166
|
+
if (!bodyResult.ok)
|
|
167
|
+
return bodyResult;
|
|
168
|
+
if (!isRecord(raw.target))
|
|
169
|
+
return { ok: false, reason: "target must be an object" };
|
|
170
|
+
const guildId = raw.target.guild_id;
|
|
171
|
+
if (typeof guildId !== "string" || !SNOWFLAKE_RE.test(guildId)) {
|
|
172
|
+
return { ok: false, reason: "target.guild_id must be a snowflake" };
|
|
173
|
+
}
|
|
174
|
+
const destination = raw.target.destination;
|
|
175
|
+
if (typeof destination !== "string" || !DESTINATION_NAMES.includes(destination)) {
|
|
176
|
+
return { ok: false, reason: `target.destination must be one of ${DESTINATION_NAMES.join(", ")}` };
|
|
177
|
+
}
|
|
178
|
+
return { ok: true, requestId, kind, target: { guildId, destination }, body: bodyResult.value };
|
|
179
|
+
}
|
|
180
|
+
if (kind === "dm") {
|
|
181
|
+
const bodyResult = validateBody(raw.body, { requireContent: true });
|
|
182
|
+
if (!bodyResult.ok)
|
|
183
|
+
return bodyResult;
|
|
184
|
+
if (!isRecord(raw.target))
|
|
185
|
+
return { ok: false, reason: "target must be an object" };
|
|
186
|
+
const userId = raw.target.user_id;
|
|
187
|
+
if (typeof userId !== "string" || !DISCORD_USER_ID_RE.test(userId)) {
|
|
188
|
+
return { ok: false, reason: "target.user_id must be a snowflake" };
|
|
189
|
+
}
|
|
190
|
+
return { ok: true, requestId, kind, target: { userId }, body: bodyResult.value };
|
|
191
|
+
}
|
|
192
|
+
const bodyResult = validateBody(raw.body, { requireContent: false });
|
|
193
|
+
if (!bodyResult.ok)
|
|
194
|
+
return bodyResult;
|
|
195
|
+
if (!isRecord(raw.target))
|
|
196
|
+
return { ok: false, reason: "target must be an object" };
|
|
197
|
+
const messageRef = raw.target.message_ref;
|
|
198
|
+
if (typeof messageRef !== "string" || !REQUEST_ID_RE.test(messageRef)) {
|
|
199
|
+
return { ok: false, reason: "target.message_ref must be a lowercase 64-character hex string" };
|
|
200
|
+
}
|
|
201
|
+
const seq = raw.target.seq;
|
|
202
|
+
if (typeof seq !== "number" || !Number.isInteger(seq) || seq < SEQ_MIN || seq > SEQ_MAX) {
|
|
203
|
+
return { ok: false, reason: `target.seq must be an integer from ${SEQ_MIN} to ${SEQ_MAX}` };
|
|
204
|
+
}
|
|
205
|
+
return { ok: true, requestId, kind, target: { messageRef, seq }, body: bodyResult.value };
|
|
206
|
+
}
|
|
207
|
+
function validateRedeemRequest(raw) {
|
|
208
|
+
if (!isRecord(raw))
|
|
209
|
+
return { ok: false };
|
|
210
|
+
const code = raw.code;
|
|
211
|
+
if (typeof code !== "string" || !PAIR_CODE_RE.test(code))
|
|
212
|
+
return { ok: false };
|
|
213
|
+
return { ok: true, code };
|
|
214
|
+
}
|
|
215
|
+
function capabilitiesResponse(flags) {
|
|
216
|
+
return {
|
|
217
|
+
dm: flags.dm,
|
|
218
|
+
targeted_post: flags.post,
|
|
219
|
+
cards: flags.post,
|
|
220
|
+
edit: flags.edit,
|
|
221
|
+
destinations: DESTINATIONS.map((d) => ({ destination: d.name, description: d.description }))
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function wireTarget(target) {
|
|
225
|
+
if (isDmTarget(target))
|
|
226
|
+
return { user_id: target.userId };
|
|
227
|
+
if (isEditTarget(target))
|
|
228
|
+
return { message_ref: target.messageRef, seq: target.seq };
|
|
229
|
+
return { guild_id: target.guildId, destination: target.destination };
|
|
230
|
+
}
|
|
231
|
+
function toWireState(stored) {
|
|
232
|
+
const base = {
|
|
233
|
+
kind: stored.kind,
|
|
234
|
+
target: wireTarget(stored.target),
|
|
235
|
+
body: {
|
|
236
|
+
...stored.body.content !== undefined ? { content: stored.body.content } : {},
|
|
237
|
+
...stored.body.card !== undefined ? { card: stored.body.card } : {},
|
|
238
|
+
...stored.body.links !== undefined ? { links: stored.body.links } : {}
|
|
239
|
+
},
|
|
240
|
+
created_at: stored.createdAt
|
|
241
|
+
};
|
|
242
|
+
switch (stored.state) {
|
|
243
|
+
case "pending":
|
|
244
|
+
case "unknown":
|
|
245
|
+
return { state: stored.state, ...base };
|
|
246
|
+
case "delivered":
|
|
247
|
+
return {
|
|
248
|
+
state: "delivered",
|
|
249
|
+
...base,
|
|
250
|
+
message_ref: stored.messageRef,
|
|
251
|
+
url: stored.url,
|
|
252
|
+
...stored.applied !== undefined ? { applied: stored.applied } : {}
|
|
253
|
+
};
|
|
254
|
+
case "failed":
|
|
255
|
+
return { state: "failed", ...base, code: stored.code };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function recipientsResponse(entries) {
|
|
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
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// plugins/mcp/src/drain.ts
|
|
266
|
+
var RECIPIENT_UNREACHABLE_MESSAGE = "recipient cannot be messaged";
|
|
267
|
+
function createEditQueue() {
|
|
268
|
+
const chains = new Map;
|
|
269
|
+
return {
|
|
270
|
+
run(key, fn) {
|
|
271
|
+
const prior = chains.get(key) ?? Promise.resolve();
|
|
272
|
+
const next = prior.then(fn, fn);
|
|
273
|
+
chains.set(key, next.catch(() => {}));
|
|
274
|
+
return next;
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
async function attemptEdit(deps, requestId, target, body) {
|
|
279
|
+
if (!isEditTarget(target)) {
|
|
280
|
+
return { state: "failed", code: "NOT_FOUND" };
|
|
281
|
+
}
|
|
282
|
+
const { messageRef, seq } = target;
|
|
283
|
+
return deps.editQueue.run(messageRef, async () => {
|
|
284
|
+
const original = await deps.store.get(messageRef);
|
|
285
|
+
if (original === undefined || original.state !== "delivered" || original.kind === "edit") {
|
|
286
|
+
return { state: "failed", code: "NOT_FOUND" };
|
|
287
|
+
}
|
|
288
|
+
const delivered = original;
|
|
289
|
+
if (typeof deps.host.edit !== "function" || delivered.delivery === null) {
|
|
290
|
+
return { state: "failed", code: "CAPABILITY_UNAVAILABLE" };
|
|
291
|
+
}
|
|
292
|
+
if (delivered.kind === "dm") {
|
|
293
|
+
const userId = isDmTarget(delivered.target) ? delivered.target.userId : undefined;
|
|
294
|
+
const generation = userId !== undefined ? await deps.registry.generationOf(userId) : undefined;
|
|
295
|
+
if (generation === undefined) {
|
|
296
|
+
return { state: "failed", code: "RECIPIENT_UNREACHABLE" };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (seq < (delivered.lastEditSeq ?? 0)) {
|
|
300
|
+
return { state: "delivered", messageRef: delivered.messageRef, url: delivered.url, delivery: delivered.delivery, applied: false };
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
await deps.host.edit(delivered.delivery, body);
|
|
304
|
+
} catch (err) {
|
|
305
|
+
deps.log.error(`edit ${requestId} (of ${messageRef}) failed`, err);
|
|
306
|
+
return { state: "failed", code: "UPSTREAM_UNAVAILABLE" };
|
|
307
|
+
}
|
|
308
|
+
await deps.store.recordEditSeq(messageRef, seq);
|
|
309
|
+
return { state: "delivered", messageRef: delivered.messageRef, url: delivered.url, delivery: delivered.delivery, applied: true };
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
async function attemptDm(deps, requestId, target, body) {
|
|
313
|
+
if (!isDmTarget(target)) {
|
|
314
|
+
return { state: "failed", code: "RECIPIENT_UNREACHABLE" };
|
|
315
|
+
}
|
|
316
|
+
const generation = await deps.registry.generationOf(target.userId);
|
|
317
|
+
if (generation === undefined) {
|
|
318
|
+
return { state: "failed", code: "RECIPIENT_UNREACHABLE" };
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
const delivery = await deps.host.dm(target.userId, {
|
|
322
|
+
content: body.content ?? "",
|
|
323
|
+
...body.card !== undefined ? { card: body.card } : {},
|
|
324
|
+
...body.links !== undefined ? { links: body.links } : {}
|
|
325
|
+
});
|
|
326
|
+
const url = `https://discord.com/channels/@me/${delivery.channelId}/${delivery.messageId}`;
|
|
327
|
+
return { state: "delivered", messageRef: requestId, url, delivery };
|
|
328
|
+
} catch (err) {
|
|
329
|
+
if (err instanceof Error && err.message === RECIPIENT_UNREACHABLE_MESSAGE) {
|
|
330
|
+
return { state: "failed", code: "RECIPIENT_UNREACHABLE" };
|
|
331
|
+
}
|
|
332
|
+
deps.log.error(`delivery ${requestId} failed`, err);
|
|
333
|
+
return { state: "failed", code: "UPSTREAM_UNAVAILABLE" };
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
async function attemptDelivery(deps, requestId, entry) {
|
|
337
|
+
const { kind, target, body } = entry;
|
|
338
|
+
if (kind === "dm")
|
|
339
|
+
return attemptDm(deps, requestId, target, body);
|
|
340
|
+
if (kind === "edit")
|
|
341
|
+
return attemptEdit(deps, requestId, target, body);
|
|
342
|
+
if (!isPostTarget(target)) {
|
|
343
|
+
return { state: "failed", code: "CAPABILITY_UNAVAILABLE" };
|
|
344
|
+
}
|
|
345
|
+
const { host, log } = deps;
|
|
346
|
+
if (typeof host.post === "function") {
|
|
347
|
+
try {
|
|
348
|
+
const delivery = await host.post(target.guildId, target.destination, {
|
|
349
|
+
content: body.content ?? "",
|
|
350
|
+
...body.card !== undefined ? { card: body.card } : {},
|
|
351
|
+
...body.links !== undefined ? { links: body.links } : {}
|
|
352
|
+
});
|
|
353
|
+
const url = `https://discord.com/channels/${delivery.guildId ?? "@me"}/${delivery.channelId}/${delivery.messageId}`;
|
|
354
|
+
return { state: "delivered", messageRef: requestId, url, delivery };
|
|
355
|
+
} catch (err) {
|
|
356
|
+
log.error(`delivery ${requestId} failed`, err);
|
|
357
|
+
return { state: "failed", code: "UPSTREAM_UNAVAILABLE" };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (body.card !== undefined || body.links !== undefined && body.links.length > 0) {
|
|
361
|
+
return { state: "failed", code: "CAPABILITY_UNAVAILABLE" };
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
await host.announce(body.content ?? "", target.destination);
|
|
365
|
+
return { state: "delivered", messageRef: null, url: null, delivery: null };
|
|
366
|
+
} catch (err) {
|
|
367
|
+
log.error(`delivery ${requestId} failed`, err);
|
|
368
|
+
return { state: "failed", code: "UPSTREAM_UNAVAILABLE" };
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
function createDrainLock() {
|
|
372
|
+
const inFlight = new Map;
|
|
373
|
+
return {
|
|
374
|
+
tryStart(requestId) {
|
|
375
|
+
if (inFlight.has(requestId))
|
|
376
|
+
return false;
|
|
377
|
+
let resolve;
|
|
378
|
+
const promise = new Promise((res) => {
|
|
379
|
+
resolve = res;
|
|
380
|
+
});
|
|
381
|
+
inFlight.set(requestId, { promise, resolve });
|
|
382
|
+
return true;
|
|
383
|
+
},
|
|
384
|
+
finish(requestId) {
|
|
385
|
+
const entry = inFlight.get(requestId);
|
|
386
|
+
inFlight.delete(requestId);
|
|
387
|
+
entry?.resolve();
|
|
388
|
+
},
|
|
389
|
+
settled(requestId) {
|
|
390
|
+
return inFlight.get(requestId)?.promise ?? Promise.resolve();
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
async function drainAndPersist(deps, requestId, entry) {
|
|
395
|
+
const outcome = await attemptDelivery(deps, requestId, entry);
|
|
396
|
+
const next = outcome.state === "delivered" ? {
|
|
397
|
+
...entry,
|
|
398
|
+
state: "delivered",
|
|
399
|
+
messageRef: outcome.messageRef,
|
|
400
|
+
url: outcome.url,
|
|
401
|
+
delivery: outcome.delivery,
|
|
402
|
+
...outcome.applied !== undefined ? { applied: outcome.applied } : {}
|
|
403
|
+
} : { ...entry, state: "failed", code: outcome.code };
|
|
404
|
+
await deps.store.set(requestId, next);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// plugins/mcp/src/http.ts
|
|
408
|
+
function json(status, body) {
|
|
409
|
+
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
410
|
+
}
|
|
411
|
+
async function startDrain(deps, requestId, entry) {
|
|
412
|
+
if (!deps.drainLock.tryStart(requestId))
|
|
413
|
+
return false;
|
|
414
|
+
try {
|
|
415
|
+
await deps.store.set(requestId, entry);
|
|
416
|
+
} catch (err) {
|
|
417
|
+
deps.log.error(`writing the initial state for delivery ${requestId} failed -- attempting delivery anyway`, err);
|
|
418
|
+
}
|
|
419
|
+
drainAndPersist(deps, requestId, entry).catch((err) => deps.log.error(`delivery ${requestId} failed to persist`, err)).finally(() => deps.drainLock.finish(requestId));
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
function hasCapability(deps, kind) {
|
|
423
|
+
if (kind === "post")
|
|
424
|
+
return true;
|
|
425
|
+
if (kind === "dm")
|
|
426
|
+
return typeof deps.host.dm === "function";
|
|
427
|
+
return typeof deps.host.edit === "function";
|
|
428
|
+
}
|
|
429
|
+
async function handleCreateDelivery(request, deps) {
|
|
430
|
+
const text = await request.text();
|
|
431
|
+
if (new TextEncoder().encode(text).length > MAX_BODY_BYTES)
|
|
432
|
+
return json(413, { error: "request body too large" });
|
|
433
|
+
let raw;
|
|
434
|
+
try {
|
|
435
|
+
raw = JSON.parse(text);
|
|
436
|
+
} catch {
|
|
437
|
+
return json(400, { error: "request body must be valid JSON" });
|
|
438
|
+
}
|
|
439
|
+
const validated = validateCreateRequest(raw);
|
|
440
|
+
if (!validated.ok)
|
|
441
|
+
return json(400, { error: validated.reason });
|
|
442
|
+
const { requestId, kind, target, body } = validated;
|
|
443
|
+
if (!hasCapability(deps, kind)) {
|
|
444
|
+
const { value, existing } = await deps.store.reserve(requestId, kind, target, body, deps.now);
|
|
445
|
+
if (value.state === "pending" || value.state === "unknown") {
|
|
446
|
+
const failed = { state: "failed", kind, target, body, createdAt: value.createdAt, code: "CAPABILITY_UNAVAILABLE" };
|
|
447
|
+
await deps.store.set(requestId, failed);
|
|
448
|
+
return json(202, { state: toWireState(failed), existing });
|
|
449
|
+
}
|
|
450
|
+
return json(202, { state: toWireState(value), existing });
|
|
451
|
+
}
|
|
452
|
+
const { value, existing } = await deps.store.reserve(requestId, kind, target, body, deps.now);
|
|
453
|
+
if (!existing) {
|
|
454
|
+
await startDrain(deps, requestId, value);
|
|
455
|
+
return json(202, { state: toWireState(value), existing });
|
|
456
|
+
}
|
|
457
|
+
if (value.state === "delivered" || value.state === "pending") {
|
|
458
|
+
return json(202, { state: toWireState(value), existing });
|
|
459
|
+
}
|
|
460
|
+
const reset = {
|
|
461
|
+
state: "pending",
|
|
462
|
+
kind: value.kind,
|
|
463
|
+
target: value.target,
|
|
464
|
+
body: value.body,
|
|
465
|
+
createdAt: value.createdAt
|
|
466
|
+
};
|
|
467
|
+
const started = await startDrain(deps, requestId, reset);
|
|
468
|
+
return json(202, { state: toWireState(started ? reset : value), existing });
|
|
469
|
+
}
|
|
470
|
+
async function handleGetDelivery(requestId, deps) {
|
|
471
|
+
const value = await deps.store.get(requestId);
|
|
472
|
+
if (value === undefined)
|
|
473
|
+
return json(404, { error: "unknown request_id" });
|
|
474
|
+
return json(200, toWireState(value));
|
|
475
|
+
}
|
|
476
|
+
async function handleRedeemPair(request, deps) {
|
|
477
|
+
const text = await request.text();
|
|
478
|
+
if (new TextEncoder().encode(text).length > MAX_BODY_BYTES)
|
|
479
|
+
return json(413, { error: "request body too large" });
|
|
480
|
+
let raw;
|
|
481
|
+
try {
|
|
482
|
+
raw = JSON.parse(text);
|
|
483
|
+
} catch {
|
|
484
|
+
return json(400, { error: "malformed request" });
|
|
485
|
+
}
|
|
486
|
+
const validated = validateRedeemRequest(raw);
|
|
487
|
+
if (!validated.ok)
|
|
488
|
+
return json(400, { error: "malformed request" });
|
|
489
|
+
const outcome = await deps.registry.redeem(validated.code, deps.now);
|
|
490
|
+
if (!outcome.ok) {
|
|
491
|
+
deps.log.warn("pair redeem: no matching unexpired code");
|
|
492
|
+
return json(404, { error: "invalid or expired code" });
|
|
493
|
+
}
|
|
494
|
+
deps.log.info(`pair redeem: succeeded for discord user ${outcome.discordUserId}`);
|
|
495
|
+
return json(200, { discord_user_id: outcome.discordUserId, generation: outcome.generation });
|
|
496
|
+
}
|
|
497
|
+
async function handleGetRegistration(userId, deps) {
|
|
498
|
+
const generation = await deps.registry.generationOf(userId);
|
|
499
|
+
if (generation === undefined)
|
|
500
|
+
return json(404, { error: "not registered" });
|
|
501
|
+
return json(200, { generation });
|
|
502
|
+
}
|
|
503
|
+
async function handleGetRecipients(deps) {
|
|
504
|
+
const entries = await deps.registry.listRecipients();
|
|
505
|
+
return json(200, recipientsResponse(entries));
|
|
506
|
+
}
|
|
507
|
+
var DELIVERY_ID_RE = /^\/deliveries\/([0-9a-f]{64})$/;
|
|
508
|
+
var REGISTRATION_ID_RE = /^\/registration\/([1-9][0-9]{16,19})$/;
|
|
509
|
+
async function handleMcpHttp(request, info, deps) {
|
|
510
|
+
if (deps.token === undefined)
|
|
511
|
+
return json(503, { error: "bridge not configured" });
|
|
512
|
+
const auth = checkAuth(request, info.clientIp, deps.token, deps.limiter, deps.now, deps.log);
|
|
513
|
+
if (!auth.ok)
|
|
514
|
+
return json(auth.status, { error: auth.status === 429 ? "too many failed attempts" : "unauthorized" });
|
|
515
|
+
if (info.path === "/capabilities") {
|
|
516
|
+
if (request.method !== "GET")
|
|
517
|
+
return json(405, { error: "method not allowed" });
|
|
518
|
+
return json(200, capabilitiesResponse({
|
|
519
|
+
post: typeof deps.host.post === "function",
|
|
520
|
+
dm: typeof deps.host.dm === "function",
|
|
521
|
+
edit: typeof deps.host.edit === "function"
|
|
522
|
+
}));
|
|
523
|
+
}
|
|
524
|
+
if (info.path === "/deliveries") {
|
|
525
|
+
if (request.method !== "POST")
|
|
526
|
+
return json(405, { error: "method not allowed" });
|
|
527
|
+
return handleCreateDelivery(request, deps);
|
|
528
|
+
}
|
|
529
|
+
const deliveryMatch = DELIVERY_ID_RE.exec(info.path);
|
|
530
|
+
if (deliveryMatch) {
|
|
531
|
+
if (request.method !== "GET")
|
|
532
|
+
return json(405, { error: "method not allowed" });
|
|
533
|
+
return handleGetDelivery(deliveryMatch[1], deps);
|
|
534
|
+
}
|
|
535
|
+
if (info.path === "/pair/redeem") {
|
|
536
|
+
if (request.method !== "POST")
|
|
537
|
+
return json(405, { error: "method not allowed" });
|
|
538
|
+
return handleRedeemPair(request, deps);
|
|
539
|
+
}
|
|
540
|
+
const registrationMatch = REGISTRATION_ID_RE.exec(info.path);
|
|
541
|
+
if (registrationMatch) {
|
|
542
|
+
if (request.method !== "GET")
|
|
543
|
+
return json(405, { error: "method not allowed" });
|
|
544
|
+
return handleGetRegistration(registrationMatch[1], deps);
|
|
545
|
+
}
|
|
546
|
+
if (info.path === "/recipients") {
|
|
547
|
+
if (request.method !== "GET")
|
|
548
|
+
return json(405, { error: "method not allowed" });
|
|
549
|
+
return handleGetRecipients(deps);
|
|
550
|
+
}
|
|
551
|
+
return json(404, { error: "not found" });
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// plugins/mcp/src/registry.ts
|
|
555
|
+
import { createHash as createHash2, randomBytes, randomInt } from "crypto";
|
|
556
|
+
import { join } from "path";
|
|
557
|
+
function freshRegistry() {
|
|
558
|
+
return { users: {} };
|
|
559
|
+
}
|
|
560
|
+
var MAX_CODES = 5;
|
|
561
|
+
var CODE_TTL_MS = 10 * 60 * 1000;
|
|
562
|
+
function generateGeneration() {
|
|
563
|
+
return randomBytes(16).toString("base64url");
|
|
564
|
+
}
|
|
565
|
+
var CODE_ALPHABET = "ABCDEFGHJKMNPQRSTVWXYZ23456789";
|
|
566
|
+
var CODE_LENGTH = 27;
|
|
567
|
+
function generateCode() {
|
|
568
|
+
let code = "";
|
|
569
|
+
for (let i = 0;i < CODE_LENGTH; i++)
|
|
570
|
+
code += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
|
|
571
|
+
return code;
|
|
572
|
+
}
|
|
573
|
+
function hashCode(code) {
|
|
574
|
+
return createHash2("sha256").update(code).digest("hex");
|
|
575
|
+
}
|
|
576
|
+
function entryOf(registry, userId) {
|
|
577
|
+
return Object.hasOwn(registry.users, userId) ? registry.users[userId] : undefined;
|
|
578
|
+
}
|
|
579
|
+
function pruneRegistry(registry, now) {
|
|
580
|
+
let changed = false;
|
|
581
|
+
const users = {};
|
|
582
|
+
for (const userId of Object.keys(registry.users)) {
|
|
583
|
+
const entry = registry.users[userId];
|
|
584
|
+
const live = entry.codes.filter((c) => new Date(c.expiresAt).getTime() > now.getTime());
|
|
585
|
+
if (live.length !== entry.codes.length) {
|
|
586
|
+
changed = true;
|
|
587
|
+
users[userId] = { ...entry, codes: live };
|
|
588
|
+
} else {
|
|
589
|
+
users[userId] = entry;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return changed ? { ...registry, users } : registry;
|
|
593
|
+
}
|
|
594
|
+
function registerUser(registry, userId, displayName, now) {
|
|
595
|
+
const existing = entryOf(registry, userId);
|
|
596
|
+
if (existing !== undefined)
|
|
597
|
+
return { registry, outcome: { changed: false, generation: existing.generation } };
|
|
598
|
+
const entry = { generation: generateGeneration(), displayName, registeredAt: now.toISOString(), codes: [] };
|
|
599
|
+
return { registry: { ...registry, users: { ...registry.users, [userId]: entry } }, outcome: { changed: true, generation: entry.generation } };
|
|
600
|
+
}
|
|
601
|
+
function issueCode(registry, userId, displayName, now) {
|
|
602
|
+
const existing = entryOf(registry, userId);
|
|
603
|
+
if (existing === undefined)
|
|
604
|
+
return { registry, outcome: { ok: false } };
|
|
605
|
+
const code = generateCode();
|
|
606
|
+
const withNew = [...existing.codes, { hash: hashCode(code), expiresAt: new Date(now.getTime() + CODE_TTL_MS).toISOString() }];
|
|
607
|
+
const capped = withNew.length > MAX_CODES ? withNew.slice(withNew.length - MAX_CODES) : withNew;
|
|
608
|
+
const entry = { ...existing, displayName, codes: capped };
|
|
609
|
+
return { registry: { ...registry, users: { ...registry.users, [userId]: entry } }, outcome: { ok: true, code } };
|
|
610
|
+
}
|
|
611
|
+
function unregisterUser(registry, userId) {
|
|
612
|
+
if (!Object.hasOwn(registry.users, userId))
|
|
613
|
+
return { registry, changed: false };
|
|
614
|
+
const users = { ...registry.users };
|
|
615
|
+
delete users[userId];
|
|
616
|
+
return { registry: { ...registry, users }, changed: true };
|
|
617
|
+
}
|
|
618
|
+
function redeemCode(registry, code, now) {
|
|
619
|
+
const hash = hashCode(code);
|
|
620
|
+
for (const userId of Object.keys(registry.users)) {
|
|
621
|
+
const entry = registry.users[userId];
|
|
622
|
+
const idx = entry.codes.findIndex((c) => c.hash === hash);
|
|
623
|
+
if (idx === -1)
|
|
624
|
+
continue;
|
|
625
|
+
const codes = entry.codes.filter((_, i) => i !== idx);
|
|
626
|
+
const updated = { ...entry, codes };
|
|
627
|
+
return { registry: { ...registry, users: { ...registry.users, [userId]: updated } }, outcome: { ok: true, discordUserId: userId, generation: entry.generation } };
|
|
628
|
+
}
|
|
629
|
+
return { registry, outcome: { ok: false } };
|
|
630
|
+
}
|
|
631
|
+
function generationOf(registry, userId) {
|
|
632
|
+
return entryOf(registry, userId)?.generation;
|
|
633
|
+
}
|
|
634
|
+
function registryPath(dataDir) {
|
|
635
|
+
return join(dataDir, "mcp", "registry.json");
|
|
636
|
+
}
|
|
637
|
+
function createRegistryStore(dataDir, storage) {
|
|
638
|
+
const mutator = storage.createKeyedJsonMutator();
|
|
639
|
+
const path = registryPath(dataDir);
|
|
640
|
+
async function mutate(now, op) {
|
|
641
|
+
let result;
|
|
642
|
+
await mutator.update(path, freshRegistry, (current) => {
|
|
643
|
+
const pruned = pruneRegistry(current, now());
|
|
644
|
+
const outcome = op(pruned);
|
|
645
|
+
result = outcome.result;
|
|
646
|
+
return outcome.registry;
|
|
647
|
+
}, "mcp-registry");
|
|
648
|
+
return result;
|
|
649
|
+
}
|
|
650
|
+
return {
|
|
651
|
+
register: (userId, displayName, now) => mutate(now, (pruned) => {
|
|
652
|
+
const { registry, outcome } = registerUser(pruned, userId, displayName, now());
|
|
653
|
+
return { registry, result: outcome };
|
|
654
|
+
}),
|
|
655
|
+
pair: (userId, displayName, now) => mutate(now, (pruned) => {
|
|
656
|
+
const { registry, outcome } = issueCode(pruned, userId, displayName, now());
|
|
657
|
+
return { registry, result: outcome };
|
|
658
|
+
}),
|
|
659
|
+
unregister: (userId, now) => mutate(now, (pruned) => {
|
|
660
|
+
const { registry, changed } = unregisterUser(pruned, userId);
|
|
661
|
+
return { registry, result: { changed } };
|
|
662
|
+
}),
|
|
663
|
+
redeem: (code, now) => mutate(now, (pruned) => {
|
|
664
|
+
const { registry, outcome } = redeemCode(pruned, code, now());
|
|
665
|
+
return { registry, result: outcome };
|
|
666
|
+
}),
|
|
667
|
+
async generationOf(userId) {
|
|
668
|
+
const current = await storage.readJsonOrFresh(path, freshRegistry, "mcp-registry");
|
|
669
|
+
return generationOf(current, userId);
|
|
670
|
+
},
|
|
671
|
+
async listRecipients() {
|
|
672
|
+
const current = await storage.readJsonOrFresh(path, freshRegistry, "mcp-registry");
|
|
673
|
+
return Object.keys(current.users).map((userId) => {
|
|
674
|
+
const entry = current.users[userId];
|
|
675
|
+
return { userId, displayName: entry.displayName, registeredAt: entry.registeredAt };
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// plugins/mcp/src/store.ts
|
|
682
|
+
import { readdir, unlink } from "fs/promises";
|
|
683
|
+
import { join as join2 } from "path";
|
|
684
|
+
var NOOP_LOG = { info() {}, warn() {}, error() {} };
|
|
685
|
+
|
|
686
|
+
class NoRecordForEditSeq extends Error {
|
|
687
|
+
}
|
|
688
|
+
var PRUNE_AFTER_MS = 8 * 24 * 60 * 60 * 1000;
|
|
689
|
+
function deliveriesDir(dataDir) {
|
|
690
|
+
return join2(dataDir, "mcp", "deliveries");
|
|
691
|
+
}
|
|
692
|
+
function deliveryPath(dataDir, requestId) {
|
|
693
|
+
return join2(deliveriesDir(dataDir), `${requestId}.json`);
|
|
694
|
+
}
|
|
695
|
+
function createDeliveryStore(dataDir, storage, log = NOOP_LOG) {
|
|
696
|
+
const mutator = storage.createKeyedJsonMutator();
|
|
697
|
+
async function get(requestId) {
|
|
698
|
+
return storage.readJsonOrFresh(deliveryPath(dataDir, requestId), () => {
|
|
699
|
+
return;
|
|
700
|
+
}, "mcp-delivery");
|
|
701
|
+
}
|
|
702
|
+
async function set(requestId, value) {
|
|
703
|
+
await storage.writeJsonAtomic(deliveryPath(dataDir, requestId), value);
|
|
704
|
+
}
|
|
705
|
+
async function list() {
|
|
706
|
+
let names;
|
|
707
|
+
try {
|
|
708
|
+
names = await readdir(deliveriesDir(dataDir));
|
|
709
|
+
} catch (err) {
|
|
710
|
+
if (err.code === "ENOENT")
|
|
711
|
+
return [];
|
|
712
|
+
throw err;
|
|
713
|
+
}
|
|
714
|
+
return names.filter((n) => n.endsWith(".json")).map((n) => n.slice(0, -".json".length));
|
|
715
|
+
}
|
|
716
|
+
return {
|
|
717
|
+
async reserve(requestId, kind, target, body, now) {
|
|
718
|
+
let existing = false;
|
|
719
|
+
let result;
|
|
720
|
+
await mutator.update(deliveryPath(dataDir, requestId), () => null, (current) => {
|
|
721
|
+
if (current !== null) {
|
|
722
|
+
existing = true;
|
|
723
|
+
result = current;
|
|
724
|
+
return current;
|
|
725
|
+
}
|
|
726
|
+
existing = false;
|
|
727
|
+
result = { state: "pending", kind, target, body, createdAt: now().toISOString() };
|
|
728
|
+
return result;
|
|
729
|
+
}, "mcp-delivery");
|
|
730
|
+
return { value: result, existing };
|
|
731
|
+
},
|
|
732
|
+
set,
|
|
733
|
+
get,
|
|
734
|
+
list,
|
|
735
|
+
async markPendingUnknown() {
|
|
736
|
+
for (const requestId of await list()) {
|
|
737
|
+
try {
|
|
738
|
+
const current = await get(requestId);
|
|
739
|
+
if (current?.state === "pending")
|
|
740
|
+
await set(requestId, { ...current, state: "unknown" });
|
|
741
|
+
} catch (err) {
|
|
742
|
+
log.error(`marking delivery ${requestId} unknown after restart failed`, err);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
},
|
|
746
|
+
async recordEditSeq(requestId, seq) {
|
|
747
|
+
try {
|
|
748
|
+
await mutator.update(deliveryPath(dataDir, requestId), () => {
|
|
749
|
+
throw new NoRecordForEditSeq;
|
|
750
|
+
}, (current) => current !== null && current.state === "delivered" ? { ...current, lastEditSeq: seq } : current, "mcp-delivery");
|
|
751
|
+
} catch (err) {
|
|
752
|
+
if (!(err instanceof NoRecordForEditSeq))
|
|
753
|
+
throw err;
|
|
754
|
+
}
|
|
755
|
+
},
|
|
756
|
+
async prune(now) {
|
|
757
|
+
const cutoff = now().getTime() - PRUNE_AFTER_MS;
|
|
758
|
+
const pruned = [];
|
|
759
|
+
for (const requestId of await list()) {
|
|
760
|
+
try {
|
|
761
|
+
const current = await get(requestId);
|
|
762
|
+
if (current !== undefined && new Date(current.createdAt).getTime() <= cutoff) {
|
|
763
|
+
await unlink(deliveryPath(dataDir, requestId));
|
|
764
|
+
pruned.push(requestId);
|
|
765
|
+
}
|
|
766
|
+
} catch (err) {
|
|
767
|
+
log.error(`pruning delivery ${requestId} failed`, err);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return pruned;
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// plugins/mcp/src/index.ts
|
|
776
|
+
function createPlugin(host) {
|
|
777
|
+
const store = createDeliveryStore(host.dataDir, host.storage, host.log);
|
|
778
|
+
const registry = createRegistryStore(host.dataDir, host.storage);
|
|
779
|
+
const limiter = createRateLimiter();
|
|
780
|
+
const drainLock = createDrainLock();
|
|
781
|
+
const editQueue = createEditQueue();
|
|
782
|
+
let disposed = false;
|
|
783
|
+
const httpDeps = {
|
|
784
|
+
host,
|
|
785
|
+
store,
|
|
786
|
+
registry,
|
|
787
|
+
editQueue,
|
|
788
|
+
token: host.env.MCP_BRIDGE_TOKEN,
|
|
789
|
+
limiter,
|
|
790
|
+
drainLock,
|
|
791
|
+
now: () => new Date,
|
|
792
|
+
log: host.log
|
|
793
|
+
};
|
|
794
|
+
return {
|
|
795
|
+
commands: [agentCommand(registry)],
|
|
796
|
+
async http(request, info) {
|
|
797
|
+
return handleMcpHttp(request, info, httpDeps);
|
|
798
|
+
},
|
|
799
|
+
ticks: [
|
|
800
|
+
{
|
|
801
|
+
name: "redrive",
|
|
802
|
+
async run(signal) {
|
|
803
|
+
for (const requestId of await store.list()) {
|
|
804
|
+
if (disposed || signal?.aborted === true)
|
|
805
|
+
return;
|
|
806
|
+
try {
|
|
807
|
+
const current = await store.get(requestId);
|
|
808
|
+
if (current === undefined || current.state !== "unknown")
|
|
809
|
+
continue;
|
|
810
|
+
if (!drainLock.tryStart(requestId))
|
|
811
|
+
continue;
|
|
812
|
+
try {
|
|
813
|
+
await drainAndPersist(httpDeps, requestId, current);
|
|
814
|
+
} finally {
|
|
815
|
+
drainLock.finish(requestId);
|
|
816
|
+
}
|
|
817
|
+
} catch (err) {
|
|
818
|
+
host.log.error(`redrive of delivery ${requestId} failed`, err);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
if (disposed || signal?.aborted === true)
|
|
822
|
+
return;
|
|
823
|
+
const pruned = await store.prune(() => new Date);
|
|
824
|
+
if (pruned.length > 0)
|
|
825
|
+
host.log.info(`pruned ${pruned.length} deliveries older than 8 days`);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
],
|
|
829
|
+
async activate() {
|
|
830
|
+
await store.markPendingUnknown();
|
|
831
|
+
},
|
|
832
|
+
async dispose() {
|
|
833
|
+
disposed = true;
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
export {
|
|
838
|
+
createPlugin
|
|
839
|
+
};
|
package/package.json
CHANGED
|
@@ -1,12 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rackbops/plugin-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
3
|
+
"version": "0.3.1",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "dist/plugin.js",
|
|
7
|
+
"files": ["dist"],
|
|
6
8
|
"repository": {
|
|
7
9
|
"type": "git",
|
|
8
10
|
"url": "git+https://github.com/Rackbops/rackbops-bot-plugins.git",
|
|
9
11
|
"directory": "plugins/mcp"
|
|
10
12
|
},
|
|
11
|
-
"
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"discord.js": "^14.27.0"
|
|
15
|
+
},
|
|
16
|
+
"botPlugin": {
|
|
17
|
+
"hostApiVersion": 1,
|
|
18
|
+
"intents": [],
|
|
19
|
+
"commands": ["agent"],
|
|
20
|
+
"env": [
|
|
21
|
+
{
|
|
22
|
+
"key": "MCP_BRIDGE_TOKEN",
|
|
23
|
+
"format": "^\\S{43,}$",
|
|
24
|
+
"required": false,
|
|
25
|
+
"secret": true,
|
|
26
|
+
"description": "Shared secret the Discord MCP service presents (>= 256 bits, base64url). Unset = the bridge answers 503."
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"destinations": [
|
|
30
|
+
{ "name": "alerts", "description": "Time-sensitive findings and warnings" },
|
|
31
|
+
{ "name": "deals", "description": "Notable deals or opportunities" },
|
|
32
|
+
{ "name": "digest", "description": "Periodic summaries" },
|
|
33
|
+
{ "name": "ops", "description": "Operational status and infrastructure notices" }
|
|
34
|
+
]
|
|
35
|
+
}
|
|
12
36
|
}
|