@nyte-ai/host 0.0.0-stage → 0.0.18

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 (235) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/THIRD-PARTY-NOTICES.md +800 -0
  4. package/dist/chunks/canvas-preview-0cmbkp2j.js +65 -0
  5. package/dist/chunks/canvas-preview-0nq75h9k.js +7 -0
  6. package/dist/chunks/canvas-preview-0nrnbt76.js +497 -0
  7. package/dist/chunks/canvas-preview-1sq7z9jq.js +17 -0
  8. package/dist/chunks/canvas-preview-1ta3avah.js +56 -0
  9. package/dist/chunks/canvas-preview-2zdyh9ny.js +26 -0
  10. package/dist/chunks/canvas-preview-31w7xm8z.js +34 -0
  11. package/dist/chunks/canvas-preview-4b6sayhg.js +295 -0
  12. package/dist/chunks/canvas-preview-4e0pxa32.js +63 -0
  13. package/dist/chunks/canvas-preview-518dh8by.js +169 -0
  14. package/dist/chunks/canvas-preview-5hxat7hh.js +239 -0
  15. package/dist/chunks/canvas-preview-5mex725c.js +14 -0
  16. package/dist/chunks/canvas-preview-5z63am63.js +295 -0
  17. package/dist/chunks/canvas-preview-65xqjw7f.js +20 -0
  18. package/dist/chunks/canvas-preview-69fkk9yn.js +95 -0
  19. package/dist/chunks/canvas-preview-75db60xy.js +457 -0
  20. package/dist/chunks/canvas-preview-76fwvd6e.js +272 -0
  21. package/dist/chunks/canvas-preview-8xgt67ka.js +598 -0
  22. package/dist/chunks/canvas-preview-98aj5p34.js +176 -0
  23. package/dist/chunks/canvas-preview-9aqczacs.js +73 -0
  24. package/dist/chunks/canvas-preview-a3yw0j70.js +72 -0
  25. package/dist/chunks/canvas-preview-azg56359.js +68 -0
  26. package/dist/chunks/canvas-preview-d9wwhbtz.js +240 -0
  27. package/dist/chunks/canvas-preview-ehf3vp31.js +411 -0
  28. package/dist/chunks/canvas-preview-evh92ywd.js +369 -0
  29. package/dist/chunks/canvas-preview-ewgspmsd.js +81 -0
  30. package/dist/chunks/canvas-preview-g3qj3y3k.js +156 -0
  31. package/dist/chunks/canvas-preview-h8hshnff.js +63 -0
  32. package/dist/chunks/canvas-preview-j1tnf51w.js +36 -0
  33. package/dist/chunks/canvas-preview-kdqgrstc.js +194 -0
  34. package/dist/chunks/canvas-preview-kn7kvbfj.js +12 -0
  35. package/dist/chunks/canvas-preview-m02v4yy6.js +386 -0
  36. package/dist/chunks/canvas-preview-mgcx2b2p.js +271 -0
  37. package/dist/chunks/canvas-preview-rp4x6y1h.js +144 -0
  38. package/dist/chunks/canvas-preview-rp6fz0w5.js +130 -0
  39. package/dist/chunks/canvas-preview-s94ebwf5.js +112 -0
  40. package/dist/chunks/canvas-preview-senpjwep.js +518 -0
  41. package/dist/chunks/canvas-preview-skzwvw4d.js +23 -0
  42. package/dist/chunks/canvas-preview-srgfpdmf.js +60 -0
  43. package/dist/chunks/canvas-preview-tjhabynd.js +252 -0
  44. package/dist/chunks/canvas-preview-vaatfznm.js +189 -0
  45. package/dist/chunks/canvas-preview-vax9ne4e.js +341 -0
  46. package/dist/chunks/canvas-preview-wg80rbc2.js +46 -0
  47. package/dist/chunks/canvas-preview-xn10d4f8.js +608 -0
  48. package/dist/chunks/canvas-preview-yjz0f7n4.js +50 -0
  49. package/dist/chunks/canvas-preview-ymgz0937.js +587 -0
  50. package/dist/chunks/canvas-preview-yrfz2ak6.js +134 -0
  51. package/dist/chunks/canvas-preview-zaaj99wd.js +244 -0
  52. package/dist/chunks/canvas-preview-zn2d0k9d.js +133 -0
  53. package/dist/src/canvas-preview.d.ts +29 -0
  54. package/dist/src/canvas-preview.js +267 -0
  55. package/dist/src/credentials.d.ts +44 -0
  56. package/dist/src/credentials.js +10 -0
  57. package/dist/src/deploy/cloudflare/worker.d.ts +50 -0
  58. package/dist/src/deploy/cloudflare/worker.js +196 -0
  59. package/dist/src/deploy/vercel/nitro.config.d.ts +2 -0
  60. package/dist/src/deploy/vercel/nitro.config.js +30 -0
  61. package/dist/src/deploy/vercel/server/routes/[...].d.ts +2 -0
  62. package/dist/src/deploy/vercel/server/routes/[...].js +18 -0
  63. package/dist/src/deploy/vercel/server/utils/host.d.ts +13 -0
  64. package/dist/src/deploy/vercel/server/utils/host.js +7 -0
  65. package/dist/src/deploy/vercel/server/utils/repair.d.ts +1 -0
  66. package/dist/src/deploy/vercel/server/utils/repair.js +6 -0
  67. package/dist/src/deploy/vercel/server/utils/step.d.ts +10 -0
  68. package/dist/src/deploy/vercel/server/utils/step.js +6 -0
  69. package/dist/src/deploy/vercel/server/workflows/drive.d.ts +14 -0
  70. package/dist/src/deploy/vercel/server/workflows/drive.js +13 -0
  71. package/dist/src/durable-object.d.ts +14 -0
  72. package/dist/src/durable-object.js +231 -0
  73. package/dist/src/hash.d.ts +1 -0
  74. package/dist/src/hash.js +6 -0
  75. package/dist/src/identity-key.d.ts +11 -0
  76. package/dist/src/identity-key.js +9 -0
  77. package/dist/src/identity.d.ts +12 -0
  78. package/dist/src/identity.js +6 -0
  79. package/dist/src/index.d.ts +21 -0
  80. package/dist/src/index.js +120 -0
  81. package/dist/src/key-file.d.ts +1 -0
  82. package/dist/src/key-file.js +7 -0
  83. package/dist/src/listen-fetch.d.ts +8 -0
  84. package/dist/src/listen-fetch.js +9 -0
  85. package/dist/src/listen.d.ts +4 -0
  86. package/dist/src/listen.js +10 -0
  87. package/dist/src/local/command.d.ts +15 -0
  88. package/dist/src/local/command.js +8 -0
  89. package/dist/src/local/feed.d.ts +7 -0
  90. package/dist/src/local/feed.js +6 -0
  91. package/dist/src/local/files.d.ts +18 -0
  92. package/dist/src/local/files.js +13 -0
  93. package/dist/src/local/git-watch.d.ts +15 -0
  94. package/dist/src/local/git-watch.js +7 -0
  95. package/dist/src/local/git.d.ts +5 -0
  96. package/dist/src/local/git.js +13 -0
  97. package/dist/src/local/github.d.ts +30 -0
  98. package/dist/src/local/github.js +9 -0
  99. package/dist/src/local/identity.d.ts +3 -0
  100. package/dist/src/local/identity.js +7 -0
  101. package/dist/src/local/jobs.d.ts +7 -0
  102. package/dist/src/local/jobs.js +6 -0
  103. package/dist/src/local/legacy-trust.d.ts +10 -0
  104. package/dist/src/local/legacy-trust.js +6 -0
  105. package/dist/src/local/patch.d.ts +16 -0
  106. package/dist/src/local/patch.js +17 -0
  107. package/dist/src/local/provider.d.ts +11 -0
  108. package/dist/src/local/provider.js +20 -0
  109. package/dist/src/local/snapshots.d.ts +7 -0
  110. package/dist/src/local/snapshots.js +11 -0
  111. package/dist/src/local/text.d.ts +1 -0
  112. package/dist/src/local/text.js +6 -0
  113. package/dist/src/local/tree.d.ts +50 -0
  114. package/dist/src/local/tree.js +16 -0
  115. package/dist/src/local/workspace-migration.d.ts +2 -0
  116. package/dist/src/local/workspace-migration.js +7 -0
  117. package/dist/src/local/worktrees.d.ts +46 -0
  118. package/dist/src/local/worktrees.js +14 -0
  119. package/dist/src/mcp/config.d.ts +75 -0
  120. package/dist/src/mcp/config.js +12 -0
  121. package/dist/src/mcp/connect.d.ts +28 -0
  122. package/dist/src/mcp/connect.js +9 -0
  123. package/dist/src/mcp/hub.d.ts +19 -0
  124. package/dist/src/mcp/hub.js +11 -0
  125. package/dist/src/mcp/oauth.d.ts +46 -0
  126. package/dist/src/mcp/oauth.js +12 -0
  127. package/dist/src/mcp/tools.d.ts +29 -0
  128. package/dist/src/mcp/tools.js +12 -0
  129. package/dist/src/open.d.ts +42 -0
  130. package/dist/src/open.js +44 -0
  131. package/dist/src/plugins/loader.d.ts +21 -0
  132. package/dist/src/plugins/loader.js +11 -0
  133. package/dist/src/postgres.d.ts +19 -0
  134. package/dist/src/postgres.js +8 -0
  135. package/dist/src/providers.d.ts +16 -0
  136. package/dist/src/providers.js +112 -0
  137. package/dist/src/secrets.d.ts +12 -0
  138. package/dist/src/secrets.js +8 -0
  139. package/dist/src/serve/binding.d.ts +39 -0
  140. package/dist/src/serve/binding.js +6 -0
  141. package/dist/src/serve/handler.d.ts +15 -0
  142. package/dist/src/serve/handler.js +8 -0
  143. package/dist/src/serve/stream-server.d.ts +6 -0
  144. package/dist/src/serve/stream-server.js +6 -0
  145. package/dist/src/session-index.d.ts +11 -0
  146. package/dist/src/session-index.js +11 -0
  147. package/dist/src/shell-env.d.ts +14 -0
  148. package/dist/src/shell-env.js +108 -0
  149. package/dist/src/sqlite-workers.d.ts +6 -0
  150. package/dist/src/sqlite-workers.js +6 -0
  151. package/dist/src/sqlite.d.ts +7 -0
  152. package/dist/src/sqlite.js +10 -0
  153. package/dist/src/store-owner.d.ts +15 -0
  154. package/dist/src/store-owner.js +9 -0
  155. package/dist/src/store-rows.d.ts +13 -0
  156. package/dist/src/store-rows.js +26 -0
  157. package/dist/src/store-usage.d.ts +25 -0
  158. package/dist/src/store-usage.js +16 -0
  159. package/dist/src/usage-scan.d.ts +24 -0
  160. package/dist/src/usage-scan.js +65 -0
  161. package/dist/src/usage-service.d.ts +22 -0
  162. package/dist/src/usage-service.js +12 -0
  163. package/dist/src/usage-summary.d.ts +9 -0
  164. package/dist/src/usage-summary.js +16 -0
  165. package/dist/src/usage-types.d.ts +86 -0
  166. package/dist/src/usage-types.js +0 -0
  167. package/dist/src/usage-worker.d.ts +1 -0
  168. package/dist/src/usage-worker.js +25 -0
  169. package/dist/src/usage.d.ts +72 -0
  170. package/dist/src/usage.js +13 -0
  171. package/package.json +132 -3
  172. package/src/canvas-preview.ts +402 -0
  173. package/src/credentials.ts +294 -0
  174. package/src/deploy/cloudflare/README.md +3 -0
  175. package/src/deploy/cloudflare/worker.ts +306 -0
  176. package/src/deploy/cloudflare/wrangler.jsonc +12 -0
  177. package/src/deploy/vercel/README.md +3 -0
  178. package/src/deploy/vercel/nitro.config.ts +29 -0
  179. package/src/deploy/vercel/server/routes/[...].ts +10 -0
  180. package/src/deploy/vercel/server/utils/host.ts +104 -0
  181. package/src/deploy/vercel/server/utils/repair.ts +35 -0
  182. package/src/deploy/vercel/server/utils/step.ts +25 -0
  183. package/src/deploy/vercel/server/workflows/drive.ts +46 -0
  184. package/src/deploy/vercel/tsconfig.json +9 -0
  185. package/src/deploy/vercel/types/bun.d.ts +2 -0
  186. package/src/durable-object.ts +386 -0
  187. package/src/hash.ts +5 -0
  188. package/src/identity-key.ts +20 -0
  189. package/src/identity.ts +66 -0
  190. package/src/index.ts +21 -0
  191. package/src/key-file.ts +53 -0
  192. package/src/listen-fetch.ts +409 -0
  193. package/src/listen.ts +228 -0
  194. package/src/local/command.ts +109 -0
  195. package/src/local/feed.ts +70 -0
  196. package/src/local/files.ts +812 -0
  197. package/src/local/git-watch.ts +189 -0
  198. package/src/local/git.ts +914 -0
  199. package/src/local/github.ts +692 -0
  200. package/src/local/identity.ts +21 -0
  201. package/src/local/jobs.ts +587 -0
  202. package/src/local/legacy-trust.ts +105 -0
  203. package/src/local/patch.ts +334 -0
  204. package/src/local/provider.ts +184 -0
  205. package/src/local/snapshots.ts +776 -0
  206. package/src/local/text.ts +9 -0
  207. package/src/local/tree.ts +398 -0
  208. package/src/local/workspace-migration.ts +95 -0
  209. package/src/local/worktrees.ts +239 -0
  210. package/src/mcp/config.ts +330 -0
  211. package/src/mcp/connect.ts +105 -0
  212. package/src/mcp/hub.ts +836 -0
  213. package/src/mcp/oauth.ts +436 -0
  214. package/src/mcp/tools.ts +333 -0
  215. package/src/open.ts +469 -0
  216. package/src/plugins/loader.ts +186 -0
  217. package/src/postgres.ts +492 -0
  218. package/src/providers.ts +135 -0
  219. package/src/secrets.ts +31 -0
  220. package/src/serve/binding.ts +628 -0
  221. package/src/serve/handler.ts +362 -0
  222. package/src/serve/stream-server.ts +176 -0
  223. package/src/session-index.ts +99 -0
  224. package/src/shell-env.ts +153 -0
  225. package/src/sqlite-workers.ts +283 -0
  226. package/src/sqlite.ts +528 -0
  227. package/src/store-owner.ts +98 -0
  228. package/src/store-rows.ts +110 -0
  229. package/src/store-usage.ts +344 -0
  230. package/src/usage-scan.ts +81 -0
  231. package/src/usage-service.ts +73 -0
  232. package/src/usage-summary.ts +79 -0
  233. package/src/usage-types.ts +87 -0
  234. package/src/usage-worker.ts +24 -0
  235. package/src/usage.ts +701 -0
