@ikarmic/forge-core 5.0.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/dist/agent/agent.d.ts +8 -0
  2. package/dist/agent/agent.js +123 -22
  3. package/dist/agent/background.d.ts +58 -0
  4. package/dist/agent/background.js +139 -0
  5. package/dist/agent/enhance-prompt.d.ts +3 -1
  6. package/dist/agent/enhance-prompt.js +6 -2
  7. package/dist/agent/goal.d.ts +54 -0
  8. package/dist/agent/goal.js +65 -0
  9. package/dist/agents/agent-files.d.ts +43 -0
  10. package/dist/agents/agent-files.js +109 -0
  11. package/dist/checkpoints/store.js +5 -1
  12. package/dist/commands/custom-commands.d.ts +9 -4
  13. package/dist/commands/custom-commands.js +13 -2
  14. package/dist/config-file.d.ts +43 -0
  15. package/dist/config-file.js +249 -5
  16. package/dist/config-merge.d.ts +13 -0
  17. package/dist/config-merge.js +67 -0
  18. package/dist/config-schema.d.ts +340 -0
  19. package/dist/config-schema.js +126 -0
  20. package/dist/config.js +2 -1
  21. package/dist/context/compaction.js +4 -0
  22. package/dist/context/mask.js +1 -1
  23. package/dist/custom-providers.d.ts +95 -0
  24. package/dist/custom-providers.js +120 -0
  25. package/dist/git/auto-commit.d.ts +2 -2
  26. package/dist/git/auto-commit.js +5 -4
  27. package/dist/github/github-tools.d.ts +2 -2
  28. package/dist/hooks/runner.js +3 -2
  29. package/dist/import/claude-code.d.ts +39 -0
  30. package/dist/import/claude-code.js +254 -0
  31. package/dist/index.d.ts +32 -4
  32. package/dist/index.js +24 -3
  33. package/dist/lsp/client.d.ts +5 -0
  34. package/dist/lsp/client.js +39 -3
  35. package/dist/mcp/index.d.ts +5 -1
  36. package/dist/mcp/index.js +3 -1
  37. package/dist/mcp/manager.d.ts +17 -3
  38. package/dist/mcp/manager.js +27 -2
  39. package/dist/mcp/marketplace.d.ts +2 -0
  40. package/dist/mcp/marketplace.js +10 -4
  41. package/dist/mcp/mcp-client.d.ts +12 -2
  42. package/dist/mcp/mcp-client.js +74 -17
  43. package/dist/mcp/mcp-config.d.ts +2 -0
  44. package/dist/mcp/mcp-config.js +1 -0
  45. package/dist/mcp/oauth.d.ts +55 -0
  46. package/dist/mcp/oauth.js +197 -0
  47. package/dist/mcp/resource-tools.d.ts +8 -0
  48. package/dist/mcp/resource-tools.js +56 -0
  49. package/dist/memory/session-digest.d.ts +12 -0
  50. package/dist/memory/session-digest.js +47 -0
  51. package/dist/memory/stale.js +2 -1
  52. package/dist/model-alias.d.ts +18 -2
  53. package/dist/model-alias.js +27 -3
  54. package/dist/model-catalog.js +0 -1
  55. package/dist/model-list.js +2 -1
  56. package/dist/model-overrides.d.ts +30 -0
  57. package/dist/model-overrides.js +48 -0
  58. package/dist/orchestrator/specialists-registry.js +2 -3
  59. package/dist/orchestrator/specialists.js +4 -4
  60. package/dist/policy/ca-certs.d.ts +8 -0
  61. package/dist/policy/ca-certs.js +30 -0
  62. package/dist/policy/proxy.d.ts +10 -3
  63. package/dist/policy/proxy.js +33 -6
  64. package/dist/pricing.js +4 -0
  65. package/dist/providers.d.ts +7 -0
  66. package/dist/providers.js +39 -5
  67. package/dist/semantic/embedder.d.ts +15 -0
  68. package/dist/semantic/embedder.js +25 -0
  69. package/dist/semantic/semantic-index.d.ts +10 -2
  70. package/dist/semantic/semantic-index.js +16 -7
  71. package/dist/sessions/session-store.js +2 -1
  72. package/dist/skills/builtin.d.ts +2 -0
  73. package/dist/skills/builtin.js +68 -0
  74. package/dist/skills/index.d.ts +2 -0
  75. package/dist/skills/index.js +2 -0
  76. package/dist/skills/loader.d.ts +10 -3
  77. package/dist/skills/loader.js +20 -7
  78. package/dist/skills/marketplace.js +2 -1
  79. package/dist/skills/remote.d.ts +14 -0
  80. package/dist/skills/remote.js +131 -0
  81. package/dist/skills/types.d.ts +10 -0
  82. package/dist/steering/extra-instructions.d.ts +8 -0
  83. package/dist/steering/extra-instructions.js +104 -0
  84. package/dist/steering/folder-instructions.d.ts +8 -0
  85. package/dist/steering/folder-instructions.js +28 -0
  86. package/dist/steering/select.js +2 -1
  87. package/dist/timed-fetch.d.ts +12 -0
  88. package/dist/timed-fetch.js +83 -0
  89. package/dist/tools/apply-patch.d.ts +52 -0
  90. package/dist/tools/apply-patch.js +197 -0
  91. package/dist/tools/ask-user.d.ts +27 -0
  92. package/dist/tools/ask-user.js +32 -0
  93. package/dist/tools/browser-tools.d.ts +6 -6
  94. package/dist/tools/browser-tools.js +3 -13
  95. package/dist/tools/codebase-search.d.ts +4 -2
  96. package/dist/tools/codebase-search.js +2 -2
  97. package/dist/tools/edit-file.js +5 -3
  98. package/dist/tools/fetch-url.d.ts +7 -0
  99. package/dist/tools/fetch-url.js +52 -19
  100. package/dist/tools/find-symbol.d.ts +2 -2
  101. package/dist/tools/ignore-rules.js +2 -1
  102. package/dist/tools/index.d.ts +5 -1
  103. package/dist/tools/index.js +10 -1
  104. package/dist/tools/memory.d.ts +6 -6
  105. package/dist/tools/notebook.d.ts +2 -2
  106. package/dist/tools/permission-rules.d.ts +32 -0
  107. package/dist/tools/permission-rules.js +182 -0
  108. package/dist/tools/read-file.d.ts +5 -0
  109. package/dist/tools/read-file.js +7 -2
  110. package/dist/tools/read-only.d.ts +1 -1
  111. package/dist/tools/read-only.js +2 -0
  112. package/dist/tools/run-command.d.ts +4 -2
  113. package/dist/tools/run-command.js +12 -8
  114. package/dist/tools/sandbox-loader.js +3 -13
  115. package/dist/tools/search-files.d.ts +8 -2
  116. package/dist/tools/search-files.js +43 -0
  117. package/dist/tools/shared.d.ts +5 -0
  118. package/dist/tools/shared.js +30 -2
  119. package/dist/tools/shell-env.d.ts +7 -0
  120. package/dist/tools/shell-env.js +9 -0
  121. package/dist/tools/sub-agent-wait.d.ts +29 -0
  122. package/dist/tools/sub-agent-wait.js +60 -0
  123. package/dist/tools/sub-agent.d.ts +50 -4
  124. package/dist/tools/sub-agent.js +210 -125
  125. package/dist/tools/text-encoding.d.ts +10 -0
  126. package/dist/tools/text-encoding.js +40 -0
  127. package/dist/tools/todo.d.ts +36 -0
  128. package/dist/tools/todo.js +42 -0
  129. package/dist/tools/write-file.js +15 -9
  130. package/dist/tools/write-tools.d.ts +12 -0
  131. package/dist/tools/write-tools.js +18 -0
  132. package/dist/types.d.ts +73 -0
  133. package/dist/worktree/worktree.d.ts +3 -0
  134. package/dist/worktree/worktree.js +22 -0
  135. package/package.json +1 -1
