pi-roundtable-mcp 0.15.4 → 0.17.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/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.17.0] - 2026-10-16
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **Breaking for plugins that read `CONNECTORS` while they set up:** `CONNECTORS.list()`, `servers()` and `profileSources()` are empty during `setup` (and `version` is 0), because `mcpConnectors` no longer reads the database or reaches the gateway there. It reads them in its `preflight`, which the host runs after every plugin is set up and before any service starts or any listener opens, and `version` changes when it has. Read the list from a service's `start`, or later (a turn, a command, a request); a host that cached tool sets by `version` before the host started rebuilds them once. `ConnectorRegistry.attach` is synchronous, and a new `load()` does the reading. The runtime's startup tool probe runs before this preflight, so it does not see the connectors' servers: a `requiredTools` entry that only a connector's server provides fails the start; the first real session connects to them instead.
|
|
13
|
+
- `mcpConnectors` and `remoteMcp` no longer query the database, or reach the gateway, during `setup`, so `Roundtable.check()` passes with them and needs neither. `remoteMcp` reads the principal the dispatch token stands for, and adopts the sessions and conversations of earlier versions, in its `preflight` too; a persona and the relay note read the principal when a conversation opens. Because both run before any service starts, no command or turn sees an empty connector list, and a failure there still stops the start before a surface connects. Which error stops the start where: a principal that does not exist, and a token linked to someone else by the configuration or the CLI, are refused by the identity plugin's setup as before; only a token linked to no one, which needs an `IDENTITY` that replaces the built-in plugin, is now refused in `remoteMcp`'s `preflight`, with the same message and without the `setup failed` prefix. A database error while adopting earlier sessions is now raised there too.
|
|
14
|
+
- Release in lockstep with pi-roundtable 0.17.0. The core peer range moves to `>=0.17.0 <0.18.0`.
|
|
15
|
+
|
|
16
|
+
## [0.16.0] - 2026-10-16
|
|
17
|
+
|
|
18
|
+
- Release in lockstep with pi-roundtable 0.16.0; no package-specific behavior changes. The core peer range moves to `>=0.16.0 <0.17.0`.
|
|
19
|
+
|
|
8
20
|
## [0.15.4] - 2026-10-15
|
|
9
21
|
|
|
10
22
|
- Release in lockstep with pi-roundtable 0.15.4; no package-specific behavior changes. The core peer range stays `>=0.15.0 <0.16.0`.
|
package/README.md
CHANGED
|
@@ -110,6 +110,7 @@ It provides the `CONNECTORS` service (`Connectors`):
|
|
|
110
110
|
| `resolve(serverName)` | Reads one virtual server's URL and tools by name, for servers the host manages itself rather than as connectors |
|
|
111
111
|
| `admin` | `gateways()` and `servers()`: what ContextForge holds, upstream gateways with their state and every virtual server with its tool names, for a host's status view |
|
|
112
112
|
|
|
113
|
+
The plugin reads the stored connectors, and resolves each one's server in ContextForge, in its `preflight`, which the host runs after every plugin is set up and before any service starts or any listener opens: during `setup` `list()`, `servers()` and `profileSources()` are empty and `version` is 0, and `version` changes once the connectors are loaded. Read them from a service's `start`, or later (a turn, a command, a request); no command or turn arrives before they are loaded. `resolve(serverName)` and `token` work from setup. A host's `Roundtable.check()` runs no preflight, so it needs neither the database nor ContextForge. The runtime's startup tool probe runs before this preflight, so it does not see the connectors' servers: a `requiredTools` entry that only a connector's server provides fails the start; the first real session connects to them instead.
|
|
113
114
|
The plugin does not decide which agent uses which connector.
|
|
114
115
|
The host reads the service, builds its own profiles, and compares `version` with the one it built at.
|
|
115
116
|
A plugin registered after `mcpConnectors` can read the list like this:
|
|
@@ -217,7 +218,7 @@ The conversation is recorded as private to that principal.
|
|
|
217
218
|
Nothing is posted to Discord, since no chat surface serves `mcp:` channels; the outside agent polls for the answer.
|
|
218
219
|
A relayed message that the host's judge reads as approving held actions confirms them, as the person's own reply would.
|
|
219
220
|
Each message begins with a short note that says it was written in an outside agent: for an owner, the note and the persona name the owner, word for word as in 0.8; for anyone else they name no owner, so the agent does not take them for one.
|
|
220
|
-
Which of the two is chosen at the start, by whether the principal then holds the owner role.
|
|
221
|
+
Which of the two is chosen at the start, by whether the principal then holds the owner role: the plugin reads the principal, and adopts the sessions and conversations of earlier versions, in its `preflight`, before any service starts and the listeners open, so a request never arrives before it. A conversation's persona and relay note read it when the conversation opens.
|
|
221
222
|
Starting a remote conversation over archives it, and deleting it also ends the outside agent's session.
|
|
222
223
|
Sessions that stay idle for 14 days are deleted once a day.
|
|
223
224
|
|
|
@@ -267,7 +268,7 @@ export function hostRemote(dispatchToken: string, publicUrl: string) {
|
|
|
267
268
|
The dispatch token is the identity `token:<toolNames.dispatch, or remote-mcp>`, which the plugin declares in its `identities`.
|
|
268
269
|
At every start the host links it to `principal`, or to the primary owner without one, as in 0.8.
|
|
269
270
|
`roundtable principal list` shows it under that principal as `token:remote-mcp (plugin remote-mcp)`, and `roundtable principal unlink` refuses it: change the option instead.
|
|
270
|
-
The start stops when the principal does not exist, or when the identity is linked to someone else by the configuration or the CLI; the error names what to change.
|
|
271
|
+
The start stops when the principal does not exist, or when the identity is linked to someone else by the configuration or the CLI (the identity plugin refuses either as it sets up), or when `IDENTITY` links the token to no one (the plugin's `preflight`); the error names what to change.
|
|
271
272
|
|
|
272
273
|
Bound to a member, a remote turn reads and changes the member's memory, and only the tools of their tier are offered; when the host runs the conversations itself, that holds only if its `answer` runs the turn as the `speaker` it receives.
|
|
273
274
|
Give that member a lasting role with `roundtable principal grant <principal> member` (or `admin`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "MCP connectors through ContextForge and a remote MCP endpoint for pi-roundtable",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,12 +40,12 @@
|
|
|
40
40
|
"typebox": "1.3.34"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"pi-roundtable": ">=0.
|
|
43
|
+
"pi-roundtable": ">=0.17.0 <0.18.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@biomejs/biome": "2.5.15",
|
|
47
47
|
"@types/bun": "1.4.2",
|
|
48
|
-
"pi-roundtable": "0.
|
|
48
|
+
"pi-roundtable": "0.17.0",
|
|
49
49
|
"typescript": "7.0.2"
|
|
50
50
|
}
|
|
51
51
|
}
|
|
@@ -106,27 +106,35 @@ export class ConnectorRegistry {
|
|
|
106
106
|
},
|
|
107
107
|
};
|
|
108
108
|
|
|
109
|
-
/** The registry over the host's migrated pool, with
|
|
110
|
-
static
|
|
109
|
+
/** The registry over the host's migrated pool, with no connector read yet; `load()` reads them. */
|
|
110
|
+
static attach(
|
|
111
111
|
sql: SQL,
|
|
112
112
|
options: ConnectorRegistryOptions,
|
|
113
|
-
):
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
): ConnectorRegistry {
|
|
114
|
+
return new ConnectorRegistry(sql, options);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Reads the stored connectors and resolves each one's server; one that cannot be reached is logged and left without a server. Changes `version`. */
|
|
118
|
+
async load(): Promise<void> {
|
|
119
|
+
const rows: Row[] = await this
|
|
120
|
+
.#sql`SELECT * FROM owner_connectors ORDER BY name`;
|
|
116
121
|
for (const row of rows) {
|
|
117
122
|
const connector = fromRow(row);
|
|
118
123
|
// One unreachable connector must not keep the host from starting.
|
|
119
124
|
try {
|
|
120
|
-
connector.server = await options.resolve(
|
|
125
|
+
connector.server = await this.#options.resolve(
|
|
126
|
+
this.serverName(row.name),
|
|
127
|
+
);
|
|
121
128
|
} catch (error) {
|
|
122
|
-
options.logger.error(
|
|
129
|
+
this.#options.logger.error(
|
|
123
130
|
{ connector: row.name, err: error },
|
|
124
131
|
"connector could not be read; its profile is off",
|
|
125
132
|
);
|
|
126
133
|
}
|
|
127
|
-
|
|
134
|
+
this.#connectors.set(row.name, connector);
|
|
128
135
|
}
|
|
129
|
-
|
|
136
|
+
// What a host built from the list before this is stale.
|
|
137
|
+
this.#version += 1;
|
|
130
138
|
}
|
|
131
139
|
|
|
132
140
|
/** The name of the connector's virtual server in ContextForge. */
|
|
@@ -85,18 +85,22 @@ function checkOptions({ contextForge }: McpConnectorsOptions): string {
|
|
|
85
85
|
export function mcpConnectors(options: McpConnectorsOptions): RoundtablePlugin {
|
|
86
86
|
const url = checkOptions(options);
|
|
87
87
|
const text = connectorMessages(options.messages);
|
|
88
|
+
let load: (() => Promise<void>) | undefined;
|
|
88
89
|
return definePlugin({
|
|
89
90
|
name: "mcp-connectors",
|
|
90
91
|
requires: [DISCORD],
|
|
91
92
|
provides: [CONNECTORS],
|
|
92
93
|
migrations: [ConnectorRegistry.migration],
|
|
93
|
-
|
|
94
|
+
// The stored connectors are read, and their servers resolved, before any service starts, so
|
|
95
|
+
// Discord takes no command or turn that sees an empty list; `Roundtable.check()` never runs it.
|
|
96
|
+
preflight: () => load?.(),
|
|
97
|
+
setup: ({ database, services, logger }) => {
|
|
94
98
|
const { jwtSecret, user } = options.contextForge;
|
|
95
99
|
const token = contextForgeToken(jwtSecret, user, TOKEN_TTL_SECONDS);
|
|
96
100
|
const admin = new ContextForgeAdmin(url, token, fetch, text);
|
|
97
101
|
const resolve = (name: string) =>
|
|
98
102
|
resolveVirtualServer(url, token, name, fetch, text);
|
|
99
|
-
const registry =
|
|
103
|
+
const registry = ConnectorRegistry.attach(database(), {
|
|
100
104
|
admin,
|
|
101
105
|
resolve,
|
|
102
106
|
logger,
|
|
@@ -120,6 +124,8 @@ export function mcpConnectors(options: McpConnectorsOptions): RoundtablePlugin {
|
|
|
120
124
|
servers: () => admin.servers(),
|
|
121
125
|
},
|
|
122
126
|
});
|
|
127
|
+
// Nothing is added but what preflight reads.
|
|
128
|
+
load = () => registry.load();
|
|
123
129
|
return {};
|
|
124
130
|
},
|
|
125
131
|
});
|
|
@@ -138,6 +138,7 @@ export function remoteMcp(options: RemoteMcpOptions): RoundtablePlugin {
|
|
|
138
138
|
const text = remoteMcpMessages(options.messages);
|
|
139
139
|
const toolNames = toolNamesOf(options);
|
|
140
140
|
const identity = tokenIdentity(options);
|
|
141
|
+
let adopt: (() => Promise<void>) | undefined;
|
|
141
142
|
return definePlugin({
|
|
142
143
|
name: "remote-mcp",
|
|
143
144
|
requires: [DISCORD, IDENTITY, CONVERSATIONS],
|
|
@@ -154,40 +155,47 @@ export function remoteMcp(options: RemoteMcpOptions): RoundtablePlugin {
|
|
|
154
155
|
ChannelGrantStore.migration,
|
|
155
156
|
...RemoteSessionStore.migrations(),
|
|
156
157
|
],
|
|
158
|
+
// Who the token stands for is read, and the sessions of earlier versions adopted, before any
|
|
159
|
+
// service starts, so a failure stops the start before Discord connects; `Roundtable.check()`
|
|
160
|
+
// never runs it.
|
|
161
|
+
preflight: () => adopt?.(),
|
|
157
162
|
setup: async (context) => {
|
|
158
163
|
const { services, logger, conversations, database } = context;
|
|
159
164
|
const discord = services.get(DISCORD);
|
|
160
165
|
const identities = services.get(IDENTITY);
|
|
161
|
-
|
|
166
|
+
// Who the token stands for is read from the stored principals in preflight.
|
|
167
|
+
let found: Bound | undefined;
|
|
168
|
+
const bound = (): Bound => {
|
|
169
|
+
if (!found)
|
|
170
|
+
throw new PluginError(
|
|
171
|
+
"remote-mcp: the dispatch token's principal is read in preflight, before the listeners open; a request before that has no one to act for.",
|
|
172
|
+
);
|
|
173
|
+
return found;
|
|
174
|
+
};
|
|
162
175
|
const conversation = conversationOf(options, context);
|
|
163
176
|
const grants = await ChannelGrantStore.attach(database());
|
|
164
177
|
const sessions = await RemoteSessionStore.attach(database());
|
|
165
|
-
// 0.8's sessions were the owner's, and their conversations, recorded shared, become theirs.
|
|
166
178
|
const registry = services.get(CONVERSATIONS);
|
|
167
|
-
const [primary] = await identities.owners();
|
|
168
|
-
if (primary)
|
|
169
|
-
await sessions.adopt(primary.id, async (id) => {
|
|
170
|
-
await registry.adopt(`mcp:${id}`, primary.id);
|
|
171
|
-
});
|
|
172
|
-
// Every start adopts each session's conversation for its principal again, so one a failed
|
|
173
|
-
// hand-over left no one's becomes theirs; adopt never changes one already someone's.
|
|
174
|
-
for (const { id, principalId } of await sessions.owned())
|
|
175
|
-
await registry.adopt(`mcp:${id}`, principalId);
|
|
176
179
|
const gateway = new McpGateway({
|
|
177
180
|
dispatchToken: options.dispatchToken,
|
|
178
181
|
agent: new RemoteAgent({
|
|
179
182
|
sessions: {
|
|
180
|
-
create: () => sessions.create(bound.id),
|
|
181
|
-
touch: (id) => sessions.touch(id, bound.id),
|
|
183
|
+
create: () => sessions.create(bound().id),
|
|
184
|
+
touch: (id) => sessions.touch(id, bound().id),
|
|
182
185
|
},
|
|
183
186
|
answer: async (channel, relayed) =>
|
|
184
187
|
conversation.answer(
|
|
185
188
|
channel,
|
|
186
189
|
relayed,
|
|
187
|
-
await identities.speakerFor(bound.id),
|
|
190
|
+
await identities.speakerFor(bound().id),
|
|
188
191
|
),
|
|
189
192
|
logger,
|
|
190
|
-
messages: {
|
|
193
|
+
messages: {
|
|
194
|
+
...text,
|
|
195
|
+
get relayNote() {
|
|
196
|
+
return relayNoteOf(options, bound());
|
|
197
|
+
},
|
|
198
|
+
},
|
|
191
199
|
}),
|
|
192
200
|
grants,
|
|
193
201
|
executor: () => discord.connection.channelExecutor(),
|
|
@@ -205,6 +213,20 @@ export function remoteMcp(options: RemoteMcpOptions): RoundtablePlugin {
|
|
|
205
213
|
mcpGrantCommands(discord.guard, grants, options.publicUrl, text),
|
|
206
214
|
);
|
|
207
215
|
services.provide(REMOTE_MCP, remoteMcpService(grants, text));
|
|
216
|
+
// Read before any service starts, by `preflight` above.
|
|
217
|
+
adopt = async () => {
|
|
218
|
+
found = await boundPrincipal(identities, identity);
|
|
219
|
+
// 0.8's sessions were the owner's, and their conversations, recorded shared, become theirs.
|
|
220
|
+
const [primary] = await identities.owners();
|
|
221
|
+
if (primary)
|
|
222
|
+
await sessions.adopt(primary.id, async (id) => {
|
|
223
|
+
await registry.adopt(`mcp:${id}`, primary.id);
|
|
224
|
+
});
|
|
225
|
+
// Every start adopts each session's conversation for its principal again, so one a failed
|
|
226
|
+
// hand-over left no one's becomes theirs; adopt never changes one already someone's.
|
|
227
|
+
for (const { id, principalId } of await sessions.owned())
|
|
228
|
+
await registry.adopt(`mcp:${id}`, principalId);
|
|
229
|
+
};
|
|
208
230
|
return {
|
|
209
231
|
services: [
|
|
210
232
|
{
|
|
@@ -262,14 +284,20 @@ function conversationOf(
|
|
|
262
284
|
|
|
263
285
|
/**
|
|
264
286
|
* The persona of the default conversations, the owner's or one that names no owner, as the token
|
|
265
|
-
* stands for at the start; a host that runs its own brings its own.
|
|
287
|
+
* stands for at the start (read when a conversation opens); a host that runs its own brings its own.
|
|
266
288
|
*/
|
|
267
289
|
function personaOf(
|
|
268
290
|
options: RemoteMcpOptions,
|
|
269
|
-
bound: Bound,
|
|
291
|
+
bound: () => Bound,
|
|
270
292
|
): Pick<Contribution, "personas"> {
|
|
271
293
|
if (options.answer) return {};
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
294
|
+
return {
|
|
295
|
+
personas: [
|
|
296
|
+
{
|
|
297
|
+
kind: REMOTE_KIND,
|
|
298
|
+
prompt: () =>
|
|
299
|
+
options.persona ?? (bound().owner ? DEFAULT_PERSONA : MEMBER_PERSONA),
|
|
300
|
+
},
|
|
301
|
+
],
|
|
302
|
+
};
|
|
275
303
|
}
|