@mohou/host 1.0.20
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 +10 -0
- package/package.json +65 -0
- package/src/apps/monogram.ts +15 -0
- package/src/apps/registry.ts +238 -0
- package/src/apps/startup-seed.ts +95 -0
- package/src/bash/client.ts +21 -0
- package/src/bash/codes.ts +21 -0
- package/src/compile/allowlist.ts +89 -0
- package/src/compile/asset-path.ts +48 -0
- package/src/compile/autogen.ts +71 -0
- package/src/compile/build-vendor-cli.ts +8 -0
- package/src/compile/build-vendor.ts +160 -0
- package/src/compile/bundle-ui.ts +137 -0
- package/src/compile/codes.ts +22 -0
- package/src/compile/failure.ts +38 -0
- package/src/compile/imports.ts +43 -0
- package/src/compile/leave.ts +47 -0
- package/src/compile/load-backend.ts +95 -0
- package/src/compile/review.ts +342 -0
- package/src/compile/runner.ts +205 -0
- package/src/compile/sheet.ts +174 -0
- package/src/compile/wrapper.ts +297 -0
- package/src/credentials/codes.ts +26 -0
- package/src/credentials/create.ts +69 -0
- package/src/credentials/file.ts +113 -0
- package/src/credentials/provider.ts +66 -0
- package/src/events/app-events.ts +214 -0
- package/src/events/error-ring.ts +109 -0
- package/src/events/host-events.ts +39 -0
- package/src/events/view-bridge.ts +402 -0
- package/src/events/view-eval.ts +213 -0
- package/src/files/codes.ts +28 -0
- package/src/files/skip.ts +23 -0
- package/src/files/tools.ts +215 -0
- package/src/history/codes.ts +22 -0
- package/src/history/store.ts +287 -0
- package/src/host/activity.ts +110 -0
- package/src/host/author-mcp.ts +248 -0
- package/src/host/author-skill.ts +196 -0
- package/src/host/codes.ts +21 -0
- package/src/host/config.ts +239 -0
- package/src/host/heat.ts +35 -0
- package/src/host/layout.ts +81 -0
- package/src/host/log.ts +142 -0
- package/src/host/mcp-editor.ts +205 -0
- package/src/host/mcp-import.ts +141 -0
- package/src/host/mcp.ts +109 -0
- package/src/host/port.ts +83 -0
- package/src/host/session.ts +358 -0
- package/src/host/workbench.ts +38 -0
- package/src/http/app.ts +46 -0
- package/src/http/asset.ts +49 -0
- package/src/http/author.ts +157 -0
- package/src/http/bind.ts +217 -0
- package/src/http/client.ts +175 -0
- package/src/http/codes.ts +29 -0
- package/src/http/env.ts +3 -0
- package/src/http/guard.ts +20 -0
- package/src/http/iframe.ts +212 -0
- package/src/http/layout.ts +63 -0
- package/src/http/owner.ts +380 -0
- package/src/http/ports.ts +114 -0
- package/src/http/reply.ts +53 -0
- package/src/http/route-codes.ts +19 -0
- package/src/http/updates.ts +258 -0
- package/src/index.ts +201 -0
- package/src/install/codes.ts +21 -0
- package/src/install/install.ts +230 -0
- package/src/install/layout.ts +16 -0
- package/src/kernel/bind-brain.ts +117 -0
- package/src/kernel/call.ts +134 -0
- package/src/kernel/codes.ts +21 -0
- package/src/kernel/index.ts +13 -0
- package/src/kernel/model-policy.ts +83 -0
- package/src/kernel/model-stream.ts +86 -0
- package/src/kernel/present.ts +4 -0
- package/src/kernel/working-directory.ts +35 -0
- package/src/metrics/codes.ts +22 -0
- package/src/metrics/read.ts +48 -0
- package/src/owner/read.ts +85 -0
- package/src/pwsh/client.ts +47 -0
- package/src/pwsh/codes.ts +21 -0
- package/src/shell/command.ts +172 -0
- package/src/storage/codes.ts +33 -0
- package/src/storage/layout.ts +44 -0
- package/src/storage/open.ts +461 -0
- package/src/storage/schema.ts +176 -0
- package/src/storage/sql.ts +40 -0
- package/src/theme/codes.ts +20 -0
- package/src/theme/layout.ts +42 -0
- package/src/theme/paint.ts +108 -0
- package/src/theme/parse.ts +58 -0
- package/src/theme/pin.ts +197 -0
- package/src/theme/tokens.ts +30 -0
- package/src/tools/author.ts +970 -0
- package/src/tools/codes.ts +21 -0
- package/src/tools/diagnostics.ts +53 -0
- package/src/tools/http.ts +48 -0
- package/src/tools/mcp-list.ts +90 -0
- package/src/tools/schemas.ts +185 -0
- package/themes/theme-default.css +4 -0
- package/themes/theme-forest.css +4 -0
- package/themes/theme-hokage.css +4 -0
- package/themes/theme-matcha.css +4 -0
- package/themes/theme-slate.css +4 -0
- package/themes/theme-tokyo.css +4 -0
- package/themes/theme-yellow.css +4 -0
- package/themes/theme-zoro.css +4 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { isCredential, maskCredential, McpClient, McpError, resolveMcpConfig, resolveServerMap, type McpReferenceSources, type McpServerSpec } from '@mohou/mcp-client'
|
|
5
|
+
|
|
6
|
+
import { hostMcpPath } from './layout.ts'
|
|
7
|
+
import { admitMcpText } from './mcp-import.ts'
|
|
8
|
+
import { mcpConfigEnv, mcpReferenceSources, resolveMcpServers, type ResolvedMcpServers } from './mcp.ts'
|
|
9
|
+
|
|
10
|
+
/** One server the panel may edit. The file shape stays in the MCP client. */
|
|
11
|
+
export interface McpEditorServer {
|
|
12
|
+
readonly id: string
|
|
13
|
+
readonly description?: string
|
|
14
|
+
readonly enabled?: boolean
|
|
15
|
+
readonly command?: string
|
|
16
|
+
readonly args?: readonly string[]
|
|
17
|
+
readonly env?: Readonly<Record<string, string>>
|
|
18
|
+
readonly url?: string
|
|
19
|
+
readonly transport?: 'sse' | 'streamable-http'
|
|
20
|
+
readonly headers?: Readonly<Record<string, string>>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface McpCheck {
|
|
24
|
+
readonly ok: boolean
|
|
25
|
+
readonly tools: readonly {
|
|
26
|
+
readonly name: string
|
|
27
|
+
readonly description?: string
|
|
28
|
+
/** The protocol requires this one. */
|
|
29
|
+
readonly inputSchema?: Record<string, unknown>
|
|
30
|
+
/** Only a server that declares one sends it. */
|
|
31
|
+
readonly outputSchema?: Record<string, unknown>
|
|
32
|
+
}[]
|
|
33
|
+
readonly code?: string
|
|
34
|
+
readonly message?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Servers currently in the file. A missing default file is an empty list.
|
|
39
|
+
* @param runtimeRoot - directory that holds the default file
|
|
40
|
+
* @param env - process environment; only `MINI_APP_MCP_CONFIG` is read
|
|
41
|
+
* @param sanitize - mask the values that {@link isCredential} recognizes: a name segment that says
|
|
42
|
+
* credential, or a shape that gives it away. A caller whose output reaches a model or a log sets
|
|
43
|
+
* it; the panel reads the true values because it has to edit them.
|
|
44
|
+
*/
|
|
45
|
+
export async function readMcpEditor(
|
|
46
|
+
runtimeRoot: string,
|
|
47
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
48
|
+
sanitize = false,
|
|
49
|
+
): Promise<McpEditorServer[]> {
|
|
50
|
+
const text = await readFile(mcpFile(runtimeRoot, env), 'utf8').catch(() => undefined)
|
|
51
|
+
if (text === undefined || text.trim().length === 0) return []
|
|
52
|
+
try {
|
|
53
|
+
const servers = admitMcpText(text)
|
|
54
|
+
return sanitize ? maskEditorServers(servers) : servers
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error instanceof McpError && error.message === 'mcp import has no servers') return []
|
|
57
|
+
throw error
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Replace the file with these servers and return the servers they resolved to, so a caller that
|
|
63
|
+
* holds a live client can hand it the same set it just wrote. The file keeps each reference itself.
|
|
64
|
+
* A server whose reference names nothing is left out of the return and listed in `failures`, the same
|
|
65
|
+
* way boot leaves it out. Invalid rows throw and the file stays. An explicit `MINI_APP_MCP_CONFIG`
|
|
66
|
+
* writes that path.
|
|
67
|
+
* @param sources - the lookups references resolve through
|
|
68
|
+
*/
|
|
69
|
+
export async function writeMcpEditor(
|
|
70
|
+
runtimeRoot: string,
|
|
71
|
+
servers: readonly McpEditorServer[],
|
|
72
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
73
|
+
sources: McpReferenceSources = mcpReferenceSources(env),
|
|
74
|
+
): Promise<ResolvedMcpServers> {
|
|
75
|
+
const resolved = editorToConfig(servers)
|
|
76
|
+
const body: Record<string, unknown> = {}
|
|
77
|
+
for (const server of servers) {
|
|
78
|
+
const spec = resolved[server.id]
|
|
79
|
+
if (spec === undefined) continue
|
|
80
|
+
const description = server.description?.trim()
|
|
81
|
+
body[server.id] = {
|
|
82
|
+
...spec,
|
|
83
|
+
...description === undefined || description.length === 0 ? {} : { description },
|
|
84
|
+
...server.enabled === false ? { disabled: true } : {},
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const file = mcpFile(runtimeRoot, env)
|
|
88
|
+
await mkdir(path.dirname(file), { recursive: true })
|
|
89
|
+
await writeFile(file, `${JSON.stringify(body, null, 2)}\n`, 'utf8')
|
|
90
|
+
return resolveMcpServers(resolved, sources)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Open one server and list its tools. Does not write the file.
|
|
95
|
+
* References resolve the same way boot resolves them, so a check that passes is a server that starts.
|
|
96
|
+
* The process is closed before this returns.
|
|
97
|
+
* @param server - the draft, references included
|
|
98
|
+
* @param env - process environment
|
|
99
|
+
* @param sources - the lookups references resolve through
|
|
100
|
+
*/
|
|
101
|
+
export async function checkMcpEditor(
|
|
102
|
+
server: McpEditorServer,
|
|
103
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
104
|
+
sources: McpReferenceSources = mcpReferenceSources(env),
|
|
105
|
+
): Promise<McpCheck> {
|
|
106
|
+
const resolved = editorToConfig([server])
|
|
107
|
+
let ready: Record<string, McpServerSpec>
|
|
108
|
+
try {
|
|
109
|
+
ready = await resolveServerMap(resolved, sources)
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
tools: [],
|
|
114
|
+
code: error instanceof McpError ? error.code : 'mcp-start-failed',
|
|
115
|
+
message: error instanceof Error && error.message.length > 0 ? error.message : 'mcp server did not start',
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const client = new McpClient(ready, env, 0)
|
|
119
|
+
try {
|
|
120
|
+
const tools = await client.listTools(server.id)
|
|
121
|
+
return {
|
|
122
|
+
ok: true,
|
|
123
|
+
tools: tools.map(tool => ({
|
|
124
|
+
name: tool.name,
|
|
125
|
+
...tool.description === undefined ? {} : { description: tool.description },
|
|
126
|
+
inputSchema: tool.inputSchema,
|
|
127
|
+
...tool.outputSchema === undefined ? {} : { outputSchema: tool.outputSchema },
|
|
128
|
+
})),
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
tools: [],
|
|
134
|
+
code: error instanceof McpError ? error.code : 'mcp-start-failed',
|
|
135
|
+
message: error instanceof Error && error.message.length > 0 ? error.message : 'mcp server did not start',
|
|
136
|
+
}
|
|
137
|
+
} finally {
|
|
138
|
+
await client.dispose()
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function editorServers(resolved: Record<string, McpServerSpec>): McpEditorServer[] {
|
|
143
|
+
return Object.entries(resolved).map(([id, spec]) => {
|
|
144
|
+
if ('command' in spec) {
|
|
145
|
+
return {
|
|
146
|
+
id,
|
|
147
|
+
command: spec.command,
|
|
148
|
+
...spec.args === undefined ? {} : { args: spec.args },
|
|
149
|
+
...spec.env === undefined ? {} : { env: spec.env },
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
id,
|
|
154
|
+
url: spec.url,
|
|
155
|
+
...spec.transport === undefined ? {} : { transport: spec.transport },
|
|
156
|
+
...spec.headers === undefined ? {} : { headers: spec.headers },
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function editorToConfig(servers: readonly McpEditorServer[]): Record<string, McpServerSpec> {
|
|
162
|
+
const raw: Record<string, unknown> = {}
|
|
163
|
+
for (const server of servers) {
|
|
164
|
+
if (server.id.trim().length === 0) throw new McpError('config-invalid', 'mcp server id is empty')
|
|
165
|
+
raw[server.id] = {
|
|
166
|
+
...server.command === undefined ? {} : { command: server.command },
|
|
167
|
+
...server.args === undefined ? {} : { args: [...server.args] },
|
|
168
|
+
...server.env === undefined ? {} : { env: { ...server.env } },
|
|
169
|
+
...server.url === undefined ? {} : { url: server.url },
|
|
170
|
+
...server.transport === undefined ? {} : { transport: server.transport },
|
|
171
|
+
...server.headers === undefined ? {} : { headers: { ...server.headers } },
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return resolveMcpConfig(raw)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Mask the credential entries of a map. Keys stay, and an ordinary setting keeps its value. */
|
|
178
|
+
/** The rows a caller that is not the file's owner may see: credential-shaped values masked. */
|
|
179
|
+
export function maskEditorServers(rows: readonly McpEditorServer[]): McpEditorServer[] {
|
|
180
|
+
return rows.map(maskServer)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function maskServer(row: McpEditorServer): McpEditorServer {
|
|
184
|
+
return {
|
|
185
|
+
...row,
|
|
186
|
+
...row.env === undefined ? {} : { env: maskValues(row.env) },
|
|
187
|
+
...row.headers === undefined ? {} : { headers: maskValues(row.headers) },
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function maskValues(values: Readonly<Record<string, string>>): Record<string, string> {
|
|
192
|
+
return Object.fromEntries(Object.entries(values).map(([key, value]) => [key, isCredential(key, value) ? maskCredential(value) : value]))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Read one import file. Shell chooses the path. Host does not name another product's home. */
|
|
196
|
+
export async function readMcpImport(file: string): Promise<McpEditorServer[]> {
|
|
197
|
+
const text = await readFile(file, 'utf8').catch(() => undefined)
|
|
198
|
+
if (text === undefined) throw new McpError('config-invalid', `mcp import is missing: ${file}`)
|
|
199
|
+
return admitMcpText(text)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function mcpFile(runtimeRoot: string, env: NodeJS.ProcessEnv): string {
|
|
203
|
+
const override = env[mcpConfigEnv]
|
|
204
|
+
return typeof override === 'string' && override.length > 0 ? override : hostMcpPath(runtimeRoot)
|
|
205
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { McpError } from '@mohou/mcp-client'
|
|
2
|
+
|
|
3
|
+
import type { McpEditorServer } from './mcp-editor.ts'
|
|
4
|
+
|
|
5
|
+
const commandKeys = ['command', 'cmd'] as const
|
|
6
|
+
const urlKeys = ['url', 'serverUrl', 'baseUrl', 'endpoint'] as const
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Turn pasted or file text into editor servers.
|
|
10
|
+
* A fragment without braces is wrapped. A map, an `mcpServers` object, and one server object are accepted.
|
|
11
|
+
* Other editors' field names are mapped onto command, args, url, and env.
|
|
12
|
+
*/
|
|
13
|
+
export function admitMcpText(raw: string): McpEditorServer[] {
|
|
14
|
+
const text = brace(stripFence(raw.trim()))
|
|
15
|
+
if (text.length === 0) throw new McpError('config-invalid', 'mcp import is empty')
|
|
16
|
+
let parsed: unknown
|
|
17
|
+
try {
|
|
18
|
+
parsed = JSON.parse(text) as unknown
|
|
19
|
+
} catch (error) {
|
|
20
|
+
throw new McpError('config-invalid', 'mcp import is not JSON', { cause: error })
|
|
21
|
+
}
|
|
22
|
+
return admitParsed(parsed)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function admitParsed(value: unknown): McpEditorServer[] {
|
|
26
|
+
if (Array.isArray(value)) return value.flatMap((item, index) => oneServer(item, `server-${index + 1}`))
|
|
27
|
+
if (!isRecord(value)) throw new McpError('config-invalid', 'mcp import must be an object')
|
|
28
|
+
const wrapped = isRecord(value.mcpServers) ? value.mcpServers : value
|
|
29
|
+
if (isServerEntry(wrapped)) return oneServer(wrapped, nameOf(wrapped) ?? 'imported')
|
|
30
|
+
const servers: McpEditorServer[] = []
|
|
31
|
+
for (const [id, entry] of Object.entries(wrapped)) {
|
|
32
|
+
if (id === 'settings') continue
|
|
33
|
+
servers.push(...oneServer(entry, id))
|
|
34
|
+
}
|
|
35
|
+
if (servers.length === 0) throw new McpError('config-invalid', 'mcp import has no servers')
|
|
36
|
+
return servers
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function oneServer(value: unknown, fallbackId: string): McpEditorServer[] {
|
|
40
|
+
if (!isRecord(value)) return []
|
|
41
|
+
const id = (nameOf(value) ?? fallbackId).trim()
|
|
42
|
+
if (id.length === 0 || id === 'settings') return []
|
|
43
|
+
const command = stringField(value, commandKeys)
|
|
44
|
+
const url = stringField(value, urlKeys)
|
|
45
|
+
const kind = transportOf(value.transport ?? value.type)
|
|
46
|
+
const args = argsOf(value.args ?? value.arguments ?? value.arg)
|
|
47
|
+
const env = recordOf(value.env ?? value.environment ?? value.envVars)
|
|
48
|
+
const headers = recordOf(value.headers)
|
|
49
|
+
const description = descriptionOf(value)
|
|
50
|
+
const note = description === undefined ? {} : { description }
|
|
51
|
+
const remote = kind === 'sse' || kind === 'http' || (url !== undefined && command === undefined)
|
|
52
|
+
if (remote && url !== undefined) {
|
|
53
|
+
return [flagged({
|
|
54
|
+
id,
|
|
55
|
+
url,
|
|
56
|
+
transport: kind === 'sse' ? 'sse' : 'streamable-http',
|
|
57
|
+
...headers === undefined ? {} : { headers },
|
|
58
|
+
...env === undefined ? {} : { env },
|
|
59
|
+
...note,
|
|
60
|
+
}, value)]
|
|
61
|
+
}
|
|
62
|
+
if (command === undefined) return []
|
|
63
|
+
return [flagged({
|
|
64
|
+
id,
|
|
65
|
+
command,
|
|
66
|
+
...args === undefined ? {} : { args },
|
|
67
|
+
...env === undefined ? {} : { env },
|
|
68
|
+
...note,
|
|
69
|
+
}, value)]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function flagged(server: McpEditorServer, value: Record<string, unknown>): McpEditorServer {
|
|
73
|
+
return value.disabled === true || value.enabled === false ? { ...server, enabled: false } : server
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function nameOf(value: Record<string, unknown>): string | undefined {
|
|
77
|
+
if (typeof value.name === 'string' && value.name.trim().length > 0) return value.name.trim()
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isServerEntry(value: Record<string, unknown>): boolean {
|
|
82
|
+
return stringField(value, [...commandKeys, ...urlKeys]) !== undefined || value.transport !== undefined || value.type !== undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function descriptionOf(value: Record<string, unknown>): string | undefined {
|
|
86
|
+
if (typeof value.description === 'string' && value.description.trim().length > 0) return value.description.trim()
|
|
87
|
+
const meta = value._monkeyagent
|
|
88
|
+
if (!isRecord(meta) || typeof meta.description !== 'string') return undefined
|
|
89
|
+
const text = meta.description.trim()
|
|
90
|
+
return text.length === 0 ? undefined : text
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function transportOf(value: unknown): 'stdio' | 'sse' | 'http' | undefined {
|
|
94
|
+
if (typeof value !== 'string') return undefined
|
|
95
|
+
const text = value.trim().toLowerCase()
|
|
96
|
+
if (text === 'stdio' || text === 'local' || text.startsWith('stdio')) return 'stdio'
|
|
97
|
+
if (text === 'sse' || text.startsWith('sse')) return 'sse'
|
|
98
|
+
if (text === 'http' || text.startsWith('http') || text === 'streamable-http' || text === 'streamable_http') return 'http'
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function stringField(value: Record<string, unknown>, keys: readonly string[]): string | undefined {
|
|
103
|
+
for (const key of keys) {
|
|
104
|
+
const item = value[key]
|
|
105
|
+
if (typeof item === 'string' && item.trim().length > 0) return item.trim()
|
|
106
|
+
}
|
|
107
|
+
return undefined
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function argsOf(value: unknown): string[] | undefined {
|
|
111
|
+
if (typeof value === 'string') {
|
|
112
|
+
const args = value.split(/\s+/).filter(item => item.length > 0)
|
|
113
|
+
return args.length === 0 ? undefined : args
|
|
114
|
+
}
|
|
115
|
+
if (!Array.isArray(value)) return undefined
|
|
116
|
+
const args = value.filter((item): item is string => typeof item === 'string' && item.length > 0)
|
|
117
|
+
return args.length === 0 ? undefined : args
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function recordOf(value: unknown): Record<string, string> | undefined {
|
|
121
|
+
if (!isRecord(value)) return undefined
|
|
122
|
+
const record: Record<string, string> = {}
|
|
123
|
+
for (const [key, item] of Object.entries(value)) {
|
|
124
|
+
if (typeof item === 'string') record[key] = item
|
|
125
|
+
}
|
|
126
|
+
return Object.keys(record).length === 0 ? undefined : record
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
130
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function stripFence(text: string): string {
|
|
134
|
+
const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)
|
|
135
|
+
return (fenced?.[1] ?? text).trim()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function brace(text: string): string {
|
|
139
|
+
if (text.startsWith('{') || text.startsWith('[')) return text
|
|
140
|
+
return `{${text}}`
|
|
141
|
+
}
|
package/src/host/mcp.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
|
|
3
|
+
import { McpError, resolveMcpConfig, resolveServerValues, type McpReferenceSources, type McpServerSpec } from '@mohou/mcp-client'
|
|
4
|
+
|
|
5
|
+
import type { CredentialProvider } from '../credentials/provider.ts'
|
|
6
|
+
import { hostMcpPath } from './layout.ts'
|
|
7
|
+
|
|
8
|
+
/** Explicit config path. Set means that file, not a search of another home. */
|
|
9
|
+
export const mcpConfigEnv = 'MINI_APP_MCP_CONFIG'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The lookups a `${env:NAME}` or `${credential:NAME}` reference resolves through.
|
|
13
|
+
* Host binds them here and nowhere else: the environment, and the credential interface Shell
|
|
14
|
+
* injects. Another credential implementation — a system keychain, say — changes the binding
|
|
15
|
+
* Shell passes and not the resolver, nor any caller of it.
|
|
16
|
+
* @param env - process environment
|
|
17
|
+
* @param credentials - the read port, when Shell injected one
|
|
18
|
+
*/
|
|
19
|
+
export function mcpReferenceSources(env: NodeJS.ProcessEnv, credentials?: CredentialProvider): McpReferenceSources {
|
|
20
|
+
return {
|
|
21
|
+
env: name => env[name],
|
|
22
|
+
credential: credentials === undefined ? () => undefined : name => credentials.get(name),
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A server that is not started, and why. `code` is the MCP client's own code. */
|
|
27
|
+
export interface McpLoadFailure {
|
|
28
|
+
readonly id: string
|
|
29
|
+
readonly code: string
|
|
30
|
+
readonly message: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ResolvedMcpServers {
|
|
34
|
+
readonly servers: Record<string, McpServerSpec>
|
|
35
|
+
/** One entry per server a reference left unresolvable. Those servers do not start. */
|
|
36
|
+
readonly failures: readonly McpLoadFailure[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve one admitted map, one server at a time. A reference that names nothing leaves that server
|
|
41
|
+
* out and reports it; the value never becomes an empty string. Boot and a write share this rule, so a
|
|
42
|
+
* file written at runtime reads back the way the next boot would read it.
|
|
43
|
+
* @param admitted - servers a config read accepted
|
|
44
|
+
* @param sources - the lookups references resolve through
|
|
45
|
+
*/
|
|
46
|
+
export async function resolveMcpServers(
|
|
47
|
+
admitted: Record<string, McpServerSpec>,
|
|
48
|
+
sources: McpReferenceSources,
|
|
49
|
+
): Promise<ResolvedMcpServers> {
|
|
50
|
+
const servers: Record<string, McpServerSpec> = {}
|
|
51
|
+
const failures: McpLoadFailure[] = []
|
|
52
|
+
for (const [id, spec] of Object.entries(admitted)) {
|
|
53
|
+
try {
|
|
54
|
+
servers[id] = await resolveServerValues(spec, sources, id)
|
|
55
|
+
} catch (error) {
|
|
56
|
+
failures.push({
|
|
57
|
+
id,
|
|
58
|
+
code: error instanceof McpError ? error.code : 'config-invalid',
|
|
59
|
+
message: error instanceof Error && error.message.length > 0 ? error.message : 'server value did not resolve',
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { servers, failures }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read MCP servers for Host boot, and resolve the references in them.
|
|
68
|
+
* A missing default file is zero servers. A present bad file, or a missing explicit path, fails boot:
|
|
69
|
+
* the file itself is unusable. A reference that names nothing is not the same class — that one server
|
|
70
|
+
* is left out and reported in `failures`, so the host still serves the panel that can fix it. The
|
|
71
|
+
* value never falls back to an empty string either way.
|
|
72
|
+
* @param runtimeRoot - directory that contains the default file
|
|
73
|
+
* @param env - process environment; only {@link mcpConfigEnv} is read
|
|
74
|
+
* @param sources - the lookups references resolve through
|
|
75
|
+
*/
|
|
76
|
+
export async function loadMcpServers(
|
|
77
|
+
runtimeRoot: string,
|
|
78
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
79
|
+
sources: McpReferenceSources = mcpReferenceSources(env),
|
|
80
|
+
): Promise<ResolvedMcpServers> {
|
|
81
|
+
const override = env[mcpConfigEnv]
|
|
82
|
+
const explicit = typeof override === 'string' && override.length > 0
|
|
83
|
+
const file = explicit ? override : hostMcpPath(runtimeRoot)
|
|
84
|
+
const text = await readFile(file, 'utf8').catch(() => undefined)
|
|
85
|
+
if (text === undefined) {
|
|
86
|
+
if (explicit) throw new McpError('config-invalid', `mcp config is missing: ${file}`)
|
|
87
|
+
return { servers: {}, failures: [] }
|
|
88
|
+
}
|
|
89
|
+
let parsed: unknown
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(text) as unknown
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new McpError('config-invalid', 'mcp config is not JSON', { cause: error })
|
|
94
|
+
}
|
|
95
|
+
return resolveMcpServers(resolveMcpConfig(omitDisabled(parsed)), sources)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function omitDisabled(value: unknown): unknown {
|
|
99
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return value
|
|
100
|
+
const record = value as Record<string, unknown>
|
|
101
|
+
const wrapped = typeof record.mcpServers === 'object' && record.mcpServers !== null && !Array.isArray(record.mcpServers)
|
|
102
|
+
const source = wrapped ? record.mcpServers as Record<string, unknown> : record
|
|
103
|
+
const next: Record<string, unknown> = {}
|
|
104
|
+
for (const [id, entry] of Object.entries(source)) {
|
|
105
|
+
if (typeof entry === 'object' && entry !== null && 'disabled' in entry && entry.disabled === true) continue
|
|
106
|
+
next[id] = entry
|
|
107
|
+
}
|
|
108
|
+
return wrapped ? { ...record, mcpServers: next } : next
|
|
109
|
+
}
|
package/src/host/port.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createServer } from 'node:net'
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_PORT_BOUND, type PortBound } from './config.ts'
|
|
4
|
+
|
|
5
|
+
/** How many successive ports to probe after the preferred one. Host policy, not locked. */
|
|
6
|
+
export const HOST_PORT_PROBE_SPAN = 64
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Preferred port is busy on an existing `host.json`.
|
|
10
|
+
* Start does not rebind or rewrite the file; the caller offers a confirm path.
|
|
11
|
+
*/
|
|
12
|
+
export class PortInUseError extends Error {
|
|
13
|
+
readonly code = 'port-in-use' as const
|
|
14
|
+
readonly busyPort: number
|
|
15
|
+
readonly suggestedPort: number
|
|
16
|
+
|
|
17
|
+
constructor(busyPort: number, suggestedPort: number) {
|
|
18
|
+
super(`port ${busyPort} is in use`)
|
|
19
|
+
this.name = 'PortInUseError'
|
|
20
|
+
this.busyPort = busyPort
|
|
21
|
+
this.suggestedPort = suggestedPort
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isPortInUseError(error: unknown): error is PortInUseError {
|
|
26
|
+
return error instanceof PortInUseError
|
|
27
|
+
|| (typeof error === 'object' && error !== null && 'code' in error && error.code === 'port-in-use'
|
|
28
|
+
&& 'busyPort' in error && 'suggestedPort' in error)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True when loopback can bind `port` right now. */
|
|
32
|
+
export function isHostPortFree(port: number): Promise<boolean> {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const server = createServer()
|
|
35
|
+
server.once('error', () => {
|
|
36
|
+
resolve(false)
|
|
37
|
+
})
|
|
38
|
+
server.listen(port, '127.0.0.1', () => {
|
|
39
|
+
server.close(() => {
|
|
40
|
+
resolve(true)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* First free loopback port at or above `preferred` within the probe span and bound.
|
|
48
|
+
* Falls back to an ephemeral OS port when the span is exhausted.
|
|
49
|
+
*/
|
|
50
|
+
export async function allocateHostPort(
|
|
51
|
+
preferred: number,
|
|
52
|
+
ports: PortBound = DEFAULT_PORT_BOUND,
|
|
53
|
+
span = HOST_PORT_PROBE_SPAN,
|
|
54
|
+
): Promise<number> {
|
|
55
|
+
const start = clampPort(preferred, ports)
|
|
56
|
+
const last = Math.min(ports.max, start + span - 1)
|
|
57
|
+
for (let port = start; port <= last; port++) {
|
|
58
|
+
if (await isHostPortFree(port)) return port
|
|
59
|
+
}
|
|
60
|
+
return ephemeralHostPort()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function clampPort(port: number, ports: PortBound): number {
|
|
64
|
+
if (!Number.isInteger(port)) return ports.min
|
|
65
|
+
if (port < ports.min) return ports.min
|
|
66
|
+
if (port > ports.max) return ports.max
|
|
67
|
+
return port
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ephemeralHostPort(): Promise<number> {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const server = createServer()
|
|
73
|
+
server.once('error', reject)
|
|
74
|
+
server.listen(0, '127.0.0.1', () => {
|
|
75
|
+
const address = server.address()
|
|
76
|
+
const port = typeof address === 'object' && address !== null ? address.port : 0
|
|
77
|
+
server.close((error) => {
|
|
78
|
+
if (error) reject(error)
|
|
79
|
+
else resolve(port)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
}
|