pi-roundtable-mcp 0.16.0 → 0.17.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/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.1] - 2026-10-16
9
+
10
+ - Release in lockstep with pi-roundtable 0.17.1; no package-specific behavior changes. The core peer range stays `>=0.17.0 <0.18.0`.
11
+
12
+ ## [0.17.0] - 2026-10-16
13
+
14
+ ### Changed
15
+
16
+ - **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.
17
+ - `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.
18
+ - Release in lockstep with pi-roundtable 0.17.0. The core peer range moves to `>=0.17.0 <0.18.0`.
19
+
8
20
  ## [0.16.0] - 2026-10-16
9
21
 
10
22
  - 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`.
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.16.0",
3
+ "version": "0.17.1",
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.16.0 <0.17.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.16.0",
48
+ "pi-roundtable": "0.17.1",
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 each connector's server resolved. */
110
- static async attach(
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
- ): Promise<ConnectorRegistry> {
114
- const registry = new ConnectorRegistry(sql, options);
115
- const rows: Row[] = await sql`SELECT * FROM owner_connectors ORDER BY name`;
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(registry.serverName(row.name));
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
- registry.#connectors.set(row.name, connector);
134
+ this.#connectors.set(row.name, connector);
128
135
  }
129
- return registry;
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
- setup: async ({ database, services, logger }) => {
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 = await ConnectorRegistry.attach(database(), {
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
- const bound = await boundPrincipal(identities, identity);
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: { ...text, relayNote: relayNoteOf(options, bound) },
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
- const prompt =
273
- options.persona ?? (bound.owner ? DEFAULT_PERSONA : MEMBER_PERSONA);
274
- return { personas: [{ kind: REMOTE_KIND, prompt: () => prompt }] };
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
  }