@kolisachint/hoocode-agent-core 0.4.119 → 0.4.121

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -18,6 +18,8 @@ export * from "./harness/utils/shell-output.js";
18
18
  export * from "./harness/utils/truncate.js";
19
19
  export * from "./proxy.js";
20
20
  export * from "./tools/default-tools.js";
21
+ export * from "./tools/mcp-http-transport.js";
22
+ export * from "./tools/mcp-oauth.js";
21
23
  export * from "./tools/mcp-tools.js";
22
24
  export * from "./types.js";
23
25
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,YAAY,CAAC;AAE3B,cAAc,iBAAiB,CAAC;AAChC,cAAc,4BAA4B,CAAC;AAE3C,cAAc,8CAA8C,CAAC;AAC7D,cAAc,oCAAoC,CAAC;AACnD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,uBAAuB,CAAC;AACtC,cAAc,+BAA+B,CAAC;AAC9C,cAAc,iCAAiC,CAAC;AAChD,cAAc,kCAAkC,CAAC;AACjD,cAAc,kCAAkC,CAAC;AACjD,cAAc,8BAA8B,CAAC;AAC7C,cAAc,qBAAqB,CAAC;AACpC,cAAc,4BAA4B,CAAC;AAE3C,cAAc,oBAAoB,CAAC;AACnC,cAAc,uCAAuC,CAAC;AACtD,cAAc,iCAAiC,CAAC;AAChD,cAAc,6BAA6B,CAAC;AAE5C,cAAc,YAAY,CAAC;AAE3B,cAAc,0BAA0B,CAAC;AACzC,cAAc,sBAAsB,CAAC;AAErC,cAAc,YAAY,CAAC","sourcesContent":["// Core Agent\nexport * from \"./agent.js\";\n// Loop functions\nexport * from \"./agent-loop.js\";\nexport * from \"./harness/agent-harness.js\";\n// Compaction and branch summarization\nexport * from \"./harness/compaction/branch-summarization.js\";\nexport * from \"./harness/compaction/compaction.js\";\nexport * from \"./harness/execution-env.js\";\nexport * from \"./harness/messages.js\";\nexport * from \"./harness/prompt-templates.js\";\nexport * from \"./harness/session/repo/jsonl.js\";\nexport * from \"./harness/session/repo/memory.js\";\nexport * from \"./harness/session/repo/shared.js\";\nexport * from \"./harness/session/session.js\";\nexport * from \"./harness/skills.js\";\nexport * from \"./harness/system-prompt.js\";\n// Harness\nexport * from \"./harness/types.js\";\nexport * from \"./harness/utils/output-compression.js\";\nexport * from \"./harness/utils/shell-output.js\";\nexport * from \"./harness/utils/truncate.js\";\n// Proxy utilities\nexport * from \"./proxy.js\";\n// Headless tool bundles\nexport * from \"./tools/default-tools.js\";\nexport * from \"./tools/mcp-tools.js\";\n// Types\nexport * from \"./types.js\";\n"]}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,YAAY,CAAC;AAE3B,cAAc,iBAAiB,CAAC;AAChC,cAAc,4BAA4B,CAAC;AAE3C,cAAc,8CAA8C,CAAC;AAC7D,cAAc,oCAAoC,CAAC;AACnD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,uBAAuB,CAAC;AACtC,cAAc,+BAA+B,CAAC;AAC9C,cAAc,iCAAiC,CAAC;AAChD,cAAc,kCAAkC,CAAC;AACjD,cAAc,kCAAkC,CAAC;AACjD,cAAc,8BAA8B,CAAC;AAC7C,cAAc,qBAAqB,CAAC;AACpC,cAAc,4BAA4B,CAAC;AAE3C,cAAc,oBAAoB,CAAC;AACnC,cAAc,uCAAuC,CAAC;AACtD,cAAc,iCAAiC,CAAC;AAChD,cAAc,6BAA6B,CAAC;AAE5C,cAAc,YAAY,CAAC;AAE3B,cAAc,0BAA0B,CAAC;AACzC,cAAc,+BAA+B,CAAC;AAC9C,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AAErC,cAAc,YAAY,CAAC","sourcesContent":["// Core Agent\nexport * from \"./agent.js\";\n// Loop functions\nexport * from \"./agent-loop.js\";\nexport * from \"./harness/agent-harness.js\";\n// Compaction and branch summarization\nexport * from \"./harness/compaction/branch-summarization.js\";\nexport * from \"./harness/compaction/compaction.js\";\nexport * from \"./harness/execution-env.js\";\nexport * from \"./harness/messages.js\";\nexport * from \"./harness/prompt-templates.js\";\nexport * from \"./harness/session/repo/jsonl.js\";\nexport * from \"./harness/session/repo/memory.js\";\nexport * from \"./harness/session/repo/shared.js\";\nexport * from \"./harness/session/session.js\";\nexport * from \"./harness/skills.js\";\nexport * from \"./harness/system-prompt.js\";\n// Harness\nexport * from \"./harness/types.js\";\nexport * from \"./harness/utils/output-compression.js\";\nexport * from \"./harness/utils/shell-output.js\";\nexport * from \"./harness/utils/truncate.js\";\n// Proxy utilities\nexport * from \"./proxy.js\";\n// Headless tool bundles\nexport * from \"./tools/default-tools.js\";\nexport * from \"./tools/mcp-http-transport.js\";\nexport * from \"./tools/mcp-oauth.js\";\nexport * from \"./tools/mcp-tools.js\";\n// Types\nexport * from \"./types.js\";\n"]}
package/dist/index.js CHANGED
@@ -24,6 +24,8 @@ export * from "./harness/utils/truncate.js";
24
24
  export * from "./proxy.js";
25
25
  // Headless tool bundles
26
26
  export * from "./tools/default-tools.js";
27
+ export * from "./tools/mcp-http-transport.js";
28
+ export * from "./tools/mcp-oauth.js";
27
29
  export * from "./tools/mcp-tools.js";
28
30
  // Types
