@onlyne/mcp-gateway 3.0.0 → 3.1.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/src/index.ts CHANGED
@@ -1,18 +1,25 @@
1
1
  import {
2
- OnlyneNode, OnlyneError, type Auth, type KeyHook, type MaintenanceClock, type NodeId, type Plugins, type Resource,
2
+ OnlyneNode, OnlyneError, type Auth, type KeyHook, type MaintenanceClock, type NodeId, type Plugins,
3
+ type Resource, type ResourceDriver,
3
4
  } from '@onlyne/lib';
4
5
  import { createMcpBridge, resourceOf, McpConnection, type McpServerOptions } from '@onlyne/mcp-core';
5
6
  import type { GatewayConfig, ServerConfig } from './config.js';
6
7
  import { FileStorage } from '@onlyne/im-gateway/storage';
8
+ import { createMountService, MOUNTS_RESOURCE_PREFIX, type MountService, type MountTransport } from './mounts.js';
7
9
  import { t } from './gateway-text.js';
8
10
 
9
11
  export type { GatewayConfig, ServerConfig } from './config.js';
12
+ export type { MountService } from './mounts.js';
10
13
 
11
14
  export interface McpGateway {
12
15
  readonly node: OnlyneNode;
13
16
  readonly config: GatewayConfig;
14
17
  /** The MCP connections, one per configured server, keyed by resource id. */
15
18
  readonly connections: Map<string, McpConnection>;
19
+ /** The scheduled-mount service; its driver is part of the node's resource routing. */
20
+ readonly mounts: MountService;
21
+ /** Display lines for the node host's TUI: servers, mounts, push queue, recent results. */
22
+ status(): Promise<string[]>;
16
23
  stop(): Promise<void>;
17
24
  }
18
25
 
@@ -25,10 +32,10 @@ export function nodeClock(): MaintenanceClock {
25
32
  }
26
33
 
27
34
  /**
28
- * Assembles the gateway: one session per configured MCP server, one Onlyne resource per server.
29
- * A server that cannot be reached at startup still registers (with its last known tools if the
30
- * store has them, an empty list otherwise): the node stays up, and the session re-initializes
31
- * on the first call (RFC 0015 §5).
35
+ * Assembles the gateway: one session per configured MCP server, one Onlyne resource per server,
36
+ * and — when configured — one scheduler resource per mount controller. A server that cannot be
37
+ * reached at startup still registers (with its last known tools if the store has them, an empty
38
+ * list otherwise): the node stays up, and the session re-initializes on the first call (RFC 0015 §5).
32
39
  */
33
40
  export interface McpGatewayOptions {
34
41
  log?: (message: string, fields?: Record<string, unknown>) => void;
@@ -98,24 +105,70 @@ export async function createMcpGateway(config: GatewayConfig, options: McpGatewa
98
105
  node: event.entry.node_id, phase: event.phase, fingerprint: event.entry.fingerprint, cause: event.cause,
99
106
  });
100
107
  },
101
- supports: async () => false,
108
+ // The gateway stores messages but holds no model context: it can fulfill every plan
109
+ // except an await, which would need a model to answer. Its own scheduled result
110
+ // messages are admitted locally under the thread it owns, so this gate must allow
111
+ // `response: none`; refusing it would turn every run result into a delivery refusal.
112
+ supports: async plan => plan.response !== 'await',
102
113
  inject: async () => { throw new OnlyneError('unsupported_delivery', t('error.noContext')); },
103
114
  },
104
115
  domainCandidates: async domain_id => node.discovery.candidates(domain_id),
105
116
  ...(keys === undefined ? {} : { keys }),
106
117
  };
107
118
  let node: OnlyneNode;
119
+ let mounts: MountService | undefined;
120
+ const requireMounts = (): MountService => {
121
+ if (mounts === undefined) throw new OnlyneError('unsupported_delivery', 'The scheduler is not assembled yet.');
122
+ return mounts;
123
+ };
124
+ // One driver routes a resource by id: the scheduler owns `mounts:<controller>`, every other id
125
+ // belongs to the configured MCP server of that name (config rejects a server on the prefix).
126
+ const resources: ResourceDriver = {
127
+ validate: (resource, operation, data) => (resource.resource_ref.id.startsWith(MOUNTS_RESOURCE_PREFIX)
128
+ ? requireMounts().driver.validate(resource, operation, data)
129
+ : bridge.validate(resource, operation, data)),
130
+ execute: (resource, request, signal) => (resource.resource_ref.id.startsWith(MOUNTS_RESOURCE_PREFIX)
131
+ ? requireMounts().driver.execute(resource, request, signal)
132
+ : bridge.execute(resource, request, signal)),
133
+ };
108
134
  node = new OnlyneNode({
109
135
  node_id: owner,
110
136
  base_url,
111
137
  // RFC 0017 §1: a keyed node advertises the scheme it authenticates with. The keyless
112
138
  // binding stays for programmatic callers on loopback.
113
139
  authentication: keys === undefined ? ['none'] : ['signature'],
114
- plugins: { ...plugins, storage, resources: bridge },
140
+ plugins: { ...plugins, storage, resources },
115
141
  now: () => Date.now(),
116
142
  id: () => crypto.randomUUID(),
117
143
  });
118
144
 
