@octo-dock/mcp-bridge 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -4,13 +4,31 @@ Stdio ↔ HTTP bridge for [OctoDock](https://octo-dock.com) MCP.
4
4
 
5
5
  OctoDock serves Model Context Protocol over HTTP at `https://octo-dock.com/mcp/{apiKey}`. Some MCP clients (e.g. **Claude Desktop free tier**) only support stdio transport and cannot connect to HTTP remotes directly. This package is a thin bridge: it reads/writes stdio on one side and forwards JSON-RPC messages to the OctoDock HTTP endpoint on the other.
6
6
 
7
+ ## What's new in 0.2.0
8
+
9
+ **OAuth 2.0 + PKCE S256 is now the default login flow.** The bridge no longer stores your MCP URL with embedded API key — instead it holds short-lived access tokens + refresh tokens that are auto-rotated. Credential leakage risk drops dramatically (sharing your URL no longer equals sharing your Gmail control).
10
+
11
+ - Default `login` opens a browser to OctoDock's authorization page, you click Allow, bridge stores `access_token` + `refresh_token` + `client_credentials` locally (`~/.octodock/config.json` with `chmod 0o600`).
12
+ - `login --legacy-api-key` flag keeps the old URL-paste flow for backward compatibility (0.1.x config migrates automatically — no re-login required if you don't want).
13
+ - Existing 0.1.x users upgrading to 0.2.0: bridge detects legacy config and keeps working. Run `login` (without flag) whenever you want to migrate to OAuth.
14
+
7
15
  ## Quick start
8
16
 
17
+ ### New user (OAuth — recommended)
18
+
9
19
  ```bash
10
20
  npx -y @octo-dock/mcp-bridge login
11
21
  ```
12
22
 
13
- This opens [octo-dock.com/dashboard](https://octo-dock.com/dashboard), you copy your MCP URL from the top of the page, paste it back into the terminal, and the bridge saves it to `~/.octodock/config.json`.
23
+ This opens your browser to OctoDock's authorization page. Sign in, click **Allow**, bridge saves OAuth credentials to `~/.octodock/config.json`.
24
+
25
+ ### Existing 0.1.x user or OAuth not available (legacy)
26
+
27
+ ```bash
28
+ npx -y @octo-dock/mcp-bridge login --legacy-api-key
29
+ ```
30
+
31
+ This opens [octo-dock.com/dashboard](https://octo-dock.com/dashboard), you copy your legacy MCP URL (the one containing `ak_xxxxxx`) from the collapsed section on the dashboard, paste it back into the terminal.
14
32
 
15
33
  Then add the bridge to your MCP client config.
16
34
 
@@ -40,15 +58,18 @@ Run `npx -y @octo-dock/mcp-bridge` with no args. It reads `~/.octodock/config.js
40
58
 
41
59
  ## Environment variables
42
60
 
43
- - `OCTODOCK_MCP_URL` — override the stored URL at runtime (useful in CI / Docker).
44
- - `OCTODOCK_DASHBOARD_URL` — override the dashboard URL opened by `login` (default `https://octo-dock.com/dashboard`).
61
+ - `OCTODOCK_MCP_URL` — override the stored URL at runtime, legacy mode only (useful in CI / Docker).
62
+ - `OCTODOCK_DASHBOARD_URL` — override the dashboard URL opened by legacy `login --legacy-api-key` (default `https://octo-dock.com/dashboard`).
63
+ - `OCTODOCK_ISSUER_URL` — override OAuth issuer base URL used by default `login` (default `https://octo-dock.com`).
45
64
 
46
65
  ## How it works
47
66
 
48
67
  The bridge opens two MCP SDK transports and wires their `onmessage` / `send` hooks together:
49
68
 
50
69
  - `StdioServerTransport` — reads the MCP client's JSON-RPC messages on stdin, writes responses to stdout.
51
- - `StreamableHTTPClientTransport` — POSTs JSON-RPC messages to `https://octo-dock.com/mcp/{apiKey}`, subscribes to SSE for server-initiated messages.
70
+ - `StreamableHTTPClientTransport` — sends JSON-RPC to OctoDock remote, subscribes to SSE for server-initiated messages.
71
+ - **OAuth mode (0.2.0+ default):** connects to `https://octo-dock.com/mcp` with `Authorization: Bearer <access_token>`. Access tokens auto-refresh before expiry (60-second window); if refresh fails, bridge shuts down and asks you to re-login.
72
+ - **Legacy mode:** connects to `https://octo-dock.com/mcp/{apiKey}` directly (API key in URL).
52
73
 
53
74
  No message parsing or state machine — pure forwarding. Whatever your MCP client says, OctoDock hears; whatever OctoDock says, your MCP client hears.
54
75
 
package/dist/bridge.js CHANGED
@@ -1,25 +1,123 @@
1
- // stdio ↔ HTTP transparent proxy
1
+ // stdio ↔ HTTP transparent proxy for @octo-dock/mcp-bridge
2
+ //
3
+ // Phase C4 雙軌 runtime:
4
+ // - OAuth 模式:從 config 拿 accessToken 當 Authorization: Bearer header、
5
+ // 啟動前預判 tokenExpiresAt < now + 60s 主動 refresh、refresh 結果寫回
6
+ // config(chmod 0o600)。issuerUrl 用於 fetch metadata + endpoint 路由。
7
+ // - Legacy 模式:保留 C2 之前路徑、URL 夾 API key 直接 StreamableHTTP 連
8
+ //
2
9
  // 架構:本地跑一個 StdioServerTransport 吃 stdin/stdout,另一邊開一個
3
10
  // StreamableHTTPClientTransport 連到 OctoDock,收到的訊息直接互相轉發。
4
11
  // 不做 method 解析、不做 JSON-RPC 狀態機,純 forward。
12
+ //
13
+ // 401 處理策略(bot-2 pre-flight #4):bridge 啟動前主動 refresh 解 90%
14
+ // 情境。運作中若 transport error 含 401/invalid_token 內容、shutdown +
15
+ // 提示「下次啟動會自動 refresh、如 refresh 仍失敗請 re-login」— 不做 hot
16
+ // token swap(MCP SDK transport 中途換 token 複雜度高、refresh 失敗無限
17
+ // retry 會違反 pre-flight #4 防循環)
5
18
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
19
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
7
- import { readConfig } from "./config.js";
20
+ import { readConfig, writeConfig, getConfigMode } from "./config.js";
21
+ import { fetchAuthorizationServerMetadata, refreshAccessToken, redactSecrets, } from "./oauth-client.js";
22
+ /** Token refresh pre-check:少於 60 秒才 refresh、避免每次 bridge 啟動都打 token endpoint */
23
+ const REFRESH_THRESHOLD_MS = 60_000;
8
24
  function logStderr(msg) {
9
25
  // Claude Desktop 會把 stderr 收到 log file;stdout 不能寫非 JSON-RPC 訊息否則會污染 protocol
10
26
  process.stderr.write(`[octodock-mcp-bridge] ${msg}\n`);
11
27
  }
28
+ /**
29
+ * OAuth 模式:確保 accessToken 可用(快過期則 refresh)。
30
+ * Refresh 成功回新 config、失敗 throw 明確錯讓上層提示 re-login。
31
+ */
32
+ async function ensureFreshAccessToken(config) {
33
+ if (!config.accessToken ||
34
+ !config.refreshToken ||
35
+ !config.clientId ||
36
+ !config.clientSecret ||
37
+ !config.issuerUrl ||
38
+ !config.tokenExpiresAt) {
39
+ throw new Error("OAuth config incomplete — 請執行 `npx @octo-dock/mcp-bridge login`");
40
+ }
41
+ const needsRefresh = config.tokenExpiresAt < Date.now() + REFRESH_THRESHOLD_MS;
42
+ if (!needsRefresh)
43
+ return config;
44
+ logStderr("Access token 快過期、正在 refresh...");
45
+ const metadata = await fetchAuthorizationServerMetadata(config.issuerUrl);
46
+ const newTokens = await refreshAccessToken({
47
+ tokenEndpoint: metadata.token_endpoint,
48
+ clientId: config.clientId,
49
+ clientSecret: config.clientSecret,
50
+ refreshToken: config.refreshToken,
51
+ });
52
+ const updated = {
53
+ ...config,
54
+ accessToken: newTokens.accessToken,
55
+ refreshToken: newTokens.refreshToken,
56
+ tokenExpiresAt: newTokens.expiresAt,
57
+ };
58
+ await writeConfig(updated);
59
+ logStderr("✓ Token 已刷新");
60
+ return updated;
61
+ }
62
+ /**
63
+ * 組 StreamableHTTPClientTransport、根據 mode 決定是否帶 Bearer header
64
+ */
65
+ function createHttpTransport(config) {
66
+ const mode = getConfigMode(config);
67
+ if (mode === "oauth") {
68
+ // OAuth 模式:endpoint 是 issuerUrl + /mcp、Bearer header 帶 accessToken
69
+ const remoteUrl = new URL("/mcp", config.issuerUrl);
70
+ const transport = new StreamableHTTPClientTransport(remoteUrl, {
71
+ requestInit: {
72
+ headers: {
73
+ Authorization: `Bearer ${config.accessToken}`,
74
+ },
75
+ },
76
+ });
77
+ return { transport, remoteUrl };
78
+ }
79
+ // Legacy 模式:URL path 含 ak_xxx API key、無 Authorization header
80
+ const remoteUrl = new URL(config.mcpUrl);
81
+ const transport = new StreamableHTTPClientTransport(remoteUrl);
82
+ return { transport, remoteUrl };
83
+ }
12
84
  export async function runBridge() {
13
- const config = await readConfig();
85
+ let config = await readConfig();
14
86
  if (!config) {
15
87
  logStderr("找不到設定檔 ~/.octodock/config.json。請先執行:npx -y @octo-dock/mcp-bridge login");
16
88
  process.exitCode = 2;
17
89
  return;
18
90
  }
19
- const remoteUrl = new URL(config.mcpUrl);
20
- logStderr(`啟動 bridge ${remoteUrl.origin}${remoteUrl.pathname}`);
91
+ const initialMode = getConfigMode(config);
92
+ if (initialMode === "uninitialized") {
93
+ logStderr("Config 不完整(沒有 OAuth tokens 也沒有 legacy mcpUrl)。請重新執行 `npx @octo-dock/mcp-bridge login`");
94
+ process.exitCode = 2;
95
+ return;
96
+ }
97
+ // OAuth 模式:啟動前預先刷新快過期的 access token
98
+ if (initialMode === "oauth") {
99
+ try {
100
+ config = await ensureFreshAccessToken(config);
101
+ }
102
+ catch (err) {
103
+ const errMsg = err instanceof Error ? err.message : String(err);
104
+ logStderr(`✗ OAuth token refresh 失敗:${errMsg}`);
105
+ logStderr("提示:若 refresh token 已過期或被撤銷、請執行 `npx @octo-dock/mcp-bridge login` 重新授權");
106
+ // redact 再 log 完整錯誤給 debug 用
107
+ try {
108
+ logStderr(`debug: ${JSON.stringify(redactSecrets(err))}`);
109
+ }
110
+ catch {
111
+ /* JSON fail 吞 */
112
+ }
113
+ process.exitCode = 3;
114
+ return;
115
+ }
116
+ }
117
+ const { transport: http, remoteUrl } = createHttpTransport(config);
118
+ const modeLabel = initialMode === "oauth" ? "OAuth" : "legacy API key";
119
+ logStderr(`啟動 bridge → ${remoteUrl.origin}${remoteUrl.pathname} (${modeLabel} mode)`);
21
120
  const stdio = new StdioServerTransport();
22
- const http = new StreamableHTTPClientTransport(remoteUrl);
23
121
  // stdin → HTTP
24
122
  stdio.onmessage = (msg) => {
25
123
  http.send(msg).catch((err) => {
@@ -47,8 +145,19 @@ export async function runBridge() {
47
145
  stdio.onclose = () => { void shutdown("stdio closed"); };
48
146
  http.onclose = () => { void shutdown("remote closed"); };
49
147
  stdio.onerror = (err) => { logStderr(`stdio error: ${err.message}`); };
50
- http.onerror = (err) => { logStderr(`remote error: ${err.message}`); };
51
- // 先起 HTTP client,確認連線 OK 再吃 stdin,避免 race
148
+ http.onerror = (err) => {
149
+ // OAuth 模式下遇到 401 / unauthorized 類錯誤、提示用戶下次啟動會自動 refresh
150
+ // 若啟動前 ensureFreshAccessToken 已通但運作中仍 401、代表 server 撤銷或 token
151
+ // 被無效化、不 hot swap 避免 bot-2 pre-flight #4 無限 retry 風險
152
+ const errMsg = err.message.toLowerCase();
153
+ if (initialMode === "oauth" && (errMsg.includes("401") || errMsg.includes("unauthorized") || errMsg.includes("invalid_token"))) {
154
+ logStderr(`remote error: ${err.message} — 可能 access token 已被撤銷、請執行 \`npx @octo-dock/mcp-bridge login\` 重新授權`);
155
+ }
156
+ else {
157
+ logStderr(`remote error: ${err.message}`);
158
+ }
159
+ };
160
+ // 先起 HTTP client、確認連線 OK 再吃 stdin、避免 race
52
161
  try {
53
162
  await http.start();
54
163
  }
package/dist/cli.js CHANGED
@@ -57,7 +57,9 @@ async function main() {
57
57
  return;
58
58
  }
59
59
  if (cmd === "login") {
60
- await runLogin();
60
+ // Phase C3:預設走 OAuth、--legacy-api-key flag fallback 到舊貼 URL 模式
61
+ const legacy = args.includes("--legacy-api-key");
62
+ await runLogin({ legacy });
61
63
  return;
62
64
  }
63
65
  if (cmd && cmd.startsWith("-")) {
package/dist/config.js CHANGED
@@ -1,5 +1,12 @@
1
1
  // 讀寫 ~/.octodock/config.json
2
- // 這個檔案存的是使用者的 MCP URL(包含 API key),所以要 chmod 600 避免同機器其他使用者讀到。
2
+ //
3
+ // Phase C2 雙軌 schema:
4
+ // - legacy 模式(bridge 0.1.x 舊用戶):只有 mcpUrl 欄位、URL path 含 ak_xxx API key
5
+ // - OAuth 模式(bridge 0.2.0+):加 issuerUrl + clientId/clientSecret + accessToken/refreshToken/tokenExpiresAt
6
+ //
7
+ // readConfig 會自動識別 config 形態、不 break 舊用戶升級(跟 C3 login `--legacy-api-key` flag 搭配)
8
+ // writeConfig chmod 0o600 保護 accessToken / refreshToken / clientSecret 不讓同機器其他用戶讀到
9
+ // (對應 bot-2 D2 pre-flight #1 Token 本地儲存安全性)
3
10
  import { homedir } from "node:os";
4
11
  import { join } from "node:path";
5
12
  import { promises as fs } from "node:fs";
@@ -8,6 +15,18 @@ const CONFIG_PATH = join(CONFIG_DIR, "config.json");
8
15
  export function getConfigPath() {
9
16
  return CONFIG_PATH;
10
17
  }
18
+ /** 判斷 config 是 oauth / legacy / uninitialized 哪種模式 */
19
+ export function getConfigMode(config) {
20
+ if (!config)
21
+ return "uninitialized";
22
+ // OAuth 模式優先檢查:有 accessToken + clientId + issuerUrl 才算 OAuth ready
23
+ if (config.accessToken && config.clientId && config.issuerUrl)
24
+ return "oauth";
25
+ // Legacy 模式:只有 mcpUrl
26
+ if (config.mcpUrl)
27
+ return "legacy";
28
+ return "uninitialized";
29
+ }
11
30
  export async function readConfig() {
12
31
  // ENV override 優先:CI / Docker 情境 user 可能直接設環境變數而不寫檔
13
32
  const envUrl = process.env.OCTODOCK_MCP_URL?.trim();
@@ -17,10 +36,24 @@ export async function readConfig() {
17
36
  try {
18
37
  const raw = await fs.readFile(CONFIG_PATH, "utf-8");
19
38
  const parsed = JSON.parse(raw);
20
- if (!parsed.mcpUrl || typeof parsed.mcpUrl !== "string") {
39
+ // Migration:舊 config 只有 mcpUrl、新 config 可能同時有 mcpUrl + OAuth 欄位
40
+ // 兩種都當 valid config 讀取、runtime 靠 getConfigMode 判斷走哪條路
41
+ const hasLegacyUrl = typeof parsed.mcpUrl === "string" && parsed.mcpUrl.length > 0;
42
+ const hasOAuthSet = typeof parsed.accessToken === "string" &&
43
+ typeof parsed.clientId === "string" &&
44
+ typeof parsed.issuerUrl === "string";
45
+ if (!hasLegacyUrl && !hasOAuthSet) {
21
46
  return null;
22
47
  }
23
- return { mcpUrl: parsed.mcpUrl };
48
+ return {
49
+ mcpUrl: typeof parsed.mcpUrl === "string" ? parsed.mcpUrl : undefined,
50
+ issuerUrl: typeof parsed.issuerUrl === "string" ? parsed.issuerUrl : undefined,
51
+ clientId: typeof parsed.clientId === "string" ? parsed.clientId : undefined,
52
+ clientSecret: typeof parsed.clientSecret === "string" ? parsed.clientSecret : undefined,
53
+ accessToken: typeof parsed.accessToken === "string" ? parsed.accessToken : undefined,
54
+ refreshToken: typeof parsed.refreshToken === "string" ? parsed.refreshToken : undefined,
55
+ tokenExpiresAt: typeof parsed.tokenExpiresAt === "number" ? parsed.tokenExpiresAt : undefined,
56
+ };
24
57
  }
25
58
  catch (err) {
26
59
  if (err.code === "ENOENT")
@@ -28,10 +61,24 @@ export async function readConfig() {
28
61
  throw err;
29
62
  }
30
63
  }
64
+ /**
65
+ * 寫入 config。chmod 0o600 是 bot-2 D2 pre-flight #1 token 本地儲存安全性的落實
66
+ * (同機器其他 user 讀不到 accessToken / refreshToken / clientSecret)
67
+ */
31
68
  export async function writeConfig(config) {
32
69
  await fs.mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
33
- await fs.writeFile(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
70
+ // 過濾 undefined 欄位再 stringify、避免 JSON explicit null/undefined
71
+ const filtered = {};
72
+ for (const [k, v] of Object.entries(config)) {
73
+ if (v !== undefined && v !== null)
74
+ filtered[k] = v;
75
+ }
76
+ await fs.writeFile(CONFIG_PATH, JSON.stringify(filtered, null, 2), { mode: 0o600 });
34
77
  }
78
+ /**
79
+ * 驗 legacy mcpUrl 格式:protocol http/https + path 含 /mcp/{apiKey}
80
+ * 這個驗證只適用 legacy 模式,OAuth 模式用 issuerUrl 走 /.well-known/ 發現
81
+ */
35
82
  export function validateMcpUrl(url) {
36
83
  let parsed;
37
84
  try {
@@ -49,3 +96,24 @@ export function validateMcpUrl(url) {
49
96
  }
50
97
  return { ok: true };
51
98
  }
99
+ /**
100
+ * 驗 OAuth issuerUrl 格式:protocol http/https + 無 path(或僅 /)
101
+ * bridge login OAuth flow 接收 issuerUrl 時用
102
+ */
103
+ export function validateIssuerUrl(url) {
104
+ let parsed;
105
+ try {
106
+ parsed = new URL(url);
107
+ }
108
+ catch {
109
+ return { ok: false, reason: "不是合法的 URL 格式" };
110
+ }
111
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
112
+ return { ok: false, reason: "protocol 必須是 http 或 https" };
113
+ }
114
+ // issuerUrl 應該是 base URL 不含 path(例如 https://octo-dock.com)
115
+ if (parsed.pathname !== "/" && parsed.pathname !== "") {
116
+ return { ok: false, reason: "issuerUrl 應該是 base URL 不含 path(例如 https://octo-dock.com)" };
117
+ }
118
+ return { ok: true };
119
+ }
package/dist/login.js CHANGED
@@ -1,10 +1,24 @@
1
- // 互動式 login:開瀏覽器到 dashboard,請使用者貼 MCP URL 回終端機
2
- // 這是 MVP 版本的 device flow:手動複製貼上,不走 OAuth device code(等後端加 endpoint 再升級)
1
+ // Interactive login for @octo-dock/mcp-bridge
2
+ //
3
+ // Phase C3:預設走 OAuth 2.0 authorization code flow + PKCE S256、拿 access/
4
+ // refresh tokens 存進 ~/.octodock/config.json。
5
+ //
6
+ // `--legacy-api-key` flag fallback 到 Phase C2 之前的舊流程(用戶手動貼含
7
+ // `ak_xxx` 的 MCP URL 回 terminal)、給雙軌期舊用戶 or 暫時無法走 OAuth 的
8
+ // 情境用(例如公司 proxy 擋瀏覽器 callback、或 CI 環境無互動)。
9
+ //
10
+ // 流程選擇原則:
11
+ // - 預設 OAuth:安全、URL 分享不等於 credential 洩漏、token 自動 refresh
12
+ // - legacy:URL 夾 API key 易用但 URL 洩漏等同 Gmail 控制權給人;未來
13
+ // Phase C 後期 bridge 升到 1.0 時考慮下架 legacy flag
3
14
  import { createInterface } from "node:readline/promises";
4
15
  import { stdin as input, stdout as output } from "node:process";
5
16
  import { spawn } from "node:child_process";
6
17
  import { writeConfig, validateMcpUrl, getConfigPath } from "./config.js";
18
+ import { startOAuthFlow, redactSecrets } from "./oauth-client.js";
7
19
  const DASHBOARD_URL = process.env.OCTODOCK_DASHBOARD_URL || "https://octo-dock.com/dashboard";
20
+ const DEFAULT_ISSUER_URL = process.env.OCTODOCK_ISSUER_URL || "https://octo-dock.com";
21
+ /** 開 default browser 給 OAuth / dashboard flow 用、延用跟 oauth-client 同 pattern */
8
22
  function openBrowser(url) {
9
23
  const platform = process.platform;
10
24
  let cmd;
@@ -27,26 +41,31 @@ function openBrowser(url) {
27
41
  child.unref();
28
42
  }
29
43
  catch {
30
- // 忽略:user 會看到 URL 自己開
44
+ /* ignore */
31
45
  }
32
46
  }
33
- export async function runLogin() {
34
- console.log("=== OctoDock MCP Bridge Login ===");
47
+ // ============================================================
48
+ // Legacy login flow(保留 C2 之前的貼 URL 模式、給 --legacy-api-key flag 或
49
+ // OAuth fail fallback 用)
50
+ // ============================================================
51
+ async function runLegacyLogin() {
52
+ console.log("=== OctoDock MCP Bridge ─ Legacy API Key Login ===");
35
53
  console.log("");
36
- console.log("這個流程會把你的 OctoDock MCP URL 存到本機 config,讓 Claude Desktop、Cursor stdio-only 客戶端可以使用你的帳號。");
54
+ console.log("注意:legacy 模式 URL API key、分享 URL 等同分享 Gmail 控制權。");
55
+ console.log("建議改走 OAuth 流程(不加 --legacy-api-key flag、重新執行 login)。");
37
56
  console.log("");
38
57
  console.log(`步驟 1:打開瀏覽器前往 ${DASHBOARD_URL}`);
39
- console.log("步驟 2:在 dashboard 上方複製你的 MCP URL(長得像 https://octo-dock.com/mcp/ak_xxxxxx");
40
- console.log("步驟 3:把 URL 貼回下面這個輸入框,按 Enter");
58
+ console.log("步驟 2:在 dashboard 展開「舊版 URL」折疊、複製含 ak_xxxxxx 的 URL");
59
+ console.log("步驟 3:把 URL 貼回下面這個輸入框、按 Enter");
41
60
  console.log("");
42
61
  openBrowser(DASHBOARD_URL);
43
62
  const rl = createInterface({ input, output });
44
63
  try {
45
- // 最多給 3 次輸入機會,無效就結束
64
+ // 最多給 3 次輸入機會、無效就結束
46
65
  for (let attempt = 0; attempt < 3; attempt++) {
47
66
  const url = (await rl.question("MCP URL > ")).trim();
48
67
  if (!url) {
49
- console.log("(空白輸入,請重新貼上 URL)");
68
+ console.log("(空白輸入、請重新貼上 URL)");
50
69
  continue;
51
70
  }
52
71
  const check = validateMcpUrl(url);
@@ -56,9 +75,9 @@ export async function runLogin() {
56
75
  }
57
76
  await writeConfig({ mcpUrl: url });
58
77
  console.log("");
59
- console.log(`✓ 設定已存到 ${getConfigPath()}`);
78
+ console.log(`✓ 設定已存到 ${getConfigPath()}(legacy 模式、含 API key)`);
60
79
  console.log("");
61
- console.log("接下來:把這段 JSON 貼到 Claude Desktop 的 claude_desktop_config.json(位置見 README),然後重啟 Claude Desktop");
80
+ console.log("接下來:把這段 JSON 貼到 Claude Desktop 的 claude_desktop_config.json、重啟 Claude Desktop");
62
81
  console.log("");
63
82
  console.log(JSON.stringify({
64
83
  mcpServers: {
@@ -70,10 +89,87 @@ export async function runLogin() {
70
89
  }, null, 2));
71
90
  return;
72
91
  }
73
- console.log("✗ 嘗試 3 次都無效,請重新執行 `npx @octo-dock/mcp-bridge login`");
92
+ console.log("✗ 嘗試 3 次都無效、請重新執行 `npx @octo-dock/mcp-bridge login`");
74
93
  process.exitCode = 1;
75
94
  }
76
95
  finally {
77
96
  rl.close();
78
97
  }
79
98
  }
99
+ // ============================================================
100
+ // OAuth login flow(Phase C3 預設)
101
+ // ============================================================
102
+ async function runOAuthLogin() {
103
+ console.log("=== OctoDock MCP Bridge ─ OAuth Login ===");
104
+ console.log("");
105
+ console.log("這個流程會走 OAuth 2.0 授權、拿到短效 access token 跟可續期的");
106
+ console.log("refresh token、存到本機 config(chmod 0o600、其他 user 讀不到)。");
107
+ console.log("");
108
+ console.log("你會看到瀏覽器打開 OctoDock 的授權頁、登入後點「允許」就完成。");
109
+ console.log("如果瀏覽器沒自動開、下面會印出授權 URL 讓你手動開。");
110
+ console.log("");
111
+ try {
112
+ const result = await startOAuthFlow({
113
+ issuerUrl: DEFAULT_ISSUER_URL,
114
+ clientName: "@octo-dock/mcp-bridge",
115
+ });
116
+ // 存進 config(chmod 0o600 由 writeConfig 負責)
117
+ const config = {
118
+ issuerUrl: result.issuerUrl,
119
+ clientId: result.clientId,
120
+ clientSecret: result.clientSecret,
121
+ accessToken: result.accessToken,
122
+ refreshToken: result.refreshToken,
123
+ tokenExpiresAt: result.tokenExpiresAt,
124
+ };
125
+ await writeConfig(config);
126
+ console.log("");
127
+ console.log(`✓ OAuth 授權完成、已存到 ${getConfigPath()}`);
128
+ console.log(` Client: ${result.clientId}`);
129
+ console.log(` Access token 過期時間:${new Date(result.tokenExpiresAt).toLocaleString("zh-TW")}`);
130
+ console.log(` Refresh token:${result.refreshToken.slice(0, 8)}... (自動續期)`);
131
+ console.log("");
132
+ console.log("接下來:把這段 JSON 貼到 Claude Desktop 的 claude_desktop_config.json、重啟 Claude Desktop:");
133
+ console.log("");
134
+ console.log(JSON.stringify({
135
+ mcpServers: {
136
+ octodock: {
137
+ command: "npx",
138
+ args: ["-y", "@octo-dock/mcp-bridge@latest"],
139
+ },
140
+ },
141
+ }, null, 2));
142
+ }
143
+ catch (err) {
144
+ // 錯誤訊息經 redactSecrets 再 log、防意外洩漏 token 到 stderr
145
+ const errMsg = err instanceof Error ? err.message : String(err);
146
+ console.error("");
147
+ console.error(`✗ OAuth login 失敗:${errMsg}`);
148
+ console.error("");
149
+ if (errMsg.includes("Another bridge instance is registering")) {
150
+ console.error("提示:你可能在跑多個 login、等幾秒重試、或手動刪 ~/.octodock/.register.lock");
151
+ }
152
+ else if (errMsg.includes("Timed out waiting for OAuth callback")) {
153
+ console.error("提示:OAuth 超時(5 分鐘)、可能瀏覽器沒打開或沒點同意、重新執行 login");
154
+ }
155
+ else {
156
+ console.error("如果問題反覆出現、可以試 `--legacy-api-key` flag 走舊 API key 模式。");
157
+ }
158
+ // 不重試 OAuth(refresh token 失敗等也會進來)、用戶自己下次再跑 login
159
+ process.exitCode = 1;
160
+ // 再印一次 redacted 完整 error 物件給 debug 用(沒 token 殘留)
161
+ try {
162
+ console.error("debug:", JSON.stringify(redactSecrets(err), null, 2));
163
+ }
164
+ catch {
165
+ /* JSON.stringify fail、吞 */
166
+ }
167
+ }
168
+ }
169
+ export async function runLogin(options = {}) {
170
+ if (options.legacy) {
171
+ await runLegacyLogin();
172
+ return;
173
+ }
174
+ await runOAuthLogin();
175
+ }
@@ -0,0 +1,357 @@
1
+ /**
2
+ * OAuth client for @octo-dock/mcp-bridge (Phase C Commit C1)
3
+ *
4
+ * 提供 bridge 走 OAuth 2.0 authorization code flow + PKCE S256 連接 OctoDock
5
+ * server 的所有 primitive。取代舊版「URL 夾 API key `ak_xxx`」單軌模式。
6
+ *
7
+ * 對齊 server-side(Phase A + A.5)實作:
8
+ * - /oauth/register:動態註冊 client(RFC 7591、open registration)
9
+ * - /oauth/authorize:PKCE S256 enforcement(code_challenge 驗 43-128 chars + S256 method)
10
+ * - /oauth/token:code_verifier 驗 SHA-256 比對(A.5 新增)
11
+ * - /oauth/revoke:logout 用(C3 login 會用到)
12
+ *
13
+ * Security 設計對齊 bot-2 D2 pre-flight 10 條 + bot-1 主動延伸 2 條:
14
+ * - PKCE S256(#3):code_verifier 32 random bytes base64url → challenge = SHA-256 base64url
15
+ * - Local callback server bind 127.0.0.1 不 0.0.0.0(#2、防 LAN 暴露)
16
+ * - state CSRF 防護(#2 延伸):隨機 state、callback 比對才 accept
17
+ * - Dynamic client registration race(#5):lock file `~/.octodock/.register.lock` 獨占
18
+ * - Log redact helper(#7):secret 欄位不進 stderr / crash stack
19
+ * - Token refresh 防循環(#4):refresh 失敗 → 明確提示 re-login、不無限 retry
20
+ * - Zero new npm deps(#8):Node 20 原生 fetch + crypto + http 模組
21
+ *
22
+ * Plan reference: docs/plan-oauth-upgrade.md @ 4d667e8 (v2e) 第三章 C1
23
+ */
24
+ import { createServer } from "node:http";
25
+ import { randomBytes, createHash } from "node:crypto";
26
+ import { spawn } from "node:child_process";
27
+ import { openSync, closeSync, unlinkSync } from "node:fs";
28
+ import { join } from "node:path";
29
+ import { homedir } from "node:os";
30
+ const CONFIG_DIR = join(homedir(), ".octodock");
31
+ const REGISTER_LOCK_PATH = join(CONFIG_DIR, ".register.lock");
32
+ // ============================================================
33
+ // Secret keys 清單:log redact helper 過濾這些欄位
34
+ // ============================================================
35
+ const SECRET_KEYS = new Set([
36
+ "accessToken",
37
+ "refreshToken",
38
+ "clientSecret",
39
+ "mcpApiKey",
40
+ "code_verifier",
41
+ "code_challenge",
42
+ "access_token",
43
+ "refresh_token",
44
+ "client_secret",
45
+ "secret_hash",
46
+ ]);
47
+ /**
48
+ * 遞迴過濾 object 裡的 secret 欄位、替換成 "[REDACTED]"
49
+ * 給 console.log / console.error / crash stack 用、防 token 洩漏到用戶 paste 的 log
50
+ */
51
+ export function redactSecrets(value) {
52
+ if (value === null || value === undefined)
53
+ return value;
54
+ if (typeof value !== "object")
55
+ return value;
56
+ if (Array.isArray(value))
57
+ return value.map((v) => redactSecrets(v));
58
+ const out = {};
59
+ for (const [k, v] of Object.entries(value)) {
60
+ if (SECRET_KEYS.has(k)) {
61
+ out[k] = "[REDACTED]";
62
+ }
63
+ else {
64
+ out[k] = redactSecrets(v);
65
+ }
66
+ }
67
+ return out;
68
+ }
69
+ // ============================================================
70
+ // PKCE helpers (RFC 7636)
71
+ // ============================================================
72
+ /** 產生 code_verifier:32 random bytes base64url(輸出長度 43 chars) */
73
+ function generateCodeVerifier() {
74
+ return randomBytes(32).toString("base64url");
75
+ }
76
+ /** 產生 code_challenge:SHA-256(code_verifier) base64url 無 padding */
77
+ function generateCodeChallenge(verifier) {
78
+ return createHash("sha256").update(verifier).digest("base64url");
79
+ }
80
+ /** 產生 random state 防 CSRF(16 bytes base64url) */
81
+ function generateState() {
82
+ return randomBytes(16).toString("base64url");
83
+ }
84
+ // ============================================================
85
+ // Lock file:防 parallel registerClient race
86
+ // ============================================================
87
+ function acquireRegisterLock() {
88
+ try {
89
+ // `wx` = O_CREAT | O_EXCL、存在則 throw EEXIST
90
+ return openSync(REGISTER_LOCK_PATH, "wx");
91
+ }
92
+ catch (err) {
93
+ if (err.code === "EEXIST")
94
+ return null;
95
+ throw err;
96
+ }
97
+ }
98
+ function releaseRegisterLock(fd) {
99
+ try {
100
+ closeSync(fd);
101
+ }
102
+ catch {
103
+ /* ignore */
104
+ }
105
+ try {
106
+ unlinkSync(REGISTER_LOCK_PATH);
107
+ }
108
+ catch {
109
+ /* ignore */
110
+ }
111
+ }
112
+ // ============================================================
113
+ // Open browser(延用 login.ts 既有 pattern)
114
+ // ============================================================
115
+ function openBrowser(url) {
116
+ const platform = process.platform;
117
+ let cmd;
118
+ let args;
119
+ if (platform === "darwin") {
120
+ cmd = "open";
121
+ args = [url];
122
+ }
123
+ else if (platform === "win32") {
124
+ cmd = "cmd";
125
+ args = ["/c", "start", "", url];
126
+ }
127
+ else {
128
+ cmd = "xdg-open";
129
+ args = [url];
130
+ }
131
+ try {
132
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
133
+ child.on("error", () => {
134
+ /* 打不開沒關係、上層會印 URL 讓 user 手動開 */
135
+ });
136
+ child.unref();
137
+ }
138
+ catch {
139
+ /* 同上 */
140
+ }
141
+ }
142
+ export async function fetchAuthorizationServerMetadata(issuerUrl) {
143
+ const url = new URL("/.well-known/oauth-authorization-server", issuerUrl).toString();
144
+ const res = await fetch(url);
145
+ if (!res.ok) {
146
+ throw new Error(`Failed to fetch authorization server metadata: HTTP ${res.status} ${res.statusText}`);
147
+ }
148
+ return (await res.json());
149
+ }
150
+ export async function registerClient(issuerUrl, clientName) {
151
+ const metadata = await fetchAuthorizationServerMetadata(issuerUrl);
152
+ // bridge register 時用 `http://localhost` 當 redirect_uri prefix、
153
+ // 對應 server-side Phase A Commit 2 f8990e7 的 RFC 8252 localhost:* port-agnostic 豁免
154
+ const registrationEndpoint = metadata.registration_endpoint ?? new URL("/oauth/register", issuerUrl).toString();
155
+ // Race protection:獨占 lock file、fail 就等用戶重試
156
+ const lockFd = acquireRegisterLock();
157
+ if (lockFd === null) {
158
+ throw new Error("Another bridge instance is registering. Please wait a few seconds and retry `npx @octo-dock/mcp-bridge login`.");
159
+ }
160
+ try {
161
+ const res = await fetch(registrationEndpoint, {
162
+ method: "POST",
163
+ headers: { "Content-Type": "application/json" },
164
+ body: JSON.stringify({
165
+ client_name: clientName,
166
+ redirect_uris: ["http://localhost", "http://127.0.0.1"],
167
+ }),
168
+ });
169
+ if (!res.ok) {
170
+ const body = await res.text().catch(() => "");
171
+ throw new Error(`Dynamic client registration failed: HTTP ${res.status} ${res.statusText}. ${body.slice(0, 200)}`);
172
+ }
173
+ const data = (await res.json());
174
+ return { clientId: data.client_id, clientSecret: data.client_secret };
175
+ }
176
+ finally {
177
+ releaseRegisterLock(lockFd);
178
+ }
179
+ }
180
+ function startCallbackServer() {
181
+ return new Promise((resolve, reject) => {
182
+ let resolveCallback;
183
+ let rejectCallback;
184
+ const server = createServer((req, res) => {
185
+ try {
186
+ const url = new URL(req.url ?? "/", "http://localhost");
187
+ if (url.pathname !== "/callback") {
188
+ res.writeHead(404);
189
+ res.end("Not found");
190
+ return;
191
+ }
192
+ const code = url.searchParams.get("code");
193
+ const state = url.searchParams.get("state");
194
+ const error = url.searchParams.get("error");
195
+ const errorDescription = url.searchParams.get("error_description");
196
+ if (error) {
197
+ res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
198
+ res.end(`<html><body style="font-family:sans-serif;padding:2rem"><h1>授權失敗</h1><p>${error}${errorDescription ? `: ${errorDescription}` : ""}</p></body></html>`);
199
+ rejectCallback?.(new Error(`OAuth authorization failed: ${error}`));
200
+ return;
201
+ }
202
+ if (!code || !state) {
203
+ res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
204
+ res.end(`<html><body style="font-family:sans-serif;padding:2rem"><h1>授權失敗</h1><p>缺少 code 或 state 參數</p></body></html>`);
205
+ rejectCallback?.(new Error("Missing code or state in callback"));
206
+ return;
207
+ }
208
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
209
+ res.end(`<html><body style="font-family:sans-serif;padding:2rem"><h1>✓ 授權成功</h1><p>你可以關掉這個視窗、回到 terminal 完成設定。</p></body></html>`);
210
+ resolveCallback?.({ code, state });
211
+ }
212
+ catch (err) {
213
+ rejectCallback?.(err);
214
+ }
215
+ });
216
+ server.on("error", reject);
217
+ // 明確 bind 127.0.0.1(不 0.0.0.0、不 ::1)
218
+ server.listen(0, "127.0.0.1", () => {
219
+ const addr = server.address();
220
+ resolve({
221
+ port: addr.port,
222
+ waitForCallback: (expectedState, timeoutMs) => new Promise((res, rej) => {
223
+ const timer = setTimeout(() => {
224
+ rej(new Error("Timed out waiting for OAuth callback"));
225
+ server.close();
226
+ }, timeoutMs);
227
+ resolveCallback = (result) => {
228
+ clearTimeout(timer);
229
+ if (result.state !== expectedState) {
230
+ rej(new Error("state mismatch (possible CSRF attack) — aborting"));
231
+ server.close();
232
+ return;
233
+ }
234
+ res(result);
235
+ };
236
+ rejectCallback = (err) => {
237
+ clearTimeout(timer);
238
+ rej(err);
239
+ };
240
+ }),
241
+ close: () => server.close(),
242
+ });
243
+ });
244
+ });
245
+ }
246
+ async function exchangeCodeForTokens(params) {
247
+ const body = new URLSearchParams({
248
+ grant_type: "authorization_code",
249
+ client_id: params.clientId,
250
+ client_secret: params.clientSecret,
251
+ code: params.code,
252
+ redirect_uri: params.redirectUri,
253
+ code_verifier: params.codeVerifier,
254
+ });
255
+ const res = await fetch(params.tokenEndpoint, {
256
+ method: "POST",
257
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
258
+ body: body.toString(),
259
+ });
260
+ if (!res.ok) {
261
+ const errBody = await res.json().catch(() => ({ error: "unknown" }));
262
+ throw new Error(`Token exchange failed: ${JSON.stringify(redactSecrets(errBody))}`);
263
+ }
264
+ const data = (await res.json());
265
+ return {
266
+ accessToken: data.access_token,
267
+ refreshToken: data.refresh_token,
268
+ expiresAt: Date.now() + data.expires_in * 1000,
269
+ scope: data.scope,
270
+ };
271
+ }
272
+ // ============================================================
273
+ // Refresh access token
274
+ // ============================================================
275
+ export async function refreshAccessToken(params) {
276
+ const body = new URLSearchParams({
277
+ grant_type: "refresh_token",
278
+ client_id: params.clientId,
279
+ client_secret: params.clientSecret,
280
+ refresh_token: params.refreshToken,
281
+ });
282
+ const res = await fetch(params.tokenEndpoint, {
283
+ method: "POST",
284
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
285
+ body: body.toString(),
286
+ });
287
+ if (!res.ok) {
288
+ // 區分 refresh_token 過期 / 撤銷 vs 暫時 server error
289
+ const errBody = (await res.json().catch(() => ({})));
290
+ if (res.status === 400 && errBody.error === "invalid_grant") {
291
+ // refresh_token 無效(過期或被撤銷)— 不 retry、請用戶 re-login
292
+ throw new Error("Refresh token is invalid or revoked. Please run `npx @octo-dock/mcp-bridge login` to re-authorize.");
293
+ }
294
+ throw new Error(`Token refresh failed: HTTP ${res.status} ${JSON.stringify(redactSecrets(errBody))}`);
295
+ }
296
+ const data = (await res.json());
297
+ return {
298
+ accessToken: data.access_token,
299
+ refreshToken: data.refresh_token,
300
+ expiresAt: Date.now() + data.expires_in * 1000,
301
+ scope: data.scope,
302
+ };
303
+ }
304
+ export async function startOAuthFlow(options) {
305
+ const clientName = options.clientName ?? "@octo-dock/mcp-bridge";
306
+ const timeoutMs = options.callbackTimeoutMs ?? 5 * 60 * 1000; // 5 分鐘
307
+ // 1. Fetch authorization server metadata
308
+ const metadata = await fetchAuthorizationServerMetadata(options.issuerUrl);
309
+ // 2. Register client if no existing credentials
310
+ const client = options.existingClient
311
+ ? options.existingClient
312
+ : await registerClient(options.issuerUrl, clientName);
313
+ // 3. Generate PKCE + state
314
+ const codeVerifier = generateCodeVerifier();
315
+ const codeChallenge = generateCodeChallenge(codeVerifier);
316
+ const state = generateState();
317
+ // 4. Start local callback server bind 127.0.0.1 隨機 port
318
+ const server = await startCallbackServer();
319
+ const redirectUri = `http://127.0.0.1:${server.port}/callback`;
320
+ try {
321
+ // 5. Build authorize URL + open browser
322
+ const authUrl = new URL(metadata.authorization_endpoint);
323
+ authUrl.searchParams.set("response_type", "code");
324
+ authUrl.searchParams.set("client_id", client.clientId);
325
+ authUrl.searchParams.set("redirect_uri", redirectUri);
326
+ authUrl.searchParams.set("scope", "mcp");
327
+ authUrl.searchParams.set("state", state);
328
+ authUrl.searchParams.set("code_challenge", codeChallenge);
329
+ authUrl.searchParams.set("code_challenge_method", "S256");
330
+ console.log("\n打開瀏覽器完成授權(5 分鐘內完成):");
331
+ console.log(authUrl.toString());
332
+ console.log("");
333
+ openBrowser(authUrl.toString());
334
+ // 6. Wait for callback
335
+ const { code } = await server.waitForCallback(state, timeoutMs);
336
+ // 7. Exchange code for tokens
337
+ const tokens = await exchangeCodeForTokens({
338
+ tokenEndpoint: metadata.token_endpoint,
339
+ clientId: client.clientId,
340
+ clientSecret: client.clientSecret,
341
+ code,
342
+ redirectUri,
343
+ codeVerifier,
344
+ });
345
+ return {
346
+ clientId: client.clientId,
347
+ clientSecret: client.clientSecret,
348
+ accessToken: tokens.accessToken,
349
+ refreshToken: tokens.refreshToken,
350
+ tokenExpiresAt: tokens.expiresAt,
351
+ issuerUrl: options.issuerUrl,
352
+ };
353
+ }
354
+ finally {
355
+ server.close();
356
+ }
357
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@octo-dock/mcp-bridge",
3
- "version": "0.1.0",
4
- "description": "Stdio ↔ HTTP bridge for OctoDock MCP. Lets Claude Desktop free tier (and any stdio-only MCP client) connect to your OctoDock remote MCP.",
3
+ "version": "0.2.0",
4
+ "description": "Stdio ↔ HTTP bridge for OctoDock MCP with OAuth 2.0 + PKCE S256 support. Lets Claude Desktop free tier (and any stdio-only MCP client) connect to your OctoDock remote MCP via OAuth (no more URL-embedded API keys).",
5
5
  "license": "BSL-1.1",
6
6
  "author": "OctoDock Contributors",
7
7
  "homepage": "https://octo-dock.com",