29
31
  export * from "./types.js";
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,aAAa;AACb,cAAc,YAAY,CAAC;AAC3B,iBAAiB;AACjB,cAAc,iBAAiB,CAAC;AAChC,cAAc,4BAA4B,CAAC;AAC3C,sCAAsC;AACtC,cAAc,8CAA8C,CAAC;AAC7D,cAAc,oCAAoC,CAAC;AACnD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,uBAAuB,CAAC;AACtC,cAAc,+BAA+B,CAAC;AAC9C,cAAc,iCAAiC,CAAC;AAChD,cAAc,kCAAkC,CAAC;AACjD,cAAc,kCAAkC,CAAC;AACjD,cAAc,8BAA8B,CAAC;AAC7C,cAAc,qBAAqB,CAAC;AACpC,cAAc,4BAA4B,CAAC;AAC3C,UAAU;AACV,cAAc,oBAAoB,CAAC;AACnC,cAAc,uCAAuC,CAAC;AACtD,cAAc,iCAAiC,CAAC;AAChD,cAAc,6BAA6B,CAAC;AAC5C,kBAAkB;AAClB,cAAc,YAAY,CAAC;AAC3B,wBAAwB;AACxB,cAAc,0BAA0B,CAAC;AACzC,cAAc,sBAAsB,CAAC;AACrC,QAAQ;AACR,cAAc,YAAY,CAAC","sourcesContent":["// Core Agent\nexport * from \"./agent.js\";\n// Loop functions\nexport * from \"./agent-loop.js\";\nexport * from \"./harness/agent-harness.js\";\n// Compaction and branch summarization\nexport * from \"./harness/compaction/branch-summarization.js\";\nexport * from \"./harness/compaction/compaction.js\";\nexport * from \"./harness/execution-env.js\";\nexport * from \"./harness/messages.js\";\nexport * from \"./harness/prompt-templates.js\";\nexport * from \"./harness/session/repo/jsonl.js\";\nexport * from \"./harness/session/repo/memory.js\";\nexport * from \"./harness/session/repo/shared.js\";\nexport * from \"./harness/session/session.js\";\nexport * from \"./harness/skills.js\";\nexport * from \"./harness/system-prompt.js\";\n// Harness\nexport * from \"./harness/types.js\";\nexport * from \"./harness/utils/output-compression.js\";\nexport * from \"./harness/utils/shell-output.js\";\nexport * from \"./harness/utils/truncate.js\";\n// Proxy utilities\nexport * from \"./proxy.js\";\n// Headless tool bundles\nexport * from \"./tools/default-tools.js\";\nexport * from \"./tools/mcp-tools.js\";\n// Types\nexport * from \"./types.js\";\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,aAAa;AACb,cAAc,YAAY,CAAC;AAC3B,iBAAiB;AACjB,cAAc,iBAAiB,CAAC;AAChC,cAAc,4BAA4B,CAAC;AAC3C,sCAAsC;AACtC,cAAc,8CAA8C,CAAC;AAC7D,cAAc,oCAAoC,CAAC;AACnD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,uBAAuB,CAAC;AACtC,cAAc,+BAA+B,CAAC;AAC9C,cAAc,iCAAiC,CAAC;AAChD,cAAc,kCAAkC,CAAC;AACjD,cAAc,kCAAkC,CAAC;AACjD,cAAc,8BAA8B,CAAC;AAC7C,cAAc,qBAAqB,CAAC;AACpC,cAAc,4BAA4B,CAAC;AAC3C,UAAU;AACV,cAAc,oBAAoB,CAAC;AACnC,cAAc,uCAAuC,CAAC;AACtD,cAAc,iCAAiC,CAAC;AAChD,cAAc,6BAA6B,CAAC;AAC5C,kBAAkB;AAClB,cAAc,YAAY,CAAC;AAC3B,wBAAwB;AACxB,cAAc,0BAA0B,CAAC;AACzC,cAAc,+BAA+B,CAAC;AAC9C,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,QAAQ;AACR,cAAc,YAAY,CAAC","sourcesContent":["// Core Agent\nexport * from \"./agent.js\";\n// Loop functions\nexport * from \"./agent-loop.js\";\nexport * from \"./harness/agent-harness.js\";\n// Compaction and branch summarization\nexport * from \"./harness/compaction/branch-summarization.js\";\nexport * from \"./harness/compaction/compaction.js\";\nexport * from \"./harness/execution-env.js\";\nexport * from \"./harness/messages.js\";\nexport * from \"./harness/prompt-templates.js\";\nexport * from \"./harness/session/repo/jsonl.js\";\nexport * from \"./harness/session/repo/memory.js\";\nexport * from \"./harness/session/repo/shared.js\";\nexport * from \"./harness/session/session.js\";\nexport * from \"./harness/skills.js\";\nexport * from \"./harness/system-prompt.js\";\n// Harness\nexport * from \"./harness/types.js\";\nexport * from \"./harness/utils/output-compression.js\";\nexport * from \"./harness/utils/shell-output.js\";\nexport * from \"./harness/utils/truncate.js\";\n// Proxy utilities\nexport * from \"./proxy.js\";\n// Headless tool bundles\nexport * from \"./tools/default-tools.js\";\nexport * from \"./tools/mcp-http-transport.js\";\nexport * from \"./tools/mcp-oauth.js\";\nexport * from \"./tools/mcp-tools.js\";\n// Types\nexport * from \"./types.js\";\n"]}
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Remote MCP server connections over the official @modelcontextprotocol/sdk
3
+ * client transports.
4
+ *
5
+ * - `{ "type": "http", "url": ... }` (and url-only configs) use
6
+ * StreamableHTTPClientTransport, falling back to SSEClientTransport when the
7
+ * endpoint rejects streamable HTTP with a 4xx (e.g. 405 from a legacy
8
+ * 2024-11-05 server) before the first successful response.
9
+ * - `{ "type": "sse", "url": ... }` selects the legacy HTTP+SSE transport
10
+ * directly.
11
+ *
12
+ * OAuth (MCP authorization spec) is handled by the SDK's `auth()` through a
13
+ * file-backed provider (see mcp-oauth.ts): on 401 the SDK discovers the
14
+ * authorization server, refreshes persisted tokens when possible, and
15
+ * otherwise starts a browser-based authorization-code + PKCE flow. This module
16
+ * waits on the loopback redirect, exchanges the code, and retries the request
17
+ * — so an rpc issued before authorization completes succeeds once the user
18
+ * finishes signing in (or times out at its own deadline while the background
19
+ * flow keeps running and persists tokens for the next connect).
20
+ *
21
+ * Both Claude Code (`mcpServers`) and Copilot / VS Code (`servers`) mcp.json
22
+ * shapes use these `type`/`url`/`headers` fields. The returned connection has
23
+ * the same rpc/notify/terminate surface as the stdio spawn in mcp-tools.ts so
24
+ * loaders can treat local and remote servers uniformly.
25
+ */
26
+ /** Transport-agnostic MCP connection surface (matches the stdio loaders'). */
27
+ export interface McpTransport {
28
+ rpc(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
29
+ /** Send a JSON-RPC notification (no id, no response expected). */
30
+ notify(method: string, params?: unknown): void;
31
+ terminate(): void;
32
+ }
33
+ export interface McpHttpServerConfig {
34
+ /** Unique server identifier (used in error messages and tool-name prefixes). */
35
+ name: string;
36
+ /** Remote server URL. */
37
+ url: string;
38
+ /** Extra HTTP headers (e.g. a static Authorization) sent on every request. */
39
+ headers?: Record<string, string>;
40
+ /** "http" = Streamable HTTP with SSE fallback (default); "sse" = legacy HTTP+SSE. */
41
+ type?: "http" | "sse";
42
+ }
43
+ export interface McpRemoteOptions {
44
+ /** Directory for persisted OAuth state (default `~/.hoocode/mcp-auth`). */
45
+ authStorageDir?: string;
46
+ /** Open the authorization URL (default: platform browser opener). */
47
+ openBrowser?: (url: string) => void | Promise<void>;
48
+ /** Max time to wait for the browser redirect (default 5 minutes). */
49
+ authTimeoutMs?: number;
50
+ /**
51
+ * Invoked when an interactive authorization flow starts. `completed`
52
+ * resolves once the code was exchanged and tokens persisted — callers can
53
+ * use it to retry the connection — and rejects on timeout/denial.
54
+ */
55
+ onAuthRequired?: (authorizationUrl: string | undefined, completed: Promise<void>) => void;
56
+ }
57
+ /**
58
+ * Open a connection to a remote MCP server. `type: "sse"` selects the legacy
59
+ * HTTP+SSE transport; anything else uses Streamable HTTP with automatic SSE
60
+ * fallback. Connection setup is lazy — errors surface on the first rpc (the
61
+ * initialize handshake), same as a stdio spawn.
62
+ */
63
+ export declare function connectHttpMcpServer(config: McpHttpServerConfig, options?: McpRemoteOptions): McpTransport;
64
+ //# sourceMappingURL=mcp-http-transport.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mcp-http-transport.d.ts","sourceRoot":"","sources":["../../src/tools/mcp-http-transport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AASH,8EAA8E;AAC9E,MAAM,WAAW,YAAY;IAC5B,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5E,kEAAkE;IAClE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;IAC/C,SAAS,IAAI,IAAI,CAAC;CAClB;AAED,MAAM,WAAW,mBAAmB;IACnC,gFAAgF;IAChF,IAAI,EAAE,MAAM,CAAC;IACb,yBAAyB;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,8EAA8E;IAC9E,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,qFAAqF;IACrF,IAAI,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;CACtB;AAED,MAAM,WAAW,gBAAgB;IAChC,2EAA2E;IAC3E,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,qEAAqE;IACrE,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,qEAAqE;IACrE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,gBAAgB,EAAE,MAAM,GAAG,SAAS,EAAE,SAAS,EAAE,OAAO,CAAC,IAAI,CAAC,KAAK,IAAI,CAAC;CAC1F;AAoBD;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,mBAAmB,EAAE,OAAO,GAAE,gBAAqB,GAAG,YAAY,CA6L9G","sourcesContent":["/**\n * Remote MCP server connections over the official @modelcontextprotocol/sdk\n * client transports.\n *\n * - `{ \"type\": \"http\", \"url\": ... }` (and url-only configs) use\n * StreamableHTTPClientTransport, falling back to SSEClientTransport when the\n * endpoint rejects streamable HTTP with a 4xx (e.g. 405 from a legacy\n * 2024-11-05 server) before the first successful response.\n * - `{ \"type\": \"sse\", \"url\": ... }` selects the legacy HTTP+SSE transport\n * directly.\n *\n * OAuth (MCP authorization spec) is handled by the SDK's `auth()` through a\n * file-backed provider (see mcp-oauth.ts): on 401 the SDK discovers the\n * authorization server, refreshes persisted tokens when possible, and\n * otherwise starts a browser-based authorization-code + PKCE flow. This module\n * waits on the loopback redirect, exchanges the code, and retries the request\n * — so an rpc issued before authorization completes succeeds once the user\n * finishes signing in (or times out at its own deadline while the background\n * flow keeps running and persists tokens for the next connect).\n *\n * Both Claude Code (`mcpServers`) and Copilot / VS Code (`servers`) mcp.json\n * shapes use these `type`/`url`/`headers` fields. The returned connection has\n * the same rpc/notify/terminate surface as the stdio spawn in mcp-tools.ts so\n * loaders can treat local and remote servers uniformly.\n */\n\nimport { auth, UnauthorizedError } from \"@modelcontextprotocol/sdk/client/auth.js\";\nimport { SSEClientTransport } from \"@modelcontextprotocol/sdk/client/sse.js\";\nimport { StreamableHTTPClientTransport, StreamableHTTPError } from \"@modelcontextprotocol/sdk/client/streamableHttp.js\";\nimport type { Transport } from \"@modelcontextprotocol/sdk/shared/transport.js\";\nimport type { JSONRPCMessage } from \"@modelcontextprotocol/sdk/types.js\";\nimport { McpFileOAuthProvider } from \"./mcp-oauth.js\";\n\n/** Transport-agnostic MCP connection surface (matches the stdio loaders'). */\nexport interface McpTransport {\n\trpc(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;\n\t/** Send a JSON-RPC notification (no id, no response expected). */\n\tnotify(method: string, params?: unknown): void;\n\tterminate(): void;\n}\n\nexport interface McpHttpServerConfig {\n\t/** Unique server identifier (used in error messages and tool-name prefixes). */\n\tname: string;\n\t/** Remote server URL. */\n\turl: string;\n\t/** Extra HTTP headers (e.g. a static Authorization) sent on every request. */\n\theaders?: Record<string, string>;\n\t/** \"http\" = Streamable HTTP with SSE fallback (default); \"sse\" = legacy HTTP+SSE. */\n\ttype?: \"http\" | \"sse\";\n}\n\nexport interface McpRemoteOptions {\n\t/** Directory for persisted OAuth state (default `~/.hoocode/mcp-auth`). */\n\tauthStorageDir?: string;\n\t/** Open the authorization URL (default: platform browser opener). */\n\topenBrowser?: (url: string) => void | Promise<void>;\n\t/** Max time to wait for the browser redirect (default 5 minutes). */\n\tauthTimeoutMs?: number;\n\t/**\n\t * Invoked when an interactive authorization flow starts. `completed`\n\t * resolves once the code was exchanged and tokens persisted — callers can\n\t * use it to retry the connection — and rejects on timeout/denial.\n\t */\n\tonAuthRequired?: (authorizationUrl: string | undefined, completed: Promise<void>) => void;\n}\n\nconst DEFAULT_AUTH_TIMEOUT_MS = 300_000;\n\n/** 4xx responses (other than auth errors) mean \"endpoint doesn't speak streamable HTTP\". */\nfunction isFallbackError(err: unknown): boolean {\n\treturn (\n\t\terr instanceof StreamableHTTPError &&\n\t\terr.code !== undefined &&\n\t\terr.code >= 400 &&\n\t\terr.code < 500 &&\n\t\terr.code !== 401 &&\n\t\terr.code !== 403\n\t);\n}\n\nfunction toError(err: unknown): Error {\n\treturn err instanceof Error ? err : new Error(String(err));\n}\n\n/**\n * Open a connection to a remote MCP server. `type: \"sse\"` selects the legacy\n * HTTP+SSE transport; anything else uses Streamable HTTP with automatic SSE\n * fallback. Connection setup is lazy — errors surface on the first rpc (the\n * initialize handshake), same as a stdio spawn.\n */\nexport function connectHttpMcpServer(config: McpHttpServerConfig, options: McpRemoteOptions = {}): McpTransport {\n\tlet nextId = 1;\n\tconst pending = new Map<number, { resolve: (r: unknown) => void; reject: (e: Error) => void }>();\n\tlet kind: \"http\" | \"sse\" = config.type === \"sse\" ? \"sse\" : \"http\";\n\tlet fellBack = false;\n\tlet gotResponse = false;\n\tlet closed = false;\n\tlet transport: Transport | undefined;\n\tlet starting: Promise<Transport> | undefined;\n\tlet authInFlight: Promise<void> | undefined;\n\n\tconst provider = new McpFileOAuthProvider(config.url, {\n\t\tstorageDir: options.authStorageDir,\n\t\topenBrowser: options.openBrowser,\n\t});\n\n\tfunction routeMessage(msg: JSONRPCMessage): void {\n\t\tif (!(\"id\" in msg) || msg.id === undefined || msg.id === null) return;\n\t\tif (!(\"result\" in msg) && !(\"error\" in msg)) return;\n\t\t// The initialize result carries the negotiated protocol version; the raw\n\t\t// transport doesn't track it itself (the SDK Client normally does), so\n\t\t// feed it back for the MCP-Protocol-Version request header.\n\t\tif (\"result\" in msg) {\n\t\t\tconst negotiated = (msg.result as { protocolVersion?: string } | undefined)?.protocolVersion;\n\t\t\tif (negotiated && transport?.setProtocolVersion) transport.setProtocolVersion(negotiated);\n\t\t}\n\t\tgotResponse = true;\n\t\tconst cb = pending.get(Number(msg.id));\n\t\tif (!cb) return;\n\t\tpending.delete(Number(msg.id));\n\t\tif (\"error\" in msg) cb.reject(new Error(msg.error.message));\n\t\telse cb.resolve(msg.result);\n\t}\n\n\tfunction makeTransport(): Promise<Transport> {\n\t\tif (!starting) {\n\t\t\tstarting = (async () => {\n\t\t\t\tconst url = new URL(config.url);\n\t\t\t\tconst opts = {\n\t\t\t\t\tauthProvider: provider,\n\t\t\t\t\trequestInit: config.headers ? { headers: config.headers } : undefined,\n\t\t\t\t};\n\t\t\t\tconst t: Transport =\n\t\t\t\t\tkind === \"sse\" ? new SSEClientTransport(url, opts) : new StreamableHTTPClientTransport(url, opts);\n\t\t\t\tt.onmessage = routeMessage;\n\t\t\t\tt.onerror = () => {\n\t\t\t\t\t// Non-fatal transport notices (e.g. a server that 405s the optional\n\t\t\t\t\t// GET notification stream). Request failures reject via send().\n\t\t\t\t};\n\t\t\t\tt.onclose = () => {\n\t\t\t\t\tif (transport === t) transport = undefined;\n\t\t\t\t};\n\t\t\t\tawait t.start();\n\t\t\t\ttransport = t;\n\t\t\t\treturn t;\n\t\t\t})();\n\t\t\tstarting\n\t\t\t\t.catch(() => {})\n\t\t\t\t.finally(() => {\n\t\t\t\t\tstarting = undefined;\n\t\t\t\t});\n\t\t}\n\t\treturn starting;\n\t}\n\n\tasync function discardTransport(): Promise<void> {\n\t\tconst t = transport;\n\t\ttransport = undefined;\n\t\ttry {\n\t\t\tawait t?.close();\n\t\t} catch {\n\t\t\t// already closed\n\t\t}\n\t}\n\n\t/**\n\t * Complete a browser-based authorization flow the SDK just initiated (it\n\t * opened the browser via the provider before throwing UnauthorizedError):\n\t * wait for the loopback redirect, exchange the code, persist tokens.\n\t * Deduplicated so concurrent sends share one flow.\n\t */\n\tfunction completeInteractiveAuth(): Promise<void> {\n\t\tif (!authInFlight) {\n\t\t\tauthInFlight = (async () => {\n\t\t\t\tconst code = await provider.waitForAuthorizationCode(options.authTimeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS);\n\t\t\t\tconst result = await auth(provider, { serverUrl: config.url, authorizationCode: code });\n\t\t\t\tif (result !== \"AUTHORIZED\") throw new Error(`MCP server \"${config.name}\" authorization did not complete`);\n\t\t\t})();\n\t\t\tauthInFlight\n\t\t\t\t.catch(() => {})\n\t\t\t\t.finally(() => {\n\t\t\t\t\tprovider.closeCallbackServer();\n\t\t\t\t\tauthInFlight = undefined;\n\t\t\t\t});\n\t\t\toptions.onAuthRequired?.(provider.lastAuthorizationUrl, authInFlight);\n\t\t}\n\t\treturn authInFlight;\n\t}\n\n\tasync function transportSend(message: JSONRPCMessage): Promise<void> {\n\t\tlet authRetries = 0;\n\t\tfor (;;) {\n\t\t\tif (closed) throw new Error(`MCP server \"${config.name}\" connection terminated`);\n\t\t\ttry {\n\t\t\t\tconst t = transport ?? (await makeTransport());\n\t\t\t\tawait t.send(message);\n\t\t\t\treturn;\n\t\t\t} catch (err) {\n\t\t\t\tif (err instanceof UnauthorizedError && authRetries === 0) {\n\t\t\t\t\tauthRetries++;\n\t\t\t\t\tawait completeInteractiveAuth();\n\t\t\t\t\t// Fresh transport so both kinds re-handshake with the new tokens.\n\t\t\t\t\tawait discardTransport();\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (kind === \"http\" && !fellBack && !gotResponse && config.type !== \"sse\" && isFallbackError(err)) {\n\t\t\t\t\tfellBack = true;\n\t\t\t\t\tkind = \"sse\";\n\t\t\t\t\tawait discardTransport();\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tthrow toError(err);\n\t\t\t}\n\t\t}\n\t}\n\n\tfunction rpc(method: string, params?: unknown, timeoutMs?: number): Promise<unknown> {\n\t\tconst id = nextId++;\n\t\treturn new Promise<unknown>((resolve, reject) => {\n\t\t\tlet timer: NodeJS.Timeout | undefined;\n\t\t\tif (timeoutMs && timeoutMs > 0) {\n\t\t\t\ttimer = setTimeout(() => {\n\t\t\t\t\tif (pending.delete(id)) {\n\t\t\t\t\t\treject(new Error(`MCP server \"${config.name}\" timed out after ${timeoutMs}ms on ${method}`));\n\t\t\t\t\t}\n\t\t\t\t}, timeoutMs);\n\t\t\t\ttimer.unref?.();\n\t\t\t}\n\t\t\tpending.set(id, {\n\t\t\t\tresolve: (r) => {\n\t\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\t\tresolve(r);\n\t\t\t\t},\n\t\t\t\treject: (e) => {\n\t\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\t\treject(e);\n\t\t\t\t},\n\t\t\t});\n\t\t\ttransportSend({ jsonrpc: \"2.0\", id, method, params } as JSONRPCMessage).catch((err: unknown) => {\n\t\t\t\tconst cb = pending.get(id);\n\t\t\t\tif (cb) {\n\t\t\t\t\tpending.delete(id);\n\t\t\t\t\tcb.reject(toError(err));\n\t\t\t\t}\n\t\t\t});\n\t\t});\n\t}\n\n\tfunction notify(method: string, params?: unknown): void {\n\t\ttransportSend({ jsonrpc: \"2.0\", method, params } as JSONRPCMessage).catch(() => {\n\t\t\t// fire-and-forget\n\t\t});\n\t}\n\n\tfunction terminate(): void {\n\t\tclosed = true;\n\t\tprovider.closeCallbackServer();\n\t\tconst t = transport;\n\t\ttransport = undefined;\n\t\tvoid (async () => {\n\t\t\ttry {\n\t\t\t\t// Streamable HTTP sessions should be ended explicitly (HTTP DELETE).\n\t\t\t\tconst st = t as StreamableHTTPClientTransport | undefined;\n\t\t\t\tif (st && typeof st.terminateSession === \"function\") await st.terminateSession();\n\t\t\t} catch {\n\t\t\t\t// best-effort\n\t\t\t}\n\t\t\ttry {\n\t\t\t\tawait t?.close();\n\t\t\t} catch {\n\t\t\t\t// already closed\n\t\t\t}\n\t\t})();\n\t\tconst err = new Error(`MCP server \"${config.name}\" connection terminated`);\n\t\tfor (const cb of pending.values()) cb.reject(err);\n\t\tpending.clear();\n\t}\n\n\treturn { rpc, notify, terminate };\n}\n"]}
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Remote MCP server connections over the official @modelcontextprotocol/sdk
3
+ * client transports.
4
+ *
5
+ * - `{ "type": "http", "url": ... }` (and url-only configs) use
6
+ * StreamableHTTPClientTransport, falling back to SSEClientTransport when the
7
+ * endpoint rejects streamable HTTP with a 4xx (e.g. 405 from a legacy
8
+ * 2024-11-05 server) before the first successful response.
9
+ * - `{ "type": "sse", "url": ... }` selects the legacy HTTP+SSE transport
10
+ * directly.
11
+ *
12
+ * OAuth (MCP authorization spec) is handled by the SDK's `auth()` through a
13
+ * file-backed provider (see mcp-oauth.ts): on 401 the SDK discovers the
14
+ * authorization server, refreshes persisted tokens when possible, and
15
+ * otherwise starts a browser-based authorization-code + PKCE flow. This module
16
+ * waits on the loopback redirect, exchanges the code, and retries the request
17
+ * — so an rpc issued before authorization completes succeeds once the user
18
+ * finishes signing in (or times out at its own deadline while the background
19
+ * flow keeps running and persists tokens for the next connect).
20
+ *
21
+ * Both Claude Code (`mcpServers`) and Copilot / VS Code (`servers`) mcp.json
22
+ * shapes use these `type`/`url`/`headers` fields. The returned connection has
23
+ * the same rpc/notify/terminate surface as the stdio spawn in mcp-tools.ts so
24
+ * loaders can treat local and remote servers uniformly.
25
+ */
26
+ import { auth, UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
27
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
28
+ import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
29
+ import { McpFileOAuthProvider } from "./mcp-oauth.js";
30
+ const DEFAULT_AUTH_TIMEOUT_MS = 300_000;
31
+ /** 4xx responses (other than auth errors) mean "endpoint doesn't speak streamable HTTP". */
32
+ function isFallbackError(err) {
33
+ return (err instanceof StreamableHTTPError &&
34
+ err.code !== undefined &&
35
+ err.code >= 400 &&
36
+ err.code < 500 &&
37
+ err.code !== 401 &&
38
+ err.code !== 403);
39
+ }
40
+ function toError(err) {
41
+ return err instanceof Error ? err : new Error(String(err));
42
+ }
43
+ /**
44
+ * Open a connection to a remote MCP server. `type: "sse"` selects the legacy
45
+ * HTTP+SSE transport; anything else uses Streamable HTTP with automatic SSE
46
+ * fallback. Connection setup is lazy — errors surface on the first rpc (the
47
+ * initialize handshake), same as a stdio spawn.
48
+ */
49
+ export function connectHttpMcpServer(config, options = {}) {
50
+ let nextId = 1;
51
+ const pending = new Map();
52
+ let kind = config.type === "sse" ? "sse" : "http";
53
+ let fellBack = false;
54
+ let gotResponse = false;
55
+ let closed = false;
56
+ let transport;
57
+ let starting;
58
+ let authInFlight;
59
+ const provider = new McpFileOAuthProvider(config.url, {
60
+ storageDir: options.authStorageDir,
61
+ openBrowser: options.openBrowser,
62
+ });
63
+ function routeMessage(msg) {
64
+ if (!("id" in msg) || msg.id === undefined || msg.id === null)
65
+ return;
66
+ if (!("result" in msg) && !("error" in msg))
67
+ return;
68
+ // The initialize result carries the negotiated protocol version; the raw
69
+ // transport doesn't track it itself (the SDK Client normally does), so
70
+ // feed it back for the MCP-Protocol-Version request header.
71
+ if ("result" in msg) {
72
+ const negotiated = msg.result?.protocolVersion;
73
+ if (negotiated && transport?.setProtocolVersion)
74
+ transport.setProtocolVersion(negotiated);
75
+ }
76
+ gotResponse = true;
77
+ const cb = pending.get(Number(msg.id));
78
+ if (!cb)
79
+ return;
80
+ pending.delete(Number(msg.id));
81
+ if ("error" in msg)
82
+ cb.reject(new Error(msg.error.message));
83
+ else
84
+ cb.resolve(msg.result);
85
+ }
86
+ function makeTransport() {
87
+ if (!starting) {
88
+ starting = (async () => {
89
+ const url = new URL(config.url);
90
+ const opts = {
91
+ authProvider: provider,
92
+ requestInit: config.headers ? { headers: config.headers } : undefined,
93
+ };
94
+ const t = kind === "sse" ? new SSEClientTransport(url, opts) : new StreamableHTTPClientTransport(url, opts);
95
+ t.onmessage = routeMessage;
96
+ t.onerror = () => {
97
+ // Non-fatal transport notices (e.g. a server that 405s the optional
98
+ // GET notification stream). Request failures reject via send().
99
+ };
100
+ t.onclose = () => {
101
+ if (transport === t)
102
+ transport = undefined;
103
+ };
104
+ await t.start();
105
+ transport = t;
106
+ return t;
107
+ })();
108
+ starting
109
+ .catch(() => { })
110
+ .finally(() => {
111
+ starting = undefined;
112
+ });
113
+ }
114
+ return starting;
115
+ }
116
+ async function discardTransport() {
117
+ const t = transport;
118
+ transport = undefined;
119
+ try {
120
+ await t?.close();
121
+ }
122
+ catch {
123
+ // already closed
124
+ }
125
+ }
126
+ /**
127
+ * Complete a browser-based authorization flow the SDK just initiated (it
128
+ * opened the browser via the provider before throwing UnauthorizedError):
129
+ * wait for the loopback redirect, exchange the code, persist tokens.
130
+ * Deduplicated so concurrent sends share one flow.
131
+ */
132
+ function completeInteractiveAuth() {
133
+ if (!authInFlight) {
134
+ authInFlight = (async () => {
135
+ const code = await provider.waitForAuthorizationCode(options.authTimeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS);
136
+ const result = await auth(provider, { serverUrl: config.url, authorizationCode: code });
137
+ if (result !== "AUTHORIZED")
138
+ throw new Error(`MCP server "${config.name}" authorization did not complete`);
139
+ })();
140
+ authInFlight
141
+ .catch(() => { })
142
+ .finally(() => {
143
+ provider.closeCallbackServer();
144
+ authInFlight = undefined;
145
+ });
146
+ options.onAuthRequired?.(provider.lastAuthorizationUrl, authInFlight);
147
+ }
148
+ return authInFlight;
149
+ }
150
+ async function transportSend(message) {
151
+ let authRetries = 0;
152
+ for (;;) {
153
+ if (closed)
154
+ throw new Error(`MCP server "${config.name}" connection terminated`);
155
+ try {
156
+ const t = transport ?? (await makeTransport());
157
+ await t.send(message);
158
+ return;
159
+ }
160
+ catch (err) {
161
+ if (err instanceof UnauthorizedError && authRetries === 0) {
162
+ authRetries++;
163
+ await completeInteractiveAuth();
164
+ // Fresh transport so both kinds re-handshake with the new tokens.
165
+ await discardTransport();
166
+ continue;
167
+ }
168
+ if (kind === "http" && !fellBack && !gotResponse && config.type !== "sse" && isFallbackError(err)) {
169
+ fellBack = true;
170
+ kind = "sse";
171
+ await discardTransport();
172
+ continue;
173
+ }
174
+ throw toError(err);
175
+ }
176
+ }
177
+ }
178
+ function rpc(method, params, timeoutMs) {
179
+ const id = nextId++;
180
+ return new Promise((resolve, reject) => {
181
+ let timer;
182
+ if (timeoutMs && timeoutMs > 0) {
183
+ timer = setTimeout(() => {
184
+ if (pending.delete(id)) {
185
+ reject(new Error(`MCP server "${config.name}" timed out after ${timeoutMs}ms on ${method}`));
186
+ }
187
+ }, timeoutMs);
188
+ timer.unref?.();
189
+ }
190
+ pending.set(id, {
191
+ resolve: (r) => {
192
+ if (timer)
193
+ clearTimeout(timer);
194
+ resolve(r);
195
+ },
196
+ reject: (e) => {
197
+ if (timer)
198
+ clearTimeout(timer);
199
+ reject(e);
200
+ },
201
+ });
202
+ transportSend({ jsonrpc: "2.0", id, method, params }).catch((err) => {
203
+ const cb = pending.get(id);
204
+ if (cb) {
205
+ pending.delete(id);
206
+ cb.reject(toError(err));
207
+ }
208
+ });
209
+ });
210
+ }
211
+ function notify(method, params) {
212
+ transportSend({ jsonrpc: "2.0", method, params }).catch(() => {
213
+ // fire-and-forget
214
+ });
215
+ }
216
+ function terminate() {
217
+ closed = true;
218
+ provider.closeCallbackServer();
219
+ const t = transport;
220
+ transport = undefined;
221
+ void (async () => {
222
+ try {
223
+ // Streamable HTTP sessions should be ended explicitly (HTTP DELETE).
224
+ const st = t;
225
+ if (st && typeof st.terminateSession === "function")
226
+ await st.terminateSession();
227
+ }
228
+ catch {
229
+ // best-effort
230
+ }
231
+ try {
232
+ await t?.close();
233
+ }
234
+ catch {
235
+ // already closed
236
+ }
237
+ })();
238
+ const err = new Error(`MCP server "${config.name}" connection terminated`);
239
+ for (const cb of pending.values())
240
+ cb.reject(err);
241
+ pending.clear();
242
+ }
243
+ return { rpc, notify, terminate };
244
+ }
245
+ //# sourceMappingURL=mcp-http-transport.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mcp-http-transport.js","sourceRoot":"","sources":["../../src/tools/mcp-http-transport.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Remote MCP server connections over the official @modelcontextprotocol/sdk\n * client transports.\n *\n * - `{ \"type\": \"http\", \"url\": ... }` (and url-only configs) use\n * StreamableHTTPClientTransport, falling back to SSEClientTransport when the\n * endpoint rejects streamable HTTP with a 4xx (e.g. 405 from a legacy\n * 2024-11-05 server) before the first successful response.\n * - `{ \"type\": \"sse\", \"url\": ... }` selects the legacy HTTP+SSE transport\n * directly.\n *\n * OAuth (MCP authorization spec) is handled by the SDK's `auth()` through a\n * file-backed provider (see mcp-oauth.ts): on 401 the SDK discovers the\n * authorization server, refreshes persisted tokens when possible, and\n * otherwise starts a browser-based authorization-code + PKCE flow. This module\n * waits on the loopback redirect, exchanges the code, and retries the request\n * — so an rpc issued before authorization completes succeeds once the user\n * finishes signing in (or times out at its own deadline while the background\n * flow keeps running and persists tokens for the next connect).\n *\n * Both Claude Code (`mcpServers`) and Copilot / VS Code (`servers`) mcp.json\n * shapes use these `type`/`url`/`headers` fields. The returned connection has\n * the same rpc/notify/terminate surface as the stdio spawn in mcp-tools.ts so\n * loaders can treat local and remote servers uniformly.\n */\n\nimport { auth, UnauthorizedError } from \"@modelcontextprotocol/sdk/client/auth.js\";\nimport { SSEClientTransport } from \"@modelcontextprotocol/sdk/client/sse.js\";\nimport { StreamableHTTPClientTransport, StreamableHTTPError } from \"@modelcontextprotocol/sdk/client/streamableHttp.js\";\nimport type { Transport } from \"@modelcontextprotocol/sdk/shared/transport.js\";\nimport type { JSONRPCMessage } from \"@modelcontextprotocol/sdk/types.js\";\nimport { McpFileOAuthProvider } from \"./mcp-oauth.js\";\n\n/** Transport-agnostic MCP connection surface (matches the stdio loaders'). */\nexport interface McpTransport {\n\trpc(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;\n\t/** Send a JSON-RPC notification (no id, no response expected). */\n\tnotify(method: string, params?: unknown): void;\n\tterminate(): void;\n}\n\nexport interface McpHttpServerConfig {\n\t/** Unique server identifier (used in error messages and tool-name prefixes). */\n\tname: string;\n\t/** Remote server URL. */\n\turl: string;\n\t/** Extra HTTP headers (e.g. a static Authorization) sent on every request. */\n\theaders?: Record<string, string>;\n\t/** \"http\" = Streamable HTTP with SSE fallback (default); \"sse\" = legacy HTTP+SSE. */\n\ttype?: \"http\" | \"sse\";\n}\n\nexport interface McpRemoteOptions {\n\t/** Directory for persisted OAuth state (default `~/.hoocode/mcp-auth`). */\n\tauthStorageDir?: string;\n\t/** Open the authorization URL (default: platform browser opener). */\n\topenBrowser?: (url: string) => void | Promise<void>;\n\t/** Max time to wait for the browser redirect (default 5 minutes). */\n\tauthTimeoutMs?: number;\n\t/**\n\t * Invoked when an interactive authorization flow starts. `completed`\n\t * resolves once the code was exchanged and tokens persisted — callers can\n\t * use it to retry the connection — and rejects on timeout/denial.\n\t */\n\tonAuthRequired?: (authorizationUrl: string | undefined, completed: Promise<void>) => void;\n}\n\nconst DEFAULT_AUTH_TIMEOUT_MS = 300_000;\n\n/** 4xx responses (other than auth errors) mean \"endpoint doesn't speak streamable HTTP\". */\nfunction isFallbackError(err: unknown): boolean {\n\treturn (\n\t\terr instanceof StreamableHTTPError &&\n\t\terr.code !== undefined &&\n\t\terr.code >= 400 &&\n\t\terr.code < 500 &&\n\t\terr.code !== 401 &&\n\t\terr.code !== 403\n\t);\n}\n\nfunction toError(err: unknown): Error {\n\treturn err instanceof Error ? err : new Error(String(err));\n}\n\n/**\n * Open a connection to a remote MCP server. `type: \"sse\"` selects the legacy\n * HTTP+SSE transport; anything else uses Streamable HTTP with automatic SSE\n * fallback. Connection setup is lazy — errors surface on the first rpc (the\n * initialize handshake), same as a stdio spawn.\n */\nexport function connectHttpMcpServer(config: McpHttpServerConfig, options: McpRemoteOptions = {}): McpTransport {\n\tlet nextId = 1;\n\tconst pending = new Map<number, { resolve: (r: unknown) => void; reject: (e: Error) => void }>();\n\tlet kind: \"http\" | \"sse\" = config.type === \"sse\" ? \"sse\" : \"http\";\n\tlet fellBack = false;\n\tlet gotResponse = false;\n\tlet closed = false;\n\tlet transport: Transport | undefined;\n\tlet starting: Promise<Transport> | undefined;\n\tlet authInFlight: Promise<void> | undefined;\n\n\tconst provider = new McpFileOAuthProvider(config.url, {\n\t\tstorageDir: options.authStorageDir,\n\t\topenBrowser: options.openBrowser,\n\t});\n\n\tfunction routeMessage(msg: JSONRPCMessage): void {\n\t\tif (!(\"id\" in msg) || msg.id === undefined || msg.id === null) return;\n\t\tif (!(\"result\" in msg) && !(\"error\" in msg)) return;\n\t\t// The initialize result carries the negotiated protocol version; the raw\n\t\t// transport doesn't track it itself (the SDK Client normally does), so\n\t\t// feed it back for the MCP-Protocol-Version request header.\n\t\tif (\"result\" in msg) {\n\t\t\tconst negotiated = (msg.result as { protocolVersion?: string } | undefined)?.protocolVersion;\n\t\t\tif (negotiated && transport?.setProtocolVersion) transport.setProtocolVersion(negotiated);\n\t\t}\n\t\tgotResponse = true;\n\t\tconst cb = pending.get(Number(msg.id));\n\t\tif (!cb) return;\n\t\tpending.delete(Number(msg.id));\n\t\tif (\"error\" in msg) cb.reject(new Error(msg.error.message));\n\t\telse cb.resolve(msg.result);\n\t}\n\n\tfunction makeTransport(): Promise<Transport> {\n\t\tif (!starting) {\n\t\t\tstarting = (async () => {\n\t\t\t\tconst url = new URL(config.url);\n\t\t\t\tconst opts = {\n\t\t\t\t\tauthProvider: provider,\n\t\t\t\t\trequestInit: config.headers ? { headers: config.headers } : undefined,\n\t\t\t\t};\n\t\t\t\tconst t: Transport =\n\t\t\t\t\tkind === \"sse\" ? new SSEClientTransport(url, opts) : new StreamableHTTPClientTransport(url, opts);\n\t\t\t\tt.onmessage = routeMessage;\n\t\t\t\tt.onerror = () => {\n\t\t\t\t\t// Non-fatal transport notices (e.g. a server that 405s the optional\n\t\t\t\t\t// GET notification stream). Request failures reject via send().\n\t\t\t\t};\n\t\t\t\tt.onclose = () => {\n\t\t\t\t\tif (transport === t) transport = undefined;\n\t\t\t\t};\n\t\t\t\tawait t.start();\n\t\t\t\ttransport = t;\n\t\t\t\treturn t;\n\t\t\t})();\n\t\t\tstarting\n\t\t\t\t.catch(() => {})\n\t\t\t\t.finally(() => {\n\t\t\t\t\tstarting = undefined;\n\t\t\t\t});\n\t\t}\n\t\treturn starting;\n\t}\n\n\tasync function discardTransport(): Promise<void> {\n\t\tconst t = transport;\n\t\ttransport = undefined;\n\t\ttry {\n\t\t\tawait t?.close();\n\t\t} catch {\n\t\t\t// already closed\n\t\t}\n\t}\n\n\t/**\n\t * Complete a browser-based authorization flow the SDK just initiated (it\n\t * opened the browser via the provider before throwing UnauthorizedError):\n\t * wait for the loopback redirect, exchange the code, persist tokens.\n\t * Deduplicated so concurrent sends share one flow.\n\t */\n\tfunction completeInteractiveAuth(): Promise<void> {\n\t\tif (!authInFlight) {\n\t\t\tauthInFlight = (async () => {\n\t\t\t\tconst code = await provider.waitForAuthorizationCode(options.authTimeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS);\n\t\t\t\tconst result = await auth(provider, { serverUrl: config.url, authorizationCode: code });\n\t\t\t\tif (result !== \"AUTHORIZED\") throw new Error(`MCP server \"${config.name}\" authorization did not complete`);\n\t\t\t})();\n\t\t\tauthInFlight\n\t\t\t\t.catch(() => {})\n\t\t\t\t.finally(() => {\n\t\t\t\t\tprovider.closeCallbackServer();\n\t\t\t\t\tauthInFlight = undefined;\n\t\t\t\t});\n\t\t\toptions.onAuthRequired?.(provider.lastAuthorizationUrl, authInFlight);\n\t\t}\n\t\treturn authInFlight;\n\t}\n\n\tasync function transportSend(message: JSONRPCMessage): Promise<void> {\n\t\tlet authRetries = 0;\n\t\tfor (;;) {\n\t\t\tif (closed) throw new Error(`MCP server \"${config.name}\" connection terminated`);\n\t\t\ttry {\n\t\t\t\tconst t = transport ?? (await makeTransport());\n\t\t\t\tawait t.send(message);\n\t\t\t\treturn;\n\t\t\t} catch (err) {\n\t\t\t\tif (err instanceof UnauthorizedError && authRetries === 0) {\n\t\t\t\t\tauthRetries++;\n\t\t\t\t\tawait completeInteractiveAuth();\n\t\t\t\t\t// Fresh transport so both kinds re-handshake with the new tokens.\n\t\t\t\t\tawait discardTransport();\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (kind === \"http\" && !fellBack && !gotResponse && config.type !== \"sse\" && isFallbackError(err)) {\n\t\t\t\t\tfellBack = true;\n\t\t\t\t\tkind = \"sse\";\n\t\t\t\t\tawait discardTransport();\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tthrow toError(err);\n\t\t\t}\n\t\t}\n\t}\n\n\tfunction rpc(method: string, params?: unknown, timeoutMs?: number): Promise<unknown> {\n\t\tconst id = nextId++;\n\t\treturn new Promise<unknown>((resolve, reject) => {\n\t\t\tlet timer: NodeJS.Timeout | undefined;\n\t\t\tif (timeoutMs && timeoutMs > 0) {\n\t\t\t\ttimer = setTimeout(() => {\n\t\t\t\t\tif (pending.delete(id)) {\n\t\t\t\t\t\treject(new Error(`MCP server \"${config.name}\" timed out after ${timeoutMs}ms on ${method}`));\n\t\t\t\t\t}\n\t\t\t\t}, timeoutMs);\n\t\t\t\ttimer.unref?.();\n\t\t\t}\n\t\t\tpending.set(id, {\n\t\t\t\tresolve: (r) => {\n\t\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\t\tresolve(r);\n\t\t\t\t},\n\t\t\t\treject: (e) => {\n\t\t\t\t\tif (timer) clearTimeout(timer);\n\t\t\t\t\treject(e);\n\t\t\t\t},\n\t\t\t});\n\t\t\ttransportSend({ jsonrpc: \"2.0\", id, method, params } as JSONRPCMessage).catch((err: unknown) => {\n\t\t\t\tconst cb = pending.get(id);\n\t\t\t\tif (cb) {\n\t\t\t\t\tpending.delete(id);\n\t\t\t\t\tcb.reject(toError(err));\n\t\t\t\t}\n\t\t\t});\n\t\t});\n\t}\n\n\tfunction notify(method: string, params?: unknown): void {\n\t\ttransportSend({ jsonrpc: \"2.0\", method, params } as JSONRPCMessage).catch(() => {\n\t\t\t// fire-and-forget\n\t\t});\n\t}\n\n\tfunction terminate(): void {\n\t\tclosed = true;\n\t\tprovider.closeCallbackServer();\n\t\tconst t = transport;\n\t\ttransport = undefined;\n\t\tvoid (async () => {\n\t\t\ttry {\n\t\t\t\t// Streamable HTTP sessions should be ended explicitly (HTTP DELETE).\n\t\t\t\tconst st = t as StreamableHTTPClientTransport | undefined;\n\t\t\t\tif (st && typeof st.terminateSession === \"function\") await st.terminateSession();\n\t\t\t} catch {\n\t\t\t\t// best-effort\n\t\t\t}\n\t\t\ttry {\n\t\t\t\tawait t?.close();\n\t\t\t} catch {\n\t\t\t\t// already closed\n\t\t\t}\n\t\t})();\n\t\tconst err = new Error(`MCP server \"${config.name}\" connection terminated`);\n\t\tfor (const cb of pending.values()) cb.reject(err);\n\t\tpending.clear();\n\t}\n\n\treturn { rpc, notify, terminate };\n}\n"]}
@@ -0,0 +1,90 @@
1
+ /**
2
+ * OAuth support for remote MCP servers: a file-backed
3
+ * {@link OAuthClientProvider} for the official MCP SDK plus a loopback
4
+ * callback server for the browser-based authorization-code + PKCE flow.
5
+ *
6
+ * The SDK's `auth()` orchestrates discovery (RFC 9728 / RFC 8414), dynamic
7
+ * client registration, PKCE, token exchange, and refresh; this module supplies
8
+ * the persistence and user-interaction pieces:
9
+ * - per-server-URL state (client registration, tokens, code verifier) stored
10
+ * as 0600 JSON files under `~/.hoocode/mcp-auth/` by default;
11
+ * - a localhost HTTP server that receives the authorization redirect. It is
12
+ * started lazily inside `clientInformation()` — the first provider call of
13
+ * every interactive `auth()` run that happens before the authorization URL
14
+ * is built — so no listener exists unless an OAuth flow is actually running.
15
+ * Re-binding the port used at registration keeps persisted dynamic client
16
+ * registrations valid; if the port is taken, the registration is dropped so
17
+ * the SDK re-registers with the fresh redirect URI.
18
+ */
19
+ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
20
+ import type { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
21
+ /** Default directory for persisted MCP OAuth state. */
22
+ export declare function defaultMcpAuthDir(): string;
23
+ /** Open a URL in the user's browser (best-effort, detached). */
24
+ export declare function openBrowserDefault(url: string): void;
25
+ /** Loopback HTTP server that receives the OAuth authorization redirect. */
26
+ declare class OAuthCallbackServer {
27
+ private server;
28
+ private waiter?;
29
+ /** Redirect that arrived before anyone called waitForCode (fast redirects). */
30
+ private buffered?;
31
+ readonly url: string;
32
+ private constructor();
33
+ /** Bind the callback server; `preferredPort` first, any free port otherwise. */
34
+ static start(preferredPort?: number): Promise<{
35
+ server: OAuthCallbackServer;
36
+ reboundPort: boolean;
37
+ }>;
38
+ private deliver;
39
+ waitForCode(expectedState: string | undefined, timeoutMs: number): Promise<string>;
40
+ close(): void;
41
+ }
42
+ export interface McpOAuthProviderOptions {
43
+ /** Directory for persisted auth state (default `~/.hoocode/mcp-auth`). */
44
+ storageDir?: string;
45
+ /** Open the authorization URL (default: spawn the platform browser opener). */
46
+ openBrowser?: (url: string) => void | Promise<void>;
47
+ }
48
+ /**
49
+ * File-backed OAuth provider for one remote MCP server, keyed by server URL.
50
+ * Implements the SDK's {@link OAuthClientProvider} so `auth()` handles the
51
+ * protocol; this class owns persistence, the loopback redirect listener, and
52
+ * opening the user's browser.
53
+ */
54
+ export declare class McpFileOAuthProvider implements OAuthClientProvider {
55
+ private readonly serverUrl;
56
+ private readonly statePath;
57
+ private readonly openBrowser;
58
+ private callback?;
59
+ private callbackStarting?;
60
+ private currentState?;
61
+ /** Last authorization URL handed to `redirectToAuthorization`. */
62
+ lastAuthorizationUrl?: string;
63
+ constructor(serverUrl: string, options?: McpOAuthProviderOptions);
64
+ private read;
65
+ private write;
66
+ /**
67
+ * Ensure the loopback redirect listener is running. Prefers the port used at
68
+ * dynamic client registration so the persisted client stays valid; when that
69
+ * port can't be re-bound the registration is dropped and the SDK registers a
70
+ * fresh client against the new redirect URI.
71
+ */
72
+ ensureCallbackServer(): Promise<OAuthCallbackServer>;
73
+ /** Wait for the browser redirect to deliver an authorization code. */
74
+ waitForAuthorizationCode(timeoutMs: number): Promise<string>;
75
+ /** Stop the loopback listener (auth finished, failed, or connection closed). */
76
+ closeCallbackServer(): void;
77
+ get redirectUrl(): string | undefined;
78
+ get clientMetadata(): OAuthClientMetadata;
79
+ state(): string;
80
+ clientInformation(): Promise<OAuthClientInformationMixed | undefined>;
81
+ saveClientInformation(clientInformation: OAuthClientInformationMixed): void;
82
+ tokens(): OAuthTokens | undefined;
83
+ saveTokens(tokens: OAuthTokens): void;
84
+ redirectToAuthorization(authorizationUrl: URL): Promise<void>;
85
+ saveCodeVerifier(codeVerifier: string): void;
86
+ codeVerifier(): string;
87
+ invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): void;
88
+ }
89
+ export {};
90
+ //# sourceMappingURL=mcp-oauth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mcp-oauth.d.ts","sourceRoot":"","sources":["../../src/tools/mcp-oauth.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AASH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,0CAA0C,CAAC;AACpF,OAAO,KAAK,EACX,2BAA2B,EAC3B,mBAAmB,EACnB,WAAW,EACX,MAAM,0CAA0C,CAAC;AAElD,uDAAuD;AACvD,wBAAgB,iBAAiB,IAAI,MAAM,CAE1C;AAED,gEAAgE;AAChE,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAYpD;AAmBD,2EAA2E;AAC3E,cAAM,mBAAmB;IACxB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,MAAM,CAAC,CAAiB;IAChC,+EAA+E;IAC/E,OAAO,CAAC,QAAQ,CAAC,CAAoD;IACrE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IAErB,OAAO,eAGN;IAED,gFAAgF;IAChF,OAAa,KAAK,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,mBAAmB,CAAC;QAAC,WAAW,EAAE,OAAO,CAAA;KAAE,CAAC,CAqDzG;IAED,OAAO,CAAC,OAAO;IAUf,WAAW,CAAC,aAAa,EAAE,MAAM,GAAG,SAAS,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CA0BjF;IAED,KAAK,IAAI,IAAI,CAMZ;CACD;AAED,MAAM,WAAW,uBAAuB;IACvC,0EAA0E;IAC1E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,+EAA+E;IAC/E,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpD;AAED;;;;;GAKG;AACH,qBAAa,oBAAqB,YAAW,mBAAmB;IAC/D,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAwC;IACpE,OAAO,CAAC,QAAQ,CAAC,CAAsB;IACvC,OAAO,CAAC,gBAAgB,CAAC,CAA+B;IACxD,OAAO,CAAC,YAAY,CAAC,CAAS;IAC9B,kEAAkE;IAClE,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAE9B,YAAY,SAAS,EAAE,MAAM,EAAE,OAAO,GAAE,uBAA4B,EAOnE;IAED,OAAO,CAAC,IAAI;IAQZ,OAAO,CAAC,KAAK;IAOb;;;;;OAKG;IACG,oBAAoB,IAAI,OAAO,CAAC,mBAAmB,CAAC,CAwBzD;IAED,sEAAsE;IAChE,wBAAwB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAGjE;IAED,gFAAgF;IAChF,mBAAmB,IAAI,IAAI,CAI1B;IAID,IAAI,WAAW,IAAI,MAAM,GAAG,SAAS,CAMpC;IAED,IAAI,cAAc,IAAI,mBAAmB,CAQxC;IAED,KAAK,IAAI,MAAM,CAGd;IAEK,iBAAiB,IAAI,OAAO,CAAC,2BAA2B,GAAG,SAAS,CAAC,CAM1E;IAED,qBAAqB,CAAC,iBAAiB,EAAE,2BAA2B,GAAG,IAAI,CAM1E;IAED,MAAM,IAAI,WAAW,GAAG,SAAS,CAEhC;IAED,UAAU,CAAC,MAAM,EAAE,WAAW,GAAG,IAAI,CAIpC;IAEK,uBAAuB,CAAC,gBAAgB,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC,CAGlE;IAED,gBAAgB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAI3C;IAED,YAAY,IAAI,MAAM,CAIrB;IAED,qBAAqB,CAAC,KAAK,EAAE,KAAK,GAAG,QAAQ,GAAG,QAAQ,GAAG,UAAU,GAAG,WAAW,GAAG,IAAI,CAkBzF;CACD","sourcesContent":["/**\n * OAuth support for remote MCP servers: a file-backed\n * {@link OAuthClientProvider} for the official MCP SDK plus a loopback\n * callback server for the browser-based authorization-code + PKCE flow.\n *\n * The SDK's `auth()` orchestrates discovery (RFC 9728 / RFC 8414), dynamic\n * client registration, PKCE, token exchange, and refresh; this module supplies\n * the persistence and user-interaction pieces:\n * - per-server-URL state (client registration, tokens, code verifier) stored\n * as 0600 JSON files under `~/.hoocode/mcp-auth/` by default;\n * - a localhost HTTP server that receives the authorization redirect. It is\n * started lazily inside `clientInformation()` — the first provider call of\n * every interactive `auth()` run that happens before the authorization URL\n * is built — so no listener exists unless an OAuth flow is actually running.\n * Re-binding the port used at registration keeps persisted dynamic client\n * registrations valid; if the port is taken, the registration is dropped so\n * the SDK re-registers with the fresh redirect URI.\n */\n\nimport { spawn } from \"node:child_process\";\nimport { createHash, randomBytes } from \"node:crypto\";\nimport { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from \"node:fs\";\nimport { createServer, type Server } from \"node:http\";\nimport type { AddressInfo } from \"node:net\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport type { OAuthClientProvider } from \"@modelcontextprotocol/sdk/client/auth.js\";\nimport type {\n\tOAuthClientInformationMixed,\n\tOAuthClientMetadata,\n\tOAuthTokens,\n} from \"@modelcontextprotocol/sdk/shared/auth.js\";\n\n/** Default directory for persisted MCP OAuth state. */\nexport function defaultMcpAuthDir(): string {\n\treturn join(homedir(), \".hoocode\", \"mcp-auth\");\n}\n\n/** Open a URL in the user's browser (best-effort, detached). */\nexport function openBrowserDefault(url: string): void {\n\tconst [cmd, args] =\n\t\tprocess.platform === \"darwin\"\n\t\t\t? [\"open\", [url]]\n\t\t\t: process.platform === \"win32\"\n\t\t\t\t? [\"cmd\", [\"/c\", \"start\", \"\", url]]\n\t\t\t\t: [\"xdg-open\", [url]];\n\ttry {\n\t\tspawn(cmd, args as string[], { detached: true, stdio: \"ignore\" }).unref();\n\t} catch {\n\t\t// best-effort — the authorization URL is also surfaced via onAuthorizationUrl\n\t}\n}\n\ninterface PersistedAuthState {\n\tserverUrl: string;\n\tclientInformation?: OAuthClientInformationMixed;\n\t/** Redirect URI the dynamic client registration was performed with. */\n\tredirectUrl?: string;\n\ttokens?: OAuthTokens;\n\tcodeVerifier?: string;\n}\n\nconst CALLBACK_PATH = \"/callback\";\n\ninterface CallbackWaiter {\n\tresolve: (code: string) => void;\n\treject: (err: Error) => void;\n\texpectedState?: string;\n}\n\n/** Loopback HTTP server that receives the OAuth authorization redirect. */\nclass OAuthCallbackServer {\n\tprivate server: Server;\n\tprivate waiter?: CallbackWaiter;\n\t/** Redirect that arrived before anyone called waitForCode (fast redirects). */\n\tprivate buffered?: { code?: string; state?: string; error?: string };\n\treadonly url: string;\n\n\tprivate constructor(server: Server, port: number) {\n\t\tthis.server = server;\n\t\tthis.url = `http://127.0.0.1:${port}${CALLBACK_PATH}`;\n\t}\n\n\t/** Bind the callback server; `preferredPort` first, any free port otherwise. */\n\tstatic async start(preferredPort?: number): Promise<{ server: OAuthCallbackServer; reboundPort: boolean }> {\n\t\tconst listen = (port: number) =>\n\t\t\tnew Promise<Server>((resolve, reject) => {\n\t\t\t\tconst srv = createServer();\n\t\t\t\tsrv.once(\"error\", reject);\n\t\t\t\tsrv.listen(port, \"127.0.0.1\", () => {\n\t\t\t\t\tsrv.removeAllListeners(\"error\");\n\t\t\t\t\tresolve(srv);\n\t\t\t\t});\n\t\t\t});\n\n\t\tlet server: Server;\n\t\tlet reboundPort = true;\n\t\tif (preferredPort) {\n\t\t\ttry {\n\t\t\t\tserver = await listen(preferredPort);\n\t\t\t} catch {\n\t\t\t\tserver = await listen(0);\n\t\t\t\treboundPort = false;\n\t\t\t}\n\t\t} else {\n\t\t\tserver = await listen(0);\n\t\t\treboundPort = false;\n\t\t}\n\n\t\tconst cb = new OAuthCallbackServer(server, (server.address() as AddressInfo).port);\n\t\tserver.on(\"request\", (req, res) => {\n\t\t\tconst url = new URL(req.url ?? \"/\", cb.url);\n\t\t\tif (url.pathname !== CALLBACK_PATH) {\n\t\t\t\tres.writeHead(404).end();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst code = url.searchParams.get(\"code\") ?? undefined;\n\t\t\tconst state = url.searchParams.get(\"state\") ?? undefined;\n\t\t\tconst error = url.searchParams.get(\"error\") ?? undefined;\n\t\t\tif (error || !code) {\n\t\t\t\tres.writeHead(400, { \"content-type\": \"text/html\" }).end(\"<h3>Authorization failed.</h3>\");\n\t\t\t} else {\n\t\t\t\tres.writeHead(200, { \"content-type\": \"text/html\" }).end(\n\t\t\t\t\t\"<h3>Authorization complete.</h3><p>You can return to hoocode.</p>\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst waiter = cb.waiter;\n\t\t\tif (waiter) {\n\t\t\t\tcb.waiter = undefined;\n\t\t\t\tcb.deliver(waiter, { code, state, error });\n\t\t\t} else {\n\t\t\t\t// Redirect can land before the client starts waiting (instant\n\t\t\t\t// redirects, test drivers); hold it for the next waitForCode.\n\t\t\t\tcb.buffered = { code, state, error };\n\t\t\t}\n\t\t});\n\t\treturn { server: cb, reboundPort };\n\t}\n\n\tprivate deliver(waiter: CallbackWaiter, result: { code?: string; state?: string; error?: string }): void {\n\t\tif (result.error || !result.code) {\n\t\t\twaiter.reject(new Error(`OAuth authorization failed: ${result.error ?? \"no code returned\"}`));\n\t\t} else if (waiter.expectedState && result.state !== waiter.expectedState) {\n\t\t\twaiter.reject(new Error(\"OAuth authorization failed: state mismatch\"));\n\t\t} else {\n\t\t\twaiter.resolve(result.code);\n\t\t}\n\t}\n\n\twaitForCode(expectedState: string | undefined, timeoutMs: number): Promise<string> {\n\t\treturn new Promise<string>((resolve, reject) => {\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tthis.waiter = undefined;\n\t\t\t\treject(new Error(`Timed out waiting ${timeoutMs}ms for OAuth authorization`));\n\t\t\t}, timeoutMs);\n\t\t\ttimer.unref?.();\n\t\t\tconst waiter: CallbackWaiter = {\n\t\t\t\texpectedState,\n\t\t\t\tresolve: (code) => {\n\t\t\t\t\tclearTimeout(timer);\n\t\t\t\t\tresolve(code);\n\t\t\t\t},\n\t\t\t\treject: (err) => {\n\t\t\t\t\tclearTimeout(timer);\n\t\t\t\t\treject(err);\n\t\t\t\t},\n\t\t\t};\n\t\t\tconst buffered = this.buffered;\n\t\t\tif (buffered) {\n\t\t\t\tthis.buffered = undefined;\n\t\t\t\tthis.deliver(waiter, buffered);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.waiter = waiter;\n\t\t});\n\t}\n\n\tclose(): void {\n\t\tthis.waiter?.reject(new Error(\"OAuth callback server closed\"));\n\t\tthis.waiter = undefined;\n\t\tthis.server.close();\n\t\t// Sever keep-alive connections so close() doesn't linger.\n\t\tthis.server.closeAllConnections?.();\n\t}\n}\n\nexport interface McpOAuthProviderOptions {\n\t/** Directory for persisted auth state (default `~/.hoocode/mcp-auth`). */\n\tstorageDir?: string;\n\t/** Open the authorization URL (default: spawn the platform browser opener). */\n\topenBrowser?: (url: string) => void | Promise<void>;\n}\n\n/**\n * File-backed OAuth provider for one remote MCP server, keyed by server URL.\n * Implements the SDK's {@link OAuthClientProvider} so `auth()` handles the\n * protocol; this class owns persistence, the loopback redirect listener, and\n * opening the user's browser.\n */\nexport class McpFileOAuthProvider implements OAuthClientProvider {\n\tprivate readonly serverUrl: string;\n\tprivate readonly statePath: string;\n\tprivate readonly openBrowser: (url: string) => void | Promise<void>;\n\tprivate callback?: OAuthCallbackServer;\n\tprivate callbackStarting?: Promise<OAuthCallbackServer>;\n\tprivate currentState?: string;\n\t/** Last authorization URL handed to `redirectToAuthorization`. */\n\tlastAuthorizationUrl?: string;\n\n\tconstructor(serverUrl: string, options: McpOAuthProviderOptions = {}) {\n\t\tthis.serverUrl = serverUrl;\n\t\tthis.openBrowser = options.openBrowser ?? openBrowserDefault;\n\t\tconst dir = options.storageDir ?? defaultMcpAuthDir();\n\t\tconst hash = createHash(\"sha256\").update(serverUrl).digest(\"hex\").slice(0, 12);\n\t\tconst host = (URL.canParse(serverUrl) ? new URL(serverUrl).hostname : \"server\").replace(/[^a-zA-Z0-9.-]/g, \"_\");\n\t\tthis.statePath = join(dir, `${host}-${hash}.json`);\n\t}\n\n\tprivate read(): PersistedAuthState {\n\t\ttry {\n\t\t\treturn JSON.parse(readFileSync(this.statePath, \"utf8\")) as PersistedAuthState;\n\t\t} catch {\n\t\t\treturn { serverUrl: this.serverUrl };\n\t\t}\n\t}\n\n\tprivate write(mutate: (state: PersistedAuthState) => void): void {\n\t\tconst state = this.read();\n\t\tmutate(state);\n\t\tmkdirSync(dirname(this.statePath), { recursive: true });\n\t\twriteFileSync(this.statePath, JSON.stringify(state, null, 2), { mode: 0o600 });\n\t}\n\n\t/**\n\t * Ensure the loopback redirect listener is running. Prefers the port used at\n\t * dynamic client registration so the persisted client stays valid; when that\n\t * port can't be re-bound the registration is dropped and the SDK registers a\n\t * fresh client against the new redirect URI.\n\t */\n\tasync ensureCallbackServer(): Promise<OAuthCallbackServer> {\n\t\tif (this.callback) return this.callback;\n\t\tif (!this.callbackStarting) {\n\t\t\tthis.callbackStarting = (async () => {\n\t\t\t\tconst persisted = this.read();\n\t\t\t\tconst preferredPort =\n\t\t\t\t\tpersisted.redirectUrl && URL.canParse(persisted.redirectUrl)\n\t\t\t\t\t\t? Number(new URL(persisted.redirectUrl).port) || undefined\n\t\t\t\t\t\t: undefined;\n\t\t\t\tconst { server, reboundPort } = await OAuthCallbackServer.start(preferredPort);\n\t\t\t\tif (preferredPort && !reboundPort) {\n\t\t\t\t\tthis.write((s) => {\n\t\t\t\t\t\ts.clientInformation = undefined;\n\t\t\t\t\t\ts.redirectUrl = undefined;\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\tthis.callback = server;\n\t\t\t\treturn server;\n\t\t\t})();\n\t\t\tthis.callbackStarting.catch(() => {\n\t\t\t\tthis.callbackStarting = undefined;\n\t\t\t});\n\t\t}\n\t\treturn this.callbackStarting;\n\t}\n\n\t/** Wait for the browser redirect to deliver an authorization code. */\n\tasync waitForAuthorizationCode(timeoutMs: number): Promise<string> {\n\t\tconst server = await this.ensureCallbackServer();\n\t\treturn server.waitForCode(this.currentState, timeoutMs);\n\t}\n\n\t/** Stop the loopback listener (auth finished, failed, or connection closed). */\n\tcloseCallbackServer(): void {\n\t\tthis.callback?.close();\n\t\tthis.callback = undefined;\n\t\tthis.callbackStarting = undefined;\n\t}\n\n\t// ---- OAuthClientProvider ----\n\n\tget redirectUrl(): string | undefined {\n\t\t// Must stay defined even before the listener is up: an undefined\n\t\t// redirectUrl tells the SDK to run a non-interactive grant instead of the\n\t\t// authorization-code flow. The real port is bound in clientInformation()\n\t\t// before the SDK builds the authorization URL.\n\t\treturn this.callback?.url ?? this.read().redirectUrl ?? \"http://127.0.0.1:0/callback\";\n\t}\n\n\tget clientMetadata(): OAuthClientMetadata {\n\t\treturn {\n\t\t\tclient_name: \"hoocode\",\n\t\t\tredirect_uris: [String(this.redirectUrl)],\n\t\t\tgrant_types: [\"authorization_code\", \"refresh_token\"],\n\t\t\tresponse_types: [\"code\"],\n\t\t\ttoken_endpoint_auth_method: \"none\",\n\t\t};\n\t}\n\n\tstate(): string {\n\t\tthis.currentState ??= randomBytes(16).toString(\"hex\");\n\t\treturn this.currentState;\n\t}\n\n\tasync clientInformation(): Promise<OAuthClientInformationMixed | undefined> {\n\t\t// First provider call of every auth() run that precedes building the\n\t\t// authorization URL — bind the redirect listener here so redirectUrl is\n\t\t// live for registration and the authorization request.\n\t\tawait this.ensureCallbackServer();\n\t\treturn this.read().clientInformation;\n\t}\n\n\tsaveClientInformation(clientInformation: OAuthClientInformationMixed): void {\n\t\tconst redirectUrl = String(this.redirectUrl);\n\t\tthis.write((s) => {\n\t\t\ts.clientInformation = clientInformation;\n\t\t\ts.redirectUrl = redirectUrl;\n\t\t});\n\t}\n\n\ttokens(): OAuthTokens | undefined {\n\t\treturn this.read().tokens;\n\t}\n\n\tsaveTokens(tokens: OAuthTokens): void {\n\t\tthis.write((s) => {\n\t\t\ts.tokens = tokens;\n\t\t});\n\t}\n\n\tasync redirectToAuthorization(authorizationUrl: URL): Promise<void> {\n\t\tthis.lastAuthorizationUrl = authorizationUrl.toString();\n\t\tawait this.openBrowser(this.lastAuthorizationUrl);\n\t}\n\n\tsaveCodeVerifier(codeVerifier: string): void {\n\t\tthis.write((s) => {\n\t\t\ts.codeVerifier = codeVerifier;\n\t\t});\n\t}\n\n\tcodeVerifier(): string {\n\t\tconst verifier = this.read().codeVerifier;\n\t\tif (!verifier) throw new Error(`No PKCE code verifier stored for ${this.serverUrl}`);\n\t\treturn verifier;\n\t}\n\n\tinvalidateCredentials(scope: \"all\" | \"client\" | \"tokens\" | \"verifier\" | \"discovery\"): void {\n\t\tif (scope === \"discovery\") return;\n\t\tif (scope === \"all\" && existsSync(this.statePath)) {\n\t\t\ttry {\n\t\t\t\tunlinkSync(this.statePath);\n\t\t\t} catch {\n\t\t\t\t// fall through to field-level clearing\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\t\tthis.write((s) => {\n\t\t\tif (scope === \"all\" || scope === \"client\") {\n\t\t\t\ts.clientInformation = undefined;\n\t\t\t\ts.redirectUrl = undefined;\n\t\t\t}\n\t\t\tif (scope === \"all\" || scope === \"tokens\") s.tokens = undefined;\n\t\t\tif (scope === \"all\" || scope === \"verifier\") s.codeVerifier = undefined;\n\t\t});\n\t}\n}\n"]}