@plaudai/dsh-mcp-oauth 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/lib/browser.d.ts +8 -0
- package/lib/browser.js +23 -0
- package/lib/index.d.ts +59 -0
- package/lib/index.js +191 -0
- package/lib/oauth.d.ts +90 -0
- package/lib/oauth.js +247 -0
- package/lib/store.d.ts +43 -0
- package/lib/store.js +115 -0
- package/lib/tools.d.ts +60 -0
- package/lib/tools.js +68 -0
- package/lib/transport.d.ts +29 -0
- package/lib/transport.js +42 -0
- package/package.json +40 -4
- package/README.md +0 -3
package/lib/browser.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand a URL to the user's default browser without blocking the host process.
|
|
3
|
+
*
|
|
4
|
+
* Never throws: an environment with no desktop session simply gets the URL
|
|
5
|
+
* logged by the caller, and the authorization flow can still be completed by
|
|
6
|
+
* pasting it elsewhere.
|
|
7
|
+
*/
|
|
8
|
+
export declare function openBrowser(url: string): void;
|
package/lib/browser.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
const [command, args] = process.platform === 'win32'
|
|
11
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
12
|
+
: process.platform === 'darwin'
|
|
13
|
+
? ['open', [url]]
|
|
14
|
+
: ['xdg-open', [url]];
|
|
15
|
+
try {
|
|
16
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
|
|
17
|
+
child.on('error', () => { });
|
|
18
|
+
child.unref();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// The caller logs the URL; a missing browser is not a fatal condition.
|
|
22
|
+
}
|
|
23
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type PluginContext } from './tools.js';
|
|
2
|
+
export { DshOAuthProvider, OAuthCallbackListener, ensureAuthorized } from './oauth.js';
|
|
3
|
+
export { TokenStore, dshHome } from './store.js';
|
|
4
|
+
export { buildChildEnv, createTransport } from './transport.js';
|
|
5
|
+
export { publicToolName, syncTools } from './tools.js';
|
|
6
|
+
export type { CallbackResult, ProviderOptions } from './oauth.js';
|
|
7
|
+
export type { PluginContext, PluginLogger } from './tools.js';
|
|
8
|
+
/** Per-service defaults baked into a connector package. */
|
|
9
|
+
export interface McpPluginPreset {
|
|
10
|
+
/** Namespace for tool names and the credential file. */
|
|
11
|
+
serverName: string;
|
|
12
|
+
/** `streamable-http` for remote servers, `stdio` for child processes. */
|
|
13
|
+
transport: 'streamable-http' | 'stdio';
|
|
14
|
+
/** How the remote server authenticates. Ignored for stdio. */
|
|
15
|
+
auth?: 'oauth' | 'bearer' | 'none' | undefined;
|
|
16
|
+
/** Remote MCP endpoint. */
|
|
17
|
+
url?: string | undefined;
|
|
18
|
+
/** Local command for stdio servers. */
|
|
19
|
+
command?: string | undefined;
|
|
20
|
+
/** Default argument vector for stdio servers. */
|
|
21
|
+
args?: string[] | undefined;
|
|
22
|
+
/** Environment variable that carries a bearer token. */
|
|
23
|
+
bearerTokenEnv?: string | undefined;
|
|
24
|
+
/** Default scope request. */
|
|
25
|
+
scope?: string | undefined;
|
|
26
|
+
}
|
|
27
|
+
/** Resolved plugin configuration after schemastery defaults are applied. */
|
|
28
|
+
export interface ResolvedConfig {
|
|
29
|
+
serverName: string;
|
|
30
|
+
transport: string;
|
|
31
|
+
auth: string;
|
|
32
|
+
url: string;
|
|
33
|
+
headers: Record<string, string>;
|
|
34
|
+
bearerTokenEnv: string;
|
|
35
|
+
command: string;
|
|
36
|
+
args: string[];
|
|
37
|
+
env: Record<string, string>;
|
|
38
|
+
cwd: string;
|
|
39
|
+
scope: string;
|
|
40
|
+
clientId: string;
|
|
41
|
+
clientSecret: string;
|
|
42
|
+
authorizationParams: Record<string, string>;
|
|
43
|
+
tokenEndpointAuthMethod: string;
|
|
44
|
+
toolCallTimeoutMs: number;
|
|
45
|
+
authTimeoutMs: number;
|
|
46
|
+
failOnStartupError: boolean;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Build a Cordis plugin for one MCP server.
|
|
50
|
+
*
|
|
51
|
+
* The returned object carries the four names the harness loader reads, so a
|
|
52
|
+
* service package only needs to re-export them.
|
|
53
|
+
*/
|
|
54
|
+
export declare function createMcpPlugin(preset: McpPluginPreset): {
|
|
55
|
+
name: string;
|
|
56
|
+
inject: string[];
|
|
57
|
+
Config: unknown;
|
|
58
|
+
apply(ctx: PluginContext, config: ResolvedConfig): Promise<void>;
|
|
59
|
+
};
|
package/lib/index.js
ADDED
|
@@ -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
|
+
}
|
package/lib/oauth.d.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { OAuthClientProvider } from '@modelcontextprotocol/client';
|
|
2
|
+
import type { ClientInformation, IssuerContext, TokenStore, Tokens } from './store.js';
|
|
3
|
+
/** Everything the provider needs that the host configuration supplies. */
|
|
4
|
+
export interface ProviderOptions {
|
|
5
|
+
/** MCP server namespace, used only for diagnostics. */
|
|
6
|
+
serverName: string;
|
|
7
|
+
/** Loopback redirect URI the SDK registers and the authorization server calls back. */
|
|
8
|
+
redirectUrl: string;
|
|
9
|
+
/** Store that persists the client registration, tokens and issuer keys. */
|
|
10
|
+
store: TokenStore;
|
|
11
|
+
/** Space-delimited scopes to request; empty lets the server decide. */
|
|
12
|
+
scope?: string | undefined;
|
|
13
|
+
/** Pre-registered client id (Slack and other non-DCR servers). */
|
|
14
|
+
clientId?: string | undefined;
|
|
15
|
+
/** Pre-registered client secret (confidential clients only). */
|
|
16
|
+
clientSecret?: string | undefined;
|
|
17
|
+
/** How the client authenticates at the token endpoint. */
|
|
18
|
+
tokenEndpointAuthMethod?: 'none' | 'client_secret_basic' | 'client_secret_post' | undefined;
|
|
19
|
+
/** Extra query parameters appended to the authorization URL (e.g. Slack `app_id`). */
|
|
20
|
+
authorizationParams?: Record<string, string> | undefined;
|
|
21
|
+
/** Diagnostics sink; the host logger. */
|
|
22
|
+
log?: ((message: string) => void) | undefined;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* OAuth 2.0 client provider backed by the host's credential store.
|
|
26
|
+
*
|
|
27
|
+
* The MCP SDK owns discovery, DCR, PKCE, issuer validation and refresh; this
|
|
28
|
+
* class only answers "what do we know" and "where should the browser go".
|
|
29
|
+
* That split is what keeps the flow standard rather than per-vendor.
|
|
30
|
+
*/
|
|
31
|
+
export declare class DshOAuthProvider implements OAuthClientProvider {
|
|
32
|
+
#private;
|
|
33
|
+
readonly options: ProviderOptions;
|
|
34
|
+
constructor(options: ProviderOptions);
|
|
35
|
+
/** Loopback redirect URI, known before the first request so DCR can register it. */
|
|
36
|
+
get redirectUrl(): string;
|
|
37
|
+
/** Last authorization URL handed to the browser, for diagnostics and retries. */
|
|
38
|
+
get authorizationUrl(): URL | undefined;
|
|
39
|
+
get clientMetadata(): ReturnType<() => OAuthClientProvider['clientMetadata']>;
|
|
40
|
+
/** CSRF state for the authorization request; constant for one flow. */
|
|
41
|
+
state(): Promise<string>;
|
|
42
|
+
/** Pre-registered client wins over anything discovered through DCR. */
|
|
43
|
+
clientInformation(ctx?: IssuerContext): Promise<ClientInformation | undefined>;
|
|
44
|
+
/** Persist a DCR result; a pre-registered client is never overwritten. */
|
|
45
|
+
saveClientInformation(information: ClientInformation, ctx?: IssuerContext): Promise<void>;
|
|
46
|
+
tokens(ctx?: IssuerContext): Promise<Tokens | undefined>;
|
|
47
|
+
saveTokens(tokens: Tokens, ctx?: IssuerContext): Promise<void>;
|
|
48
|
+
saveCodeVerifier(codeVerifier: string): Promise<void>;
|
|
49
|
+
codeVerifier(): Promise<string>;
|
|
50
|
+
/** Append vendor-required parameters, then open the browser. */
|
|
51
|
+
redirectToAuthorization(authorizationUrl: URL): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
/** One authorization callback captured from the loopback listener. */
|
|
54
|
+
export interface CallbackResult {
|
|
55
|
+
code: string;
|
|
56
|
+
iss?: string;
|
|
57
|
+
state?: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Single-use loopback listener for the OAuth redirect.
|
|
61
|
+
*
|
|
62
|
+
* Binds `127.0.0.1` on an ephemeral port so two servers (or two harness
|
|
63
|
+
* processes) never fight over a fixed callback port. A callback that arrives
|
|
64
|
+
* before anyone awaits is queued, not dropped.
|
|
65
|
+
*/
|
|
66
|
+
export declare class OAuthCallbackListener {
|
|
67
|
+
#private;
|
|
68
|
+
/** Redirect URI; only valid after {@link start}. */
|
|
69
|
+
get redirectUrl(): string;
|
|
70
|
+
/** Start listening and return the redirect URI to register. */
|
|
71
|
+
start(): Promise<string>;
|
|
72
|
+
/** Resolve with the next callback, or reject on timeout / listener failure. */
|
|
73
|
+
wait(timeoutMs?: number): Promise<CallbackResult>;
|
|
74
|
+
/** Stop listening; safe to call more than once. */
|
|
75
|
+
close(): void;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Drive the full interactive authorization flow for one server.
|
|
79
|
+
*
|
|
80
|
+
* First call runs discovery and either refreshes or redirects; when it
|
|
81
|
+
* redirected, the callback is awaited and exchanged for tokens. Idempotent:
|
|
82
|
+
* with a live token set it returns after the refresh check.
|
|
83
|
+
*/
|
|
84
|
+
export declare function ensureAuthorized(options: {
|
|
85
|
+
serverUrl: URL;
|
|
86
|
+
provider: DshOAuthProvider;
|
|
87
|
+
listener: OAuthCallbackListener;
|
|
88
|
+
timeoutMs?: number | undefined;
|
|
89
|
+
log?: ((message: string) => void) | undefined;
|
|
90
|
+
}): Promise<void>;
|
package/lib/oauth.js
ADDED
|
@@ -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
|
+
}
|
package/lib/store.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { OAuthClientProvider } from '@modelcontextprotocol/client';
|
|
2
|
+
/** Token set as the MCP SDK's provider interface describes it. */
|
|
3
|
+
export type Tokens = NonNullable<Awaited<ReturnType<OAuthClientProvider['tokens']>>>;
|
|
4
|
+
/** Registered-client information as the MCP SDK's provider interface describes it. */
|
|
5
|
+
export type ClientInformation = NonNullable<Awaited<ReturnType<OAuthClientProvider['clientInformation']>>>;
|
|
6
|
+
/** Per-authorization-server context the SDK passes to credential callbacks. */
|
|
7
|
+
export type IssuerContext = Parameters<OAuthClientProvider['tokens']>[0];
|
|
8
|
+
/**
|
|
9
|
+
* Root of the DeepSeek Harness installation that owns this process.
|
|
10
|
+
*
|
|
11
|
+
* Matches the host's own convention so a plugin running under the desktop app
|
|
12
|
+
* and one running under the CLI resolve to the same directory.
|
|
13
|
+
*/
|
|
14
|
+
export declare function dshHome(): string;
|
|
15
|
+
/**
|
|
16
|
+
* Persistent OAuth credentials for one MCP server.
|
|
17
|
+
*
|
|
18
|
+
* One JSON document per server under `$DSH_HOME/mcp-oauth/`, written atomically
|
|
19
|
+
* and chmod 0600. Entries are keyed by authorization-server issuer so a server
|
|
20
|
+
* that moves between issuers never replays a token against the wrong one
|
|
21
|
+
* (RFC 9207 mix-up defense).
|
|
22
|
+
*/
|
|
23
|
+
export declare class TokenStore {
|
|
24
|
+
#private;
|
|
25
|
+
/** Absolute path of the backing file; surfaced in logs so users can find it. */
|
|
26
|
+
readonly file: string;
|
|
27
|
+
constructor(serverName: string);
|
|
28
|
+
/** Stored client registration for an issuer, or `undefined` when unregistered. */
|
|
29
|
+
client(ctx?: IssuerContext): Promise<ClientInformation | undefined>;
|
|
30
|
+
/** Persist a dynamically registered client and remember it as the latest issuer. */
|
|
31
|
+
putClient(ctx: IssuerContext | undefined, client: ClientInformation): Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Tokens for an issuer, or the most recently saved set when the SDK asks
|
|
34
|
+
* without context (its per-request bearer-token read).
|
|
35
|
+
*/
|
|
36
|
+
tokens(ctx?: IssuerContext): Promise<Tokens | undefined>;
|
|
37
|
+
/** Persist a token set for an issuer. */
|
|
38
|
+
putTokens(ctx: IssuerContext | undefined, tokens: Tokens): Promise<void>;
|
|
39
|
+
/** Drop every stored credential for this server (used by the logout path). */
|
|
40
|
+
clear(): Promise<void>;
|
|
41
|
+
/** Whether a token set exists, without loading the token values. */
|
|
42
|
+
hasTokens(): Promise<boolean>;
|
|
43
|
+
}
|
package/lib/store.js
ADDED
|
@@ -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
|
+
}
|
package/lib/tools.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Logger surface the host exposes to plugins. */
|
|
2
|
+
export interface PluginLogger {
|
|
3
|
+
info(message: string): void;
|
|
4
|
+
warn(message: string): void;
|
|
5
|
+
error(message: string): void;
|
|
6
|
+
}
|
|
7
|
+
/** The subset of the Cordis plugin context this bridge depends on. */
|
|
8
|
+
export interface PluginContext {
|
|
9
|
+
tools: {
|
|
10
|
+
register(definition: unknown): () => void;
|
|
11
|
+
};
|
|
12
|
+
logger: PluginLogger;
|
|
13
|
+
effect(callback: () => (() => void) | void, label?: string): unknown;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Public tool name for one upstream tool: `mcp__<server>__<tool>`.
|
|
17
|
+
*
|
|
18
|
+
* Mirrors the host bridge's naming so permission rules, transcripts and
|
|
19
|
+
* allowlists written for the built-in client keep working unchanged. Over-long
|
|
20
|
+
* names collapse to a truncated form plus a stable hash.
|
|
21
|
+
*/
|
|
22
|
+
export declare function publicToolName(serverName: string, rawName: string): string;
|
|
23
|
+
/** The connected MCP client as far as this bridge needs to see it. */
|
|
24
|
+
interface McpClientLike {
|
|
25
|
+
getServerCapabilities(): {
|
|
26
|
+
tools?: unknown;
|
|
27
|
+
} | undefined;
|
|
28
|
+
listTools(params: undefined, options: {
|
|
29
|
+
cacheMode: 'refresh';
|
|
30
|
+
}): Promise<{
|
|
31
|
+
tools: readonly McpToolLike[];
|
|
32
|
+
}>;
|
|
33
|
+
callTool(request: {
|
|
34
|
+
name: string;
|
|
35
|
+
arguments: Record<string, unknown>;
|
|
36
|
+
}, options: Record<string, unknown>): Promise<unknown>;
|
|
37
|
+
}
|
|
38
|
+
interface McpToolLike {
|
|
39
|
+
name: string;
|
|
40
|
+
description?: string | undefined;
|
|
41
|
+
inputSchema: unknown;
|
|
42
|
+
outputSchema?: unknown;
|
|
43
|
+
execution?: {
|
|
44
|
+
taskSupport?: string | undefined;
|
|
45
|
+
} | undefined;
|
|
46
|
+
}
|
|
47
|
+
export interface SyncOptions {
|
|
48
|
+
serverName: string;
|
|
49
|
+
toolCallTimeoutMs: number;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Publish one MCP server's tool list on `ctx.tools`.
|
|
53
|
+
*
|
|
54
|
+
* Fetch first, swap second: a failed discovery leaves the previous generation
|
|
55
|
+
* registered instead of half-replacing it.
|
|
56
|
+
*
|
|
57
|
+
* @returns number of tools registered for this server.
|
|
58
|
+
*/
|
|
59
|
+
export declare function syncTools(client: McpClientLike, ctx: PluginContext, options: SyncOptions, live: Map<string, () => void>): Promise<number>;
|
|
60
|
+
export {};
|
package/lib/tools.js
ADDED
|
@@ -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,29 @@
|
|
|
1
|
+
import type { DshOAuthProvider } from './oauth.js';
|
|
2
|
+
/** Remote transport: streamable HTTP, optionally OAuth or static headers. */
|
|
3
|
+
export interface RemoteTransportSpec {
|
|
4
|
+
transport: 'streamable-http';
|
|
5
|
+
url: string;
|
|
6
|
+
headers?: Record<string, string> | undefined;
|
|
7
|
+
authProvider?: DshOAuthProvider | undefined;
|
|
8
|
+
}
|
|
9
|
+
/** Local transport: an MCP server spawned as a child process. */
|
|
10
|
+
export interface StdioTransportSpec {
|
|
11
|
+
transport: 'stdio';
|
|
12
|
+
command: string;
|
|
13
|
+
args?: string[] | undefined;
|
|
14
|
+
env?: Record<string, string> | undefined;
|
|
15
|
+
cwd?: string | undefined;
|
|
16
|
+
}
|
|
17
|
+
export type TransportSpec = RemoteTransportSpec | StdioTransportSpec;
|
|
18
|
+
/**
|
|
19
|
+
* Child environment for a stdio server: the scrubbed parent environment plus
|
|
20
|
+
* whatever the plugin configuration passes explicitly.
|
|
21
|
+
*
|
|
22
|
+
* Credential-shaped names are dropped deliberately — the host does the same for
|
|
23
|
+
* its own MCP children, and silently forwarding them would make a plugin a
|
|
24
|
+
* privilege-escalation path. Pass secrets as command arguments instead, or name
|
|
25
|
+
* them explicitly in `env`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function buildChildEnv(extra: Record<string, string> | undefined): Record<string, string>;
|
|
28
|
+
/** Build the transport for one configured server. */
|
|
29
|
+
export declare function createTransport(spec: TransportSpec): unknown;
|
package/lib/transport.js
ADDED
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plaudai/dsh-mcp-oauth",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "MCP bridge for DeepSeek Harness with a built-in OAuth 2.0 client (DCR + PKCE), bearer-header and stdio transports",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./lib/index.js",
|
|
8
|
+
"types": "./lib/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./lib/index.d.ts",
|
|
12
|
+
"default": "./lib/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"lib",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.json",
|
|
22
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@deepseek-ai/dsh-mcp-client": "^0.2.0-rc.2",
|
|
26
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
27
|
+
"@modelcontextprotocol/client": "^2.0.0"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"dsh",
|
|
34
|
+
"dsh-plugin",
|
|
35
|
+
"deepseek-harness",
|
|
36
|
+
"mcp",
|
|
37
|
+
"oauth"
|
|
38
|
+
],
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/README.md
DELETED