@onlyne/mcp-gateway 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +125 -2
- package/package.json +38 -4
- package/src/config.ts +182 -0
- package/src/cron.ts +122 -0
- package/src/exec.ts +163 -0
- package/src/gateway-text.ts +51 -0
- package/src/index.ts +214 -0
- package/src/main.ts +65 -0
- package/src/mounts.ts +1043 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createTranslator } from '@onlyne/node-host/i18n';
|
|
2
|
+
|
|
3
|
+
const en = {
|
|
4
|
+
'cli.usage': 'usage: onlyne-mcp-gateway <config.json>',
|
|
5
|
+
'startup.pending': 'The gateway is starting.',
|
|
6
|
+
'config.unreadable': 'The configuration file is unreadable: {path}',
|
|
7
|
+
'config.object': 'The configuration needs {field} as an object.',
|
|
8
|
+
'config.string': 'The configuration field {field} is a non-empty string.',
|
|
9
|
+
'config.servers': 'The configuration needs servers as an array.',
|
|
10
|
+
'config.duplicateName': 'Two servers share the name {name}.',
|
|
11
|
+
'config.language': 'The configuration language is en or zh-CN, not {value}.',
|
|
12
|
+
'config.stringList': 'The configuration field {field} needs a list of non-empty strings.',
|
|
13
|
+
'config.reservedName': 'The server name {name} uses the reserved resource prefix {prefix}.',
|
|
14
|
+
'config.mountsControllers': 'mounts.controllers needs at least one node id.',
|
|
15
|
+
'config.mountsOwner': 'The node owner {owner} cannot be its own mount controller.',
|
|
16
|
+
'config.mountsCommands': 'mounts.commands needs an array.',
|
|
17
|
+
'config.mountsTools': 'mounts.tools needs an array.',
|
|
18
|
+
'config.mountsToolServer': 'mounts.tools names the server {server}, which is not configured.',
|
|
19
|
+
'config.mountsDuplicate': 'Two mounts entries share the {kind} {id}.',
|
|
20
|
+
'config.mountTick': 'mounts.tick_ms is an integer from 100 to 60000.',
|
|
21
|
+
'error.claimIdentity': 'HTTP callers cannot claim the gateway identity.',
|
|
22
|
+
'error.decide': 'The gateway forwards tool calls; it decides nothing.',
|
|
23
|
+
'error.noContext': 'The gateway consumes no model context.',
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
const zh: Record<keyof typeof en, string> = {
|
|
27
|
+
'cli.usage': '用法:onlyne-mcp-gateway <config.json>',
|
|
28
|
+
'startup.pending': '网关正在启动。',
|
|
29
|
+
'config.unreadable': '配置文件无法读取:{path}',
|
|
30
|
+
'config.object': '配置项 {field} 需要是一个对象。',
|
|
31
|
+
'config.string': '配置项 {field} 需要是非空字符串。',
|
|
32
|
+
'config.servers': '配置项 servers 需要是一个数组。',
|
|
33
|
+
'config.duplicateName': '有两个服务器共用了名称 {name}。',
|
|
34
|
+
'config.language': '配置项 language 只能是 en 或 zh-CN,而不是 {value}。',
|
|
35
|
+
'config.stringList': '配置项 {field} 需要是非空字符串列表。',
|
|
36
|
+
'config.reservedName': '服务器名称 {name} 占用了保留的资源前缀 {prefix}。',
|
|
37
|
+
'config.mountsControllers': 'mounts.controllers 至少需要一个节点标识。',
|
|
38
|
+
'config.mountsOwner': '节点自身 {owner} 不能作为自己的挂载控制者。',
|
|
39
|
+
'config.mountsCommands': 'mounts.commands 需要是一个数组。',
|
|
40
|
+
'config.mountsTools': 'mounts.tools 需要是一个数组。',
|
|
41
|
+
'config.mountsToolServer': 'mounts.tools 指向的服务器 {server} 并未配置。',
|
|
42
|
+
'config.mountsDuplicate': '有两个挂载配置项共用了 {kind} {id}。',
|
|
43
|
+
'config.mountTick': 'mounts.tick_ms 是 100 到 60000 之间的整数。',
|
|
44
|
+
'error.claimIdentity': 'HTTP 调用方不能冒用网关身份。',
|
|
45
|
+
'error.decide': '网关只转发工具调用,不做任何决策。',
|
|
46
|
+
'error.noContext': '网关不消费模型上下文。',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type GatewayTextKey = keyof typeof en;
|
|
50
|
+
|
|
51
|
+
export const t = createTranslator({ en, 'zh-CN': zh });
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OnlyneNode, OnlyneError, type Auth, type KeyHook, type MaintenanceClock, type NodeId, type Plugins,
|
|
3
|
+
type Resource, type ResourceDriver,
|
|
4
|
+
} from '@onlyne/lib';
|
|
5
|
+
import { createMcpBridge, resourceOf, McpConnection, type McpServerOptions } from '@onlyne/mcp-core';
|
|
6
|
+
import type { GatewayConfig, ServerConfig } from './config.js';
|
|
7
|
+
import { FileStorage } from '@onlyne/im-gateway/storage';
|
|
8
|
+
import { createMountService, MOUNTS_RESOURCE_PREFIX, type MountService, type MountTransport } from './mounts.js';
|
|
9
|
+
import { t } from './gateway-text.js';
|
|
10
|
+
|
|
11
|
+
export type { GatewayConfig, ServerConfig } from './config.js';
|
|
12
|
+
export type { MountService } from './mounts.js';
|
|
13
|
+
|
|
14
|
+
export interface McpGateway {
|
|
15
|
+
readonly node: OnlyneNode;
|
|
16
|
+
readonly config: GatewayConfig;
|
|
17
|
+
/** The MCP connections, one per configured server, keyed by resource id. */
|
|
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[]>;
|
|
23
|
+
stop(): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The Node-timer MaintenanceClock: one-shot timers, the lib's loop re-arms them. */
|
|
27
|
+
export function nodeClock(): MaintenanceClock {
|
|
28
|
+
return {
|
|
29
|
+
setTimer: (ms, run) => setTimeout(run, ms),
|
|
30
|
+
clearTimer: handle => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
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).
|
|
39
|
+
*/
|
|
40
|
+
export interface McpGatewayOptions {
|
|
41
|
+
log?: (message: string, fields?: Record<string, unknown>) => void;
|
|
42
|
+
/**
|
|
43
|
+
* RFC 0017 identity key. The CLI always supplies one; a programmatic caller may omit it and
|
|
44
|
+
* keep the keyless loopback binding the tests use.
|
|
45
|
+
*/
|
|
46
|
+
keys?: KeyHook;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function createMcpGateway(config: GatewayConfig, options: McpGatewayOptions = {}): Promise<McpGateway> {
|
|
50
|
+
const owner: NodeId = config.node.id;
|
|
51
|
+
const storage = new FileStorage(config.storage_path);
|
|
52
|
+
const connections = new Map<string, McpConnection>();
|
|
53
|
+
const serverEntries: Array<{ server: McpServerOptions; connection: McpConnection }> = [];
|
|
54
|
+
|
|
55
|
+
for (const entry of config.servers) {
|
|
56
|
+
const server: McpServerOptions = {
|
|
57
|
+
name: entry.name,
|
|
58
|
+
url: entry.url,
|
|
59
|
+
...(entry.headers === undefined ? {} : { headers: entry.headers }),
|
|
60
|
+
...(entry.timeout_ms === undefined ? {} : { timeoutMs: entry.timeout_ms }),
|
|
61
|
+
};
|
|
62
|
+
const connection = new McpConnection(server);
|
|
63
|
+
let tools: Awaited<ReturnType<McpConnection['refreshTools']>> = [];
|
|
64
|
+
// The last known tool list rides in the store, so a temporarily unreachable server still
|
|
65
|
+
// presents its operations instead of an empty resource.
|
|
66
|
+
const cached = await storage.transaction(async tx => tx.get<{ tools: typeof tools }>(`mcp/tools/${encodeURIComponent(entry.name)}`));
|
|
67
|
+
try {
|
|
68
|
+
await connection.start();
|
|
69
|
+
tools = connection.tools;
|
|
70
|
+
await storage.transaction(async tx => { await tx.put(`mcp/tools/${encodeURIComponent(entry.name)}`, { tools }); });
|
|
71
|
+
} catch (err) {
|
|
72
|
+
// Registration proceeds with the cache; the connection retries on first call.
|
|
73
|
+
options.log?.('mcp.server.start.failed', { server: entry.name, error: String(err) });
|
|
74
|
+
tools = cached?.tools ?? [];
|
|
75
|
+
}
|
|
76
|
+
connections.set(entry.name, connection);
|
|
77
|
+
serverEntries.push({ server, connection });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const bridge = createMcpBridge(serverEntries);
|
|
81
|
+
const base_url = `http://${config.server.bind}:${config.server.port}/onlyne/v3`;
|
|
82
|
+
const keys = options.keys;
|
|
83
|
+
const authenticate = async (request: Request): Promise<Auth | null> => {
|
|
84
|
+
const actor = request.headers.get('Onlyne-Actor');
|
|
85
|
+
if (actor === null || actor.length === 0) return null;
|
|
86
|
+
if (actor === owner) throw new OnlyneError('forbidden', t('error.claimIdentity'));
|
|
87
|
+
return { actor };
|
|
88
|
+
};
|
|
89
|
+
const plugins: Omit<Plugins, 'storage' | 'resources'> = {
|
|
90
|
+
authenticate,
|
|
91
|
+
maintenanceClock: nodeClock(),
|
|
92
|
+
resolve: async id => {
|
|
93
|
+
// A peer's address is whatever discovery last verified; the actor header is what names
|
|
94
|
+
// this node on the wire, and the signing transport adds the signature over it.
|
|
95
|
+
const target = id === owner ? { node_id: id, base_url } : await node.discovery.resolve(id);
|
|
96
|
+
return { ...target, headers: { 'Onlyne-Actor': owner } };
|
|
97
|
+
},
|
|
98
|
+
decide: () => { throw new OnlyneError('decision_unavailable', t('error.decide')); },
|
|
99
|
+
runtime: {
|
|
100
|
+
// The terminal log is the gateway's only console, so every exchange stage lands in it
|
|
101
|
+
// with the fingerprint the operator has to repeat on `confirm`.
|
|
102
|
+
notify: event => {
|
|
103
|
+
if (event.event !== 'trust') return;
|
|
104
|
+
options.log?.('onlyne.trust', {
|
|
105
|
+
node: event.entry.node_id, phase: event.phase, fingerprint: event.entry.fingerprint, cause: event.cause,
|
|
106
|
+
});
|
|
107
|
+
},
|
|
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',
|
|
113
|
+
inject: async () => { throw new OnlyneError('unsupported_delivery', t('error.noContext')); },
|
|
114
|
+
},
|
|
115
|
+
domainCandidates: async domain_id => node.discovery.candidates(domain_id),
|
|
116
|
+
...(keys === undefined ? {} : { keys }),
|
|
117
|
+
};
|
|
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
|
+
};
|
|
134
|
+
node = new OnlyneNode({
|
|
135
|
+
node_id: owner,
|
|
136
|
+
base_url,
|
|
137
|
+
// RFC 0017 §1: a keyed node advertises the scheme it authenticates with. The keyless
|
|
138
|
+
// binding stays for programmatic callers on loopback.
|
|
139
|
+
authentication: keys === undefined ? ['none'] : ['signature'],
|
|
140
|
+
plugins: { ...plugins, storage, resources },
|
|
141
|
+
now: () => Date.now(),
|
|
142
|
+
id: () => crypto.randomUUID(),
|
|
143
|
+
});
|
|
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
|
+
|
|
172
|
+
const gatewayAuth: Auth = { actor: owner };
|
|
173
|
+
for (const [index, entry] of config.servers.entries()) {
|
|
174
|
+
const controllers = Array.from(new Set([owner, ...(entry.controllers ?? [])]));
|
|
175
|
+
const definition = resourceOf(owner, serverEntries[index]!.server, serverEntries[index]!.connection.tools, controllers);
|
|
176
|
+
// RFC 0006 §3: one handler operates a resource, so the first configured controller holds
|
|
177
|
+
// the control right; every other controller keeps read access through the log reader list.
|
|
178
|
+
const resource: Resource = {
|
|
179
|
+
resource_ref: definition.resource_ref,
|
|
180
|
+
kind: 'mcp',
|
|
181
|
+
operations: definition.operations,
|
|
182
|
+
binding: { handler: entry.controllers?.[0] ?? owner, cursor: '0' },
|
|
183
|
+
log_readers: definition.log_readers,
|
|
184
|
+
revision: 'rev-1',
|
|
185
|
+
event_seq: '0',
|
|
186
|
+
retention_ms: 0,
|
|
187
|
+
data: definition.data,
|
|
188
|
+
};
|
|
189
|
+
await registerResource(node, gatewayAuth, resource);
|
|
190
|
+
}
|
|
191
|
+
for (const { resource } of mountService.resources()) {
|
|
192
|
+
await registerResource(node, gatewayAuth, resource);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
node, config, connections, mounts: mountService,
|
|
197
|
+
status: () => mountService.status(),
|
|
198
|
+
async stop(): Promise<void> {
|
|
199
|
+
await mountService.stop();
|
|
200
|
+
node.client.stopMaintenance();
|
|
201
|
+
for (const connection of connections.values()) await connection.close();
|
|
202
|
+
},
|
|
203
|
+
};
|
|
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
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { createLogger, createNodeKey, startNodeHost, type NodeHostOptions } from '@onlyne/node-host';
|
|
3
|
+
import { resolveLocale, setLocale } from '@onlyne/node-host/i18n';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { loadConfig } from './config.js';
|
|
6
|
+
import { createMcpGateway, type McpGateway } from './index.js';
|
|
7
|
+
import { t } from './gateway-text.js';
|
|
8
|
+
|
|
9
|
+
const path = process.argv[2];
|
|
10
|
+
if (path === undefined) {
|
|
11
|
+
console.error(t('cli.usage'));
|
|
12
|
+
process.exit(2);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const config = await loadConfig(path);
|
|
16
|
+
// The configured language wins over the environment for everything this process prints; the
|
|
17
|
+
// logger and the node host below are built after this point so their text follows it too.
|
|
18
|
+
setLocale(resolveLocale(config.language));
|
|
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. `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';
|
|
23
|
+
const { log, ring } = createLogger(
|
|
24
|
+
forceJson || !(process.stdin.isTTY && process.stdout.isTTY), 200, join(dirname(config.storage_path), 'gateway.log'),
|
|
25
|
+
);
|
|
26
|
+
// RFC 0017 §2: the CLI is always keyed, and a missing key is generated and saved under
|
|
27
|
+
// ~/.config/onlyne/keys/<node-id>/ instead of being a hand-written prerequisite.
|
|
28
|
+
const keys = await createNodeKey(config.node.id);
|
|
29
|
+
// The advertised endpoint must carry the port the process actually bound, which is only
|
|
30
|
+
// known after `Bun.serve` when the configuration asks for port 0.
|
|
31
|
+
let handler: (request: Request) => Promise<Response> = async () =>
|
|
32
|
+
new Response(t('startup.pending'), { status: 503 });
|
|
33
|
+
const server = Bun.serve({ hostname: config.server.bind, port: config.server.port, fetch: request => handler(request) });
|
|
34
|
+
config.server.port = server.port ?? config.server.port;
|
|
35
|
+
const endpoint = `http://${config.server.bind}:${config.server.port}/onlyne/v3`;
|
|
36
|
+
|
|
37
|
+
const gateway: McpGateway = await createMcpGateway(config, { keys, log });
|
|
38
|
+
handler = request => gateway.node.fetch(request);
|
|
39
|
+
gateway.node.client.runMaintenance();
|
|
40
|
+
// Discovery starts only once the real handler is served: a candidate probe aimed at this
|
|
41
|
+
// node must reach the gateway instead of the starting stub.
|
|
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);
|
|
50
|
+
|
|
51
|
+
const shutdown = async (): Promise<void> => {
|
|
52
|
+
await host.close();
|
|
53
|
+
await gateway.stop();
|
|
54
|
+
server.stop(true);
|
|
55
|
+
log('mcp.gateway.stopped', { node: config.node.id });
|
|
56
|
+
process.exit(0);
|
|
57
|
+
};
|
|
58
|
+
process.on('SIGINT', () => { void shutdown(); });
|
|
59
|
+
process.on('SIGTERM', () => { void shutdown(); });
|
|
60
|
+
|
|
61
|
+
log('mcp.gateway.listening', {
|
|
62
|
+
node: config.node.id,
|
|
63
|
+
endpoint,
|
|
64
|
+
servers: config.servers.map(entry => entry.name),
|
|
65
|
+
});
|