package/src/mcp/hub.ts ADDED
@@ -0,0 +1,836 @@
1
+ import { join } from "node:path";
2
+ import { StdioTransport } from "@earendil-works/pi-mcp";
3
+ import type { McpClient, Tool } from "@earendil-works/pi-mcp";
4
+ import { McpOAuthAuthorizationRequiredError } from "@earendil-works/pi-mcp/oauth";
5
+ import type { OAuthChallenge } from "@earendil-works/pi-mcp/oauth";
6
+ import { processEnvironment, closable, dev, forwarded, traced } from "@nyte-ai/core";
7
+ import type { Login, PluginStorage, ToolDefinition } from "@nyte-ai/core";
8
+ import type { McpHub, McpHubFactory, McpServerBrief, McpWorkspace } from "@nyte-ai/core/server";
9
+ import { isJson, isJsonObject, SdkError } from "@nyte-ai/schema";
10
+ import type {
11
+ Disposer,
12
+ McpChange,
13
+ McpServerState,
14
+ McpServerView,
15
+ McpView,
16
+ ValidationIssue,
17
+ } from "@nyte-ai/schema";
18
+ import { watchPath } from "../local/tree.ts";
19
+ import { parseMcpConfig, planMcpEdit, readMcpFile, writeMcpFile } from "./config.ts";
20
+ import type { McpConfig, McpFile, McpFileContents, McpServerEntry } from "./config.ts";
21
+ import { connectServer, oauthSettings } from "./connect.ts";
22
+ import type { McpConnector } from "./connect.ts";
23
+ import { oauthKey, oauthStore, signIn } from "./oauth.ts";
24
+ import { bridgeTools, checkToolSchemas, toolExposure, TracingMcpClient } from "./tools.ts";
25
+ import type { McpCall } from "./tools.ts";
26
+
27
+ const STDERR_TAIL = 4000;
28
+
29
+ const WATCH_DELAY_MS = 100;
30
+
31
+ const LINGER_MS = 30_000;
32
+
33
+ const UNTRUSTED: McpChange = {
34
+ kind: "invalid",
35
+ message: "Project servers need a trusted folder on this machine.",
36
+ };
37
+
38
+ /**
39
+ * Serves the servers in `<home>/.nyte/mcp.json`, and in a trusted local
40
+ * workspace's `.nyte/mcp.json`, to the host's sessions. Each file is watched
41
+ * (created, edited, replaced or removed) without polling, and an edit applies
42
+ * once the file system reports it: a changed server reconnects, an unchanged
43
+ * one keeps its connection. A workspace's connections close 30 seconds after its last
44
+ * watcher stops.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * const host = await openHost({ ...options, mcp: createMcpHub({ home: homedir() }) });
49
+ * ```
50
+ */
51
+ export function createMcpHub(input: {
52
+ readonly home: string;
53
+ readonly connect?: McpConnector;
54
+ }): McpHubFactory {
55
+ return ({ secrets }) => hub({ ...input, connect: input.connect ?? connectServer, secrets });
56
+ }
57
+
58
+ type LinkState =
59
+ | { readonly kind: "connecting" }
60
+ | {
61
+ readonly kind: "connected";
62
+ readonly tools: readonly Tool[];
63
+ readonly issues: readonly ValidationIssue[];
64
+ }
65
+ | { readonly kind: "needs-sign-in" }
66
+ | { readonly kind: "failed"; readonly message: string; readonly stderr: string | null };
67
+
68
+ /** A server's last successful listing, kept while it reconnects so its sessions' tools and prompt do not change. */
69
+ type Listing = { readonly tools: readonly Tool[]; readonly instructions: string | null };
70
+
71
+ type Link = {
72
+ readonly key: string;
73
+ entry: McpServerEntry;
74
+ readonly state: () => LinkState;
75
+ readonly brief: () => McpServerBrief;
76
+ /** The server's last OAuth challenge, which a sign-in follows for its scope and metadata. */
77
+ readonly challenge: () => OAuthChallenge | null;
78
+ readonly tools: () => readonly ToolDefinition[];
79
+ /** The first connection attempt while it runs, or null once it has connected, failed or asked for sign-in, or the link closed. */
80
+ readonly attempting: () => Promise<void> | null;
81
+ close(): Promise<void>;
82
+ };
83
+
84
+ type Pool = {
85
+ readonly key: string;
86
+ readonly workspace: McpWorkspace;
87
+ readonly files: readonly Omit<McpFile, "contents">[];
88
+ config: McpConfig;
89
+ readonly loaded: Promise<void>;
90
+ /** True until `mcp.json` has been read and the links reconciled. */
91
+ loading: boolean;
92
+ readonly links: Map<string, Link>;
93
+ readonly listeners: Set<() => void>;
94
+ holders: number;
95
+ linger: ReturnType<typeof setTimeout> | undefined;
96
+ };
97
+
98
+ function hub(input: {
99
+ readonly home: string;
100
+ readonly connect: McpConnector;
101
+ readonly secrets: PluginStorage["secrets"];
102
+ }): McpHub {
103
+ const pools = new Map<string, Pool>();
104
+ const contents = new Map<string, McpFileContents>();
105
+ const watched = new Map<string, Disposer>();
106
+ const userPath = join(input.home, ".nyte", "mcp.json");
107
+ let closed = false;
108
+
109
+ const notify = (pool: Pool): void => {
110
+ for (const listener of pool.listeners) listener();
111
+ };
112
+
113
+ const reconcile = (pool: Pool): void => {
114
+ pool.config = parseMcpConfig(
115
+ pool.files.map((file) => ({
116
+ ...file,
117
+ contents: contents.get(file.path) ?? { kind: "missing" },
118
+ })),
119
+ );
120
+
121
+ for (const [name, link] of pool.links) {
122
+ const entry = pool.config.servers.get(name);
123
+
124
+ if (entry !== undefined && entry.enabled && linkKey(entry) === link.key) {
125
+ link.entry = entry;
126
+
127
+ continue;
128
+ }
129
+
130
+ pool.links.delete(name);
131
+ void link.close();
132
+ }
133
+
134
+ for (const entry of pool.config.servers.values())
135
+ if (entry.enabled && !pool.links.has(entry.name))
136
+ pool.links.set(entry.name, openLink(entry, pool));
137
+
138
+ notify(pool);
139
+ };
140
+
141
+ const openLink = (entry: McpServerEntry, pool: Pool): Link => {
142
+ let state: LinkState = { kind: "connecting" };
143
+ let listed: Listing | null = null;
144
+
145
+ let bridged: {
146
+ readonly entry: McpServerEntry;
147
+ readonly listed: Listing;
148
+ readonly tools: readonly ToolDefinition[];
149
+ } | null = null;
150
+
151
+ let client: McpClient | null = null;
152
+ let stderr: () => string | null = () => null;
153
+ let challenge: OAuthChallenge | null = null;
154
+ let ended = false;
155
+ let delay = 1000;
156
+ let timer: ReturnType<typeof setTimeout> | undefined;
157
+ const attempted = Promise.withResolvers<void>();
158
+ let attempting: Promise<void> | null = attempted.promise;
159
+
160
+ const attempt = (): void => {
161
+ attempting = null;
162
+ attempted.resolve();
163
+ };
164
+
165
+ const set = (next: LinkState): void => {
166
+ if (ended) return;
167
+ state = next;
168
+
169
+ if (next.kind !== "connecting") attempt();
170
+ notify(pool);
171
+ };
172
+
173
+ const schedule = (wait: number): void => {
174
+ clearTimeout(timer);
175
+
176
+ if (!ended) timer = setTimeout(() => void connectTraced(), wait);
177
+ };
178
+
179
+ const retry = (): void => {
180
+ schedule(delay);
181
+ delay = Math.min(delay * 2, 30_000);
182
+ };
183
+
184
+ const drop = (message: string): void => {
185
+ const previous = client;
186
+ client = null;
187
+ void previous?.close().catch(() => undefined);
188
+
189
+ if (ended) return;
190
+ set({ kind: "failed", message, stderr: stderr() });
191
+ dev("mcp", "link failed", { server: entry.name, retryIn: delay });
192
+ retry();
193
+ };
194
+
195
+ const connect = async (): Promise<void> => {
196
+ if (link.entry.transport.kind === "stdio") await processEnvironment();
197
+
198
+ if (ended) return;
199
+
200
+ const next = new TracingMcpClient({
201
+ name: "nyte",
202
+ version: "0.1.0",
203
+ requestTimeoutMs: link.entry.timeoutMs,
204
+ });
205
+
206
+ next.onClose(() => {
207
+ if (client === next) drop(`The connection to ${entry.name} closed.`);
208
+ });
209
+
210
+ let transport: ReturnType<McpConnector>;
211
+
212
+ try {
213
+ transport = input.connect(link.entry, {
214
+ workspace: pool.workspace,
215
+ home: input.home,
216
+ secrets: input.secrets,
217
+ challenged: (next) => {
218
+ challenge = next;
219
+ },
220
+ });
221
+ } catch (error) {
222
+ set({ kind: "failed", message: errorMessage(error), stderr: null });
223
+
224
+ return;
225
+ }
226
+
227
+ try {
228
+ stderr = () =>
229
+ transport instanceof StdioTransport && transport.stderr !== ""
230
+ ? transport.stderr.slice(-STDERR_TAIL)
231
+ : null;
232
+ await next.connect(transport);
233
+
234
+ if (ended) {
235
+ await next.close();
236
+
237
+ return;
238
+ }
239
+
240
+ const refresh = async (): Promise<void> => {
241
+ const offered = await next.listTools();
242
+
243
+ if (client !== next) return;
244
+ const { usable, issues } = checkToolSchemas(entry.name, offered);
245
+
246
+ dev("mcp", "tools listed", { server: entry.name, tools: usable.length });
247
+ listed = { tools: usable, instructions: next.instructions ?? null };
248
+ set({ kind: "connected", tools: usable, issues });
249
+ };
250
+
251
+ client = next;
252
+ delay = 1000;
253
+ next.onNotification("notifications/tools/list_changed", async () => {
254
+ try {
255
+ await refresh();
256
+ } catch (error) {
257
+ if (client === next)
258
+ drop(`${entry.name} failed to list its tools: ${errorMessage(error)}`);
259
+ }
260
+ });
261
+ await refresh();
262
+ } catch (error) {
263
+ if (client === next) client = null;
264
+ await next.close().catch(() => undefined);
265
+
266
+ if (ended) return;
267
+
268
+ if (error instanceof McpOAuthAuthorizationRequiredError) {
269
+ if (state.kind !== "needs-sign-in") set({ kind: "needs-sign-in" });
270
+
271
+ return;
272
+ }
273
+
274
+ set({
275
+ kind: "failed",
276
+ message: `${entry.name} failed to connect: ${errorMessage(error)}`,
277
+ stderr: stderr(),
278
+ });
279
+ retry();
280
+ }
281
+ };
282
+
283
+ const connectTraced = (): Promise<void> =>
284
+ traced("mcp.connect", null, { server: entry.name }, connect);
285
+
286
+ const call: McpCall = (name, args, options) => {
287
+ if (client === null) throw new Error(notConnected(entry.name, state));
288
+
289
+ return client.callTool(name, args, options);
290
+ };
291
+
292
+ const link: Link = {
293
+ key: linkKey(entry),
294
+ entry,
295
+ state: () => state,
296
+ brief: () => ({
297
+ name: entry.name,
298
+ instructions: listed?.instructions ?? null,
299
+ unavailable:
300
+ listed === null && (state.kind === "failed" || state.kind === "needs-sign-in")
301
+ ? state.kind
302
+ : null,
303
+ }),
304
+ challenge: () => challenge,
305
+ tools() {
306
+ if (listed === null) return [];
307
+
308
+ if (bridged?.entry !== link.entry || bridged.listed !== listed)
309
+ bridged = {
310
+ entry: link.entry,
311
+ listed,
312
+ tools: bridgeTools(link.entry, listed.tools, call),
313
+ };
314
+
315
+ return bridged.tools;
316
+ },
317
+ attempting: () => attempting,
318
+ async close() {
319
+ ended = true;
320
+ attempt();
321
+ clearTimeout(timer);
322
+ const previous = client;
323
+ client = null;
324
+ await previous?.close().catch(() => undefined);
325
+ },
326
+ };
327
+
328
+ void connectTraced();
329
+
330
+ return link;
331
+ };
332
+
333
+ const load = async (paths: readonly string[]): Promise<void> => {
334
+ await Promise.all(
335
+ paths.map(async (path) => {
336
+ contents.set(path, await readMcpFile(path));
337
+ }),
338
+ );
339
+ };
340
+
341
+ const changed = (path: string): void => {
342
+ void load([path]).then(() => {
343
+ if (!watched.has(path)) {
344
+ contents.delete(path);
345
+
346
+ return;
347
+ }
348
+
349
+ for (const pool of pools.values())
350
+ if (pool.files.some((file) => file.path === path)) reconcile(pool);
351
+ });
352
+ };
353
+
354
+ const unfollow = (path: string): void => {
355
+ if ([...pools.values()].some((pool) => pool.files.some((file) => file.path === path))) return;
356
+ watched.get(path)?.();
357
+ watched.delete(path);
358
+ contents.delete(path);
359
+ };
360
+
361
+ const shut = (pool: Pool): void => {
362
+ clearTimeout(pool.linger);
363
+
364
+ if (pools.get(pool.key) !== pool) return;
365
+ pools.delete(pool.key);
366
+
367
+ for (const link of pool.links.values()) void link.close();
368
+ pool.links.clear();
369
+ notify(pool);
370
+
371
+ for (const file of pool.files) unfollow(file.path);
372
+ };
373
+
374
+ const idle = (pool: Pool): void => {
375
+ clearTimeout(pool.linger);
376
+
377
+ if (closed || pools.get(pool.key) !== pool || pool.holders > 0) return;
378
+
379
+ if (
380
+ [...pools.values()].some(
381
+ (other) => other !== pool && other.workspace.id === pool.workspace.id,
382
+ )
383
+ ) {
384
+ shut(pool);
385
+
386
+ return;
387
+ }
388
+
389
+ pool.linger = setTimeout(() => shut(pool), LINGER_MS);
390
+ pool.linger.unref();
391
+ };
392
+
393
+ const filesFor = (workspace: McpWorkspace): readonly Omit<McpFile, "contents">[] => [
394
+ { path: userPath, scope: "user" },
395
+ ...(workspace.trusted && workspace.root !== null
396
+ ? [{ path: join(workspace.root, ".nyte", "mcp.json"), scope: "project" as const }]
397
+ : []),
398
+ ];
399
+
400
+ const open = (workspace: McpWorkspace): Pool => {
401
+ const key = poolKey(workspace);
402
+ const known = pools.get(key);
403
+
404
+ if (known !== undefined) return known;
405
+
406
+ const files = filesFor(workspace);
407
+
408
+ const unread = files.flatMap((file) => (watched.has(file.path) ? [] : [file.path]));
409
+
410
+ for (const path of unread)
411
+ watched.set(
412
+ path,
413
+ watchPath(path, { delay: WATCH_DELAY_MS }, () => changed(path)),
414
+ );
415
+
416
+ const pool: Pool = {
417
+ key,
418
+ workspace,
419
+ files,
420
+ config: { servers: new Map(), issues: [] },
421
+ loaded: load(unread).then(() => {
422
+ pool.loading = false;
423
+
424
+ if (pools.get(key) === pool) reconcile(pool);
425
+ }),
426
+ loading: true,
427
+ links: new Map(),
428
+ listeners: new Set(),
429
+ holders: 0,
430
+ linger: undefined,
431
+ };
432
+
433
+ pools.set(key, pool);
434
+
435
+ return pool;
436
+ };
437
+
438
+ const held = async <T>(workspace: McpWorkspace, run: (pool: Pool) => Promise<T>): Promise<T> => {
439
+ if (closed) throw new SdkError("unavailable", "The host is closing.");
440
+ const pool = open(workspace);
441
+ pool.holders += 1;
442
+ idle(pool);
443
+
444
+ try {
445
+ await pool.loaded;
446
+
447
+ return await run(pool);
448
+ } finally {
449
+ pool.holders -= 1;
450
+ idle(pool);
451
+ }
452
+ };
453
+
454
+ const restart = (pool: Pool, server: string): void => {
455
+ const link = pool.links.get(server);
456
+
457
+ if (link !== undefined) {
458
+ pool.links.delete(server);
459
+ void link.close();
460
+ }
461
+
462
+ reconcile(pool);
463
+ };
464
+
465
+ const restartSignedIn = (key: string): void => {
466
+ const signed = [...pools.values()].flatMap((pool) =>
467
+ [...pool.links].flatMap(([server, link]) =>
468
+ link.entry.transport.kind === "http" && oauthKey(server, link.entry.transport.url) === key
469
+ ? [{ pool, server }]
470
+ : [],
471
+ ),
472
+ );
473
+
474
+ for (const { pool, server } of signed) restart(pool, server);
475
+ };
476
+
477
+ const configFor = async (
478
+ workspace: McpWorkspace,
479
+ replaced?: { readonly path: string; readonly text: string },
480
+ ): Promise<McpConfig> =>
481
+ parseMcpConfig(
482
+ await Promise.all(
483
+ filesFor(workspace).map(async (file) => ({
484
+ ...file,
485
+ contents:
486
+ replaced?.path === file.path
487
+ ? { kind: "text" as const, text: replaced.text }
488
+ : await readMcpFile(file.path),
489
+ })),
490
+ ),
491
+ );
492
+
493
+ const writing = new Map<string, Promise<unknown>>();
494
+
495
+ const edit = (
496
+ workspace: McpWorkspace,
497
+ path: string,
498
+ server: string,
499
+ change: Parameters<typeof planMcpEdit>[1],
500
+ check: "introduced-issues" | "none",
501
+ ): Promise<McpChange> => {
502
+ const queued = (writing.get(path) ?? Promise.resolve()).then(async (): Promise<McpChange> => {
503
+ const plan = await planMcpEdit(path, change);
504
+
505
+ if (plan.kind !== "planned") return plan;
506
+
507
+ if (check === "introduced-issues") {
508
+ const about = (config: McpConfig): readonly string[] =>
509
+ config.issues.filter((issue) => issue.path[1] === server).map((issue) => issue.message);
510
+
511
+ const before = new Set(about(await configFor(workspace)));
512
+
513
+ const introduced = about(await configFor(workspace, { path, text: plan.text })).find(
514
+ (message) => !before.has(message),
515
+ );
516
+
517
+ if (introduced !== undefined) return { kind: "invalid", message: introduced };
518
+ }
519
+
520
+ await writeMcpFile(path, plan.text);
521
+
522
+ if (watched.has(path)) contents.set(path, { kind: "text", text: plan.text });
523
+
524
+ for (const pool of pools.values())
525
+ if (pool.files.some((file) => file.path === path)) reconcile(pool);
526
+
527
+ return { kind: "updated" };
528
+ });
529
+
530
+ writing.set(
531
+ path,
532
+ queued.catch(() => undefined),
533
+ );
534
+
535
+ return queued;
536
+ };
537
+
538
+ const signingIn = new Set<string>();
539
+
540
+ const view = (pool: Pool): McpView => ({
541
+ issues: [
542
+ ...pool.config.issues,
543
+ ...[...pool.links.values()].flatMap((link) => {
544
+ const state = link.state();
545
+
546
+ return state.kind === "connected" ? state.issues : [];
547
+ }),
548
+ ],
549
+ servers: [...pool.config.servers.values()]
550
+ .toSorted((a, b) => a.name.localeCompare(b.name))
551
+ .map((entry): McpServerView => ({
552
+ name: entry.name,
553
+ scope: entry.scope,
554
+ path: entry.path,
555
+ override: entry.override,
556
+ transport:
557
+ entry.transport.kind === "stdio"
558
+ ? { kind: "stdio", command: entry.transport.command, args: entry.transport.args }
559
+ : { kind: "http", url: entry.transport.url },
560
+ exposure: entry.exposure,
561
+ description: entry.description,
562
+ state: entry.enabled
563
+ ? serverState(entry, pool.links.get(entry.name)?.state() ?? { kind: "connecting" })
564
+ : { kind: "disabled" },
565
+ })),
566
+ });
567
+
568
+ return {
569
+ watch(workspace) {
570
+ return closable<McpView>(() => {
571
+ if (closed) return { pull: async () => ({ done: true, value: undefined }), close() {} };
572
+
573
+ const pool = open(workspace);
574
+ let dirty = true;
575
+ let ended = false;
576
+ let wake: (() => void) | null = null;
577
+
578
+ const listener = (): void => {
579
+ dirty = true;
580
+ wake?.();
581
+ };
582
+
583
+ pool.holders += 1;
584
+ idle(pool);
585
+ pool.listeners.add(listener);
586
+
587
+ return {
588
+ async pull() {
589
+ await pool.loaded;
590
+
591
+ while (!dirty && !ended && !closed && pools.get(pool.key) === pool)
592
+ await new Promise<void>((resolve) => (wake = resolve));
593
+ wake = null;
594
+
595
+ if (ended || closed || pools.get(pool.key) !== pool)
596
+ return { done: true, value: undefined };
597
+ dirty = false;
598
+
599
+ return { done: false, value: view(pool) };
600
+ },
601
+ close() {
602
+ if (ended) return;
603
+ ended = true;
604
+ wake?.();
605
+ pool.listeners.delete(listener);
606
+ pool.holders -= 1;
607
+ idle(pool);
608
+ },
609
+ };
610
+ });
611
+ },
612
+ tools(workspace) {
613
+ const pool = pools.get(poolKey(workspace));
614
+
615
+ return pool === undefined ? [] : [...pool.links.values()].flatMap((link) => link.tools());
616
+ },
617
+ briefs(workspace) {
618
+ const pool = pools.get(poolKey(workspace));
619
+
620
+ return pool === undefined
621
+ ? []
622
+ : [...pool.links.values()]
623
+ .map((link) => link.brief())
624
+ .toSorted((a, b) => a.name.localeCompare(b.name));
625
+ },
626
+ async ready(workspace, signal) {
627
+ const watched = [...pools.values()].filter((pool) => pool.workspace.id === workspace);
628
+
629
+ const attempting = (pool: Pool): Promise<void>[] =>
630
+ [...pool.links.values()].flatMap((link) => link.attempting() ?? []);
631
+
632
+ const attempts = watched.flatMap((pool) =>
633
+ pool.loading
634
+ ? [pool.loaded.then(() => Promise.all(attempting(pool))).then(() => undefined)]
635
+ : attempting(pool),
636
+ );
637
+
638
+ if (attempts.length === 0 || signal.aborted) return false;
639
+ const aborted = Promise.withResolvers<void>();
640
+
641
+ signal.addEventListener("abort", () => aborted.resolve(), { once: true });
642
+ await Promise.race([Promise.all(attempts), aborted.promise]);
643
+
644
+ return true;
645
+ },
646
+ reconnect: (workspace, server) =>
647
+ held(workspace, async (pool) => {
648
+ const entry = pool.config.servers.get(server);
649
+
650
+ if (entry === undefined) return { kind: "missing" };
651
+
652
+ if (!entry.enabled) return { kind: "invalid", message: `${server} is turned off.` };
653
+ restart(pool, server);
654
+
655
+ return { kind: "updated" };
656
+ }),
657
+ async setEnabled(workspace, server, enabled) {
658
+ const entry = (await configFor(workspace)).servers.get(server);
659
+
660
+ if (entry === undefined) return { kind: "missing" };
661
+ const override = entry.override !== null;
662
+
663
+ return edit(
664
+ workspace,
665
+ entry.override ?? entry.path,
666
+ server,
667
+ (servers) => {
668
+ const current = servers[server];
669
+
670
+ if (!isJsonObject(current)) return null;
671
+ const { enabled: _previous, ...rest } = current;
672
+
673
+ return {
674
+ ...servers,
675
+ [server]: override
676
+ ? { ...rest, enabled }
677
+ : enabled
678
+ ? rest
679
+ : { ...rest, enabled: false },
680
+ };
681
+ },
682
+ "introduced-issues",
683
+ );
684
+ },
685
+ login(workspace, server) {
686
+ let begun: Promise<Login> | null = null;
687
+
688
+ const begin = (): Promise<Login> =>
689
+ (begun ??= held(workspace, async (pool): Promise<Login> => {
690
+ const entry = pool.config.servers.get(server);
691
+
692
+ if (entry === undefined) return failedLogin(`No MCP server is named ${server}.`);
693
+ const { transport } = entry;
694
+
695
+ if (transport.kind !== "http")
696
+ return failedLogin(`${server} runs a command, so it has nothing to sign in to.`);
697
+
698
+ if (Object.keys(transport.headers).some((name) => name.toLowerCase() === "authorization"))
699
+ return failedLogin(`${server} sends its own Authorization header.`);
700
+ const key = oauthKey(server, transport.url);
701
+
702
+ if (signingIn.has(key)) return failedLogin(`A sign-in to ${server} is already running.`);
703
+ let settings: ReturnType<typeof oauthSettings>;
704
+
705
+ try {
706
+ settings = oauthSettings(transport.oauth, server);
707
+ } catch (error) {
708
+ return failedLogin(errorMessage(error));
709
+ }
710
+
711
+ signingIn.add(key);
712
+ pool.holders += 1;
713
+
714
+ return signIn({
715
+ server,
716
+ serverUrl: transport.url,
717
+ store: oauthStore(input.secrets, key),
718
+ settings,
719
+ challenge: pool.links.get(server)?.challenge() ?? null,
720
+ signedIn: () => restartSignedIn(key),
721
+ ended: () => {
722
+ signingIn.delete(key);
723
+ pool.holders -= 1;
724
+ idle(pool);
725
+ },
726
+ });
727
+ }));
728
+
729
+ return {
730
+ steps: () => forwarded(async () => (await begin()).steps()),
731
+ async answer(code) {
732
+ if (begun === null)
733
+ throw new SdkError("invalid", "Read the login's steps before answering.");
734
+ await (await begun).answer(code);
735
+ },
736
+ };
737
+ },
738
+ async logout(workspace, server) {
739
+ const entry = (await configFor(workspace)).servers.get(server);
740
+
741
+ if (entry === undefined) return { kind: "missing" };
742
+
743
+ if (entry.transport.kind !== "http")
744
+ return { kind: "invalid", message: `${server} runs a command and has no sign-in.` };
745
+ const key = oauthKey(server, entry.transport.url);
746
+ await input.secrets.delete(key);
747
+ restartSignedIn(key);
748
+
749
+ return { kind: "updated" };
750
+ },
751
+ async add(workspace, server, config, scope) {
752
+ const path = filesFor(workspace).find((file) => file.scope === scope)?.path;
753
+
754
+ if (path === undefined) return UNTRUSTED;
755
+
756
+ if (!isJson(config)) return { kind: "invalid", message: "The server's config is not JSON." };
757
+
758
+ return edit(
759
+ workspace,
760
+ path,
761
+ server,
762
+ (servers) => ({ ...servers, [server]: config }),
763
+ "introduced-issues",
764
+ );
765
+ },
766
+ async remove(workspace, server, scope) {
767
+ const path = filesFor(workspace).find((file) => file.scope === scope)?.path;
768
+
769
+ if (path === undefined) return UNTRUSTED;
770
+
771
+ return edit(
772
+ workspace,
773
+ path,
774
+ server,
775
+ (servers) => {
776
+ if (!(server in servers)) return null;
777
+ const { [server]: _removed, ...rest } = servers;
778
+
779
+ return rest;
780
+ },
781
+ "none",
782
+ );
783
+ },
784
+ async close() {
785
+ closed = true;
786
+ const live = [...pools.values()];
787
+ const links = live.flatMap((pool) => [...pool.links.values()]);
788
+
789
+ for (const pool of live) shut(pool);
790
+ await Promise.all(links.map((link) => link.close()));
791
+ },
792
+ };
793
+ }
794
+
795
+ function serverState(entry: McpServerEntry, state: LinkState): McpServerState {
796
+ if (state.kind !== "connected") return state;
797
+
798
+ return {
799
+ kind: "connected",
800
+ tools: state.tools.map((tool) => ({
801
+ name: tool.name,
802
+ description: tool.description ?? tool.title ?? tool.name,
803
+ exposure: toolExposure(entry, tool.name),
804
+ })),
805
+ };
806
+ }
807
+
808
+ function notConnected(server: string, state: LinkState): string {
809
+ if (state.kind === "failed") return `${server} is not connected: ${state.message}`;
810
+
811
+ if (state.kind === "needs-sign-in")
812
+ return `${server} needs the user to sign in again before its tools work.`;
813
+
814
+ return `${server} is reconnecting; try the call again shortly.`;
815
+ }
816
+
817
+ function poolKey(workspace: McpWorkspace): string {
818
+ return JSON.stringify([workspace.id, workspace.root, workspace.trusted]);
819
+ }
820
+
821
+ function linkKey(entry: McpServerEntry): string {
822
+ return JSON.stringify([entry.transport, entry.timeoutMs]);
823
+ }
824
+
825
+ function errorMessage(error: unknown): string {
826
+ return error instanceof Error ? error.message : String(error);
827
+ }
828
+
829
+ function failedLogin(reason: string): Login {
830
+ return {
831
+ async *steps() {
832
+ yield { kind: "failed", reason };
833
+ },
834
+ answer: async () => undefined,
835
+ };
836
+ }