@plaudai/dsh-plugin-feishu 0.0.0-stage → 0.1.2
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 +22 -2
- package/cordis.patch.yml +24 -0
- package/dist/core/browser.js +37 -0
- package/dist/core/index.js +191 -0
- package/dist/core/oauth.js +247 -0
- package/dist/core/store.js +115 -0
- package/dist/core/tools.js +68 -0
- package/dist/core/transport.js +42 -0
- package/dist/index.js +19 -0
- package/package.json +47 -4
package/README.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @plaudai/dsh-plugin-feishu
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把飞书 / Lark 官方 MCP 接入 DeepSeek Harness——基于 @larksuiteoapi/lark-mcp,用 App ID + Secret。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
在 DSH 桌面版「插件」面板里粘贴包名或本地绝对路径,网页版用:
|
|
8
|
+
|
|
9
|
+
```powershell
|
|
10
|
+
dsh plugin --profile web add @plaudai/dsh-plugin-feishu
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
装完重启 DSH。宿主要求:`@deepseek-ai/dsh` 0.2.0-rc.2 及以上。
|
|
14
|
+
|
|
15
|
+
## 文档
|
|
16
|
+
|
|
17
|
+
- [仓库说明](https://github.com/Plaud-AI/Plaud_DSH_plugins#readme)——五个连接器一览、安装与使用
|
|
18
|
+
- [各服务配置与排错](https://github.com/Plaud-AI/Plaud_DSH_plugins/blob/main/docs/REFERENCE.zh.md)
|
|
19
|
+
- English: [README.en.md](https://github.com/Plaud-AI/Plaud_DSH_plugins/blob/main/README.en.md)
|
|
20
|
+
|
|
21
|
+
## License
|
|
22
|
+
|
|
23
|
+
UNLICENSED,公司内部使用。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Feishu / Lark (official OpenAPI MCP) for DeepSeek Harness.
|
|
2
|
+
#
|
|
3
|
+
# Create a self-built app at open.feishu.cn (or open.larksuite.com), grant the
|
|
4
|
+
# scopes the tools need, then export before starting the harness:
|
|
5
|
+
#
|
|
6
|
+
# FEISHU_APP_ID=cli_xxx
|
|
7
|
+
# FEISHU_APP_SECRET=xxx
|
|
8
|
+
#
|
|
9
|
+
# Credentials travel as command arguments because the host strips
|
|
10
|
+
# credential-shaped environment names from MCP child processes.
|
|
11
|
+
- insert:
|
|
12
|
+
- id: mcp-feishu
|
|
13
|
+
name: '@plaudai/dsh-plugin-feishu'
|
|
14
|
+
config:
|
|
15
|
+
serverName: feishu
|
|
16
|
+
toolCallTimeoutMs: 120000
|
|
17
|
+
args:
|
|
18
|
+
- '-y'
|
|
19
|
+
- '@larksuiteoapi/lark-mcp'
|
|
20
|
+
- mcp
|
|
21
|
+
- '--app-id'
|
|
22
|
+
- !!js 'process.env.FEISHU_APP_ID ?? ""'
|
|
23
|
+
- '--app-secret'
|
|
24
|
+
- !!js 'process.env.FEISHU_APP_SECRET ?? ""'
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
/**
|
|
3
|
+
* Hand a URL to the user's default browser without blocking the host process.
|
|
4
|
+
*
|
|
5
|
+
* Never throws: an environment with no desktop session simply gets the URL
|
|
6
|
+
* logged by the caller, and the authorization flow can still be completed by
|
|
7
|
+
* pasting it elsewhere.
|
|
8
|
+
*/
|
|
9
|
+
export function openBrowser(url) {
|
|
10
|
+
try {
|
|
11
|
+
if (process.platform === 'win32') {
|
|
12
|
+
// Node only quotes an argv element when it contains whitespace or a
|
|
13
|
+
// quote, so a bare authorization URL reaches cmd.exe with its `&`
|
|
14
|
+
// separators live and the browser gets everything up to the first query
|
|
15
|
+
// parameter — for Notion that is exactly `?response_type=code`, and the
|
|
16
|
+
// server answers `client_id is required`. Build the command line here and
|
|
17
|
+
// pass it verbatim so the URL keeps its quotes.
|
|
18
|
+
const command = `start "" "${url.replace(/"/g, '')}"`;
|
|
19
|
+
const child = spawn(process.env.comspec ?? 'cmd.exe', ['/d', '/s', '/c', command], {
|
|
20
|
+
detached: true,
|
|
21
|
+
stdio: 'ignore',
|
|
22
|
+
windowsHide: true,
|
|
23
|
+
windowsVerbatimArguments: true,
|
|
24
|
+
});
|
|
25
|
+
child.on('error', () => { });
|
|
26
|
+
child.unref();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const [command, args] = process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
|
|
30
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
|
|
31
|
+
child.on('error', () => { });
|
|
32
|
+
child.unref();
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// The caller logs the URL; a missing browser is not a fatal condition.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import z from '@deepseek-ai/schemastery';
|
|
2
|
+
import { Client } from '@modelcontextprotocol/client';
|
|
3
|
+
import { DshOAuthProvider, OAuthCallbackListener, ensureAuthorized } from './oauth.js';
|
|
4
|
+
import { TokenStore } from './store.js';
|
|
5
|
+
import { createTransport } from './transport.js';
|
|
6
|
+
import { syncTools } from './tools.js';
|
|
7
|
+
export { DshOAuthProvider, OAuthCallbackListener, ensureAuthorized } from './oauth.js';
|
|
8
|
+
export { TokenStore, dshHome } from './store.js';
|
|
9
|
+
export { buildChildEnv, createTransport } from './transport.js';
|
|
10
|
+
export { publicToolName, syncTools } from './tools.js';
|
|
11
|
+
/** Valid `serverName`: short, filesystem-safe, unique per server. */
|
|
12
|
+
const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/;
|
|
13
|
+
/**
|
|
14
|
+
* Build a Cordis plugin for one MCP server.
|
|
15
|
+
*
|
|
16
|
+
* The returned object carries the four names the harness loader reads, so a
|
|
17
|
+
* service package only needs to re-export them.
|
|
18
|
+
*/
|
|
19
|
+
export function createMcpPlugin(preset) {
|
|
20
|
+
const Config = z.object({
|
|
21
|
+
serverName: z.string().default(preset.serverName).pattern(SERVER_NAME_PATTERN),
|
|
22
|
+
transport: z.string().default(preset.transport),
|
|
23
|
+
auth: z.string().default(preset.auth ?? 'none'),
|
|
24
|
+
url: z.string().default(preset.url ?? ''),
|
|
25
|
+
headers: z.dict(String).default({}),
|
|
26
|
+
bearerTokenEnv: z.string().default(preset.bearerTokenEnv ?? ''),
|
|
27
|
+
command: z.string().default(preset.command ?? ''),
|
|
28
|
+
args: z.array(String).default(preset.args ?? []),
|
|
29
|
+
env: z.dict(String).default({}),
|
|
30
|
+
cwd: z.string().default(''),
|
|
31
|
+
scope: z.string().default(preset.scope ?? ''),
|
|
32
|
+
clientId: z.string().default(''),
|
|
33
|
+
clientSecret: z.string().default(''),
|
|
34
|
+
authorizationParams: z.dict(String).default({}),
|
|
35
|
+
tokenEndpointAuthMethod: z.string().default(''),
|
|
36
|
+
toolCallTimeoutMs: z.number().default(120_000),
|
|
37
|
+
authTimeoutMs: z.number().default(300_000),
|
|
38
|
+
failOnStartupError: z.boolean().default(false),
|
|
39
|
+
});
|
|
40
|
+
async function apply(ctx, config) {
|
|
41
|
+
const label = `mcp-oauth(${config.serverName})`;
|
|
42
|
+
const disposers = new Map();
|
|
43
|
+
const listener = new OAuthCallbackListener();
|
|
44
|
+
let client;
|
|
45
|
+
let tornDown = false;
|
|
46
|
+
const teardown = () => {
|
|
47
|
+
if (tornDown)
|
|
48
|
+
return;
|
|
49
|
+
tornDown = true;
|
|
50
|
+
listener.close();
|
|
51
|
+
for (const dispose of disposers.values())
|
|
52
|
+
dispose();
|
|
53
|
+
disposers.clear();
|
|
54
|
+
const current = client;
|
|
55
|
+
client = undefined;
|
|
56
|
+
void current?.close().catch(() => { });
|
|
57
|
+
};
|
|
58
|
+
ctx.effect(() => teardown, 'mcp-oauth.connection');
|
|
59
|
+
try {
|
|
60
|
+
if (config.transport === 'stdio') {
|
|
61
|
+
if (config.command.length === 0)
|
|
62
|
+
throw new Error(`${label}: "command" is required for the stdio transport`);
|
|
63
|
+
const connected = newClient();
|
|
64
|
+
try {
|
|
65
|
+
await connected.connect(createTransport({
|
|
66
|
+
transport: 'stdio',
|
|
67
|
+
command: config.command,
|
|
68
|
+
args: config.args,
|
|
69
|
+
env: config.env,
|
|
70
|
+
cwd: config.cwd,
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
await connected.close().catch(() => { });
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
client = connected;
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
if (config.url.length === 0)
|
|
81
|
+
throw new Error(`${label}: "url" is required for the streamable-http transport`);
|
|
82
|
+
const url = new URL(config.url);
|
|
83
|
+
let provider;
|
|
84
|
+
if (config.auth === 'oauth') {
|
|
85
|
+
const redirectUrl = await listener.start();
|
|
86
|
+
provider = new DshOAuthProvider({
|
|
87
|
+
serverName: config.serverName,
|
|
88
|
+
redirectUrl,
|
|
89
|
+
store: new TokenStore(config.serverName),
|
|
90
|
+
scope: config.scope.length > 0 ? config.scope : undefined,
|
|
91
|
+
clientId: config.clientId.length > 0 ? config.clientId : undefined,
|
|
92
|
+
clientSecret: config.clientSecret.length > 0 ? config.clientSecret : undefined,
|
|
93
|
+
authorizationParams: config.authorizationParams,
|
|
94
|
+
tokenEndpointAuthMethod: config.tokenEndpointAuthMethod.length > 0
|
|
95
|
+
? config.tokenEndpointAuthMethod
|
|
96
|
+
: undefined,
|
|
97
|
+
log: (message) => ctx.logger.info(message),
|
|
98
|
+
});
|
|
99
|
+
ctx.logger.info(`${label}: OAuth client ready (redirect ${redirectUrl})`);
|
|
100
|
+
}
|
|
101
|
+
const headers = { ...config.headers };
|
|
102
|
+
if (config.auth === 'bearer') {
|
|
103
|
+
const variable = config.bearerTokenEnv;
|
|
104
|
+
const token = variable.length > 0 ? process.env[variable] : undefined;
|
|
105
|
+
if (token === undefined || token.length === 0) {
|
|
106
|
+
throw new Error(`${label}: bearer auth needs ${variable.length > 0 ? variable : 'a token'} in the environment`);
|
|
107
|
+
}
|
|
108
|
+
headers.Authorization = `Bearer ${token}`;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
client = await connectRemote(url, headers, provider);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
const needsAuthorization = provider !== undefined && (isUnauthorized(error) || !(await provider.tokens()));
|
|
115
|
+
if (!needsAuthorization)
|
|
116
|
+
throw error;
|
|
117
|
+
ctx.logger.info(`${label}: no usable token, starting the OAuth flow`);
|
|
118
|
+
await ensureAuthorized({
|
|
119
|
+
serverUrl: url,
|
|
120
|
+
provider: provider,
|
|
121
|
+
listener,
|
|
122
|
+
timeoutMs: config.authTimeoutMs,
|
|
123
|
+
log: (message) => ctx.logger.info(message),
|
|
124
|
+
});
|
|
125
|
+
client = await connectRemote(url, headers, provider);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const connectedClient = client;
|
|
129
|
+
if (connectedClient === undefined)
|
|
130
|
+
throw new Error(`${label}: connection was not established`);
|
|
131
|
+
const count = await syncTools(connectedClient, ctx, { serverName: config.serverName, toolCallTimeoutMs: config.toolCallTimeoutMs }, disposers);
|
|
132
|
+
ctx.logger.info(`${label}: connected, ${count} tool(s) registered`);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
136
|
+
ctx.logger.error(`${label}: startup failed: ${message}`);
|
|
137
|
+
if (config.failOnStartupError)
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async function connectRemote(url, headers, provider) {
|
|
142
|
+
const connected = newClient();
|
|
143
|
+
try {
|
|
144
|
+
await connected.connect(createTransport({
|
|
145
|
+
transport: 'streamable-http',
|
|
146
|
+
url: url.toString(),
|
|
147
|
+
headers,
|
|
148
|
+
...(provider !== undefined ? { authProvider: provider } : {}),
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
await connected.close().catch(() => { });
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
return connected;
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
// Per-service identity, so five connectors loaded side by side stay
|
|
159
|
+
// distinguishable in logs and in the host's plugin tree.
|
|
160
|
+
name: `mcp-${preset.serverName}`,
|
|
161
|
+
inject: ['tools'],
|
|
162
|
+
Config,
|
|
163
|
+
apply,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* One MCP client generation.
|
|
168
|
+
*
|
|
169
|
+
* A Protocol instance is bound to one transport for life, so every reconnect
|
|
170
|
+
* builds a fresh client. `versionNegotiation: auto` keeps the bridge usable
|
|
171
|
+
* across MCP protocol revisions instead of pinning one.
|
|
172
|
+
*/
|
|
173
|
+
function newClient() {
|
|
174
|
+
const options = {
|
|
175
|
+
capabilities: {},
|
|
176
|
+
versionNegotiation: { mode: 'auto' },
|
|
177
|
+
listChanged: { tools: { autoRefresh: false, debounceMs: 0, onChanged: () => { } } },
|
|
178
|
+
};
|
|
179
|
+
return new Client({ name: 'dsh-mcp-oauth', version: '0.1.0' }, options);
|
|
180
|
+
}
|
|
181
|
+
/** Whether a connect failure is the server telling us to authenticate first. */
|
|
182
|
+
function isUnauthorized(error) {
|
|
183
|
+
if (error === null || typeof error !== 'object')
|
|
184
|
+
return false;
|
|
185
|
+
const candidate = error;
|
|
186
|
+
if (candidate.code === 401 || candidate.status === 401)
|
|
187
|
+
return true;
|
|
188
|
+
if (candidate.name === 'UnauthorizedError')
|
|
189
|
+
return true;
|
|
190
|
+
return typeof candidate.message === 'string' && candidate.message.includes('401');
|
|
191
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { auth } from '@modelcontextprotocol/client';
|
|
4
|
+
import { openBrowser } from './browser.js';
|
|
5
|
+
/**
|
|
6
|
+
* OAuth 2.0 client provider backed by the host's credential store.
|
|
7
|
+
*
|
|
8
|
+
* The MCP SDK owns discovery, DCR, PKCE, issuer validation and refresh; this
|
|
9
|
+
* class only answers "what do we know" and "where should the browser go".
|
|
10
|
+
* That split is what keeps the flow standard rather than per-vendor.
|
|
11
|
+
*/
|
|
12
|
+
export class DshOAuthProvider {
|
|
13
|
+
#codeVerifier;
|
|
14
|
+
#state;
|
|
15
|
+
#authorizationUrl;
|
|
16
|
+
options;
|
|
17
|
+
constructor(options) {
|
|
18
|
+
this.options = options;
|
|
19
|
+
}
|
|
20
|
+
/** Loopback redirect URI, known before the first request so DCR can register it. */
|
|
21
|
+
get redirectUrl() {
|
|
22
|
+
return this.options.redirectUrl;
|
|
23
|
+
}
|
|
24
|
+
/** Last authorization URL handed to the browser, for diagnostics and retries. */
|
|
25
|
+
get authorizationUrl() {
|
|
26
|
+
return this.#authorizationUrl;
|
|
27
|
+
}
|
|
28
|
+
get clientMetadata() {
|
|
29
|
+
const confidential = Boolean(this.options.clientSecret);
|
|
30
|
+
const metadata = {
|
|
31
|
+
client_name: `DeepSeek Harness · ${this.options.serverName}`,
|
|
32
|
+
redirect_uris: [this.options.redirectUrl],
|
|
33
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
34
|
+
response_types: ['code'],
|
|
35
|
+
token_endpoint_auth_method: this.options.tokenEndpointAuthMethod ?? (confidential ? 'client_secret_post' : 'none'),
|
|
36
|
+
...(this.options.scope ? { scope: this.options.scope } : {}),
|
|
37
|
+
};
|
|
38
|
+
return metadata;
|
|
39
|
+
}
|
|
40
|
+
/** CSRF state for the authorization request; constant for one flow. */
|
|
41
|
+
async state() {
|
|
42
|
+
this.#state ??= randomBytes(32).toString('base64url');
|
|
43
|
+
return this.#state;
|
|
44
|
+
}
|
|
45
|
+
/** Pre-registered client wins over anything discovered through DCR. */
|
|
46
|
+
async clientInformation(ctx) {
|
|
47
|
+
const { clientId, clientSecret } = this.options;
|
|
48
|
+
if (clientId !== undefined && clientId.length > 0) {
|
|
49
|
+
const information = {
|
|
50
|
+
client_id: clientId,
|
|
51
|
+
token_endpoint_auth_method: this.clientMetadata.token_endpoint_auth_method,
|
|
52
|
+
};
|
|
53
|
+
if (clientSecret !== undefined && clientSecret.length > 0)
|
|
54
|
+
information.client_secret = clientSecret;
|
|
55
|
+
return information;
|
|
56
|
+
}
|
|
57
|
+
return this.options.store.client(ctx);
|
|
58
|
+
}
|
|
59
|
+
/** Persist a DCR result; a pre-registered client is never overwritten. */
|
|
60
|
+
async saveClientInformation(information, ctx) {
|
|
61
|
+
const { clientId } = this.options;
|
|
62
|
+
if (clientId !== undefined && clientId.length > 0)
|
|
63
|
+
return;
|
|
64
|
+
await this.options.store.putClient(ctx, information);
|
|
65
|
+
}
|
|
66
|
+
async tokens(ctx) {
|
|
67
|
+
return this.options.store.tokens(ctx);
|
|
68
|
+
}
|
|
69
|
+
async saveTokens(tokens, ctx) {
|
|
70
|
+
await this.options.store.putTokens(ctx, tokens);
|
|
71
|
+
}
|
|
72
|
+
async saveCodeVerifier(codeVerifier) {
|
|
73
|
+
this.#codeVerifier = codeVerifier;
|
|
74
|
+
}
|
|
75
|
+
async codeVerifier() {
|
|
76
|
+
if (this.#codeVerifier === undefined) {
|
|
77
|
+
throw new Error(`mcp-oauth(${this.options.serverName}): no PKCE verifier recorded — restart the authorization flow`);
|
|
78
|
+
}
|
|
79
|
+
return this.#codeVerifier;
|
|
80
|
+
}
|
|
81
|
+
/** Append vendor-required parameters, then open the browser. */
|
|
82
|
+
async redirectToAuthorization(authorizationUrl) {
|
|
83
|
+
const target = new URL(authorizationUrl.toString());
|
|
84
|
+
for (const [name, value] of Object.entries(this.options.authorizationParams ?? {})) {
|
|
85
|
+
if (value.length > 0)
|
|
86
|
+
target.searchParams.set(name, value);
|
|
87
|
+
}
|
|
88
|
+
this.#authorizationUrl = target;
|
|
89
|
+
this.options.log?.(`mcp-oauth(${this.options.serverName}): opening browser for authorization`);
|
|
90
|
+
openBrowser(target.toString());
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Single-use loopback listener for the OAuth redirect.
|
|
95
|
+
*
|
|
96
|
+
* Binds `127.0.0.1` on an ephemeral port so two servers (or two harness
|
|
97
|
+
* processes) never fight over a fixed callback port. A callback that arrives
|
|
98
|
+
* before anyone awaits is queued, not dropped.
|
|
99
|
+
*/
|
|
100
|
+
export class OAuthCallbackListener {
|
|
101
|
+
#server;
|
|
102
|
+
#port = 0;
|
|
103
|
+
#queued;
|
|
104
|
+
#pending;
|
|
105
|
+
/** Redirect URI; only valid after {@link start}. */
|
|
106
|
+
get redirectUrl() {
|
|
107
|
+
return `http://127.0.0.1:${this.#port}/callback`;
|
|
108
|
+
}
|
|
109
|
+
/** Start listening and return the redirect URI to register. */
|
|
110
|
+
async start() {
|
|
111
|
+
if (this.#server !== undefined)
|
|
112
|
+
return this.redirectUrl;
|
|
113
|
+
const server = createServer((request, response) => {
|
|
114
|
+
const url = new URL(request.url ?? '/', `http://127.0.0.1:${this.#port}`);
|
|
115
|
+
if (url.pathname !== '/callback') {
|
|
116
|
+
respond(response, 404, 'Not found');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const error = url.searchParams.get('error');
|
|
120
|
+
if (error !== null) {
|
|
121
|
+
const description = url.searchParams.get('error_description') ?? '';
|
|
122
|
+
respond(response, 400, `Authorization failed: ${error} ${description}`);
|
|
123
|
+
this.#settle(new Error(`authorization failed: ${error} ${description}`.trim()));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const code = url.searchParams.get('code');
|
|
127
|
+
if (code === null || code.length === 0) {
|
|
128
|
+
respond(response, 400, 'Missing authorization code');
|
|
129
|
+
this.#settle(new Error('authorization callback carried no code'));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const result = {
|
|
133
|
+
code,
|
|
134
|
+
...(url.searchParams.get('iss') !== null ? { iss: url.searchParams.get('iss') } : {}),
|
|
135
|
+
...(url.searchParams.get('state') !== null ? { state: url.searchParams.get('state') } : {}),
|
|
136
|
+
};
|
|
137
|
+
respond(response, 200, '授权完成,可以关闭这个页面并回到 DeepSeek Harness。');
|
|
138
|
+
this.#settle(result);
|
|
139
|
+
});
|
|
140
|
+
await new Promise((resolve, reject) => {
|
|
141
|
+
server.once('error', reject);
|
|
142
|
+
server.listen(0, '127.0.0.1', () => {
|
|
143
|
+
server.off('error', reject);
|
|
144
|
+
resolve();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
const address = server.address();
|
|
148
|
+
this.#port = typeof address === 'object' && address !== null ? address.port : 0;
|
|
149
|
+
if (this.#port === 0) {
|
|
150
|
+
server.close();
|
|
151
|
+
throw new Error('mcp-oauth: loopback listener bound to port 0');
|
|
152
|
+
}
|
|
153
|
+
this.#server = server;
|
|
154
|
+
return this.redirectUrl;
|
|
155
|
+
}
|
|
156
|
+
/** Resolve with the next callback, or reject on timeout / listener failure. */
|
|
157
|
+
wait(timeoutMs = 300_000) {
|
|
158
|
+
if (this.#queued !== undefined) {
|
|
159
|
+
const queued = this.#queued;
|
|
160
|
+
this.#queued = undefined;
|
|
161
|
+
return Promise.resolve(queued);
|
|
162
|
+
}
|
|
163
|
+
return new Promise((resolve, reject) => {
|
|
164
|
+
const timer = setTimeout(() => {
|
|
165
|
+
this.#pending = undefined;
|
|
166
|
+
reject(new Error(`mcp-oauth: authorization callback timed out after ${timeoutMs}ms`));
|
|
167
|
+
}, timeoutMs);
|
|
168
|
+
timer.unref?.();
|
|
169
|
+
this.#pending = {
|
|
170
|
+
resolve: (result) => {
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
this.#pending = undefined;
|
|
173
|
+
resolve(result);
|
|
174
|
+
},
|
|
175
|
+
reject: (error) => {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
this.#pending = undefined;
|
|
178
|
+
reject(error);
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/** Stop listening; safe to call more than once. */
|
|
184
|
+
close() {
|
|
185
|
+
this.#pending?.reject(new Error('mcp-oauth: authorization listener closed'));
|
|
186
|
+
this.#pending = undefined;
|
|
187
|
+
const server = this.#server;
|
|
188
|
+
this.#server = undefined;
|
|
189
|
+
server?.close();
|
|
190
|
+
}
|
|
191
|
+
#settle(result) {
|
|
192
|
+
if (this.#pending === undefined) {
|
|
193
|
+
if (result instanceof Error)
|
|
194
|
+
return;
|
|
195
|
+
this.#queued = result;
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (result instanceof Error)
|
|
199
|
+
this.#pending.reject(result);
|
|
200
|
+
else
|
|
201
|
+
this.#pending.resolve(result);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function respond(response, status, message) {
|
|
205
|
+
const body = `<!doctype html><meta charset="utf-8"><title>DeepSeek Harness</title><p>${escapeHtml(message)}</p>`;
|
|
206
|
+
response.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
207
|
+
response.end(body);
|
|
208
|
+
}
|
|
209
|
+
function escapeHtml(value) {
|
|
210
|
+
return value.replace(/[&<>"']/g, (character) => character === '&' ? '&' : character === '<' ? '<' : character === '>' ? '>' : character === '"' ? '"' : ''');
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Drive the full interactive authorization flow for one server.
|
|
214
|
+
*
|
|
215
|
+
* First call runs discovery and either refreshes or redirects; when it
|
|
216
|
+
* redirected, the callback is awaited and exchanged for tokens. Idempotent:
|
|
217
|
+
* with a live token set it returns after the refresh check.
|
|
218
|
+
*/
|
|
219
|
+
export async function ensureAuthorized(options) {
|
|
220
|
+
const { serverUrl, provider, listener } = options;
|
|
221
|
+
const log = options.log ?? (() => { });
|
|
222
|
+
log(`mcp-oauth(${provider.options.serverName}): starting authorization for ${serverUrl.toString()}`);
|
|
223
|
+
await auth(provider, { serverUrl: serverUrl.toString() });
|
|
224
|
+
if (await provider.tokens()) {
|
|
225
|
+
log(`mcp-oauth(${provider.options.serverName}): authorization refreshed without user interaction`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const authorizationUrl = provider.authorizationUrl;
|
|
229
|
+
if (authorizationUrl !== undefined)
|
|
230
|
+
log(`mcp-oauth(${provider.options.serverName}): authorize at ${authorizationUrl.toString()}`);
|
|
231
|
+
const callback = await listener.wait(options.timeoutMs);
|
|
232
|
+
if (callback.state !== undefined) {
|
|
233
|
+
const expected = await provider.state();
|
|
234
|
+
if (callback.state !== expected) {
|
|
235
|
+
throw new Error(`mcp-oauth(${provider.options.serverName}): state mismatch on authorization callback`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
await auth(provider, {
|
|
239
|
+
serverUrl: serverUrl.toString(),
|
|
240
|
+
authorizationCode: callback.code,
|
|
241
|
+
...(callback.iss !== undefined ? { iss: callback.iss } : {}),
|
|
242
|
+
});
|
|
243
|
+
if (!(await provider.tokens())) {
|
|
244
|
+
throw new Error(`mcp-oauth(${provider.options.serverName}): authorization finished without tokens`);
|
|
245
|
+
}
|
|
246
|
+
log(`mcp-oauth(${provider.options.serverName}): authorized`);
|
|
247
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
/** Key used when the SDK asks for tokens without naming an authorization server. */
|
|
5
|
+
const LAST_ISSUER = '*';
|
|
6
|
+
/**
|
|
7
|
+
* Root of the DeepSeek Harness installation that owns this process.
|
|
8
|
+
*
|
|
9
|
+
* Matches the host's own convention so a plugin running under the desktop app
|
|
10
|
+
* and one running under the CLI resolve to the same directory.
|
|
11
|
+
*/
|
|
12
|
+
export function dshHome() {
|
|
13
|
+
const configured = process.env.DSH_HOME?.trim();
|
|
14
|
+
return configured && configured.length > 0 ? configured : join(homedir(), '.dsh');
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Persistent OAuth credentials for one MCP server.
|
|
18
|
+
*
|
|
19
|
+
* One JSON document per server under `$DSH_HOME/mcp-oauth/`, written atomically
|
|
20
|
+
* and chmod 0600. Entries are keyed by authorization-server issuer so a server
|
|
21
|
+
* that moves between issuers never replays a token against the wrong one
|
|
22
|
+
* (RFC 9207 mix-up defense).
|
|
23
|
+
*/
|
|
24
|
+
export class TokenStore {
|
|
25
|
+
/** Absolute path of the backing file; surfaced in logs so users can find it. */
|
|
26
|
+
file;
|
|
27
|
+
#state;
|
|
28
|
+
constructor(serverName) {
|
|
29
|
+
this.file = join(dshHome(), 'mcp-oauth', `${serverName}.json`);
|
|
30
|
+
}
|
|
31
|
+
async #load() {
|
|
32
|
+
if (this.#state !== undefined)
|
|
33
|
+
return this.#state;
|
|
34
|
+
try {
|
|
35
|
+
const raw = JSON.parse(await readFile(this.file, 'utf8'));
|
|
36
|
+
this.#state = {
|
|
37
|
+
version: 1,
|
|
38
|
+
...(typeof raw.lastIssuer === 'string' ? { lastIssuer: raw.lastIssuer } : {}),
|
|
39
|
+
issuers: raw.issuers ?? {},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
this.#state = { version: 1, issuers: {} };
|
|
44
|
+
}
|
|
45
|
+
return this.#state;
|
|
46
|
+
}
|
|
47
|
+
#key(ctx) {
|
|
48
|
+
return ctx?.issuer && ctx.issuer.length > 0 ? ctx.issuer : LAST_ISSUER;
|
|
49
|
+
}
|
|
50
|
+
async #flush() {
|
|
51
|
+
const state = await this.#load();
|
|
52
|
+
await mkdir(dirname(this.file), { recursive: true });
|
|
53
|
+
const temporary = `${this.file}.${process.pid}.tmp`;
|
|
54
|
+
await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, {
|
|
55
|
+
encoding: 'utf8',
|
|
56
|
+
mode: 0o600,
|
|
57
|
+
});
|
|
58
|
+
await rename(temporary, this.file);
|
|
59
|
+
if (process.platform !== 'win32')
|
|
60
|
+
await chmod(this.file, 0o600).catch(() => { });
|
|
61
|
+
}
|
|
62
|
+
/** Stored client registration for an issuer, or `undefined` when unregistered. */
|
|
63
|
+
async client(ctx) {
|
|
64
|
+
const state = await this.#load();
|
|
65
|
+
const entry = state.issuers[this.#key(ctx)];
|
|
66
|
+
return entry?.client;
|
|
67
|
+
}
|
|
68
|
+
/** Persist a dynamically registered client and remember it as the latest issuer. */
|
|
69
|
+
async putClient(ctx, client) {
|
|
70
|
+
const state = await this.#load();
|
|
71
|
+
const key = this.#key(ctx);
|
|
72
|
+
state.issuers[key] = { ...state.issuers[key], client };
|
|
73
|
+
if (key !== LAST_ISSUER)
|
|
74
|
+
state.lastIssuer = key;
|
|
75
|
+
await this.#flush();
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Tokens for an issuer, or the most recently saved set when the SDK asks
|
|
79
|
+
* without context (its per-request bearer-token read).
|
|
80
|
+
*/
|
|
81
|
+
async tokens(ctx) {
|
|
82
|
+
const state = await this.#load();
|
|
83
|
+
const direct = state.issuers[this.#key(ctx)]?.tokens;
|
|
84
|
+
if (direct !== undefined)
|
|
85
|
+
return direct;
|
|
86
|
+
if (ctx?.issuer)
|
|
87
|
+
return undefined;
|
|
88
|
+
const latest = state.lastIssuer !== undefined ? state.issuers[state.lastIssuer]?.tokens : undefined;
|
|
89
|
+
if (latest !== undefined)
|
|
90
|
+
return latest;
|
|
91
|
+
for (const entry of Object.values(state.issuers)) {
|
|
92
|
+
if (entry.tokens !== undefined)
|
|
93
|
+
return entry.tokens;
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
/** Persist a token set for an issuer. */
|
|
98
|
+
async putTokens(ctx, tokens) {
|
|
99
|
+
const state = await this.#load();
|
|
100
|
+
const key = this.#key(ctx);
|
|
101
|
+
state.issuers[key] = { ...state.issuers[key], tokens };
|
|
102
|
+
if (key !== LAST_ISSUER)
|
|
103
|
+
state.lastIssuer = key;
|
|
104
|
+
await this.#flush();
|
|
105
|
+
}
|
|
106
|
+
/** Drop every stored credential for this server (used by the logout path). */
|
|
107
|
+
async clear() {
|
|
108
|
+
this.#state = { version: 1, issuers: {} };
|
|
109
|
+
await this.#flush();
|
|
110
|
+
}
|
|
111
|
+
/** Whether a token set exists, without loading the token values. */
|
|
112
|
+
async hasTokens() {
|
|
113
|
+
return (await this.tokens()) !== undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createMcpToolDefinition } from '@deepseek-ai/dsh-mcp-client';
|
|
3
|
+
/** Budget for the public tool name, matching the host bridge's convention. */
|
|
4
|
+
const MAX_PUBLIC_NAME_LENGTH = 64;
|
|
5
|
+
const HASH_LENGTH = 8;
|
|
6
|
+
/**
|
|
7
|
+
* Public tool name for one upstream tool: `mcp__<server>__<tool>`.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors the host bridge's naming so permission rules, transcripts and
|
|
10
|
+
* allowlists written for the built-in client keep working unchanged. Over-long
|
|
11
|
+
* names collapse to a truncated form plus a stable hash.
|
|
12
|
+
*/
|
|
13
|
+
export function publicToolName(serverName, rawName) {
|
|
14
|
+
const sanitized = rawName.replace(/[^A-Za-z0-9_-]+/g, '_');
|
|
15
|
+
const full = `mcp__${serverName}__${sanitized}`;
|
|
16
|
+
if (full.length <= MAX_PUBLIC_NAME_LENGTH)
|
|
17
|
+
return full;
|
|
18
|
+
const digest = createHash('sha256').update(`${serverName}\u0000${rawName}`).digest('hex').slice(0, HASH_LENGTH);
|
|
19
|
+
return `${full.slice(0, MAX_PUBLIC_NAME_LENGTH - HASH_LENGTH - 1)}_${digest}`;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Publish one MCP server's tool list on `ctx.tools`.
|
|
23
|
+
*
|
|
24
|
+
* Fetch first, swap second: a failed discovery leaves the previous generation
|
|
25
|
+
* registered instead of half-replacing it.
|
|
26
|
+
*
|
|
27
|
+
* @returns number of tools registered for this server.
|
|
28
|
+
*/
|
|
29
|
+
export async function syncTools(client, ctx, options, live) {
|
|
30
|
+
const capabilities = client.getServerCapabilities();
|
|
31
|
+
const response = capabilities?.tools === undefined
|
|
32
|
+
? { tools: [] }
|
|
33
|
+
: await client.listTools(undefined, { cacheMode: 'refresh' });
|
|
34
|
+
const definitions = new Map();
|
|
35
|
+
for (const tool of response.tools) {
|
|
36
|
+
const name = publicToolName(options.serverName, tool.name);
|
|
37
|
+
if (definitions.has(name)) {
|
|
38
|
+
throw new Error(`mcp-oauth(${options.serverName}): server listed tool "${tool.name}" more than once — invalid tool list`);
|
|
39
|
+
}
|
|
40
|
+
definitions.set(name, createMcpToolDefinition(ctx, {
|
|
41
|
+
name,
|
|
42
|
+
rawName: tool.name,
|
|
43
|
+
description: tool.description ?? '',
|
|
44
|
+
inputSchema: tool.inputSchema,
|
|
45
|
+
outputSchema: tool.outputSchema,
|
|
46
|
+
taskRequired: tool.execution?.taskSupport === 'required',
|
|
47
|
+
call: (args, execution) => client.callTool({ name: tool.name, arguments: args }, { signal: execution.signal, timeout: options.toolCallTimeoutMs, toolDefinition: tool }),
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
for (const dispose of live.values())
|
|
51
|
+
dispose();
|
|
52
|
+
live.clear();
|
|
53
|
+
const registered = new Map();
|
|
54
|
+
try {
|
|
55
|
+
for (const [name, definition] of definitions) {
|
|
56
|
+
registered.set(name, ctx.tools.register(definition));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
for (const dispose of registered.values())
|
|
61
|
+
dispose();
|
|
62
|
+
ctx.logger.error(`mcp-oauth(${options.serverName}): tool registration failed, no tools registered: ${String(error)}`);
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
for (const [name, dispose] of registered)
|
|
66
|
+
live.set(name, dispose);
|
|
67
|
+
return registered.size;
|
|
68
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
|
|
2
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
|
3
|
+
/** Names the host strips from a child environment; mirrored so behaviour matches. */
|
|
4
|
+
const CREDENTIAL_SHAPED = /KEY|PASSWORD|SECRET|TOKEN/i;
|
|
5
|
+
/**
|
|
6
|
+
* Child environment for a stdio server: the scrubbed parent environment plus
|
|
7
|
+
* whatever the plugin configuration passes explicitly.
|
|
8
|
+
*
|
|
9
|
+
* Credential-shaped names are dropped deliberately — the host does the same for
|
|
10
|
+
* its own MCP children, and silently forwarding them would make a plugin a
|
|
11
|
+
* privilege-escalation path. Pass secrets as command arguments instead, or name
|
|
12
|
+
* them explicitly in `env`.
|
|
13
|
+
*/
|
|
14
|
+
export function buildChildEnv(extra) {
|
|
15
|
+
const environment = {};
|
|
16
|
+
for (const [name, value] of Object.entries(process.env)) {
|
|
17
|
+
if (value === undefined)
|
|
18
|
+
continue;
|
|
19
|
+
if (CREDENTIAL_SHAPED.test(name) || name.startsWith('DSH_'))
|
|
20
|
+
continue;
|
|
21
|
+
environment[name] = value;
|
|
22
|
+
}
|
|
23
|
+
return { ...environment, ...(extra ?? {}) };
|
|
24
|
+
}
|
|
25
|
+
/** Build the transport for one configured server. */
|
|
26
|
+
export function createTransport(spec) {
|
|
27
|
+
if (spec.transport === 'stdio') {
|
|
28
|
+
return new StdioClientTransport({
|
|
29
|
+
command: spec.command,
|
|
30
|
+
args: spec.args ?? [],
|
|
31
|
+
env: buildChildEnv(spec.env),
|
|
32
|
+
...(spec.cwd !== undefined && spec.cwd.length > 0 ? { cwd: spec.cwd } : {}),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const options = {};
|
|
36
|
+
if (spec.authProvider !== undefined)
|
|
37
|
+
options.authProvider = spec.authProvider;
|
|
38
|
+
const headers = spec.headers ?? {};
|
|
39
|
+
if (Object.keys(headers).length > 0)
|
|
40
|
+
options.requestInit = { headers };
|
|
41
|
+
return new StreamableHTTPClientTransport(new URL(spec.url), options);
|
|
42
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createMcpPlugin } from './core/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Feishu / Lark — official OpenAPI MCP server.
|
|
4
|
+
*
|
|
5
|
+
* Lark ships `@larksuiteoapi/lark-mcp` as a stdio server, so this connector
|
|
6
|
+
* spawns it with `npx` and passes app credentials as command arguments. They
|
|
7
|
+
* cannot ride in `env`: the host strips credential-shaped environment names
|
|
8
|
+
* from child processes, and so does this bridge.
|
|
9
|
+
*/
|
|
10
|
+
const plugin = createMcpPlugin({
|
|
11
|
+
serverName: 'feishu',
|
|
12
|
+
transport: 'stdio',
|
|
13
|
+
command: 'npx',
|
|
14
|
+
args: ['-y', '@larksuiteoapi/lark-mcp', 'mcp'],
|
|
15
|
+
});
|
|
16
|
+
export const name = plugin.name;
|
|
17
|
+
export const inject = plugin.inject;
|
|
18
|
+
export const Config = plugin.Config;
|
|
19
|
+
export const apply = plugin.apply;
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plaudai/dsh-plugin-feishu",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "飞书 / Lark 官方 OpenAPI MCP 接入 DeepSeek Harness",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/index.js",
|
|
10
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"cordis.patch.yml",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json && node ../../scripts/bundle.mjs feishu",
|
|
20
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@deepseek-ai/cosmokit": "~1.8.6",
|
|
24
|
+
"@deepseek-ai/dsh-mcp-client": "^0.2.0-rc.2",
|
|
25
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
26
|
+
"@modelcontextprotocol/client": "^2.0.0"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"dsh",
|
|
33
|
+
"dsh-plugin",
|
|
34
|
+
"feishu",
|
|
35
|
+
"lark",
|
|
36
|
+
"mcp"
|
|
37
|
+
],
|
|
38
|
+
"dsh": {
|
|
39
|
+
"bundle": {
|
|
40
|
+
"patch": "./cordis.patch.yml"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@plaudai/dsh-mcp-oauth": "^0.1.2"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
}
|
|
49
|
+
}
|