@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.
Files changed (108) hide show
  1. package/README.md +10 -0
  2. package/package.json +65 -0
  3. package/src/apps/monogram.ts +15 -0
  4. package/src/apps/registry.ts +238 -0
  5. package/src/apps/startup-seed.ts +95 -0
  6. package/src/bash/client.ts +21 -0
  7. package/src/bash/codes.ts +21 -0
  8. package/src/compile/allowlist.ts +89 -0
  9. package/src/compile/asset-path.ts +48 -0
  10. package/src/compile/autogen.ts +71 -0
  11. package/src/compile/build-vendor-cli.ts +8 -0
  12. package/src/compile/build-vendor.ts +160 -0
  13. package/src/compile/bundle-ui.ts +137 -0
  14. package/src/compile/codes.ts +22 -0
  15. package/src/compile/failure.ts +38 -0
  16. package/src/compile/imports.ts +43 -0
  17. package/src/compile/leave.ts +47 -0
  18. package/src/compile/load-backend.ts +95 -0
  19. package/src/compile/review.ts +342 -0
  20. package/src/compile/runner.ts +205 -0
  21. package/src/compile/sheet.ts +174 -0
  22. package/src/compile/wrapper.ts +297 -0
  23. package/src/credentials/codes.ts +26 -0
  24. package/src/credentials/create.ts +69 -0
  25. package/src/credentials/file.ts +113 -0
  26. package/src/credentials/provider.ts +66 -0
  27. package/src/events/app-events.ts +214 -0
  28. package/src/events/error-ring.ts +109 -0
  29. package/src/events/host-events.ts +39 -0
  30. package/src/events/view-bridge.ts +402 -0
  31. package/src/events/view-eval.ts +213 -0
  32. package/src/files/codes.ts +28 -0
  33. package/src/files/skip.ts +23 -0
  34. package/src/files/tools.ts +215 -0
  35. package/src/history/codes.ts +22 -0
  36. package/src/history/store.ts +287 -0
  37. package/src/host/activity.ts +110 -0
  38. package/src/host/author-mcp.ts +248 -0
  39. package/src/host/author-skill.ts +196 -0
  40. package/src/host/codes.ts +21 -0
  41. package/src/host/config.ts +239 -0
  42. package/src/host/heat.ts +35 -0
  43. package/src/host/layout.ts +81 -0
  44. package/src/host/log.ts +142 -0
  45. package/src/host/mcp-editor.ts +205 -0
  46. package/src/host/mcp-import.ts +141 -0
  47. package/src/host/mcp.ts +109 -0
  48. package/src/host/port.ts +83 -0
  49. package/src/host/session.ts +358 -0
  50. package/src/host/workbench.ts +38 -0
  51. package/src/http/app.ts +46 -0
  52. package/src/http/asset.ts +49 -0
  53. package/src/http/author.ts +157 -0
  54. package/src/http/bind.ts +217 -0
  55. package/src/http/client.ts +175 -0
  56. package/src/http/codes.ts +29 -0
  57. package/src/http/env.ts +3 -0
  58. package/src/http/guard.ts +20 -0
  59. package/src/http/iframe.ts +212 -0
  60. package/src/http/layout.ts +63 -0
  61. package/src/http/owner.ts +380 -0
  62. package/src/http/ports.ts +114 -0
  63. package/src/http/reply.ts +53 -0
  64. package/src/http/route-codes.ts +19 -0
  65. package/src/http/updates.ts +258 -0
  66. package/src/index.ts +201 -0
  67. package/src/install/codes.ts +21 -0
  68. package/src/install/install.ts +230 -0
  69. package/src/install/layout.ts +16 -0
  70. package/src/kernel/bind-brain.ts +117 -0
  71. package/src/kernel/call.ts +134 -0
  72. package/src/kernel/codes.ts +21 -0
  73. package/src/kernel/index.ts +13 -0
  74. package/src/kernel/model-policy.ts +83 -0
  75. package/src/kernel/model-stream.ts +86 -0
  76. package/src/kernel/present.ts +4 -0
  77. package/src/kernel/working-directory.ts +35 -0
  78. package/src/metrics/codes.ts +22 -0
  79. package/src/metrics/read.ts +48 -0
  80. package/src/owner/read.ts +85 -0
  81. package/src/pwsh/client.ts +47 -0
  82. package/src/pwsh/codes.ts +21 -0
  83. package/src/shell/command.ts +172 -0
  84. package/src/storage/codes.ts +33 -0
  85. package/src/storage/layout.ts +44 -0
  86. package/src/storage/open.ts +461 -0
  87. package/src/storage/schema.ts +176 -0
  88. package/src/storage/sql.ts +40 -0
  89. package/src/theme/codes.ts +20 -0
  90. package/src/theme/layout.ts +42 -0
  91. package/src/theme/paint.ts +108 -0
  92. package/src/theme/parse.ts +58 -0
  93. package/src/theme/pin.ts +197 -0
  94. package/src/theme/tokens.ts +30 -0
  95. package/src/tools/author.ts +970 -0
  96. package/src/tools/codes.ts +21 -0
  97. package/src/tools/diagnostics.ts +53 -0
  98. package/src/tools/http.ts +48 -0
  99. package/src/tools/mcp-list.ts +90 -0
  100. package/src/tools/schemas.ts +185 -0
  101. package/themes/theme-default.css +4 -0
  102. package/themes/theme-forest.css +4 -0
  103. package/themes/theme-hokage.css +4 -0
  104. package/themes/theme-matcha.css +4 -0
  105. package/themes/theme-slate.css +4 -0
  106. package/themes/theme-tokyo.css +4 -0
  107. package/themes/theme-yellow.css +4 -0
  108. 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
+ }
@@ -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
+ }
@@ -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
+ }