@@ -1,3 +1,8 @@
1
+ /**
2
+ * The absolute path of `command` found on PATH (with PATHEXT on Windows), or undefined. Never the current directory.
3
+ * A command that already names a path is returned as it is.
4
+ */
5
+ export declare function resolveOnPath(command: string, env: NodeJS.ProcessEnv, platform: NodeJS.Platform): string | undefined;
1
6
  export type LspSeverity = 'error' | 'warning' | 'info' | 'hint';
2
7
  export interface LspDiagnostic {
3
8
  /** 1-based. */
@@ -4,6 +4,37 @@
4
4
  * JSON-RPC 2.0 with `Content-Length` framing is small enough to own.
5
5
  */
6
6
  import { spawn } from 'node:child_process';
7
+ import { statSync } from 'node:fs';
8
+ import path from 'node:path';
9
+ /**
10
+ * The absolute path of `command` found on PATH (with PATHEXT on Windows), or undefined. Never the current directory.
11
+ * A command that already names a path is returned as it is.
12
+ */
13
+ export function resolveOnPath(command, env, platform) {
14
+ if (/[\\/]/.test(command))
15
+ return command;
16
+ const isFile = (p) => {
17
+ try {
18
+ return statSync(p).isFile();
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ };
24
+ const win = platform === 'win32';
25
+ const dirs = (env.PATH ?? env.Path ?? '').split(win ? ';' : path.delimiter).filter(Boolean);
26
+ const exts = win ? ['', ...(env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean).map((e) => e.toLowerCase())] : [''];
27
+ for (const dir of dirs) {
28
+ for (const ext of exts) {
29
+ if (win && ext === '' && !/\.[a-z0-9]+$/i.test(command))
30
+ continue; // Windows runs nothing without an extension
31
+ const candidate = path.join(dir, command + ext);
32
+ if (isFile(candidate))
33
+ return candidate;
34
+ }
35
+ }
36
+ return undefined;
37
+ }
7
38
  import { fileURLToPath, pathToFileURL } from 'node:url';
8
39
  /**
9
40
  * Servers echo document URIs in their own spelling (`file:///c%3A/x` for the
@@ -45,9 +76,14 @@ export class LspClient {
45
76
  * the spawn error (e.g. ENOENT) when the server binary is not installed.
46
77
  */
47
78
  static async start(spec, rootDir, timeoutMs) {
48
- // Bare names on Windows are usually npm `.cmd` shims, which need a shell; explicit paths must not go through one.
49
- const shell = process.platform === 'win32' && !/[\\/]/.test(spec.command);
50
- const proc = spawn(spec.command, spec.args ?? [], { cwd: rootDir, stdio: ['pipe', 'pipe', 'pipe'], shell });
79
+ // The server runs in the workspace, and Windows looks in the current directory before PATH: a repository could
80
+ // ship its own `typescript-language-server.cmd`. So a bare name is resolved on PATH first, never in the workspace.
81
+ const command = resolveOnPath(spec.command, process.env, process.platform);
82
+ if (!command)
83
+ throw Object.assign(new Error(`${spec.command} is not installed (not found on PATH)`), { code: 'ENOENT' });
84
+ // npm `.cmd` shims need a shell on Windows; the path is quoted because it is now absolute and may contain spaces.
85
+ const shell = process.platform === 'win32' && /\.(cmd|bat)$/i.test(command);
86
+ const proc = spawn(shell ? `"${command}"` : command, spec.args ?? [], { cwd: rootDir, stdio: ['pipe', 'pipe', 'pipe'], shell });
51
87
  const spawned = new Promise((_, reject) => proc.once('error', reject));
52
88
  const client = new LspClient(proc);
53
89
  const rootUri = pathToFileURL(rootDir).href;
@@ -1,7 +1,11 @@
1
1
  export { loadMcpConfig, parseMcpConfig } from './mcp-config.js';
2
2
  export type { McpConfigFile, McpServerSpec } from './mcp-config.js';
3
3
  export { jsonSchemaToZod } from './json-schema-to-zod.js';
4
- export { connectAll, connectOne, closeAll, mcpResultToValue } from './mcp-client.js';
4
+ export { connectAll, connectOne, closeAll, mcpInstructionsBlock, mcpResultToValue, McpAuthRequiredError } from './mcp-client.js';
5
+ export type { ConnectOptions } from './mcp-client.js';
6
+ export { forgetSavedAuth, defaultMcpAuthDir } from './oauth.js';
7
+ export type { McpOAuthOptions } from './oauth.js';
8
+ export { createMcpResourceTools } from './resource-tools.js';
5
9
  export { BUILTIN_MCP_CATALOG, loadMcpCatalog } from './catalog.js';
6
10
  export { McpManager, expandSpec } from './manager.js';
7
11
  export type { McpManagerOptions, McpServerState, McpServerStatus } from './manager.js';
package/dist/mcp/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export { loadMcpConfig, parseMcpConfig } from './mcp-config.js';
2
2
  export { jsonSchemaToZod } from './json-schema-to-zod.js';
3
- export { connectAll, connectOne, closeAll, mcpResultToValue } from './mcp-client.js';
3
+ export { connectAll, connectOne, closeAll, mcpInstructionsBlock, mcpResultToValue, McpAuthRequiredError } from './mcp-client.js';
4
+ export { forgetSavedAuth, defaultMcpAuthDir } from './oauth.js';
5
+ export { createMcpResourceTools } from './resource-tools.js';
4
6
  export { BUILTIN_MCP_CATALOG, loadMcpCatalog } from './catalog.js';
5
7
  export { McpManager, expandSpec } from './manager.js';
6
8
  export { assertValidServerName, registerMcpServer, setMcpServerDisabled } from './registry.js';
@@ -1,8 +1,9 @@
1
1
  import type { ToolDefinition } from '../types.js';
2
- import { type ConnectedMcpServer } from './mcp-client.js';
2
+ import { type ConnectOptions, type ConnectedMcpServer } from './mcp-client.js';
3
+ import type { McpOAuthOptions } from './oauth.js';
3
4
  import type { McpServerSpec } from './mcp-config.js';
4
5
  import { type OrgPolicy } from '../policy/policy.js';
5
- export type McpServerState = 'connected' | 'failed' | 'disabled';
6
+ export type McpServerState = 'connected' | 'failed' | 'disabled' | 'needs-auth';
6
7
  export interface McpServerStatus {
7
8
  name: string;
8
9
  state: McpServerState;
@@ -24,8 +25,13 @@ export interface McpManagerOptions {
24
25
  connectTimeoutMs?: number;
25
26
  /** Organization policy for the MCP allowlist. Unset = the managed policy file; null = none. */
26
27
  policy?: OrgPolicy | null;
28
+ /**
29
+ * OAuth sign-in for HTTP servers that want it. Without it such a server is simply "failed" (401). With it, saved
30
+ * tokens are used and a server without them is listed as "needs-auth" until {@link McpManager.signIn} runs.
31
+ */
32
+ oauth?: Omit<McpOAuthOptions, 'interactive'>;
27
33
  /** Test seam. */
28
- connect?: (name: string, spec: McpServerSpec) => Promise<ConnectedMcpServer>;
34
+ connect?: (name: string, spec: McpServerSpec, options?: ConnectOptions) => Promise<ConnectedMcpServer>;
29
35
  }
30
36
  /** Replace `${workspaceFolder}` in args, env values, url and headers. */
31
37
  export declare function expandSpec(spec: McpServerSpec, cwd: string): McpServerSpec;
@@ -41,8 +47,16 @@ export declare class McpManager {
41
47
  ensureConnected(): Promise<void>;
42
48
  /** Tools from all connected servers (call {@link ensureConnected} first). */
43
49
  tools(): ToolDefinition[];
50
+ /** The servers that are connected right now (call {@link ensureConnected} first). */
51
+ servers(): readonly ConnectedMcpServer[];
44
52
  status(): McpServerStatus[];
45
53
  /** Drop connections and re-read the config files. */
54
+ private readonly specs;
55
+ /**
56
+ * Sign in to a server listed as `needs-auth`: opens the user's browser, waits for them to finish (up to the sign-in
57
+ * timeout), saves the tokens, then reconnects everything so the server's tools appear.
58
+ */
59
+ signIn(name: string): Promise<void>;
46
60
  reload(): Promise<void>;
47
61
  dispose(): Promise<void>;
48
62
  private readScope;
@@ -5,7 +5,7 @@
5
5
  * reported in `status()` instead of only written to stderr.
6
6
  */
7
7
  import path from 'node:path';
8
- import { closeAll, connectOne } from './mcp-client.js';
8
+ import { McpAuthRequiredError, closeAll, connectOne } from './mcp-client.js';
9
9
  import { loadMcpConfig } from './mcp-config.js';
10
10
  import { defaultUserMcpPath } from './marketplace.js';
11
11
  import { filterMcpServers, getOrgPolicy } from '../policy/policy.js';
@@ -50,10 +50,32 @@ export class McpManager {
50
50
  tools() {
51
51
  return this.connected.flatMap((s) => s.tools);
52
52
  }
53
+ /** The servers that are connected right now (call {@link ensureConnected} first). */
54
+ servers() {
55
+ return this.connected;
56
+ }
53
57
  status() {
54
58
  return [...this.statuses];
55
59
  }
56
60
  /** Drop connections and re-read the config files. */
61
+ specs = new Map();
62
+ /**
63
+ * Sign in to a server listed as `needs-auth`: opens the user's browser, waits for them to finish (up to the sign-in
64
+ * timeout), saves the tokens, then reconnects everything so the server's tools appear.
65
+ */
66
+ async signIn(name) {
67
+ const oauth = this.options.oauth;
68
+ const spec = this.specs.get(name);
69
+ if (!oauth)
70
+ throw new Error('sign-in is not available here');
71
+ if (!spec)
72
+ throw new Error(`no MCP server named "${name}"`);
73
+ const connect = this.options.connect ?? connectOne;
74
+ const wait = oauth.signInTimeoutMs ?? 5 * 60_000;
75
+ const server = await withTimeout(connect(name, expandSpec(spec, this.options.cwd), { oauth: { ...oauth, interactive: true } }), wait + (this.options.connectTimeoutMs ?? 30_000), `Signing in to MCP server "${name}"`);
76
+ await closeAll([server]);
77
+ await this.reload();
78
+ }
57
79
  async reload() {
58
80
  await this.dispose();
59
81
  await this.ensureConnected();
@@ -110,10 +132,13 @@ export class McpManager {
110
132
  if (spec.disabled)
111
133
  return { status: { name, state: 'disabled', scope, toolCount: 0 } };
112
134
  try {
113
- const server = await withTimeout(connect(name, expandSpec(spec, cwd)), timeoutMs, `MCP server "${name}"`);
135
+ this.specs.set(name, spec);
136
+ const server = await withTimeout(connect(name, expandSpec(spec, cwd), this.options.oauth ? { oauth: { ...this.options.oauth, interactive: false } } : undefined), timeoutMs, `MCP server "${name}"`);
114
137
  return { server, status: { name, state: 'connected', scope, toolCount: server.tools.length } };
115
138
  }
116
139
  catch (err) {
140
+ if (err instanceof McpAuthRequiredError)
141
+ return { status: { name, state: 'needs-auth', scope, toolCount: 0, error: err.message } };
117
142
  return { status: { name, state: 'failed', scope, toolCount: 0, error: err.message } };
118
143
  }
119
144
  }));
@@ -55,6 +55,8 @@ export declare function installDirNameFor(source: {
55
55
  * server" message instead of silently registering nothing.
56
56
  */
57
57
  export declare function probeRepoForServer(repoPath: string, subdir?: string): Promise<McpServerSpec>;
58
+ /** `--` ends git's option parsing: a source that starts with `-` (`--upload-pack=…`) must never be read as an option. */
59
+ export declare function gitCloneArgs(url: string, target: string): string[];
58
60
  /**
59
61
  * Install an MCP server from a catalog entry or a raw GitHub URL.
60
62
  * Returns the {@link InstalledMcpServer} describing what was installed.
@@ -27,6 +27,8 @@ import { homedir } from 'node:os';
27
27
  import path from 'node:path';
28
28
  import { promisify } from 'node:util';
29
29
  import { parseMcpConfig } from './mcp-config.js';
30
+ import { isOutsideRel } from '../tools/shared.js';
31
+ import { updateJsonFile } from '../config-file.js';
30
32
  const execFileP = promisify(execFile);
31
33
  /** Path to the cache root (caller-controlled so tests can inject a temp dir). */
32
34
  export function defaultCacheRoot() {
@@ -152,10 +154,14 @@ export async function probeRepoForServer(repoPath, subdir) {
152
154
  }
153
155
  /** Side-effect: clone the source into the cache dir. */
154
156
  async function gitClone(url, target) {
155
- await execFileP('git', ['clone', '--depth', '1', '--quiet', url, target], {
157
+ await execFileP('git', gitCloneArgs(url, target), {
156
158
  windowsHide: true,
157
159
  });
158
160
  }
161
+ /** `--` ends git's option parsing: a source that starts with `-` (`--upload-pack=…`) must never be read as an option. */
162
+ export function gitCloneArgs(url, target) {
163
+ return ['clone', '--depth', '1', '--quiet', '--', url, target];
164
+ }
159
165
  /**
160
166
  * Install an MCP server from a catalog entry or a raw GitHub URL.
161
167
  * Returns the {@link InstalledMcpServer} describing what was installed.
@@ -197,7 +203,7 @@ export async function installMcpServer(params) {
197
203
  // consequences of a bypass (arbitrary recursive delete) are severe.
198
204
  const installPath = path.join(cacheRoot, name);
199
205
  const relInstall = path.relative(cacheRoot, installPath);
200
- if (relInstall.startsWith('..') || path.isAbsolute(relInstall)) {
206
+ if (isOutsideRel(relInstall)) {
201
207
  throw new Error(`Refusing to install "${name}": resolved path escapes the cache root.`);
202
208
  }
203
209
  if (existsSync(installPath)) {
@@ -260,8 +266,8 @@ export async function readUserMcpRegistry(userMcpPath) {
260
266
  }
261
267
  /** Write the user's MCP registry, creating parent directories as needed. */
262
268
  export async function writeUserMcpRegistry(userMcpPath, config) {
263
- await mkdir(path.dirname(userMcpPath), { recursive: true });
264
- await writeFile(userMcpPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
269
+ // Atomic, and one writer at a time (see updateJsonFile).
270
+ await updateJsonFile(userMcpPath, () => config);
265
271
  }
266
272
  /** Remove an installed MCP server by name. Idempotent: succeeds even when absent. */
267
273
  export async function uninstallMcpServer(params) {
@@ -1,4 +1,5 @@
1
1
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { type McpOAuthOptions } from './oauth.js';
2
3
  import type { McpConfigFile, McpServerSpec } from './mcp-config.js';
3
4
  import type { ToolDefinition } from '../types.js';
4
5
  /**
@@ -9,11 +10,20 @@ export interface ConnectedMcpServer {
9
10
  name: string;
10
11
  client: Client;
11
12
  tools: ToolDefinition[];
13
+ /** What the server says about using it (from `initialize`); shown to the model next to its tools. */
14
+ instructions?: string | undefined;
12
15
  }
13
16
  /** Connect to every server in a parsed MCP config. Failed connections are skipped with a warning. */
14
- export declare function connectAll(config: McpConfigFile): Promise<ConnectedMcpServer[]>;
17
+ export declare function connectAll(config: McpConfigFile, options?: ConnectOptions): Promise<ConnectedMcpServer[]>;
18
+ export { McpAuthRequiredError } from './oauth.js';
19
+ export interface ConnectOptions {
20
+ /** Let an HTTP server that wants OAuth sign-in use saved tokens, and (with `interactive`) ask the user to sign in. */
21
+ oauth?: McpOAuthOptions;
22
+ }
15
23
  /** Connect to a single server and pull its tools. */
16
- export declare function connectOne(name: string, spec: McpServerSpec): Promise<ConnectedMcpServer>;
24
+ export declare function connectOne(name: string, spec: McpServerSpec, options?: ConnectOptions): Promise<ConnectedMcpServer>;
25
+ /** The instructions servers sent with `initialize`, as one prompt block (empty when none sent any). */
26
+ export declare function mcpInstructionsBlock(servers: readonly ConnectedMcpServer[]): string;
17
27
  /** Close all clients and their underlying transports. */
18
28
  export declare function closeAll(servers: ConnectedMcpServer[]): Promise<void>;
19
29
  /**
@@ -24,12 +24,14 @@
24
24
  import { readFileSync } from 'node:fs';
25
25
  import { dirname, join } from 'node:path';
26
26
  import { fileURLToPath } from 'node:url';
27
+ import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js';
27
28
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
28
29
  import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
29
30
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
30
31
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
31
32
  import { filterMcpServers, getOrgPolicy } from '../policy/policy.js';
32
33
  import { jsonSchemaToZod } from './json-schema-to-zod.js';
34
+ import { ForgeOAuthProvider, McpAuthRequiredError, defaultMcpAuthDir, readSavedAuth } from './oauth.js';
33
35
  /**
34
36
  * Self-identifying info we send to every MCP server during the `initialize`
35
37
  * handshake. Version is read from our own package.json so it stays in sync
@@ -53,7 +55,7 @@ function readPackageVersion() {
53
55
  }
54
56
  }
55
57
  /** Connect to every server in a parsed MCP config. Failed connections are skipped with a warning. */
56
- export async function connectAll(config) {
58
+ export async function connectAll(config, options = {}) {
57
59
  const { allowed, blocked } = filterMcpServers(config.mcpServers, getOrgPolicy()?.policy);
58
60
  for (const name of blocked) {
59
61
  process.stderr.write(`forge: MCP server "${name}" is blocked by organization policy
@@ -65,10 +67,14 @@ export async function connectAll(config) {
65
67
  if (spec.disabled)
66
68
  continue;
67
69
  try {
68
- const server = await connectOne(name, spec);
70
+ const server = await connectOne(name, spec, options);
69
71
  results.push(server);
70
72
  }
71
73
  catch (err) {
74
+ if (err instanceof McpAuthRequiredError) {
75
+ process.stderr.write(`forge: MCP server "${name}" needs you to sign in. Run: forge mcp login ${name}\n`);
76
+ continue;
77
+ }
72
78
  // Skip failed servers so one bad entry doesn't take down the whole
73
79
  // session; surface the error so the caller can log it.
74
80
  process.stderr.write(`forge: MCP server "${name}" failed to connect: ${err.message}\n`);
@@ -76,21 +82,68 @@ export async function connectAll(config) {
76
82
  }
77
83
  return results;
78
84
  }
85
+ export { McpAuthRequiredError } from './oauth.js';
86
+ /** A 401 from a server we connected to without credentials. */
87
+ function isUnauthorized(err) {
88
+ const e = err;
89
+ return err instanceof UnauthorizedError || e?.code === 401 || (typeof e?.message === 'string' && /\b401\b/.test(e.message));
90
+ }
79
91
  /** Connect to a single server and pull its tools. */
80
- export async function connectOne(name, spec) {
81
- const transport = buildTransport(spec);
82
- const client = new Client(CLIENT_INFO, { capabilities: {} });
83
- await client.connect(transport);
84
- const { tools: mcpTools } = await client.listTools();
85
- const tools = mcpTools.map((t) => mcpToolToForgeTool(name, t, client));
86
- return { name, client, tools };
92
+ export async function connectOne(name, spec, options = {}) {
93
+ const timeout = spec.timeout ? { timeout: spec.timeout } : undefined;
94
+ let client = new Client(CLIENT_INFO, { capabilities: {} });
95
+ const oauth = options.oauth;
96
+ // A static Authorization header is the user's own choice of credentials: leave OAuth out of it.
97
+ const hasStaticAuth = Object.keys(spec.headers ?? {}).some((h) => h.toLowerCase() === 'authorization');
98
+ const wantsOAuth = Boolean(oauth && spec.url && !spec.command && !hasStaticAuth);
99
+ if (!wantsOAuth) {
100
+ await client.connect(buildTransport(spec), timeout);
101
+ }
102
+ else {
103
+ const url = spec.url;
104
+ const haveTokens = Boolean((await readSavedAuth(oauth.storeDir ?? defaultMcpAuthDir(), url))?.tokens);
105
+ let provider;
106
+ if (haveTokens || oauth.interactive) {
107
+ provider = await ForgeOAuthProvider.create(name, url, oauth);
108
+ }
109
+ let transport = buildTransport(spec, provider);
110
+ try {
111
+ await client.connect(transport, timeout);
112
+ }
113
+ catch (err) {
114
+ if (!provider) {
115
+ // Tried without credentials and the server refused: it needs a sign-in the user has not done yet.
116
+ if (isUnauthorized(err))
117
+ throw new McpAuthRequiredError(name);
118
+ throw err;
119
+ }
120
+ if (!(err instanceof UnauthorizedError))
121
+ throw err;
122
+ // The SDK started the sign-in (the browser is open); wait for the user, then connect again with the tokens.
123
+ try {
124
+ const code = await provider.waitForCode();
125
+ await transport.finishAuth(code);
126
+ }
127
+ catch (signInErr) {
128
+ provider.cancel();
129
+ throw signInErr;
130
+ }
131
+ await client.close().catch(() => undefined);
132
+ client = new Client(CLIENT_INFO, { capabilities: {} });
133
+ transport = buildTransport(spec, provider);
134
+ await client.connect(transport, timeout);
135
+ }
136
+ }
137
+ const { tools: mcpTools } = await client.listTools(undefined, spec.timeout ? { timeout: spec.timeout } : undefined);
138
+ const tools = mcpTools.map((t) => mcpToolToForgeTool(name, t, client, spec.timeout));
139
+ return { name, client, tools, instructions: client.getInstructions()?.trim() || undefined };
87
140
  }
88
141
  /**
89
142
  * Pick the right SDK transport for the server spec. The transport type is
90
143
  * either explicit (`type: 'sse'` etc.) or inferred from the shape of the
91
144
  * spec: `command` present ⇒ stdio, `url` present ⇒ SSE.
92
145
  */
93
- function buildTransport(spec) {
146
+ function buildTransport(spec, authProvider) {
94
147
  const explicit = spec.type;
95
148
  const isStdio = explicit === 'stdio' || (!explicit && Boolean(spec.command));
96
149
  const isSse = explicit === 'sse' || (!explicit && Boolean(spec.url) && !explicit);
@@ -118,10 +171,17 @@ function buildTransport(spec) {
118
171
  const headers = spec.headers ?? {};
119
172
  const requestInit = headers ? { headers } : {};
120
173
  if (explicit === 'streamable-http') {
121
- return new StreamableHTTPClientTransport(new URL(spec.url), { requestInit });
174
+ return new StreamableHTTPClientTransport(new URL(spec.url), { requestInit, ...(authProvider ? { authProvider } : {}) });
122
175
  }
123
176
  // Default for HTTP-transport specs: SSE (preserves prior behavior).
124
- return new SSEClientTransport(new URL(spec.url), { requestInit });
177
+ return new SSEClientTransport(new URL(spec.url), { requestInit, ...(authProvider ? { authProvider } : {}) });
178
+ }
179
+ /** The instructions servers sent with `initialize`, as one prompt block (empty when none sent any). */
180
+ export function mcpInstructionsBlock(servers) {
181
+ return servers
182
+ .filter((s) => s.instructions)
183
+ .map((s) => `<mcp-server-instructions server="${s.name.replace(/"/g, '')}">\n${s.instructions}\n</mcp-server-instructions>`)
184
+ .join('\n\n');
125
185
  }
126
186
  /** Close all clients and their underlying transports. */
127
187
  export async function closeAll(servers) {
@@ -133,7 +193,7 @@ export async function closeAll(servers) {
133
193
  * namespace of the built-in tools and tells the model which server a tool
134
194
  * came from at a glance.
135
195
  */
136
- function mcpToolToForgeTool(serverName, mcpTool, client) {
196
+ function mcpToolToForgeTool(serverName, mcpTool, client, timeout) {
137
197
  const fullName = `mcp__${serverName}__${mcpTool.name}`;
138
198
  const description = [
139
199
  mcpTool.description ?? '',
@@ -150,10 +210,7 @@ function mcpToolToForgeTool(serverName, mcpTool, client) {
150
210
  // destructive so the user gets a confirmation prompt unless --auto.
151
211
  destructive: true,
152
212
  execute: async (args) => {
153
- const result = await client.callTool({
154
- name: mcpTool.name,
155
- arguments: args,
156
- });
213
+ const result = await client.callTool({ name: mcpTool.name, arguments: args }, undefined, timeout ? { timeout } : undefined);
157
214
  return mcpResultToValue(result);
158
215
  },
159
216
  };
@@ -45,6 +45,8 @@ export interface McpServerSpec {
45
45
  headers?: Record<string, string>;
46
46
  /** Keep the entry but do not connect it. */
47
47
  disabled?: boolean;
48
+ /** Milliseconds to wait for a connection or a tool call before giving up (default: the SDK's 60 s). */
49
+ timeout?: number;
48
50
  }
49
51
  /**
50
52
  * Load and validate a `forge.mcp.json` file. Throws if the file is missing,
@@ -9,6 +9,7 @@ const serverSchema = z
9
9
  url: z.string().optional(),
10
10
  headers: z.record(z.string(), z.string()).optional(),
11
11
  disabled: z.boolean().optional(),
12
+ timeout: z.number().int().positive().max(3_600_000).optional(),
12
13
  })
13
14
  .refine((s) => Boolean(s.command) || Boolean(s.url), {
14
15
  message: 'Each MCP server must have either `command` (stdio) or `url` (sse/streamable-http).',
@@ -0,0 +1,55 @@
1
+ import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
2
+ import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
3
+ export interface McpOAuthOptions {
4
+ /** Defaults to `~/.forge/mcp-auth`. */
5
+ storeDir?: string;
6
+ /** Show the authorization page to the user (their browser). Throw to refuse. */
7
+ openUrl: (url: string) => void | Promise<void>;
8
+ /** True: may open the browser and wait for the user. False: only use tokens already saved. */
9
+ interactive?: boolean;
10
+ /** How long to wait for the user to finish in the browser. Default five minutes. */
11
+ signInTimeoutMs?: number;
12
+ }
13
+ /** The server needs the user to sign in, and this connection was not allowed to ask. */
14
+ export declare class McpAuthRequiredError extends Error {
15
+ readonly server: string;
16
+ constructor(server: string);
17
+ }
18
+ interface SavedAuth {
19
+ url: string;
20
+ port?: number;
21
+ clientInformation?: OAuthClientInformationMixed;
22
+ tokens?: OAuthTokens;
23
+ }
24
+ export declare function defaultMcpAuthDir(): string;
25
+ export declare function readSavedAuth(dir: string, serverUrl: string): Promise<SavedAuth | undefined>;
26
+ export declare function forgetSavedAuth(dir: string, serverUrl: string): Promise<void>;
27
+ export declare class ForgeOAuthProvider implements OAuthClientProvider {
28
+ readonly serverName: string;
29
+ private readonly serverUrl;
30
+ private readonly dir;
31
+ private readonly port;
32
+ private readonly options;
33
+ private verifier;
34
+ private expectedState;
35
+ private receiver;
36
+ private constructor();
37
+ static create(serverName: string, serverUrl: string, options: McpOAuthOptions): Promise<ForgeOAuthProvider>;
38
+ get redirectUrl(): string;
39
+ get clientMetadata(): OAuthClientMetadata;
40
+ state(): string;
41
+ private saved;
42
+ clientInformation(): Promise<OAuthClientInformationMixed | undefined>;
43
+ saveClientInformation(info: OAuthClientInformationMixed): Promise<void>;
44
+ tokens(): Promise<OAuthTokens | undefined>;
45
+ saveTokens(tokens: OAuthTokens): Promise<void>;
46
+ saveCodeVerifier(v: string): void;
47
+ codeVerifier(): string;
48
+ invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): Promise<void>;
49
+ /** Called by the SDK once it has the authorization URL. Without `interactive` this is where the connection stops. */
50
+ redirectToAuthorization(url: URL): Promise<void>;
51
+ /** After the SDK reported the sign-in as started: the code the browser brings back. */
52
+ waitForCode(): Promise<string>;
53
+ cancel(): void;
54
+ }
55
+ export {};