dsh-oauth-mcp 0.0.1 → 0.0.3

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
@@ -12,102 +12,216 @@
12
12
 
13
13
  授权过的远端工具以 `mcp_oauth__<serverId>__<tool>` 的名字出现。
14
14
 
15
- ## 宿主自带的 MCP 客户端不够用在哪
15
+ ## 目录
16
16
 
17
- 宿主自带的 `@deepseek-ai/dsh-mcp-client` 只有 `stdio` 和 `streamable-http` 两种传输,请求头写死在配置里,没有授权码流程,没有 PKCE,也没有令牌刷新。
17
+ 1. [安装](#1-安装)
18
+ 2. [添加服务器](#2-添加服务器)
19
+ 3. [授权](#3-授权)
20
+ 4. [查看状态](#4-查看状态)
21
+ 5. [使用远端工具](#5-使用远端工具)
22
+ 6. [改配置](#6-改配置)
23
+ 7. [开发](#7-开发)
24
+ 8. [发布](#8-发布)
25
+ 9. [遇到问题怎么办](#9-遇到问题怎么办)
26
+ 10. [详细文档](#10-详细文档)
18
27
 
19
- 要求 OAuth 的远程 MCP 服务器对未授权请求返回 `401` 和 `WWW-Authenticate`,里面写着授权服务器的地址。用宿主自带客户端接这类服务器,只能手工把一个不会自己过期的 Bearer 串写进配置,过期后改配置、重启,再改一次。
28
+ ## 1. 安装
20
29
 
21
- 本插件把整条 OAuth 链路做在插件里:令牌过期会静默续期,收到 `401` 会先刷新再重放一次。厂商差异收在一张数据表 `knownProfiles` 里,加一家新厂商只改数据,不改代码。
22
-
23
- ## 安装
30
+ 前置条件:已经装好并运行过 DSH 桌面应用。
24
31
 
25
32
  ### 从 npm 装
26
33
 
27
- ```
28
- dsh plugin --profile desktop add dsh-oauth-mcp
29
- ```
34
+ 1. 在 DSH 的终端里执行:
35
+
36
+ ```sh
37
+ dsh plugin --profile desktop add dsh-oauth-mcp
38
+ ```
39
+
40
+ 2. 看命令的返回值:
41
+
42
+ - `application: applied` —— 已经装上并生效,往下走
43
+ - `application: restart-required` —— 改动要下次启动才生效,重启 DSH
30
44
 
31
- 宿主会读包里的 `cordis.patch.yml`,在 profile 里插入一条 `id: dsh-oauth-mcp` 的条目,加载 `dsh-oauth-mcp`。
45
+ 3. 打开「设置 → OAuth 授权管理」,确认这一节存在。
32
46
 
33
- 装载会返回 `application: restart-required`,必须重启 DSH 客户端。重启之后设置页出现「OAuth 授权管理」页签,`/oauth-mcp` 开始响应,agent 的工具列表里出现两个 `dsh_oauth_mcp_*` 工具。
47
+ 看不到这一节时,先确认第 2 步的返回值,重启一次;还不行看[遇到问题怎么办](#9-遇到问题怎么办)。
34
48
 
35
49
  ### 从源码目录装
36
50
 
37
- 要改代码就用这条路径。仓库用 pnpm,版本由 package.json 的 `packageManager` 字段固定,先装依赖:
51
+ 要改代码就用这条路径。仓库用 pnpm,版本由 package.json 的 `packageManager` 字段固定。
38
52
 
39
- ```
40
- cd <本插件目录>
41
- pnpm install
42
- ```
53
+ 1. 装依赖:
43
54
 
44
- 再用宿主的插件安装入口装载(`plugin_manager install_bundle`,`target` 填本目录的绝对路径),命令行等价写法是 `dsh plugin add <本插件目录的绝对路径>`。profile 里这个包是指向源码目录的链接,改完源码只要重启客户端,不用重新装载。
55
+ ```sh
56
+ cd <本插件目录>
57
+ pnpm install
58
+ ```
45
59
 
46
- 也可以先打包再装:
60
+ 2. 用宿主的插件安装入口装载(`plugin_manager install_bundle`,`target` 填本目录的绝对路径),命令行等价写法是:
47
61
 
48
- ```
49
- pnpm pack # 产出 dsh-oauth-mcp-0.0.1.tgz
50
- dsh plugin add ./dsh-oauth-mcp-0.0.1.tgz
62
+ ```sh
63
+ dsh plugin add <本插件目录的绝对路径>
64
+ ```
65
+
66
+ 3. 看返回值。需要重启就重启 DSH(见上面第 2 步)。
67
+
68
+ profile 里这个包是指向源码目录的链接,改完源码只要重启客户端,不用重新装载。
69
+
70
+ ### 从打包文件装
71
+
72
+ ```sh
73
+ pnpm pack # 产出 dsh-oauth-mcp-<版本号>.tgz
74
+ dsh plugin add ./dsh-oauth-mcp-<版本号>.tgz
51
75
  dsh plugin list | grep oauth # 确认装上了
52
76
  ```
53
77
 
54
- ### 构建
78
+ ## 2. 添加服务器
55
79
 
56
- 仓库有一步构建。浏览器那一半是浏览器以传统脚本方式加载的,import 不了同目录文件,所以 `client/index.js` 里的组件是生成出来的副本。改完 `client/panel.js`、`client/toolview.js`、`client/commandview.js` 或 `lib/profiles/known-profiles.js`,跑一次 `pnpm run build`,它会把厂商表、浏览器副本和语言文件一起重新生成,且是幂等的;`test/unit/client-sync.test.js` 会检查副本与来源逐行一致。
80
+ 1. 打开 设置 →「OAuth 授权管理」,点「添加服务器」。
57
81
 
58
- 插件本体是纯 JavaScript,宿主直接吃 `index.js`,运行时只依赖 `@deepseek-ai/schemastery`。
82
+ 2. 在厂商下拉里选一家。内置 40 家实测过的远程 MCP 服务器,选中之后服务器地址、ID、名称、默认权限范围、授权服务器地址自动填好。
59
83
 
60
- ## 配置
84
+ 3. 勾选权限范围,保存。
61
85
 
62
- 设置 →「OAuth 授权管理」→「添加服务器」:填 `id` 和 `url`,勾选权限范围,保存。厂商下拉里有 40 家实测过的远程 MCP 服务器,选中之后服务器地址、ID、名称、默认权限范围、授权服务器地址自动填好。
86
+ 下拉里选「自定义」就全部手填。ID 留空会按地址自动生成。
63
87
 
64
- 面板能改的字段只有 `servers`、`defaultScopes`、`autoConnect` 三个。`callbackMode`、`callbackPort`、`callbackPath`、`toolPrefix` 在面板里只读展示,要改去宿主的配置文件(设置 → 通用设置 →「打开配置文件」,改 profile 里 `dsh-oauth-mcp` 条目的 `config:`),保存后宿主重启本插件。
88
+ 也可以直接在地址框里输入厂商地址,插件只自动填 ID,不动名称和权限范围。
65
89
 
66
- GitHub、Zendesk、Slack、PagerDuty、MongoDB Atlas、Render、Shopify、Atlassian 八家要多做一步,见 [docs/vendors.md](docs/vendors.md)。
90
+ 服务器行右侧的胶囊标签是配置状态:「已启用」表示插件会尝试自动连接这台服务器,「已停用」表示插件跳过它。
67
91
 
68
- 完整的插件配置项表和服务器条目表见 [docs/usage.md](docs/usage.md#2-配置)。
92
+ ## 3. 授权
69
93
 
70
- ## 授权
94
+ 1. 在会话里发起授权:
71
95
 
72
- ```
73
- /oauth-mcp authorize <serverId | 服务器地址>
74
- ```
96
+ ```text
97
+ /oauth-mcp authorize <serverId | 服务器地址>
98
+ ```
99
+
100
+ 写地址时插件会当场把它加入服务器列表,命中已知厂商时还会补上默认权限范围与授权服务器地址。
101
+
102
+ 2. 命令结果是一张卡片,卡片里有可点的授权链接,一行可以复制的纯 URL。
103
+
104
+ 3. 在浏览器里打开链接。同意动作发生在厂商自己的网页上,插件不代点。
75
105
 
76
- 命令结果是一张卡片,卡片里有可点的授权链接、一行可以复制的纯 URL。同意动作发生在厂商自己的网页上,插件不代点。
106
+ 4. 授权完成后插件跳回 `http://127.0.0.1:39271/oauth-mcp/callback`,页面显示成功提示。页面上看不到任何令牌、code 或 state。
77
107
 
108
+ 回调端口被占用时插件自动降级到 `host-web` 或 `manual-only`。`manual-only` 模式下把浏览器地址栏里的整条回调 URL 粘回来完成授权:
109
+
110
+ ```text
111
+ /oauth-mcp complete <serverId> <回调 URL>
78
112
  ```
113
+
114
+ ## 4. 查看状态
115
+
116
+ ```text
79
117
  /oauth-mcp status
80
118
  ```
81
119
 
82
120
  `status` 报告每台服务器是否已授权、过期时间、有没有刷新令牌、是否已连接、工具数。令牌本身不显示。
83
121
 
84
- 命令结果在对话里怎么显示,见 [docs/usage.md](docs/usage.md#4-命令结果在对话里怎么显示)。界面语言跟随宿主,见 [docs/usage.md](docs/usage.md#5-界面语言)。
122
+ 看授权后拿到哪些工具:
85
123
 
86
- ## 详细文档
87
-
88
- | 文档 | 内容 |
89
- | --- | --- |
90
- | [docs/usage.md](docs/usage.md) | 字段表、命令与工具参考、回调模式、令牌存储、错误码、故障排查 |
91
- | [docs/vendors.md](docs/vendors.md) | 40 家远程 MCP 服务器的 OAuth 元数据实测表 |
92
- | [docs/verification.md](docs/verification.md) | 12 步手工验收清单 |
93
- | [docs/contract.md](docs/contract.md) | 模块级接口契约 |
94
- | [docs/review-2026-11-07.md](docs/review-2026-11-07.md) | 六个方面的评审记录:已改的问题与剩下的清单 |
95
- | [DESIGN.md](DESIGN.md) | 设计全文与未决问题 |
96
- | [TASKS.md](TASKS.md) | 任务与验收记录 |
124
+ ```text
125
+ /oauth-mcp tools
126
+ ```
97
127
 
98
- ## 跑测试
128
+ 命令的完整用法:
99
129
 
130
+ ```text
131
+ /oauth-mcp status [serverId] 查看每台服务器的状态
132
+ /oauth-mcp tools [serverId] 查看授权后拿到的工具
133
+ /oauth-mcp authorize <serverId | url> 授权一台服务器
134
+ /oauth-mcp complete <serverId> <url> 用回执 URL 完成授权
135
+ /oauth-mcp disconnect <serverId> 断开连接(凭据保留)
136
+ /oauth-mcp cancel <serverId> 取消进行中的授权
137
+ /oauth-mcp help 列出这些用法
100
138
  ```
101
- cd <本插件目录>
102
- pnpm install
103
- pnpm test # 等价于 node --test "test/**/*.test.js"
104
- ```
105
139
 
106
- 仓库记录的最近一次全量运行是 **640 例**,全绿。用例分单元、协议契约、端到端、宿主集成、冒烟五类,外加发布清单与仓库卫生两条守卫。端到端有两种:`test/e2e/full-flow.test.js` 走协议层,`test/e2e/host-flow.test.js` 走完整链路。
140
+ ## 5. 使用远端工具
141
+
142
+ 授权过的服务器,它的工具以 `mcp_oauth__<serverId>__<tool>` 注册成宿主工具。
143
+
144
+ 例如授权了 `linear`,远端的 `list_issues` 工具在宿主里叫 `mcp_oauth__linear__list_issues`。
145
+
146
+ 用的时候有两种写法:直接描述要做的事,agent 会自己挑合适的工具;或者点名调用,名字从 `/oauth-mcp tools` 里复制。
147
+
148
+ 默认前缀 `mcp_oauth` 与宿主自带的 `mcp__<server>__<tool>` 不冲突。
149
+
150
+ ### 惰性工具
107
151
 
108
- `node --test test/` 在 Node v24 上不可用:`--test` 的位置参数按 glob 解析,`test/` 会被当成模块去加载。
152
+ 授权 3 家服务器可能有 70 多个远端工具。全部挂在模型的工具表上,每一步都要背着它们的定义 —— 与本次任务无关的那部分也在里面。
109
153
 
110
- ## 发布
154
+ 默认开着惰性工具(`lazyTools`):
155
+
156
+ 1. 工具表里只有一个入口工具 `mcp_oauth`,远端工具的名字模型都看不到。
157
+ 2. agent 调用 `mcp_oauth`(可以带 `{"server":"notion"}` 指定一台,不带就列全部)。
158
+ 3. 答案里是服务器列表和它公开的工具名。
159
+ 4. 下一步起这些工具进入工具表,可以直接调用。揭示是当轮生效的。
160
+
161
+ agent 凭记忆硬调没揭示的工具会被拦下,理由里写明要先调用 `mcp_oauth`。
162
+
163
+ 揭示状态按会话隔离:同一个会话里揭示一次就一直有效,换个会话回到没揭示的状态,不落盘。
164
+
165
+ 实测(DSH 桌面版 0.2.2-rc.1,授权了 notion 与 context7):
166
+
167
+ | | 工具表里的工具数 | 远端工具数 |
168
+ | --- | --- | --- |
169
+ | `lazyTools` 关 | 132 | 79 |
170
+ | `lazyTools` 开 | 54 | 0 |
171
+
172
+ 面板里可以随时关掉这个开关,关掉后远端工具一直挂在工具表上。
173
+
174
+ ## 6. 改配置
175
+
176
+ 面板能改的字段只有 `servers`、`defaultScopes`、`autoConnect`、`lazyTools` 四个,写完立即生效,插件不重启。
177
+
178
+ `callbackMode`、`callbackPort`、`callbackPath`、`toolPrefix` 在面板里只读展示,要改去宿主的配置文件(设置 → 通用设置 →「打开配置文件」,改 profile 里 `dsh-oauth-mcp` 条目的 `config:`),保存后宿主重启本插件。
179
+
180
+ | 配置项 | 默认值 | 说明 |
181
+ | --- | --- | --- |
182
+ | `callbackMode` | `dedicated-port` | 回调接收方式:`dedicated-port` / `host-web` / `manual-only` |
183
+ | `callbackPort` | `39271` | 回环监听端口;被占用时自动降级 |
184
+ | `callbackPath` | `/oauth-mcp/callback` | 回调路径 |
185
+ | `toolPrefix` | `mcp_oauth` | 远端工具的前缀,最终工具名为 `<prefix>__<serverId>__<tool>` |
186
+ | `authorizationTimeoutMs` | `300000` | 一次授权请求的有效期 |
187
+ | `defaultScopes` | `[]` | 服务器没有单独配置权限范围时使用的默认值 |
188
+ | `autoConnect` | `true` | 插件启动时自动连接已启用的服务器 |
189
+ | `servers` | `[]` | 服务器清单 |
190
+ | `lazyTools` | `true` | 远端工具只在揭示之后进工具表 |
191
+
192
+ 改配置不影响令牌。令牌存在 `ctx.credentials` 里,不在插件配置里。但已建立的 MCP 会话会断开,并在下次连接时重建。
193
+
194
+ GitHub、Zendesk、Slack、PagerDuty、MongoDB Atlas、Render、Shopify、Atlassian 八家要多做一步,见 [docs/vendors.md](docs/vendors.md)。
195
+
196
+ 完整的插件配置项表和服务器条目表见 [docs/usage.md](docs/usage.md#2-配置)。
197
+
198
+ ## 7. 开发
199
+
200
+ 仓库有一步构建。浏览器那一半是浏览器以传统脚本方式加载的,import 不了同目录文件,所以 `client/index.js` 里的组件是生成出来的副本。
201
+
202
+ 1. 改完 `client/panel.js`、`client/toolview.js`、`client/commandview.js` 或 `lib/profiles/known-profiles.js` 后重新生成:
203
+
204
+ ```sh
205
+ pnpm run build
206
+ ```
207
+
208
+ 它把厂商表、浏览器副本和语言文件一起重新生成,且是幂等的。`test/unit/client-sync.test.js` 会检查副本与来源逐行一致。
209
+
210
+ 2. 跑测试:
211
+
212
+ ```sh
213
+ cd <本插件目录>
214
+ pnpm install
215
+ pnpm test # 等价于 node --test "test/**/*.test.js"
216
+ ```
217
+
218
+ 仓库记录的最近一次全量运行是 **660 例**,全绿。用例分单元、协议契约、端到端、宿主集成、冒烟五类,外加发布清单与仓库卫生两条守卫。端到端有两种:`test/e2e/full-flow.test.js` 走协议层,`test/e2e/host-flow.test.js` 走完整链路。
219
+
220
+ `node --test test/` 在 Node v24 上不可用:`--test` 的位置参数按 glob 解析,`test/` 会被当成模块去加载。
221
+
222
+ 插件本体是纯 JavaScript,宿主直接吃 `index.js`,运行时只依赖 `@deepseek-ai/schemastery`。
223
+
224
+ ## 8. 发布
111
225
 
112
226
  版本号写在 `package.json` 的 `version` 字段里。
113
227
 
@@ -116,3 +230,27 @@ pnpm test # 等价于 node --test "test/**/*.test.js"
116
230
  CI 用 pnpm 装依赖(`pnpm install --frozen-lockfile`),每次 push 和 PR 都会在 Node 22 与 Node 24 上跑一遍测试套件。
117
231
 
118
232
  npm 发布那一步需要 GitHub 仓库里配一个 `NPM_TOKEN` secret。
233
+
234
+ ## 9. 遇到问题怎么办
235
+
236
+ | 现象 | 原因 | 处理 |
237
+ | --- | --- | --- |
238
+ | 重启后设置页没有「OAuth 授权管理」 | 装载返回 `restart-required`,改动要下次启动才生效 | 重启 DSH 客户端;还不行就重新装载一次 |
239
+ | 面板里服务器列表是空的 | 客户端脚本没连上宿主的服务 | 硬刷新浏览器(`Ctrl+Shift+R`) |
240
+ | 改了代码但行为没变 | 宿主没有热重载 | 重启 DSH,然后在浏览器里硬刷新 |
241
+ | 状态里报 `credential_service_unavailable` | 宿主还没提供凭据服务 | 重启 DSH |
242
+ | 端口 39271 被占用 | 回环端口不可用 | 插件自动降级到 `host-web` 或 `manual-only` |
243
+
244
+ 更细的排查见 [docs/usage.md](docs/usage.md#9-故障排查)。
245
+
246
+ ## 10. 详细文档
247
+
248
+ | 文档 | 内容 |
249
+ | --- | --- |
250
+ | [docs/usage.md](docs/usage.md) | 字段表、命令与工具参考、回调模式、令牌存储、错误码、故障排查 |
251
+ | [docs/vendors.md](docs/vendors.md) | 40 家远程 MCP 服务器的 OAuth 元数据实测表 |
252
+ | [docs/verification.md](docs/verification.md) | 12 步手工验收清单 |
253
+ | [docs/contract.md](docs/contract.md) | 模块级接口契约 |
254
+ | [docs/review-2026-11-07.md](docs/review-2026-11-07.md) | 六个方面的评审记录:已改的问题与剩下的清单 |
255
+ | [DESIGN.md](DESIGN.md) | 设计全文与未决问题 |
256
+ | [TASKS.md](TASKS.md) | 任务与验收记录 |
package/client/index.js CHANGED
@@ -91,6 +91,9 @@ window.__ModuleLoader__.load({
91
91
  'panel.global.toolPrefix': '工具名前缀',
92
92
  'panel.global.defaultScopes': '默认权限范围',
93
93
  'panel.global.autoConnect': '启动时自动连接',
94
+ 'panel.global.lazyTools': '惰性工具',
95
+ 'panel.global.lazyToolsHint':
96
+ '远端工具只有在 agent 调用 mcp_oauth 揭示之后才进工具表,这样每一步就不必背着它们的定义。关掉则一直挂着。',
94
97
  'panel.global.restartHint': '回调方式、回调端口、回调路径与工具名前缀在宿主的配置文件里改(设置 → 通用设置 → 打开配置文件),保存后宿主会重启本插件。',
95
98
  'panel.servers.vendor': '厂商',
96
99
  'panel.servers.vendor.custom': '自定义(手动填写地址)',
@@ -182,6 +185,9 @@ window.__ModuleLoader__.load({
182
185
  'panel.global.toolPrefix': 'Tool name prefix',
183
186
  'panel.global.defaultScopes': 'Default scopes',
184
187
  'panel.global.autoConnect': 'Connect on startup',
188
+ 'panel.global.lazyTools': 'Lazy tools',
189
+ 'panel.global.lazyToolsHint':
190
+ 'The remote tools enter the tool table only after the agent reveals them with the mcp_oauth tool, so no step carries their definitions. Turn it off to keep them in the table all the time.',
185
191
  'panel.global.restartHint':
186
192
  'Callback mode, callback port, callback path and the tool name prefix are edited in the host configuration file (Settings → General → Open configuration file); the host restarts this plugin afterwards.',
187
193
  'panel.servers.vendor': 'Vendor',
@@ -3312,6 +3318,21 @@ function createPanelComponent(React, options = {}) {
3312
3318
  }),
3313
3319
  h('span', { style: style.muted }, t('panel.global.autoConnect')),
3314
3320
  ),
3321
+ h(
3322
+ 'label',
3323
+ { className: 'dsh-oauth-mcp-panel-check', style: style.check },
3324
+ h('input', {
3325
+ type: 'checkbox',
3326
+ checked: config.lazyTools !== false,
3327
+ onChange: (event) => void writeField('lazyTools', event.target.checked),
3328
+ }),
3329
+ h('span', { style: style.muted }, t('panel.global.lazyTools')),
3330
+ ),
3331
+ h(
3332
+ 'div',
3333
+ { className: 'dsh-oauth-mcp-panel-hint', style: style.muted },
3334
+ t('panel.global.lazyToolsHint'),
3335
+ ),
3315
3336
  h(
3316
3337
  'div',
3317
3338
  { className: 'dsh-oauth-mcp-panel-hint', style: style.muted },
package/index.js CHANGED
@@ -34,9 +34,11 @@ import { createMcpSession } from './lib/mcp/session.js'
34
34
  import { createAuthFetch } from './lib/mcp/auth-fetch.js'
35
35
  import { createToolRegistry } from './lib/mcp/tool-registry.js'
36
36
  import { createToolExecutor } from './lib/mcp/tool-executor.js'
37
+ import { createRevealState } from './lib/mcp/reveal.js'
37
38
  import { createOauthMcpService } from './lib/service.js'
38
39
  import { createAuthorizeTool } from './lib/tools/authorize.js'
39
40
  import { createStatusTool } from './lib/tools/status.js'
41
+ import { createRevealTool } from './lib/tools/reveal.js'
40
42
  import { renderStatusTable, renderScopeDetails, renderToolTable } from './lib/view-model.js'
41
43
  import { redactText } from './lib/util/redact.js'
42
44
  import { readConfigArray, readConfigField } from './lib/util/config.js'
@@ -95,6 +97,7 @@ export const Config = z.object({
95
97
  autoConnect: z.boolean().default(true).volatile(),
96
98
  servers: z.array(ServerSchema).default([]).volatile(),
97
99
  locale: z.string().default('').volatile(),
100
+ lazyTools: z.boolean().default(true).volatile(),
98
101
  })
99
102
 
100
103
  /**
@@ -238,10 +241,18 @@ export async function apply(ctx, config) {
238
241
  disconnect: (serverId) => disconnectServer(serverId),
239
242
  }
240
243
 
244
+ // The lazy exposure state. While it is on, the remote tools are only offered
245
+ // to the model after the model asked for them through the entry tool.
246
+ const reveal = createRevealState({
247
+ enabled: config.lazyTools !== false,
248
+ toolPrefix: config.toolPrefix,
249
+ })
250
+
241
251
  const toolRegistry = createToolRegistry({
242
252
  tools: ctx.tools,
243
253
  toolPrefix: config.toolPrefix,
244
254
  logger,
255
+ admissionFor: (serverId) => reveal.admission(serverId),
245
256
  callTool: async (serverId, remoteToolName, args, options) => {
246
257
  const connection = connectionMap.get(serverId)
247
258
  if (!connection?.executor) {
@@ -435,6 +446,26 @@ export async function apply(ctx, config) {
435
446
  )
436
447
  dispose(ctx.tools.register(createStatusTool({ service, logger, config, settings })))
437
448
 
449
+ // --- the lazy exposure -----------------------------------------------------
450
+ // While the lazy exposure is on, the remote tools are not in the model's
451
+ // tool table until the model asks for them through the `mcp_oauth` entry
452
+ // tool. The entry tool itself is always in the table: it is the only hint
453
+ // the model gets that the remote tools exist.
454
+ dispose(ctx.tools.register(createRevealTool({ reveal, service, locale: () => config.locale })))
455
+
456
+ // A model may remember a tool name from an earlier step and call it even
457
+ // though the tool is not in this step's table. The guard turns that call
458
+ // into an explanation instead of an opaque failure.
459
+ if (typeof ctx.tools.guard === 'function') {
460
+ try {
461
+ dispose(ctx.tools.guard((exec) => reveal.guardFor(exec)))
462
+ } catch (guardError) {
463
+ logger?.warn?.('dsh-oauth-mcp: the tool guard did not register', {
464
+ error: String(guardError?.message ?? guardError),
465
+ })
466
+ }
467
+ }
468
+
438
469
  // --- the host command ------------------------------------------------------
439
470
  if (commands?.register) {
440
471
  dispose(
@@ -825,5 +856,12 @@ export async function apply(ctx, config) {
825
856
  } catch (error) {
826
857
  logger?.warn?.('dsh-oauth-mcp: the tool registry did not dispose cleanly', { error: String(error?.message ?? error) })
827
858
  }
859
+ try {
860
+ // The pending authorizations hold a ref'd timer; they must be given up
861
+ // when the plugin goes away, or the timer keeps the process alive.
862
+ service.dispose()
863
+ } catch (error) {
864
+ logger?.warn?.('dsh-oauth-mcp: the pending authorizations did not release cleanly', { error: String(error?.message ?? error) })
865
+ }
828
866
  }
829
867
  }
package/lib/build-info.js CHANGED
@@ -12,13 +12,13 @@ import { createTranslator } from './i18n/index.js'
12
12
  export const PLUGIN_ID = 'dsh-oauth-mcp'
13
13
 
14
14
  /** The version of the plugin. It is reported to every server it talks to. */
15
- export const PLUGIN_VERSION = '0.0.1'
15
+ export const PLUGIN_VERSION = '0.0.3'
16
16
 
17
17
  /**
18
18
  * Short marker of the current source revision. Bump it whenever the behaviour
19
19
  * of the user facing surfaces (commands, tools, panel, callback pages) changes.
20
20
  */
21
- export const BUILD_STAMP = '2026-11-08-first-npm-release'
21
+ export const BUILD_STAMP = '2026-11-08-lazy-tools'
22
22
 
23
23
  /**
24
24
  * One line that names the version and the build of the running code.
@@ -160,71 +160,76 @@ export function createHttpClient({ timeoutMs = 15_000, fetchImpl = globalThis.fe
160
160
  const startedAt = now()
161
161
  let response
162
162
  try {
163
- response = await fetchImpl(target, {
164
- method,
165
- headers: requestHeaders,
166
- body,
167
- redirect: 'manual',
168
- signal: combined.signal,
169
- })
170
- } catch (error) {
171
- combined.dispose()
172
- timeout?.dispose()
173
- const durationMs = now() - startedAt
174
- if (timeout?.signal.aborted || error?.name === 'TimeoutError') {
175
- const timeoutError = new HttpError(`Request timed out after ${timeoutMs}ms`, {
163
+ try {
164
+ response = await fetchImpl(target, {
165
+ method,
166
+ headers: requestHeaders,
167
+ body,
168
+ redirect: 'manual',
169
+ signal: combined.signal,
170
+ })
171
+ } catch (error) {
172
+ const durationMs = now() - startedAt
173
+ if (timeout?.signal.aborted || error?.name === 'TimeoutError') {
174
+ const timeoutError = new HttpError(`Request timed out after ${timeoutMs}ms`, {
175
+ url: safeUrl,
176
+ cause: error,
177
+ })
178
+ timeoutError.durationMs = durationMs
179
+ throw timeoutError
180
+ }
181
+ if (signal?.aborted) {
182
+ const abortError = new HttpError('Request was aborted.', { url: safeUrl, cause: error })
183
+ abortError.durationMs = durationMs
184
+ throw abortError
185
+ }
186
+ const networkError = new HttpError(`Request to ${safeUrl} failed.`, {
176
187
  url: safeUrl,
188
+ detail: truncateDetail(redactText(error?.message ?? '')),
177
189
  cause: error,
178
190
  })
179
- timeoutError.durationMs = durationMs
180
- throw timeoutError
181
- }
182
- if (signal?.aborted) {
183
- const abortError = new HttpError('Request was aborted.', { url: safeUrl, cause: error })
184
- abortError.durationMs = durationMs
185
- throw abortError
191
+ networkError.durationMs = durationMs
192
+ throw networkError
186
193
  }
187
- const networkError = new HttpError(`Request to ${safeUrl} failed.`, {
188
- url: safeUrl,
189
- detail: truncateDetail(redactText(error?.message ?? '')),
190
- cause: error,
191
- })
192
- networkError.durationMs = durationMs
193
- throw networkError
194
- }
195
- combined.dispose()
196
- timeout?.dispose()
197
- let text = ''
198
- try {
199
- text = await response.text()
200
- } catch {
201
- text = ''
202
- }
203
- const status = response.status
204
- if (status >= 300 && status < 400) {
205
- throw new HttpError(`Cross-origin redirect is not allowed: ${safeUrl}`, {
206
- status,
207
- url: safeUrl,
208
- detail: safeDetail(text),
209
- })
210
- }
211
- if (status < 200 || status >= 300) {
212
- throw new HttpError(`Request to ${safeUrl} failed with status ${status}.`, {
213
- status,
214
- url: safeUrl,
215
- detail: safeDetail(text),
216
- headers: response.headers,
217
- })
218
- }
219
- let parsed
220
- if (text.length > 0) {
194
+
195
+ let text = ''
221
196
  try {
222
- parsed = JSON.parse(text)
197
+ text = await response.text()
223
198
  } catch {
224
- parsed = undefined
199
+ text = ''
200
+ }
201
+ const status = response.status
202
+ if (status >= 300 && status < 400) {
203
+ throw new HttpError(`Cross-origin redirect is not allowed: ${safeUrl}`, {
204
+ status,
205
+ url: safeUrl,
206
+ detail: safeDetail(text),
207
+ })
225
208
  }
209
+ if (status < 200 || status >= 300) {
210
+ throw new HttpError(`Request to ${safeUrl} failed with status ${status}.`, {
211
+ status,
212
+ url: safeUrl,
213
+ detail: safeDetail(text),
214
+ headers: response.headers,
215
+ })
216
+ }
217
+ let parsed
218
+ if (text.length > 0) {
219
+ try {
220
+ parsed = JSON.parse(text)
221
+ } catch {
222
+ parsed = undefined
223
+ }
224
+ }
225
+ return { status, headers: response.headers, body: parsed, text }
226
+ } finally {
227
+ // The timeout and the combined signal are released on every path,
228
+ // including the failing ones: a timer that outlives the request keeps
229
+ // the event loop alive.
230
+ combined.dispose()
231
+ timeout?.dispose()
226
232
  }
227
- return { status, headers: response.headers, body: parsed, text }
228
233
  }
229
234
 
230
235
  /**
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Lazy exposure of the remote MCP tools.
3
+ *
4
+ * The problem this module solves: every tool of every authorized server used to
5
+ * sit in the model's tool table for the whole session. A profile that
6
+ * authorized three servers carried thirty-odd remote tool schemas in every
7
+ * step, whether the task touched the desktop or not.
8
+ *
9
+ * The host already has the primitive for this. `@deepseek-ai/dsh-tools` lets a
10
+ * registration carry a `when` condition:
11
+ *
12
+ * ```js
13
+ * tools.register(definition, { when: (context) => boolean })
14
+ * ```
15
+ *
16
+ * The condition is evaluated once per model step, before the step's tool table
17
+ * is built. A tool whose condition returns `false` is not in that step's tool
18
+ * table at all — the model neither sees its schema nor knows its name. The
19
+ * condition receives `{ agent, modelSelection, signal }`, so the decision can
20
+ * be per session.
21
+ *
22
+ * This module owns the state behind that decision:
23
+ *
24
+ * * {@link createRevealState} keeps, per agent, the set of servers whose tools
25
+ * the model has asked for. The state lives in a `WeakMap` keyed by the live
26
+ * agent, so a new session starts over and a finished agent is collected.
27
+ * * {@link RevealState#admission} produces the `when` condition for one
28
+ * server.
29
+ * * {@link RevealState#guard} produces the dispatch veto. The `when` condition
30
+ * already keeps an unrevealed tool out of the tool table, but the model may
31
+ * remember a name from an earlier step and call it anyway; the guard turns
32
+ * that call into an explanation instead of an opaque failure.
33
+ *
34
+ * The reveal is per session on purpose. A user who authorized three servers
35
+ * does not want to re-reveal them in every new conversation, but a task that
36
+ * never touches a remote server should not pay for their schemas either. The
37
+ * compromise this module takes: the state is per agent, and the entry tool
38
+ * reveals everything on request.
39
+ *
40
+ * @module dsh-oauth-mcp/mcp/reveal
41
+ */
42
+
43
+ /**
44
+ * The state of the reveal, for every agent that asked for tools.
45
+ */
46
+ export class RevealState {
47
+ /**
48
+ * The revealed servers per agent.
49
+ *
50
+ * @type {WeakMap<object, Set<string>>}
51
+ */
52
+ #agents = new WeakMap()
53
+
54
+ /**
55
+ * The servers that were revealed to every agent.
56
+ *
57
+ * A server revealed here is offered to every session. The plugin uses it
58
+ * for the servers the user marked as always available; nothing sets it yet,
59
+ * but the code path exists so the semantics do not change later.
60
+ *
61
+ * @type {Set<string>}
62
+ */
63
+ #global = new Set()
64
+
65
+ /**
66
+ * Whether lazy exposure is on at all.
67
+ *
68
+ * When it is off every remote tool is admitted, exactly as before this
69
+ * module existed. The setting belongs to the plugin configuration.
70
+ *
71
+ * @type {boolean}
72
+ */
73
+ #enabled
74
+
75
+ /**
76
+ * The prefix of the public tool names, used to recognise them in a dispatch.
77
+ *
78
+ * @type {string}
79
+ */
80
+ #toolPrefix
81
+
82
+ /**
83
+ * Build the state.
84
+ *
85
+ * @param {object} [options] The options.
86
+ * @param {boolean} [options.enabled=true] Whether lazy exposure is on.
87
+ * @param {string} [options.toolPrefix='mcp_oauth'] The prefix of the public tool names.
88
+ */
89
+ constructor({ enabled = true, toolPrefix = 'mcp_oauth' } = {}) {
90
+ this.#enabled = enabled !== false
91
+ this.#toolPrefix = typeof toolPrefix === 'string' && toolPrefix.length > 0 ? toolPrefix : 'mcp_oauth'
92
+ }
93
+
94
+ /**
95
+ * Whether lazy exposure is on.
96
+ *
97
+ * @returns {boolean} True when the tools are revealed on demand.
98
+ */
99
+ get enabled() {
100
+ return this.#enabled
101
+ }
102
+
103
+ /**
104
+ * The servers revealed to one agent.
105
+ *
106
+ * @param {object | undefined | null} agent The agent.
107
+ * @returns {Set<string>} The revealed server ids.
108
+ */
109
+ #revealedFor(agent) {
110
+ if (!agent || typeof agent !== 'object') return this.#global
111
+ let revealed = this.#agents.get(agent)
112
+ if (!revealed) {
113
+ revealed = new Set(this.#global)
114
+ this.#agents.set(agent, revealed)
115
+ }
116
+ return revealed
117
+ }
118
+
119
+ /**
120
+ * Whether one server is revealed to one agent.
121
+ *
122
+ * @param {object | undefined | null} agent The agent.
123
+ * @param {string} serverId The server id.
124
+ * @returns {boolean} True when the tools of that server may be offered.
125
+ */
126
+ isRevealed(agent, serverId) {
127
+ if (!this.#enabled) return true
128
+ if (this.#global.has(serverId)) return true
129
+ return this.#revealedFor(agent).has(serverId)
130
+ }
131
+
132
+ /**
133
+ * Reveal servers to one agent.
134
+ *
135
+ * @param {object | undefined | null} agent The agent.
136
+ * @param {string[]} serverIds The servers to reveal.
137
+ * @returns {string[]} The server ids that are revealed afterwards.
138
+ */
139
+ reveal(agent, serverIds) {
140
+ if (!agent || typeof agent !== 'object') return []
141
+ const revealed = this.#revealedFor(agent)
142
+ const added = []
143
+ for (const serverId of serverIds) {
144
+ if (typeof serverId !== 'string' || serverId.length === 0) continue
145
+ if (revealed.has(serverId)) continue
146
+ revealed.add(serverId)
147
+ added.push(serverId)
148
+ }
149
+ return added
150
+ }
151
+
152
+ /**
153
+ * Reveal every server to one agent.
154
+ *
155
+ * @param {object | undefined | null} agent The agent.
156
+ * @param {string[]} serverIds Every known server id.
157
+ * @returns {string[]} The server ids that are revealed afterwards.
158
+ */
159
+ revealAll(agent, serverIds) {
160
+ return this.reveal(agent, serverIds)
161
+ }
162
+
163
+ /**
164
+ * The `when` condition for the tools of one server.
165
+ *
166
+ * The condition is what the host evaluates per model step. It returns true
167
+ * when the tools of that server may be offered to the model.
168
+ *
169
+ * @param {string} serverId The server id.
170
+ * @returns {(context: { agent?: unknown }) => boolean} The condition.
171
+ */
172
+ admission(serverId) {
173
+ if (!this.#enabled) return () => true
174
+ return (context) => this.isRevealed(context?.agent, serverId)
175
+ }
176
+
177
+ /**
178
+ * The dispatch veto for the tools of one server.
179
+ *
180
+ * The veto runs when a tool is about to be dispatched. It refuses a call to
181
+ * a server that was not revealed and explains how to reveal it. It returns
182
+ * `undefined` for a call it allows, which is what the host expects.
183
+ *
184
+ * @param {string} serverId The server id.
185
+ * @returns {(exec: { name?: string, agent?: unknown }) => string | undefined} The veto.
186
+ */
187
+ guard(serverId) {
188
+ if (!this.#enabled) return () => undefined
189
+ return (exec) => {
190
+ if (this.isRevealed(exec?.agent, serverId)) return undefined
191
+ return this.#refusal(serverId)
192
+ }
193
+ }
194
+
195
+ /**
196
+ * The refusal the guard gives for a server that was not revealed.
197
+ *
198
+ * @param {string} serverId The server id.
199
+ * @returns {string} The message.
200
+ */
201
+ #refusal(serverId) {
202
+ return (
203
+ `The tools of the MCP server "${serverId}" are not revealed in this session. ` +
204
+ `Call the "${REVEAL_TOOL_NAME}" tool with {"server":"${serverId}"} first, ` +
205
+ 'then call the tool again.'
206
+ )
207
+ }
208
+
209
+ /**
210
+ * The dispatch veto for every remote tool of the plugin.
211
+ *
212
+ * The host calls this before every tool dispatch. It recognises the remote
213
+ * tools by their public name and refuses the ones whose server was not
214
+ * revealed; every other tool of the host passes through untouched.
215
+ *
216
+ * @param {{ name?: string, agent?: unknown }} exec The pending call.
217
+ * @returns {string | undefined} The refusal, or undefined when the call is allowed.
218
+ */
219
+ guardFor(exec) {
220
+ if (!this.#enabled) return undefined
221
+ const serverId = this.#serverIdOf(exec?.name)
222
+ if (serverId === undefined) return undefined
223
+ if (this.isRevealed(exec?.agent, serverId)) return undefined
224
+ return this.#refusal(serverId)
225
+ }
226
+
227
+ /**
228
+ * The server a public tool name belongs to.
229
+ *
230
+ * The public name is `<prefix>__<serverId>__<toolName>`, so the server id
231
+ * is the middle part. A name that does not have that shape is not a remote
232
+ * tool of this plugin.
233
+ *
234
+ * @param {unknown} name The public tool name.
235
+ * @returns {string | undefined} The server id, or undefined.
236
+ */
237
+ #serverIdOf(name) {
238
+ if (typeof name !== 'string') return undefined
239
+ if (!name.startsWith(`${this.#toolPrefix}__`)) return undefined
240
+ const rest = name.slice(this.#toolPrefix.length + 2)
241
+ const separator = rest.indexOf('__')
242
+ if (separator <= 0) return undefined
243
+ return rest.slice(0, separator)
244
+ }
245
+ }
246
+
247
+ /**
248
+ * The name of the tool that reveals the remote tools.
249
+ *
250
+ * @type {string}
251
+ */
252
+ export const REVEAL_TOOL_NAME = 'mcp_oauth'
253
+
254
+ /**
255
+ * Build the reveal state of the plugin.
256
+ *
257
+ * @param {object} [options] The options.
258
+ * @param {boolean} [options.enabled=true] Whether lazy exposure is on.
259
+ * @param {string} [options.toolPrefix='mcp_oauth'] The prefix of the public tool names.
260
+ * @returns {RevealState} The state.
261
+ */
262
+ export function createRevealState(options = {}) {
263
+ return new RevealState(options)
264
+ }
@@ -108,10 +108,11 @@ function buildDescription(serverId, remoteToolName, remoteDescription) {
108
108
  * @param {object} [options.logger] The host logger; every call is optional.
109
109
  * @param {(serverId: string, remoteToolName: string, args: object, callOptions: { timeoutMs?: number, signal?: AbortSignal }) => Promise<unknown>} [options.callTool] The injected executor that performs `tools/call`.
110
110
  * @param {number} [options.timeoutMs] The timeout of one tool call, in milliseconds.
111
+ * @param {(serverId: string) => ((context: { agent?: unknown }) => boolean)} [options.admissionFor] The per-server condition that decides whether the tools of that server are offered to the model. The host evaluates it once per model step; a tool whose condition is false is not in that step's tool table.
111
112
  * @returns {object} The registry: `sync`, `unregisterServer`, `dispose`, `hostToolName`.
112
113
  */
113
114
  export function createToolRegistry(options = {}) {
114
- const { tools, logger, callTool } = options
115
+ const { tools, logger, callTool, admissionFor } = options
115
116
  const toolPrefix = typeof options.toolPrefix === 'string' && options.toolPrefix.length > 0
116
117
  ? options.toolPrefix
117
118
  : DEFAULT_TOOL_PREFIX
@@ -121,6 +122,23 @@ export function createToolRegistry(options = {}) {
121
122
  throw new TypeError('createToolRegistry requires the host tool service.')
122
123
  }
123
124
 
125
+ /**
126
+ * The admission condition of one server.
127
+ *
128
+ * Without a condition every tool of every server is offered to the model,
129
+ * which is the behaviour before the lazy exposure existed. With one, the
130
+ * host evaluates it per model step and leaves the tools of a server that
131
+ * was not revealed out of that step's tool table.
132
+ *
133
+ * @param {string} serverId The server id.
134
+ * @returns {(context: { agent?: unknown }) => boolean} The condition.
135
+ */
136
+ function admission(serverId) {
137
+ if (typeof admissionFor !== 'function') return () => true
138
+ const condition = admissionFor(serverId)
139
+ return typeof condition === 'function' ? condition : () => true
140
+ }
141
+
124
142
  /** Registrations per server: `serverId` → `remoteToolName` → registration. */
125
143
  const registrations = new Map()
126
144
  /** Public name → the registration that owns it. */
@@ -239,7 +257,7 @@ export function createToolRegistry(options = {}) {
239
257
 
240
258
  let disposer
241
259
  try {
242
- disposer = tools.register(definition)
260
+ disposer = tools.register(definition, { when: admission(serverId) })
243
261
  } catch (registerError) {
244
262
  error(`dsh-oauth-mcp: the host refused the tool "${name}".`, registerError)
245
263
  return
@@ -374,65 +374,69 @@ export function createMcpTransport(options = {}) {
374
374
  const signal = linkSignals([sendOptions.signal, timeout.signal])
375
375
  let response
376
376
  try {
377
- response = await fetchImpl(url, {
378
- method: 'POST',
379
- headers: mergeHeaders(baseHeaders, extraHeaders),
380
- body: JSON.stringify(message),
381
- signal,
382
- redirect: 'manual',
383
- })
384
- } catch (cause) {
385
- timeout.dispose()
386
- if (timeout.signal.aborted) {
387
- throw new McpError(`Request timed out after ${timeoutMs}ms`, {
388
- code: ERROR_CODES.mcp_transport_error,
389
- cause,
377
+ try {
378
+ response = await fetchImpl(url, {
379
+ method: 'POST',
380
+ headers: mergeHeaders(baseHeaders, extraHeaders),
381
+ body: JSON.stringify(message),
382
+ signal,
383
+ redirect: 'manual',
390
384
  })
391
- }
392
- if (sendOptions.signal?.aborted) {
393
- throw new McpError('The MCP request was aborted.', {
385
+ } catch (cause) {
386
+ if (timeout.signal.aborted) {
387
+ throw new McpError(`Request timed out after ${timeoutMs}ms`, {
388
+ code: ERROR_CODES.mcp_transport_error,
389
+ cause,
390
+ })
391
+ }
392
+ if (sendOptions.signal?.aborted) {
393
+ throw new McpError('The MCP request was aborted.', {
394
+ code: ERROR_CODES.mcp_transport_error,
395
+ cause,
396
+ })
397
+ }
398
+ throw new McpError(`The MCP server at ${safeUrl} could not be reached.`, {
394
399
  code: ERROR_CODES.mcp_transport_error,
400
+ detail: redactText(cause?.message ?? String(cause)),
395
401
  cause,
396
402
  })
397
403
  }
398
- throw new McpError(`The MCP server at ${safeUrl} could not be reached.`, {
399
- code: ERROR_CODES.mcp_transport_error,
400
- detail: redactText(cause?.message ?? String(cause)),
401
- cause,
402
- })
403
- }
404
404
 
405
- const responseHeaders = response.headers
406
- timeout.dispose()
407
- const assignedSessionId = readHeader(responseHeaders, 'mcp-session-id')
408
- if (typeof assignedSessionId === 'string' && assignedSessionId.length > 0) {
409
- currentSessionId = assignedSessionId
410
- }
405
+ const responseHeaders = response.headers
406
+ const assignedSessionId = readHeader(responseHeaders, 'mcp-session-id')
407
+ if (typeof assignedSessionId === 'string' && assignedSessionId.length > 0) {
408
+ currentSessionId = assignedSessionId
409
+ }
411
410
 
412
- const status = typeof response.status === 'number' ? response.status : 0
413
- if (status === 202) {
414
- // The server accepted a notification; there is no body to read.
415
- await response.text().catch(() => '')
416
- return { status, headers: collectHeaders(responseHeaders), messages: [], response: undefined }
417
- }
418
- if (status < 200 || status >= 300) {
419
- const text = await response.text().catch(() => '')
420
- throw httpError(status, text, responseHeaders)
421
- }
411
+ const status = typeof response.status === 'number' ? response.status : 0
412
+ if (status === 202) {
413
+ // The server accepted a notification; there is no body to read.
414
+ await response.text().catch(() => '')
415
+ return { status, headers: collectHeaders(responseHeaders), messages: [], response: undefined }
416
+ }
417
+ if (status < 200 || status >= 300) {
418
+ const text = await response.text().catch(() => '')
419
+ throw httpError(status, text, responseHeaders)
420
+ }
422
421
 
423
- const contentType = readHeader(responseHeaders, 'content-type') ?? ''
424
- const messages = contentType.toLowerCase().includes(SSE_CONTENT_TYPE)
425
- ? await readEventStream(response, { matchId: requestId })
426
- : parseJsonBody(await response.text())
422
+ const contentType = readHeader(responseHeaders, 'content-type') ?? ''
423
+ const messages = contentType.toLowerCase().includes(SSE_CONTENT_TYPE)
424
+ ? await readEventStream(response, { matchId: requestId })
425
+ : parseJsonBody(await response.text())
427
426
 
428
- const matched = messages.find((message) => sameId(message?.id, requestId))
429
- if (requestId !== undefined && matched === undefined) {
430
- throw new McpError('The MCP server did not answer the request.', {
431
- code: ERROR_CODES.mcp_protocol_error,
432
- status,
433
- })
427
+ const matched = messages.find((message) => sameId(message?.id, requestId))
428
+ if (requestId !== undefined && matched === undefined) {
429
+ throw new McpError('The MCP server did not answer the request.', {
430
+ code: ERROR_CODES.mcp_protocol_error,
431
+ status,
432
+ })
433
+ }
434
+ return { status, headers: collectHeaders(responseHeaders), messages, response: matched }
435
+ } finally {
436
+ // The timer is released on every path, including the failing ones: a
437
+ // timer that outlives the request keeps the event loop alive.
438
+ timeout.dispose()
434
439
  }
435
- return { status, headers: collectHeaders(responseHeaders), messages, response: matched }
436
440
  }
437
441
 
438
442
  /**
@@ -450,14 +454,19 @@ export function createMcpTransport(options = {}) {
450
454
  if (currentProtocolVersion) extraHeaders['MCP-Protocol-Version'] = currentProtocolVersion
451
455
  try {
452
456
  const timeout = createTimeoutSignal(DEFAULT_REQUEST_TIMEOUT_MS)
453
- const response = await fetchImpl(url, {
454
- method: 'DELETE',
455
- headers: mergeHeaders(baseHeaders, { ...extraHeaders, 'Mcp-Session-Id': currentSessionId }),
456
- signal: timeout.signal,
457
- redirect: 'manual',
458
- })
459
- timeout.dispose()
460
- await response.text().catch(() => '')
457
+ try {
458
+ const response = await fetchImpl(url, {
459
+ method: 'DELETE',
460
+ headers: mergeHeaders(baseHeaders, { ...extraHeaders, 'Mcp-Session-Id': currentSessionId }),
461
+ signal: timeout.signal,
462
+ redirect: 'manual',
463
+ })
464
+ await response.text().catch(() => '')
465
+ } finally {
466
+ // The timer must be released on every path, including the failing one:
467
+ // a timer that outlives the request keeps the event loop alive.
468
+ timeout.dispose()
469
+ }
461
470
  } catch {
462
471
  // Terminating the session is best effort: the server may not support DELETE.
463
472
  }
package/lib/service.js CHANGED
@@ -626,6 +626,30 @@ export function createOauthMcpService(options) {
626
626
  return refreshed
627
627
  }
628
628
 
629
+ /**
630
+ * Release every pending authorization.
631
+ *
632
+ * The timer of a pending authorization keeps the event loop alive: it is
633
+ * ref'd on purpose, so that a request that waits for the user still times
634
+ * out on Node 22 (see `lib/http/timeout.js`). The host must therefore be
635
+ * able to give the pending authorizations up when the plugin is unloaded,
636
+ * and a test must be able to do the same after it is done.
637
+ *
638
+ * @returns {void}
639
+ */
640
+ function dispose() {
641
+ for (const serverId of [...pending.keys()]) {
642
+ rejectPending(
643
+ serverId,
644
+ new OAuthError(
645
+ ERROR_CODES.authorization_cancelled,
646
+ language().t('server.error.no_pending_authorization', { serverId }),
647
+ ),
648
+ )
649
+ }
650
+ stateIndex.clear()
651
+ }
652
+
629
653
  return {
630
654
  listServers,
631
655
  describeServer,
@@ -645,6 +669,7 @@ export function createOauthMcpService(options) {
645
669
  updateServer,
646
670
  isAuthorized,
647
671
  refreshToken,
672
+ dispose,
648
673
  /** @internal Exposed for the host callback receiver and the host command. */
649
674
  ctx,
650
675
  }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The `mcp_oauth` agent tool: the entry point of the lazy exposure.
3
+ *
4
+ * While lazy exposure is on, the remote tools of the authorized servers are
5
+ * not in the model's tool table. This tool is the way in: the model calls it
6
+ * to learn which servers are authorized and which tools they expose, and the
7
+ * tools it asks for become callable from the next step on.
8
+ *
9
+ * The tool is the only one the plugin keeps in the table unconditionally, so
10
+ * it is also the tool that tells the model that the others exist. Its
11
+ * description has to carry that job on its own: the model sees this tool and
12
+ * nothing else.
13
+ *
14
+ * @module dsh-oauth-mcp/tools/reveal
15
+ */
16
+
17
+ import { REVEAL_TOOL_NAME } from '../mcp/reveal.js'
18
+
19
+ export { REVEAL_TOOL_NAME }
20
+
21
+ /** JSON Schema of the value the tool resolves with. */
22
+ const REVEAL_OUTPUT_SCHEMA = Object.freeze({
23
+ type: 'array',
24
+ description: 'The servers whose tools were revealed, and the tools they expose.',
25
+ items: {
26
+ type: 'object',
27
+ properties: {
28
+ serverId: { type: 'string', description: 'The identifier of the revealed MCP server.' },
29
+ label: { type: 'string', description: 'The human readable label of the server.' },
30
+ authorized: { type: 'boolean', description: 'Whether the server has a usable grant.' },
31
+ connected: { type: 'boolean', description: 'Whether the MCP session is established.' },
32
+ tools: {
33
+ type: 'array',
34
+ items: { type: 'string' },
35
+ description:
36
+ 'The public names of the tools of this server, for example "mcp_oauth__notion__search". They are callable from the next step on.',
37
+ },
38
+ },
39
+ required: ['serverId', 'label', 'authorized', 'connected', 'tools'],
40
+ },
41
+ })
42
+
43
+ /**
44
+ * The description of the entry tool.
45
+ *
46
+ * It is written for a model that has no other MCP tool in its table: it says
47
+ * what the tool is for, when to call it, and what comes back.
48
+ *
49
+ * @returns {string} The description.
50
+ */
51
+ function buildDescription() {
52
+ return (
53
+ 'Reveal the tools of the OAuth protected MCP servers of this profile. ' +
54
+ 'The tools of those servers are not in your tool table until you ask for them, ' +
55
+ 'so that every step does not carry their schemas. ' +
56
+ 'Call it with {"server":"<serverId>"} to reveal one server, ' +
57
+ 'or without arguments to list the authorized servers. ' +
58
+ 'The answer lists the server and the names of its tools; those names are callable from the next step on.'
59
+ )
60
+ }
61
+
62
+ /**
63
+ * Build the entry tool.
64
+ *
65
+ * @param {object} options The options.
66
+ * @param {import('../mcp/reveal.js').RevealState} options.reveal The reveal state.
67
+ * @param {{ status: (serverId?: string) => Promise<object[]>, listToolNames: (serverId: string) => string[] }} options.service The plugin service.
68
+ * @param {() => string} [options.locale] The current locale.
69
+ * @returns {object} The tool definition.
70
+ */
71
+ export function createRevealTool({ reveal, service, locale }) {
72
+ return {
73
+ name: REVEAL_TOOL_NAME,
74
+ description: buildDescription(),
75
+ parameters: {
76
+ type: 'object',
77
+ properties: {
78
+ server: {
79
+ type: 'string',
80
+ description:
81
+ 'The id of the MCP server to reveal, for example "notion". Leave it out to list the authorized servers.',
82
+ },
83
+ },
84
+ },
85
+ output: { schema: REVEAL_OUTPUT_SCHEMA, render: (value) => value },
86
+ execute: async (args, exec) => {
87
+ const agent = exec?.agent
88
+ const requested = typeof args?.server === 'string' ? args.server.trim() : ''
89
+
90
+ const rows = await service.status(requested || undefined)
91
+ if (rows.length === 0) {
92
+ return [
93
+ {
94
+ type: 'text',
95
+ text: requested
96
+ ? `No MCP server with the id "${requested}" is configured in this profile.`
97
+ : 'No MCP server is configured in this profile. Add one in the settings, then authorize it.',
98
+ },
99
+ ]
100
+ }
101
+
102
+ // Reveal what the model asked for. Without a server id every authorized
103
+ // server is revealed: the model asked what is there, and the answer is
104
+ // more useful than another round trip.
105
+ const wanted = rows.filter((row) => row.authorized).map((row) => row.serverId)
106
+ const target = requested ? rows.filter((row) => row.serverId === requested) : rows
107
+ const revealIds = target.filter((row) => row.authorized).map((row) => row.serverId)
108
+ reveal.reveal(agent, revealIds)
109
+
110
+ const lines = []
111
+ for (const row of target) {
112
+ const tools = service.listToolNames(row.serverId)
113
+ lines.push(
114
+ [
115
+ `${row.serverId}: ${row.label}`,
116
+ row.authorized ? 'authorized' : 'not authorized yet',
117
+ row.connected ? 'connected' : 'not connected',
118
+ tools.length > 0 ? `tools: ${tools.join(', ')}` : 'no tools yet',
119
+ ].join(' — '),
120
+ )
121
+ }
122
+ if (requested && revealIds.length === 0) {
123
+ lines.push(`The server "${requested}" is not authorized yet, so its tools were not revealed.`)
124
+ }
125
+ if (wanted.length === 0 && !requested) {
126
+ lines.push('No server is authorized yet. Use the oauth-mcp command to authorize one.')
127
+ }
128
+
129
+ return [
130
+ {
131
+ type: 'text',
132
+ text: lines.join('\n'),
133
+ },
134
+ ]
135
+ },
136
+ }
137
+ }
package/locale/en.json CHANGED
@@ -45,6 +45,8 @@
45
45
  "panel.global.toolPrefix": "Tool name prefix",
46
46
  "panel.global.defaultScopes": "Default scopes",
47
47
  "panel.global.autoConnect": "Connect on startup",
48
+ "panel.global.lazyTools": "Lazy tools",
49
+ "panel.global.lazyToolsHint": "The remote tools enter the tool table only after the agent reveals them with the mcp_oauth tool, so no step carries their definitions. Turn it off to keep them in the table all the time.",
48
50
  "panel.global.restartHint": "Callback mode, callback port, callback path and the tool name prefix are edited in the host configuration file (Settings → General → Open configuration file); the host restarts this plugin afterwards.",
49
51
  "panel.servers.vendor": "Vendor",
50
52
  "panel.servers.vendor.custom": "Custom (type the address yourself)",
package/locale/zh-CN.json CHANGED
@@ -45,6 +45,8 @@
45
45
  "panel.global.toolPrefix": "工具名前缀",
46
46
  "panel.global.defaultScopes": "默认权限范围",
47
47
  "panel.global.autoConnect": "启动时自动连接",
48
+ "panel.global.lazyTools": "惰性工具",
49
+ "panel.global.lazyToolsHint": "远端工具只有在 agent 调用 mcp_oauth 揭示之后才进工具表,这样每一步就不必背着它们的定义。关掉则一直挂着。",
48
50
  "panel.global.restartHint": "回调方式、回调端口、回调路径与工具名前缀在宿主的配置文件里改(设置 → 通用设置 → 打开配置文件),保存后宿主会重启本插件。",
49
51
  "panel.servers.vendor": "厂商",
50
52
  "panel.servers.vendor.custom": "自定义(手动填写地址)",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-oauth-mcp",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "OAuth 2.1 client and MCP bridge for DSH: discover and authorize remote MCP servers, keep tokens fresh, and expose their tools",
5
5
  "keywords": [
6
6
  "dsh",