dsh-plugin-mcp-apps 0.0.0-stage → 0.5.7

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/oauth.js ADDED
@@ -0,0 +1,239 @@
1
+ /**
2
+ * OAuth 2.0 support for MCP connections.
3
+ *
4
+ * The MCP client SDK owns discovery, dynamic client registration, PKCE and the
5
+ * authorization-code exchange; this module only supplies the persistence hooks
6
+ * (`OAuthClientProvider`) and the browser-facing redirect address. Tokens and
7
+ * the client registration live in the connection record, which the host writes
8
+ * to a 0600 file — the browser never sees them.
9
+ *
10
+ * @module dsh-plugin-mcp-apps/oauth
11
+ */
12
+ import { randomBytes } from "node:crypto";
13
+
14
+ /** Absolute path the authorization server redirects back to. */
15
+ export const CALLBACK_PATH = "/api/mcp-apps/oauth/callback";
16
+
17
+ /** Absolute path of the published client metadata document (CIMD). */
18
+ export const CLIENT_METADATA_PATH = "/api/mcp-apps/oauth/client-metadata.json";
19
+
20
+ /**
21
+ * Client metadata document for CIMD (SEP-991): the `client_id` is this URL.
22
+ * @param origin - browser origin the UI is served from, e.g. https://dsh.example.com.
23
+ * @returns the document to serve, with matching `client_id` and redirect URI.
24
+ */
25
+ export function clientMetadataDocument(origin) {
26
+ const base = origin.replace(/\/+$/, "");
27
+ return {
28
+ client_id: base + CLIENT_METADATA_PATH,
29
+ client_name: "DSH 连接应用",
30
+ redirect_uris: [base + CALLBACK_PATH],
31
+ grant_types: ["authorization_code", "refresh_token"],
32
+ response_types: ["code"],
33
+ token_endpoint_auth_method: "none"
34
+ };
35
+ }
36
+
37
+ /**
38
+ * The public origin the browser used to reach the UI, so the callback lands on
39
+ * the same host the user actually browses (a reverse proxy may front the
40
+ * loopback listener).
41
+ * @param req - incoming HTTP request.
42
+ * @param fallback - origin to use when the request carries no usable Host.
43
+ * @returns the origin, without a trailing slash.
44
+ */
45
+ export function redirectBaseFrom(req, fallback) {
46
+ const forwardedProto = headerValue(req, "x-forwarded-proto");
47
+ const forwardedHost = headerValue(req, "x-forwarded-host");
48
+ const proto = (forwardedProto ?? (req.socket?.encrypted ? "https" : "http")).split(",")[0].trim();
49
+ const rawHost = forwardedHost ?? headerValue(req, "host");
50
+ const host = rawHost === undefined ? undefined : rawHost.split(",")[0].trim();
51
+ const base = host === undefined ? fallback : `${proto}://${host}`;
52
+ return { proto, base: base.replace(/\/+$/, "") };
53
+ }
54
+
55
+ /** Read one header as a trimmed string. */
56
+ function headerValue(req, name) {
57
+ const value = req.headers?.[name];
58
+ if (typeof value === "string" && value.trim() !== "") return value.trim();
59
+ return undefined;
60
+ }
61
+
62
+ /** OAuth storage section of one connection record, created on demand. */
63
+ function section(record) {
64
+ record.auth = record.auth ?? {};
65
+ record.auth.oauth = record.auth.oauth ?? {};
66
+ return record.auth.oauth;
67
+ }
68
+
69
+ /**
70
+ * `OAuthClientProvider` over a connection record.
71
+ */
72
+ export class RecordOAuthProvider {
73
+ /**
74
+ * @param record - connection record mutated in place.
75
+ * @param persist - async callback saving the record.
76
+ * @param options - scopes and the browser origin for the callback.
77
+ */
78
+ constructor(record, persist, options) {
79
+ this.record = record;
80
+ this.persist = persist;
81
+ this.options = options;
82
+ }
83
+
84
+ /** CIMD mode: the metadata document URL is the client id (HTTPS only). */
85
+ get clientMetadataUrl() {
86
+ if (this.options.clientMode !== "cimd") return undefined;
87
+ const base = this.options.redirectBase ?? "";
88
+ return /^https:/i.test(base) ? base + CLIENT_METADATA_PATH : undefined;
89
+ }
90
+
91
+ /** Pre-registered client id, when the server does not support registration. */
92
+ configuredClientId() {
93
+ const value = this.options.clientId;
94
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
95
+ }
96
+
97
+ /** Where the authorization server sends the browser back. */
98
+ get redirectUrl() {
99
+ return this.options.redirectBase + CALLBACK_PATH;
100
+ }
101
+
102
+ /** What this client tells the authorization server about itself. */
103
+ get clientMetadata() {
104
+ const metadata = {
105
+ client_name: "DSH 连接应用",
106
+ redirect_uris: [this.redirectUrl],
107
+ grant_types: ["authorization_code", "refresh_token"],
108
+ response_types: ["code"],
109
+ token_endpoint_auth_method: this.options.clientSecret ? "client_secret_basic" : "none"
110
+ };
111
+ const scope = this.options.scopes;
112
+ if (typeof scope === "string" && scope.trim() !== "") metadata.scope = scope.trim();
113
+ return metadata;
114
+ }
115
+
116
+ /** Fresh CSRF state for one authorization request. */
117
+ state() {
118
+ const value = randomBytes(16).toString("hex");
119
+ section(this.record).state = value;
120
+ return value;
121
+ }
122
+
123
+ /** Recorded CSRF state, for the callback to compare against. */
124
+ storedState() {
125
+ return section(this.record).state;
126
+ }
127
+
128
+ /** Registered client information; a configured client id wins over registration. */
129
+ clientInformation() {
130
+ const configured = this.configuredClientId();
131
+ if (configured !== undefined) {
132
+ const secret = typeof this.options.clientSecret === "string" ? this.options.clientSecret : "";
133
+ return secret === "" ? { client_id: configured } : { client_id: configured, client_secret: secret };
134
+ }
135
+ return section(this.record).clientInformation;
136
+ }
137
+
138
+ /** Persist the client registration. */
139
+ async saveClientInformation(clientInformation) {
140
+ section(this.record).clientInformation = clientInformation;
141
+ await this.persist();
142
+ }
143
+
144
+ /** Current tokens, if any. */
145
+ tokens() {
146
+ return section(this.record).tokens;
147
+ }
148
+
149
+ /** Persist fresh tokens (also used by the SDK's refresh path). */
150
+ async saveTokens(tokens) {
151
+ section(this.record).tokens = tokens;
152
+ await this.persist();
153
+ }
154
+
155
+ /** Remember the URL the browser must visit, so the page can open it. */
156
+ async redirectToAuthorization(url) {
157
+ section(this.record).authorizationUrl = String(url);
158
+ await this.persist();
159
+ }
160
+
161
+ /** PKCE verifier saved before the redirect. */
162
+ codeVerifier() {
163
+ return section(this.record).codeVerifier;
164
+ }
165
+
166
+ /** Persist the PKCE verifier. */
167
+ async saveCodeVerifier(verifier) {
168
+ section(this.record).codeVerifier = verifier;
169
+ await this.persist();
170
+ }
171
+
172
+ /** SEP-2352 discovery state: binds the callback leg to the discovered server. */
173
+ discoveryState() {
174
+ return section(this.record).discoveryState;
175
+ }
176
+
177
+ /** Persist the discovery state alongside the PKCE verifier. */
178
+ async saveDiscoveryState(state) {
179
+ section(this.record).discoveryState = state;
180
+ await this.persist();
181
+ }
182
+
183
+ /** Authorization server identity, replayed on reconnect. */
184
+ authorizationServerUrl() {
185
+ return section(this.record).authorizationServerUrl;
186
+ }
187
+
188
+ /** Persist the authorization server URL. */
189
+ async saveAuthorizationServerUrl(url) {
190
+ section(this.record).authorizationServerUrl = url;
191
+ await this.persist();
192
+ }
193
+
194
+ /** Drop stored credentials; `all` also forgets the client registration. */
195
+ async invalidateCredentials(scope) {
196
+ const oauth = section(this.record);
197
+ if (scope === "all" || scope === "client") {
198
+ delete oauth.clientInformation;
199
+ delete oauth.authorizationServerUrl;
200
+ }
201
+ if (scope === "all" || scope === "tokens") {
202
+ delete oauth.tokens;
203
+ delete oauth.authorizationUrl;
204
+ }
205
+ if (scope === "all" || scope === "verifier") delete oauth.codeVerifier;
206
+ if (scope === "all" || scope === "discovery") delete oauth.discoveryState;
207
+ await this.persist();
208
+ }
209
+
210
+ /** Whether a token is stored (and not expired by the record's own clock). */
211
+ authorized() {
212
+ const tokens = section(this.record).tokens;
213
+ return tokens !== undefined && (tokens.refresh_token !== undefined || tokens.access_token !== undefined);
214
+ }
215
+
216
+ /** Page-facing summary; no secret values. */
217
+ summary() {
218
+ const oauth = section(this.record);
219
+ const tokens = oauth.tokens;
220
+ const expiresAt = typeof tokens?.expires_in === "number" && typeof oauth.obtainedAt === "number" ? oauth.obtainedAt + tokens.expires_in * 1000 : undefined;
221
+ return {
222
+ mode: "oauth",
223
+ authorized: this.authorized(),
224
+ scopes: this.options.scopes ?? "",
225
+ expiresAt: expiresAt ?? null,
226
+ hasClientRegistration: oauth.clientInformation !== undefined || this.configuredClientId() !== undefined,
227
+ clientMode: this.options.clientMode ?? "auto",
228
+ clientId: this.configuredClientId() ?? "",
229
+ authorizationUrl: typeof oauth.authorizationUrl === "string" ? oauth.authorizationUrl : "",
230
+ redirectUrl: this.redirectUrl
231
+ };
232
+ }
233
+ }
234
+
235
+ /** Clear the OAuth section of a record (used by 清除授权). */
236
+ export function clearOAuth(record) {
237
+ if (record.auth === undefined) return;
238
+ delete record.auth.oauth;
239
+ }
package/package.json CHANGED
@@ -1,6 +1,85 @@
1
1
  {
2
2
  "name": "dsh-plugin-mcp-apps",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.5.7",
4
+ "description": "通用 MCP 连接器 + MCP Apps 工作台:连接应用页管理 MCP 服务器(HTTP/SSE/stdio、API/OAuth 2.0/CIMD 鉴权、启停),带 ui:// 资源的工具在右侧工作台渲染",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": {
9
+ "default": "./lib/index.js"
10
+ },
11
+ "./client": {
12
+ "default": "./lib/client.js"
13
+ },
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "LICENSE",
18
+ "README.md",
19
+ "cordis.patch.yml",
20
+ "lib/auth.js",
21
+ "lib/client.js",
22
+ "lib/index.js",
23
+ "lib/oauth.js",
24
+ "scripts/install-remote.sh"
25
+ ],
26
+ "license": "MIT",
27
+ "author": "xiaoqin422",
28
+ "keywords": [
29
+ "connector",
30
+ "dsh",
31
+ "dsh-plugin",
32
+ "mcp",
33
+ "mcp-apps",
34
+ "mcp-client",
35
+ "oauth",
36
+ "workbench"
37
+ ],
38
+ "engines": {
39
+ "node": ">=20"
40
+ },
41
+ "peerDependencies": {
42
+ "@deepseek-ai/cordis": "~4.0.5",
43
+ "react": "^18.2.0",
44
+ "@modelcontextprotocol/client": "^2.2.0"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "@deepseek-ai/cordis": {
48
+ "optional": true
49
+ },
50
+ "react": {
51
+ "optional": true
52
+ },
53
+ "@modelcontextprotocol/client": {
54
+ "optional": true
55
+ }
56
+ },
57
+ "dsh": {
58
+ "bundle": {
59
+ "patch": "./cordis.patch.yml"
60
+ },
61
+ "client": {
62
+ "inject": [
63
+ "@deepseek-ai/dsh-client-ui-tool",
64
+ "@deepseek-ai/dsh-client-ui-sidebar-right"
65
+ ],
66
+ "platform": "web"
67
+ },
68
+ "compatibility": {
69
+ "dshReleases": {
70
+ "0.2.1-rc.2": "compatible"
71
+ }
72
+ }
73
+ },
74
+ "repository": {
75
+ "type": "git",
76
+ "url": "git+https://github.com/xiaoqin422/dsh-plugin-mcp-apps.git"
77
+ },
78
+ "homepage": "https://github.com/xiaoqin422/dsh-plugin-mcp-apps#readme",
79
+ "bugs": {
80
+ "url": "https://github.com/xiaoqin422/dsh-plugin-mcp-apps/issues"
81
+ },
82
+ "publishConfig": {
83
+ "access": "public"
84
+ }
85
+ }
@@ -0,0 +1,50 @@
1
+ #!/bin/sh
2
+ # Install dsh-plugin-mcp-apps on another machine, from npm, a git URL or a tarball.
3
+ #
4
+ # sh scripts/install-remote.sh # npm, profile web
5
+ # sh scripts/install-remote.sh dsh-plugin-mcp-apps web
6
+ # sh scripts/install-remote.sh https://github.com/xiaoqin422/dsh-plugin-mcp-apps web
7
+ # sh scripts/install-remote.sh ./dsh-plugin-mcp-apps-0.5.6.tgz web
8
+ #
9
+ # Needs a DSH install (`dsh` on PATH). Does the same three things as
10
+ # scripts/install.sh: pnpm add through the official forwarder, register the
11
+ # bundle in `dsh.profile.bundles`, and drop a legacy hand-written insert so the
12
+ # plugin cannot be composed twice. Restart dsh yourself afterwards.
13
+ set -eu
14
+
15
+ SPEC=${1:-dsh-plugin-mcp-apps}
16
+ PROFILE=${2:-${DSH_PROFILE:-web}}
17
+ DSH_HOME=${DSH_HOME:-/data}
18
+ PROFILE_DIR="$DSH_HOME/profiles/$PROFILE"
19
+ PKG=dsh-plugin-mcp-apps
20
+
21
+ command -v dsh >/dev/null 2>&1 || { echo "dsh is not on PATH: install DSH first" >&2; exit 2; }
22
+ if [ ! -f "$PROFILE_DIR/package.json" ]; then
23
+ echo "==> initializing profile '$PROFILE'"
24
+ dsh plugin --profile "$PROFILE" --help >/dev/null 2>&1 || true
25
+ fi
26
+ [ -f "$PROFILE_DIR/package.json" ] || { echo "profile '$PROFILE' could not be initialized at $PROFILE_DIR" >&2; exit 2; }
27
+
28
+ STAMP=$(date +%Y%m%d-%H%M%S)
29
+ cp -a "$PROFILE_DIR/package.json" "$PROFILE_DIR/package.json.bak-$STAMP"
30
+
31
+ echo "==> dsh plugin --profile $PROFILE add $SPEC"
32
+ dsh plugin --profile "$PROFILE" add "$SPEC"
33
+
34
+ echo "==> register $PKG in dsh.profile.bundles"
35
+ node - "$PROFILE_DIR/package.json" "$PKG" <<'NODE'
36
+ const fs = require("fs");
37
+ const [file, name] = process.argv.slice(2);
38
+ const data = JSON.parse(fs.readFileSync(file, "utf8"));
39
+ data.dsh = data.dsh || {};
40
+ data.dsh.profile = data.dsh.profile || {};
41
+ if (!Array.isArray(data.dsh.profile.bundles)) data.dsh.profile.bundles = [];
42
+ if (!data.dsh.profile.bundles.includes(name)) data.dsh.profile.bundles.push(name);
43
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
44
+ NODE
45
+
46
+ echo
47
+ echo "installed into profile '$PROFILE'. restart dsh to load it:"
48
+ echo " docker restart dsh # or however this host runs dsh"
49
+ echo " then hard-reload the browser page"
50
+ echo "backup: $PROFILE_DIR/package.json.bak-$STAMP"