dsh-plugin-scys-mcp 0.0.0-stage → 0.1.1

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
@@ -1,3 +1,123 @@
1
- # Temporary Holding Version
1
+ # dsh-plugin-scys-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 生财有术(scys.com)MCP 的 DeepSeek Harness 插件。装好后**打开浏览器授权一次**,就把 160 个站内工具接进 Harness:
4
+
5
+ ```
6
+ mcp__scys-mcp__searchTopic 搜帖子
7
+ mcp__scys-mcp__getDailyReport 生财日报
8
+ mcp__scys-mcp__getMyInsight 我的画像
9
+ mcp__scys-mcp__getUserInsight 用户画像
10
+ ...
11
+ ```
12
+
13
+ 不需要本地代理、不需要手工改配置、不需要额外进程;macOS / Windows / Linux 行为一致。
14
+ 连接中断时插件会自动重建传输并重试一次当前调用,已注册的工具不会消失。
15
+
16
+ ## 为什么需要插件
17
+
18
+ DSH 自带的 `dsh-mcp-client` 只支持静态 `headers`,而 scys MCP 要求 OAuth 2.1(PKCE + 动态客户端注册)。本插件在 Harness 进程内完成授权并把令牌注入每个请求,因此不需要旁路进程。
19
+
20
+ ## 安装
21
+
22
+ ```bash
23
+ # 从 npm(发布后)
24
+ dsh plugin --profile desktop add dsh-plugin-scys-mcp
25
+
26
+ # 或从本地目录 / tarball
27
+ dsh plugin --profile desktop add file:/path/to/dsh-plugin-scys-mcp
28
+ ```
29
+
30
+ 也可以在 Web 侧边栏 **Plugins** 页搜索安装。安装后插件自带一行配置:
31
+
32
+ ```yaml
33
+ - insert:
34
+ - id: mcp-scys-oauth
35
+ name: 'dsh-plugin-scys-mcp'
36
+ config:
37
+ serverName: scys-mcp
38
+ ```
39
+
40
+ > 如果你之前手工加过 `mcp-scys` / `mcp-scys-oauth` 行,先删掉,避免同 `serverName` 冲突。
41
+
42
+ ## 首次授权
43
+
44
+ 插件启动时若没有有效令牌,会自动打开浏览器:
45
+
46
+ 1. 浏览器跳到 `https://mcp.scys.com/mcp-oauth/authorize?...`
47
+ 2. 用你的生财有术账号登录并同意授权
48
+ 3. 页面显示“授权成功”,回到 Harness —— 工具会在几秒内出现
49
+
50
+ 授权窗口最长等待 15 分钟。如果错过了,跟模型说一句“重新授权生财 MCP”,或直接调用 `mcp__scys-mcp__auth`,会重新打开授权页。
51
+
52
+ ## 令牌
53
+
54
+ | 项 | 说明 |
55
+ | --- | --- |
56
+ | access token | 1 小时,到期前 60 秒自动刷新 |
57
+ | refresh token | 每次刷新都会轮换,14 天窗口随之顺延 |
58
+ | 存储位置 | `~/.dsh/mcp/scys-mcp/tokens.json`(Windows: `%USERPROFILE%\.dsh\mcp\scys-mcp\`) |
59
+ | 客户端注册 | `client.json`(动态注册的 public client,绑定 `http://127.0.0.1:<port>/callback`) |
60
+
61
+ 令牌只在本地,插件直接访问 `mcp.scys.com`,不经过任何第三方。
62
+
63
+ ## 配置项
64
+
65
+ | 字段 | 默认值 | 说明 |
66
+ | --- | --- | --- |
67
+ | `serverName` | `scys-mcp` | 工具前缀,`[A-Za-z0-9_-]{1,32}` |
68
+ | `url` | `https://mcp.scys.com/shengcai-web/mcp` | MCP 端点 |
69
+ | `port` | `18787` | 授权回调端口,占用时自动换空闲端口 |
70
+ | `openBrowser` | `true` | 授权时是否自动打开浏览器 |
71
+ | `toolCallTimeoutMs` | `120000` | 单次工具调用超时 |
72
+ | `authorizeTimeoutMs` | `900000` | 授权等待上限 |
73
+
74
+ ## 故障排查
75
+
76
+ 看 `~/.dsh/mcp/scys-mcp/plugin.log`:
77
+
78
+ - `registered N/M tools` —— 正常,N 应接近 160。
79
+ - `register <tool> failed` —— 某个工具的 schema 超出 Harness 支持的子集,会被跳过,不影响其他工具。
80
+ - `授权已失效` —— 重新授权:调用 `mcp__scys-mcp__auth`。
81
+ - 端口被占 —— 自动改用空闲端口,无需处理。
82
+
83
+ ## 卸载
84
+
85
+ ```bash
86
+ dsh plugin --profile desktop remove dsh-plugin-scys-mcp
87
+ rm -rf ~/.dsh/mcp/scys-mcp # 同时清除本地令牌
88
+ ```
89
+
90
+ ## 平台说明
91
+
92
+ - **macOS**:`open` 打开默认浏览器;无需任何系统权限。
93
+ - **Windows**:`rundll32 url.dll,FileProtocolHandler` 打开默认浏览器(不经 `cmd.exe`,URL 里的 `&` 不会被当成命令分隔符);回调只监听 `127.0.0.1`,不会触发防火墙弹窗。
94
+ - **Linux**:`xdg-open`。
95
+
96
+ ## 开发
97
+
98
+ ```bash
99
+ node scripts/selftest.mjs # 用已存令牌跑 initialize / tools/list / tools/call
100
+ node scripts/plugin-test.mjs # 全链路离线测试:模拟 AS + MCP,验证授权、注册、断线重连
101
+ ```
102
+
103
+ 源码结构:
104
+
105
+ | 文件 | 作用 |
106
+ | --- | --- |
107
+ | `lib/index.js` | 插件入口:连接、工具注册、`auth` 工具 |
108
+ | `lib/oauth.js` | OAuth 2.1:发现、DCR、PKCE、刷新、回环回调 |
109
+ | `lib/mcp.js` | Streamable HTTP 上的 JSON-RPC 客户端(JSON + SSE) |
110
+ | `lib/store.js` | 本地令牌与客户端注册存储 |
111
+ | `scripts/plugin-test.mjs` | 零网络的端到端测试(mock OAuth + mock MCP) |
112
+
113
+ 零运行时依赖,只用 Node 内置模块与全局 `fetch`。
114
+
115
+ ## 发布到 npm 前检查
116
+
117
+ 1. **包名**:如果 `dsh-plugin-scys-mcp` 已被占用,改用 scope,例如 `@yourname/dsh-plugin-scys-mcp`。
118
+ 2. **同步 patch 里的 name**:`cordis.patch.yml` 的 `name:` 必须和 package.json 的 `name` 完全一致,否则安装后加载不到插件。
119
+ 3. `npm pack` 检查内容(应为 `lib/*.js` + `cordis.patch.yml` + `README.md` + `package.json`)。
120
+ 4. `npm publish`(scoped 包加 `--access public`,仓库里已声明 `publishConfig.access`)。
121
+
122
+ 装好后的启用路径是 **Plugins 页面 / `plugin_manager.installBundle`**(它会写 `dsh.profile.bundles`);
123
+ 只跑 `dsh plugin --profile desktop add` 只是 pnpm 安装,不会自动启用该 bundle。
@@ -0,0 +1,9 @@
1
+ # 生财有术 MCP:OAuth 2.1 + 原生工具注册。
2
+ # 安装后首次启动会打开浏览器完成授权,随后自动注册 mcp__scys-mcp__* 工具。
3
+ - insert:
4
+ - id: mcp-scys-oauth
5
+ name: 'dsh-plugin-scys-mcp'
6
+ config:
7
+ serverName: scys-mcp
8
+ url: https://mcp.scys.com/shengcai-web/mcp
9
+ openBrowser: true
package/icon.svg ADDED
@@ -0,0 +1,12 @@
1
+ <svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0" y1="0" x2="36" y2="36" gradientUnits="userSpaceOnUse">
4
+ <stop stop-color="#0EA5E9"/>
5
+ <stop offset="1" stop-color="#4F46E5"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect width="36" height="36" rx="9" fill="url(#bg)"/>
9
+ <rect x="9" y="20" width="4" height="7" rx="2" fill="#FFFFFF" fill-opacity="0.82"/>
10
+ <rect x="16" y="16" width="4" height="11" rx="2" fill="#FFFFFF" fill-opacity="0.92"/>
11
+ <rect x="23" y="11" width="4" height="16" rx="2" fill="#FCD34D"/>
12
+ </svg>
package/lib/index.js ADDED
@@ -0,0 +1,372 @@
1
+ /**
2
+ * dsh-plugin-scys-mcp — 生财有术 MCP for DeepSeek Harness.
3
+ *
4
+ * Connects to https://mcp.scys.com/shengcai-web/mcp with OAuth 2.1 (browser
5
+ * authorization + PKCE + rotating refresh token), then publishes every MCP
6
+ * tool to the harness tool registry as `mcp__scys-mcp__<name>`.
7
+ *
8
+ * The plugin owns the whole connection: no sidecar process, no profile edits
9
+ * beyond this row, identical behaviour on macOS, Windows and Linux.
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { createHash } from 'node:crypto';
14
+ import { accessToken, authorize, openBrowser, watchAuthUrl, DEFAULT_RESOURCE } from './oauth.js';
15
+ import { McpHttp, McpError } from './mcp.js';
16
+ import { tokenStatus, stateDir } from './store.js';
17
+
18
+ export const name = 'scys-mcp';
19
+ export const inject = ['tools'];
20
+
21
+ /** Append to <state>/plugin.log so installs can be diagnosed without host logs. */
22
+ function fileLog(level, message) {
23
+ try {
24
+ fs.mkdirSync(stateDir(), { recursive: true, mode: 0o700 });
25
+ fs.appendFileSync(path.join(stateDir(), 'plugin.log'), `${new Date().toISOString()} ${level} ${message}\n`);
26
+ } catch { /* logging must never break the plugin */ }
27
+ }
28
+
29
+ const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/;
30
+ const MAX_PUBLIC_NAME_LENGTH = 64;
31
+ const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g;
32
+ const HASH_LENGTH = 12;
33
+ const RETRY_BASE_MS = 30_000;
34
+ const RETRY_MAX_MS = 10 * 60_000;
35
+
36
+ /** Same identity contract as dsh-mcp-client: `mcp__<server>__<tool>`. */
37
+ function publicToolName(serverName, rawName) {
38
+ const joined = `mcp__${serverName}__${rawName}`;
39
+ const normalized = joined.replace(INVALID_NAME_CHARS, '_');
40
+ if (normalized === joined && normalized.length <= MAX_PUBLIC_NAME_LENGTH) return normalized;
41
+ const hash = createHash('sha256').update(`${serverName}\0${rawName}`).digest('hex').slice(0, HASH_LENGTH);
42
+ return `${normalized.slice(0, MAX_PUBLIC_NAME_LENGTH - HASH_LENGTH - 1)}_${hash}`;
43
+ }
44
+
45
+ /** Project MCP content blocks into model-visible text. */
46
+ function extractText(content, toolName) {
47
+ if (!Array.isArray(content) || content.length === 0) return `(${toolName} returned no content)`;
48
+ const lines = [];
49
+ for (const block of content) {
50
+ if (block === null || typeof block !== 'object') { lines.push('[unsupported MCP content block]'); continue; }
51
+ switch (block.type) {
52
+ case 'text':
53
+ if (block.text !== undefined) lines.push(block.text);
54
+ break;
55
+ case 'resource_link':
56
+ lines.push(block.name !== undefined && block.uri !== undefined
57
+ ? `Resource link: ${block.name} (${block.uri})`
58
+ : '[resource link unavailable]');
59
+ break;
60
+ case 'image':
61
+ lines.push(`[image result: ${block.mimeType ?? 'unknown media type'}; not rendered as text]`);
62
+ break;
63
+ case 'audio':
64
+ lines.push(`[audio result unsupported: ${block.mimeType ?? 'unknown media type'}]`);
65
+ break;
66
+ case 'resource':
67
+ lines.push('[embedded resource unsupported]');
68
+ break;
69
+ default:
70
+ lines.push(`[unsupported MCP content type: ${block.type}]`);
71
+ }
72
+ }
73
+ return lines.join('\n');
74
+ }
75
+
76
+ /** Transport failures worth one reconnect + retry; tool-level errors are not. */
77
+ function isTransient(err) {
78
+ if (err === null || typeof err !== 'object') return false;
79
+ if (err.name === 'AbortError') return false;
80
+ if (err instanceof McpError) return err.status === undefined || err.status >= 500 || err.status === 408 || err.status === 429;
81
+ if (err instanceof TypeError) return true;
82
+ if (typeof err.code === 'string' && /^(ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|UND_ERR)/.test(err.code)) return true;
83
+ return false;
84
+ }
85
+
86
+ const SUPPORTED_SCHEMA_KEYS = new Set([
87
+ 'type', 'oneOf', 'properties', 'required', 'additionalProperties', 'items',
88
+ 'enum', 'const', 'description', 'title', 'default', 'examples',
89
+ 'deprecated', 'readOnly', 'writeOnly', '$comment',
90
+ ]);
91
+
92
+ /**
93
+ * The harness accepts a JSON-Schema subset (no `$ref`, boolean
94
+ * `additionalProperties` only). An unsupported output schema is dropped rather
95
+ * than rejecting the whole tool — the text projection still carries the result.
96
+ */
97
+ function isSupportedJsonSchema(schema, depth = 0) {
98
+ if (depth > 8) return false;
99
+ if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) return false;
100
+ for (const key of Object.keys(schema)) if (!SUPPORTED_SCHEMA_KEYS.has(key)) return false;
101
+ if (schema.type !== undefined && typeof schema.type !== 'string') return false;
102
+ if (schema.additionalProperties !== undefined && typeof schema.additionalProperties !== 'boolean') return false;
103
+ if (schema.properties !== undefined) {
104
+ if (typeof schema.properties !== 'object' || Array.isArray(schema.properties)) return false;
105
+ for (const value of Object.values(schema.properties)) {
106
+ if (!isSupportedJsonSchema(value, depth + 1)) return false;
107
+ }
108
+ }
109
+ if (schema.items !== undefined) {
110
+ const items = Array.isArray(schema.items) ? schema.items : [schema.items];
111
+ for (const item of items) if (!isSupportedJsonSchema(item, depth + 1)) return false;
112
+ }
113
+ if (schema.oneOf !== undefined) {
114
+ if (!Array.isArray(schema.oneOf)) return false;
115
+ for (const arm of schema.oneOf) if (!isSupportedJsonSchema(arm, depth + 1)) return false;
116
+ }
117
+ if (schema.required !== undefined && (!Array.isArray(schema.required) || schema.required.some((v) => typeof v !== 'string'))) return false;
118
+ return true;
119
+ }
120
+
121
+ function supportedOutputSchema(candidate) {
122
+ if (candidate === undefined) return undefined;
123
+ return isSupportedJsonSchema(candidate) ? candidate : undefined;
124
+ }
125
+
126
+ /** Build the ToolRuntime definition for one MCP tool. */
127
+ function defineTool(runtime, tool, options) {
128
+ const publicName = publicToolName(options.serverName, tool.name);
129
+ const rawName = tool.name;
130
+ const outputSchema = supportedOutputSchema(tool.outputSchema);
131
+ const timeoutMs = options.toolCallTimeoutMs;
132
+
133
+ const invoke = async (args, exec) => runtime.client().callTool(rawName, args, {
134
+ signal: exec?.signal,
135
+ timeoutMs,
136
+ });
137
+
138
+ return {
139
+ name: publicName,
140
+ description: tool.description ?? '',
141
+ parameters: tool.inputSchema ?? { type: 'object', properties: {} },
142
+ output: {
143
+ schema: {
144
+ type: 'object',
145
+ properties: {
146
+ content: { type: 'array', items: {} },
147
+ structuredContent: outputSchema ?? {},
148
+ },
149
+ required: outputSchema === undefined ? ['content'] : ['content', 'structuredContent'],
150
+ additionalProperties: false,
151
+ },
152
+ render(_args, value) {
153
+ return [{ type: 'text', text: extractText(value.content, rawName) }];
154
+ },
155
+ },
156
+ async execute(args, exec) {
157
+ if (tool.execution?.taskSupport === 'required') {
158
+ throw new Error(`Tool "${rawName}" requires task-based execution, which this bridge does not support`);
159
+ }
160
+ const callArgs = typeof args === 'object' && args !== null ? args : {};
161
+ let result;
162
+ try {
163
+ result = await invoke(callArgs, exec);
164
+ } catch (err) {
165
+ if (runtime.stopped() || !isTransient(err)) throw err;
166
+ fileLog('WARN', `tool ${rawName} hit a transport error, reconnecting: ${err?.message ?? err}`);
167
+ await runtime.reconnect();
168
+ result = await invoke(callArgs, exec);
169
+ }
170
+ const content = Array.isArray(result?.content) ? result.content : [];
171
+ const text = extractText(content, rawName);
172
+ if (result?.isError === true) throw new Error(text);
173
+ return {
174
+ content,
175
+ ...(result?.structuredContent !== undefined ? { structuredContent: result.structuredContent } : {}),
176
+ };
177
+ },
178
+ };
179
+ }
180
+
181
+ /** A small always-available tool so authorization can be retried from chat. */
182
+ function authTool(ctx, options, runtime) {
183
+ const publicName = publicToolName(options.serverName, 'auth');
184
+ return {
185
+ name: publicName,
186
+ description: '生财有术 MCP(scys-mcp)的授权状态与重新授权。未授权时调用会在浏览器打开授权页;授权完成后 MCP 工具会自动注册。',
187
+ parameters: { type: 'object', properties: { reauthorize: { type: 'boolean', description: '强制重新走一次浏览器授权' } } },
188
+ output: {
189
+ schema: { type: 'object', properties: { content: { type: 'array', items: {} } }, required: ['content'], additionalProperties: false },
190
+ render(_args, value) { return value.content; },
191
+ },
192
+ async execute(args) {
193
+ if (args?.reauthorize === true) runtime.needsAuth = true;
194
+ const status = tokenStatus();
195
+ if (!runtime.needsAuth && status.authorized) {
196
+ const suffix = status.expired ? '(已过期,下一次工具调用会自动刷新)' : '';
197
+ return { content: [{ type: 'text', text: `已授权,access token 到期时间:${status.expires_at ?? '未知'}${suffix};已注册 ${runtime.registered} 个工具。` }] };
198
+ }
199
+ const url = await runtime.pendingUrl();
200
+ return { content: [{ type: 'text', text: `已发起授权,请在浏览器完成登录:\n${url}` }] };
201
+ },
202
+ };
203
+ }
204
+
205
+ function normalize(config = {}) {
206
+ const serverName = config.serverName ?? 'scys-mcp';
207
+ if (!SERVER_NAME_PATTERN.test(serverName)) throw new Error(`scys-mcp: invalid serverName "${serverName}"`);
208
+ return {
209
+ serverName,
210
+ url: config.url ?? DEFAULT_RESOURCE,
211
+ resource: config.resource ?? config.url ?? DEFAULT_RESOURCE,
212
+ port: Number(config.port ?? 18787),
213
+ openBrowser: config.openBrowser !== false,
214
+ toolCallTimeoutMs: Number(config.toolCallTimeoutMs ?? 120_000),
215
+ authorizeTimeoutMs: Number(config.authorizeTimeoutMs ?? 15 * 60 * 1000),
216
+ clientName: config.clientName,
217
+ };
218
+ }
219
+
220
+ export function apply(ctx, config = {}) {
221
+ const options = normalize(config);
222
+
223
+ ctx.effect(() => {
224
+ const controller = new AbortController();
225
+ let stopped = false;
226
+ let disposers = [];
227
+ let retryTimer;
228
+ let mcp;
229
+ let attempt = 0;
230
+ let authUrl;
231
+ let authPromise; // resolves with tokens
232
+ let urlPromise; // resolves with the authorization URL
233
+ let reconnecting = null;
234
+
235
+ const tokenOptions = () => ({ ...options, url: options.url });
236
+ const getToken = (control = {}) => accessToken(tokenOptions(), {
237
+ ...control,
238
+ allowInteractive: false,
239
+ onUrl: (url) => { authUrl = url; },
240
+ });
241
+
242
+ const runtime = {
243
+ needsAuth: false,
244
+ authTried: false,
245
+ registered: 0,
246
+ stopped: () => stopped,
247
+ client() {
248
+ if (!mcp) throw new Error('生财有术 MCP 尚未连接(授权可能未完成)');
249
+ return mcp;
250
+ },
251
+ /** Replace the transport after a transient failure; tools keep working. */
252
+ reconnect() {
253
+ if (reconnecting) return reconnecting;
254
+ reconnecting = (async () => {
255
+ const next = new McpHttp({ url: options.url, timeoutMs: options.toolCallTimeoutMs, getToken });
256
+ await next.initialize(controller.signal);
257
+ const previous = mcp;
258
+ mcp = next;
259
+ previous?.close().catch(() => {});
260
+ fileLog('INFO', 'transport reconnected');
261
+ return next;
262
+ })().finally(() => { reconnecting = null; });
263
+ return reconnecting;
264
+ },
265
+ /** Start (or join) the interactive flow and resolve with its URL. */
266
+ async pendingUrl() {
267
+ if (authPromise) return urlPromise;
268
+ authPromise = startAuth();
269
+ return urlPromise;
270
+ },
271
+ };
272
+
273
+ async function startAuth() {
274
+ const deferred = Promise.withResolvers();
275
+ const stop = watchAuthUrl((url) => {
276
+ authUrl = url;
277
+ deferred.resolve(url);
278
+ fileLog('WARN', `authorization url ${url}`);
279
+ ctx.logger.warn(`scys-mcp: 请在浏览器完成授权 ${url}`);
280
+ });
281
+ urlPromise = deferred.promise;
282
+ authPromise = authorize({ ...options, openBrowser: true }).then((tokens) => {
283
+ runtime.needsAuth = false;
284
+ return tokens;
285
+ }).catch((err) => {
286
+ if (!authUrl) deferred.reject(err);
287
+ ctx.logger.error(`scys-mcp: 授权失败 ${err?.message ?? err}`);
288
+ throw err;
289
+ }).finally(() => {
290
+ stop();
291
+ authPromise = null;
292
+ authUrl = undefined;
293
+ });
294
+ return authPromise;
295
+ }
296
+
297
+ const resetTools = () => {
298
+ for (const dispose of disposers) {
299
+ try { dispose(); } catch { /* already gone */ }
300
+ }
301
+ disposers = [];
302
+ runtime.registered = 0;
303
+ };
304
+
305
+ async function connect() {
306
+ try {
307
+ const token = await accessToken(
308
+ // Only the first attempt opens a browser on its own; later attempts
309
+ // go through the `auth` tool so a sleeping machine is not spammed.
310
+ { ...tokenOptions(), openBrowser: runtime.authTried ? false : options.openBrowser },
311
+ {
312
+ onUrl: (url) => {
313
+ authUrl = url;
314
+ ctx.logger.warn(`scys-mcp: 需要浏览器授权,已打开 ${url}`);
315
+ },
316
+ },
317
+ );
318
+ if (stopped) return;
319
+ if (!token) throw new Error('no access token');
320
+ const client = new McpHttp({ url: options.url, timeoutMs: options.toolCallTimeoutMs, getToken });
321
+ await client.initialize(controller.signal);
322
+ if (stopped) return;
323
+ const tools = await client.listTools(controller.signal);
324
+ if (stopped) return;
325
+ mcp = client;
326
+ resetTools();
327
+ const definitions = tools.map((tool) => defineTool(runtime, tool, options));
328
+ definitions.push(authTool(ctx, options, runtime));
329
+ let failed = 0;
330
+ for (const definition of definitions) {
331
+ try {
332
+ disposers.push(ctx.tools.register(definition));
333
+ } catch (err) {
334
+ failed += 1;
335
+ fileLog('WARN', `register ${definition.name} failed: ${err?.message ?? err}`);
336
+ }
337
+ }
338
+ attempt = 0;
339
+ runtime.registered = definitions.length - failed;
340
+ runtime.needsAuth = false;
341
+ fileLog('INFO', `connected ${options.url}, registered ${runtime.registered}/${definitions.length} tools`);
342
+ ctx.logger.info(`scys-mcp: connected to ${options.url}, registered ${runtime.registered}/${definitions.length} tools`);
343
+ } catch (err) {
344
+ if (stopped) return;
345
+ runtime.needsAuth = true;
346
+ runtime.authTried = true;
347
+ fileLog('ERROR', `connect failed: ${err?.stack ?? err}`);
348
+ ctx.logger.error(`scys-mcp: connect failed — ${err?.message ?? err}`);
349
+ // Register the auth escape hatch even when the MCP side is unavailable.
350
+ if (disposers.length === 0) {
351
+ try { disposers.push(ctx.tools.register(authTool(ctx, options, runtime))); } catch { /* tools may be gone */ }
352
+ }
353
+ const delay = Math.min(RETRY_BASE_MS * 2 ** Math.min(attempt, 5), RETRY_MAX_MS);
354
+ attempt += 1;
355
+ retryTimer = setTimeout(connect, delay);
356
+ retryTimer.unref?.();
357
+ }
358
+ }
359
+
360
+ connect();
361
+
362
+ return () => {
363
+ stopped = true;
364
+ controller.abort();
365
+ if (retryTimer) clearTimeout(retryTimer);
366
+ resetTools();
367
+ mcp?.close();
368
+ };
369
+ }, 'scys-mcp.connection');
370
+ }
371
+
372
+ export { openBrowser, tokenStatus };
package/lib/mcp.js ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Minimal MCP client over the Streamable HTTP transport.
3
+ *
4
+ * The plugin only needs request/response MCP (initialize, tools/list,
5
+ * tools/call), so this speaks JSON-RPC directly and understands both plain
6
+ * JSON and `text/event-stream` replies. No SDK dependency — the plugin stays
7
+ * installable on any DSH build.
8
+ */
9
+
10
+ const PROTOCOL_VERSION = '2025-06-18';
11
+ const CLIENT_INFO = { name: 'dsh-plugin-scys-mcp', version: '0.1.0' };
12
+
13
+ export class McpError extends Error {
14
+ constructor(message, { code, data, status } = {}) {
15
+ super(message);
16
+ this.name = 'McpError';
17
+ this.code = code;
18
+ this.data = data;
19
+ this.status = status;
20
+ }
21
+ }
22
+
23
+ /** Read an SSE body until the reply for `id` arrives, then cancel the stream. */
24
+ async function readSse(res, id) {
25
+ const reader = res.body.getReader();
26
+ const decoder = new TextDecoder();
27
+ let buffer = '';
28
+ try {
29
+ for (;;) {
30
+ const { done, value } = await reader.read();
31
+ if (done) return null;
32
+ buffer += decoder.decode(value, { stream: true });
33
+ let split;
34
+ while ((split = buffer.indexOf('\n\n')) >= 0) {
35
+ const block = buffer.slice(0, split);
36
+ buffer = buffer.slice(split + 2);
37
+ const data = block.split('\n')
38
+ .filter((line) => line.startsWith('data:'))
39
+ .map((line) => line.slice(5).trim())
40
+ .join('\n');
41
+ if (!data) continue;
42
+ let message;
43
+ try { message = JSON.parse(data); } catch { continue; }
44
+ if (message.id === id || (id === undefined && message.method)) return message;
45
+ }
46
+ }
47
+ } finally {
48
+ reader.cancel().catch(() => {});
49
+ }
50
+ }
51
+
52
+ export class McpHttp {
53
+ /**
54
+ * @param {object} options
55
+ * @param {string} options.url MCP endpoint
56
+ * @param {(control?: {force?: boolean, onUrl?: (url: string) => void}) => Promise<string>} options.getToken
57
+ * @param {number} [options.timeoutMs] default per-request timeout
58
+ */
59
+ constructor({ url, getToken, timeoutMs = 60_000 }) {
60
+ this.url = url;
61
+ this.getToken = getToken;
62
+ this.timeoutMs = timeoutMs;
63
+ this.sessionId = null;
64
+ this.protocolVersion = null;
65
+ this.nextId = 1;
66
+ this.closed = false;
67
+ }
68
+
69
+ headers(token) {
70
+ const headers = {
71
+ 'content-type': 'application/json',
72
+ accept: 'application/json, text/event-stream',
73
+ authorization: `Bearer ${token}`,
74
+ };
75
+ if (this.sessionId) headers['mcp-session-id'] = this.sessionId;
76
+ if (this.protocolVersion) headers['mcp-protocol-version'] = this.protocolVersion;
77
+ return headers;
78
+ }
79
+
80
+ async post(payload, { signal, timeoutMs, forceToken = false } = {}) {
81
+ const timeout = AbortSignal.timeout(timeoutMs ?? this.timeoutMs);
82
+ const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
83
+ let token = await this.getToken({ force: forceToken });
84
+ const send = (bearer) => fetch(this.url, {
85
+ method: 'POST',
86
+ headers: this.headers(bearer),
87
+ body: JSON.stringify(payload),
88
+ signal: combined,
89
+ });
90
+ let res = await send(token);
91
+ if (res.status === 401) {
92
+ token = await this.getToken({ force: true });
93
+ res = await send(token);
94
+ }
95
+ const session = res.headers.get('mcp-session-id');
96
+ if (session) this.sessionId = session;
97
+ return res;
98
+ }
99
+
100
+ /** Send a request and resolve with its JSON-RPC result. */
101
+ async request(method, params, control = {}) {
102
+ const id = this.nextId++;
103
+ const res = await this.post({ jsonrpc: '2.0', id, method, params }, control);
104
+ if (!res.ok && res.status !== 200) {
105
+ const detail = await res.text().catch(() => '');
106
+ throw new McpError(`${method} failed: HTTP ${res.status} ${detail.slice(0, 300)}`.trim(), { status: res.status });
107
+ }
108
+ const contentType = res.headers.get('content-type') ?? '';
109
+ const message = contentType.includes('text/event-stream')
110
+ ? await readSse(res, id)
111
+ : await res.json().catch(() => null);
112
+ if (!message) throw new McpError(`${method} returned no response`);
113
+ if (message.error) {
114
+ throw new McpError(`${method} failed: ${message.error.message ?? 'unknown error'}`, {
115
+ code: message.error.code,
116
+ data: message.error.data,
117
+ });
118
+ }
119
+ return message.result;
120
+ }
121
+
122
+ /** Send a notification (no id, no result). */
123
+ async notify(method, params, control = {}) {
124
+ const res = await this.post({ jsonrpc: '2.0', method, params }, control);
125
+ await res.text().catch(() => '');
126
+ return res.status;
127
+ }
128
+
129
+ async initialize(signal) {
130
+ const result = await this.request('initialize', {
131
+ protocolVersion: PROTOCOL_VERSION,
132
+ capabilities: {},
133
+ clientInfo: CLIENT_INFO,
134
+ }, { signal });
135
+ this.protocolVersion = result?.protocolVersion ?? PROTOCOL_VERSION;
136
+ await this.notify('notifications/initialized', {}, { signal });
137
+ return result;
138
+ }
139
+
140
+ async listTools(signal) {
141
+ const tools = [];
142
+ let cursor;
143
+ for (let page = 0; page < 100; page++) {
144
+ const result = await this.request('tools/list', cursor ? { cursor } : {}, { signal });
145
+ if (Array.isArray(result?.tools)) tools.push(...result.tools);
146
+ cursor = result?.nextCursor;
147
+ if (!cursor) break;
148
+ }
149
+ return tools;
150
+ }
151
+
152
+ async callTool(name, args, { signal, timeoutMs } = {}) {
153
+ return this.request('tools/call', { name, arguments: args ?? {} }, { signal, timeoutMs });
154
+ }
155
+
156
+ async close() {
157
+ this.closed = true;
158
+ if (!this.sessionId) return;
159
+ try {
160
+ const token = await this.getToken();
161
+ await fetch(this.url, { method: 'DELETE', headers: this.headers(token), signal: AbortSignal.timeout(5000) });
162
+ } catch { /* best effort */ }
163
+ this.sessionId = null;
164
+ }
165
+ }
package/lib/oauth.js ADDED
@@ -0,0 +1,332 @@
1
+ /**
2
+ * OAuth 2.1 for the scys MCP server: protected-resource discovery, dynamic
3
+ * client registration (RFC 7591), authorization code + PKCE (S256), refresh,
4
+ * and a loopback callback listener that works on macOS, Windows and Linux.
5
+ *
6
+ * No third-party dependencies: only Node built-ins and global fetch.
7
+ */
8
+ import http from 'node:http';
9
+ import crypto from 'node:crypto';
10
+ import { spawn } from 'node:child_process';
11
+ import { paths, readJson, writeJson, readTokens } from './store.js';
12
+
13
+ export const DEFAULT_RESOURCE = 'https://mcp.scys.com/shengcai-web/mcp';
14
+ export const DEFAULT_SCOPE = 'mcp';
15
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
16
+ const FETCH_TIMEOUT_MS = 20_000;
17
+
18
+ const b64url = (buf) => Buffer.from(buf).toString('base64url');
19
+ /** RFC 8707 resource identifier: the MCP endpoint itself. */
20
+ const resourceOf = (options = {}) => options.resource ?? options.url ?? DEFAULT_RESOURCE;
21
+
22
+ async function fetchJson(url, init = {}) {
23
+ const res = await fetch(url, {
24
+ ...init,
25
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
26
+ });
27
+ const text = await res.text();
28
+ let json = null;
29
+ try { json = JSON.parse(text); } catch { /* not JSON */ }
30
+ if (!res.ok) {
31
+ const err = new Error(`${url} -> HTTP ${res.status}${json ? `: ${JSON.stringify(json).slice(0, 300)}` : ''}`);
32
+ err.status = res.status;
33
+ err.body = json;
34
+ throw err;
35
+ }
36
+ if (!json) throw new Error(`${url} did not return JSON: ${text.slice(0, 200)}`);
37
+ return json;
38
+ }
39
+
40
+ const discovered = new Map();
41
+
42
+ /**
43
+ * Resolve the authorization-server metadata advertised by the MCP endpoint.
44
+ * @param {{resource?: string, resourceMetadata?: string}} options
45
+ */
46
+ export async function discover(options = {}) {
47
+ const resource = resourceOf(options);
48
+ if (discovered.has(resource)) return discovered.get(resource);
49
+ const prmUrl = options.resourceMetadata
50
+ ?? new URL('/.well-known/oauth-protected-resource', resource).toString();
51
+ const prm = await fetchJson(prmUrl);
52
+ const issuer = prm.authorization_servers?.[0];
53
+ if (!issuer) throw new Error('protected resource metadata has no authorization_servers');
54
+ const u = new URL(issuer);
55
+ const base = `${u.origin}${u.pathname.replace(/\/$/, '')}`;
56
+ const candidates = [
57
+ `${u.origin}/.well-known/oauth-authorization-server${u.pathname.replace(/\/$/, '')}`,
58
+ `${base}/.well-known/oauth-authorization-server`,
59
+ `${base}/.well-known/openid-configuration`,
60
+ ];
61
+ let lastErr;
62
+ for (const url of candidates) {
63
+ try {
64
+ const meta = await fetchJson(url);
65
+ const resolved = { ...meta, resource: prm.resource ?? resource, prm };
66
+ discovered.set(resource, resolved);
67
+ return resolved;
68
+ } catch (err) { lastErr = err; }
69
+ }
70
+ throw lastErr ?? new Error('authorization server metadata not found');
71
+ }
72
+
73
+ /** Register (or reuse) a public client bound to this loopback redirect URI. */
74
+ export async function ensureClient(redirectUri, metadata, clientName) {
75
+ const existing = readJson(paths().client);
76
+ if (existing?.client_id && (existing.redirect_uris ?? []).includes(redirectUri)) return existing;
77
+ if (!metadata.registration_endpoint) throw new Error('authorization server does not support dynamic client registration');
78
+ const reg = await fetchJson(metadata.registration_endpoint, {
79
+ method: 'POST',
80
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
81
+ body: JSON.stringify({
82
+ client_name: clientName ?? 'DeepSeek Harness · 生财有术 MCP',
83
+ redirect_uris: [redirectUri],
84
+ grant_types: ['authorization_code', 'refresh_token'],
85
+ response_types: ['code'],
86
+ token_endpoint_auth_method: 'none',
87
+ scope: DEFAULT_SCOPE,
88
+ }),
89
+ });
90
+ const client = { ...reg, registered_at: new Date().toISOString() };
91
+ writeJson(paths().client, client);
92
+ return client;
93
+ }
94
+
95
+ async function tokenRequest(metadata, params) {
96
+ return fetchJson(metadata.token_endpoint, {
97
+ method: 'POST',
98
+ headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
99
+ body: new URLSearchParams(params).toString(),
100
+ });
101
+ }
102
+
103
+ function saveTokens(tok, resource) {
104
+ const previous = readTokens() ?? {};
105
+ writeJson(paths().tokens, {
106
+ access_token: tok.access_token,
107
+ refresh_token: tok.refresh_token ?? previous.refresh_token ?? null,
108
+ token_type: tok.token_type ?? 'Bearer',
109
+ scope: tok.scope ?? previous.scope ?? DEFAULT_SCOPE,
110
+ expires_at: tok.expires_in ? Date.now() + Number(tok.expires_in) * 1000 : null,
111
+ resource,
112
+ updated_at: new Date().toISOString(),
113
+ });
114
+ return readTokens();
115
+ }
116
+
117
+ /** Open a URL in the user's default browser on macOS, Windows or Linux. */
118
+ export function openBrowser(url) {
119
+ // Windows: rundll32 receives argv directly (no cmd.exe), so URL characters
120
+ // such as `&` can never be parsed as shell operators.
121
+ const [command, args] = process.platform === 'darwin'
122
+ ? ['open', [url]]
123
+ : process.platform === 'win32'
124
+ ? ['rundll32.exe', ['url.dll,FileProtocolHandler', url]]
125
+ : ['xdg-open', [url]];
126
+ try {
127
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
128
+ child.on('error', () => {});
129
+ child.unref();
130
+ return true;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ const page = (title, body) => `<!doctype html><html lang="zh-CN"><meta charset="utf-8">
137
+ <title>${title}</title>
138
+ <style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
139
+ background:#f5f5f7;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;color:#1d1d1f}
140
+ .card{background:#fff;padding:40px 48px;border-radius:16px;box-shadow:0 8px 30px rgba(0,0,0,.08);max-width:560px;text-align:center}
141
+ h1{font-size:20px;margin:0 0 12px}p{color:#555;line-height:1.6;margin:6px 0}
142
+ code{background:#f2f2f4;padding:2px 6px;border-radius:6px}</style>
143
+ <div class="card"><h1>${title}</h1>${body}</div></html>`;
144
+
145
+ const escapeHtml = (s) => String(s).replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
146
+
147
+ function listen(server, port) {
148
+ return new Promise((resolve, reject) => {
149
+ const onError = (err) => {
150
+ server.off('listening', onListening);
151
+ if (err.code === 'EADDRINUSE' && port !== 0) {
152
+ // Another login (or an older bridge) owns the default port: take any free one.
153
+ listen(server, 0).then(resolve, reject);
154
+ return;
155
+ }
156
+ reject(err);
157
+ };
158
+ const onListening = () => {
159
+ server.off('error', onError);
160
+ resolve(server.address().port);
161
+ };
162
+ server.once('error', onError);
163
+ server.once('listening', onListening);
164
+ server.listen(port, '127.0.0.1');
165
+ });
166
+ }
167
+
168
+ let authInFlight = null;
169
+ let currentAuthUrl = null;
170
+ const urlListeners = new Set();
171
+
172
+ /** Observe the authorization URL of the running (or next) flow. */
173
+ export function watchAuthUrl(listener) {
174
+ urlListeners.add(listener);
175
+ if (currentAuthUrl) {
176
+ try { listener(currentAuthUrl); } catch { /* listener failure is not fatal */ }
177
+ }
178
+ return () => { urlListeners.delete(listener); };
179
+ }
180
+
181
+ function emitAuthUrl(url) {
182
+ currentAuthUrl = url;
183
+ for (const listener of urlListeners) {
184
+ try { listener(url); } catch { /* listener failure is not fatal */ }
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Run the interactive authorization-code + PKCE flow.
190
+ * @param {object} options plugin options
191
+ * @param {(url: string) => void} [onUrl] called with the authorization URL
192
+ */
193
+ export async function authorize(options = {}, onUrl) {
194
+ if (authInFlight) return authInFlight;
195
+ authInFlight = (async () => {
196
+ const resource = resourceOf(options);
197
+ const metadata = await discover({ ...options, resource });
198
+ const server = http.createServer();
199
+ const port = await listen(server, Number(options.port ?? 18787));
200
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
201
+ const client = await ensureClient(redirectUri, metadata, options.clientName);
202
+
203
+ const verifier = b64url(crypto.randomBytes(48));
204
+ const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
205
+ const state = b64url(crypto.randomBytes(24));
206
+
207
+ const url = new URL(metadata.authorization_endpoint);
208
+ url.searchParams.set('response_type', 'code');
209
+ url.searchParams.set('client_id', client.client_id);
210
+ url.searchParams.set('redirect_uri', redirectUri);
211
+ url.searchParams.set('scope', options.scope ?? DEFAULT_SCOPE);
212
+ url.searchParams.set('state', state);
213
+ url.searchParams.set('code_challenge', challenge);
214
+ url.searchParams.set('code_challenge_method', 'S256');
215
+ url.searchParams.set('resource', resource);
216
+
217
+ const { promise, resolve, reject } = Promise.withResolvers();
218
+ let settled = false;
219
+ const finish = (fn, value) => {
220
+ if (settled) return;
221
+ settled = true;
222
+ clearTimeout(timer);
223
+ fn(value);
224
+ };
225
+
226
+ server.on('request', async (req, res) => {
227
+ const reqUrl = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
228
+ const reply = (status, title, body) => {
229
+ res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
230
+ res.end(page(title, body));
231
+ };
232
+ if (reqUrl.pathname !== '/callback') {
233
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
234
+ res.end('not found');
235
+ return;
236
+ }
237
+ const error = reqUrl.searchParams.get('error');
238
+ if (error) {
239
+ reply(400, '授权未完成', `<p><code>${escapeHtml(error)}</code></p><p>${escapeHtml(reqUrl.searchParams.get('error_description') ?? '')}</p>`);
240
+ finish(reject, new Error(`authorization denied: ${error}`));
241
+ return;
242
+ }
243
+ const code = reqUrl.searchParams.get('code');
244
+ const returnedState = reqUrl.searchParams.get('state');
245
+ if (!code || returnedState !== state) {
246
+ reply(400, '授权回调无效', '<p>缺少授权码或 state 校验失败,请重新发起授权。</p>');
247
+ finish(reject, new Error('invalid authorization callback'));
248
+ return;
249
+ }
250
+ try {
251
+ const tok = await tokenRequest(metadata, {
252
+ grant_type: 'authorization_code',
253
+ code,
254
+ redirect_uri: redirectUri,
255
+ client_id: client.client_id,
256
+ code_verifier: verifier,
257
+ resource,
258
+ });
259
+ const saved = saveTokens(tok, resource);
260
+ reply(200, '授权成功', '<p>生财有术 MCP 已连接,可以关闭此页面。</p><p>回到 DeepSeek Harness 继续对话即可使用 <code>mcp__scys-mcp__*</code> 工具。</p>');
261
+ finish(resolve, saved);
262
+ } catch (err) {
263
+ reply(502, '换取令牌失败', `<p>${escapeHtml(err.message ?? err)}</p>`);
264
+ finish(reject, err);
265
+ }
266
+ });
267
+
268
+ const timer = setTimeout(() => {
269
+ finish(reject, new Error(`authorization timed out (${Math.round(Number(options.authorizeTimeoutMs ?? DEFAULT_TIMEOUT_MS) / 60000)} minutes)`));
270
+ }, Number(options.authorizeTimeoutMs ?? DEFAULT_TIMEOUT_MS));
271
+ timer.unref?.();
272
+
273
+ emitAuthUrl(url.toString());
274
+ onUrl?.(url.toString());
275
+ if (options.openBrowser !== false) openBrowser(url.toString());
276
+
277
+ try {
278
+ return await promise;
279
+ } finally {
280
+ setTimeout(() => server.close(), 250).unref?.();
281
+ }
282
+ })().finally(() => { authInFlight = null; currentAuthUrl = null; });
283
+ return authInFlight;
284
+ }
285
+
286
+ let refreshInFlight = null;
287
+
288
+ /** Exchange the refresh token for a new access token (the server rotates it). */
289
+ export async function refresh(options = {}) {
290
+ if (refreshInFlight) return refreshInFlight;
291
+ const current = readTokens();
292
+ if (!current?.refresh_token) throw new Error('no refresh token stored');
293
+ const resource = resourceOf(options);
294
+ const metadata = await discover({ ...options, resource });
295
+ const client = readJson(paths().client);
296
+ refreshInFlight = (async () => {
297
+ const tok = await tokenRequest(metadata, {
298
+ grant_type: 'refresh_token',
299
+ refresh_token: current.refresh_token,
300
+ client_id: client?.client_id ?? '',
301
+ resource,
302
+ });
303
+ return saveTokens(tok, resource);
304
+ })().finally(() => { refreshInFlight = null; });
305
+ return refreshInFlight;
306
+ }
307
+
308
+ /**
309
+ * Return a usable bearer token, refreshing it shortly before expiry and
310
+ * falling back to the interactive flow when no grant exists any more.
311
+ * @param {object} options plugin options
312
+ * @param {{force?: boolean, onUrl?: (url: string) => void}} [control]
313
+ */
314
+ export async function accessToken(options = {}, control = {}) {
315
+ const t = readTokens();
316
+ const stillFresh = t?.access_token && t.expires_at && Date.now() < t.expires_at - 60_000;
317
+ if (!control.force && stillFresh) return t.access_token;
318
+ if (t?.refresh_token) {
319
+ try {
320
+ const next = await refresh(options);
321
+ return next.access_token;
322
+ } catch (err) {
323
+ if (err?.status !== 400 && err?.status !== 401) throw err;
324
+ // invalid_grant: the grant is gone, fall through to a fresh login.
325
+ }
326
+ }
327
+ if (control.allowInteractive === false) {
328
+ throw new Error('生财有术 MCP 授权已失效:请调用 mcp__scys-mcp__auth 重新授权');
329
+ }
330
+ const next = await authorize(options, control.onUrl);
331
+ return next.access_token;
332
+ }
package/lib/store.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Local state for the scys-mcp plugin: OAuth client registration and tokens.
3
+ *
4
+ * Everything lives under `<home>/.dsh/mcp/scys-mcp/` on every platform, so the
5
+ * macOS and Windows builds behave identically. `SCYS_MCP_STATE_DIR` overrides
6
+ * the location (tests, multi-account setups).
7
+ */
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+
12
+ export function stateDir() {
13
+ return process.env.SCYS_MCP_STATE_DIR || path.join(os.homedir(), '.dsh', 'mcp', 'scys-mcp');
14
+ }
15
+
16
+ export function paths() {
17
+ const dir = stateDir();
18
+ return {
19
+ dir,
20
+ client: path.join(dir, 'client.json'),
21
+ tokens: path.join(dir, 'tokens.json'),
22
+ };
23
+ }
24
+
25
+ export function readJson(file) {
26
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
27
+ }
28
+
29
+ export function writeJson(file, value) {
30
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
31
+ const tmp = `${file}.${process.pid}.tmp`;
32
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2), { mode: 0o600 });
33
+ fs.renameSync(tmp, file);
34
+ try { fs.chmodSync(file, 0o600); } catch { /* Windows ignores POSIX modes */ }
35
+ }
36
+
37
+ export function readTokens() {
38
+ return readJson(paths().tokens);
39
+ }
40
+
41
+ export function tokenStatus() {
42
+ const t = readTokens();
43
+ if (!t?.access_token) return { authorized: false };
44
+ return {
45
+ authorized: true,
46
+ expires_at: t.expires_at ? new Date(t.expires_at).toISOString() : null,
47
+ expired: Boolean(t.expires_at && Date.now() >= t.expires_at),
48
+ has_refresh_token: Boolean(t.refresh_token),
49
+ scope: t.scope ?? null,
50
+ updated_at: t.updated_at ?? null,
51
+ };
52
+ }
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "dsh-plugin-scys-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "生财有术 MCP(scys.com)for DeepSeek Harness — OAuth 2.1 网页授权,原生工具注册,macOS / Windows / Linux",
5
+ "icon": "./icon.svg",
6
+ "type": "module",
7
+ "main": "lib/index.js",
8
+ "exports": {
9
+ ".": "./lib/index.js",
10
+ "./cordis.patch.yml": "./cordis.patch.yml",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "icon.svg",
15
+ "lib/*.js",
16
+ "cordis.patch.yml",
17
+ "README.md"
18
+ ],
19
+ "keywords": [
20
+ "dsh",
21
+ "dsh-plugin",
22
+ "mcp",
23
+ "scys",
24
+ "生财有术"
25
+ ],
26
+ "license": "MIT",
27
+ "engines": {
28
+ "node": ">=22"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "dsh": {
34
+ "bundle": {
35
+ "patch": "./cordis.patch.yml"
36
+ }
37
+ }
38
+ }