@onlyne/mcp-gateway 0.0.0-stage → 3.0.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 +45 -2
- package/package.json +36 -4
- package/src/config.ts +98 -0
- package/src/gateway-text.ts +33 -0
- package/src/index.ts +151 -0
- package/src/main.ts +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dbydd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,46 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @onlyne/mcp-gateway
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An Onlyne node that hosts MCP servers as resources. Each configured server registers as one
|
|
4
|
+
resource whose operations are that server's tools, so an agent reaches MCP through the same
|
|
5
|
+
resource path it uses for every other capability.
|
|
6
|
+
|
|
7
|
+
MCP resources carry no platform event stream, so this node has no push ring, no render ring, no
|
|
8
|
+
event log, and no handler binding.
|
|
9
|
+
|
|
10
|
+
## Run
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
bunx onlyne-mcp-gateway ./gateway.json
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"node": { "id": "urn:onlyne:gateway:mcp" },
|
|
19
|
+
"server": { "bind": "127.0.0.1", "port": 3992 },
|
|
20
|
+
"storage": { "path": "./var/mcp/store.json" },
|
|
21
|
+
"servers": [
|
|
22
|
+
{
|
|
23
|
+
"name": "hindsight",
|
|
24
|
+
"url": "http://localhost:8888/mcp/example-bank/",
|
|
25
|
+
"timeout_ms": 60000,
|
|
26
|
+
"controllers": ["urn:onlyne:agent:main"]
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`headers` adds per-request headers such as an API key and is never logged. `controllers` lists
|
|
33
|
+
the nodes allowed to call a server's tools; an empty list leaves the node owner alone.
|
|
34
|
+
`language` accepts `en` or `zh-CN` and otherwise follows `ONLYNE_LANG`, `LC_ALL`, or `LANG`.
|
|
35
|
+
|
|
36
|
+
## Library use
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { createMcpGateway } from '@onlyne/mcp-gateway';
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The result carries the node, the connections, and a `fetch` handler.
|
|
43
|
+
|
|
44
|
+
## License
|
|
45
|
+
|
|
46
|
+
MIT.
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlyne/mcp-gateway",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "Onlyne MCP gateway: one node that hosts MCP servers as resources and exposes their tools as operations",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/dbydd/onlyne.git",
|
|
12
|
+
"directory": "packages/mcp-gateway"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/dbydd/onlyne#readme",
|
|
15
|
+
"bugs": "https://github.com/dbydd/onlyne/issues",
|
|
16
|
+
"keywords": ["onlyne", "mcp", "gateway", "cli"],
|
|
17
|
+
"publishConfig": { "access": "public" },
|
|
18
|
+
"engines": { "bun": ">=1.4.3" },
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./src/index.ts"
|
|
21
|
+
},
|
|
22
|
+
"bin": { "onlyne-mcp-gateway": "./src/main.ts" },
|
|
23
|
+
"files": ["src", "README.md", "LICENSE"],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"check": "tsc --noEmit",
|
|
26
|
+
"test": "bun tests/verify.ts"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@onlyne/im-gateway": "^3.0.0",
|
|
30
|
+
"@onlyne/lib": "^3.0.0",
|
|
31
|
+
"@onlyne/mcp-core": "^3.0.0",
|
|
32
|
+
"@onlyne/node-host": "^3.0.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/bun": "^1.3.0",
|
|
36
|
+
"typescript": "^5.9.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { OnlyneError, type Json, type NodeId } from '@onlyne/lib';
|
|
2
|
+
import type { Locale } from '@onlyne/node-host/i18n';
|
|
3
|
+
import { t } from './gateway-text.js';
|
|
4
|
+
|
|
5
|
+
/** One configured MCP server: its resource name, URL and per-call settings. */
|
|
6
|
+
export interface ServerConfig {
|
|
7
|
+
name: string;
|
|
8
|
+
url: string;
|
|
9
|
+
/** Extra request headers (an API key, a bank header); never logged. */
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
timeout_ms?: number;
|
|
12
|
+
/** Who may call this server's tools. Empty means the node owner only. */
|
|
13
|
+
controllers?: NodeId[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface GatewayConfig {
|
|
17
|
+
node: { id: NodeId };
|
|
18
|
+
server: { bind: string; port: number };
|
|
19
|
+
storage_path: string;
|
|
20
|
+
servers: ServerConfig[];
|
|
21
|
+
/** The language of the gateway's own console text; unset follows `ONLYNE_LANG`. */
|
|
22
|
+
language?: Locale;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const requireObject = (value: Json | undefined, field: string): Record<string, Json> => {
|
|
26
|
+
if (value === undefined || value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
27
|
+
throw new OnlyneError('invalid_request', t('config.object', { field }));
|
|
28
|
+
}
|
|
29
|
+
return value as Record<string, Json>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const requireString = (container: Record<string, unknown>, field: string): string => {
|
|
33
|
+
const value = container[field];
|
|
34
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
35
|
+
throw new OnlyneError('invalid_request', t('config.string', { field }));
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** `language` is exactly `en` or `zh-CN`: a near miss would silently pick a different console language. */
|
|
41
|
+
const parseLanguage = (value: Json | undefined): Locale | undefined => {
|
|
42
|
+
if (value === undefined) return undefined;
|
|
43
|
+
if (value === 'en' || value === 'zh-CN') return value;
|
|
44
|
+
throw new OnlyneError('invalid_request', t('config.language', { value: JSON.stringify(value) }));
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Loads the gateway configuration from one JSON file; environment retuning comes later. */
|
|
48
|
+
export async function loadConfig(path: string): Promise<GatewayConfig> {
|
|
49
|
+
let file: Json | undefined;
|
|
50
|
+
try {
|
|
51
|
+
file = JSON.parse(await Bun.file(path).text()) as Json;
|
|
52
|
+
} catch {
|
|
53
|
+
throw new OnlyneError('invalid_request', t('config.unreadable', { path }));
|
|
54
|
+
}
|
|
55
|
+
const raw = requireObject(file, 'the configuration root');
|
|
56
|
+
const node = requireObject(raw.node, 'node');
|
|
57
|
+
const server = requireObject(raw.server, 'server');
|
|
58
|
+
const storage = requireObject(raw.storage, 'storage');
|
|
59
|
+
const language = parseLanguage(raw.language);
|
|
60
|
+
const owner = requireString(node, 'id') as NodeId;
|
|
61
|
+
const rawServers = raw.servers === undefined ? [] : raw.servers;
|
|
62
|
+
if (!Array.isArray(rawServers)) {
|
|
63
|
+
throw new OnlyneError('invalid_request', t('config.servers'));
|
|
64
|
+
}
|
|
65
|
+
const servers = rawServers.map((entry, index): ServerConfig => {
|
|
66
|
+
const item = requireObject(entry, `servers[${index}]`);
|
|
67
|
+
const rawHeaders = item.headers === undefined ? undefined : requireObject(item.headers, `servers[${index}].headers`);
|
|
68
|
+
const headers = rawHeaders === undefined
|
|
69
|
+
? undefined
|
|
70
|
+
: Object.fromEntries(Object.entries(rawHeaders).filter((pair): pair is [string, string] => typeof pair[1] === 'string'));
|
|
71
|
+
const timeout = item.timeout_ms;
|
|
72
|
+
const controllers = item.controllers;
|
|
73
|
+
return {
|
|
74
|
+
name: requireString(item, 'name'),
|
|
75
|
+
url: requireString(item, 'url'),
|
|
76
|
+
...(headers === undefined || Object.keys(headers).length === 0 ? {} : { headers }),
|
|
77
|
+
...(typeof timeout === 'number' ? { timeout_ms: timeout } : {}),
|
|
78
|
+
...(Array.isArray(controllers)
|
|
79
|
+
? { controllers: controllers.filter((value): value is NodeId => typeof value === 'string' && value.length > 0) }
|
|
80
|
+
: {}),
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
const names = new Set<string>();
|
|
84
|
+
for (const entry of servers) {
|
|
85
|
+
if (names.has(entry.name)) throw new OnlyneError('invalid_request', t('config.duplicateName', { name: entry.name }));
|
|
86
|
+
names.add(entry.name);
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
node: { id: owner },
|
|
90
|
+
server: {
|
|
91
|
+
bind: typeof server.bind === 'string' && server.bind.length > 0 ? server.bind : '127.0.0.1',
|
|
92
|
+
port: typeof server.port === 'number' ? server.port : 0,
|
|
93
|
+
},
|
|
94
|
+
storage_path: requireString(storage, 'path'),
|
|
95
|
+
servers,
|
|
96
|
+
...(language === undefined ? {} : { language }),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
'error.claimIdentity': 'HTTP callers cannot claim the gateway identity.',
|
|
13
|
+
'error.decide': 'The gateway forwards tool calls; it decides nothing.',
|
|
14
|
+
'error.noContext': 'The gateway consumes no model context.',
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
const zh: Record<keyof typeof en, string> = {
|
|
18
|
+
'cli.usage': '用法:onlyne-mcp-gateway <config.json>',
|
|
19
|
+
'startup.pending': '网关正在启动。',
|
|
20
|
+
'config.unreadable': '配置文件无法读取:{path}',
|
|
21
|
+
'config.object': '配置项 {field} 需要是一个对象。',
|
|
22
|
+
'config.string': '配置项 {field} 需要是非空字符串。',
|
|
23
|
+
'config.servers': '配置项 servers 需要是一个数组。',
|
|
24
|
+
'config.duplicateName': '有两个服务器共用了名称 {name}。',
|
|
25
|
+
'config.language': '配置项 language 只能是 en 或 zh-CN,而不是 {value}。',
|
|
26
|
+
'error.claimIdentity': 'HTTP 调用方不能冒用网关身份。',
|
|
27
|
+
'error.decide': '网关只转发工具调用,不做任何决策。',
|
|
28
|
+
'error.noContext': '网关不消费模型上下文。',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type GatewayTextKey = keyof typeof en;
|
|
32
|
+
|
|
33
|
+
export const t = createTranslator({ en, 'zh-CN': zh });
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OnlyneNode, OnlyneError, type Auth, type KeyHook, type MaintenanceClock, type NodeId, type Plugins, type Resource,
|
|
3
|
+
} from '@onlyne/lib';
|
|
4
|
+
import { createMcpBridge, resourceOf, McpConnection, type McpServerOptions } from '@onlyne/mcp-core';
|
|
5
|
+
import type { GatewayConfig, ServerConfig } from './config.js';
|
|
6
|
+
import { FileStorage } from '@onlyne/im-gateway/storage';
|
|
7
|
+
import { t } from './gateway-text.js';
|
|
8
|
+
|
|
9
|
+
export type { GatewayConfig, ServerConfig } from './config.js';
|
|
10
|
+
|
|
11
|
+
export interface McpGateway {
|
|
12
|
+
readonly node: OnlyneNode;
|
|
13
|
+
readonly config: GatewayConfig;
|
|
14
|
+
/** The MCP connections, one per configured server, keyed by resource id. */
|
|
15
|
+
readonly connections: Map<string, McpConnection>;
|
|
16
|
+
stop(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The Node-timer MaintenanceClock: one-shot timers, the lib's loop re-arms them. */
|
|
20
|
+
export function nodeClock(): MaintenanceClock {
|
|
21
|
+
return {
|
|
22
|
+
setTimer: (ms, run) => setTimeout(run, ms),
|
|
23
|
+
clearTimer: handle => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
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).
|
|
32
|
+
*/
|
|
33
|
+
export interface McpGatewayOptions {
|
|
34
|
+
log?: (message: string, fields?: Record<string, unknown>) => void;
|
|
35
|
+
/**
|
|
36
|
+
* RFC 0017 identity key. The CLI always supplies one; a programmatic caller may omit it and
|
|
37
|
+
* keep the keyless loopback binding the tests use.
|
|
38
|
+
*/
|
|
39
|
+
keys?: KeyHook;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function createMcpGateway(config: GatewayConfig, options: McpGatewayOptions = {}): Promise<McpGateway> {
|
|
43
|
+
const owner: NodeId = config.node.id;
|
|
44
|
+
const storage = new FileStorage(config.storage_path);
|
|
45
|
+
const connections = new Map<string, McpConnection>();
|
|
46
|
+
const serverEntries: Array<{ server: McpServerOptions; connection: McpConnection }> = [];
|
|
47
|
+
|
|
48
|
+
for (const entry of config.servers) {
|
|
49
|
+
const server: McpServerOptions = {
|
|
50
|
+
name: entry.name,
|
|
51
|
+
url: entry.url,
|
|
52
|
+
...(entry.headers === undefined ? {} : { headers: entry.headers }),
|
|
53
|
+
...(entry.timeout_ms === undefined ? {} : { timeoutMs: entry.timeout_ms }),
|
|
54
|
+
};
|
|
55
|
+
const connection = new McpConnection(server);
|
|
56
|
+
let tools: Awaited<ReturnType<McpConnection['refreshTools']>> = [];
|
|
57
|
+
// The last known tool list rides in the store, so a temporarily unreachable server still
|
|
58
|
+
// presents its operations instead of an empty resource.
|
|
59
|
+
const cached = await storage.transaction(async tx => tx.get<{ tools: typeof tools }>(`mcp/tools/${encodeURIComponent(entry.name)}`));
|
|
60
|
+
try {
|
|
61
|
+
await connection.start();
|
|
62
|
+
tools = connection.tools;
|
|
63
|
+
await storage.transaction(async tx => { await tx.put(`mcp/tools/${encodeURIComponent(entry.name)}`, { tools }); });
|
|
64
|
+
} catch (err) {
|
|
65
|
+
// Registration proceeds with the cache; the connection retries on first call.
|
|
66
|
+
options.log?.('mcp.server.start.failed', { server: entry.name, error: String(err) });
|
|
67
|
+
tools = cached?.tools ?? [];
|
|
68
|
+
}
|
|
69
|
+
connections.set(entry.name, connection);
|
|
70
|
+
serverEntries.push({ server, connection });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const bridge = createMcpBridge(serverEntries);
|
|
74
|
+
const base_url = `http://${config.server.bind}:${config.server.port}/onlyne/v3`;
|
|
75
|
+
const keys = options.keys;
|
|
76
|
+
const authenticate = async (request: Request): Promise<Auth | null> => {
|
|
77
|
+
const actor = request.headers.get('Onlyne-Actor');
|
|
78
|
+
if (actor === null || actor.length === 0) return null;
|
|
79
|
+
if (actor === owner) throw new OnlyneError('forbidden', t('error.claimIdentity'));
|
|
80
|
+
return { actor };
|
|
81
|
+
};
|
|
82
|
+
const plugins: Omit<Plugins, 'storage' | 'resources'> = {
|
|
83
|
+
authenticate,
|
|
84
|
+
maintenanceClock: nodeClock(),
|
|
85
|
+
resolve: async id => {
|
|
86
|
+
// A peer's address is whatever discovery last verified; the actor header is what names
|
|
87
|
+
// this node on the wire, and the signing transport adds the signature over it.
|
|
88
|
+
const target = id === owner ? { node_id: id, base_url } : await node.discovery.resolve(id);
|
|
89
|
+
return { ...target, headers: { 'Onlyne-Actor': owner } };
|
|
90
|
+
},
|
|
91
|
+
decide: () => { throw new OnlyneError('decision_unavailable', t('error.decide')); },
|
|
92
|
+
runtime: {
|
|
93
|
+
// The terminal log is the gateway's only console, so every exchange stage lands in it
|
|
94
|
+
// with the fingerprint the operator has to repeat on `confirm`.
|
|
95
|
+
notify: event => {
|
|
96
|
+
if (event.event !== 'trust') return;
|
|
97
|
+
options.log?.('onlyne.trust', {
|
|
98
|
+
node: event.entry.node_id, phase: event.phase, fingerprint: event.entry.fingerprint, cause: event.cause,
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
supports: async () => false,
|
|
102
|
+
inject: async () => { throw new OnlyneError('unsupported_delivery', t('error.noContext')); },
|
|
103
|
+
},
|
|
104
|
+
domainCandidates: async domain_id => node.discovery.candidates(domain_id),
|
|
105
|
+
...(keys === undefined ? {} : { keys }),
|
|
106
|
+
};
|
|
107
|
+
let node: OnlyneNode;
|
|
108
|
+
node = new OnlyneNode({
|
|
109
|
+
node_id: owner,
|
|
110
|
+
base_url,
|
|
111
|
+
// RFC 0017 §1: a keyed node advertises the scheme it authenticates with. The keyless
|
|
112
|
+
// binding stays for programmatic callers on loopback.
|
|
113
|
+
authentication: keys === undefined ? ['none'] : ['signature'],
|
|
114
|
+
plugins: { ...plugins, storage, resources: bridge },
|
|
115
|
+
now: () => Date.now(),
|
|
116
|
+
id: () => crypto.randomUUID(),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const gatewayAuth: Auth = { actor: owner };
|
|
120
|
+
for (const [index, entry] of config.servers.entries()) {
|
|
121
|
+
const controllers = Array.from(new Set([owner, ...(entry.controllers ?? [])]));
|
|
122
|
+
const definition = resourceOf(owner, serverEntries[index]!.server, serverEntries[index]!.connection.tools, controllers);
|
|
123
|
+
// RFC 0006 §3: one handler operates a resource, so the first configured controller holds
|
|
124
|
+
// the control right; every other controller keeps read access through the log reader list.
|
|
125
|
+
const resource: Resource = {
|
|
126
|
+
resource_ref: definition.resource_ref,
|
|
127
|
+
kind: 'mcp',
|
|
128
|
+
operations: definition.operations,
|
|
129
|
+
binding: { handler: entry.controllers?.[0] ?? owner, cursor: '0' },
|
|
130
|
+
log_readers: definition.log_readers,
|
|
131
|
+
revision: 'rev-1',
|
|
132
|
+
event_seq: '0',
|
|
133
|
+
retention_ms: 0,
|
|
134
|
+
data: definition.data,
|
|
135
|
+
};
|
|
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
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
node, config, connections,
|
|
146
|
+
async stop(): Promise<void> {
|
|
147
|
+
node.client.stopMaintenance();
|
|
148
|
+
for (const connection of connections.values()) await connection.close();
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { createLogger, createNodeKey, startNodeHost } 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.
|
|
21
|
+
const { log, ring } = createLogger(
|
|
22
|
+
!(process.stdin.isTTY && process.stdout.isTTY), 200, join(dirname(config.storage_path), 'gateway.log'),
|
|
23
|
+
);
|
|
24
|
+
// RFC 0017 §2: the CLI is always keyed, and a missing key is generated and saved under
|
|
25
|
+
// ~/.config/onlyne/keys/<node-id>/ instead of being a hand-written prerequisite.
|
|
26
|
+
const keys = await createNodeKey(config.node.id);
|
|
27
|
+
// The advertised endpoint must carry the port the process actually bound, which is only
|
|
28
|
+
// known after `Bun.serve` when the configuration asks for port 0.
|
|
29
|
+
let handler: (request: Request) => Promise<Response> = async () =>
|
|
30
|
+
new Response(t('startup.pending'), { status: 503 });
|
|
31
|
+
const server = Bun.serve({ hostname: config.server.bind, port: config.server.port, fetch: request => handler(request) });
|
|
32
|
+
config.server.port = server.port ?? config.server.port;
|
|
33
|
+
const endpoint = `http://${config.server.bind}:${config.server.port}/onlyne/v3`;
|
|
34
|
+
|
|
35
|
+
const gateway: McpGateway = await createMcpGateway(config, { keys, log });
|
|
36
|
+
handler = request => gateway.node.fetch(request);
|
|
37
|
+
gateway.node.client.runMaintenance();
|
|
38
|
+
// Discovery starts only once the real handler is served: a candidate probe aimed at this
|
|
39
|
+
// 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 });
|
|
41
|
+
|
|
42
|
+
const shutdown = async (): Promise<void> => {
|
|
43
|
+
await host.close();
|
|
44
|
+
await gateway.stop();
|
|
45
|
+
server.stop(true);
|
|
46
|
+
log('mcp.gateway.stopped', { node: config.node.id });
|
|
47
|
+
process.exit(0);
|
|
48
|
+
};
|
|
49
|
+
process.on('SIGINT', () => { void shutdown(); });
|
|
50
|
+
process.on('SIGTERM', () => { void shutdown(); });
|
|
51
|
+
|
|
52
|
+
log('mcp.gateway.listening', {
|
|
53
|
+
node: config.node.id,
|
|
54
|
+
endpoint,
|
|
55
|
+
servers: config.servers.map(entry => entry.name),
|
|
56
|
+
});
|