145
+ const transport: MountTransport = {
146
+ // One thread per task: the first mount creates it, a restart recovers the same id, and the
147
+ // result messages land in one readable conversation instead of one thread per run.
148
+ ensureThread: async (threadId, title) => {
149
+ try {
150
+ await node.threads.create(node.auth, {
151
+ id: threadId, title,
152
+ idempotency_key: crypto.randomUUID(),
153
+ retry_until: new Date(Date.now() + 86_400_000).toISOString(),
154
+ });
155
+ } catch (error) {
156
+ if (!(error instanceof OnlyneError) || error.error.code !== 'id_conflict') throw error;
157
+ }
158
+ },
159
+ // A target this node cannot name yet is a queue condition, not a peer refusal: the run's
160
+ // result stays pending until discovery finds the controller.
161
+ resolveTarget: async id => { await node.discovery.resolve(id); },
162
+ send: (controller, message) => node.client.send(controller, message, { response: 'none', execution: 'existing' }),
163
+ };
164
+ mounts = createMountService({
165
+ owner, config: config.mounts, servers: config.servers, storage,
166
+ connections, transport,
167
+ ...(options.log === undefined ? {} : { log: options.log }),
168
+ });
169
+ await mounts.start();
170
+ const mountService = mounts;
171
+
119
172
  const gatewayAuth: Auth = { actor: owner };
120
173
  for (const [index, entry] of config.servers.entries()) {
121
174
  const controllers = Array.from(new Set([owner, ...(entry.controllers ?? [])]));
@@ -133,19 +186,29 @@ export async function createMcpGateway(config: GatewayConfig, options: McpGatewa
133
186
  retention_ms: 0,
134
187
  data: definition.data,
135
188
  };
136
- try {
137
- await node.resources.describe(gatewayAuth, definition.resource_ref);
138
- } catch (error) {
139
- if (!(error instanceof OnlyneError) || error.error.code !== 'not_found') throw error;
140
- await node.resources.register(gatewayAuth, resource);
141
- }
189
+ await registerResource(node, gatewayAuth, resource);
190
+ }
191
+ for (const { resource } of mountService.resources()) {
192
+ await registerResource(node, gatewayAuth, resource);
142
193
  }
143
194
 
144
195
  return {
145
- node, config, connections,
196
+ node, config, connections, mounts: mountService,
197
+ status: () => mountService.status(),
146
198
  async stop(): Promise<void> {
199
+ await mountService.stop();
147
200
  node.client.stopMaintenance();
148
201
  for (const connection of connections.values()) await connection.close();
149
202
  },
150
203
  };
151
204
  }
205
+
206
+ /** Registration is idempotent for the same definition; a changed definition is a real conflict. */
207
+ async function registerResource(node: OnlyneNode, auth: Auth, resource: Resource): Promise<void> {
208
+ try {
209
+ await node.resources.describe(auth, resource.resource_ref);
210
+ } catch (error) {
211
+ if (!(error instanceof OnlyneError) || error.error.code !== 'not_found') throw error;
212
+ await node.resources.register(auth, resource);
213
+ }
214
+ }
package/src/main.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- import { createLogger, createNodeKey, startNodeHost } from '@onlyne/node-host';
2
+ import { createLogger, createNodeKey, startNodeHost, type NodeHostOptions } from '@onlyne/node-host';
3
3
  import { resolveLocale, setLocale } from '@onlyne/node-host/i18n';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { loadConfig } from './config.js';
@@ -17,9 +17,11 @@ const config = await loadConfig(path);
17
17
  // logger and the node host below are built after this point so their text follows it too.
18
18
  setLocale(resolveLocale(config.language));
19
19
  // On a terminal the TUI owns the screen, so log lines go to the ring it renders and to a file
20
- // beside the store; the ring alone would vanish with the process.
20
+ // beside the store; the ring alone would vanish with the process. `ONLYNE_NO_TUI=1` asks for
21
+ // the JSON log even on a terminal, so the operator keeps a machine-readable stream.
22
+ const forceJson = process.env.ONLYNE_NO_TUI === '1';
21
23
  const { log, ring } = createLogger(
22
- !(process.stdin.isTTY && process.stdout.isTTY), 200, join(dirname(config.storage_path), 'gateway.log'),
24
+ forceJson || !(process.stdin.isTTY && process.stdout.isTTY), 200, join(dirname(config.storage_path), 'gateway.log'),
23
25
  );
24
26
  // RFC 0017 §2: the CLI is always keyed, and a missing key is generated and saved under
25
27
  // ~/.config/onlyne/keys/<node-id>/ instead of being a hand-written prerequisite.
@@ -37,7 +39,14 @@ handler = request => gateway.node.fetch(request);
37
39
  gateway.node.client.runMaintenance();
38
40
  // Discovery starts only once the real handler is served: a candidate probe aimed at this
39
41
  // node must reach the gateway instead of the starting stub.
40
- const host = await startNodeHost({ node: gateway.node, endpoint, bind: config.server.bind, ring, log });
42
+ // The scheduler's status lines ride into the shared trust TUI through the host's status hook;
43
+ // the node host owns how they are drawn.
44
+ type NodeHostSurface = NodeHostOptions & { status?: () => Promise<string[]> };
45
+ const hostOptions: NodeHostSurface = {
46
+ node: gateway.node, endpoint, bind: config.server.bind, ring, log,
47
+ status: () => gateway.status(),
48
+ };
49
+ const host = await startNodeHost(hostOptions);
41
50
 
42
51
  const shutdown = async (): Promise<void> => {
43
52
  await host.close();