@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/README.md +82 -2
- package/package.json +9 -7
- package/src/config.ts +84 -0
- package/src/cron.ts +122 -0
- package/src/exec.ts +163 -0
- package/src/gateway-text.ts +18 -0
- package/src/index.ts +77 -14
- package/src/main.ts +13 -4
- package/src/mounts.ts +1043 -0
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,
|
|
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
|
-
*
|
|
30
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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();
|