dsh-agent-plugins-market 0.9.2 → 0.11.0
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 +6 -6
- package/README.zh.md +6 -6
- package/client/client.js +339 -223
- package/client/client.js.map +1 -1
- package/lib/packages/market-bundle/src/application/catalog.js +8 -0
- package/lib/packages/market-bundle/src/index.js +4 -4
- package/lib/packages/market-bundle/src/platform/settings-namespace.js +4 -16
- package/lib/packages/market-bundle/src/platform/settings-schema.js +6 -6
- package/lib/packages/market-bundle/src/routes.js +14 -0
- package/lib/packages/market-catalog/src/scanning/archive.js +31 -0
- package/lib/packages/market-catalog/src/scanning/validate.js +18 -1
- package/lib/packages/market-contracts/src/contracts/market.js +1 -0
- package/lib/packages/market-contracts/src/contracts/settings.js +20 -43
- package/lib/packages/market-contracts/src/ports/ports.js +1 -0
- package/lib/packages/market-mcp/src/application/mcp/mcp-direct-config.js +28 -0
- package/lib/packages/market-mcp/src/application/mcp/mcp-status.js +36 -8
- package/lib/packages/market-mcp/src/application/mcp-service.js +44 -3
- package/lib/packages/market-mcp/src/runtime/mcp/bridge/connection.js +42 -23
- package/lib/packages/market-mcp/src/runtime/mcp/bridge/oauth.js +29 -2
- package/lib/packages/market-mcp/src/runtime/mcp/bridge/transport.js +8 -5
- package/lib/packages/market-runtime/src/runtime/agents/agent-role-router.js +2 -1
- package/lib/packages/market-runtime/src/runtime/agents/subagent-seam.js +29 -0
- package/lib/packages/market-runtime/src/runtime/agents/teammate-role-runtime.js +8 -2
- package/lib/packages/market-runtime/src/runtime/core/reconciler.js +12 -0
- package/lib/packages/market-runtime/src/runtime/host/scoped-contributors.js +4 -0
- package/lib/packages/market-runtime/src/runtime/surfaces/project-runtime.js +4 -1
- package/lib/types/packages/market-bundle/src/application/catalog.d.ts +6 -0
- package/lib/types/packages/market-bundle/src/index.d.ts +1 -1
- package/lib/types/packages/market-bundle/src/platform/settings-namespace.d.ts +4 -18
- package/lib/types/packages/market-bundle/src/platform/settings-schema.d.ts +6 -6
- package/lib/types/packages/market-contracts/src/contracts/market.d.ts +1 -0
- package/lib/types/packages/market-contracts/src/contracts/mcp-status.d.ts +16 -0
- package/lib/types/packages/market-contracts/src/contracts/settings.d.ts +15 -39
- package/lib/types/packages/market-contracts/src/ports/ports.d.ts +11 -0
- package/lib/types/packages/market-contracts/src/ports/queries.d.ts +2 -0
- package/lib/types/packages/market-mcp/src/application/mcp/mcp-direct-config.d.ts +12 -0
- package/lib/types/packages/market-mcp/src/application/mcp/mcp-status.d.ts +8 -2
- package/lib/types/packages/market-mcp/src/application/mcp-service.d.ts +12 -0
- package/lib/types/packages/market-mcp/src/runtime/mcp/bridge/connection.d.ts +4 -1
- package/lib/types/packages/market-mcp/src/runtime/mcp/bridge/oauth.d.ts +14 -0
- package/lib/types/packages/market-mcp/src/runtime/mcp/bridge/transport.d.ts +5 -1
- package/lib/types/packages/market-runtime/src/runtime/agents/agent-role-router.d.ts +2 -10
- package/lib/types/packages/market-runtime/src/runtime/agents/subagent-seam.d.ts +63 -0
- package/lib/types/packages/market-runtime/src/runtime/core/reconciler.d.ts +13 -0
- package/package.json +67 -62
- package/schemas/1.0.0/mcp.schema.json +0 -120
- package/schemas/1.0.0/plugin.schema.json +0 -65
- package/schemas/1.1.0/mcp.schema.json +0 -120
- package/schemas/1.1.0/plugin.schema.json +0 -65
- package/schemas/README.md +0 -79
- package/schemas/agent-plugins/spec.md +0 -62
- package/schemas/claude-code/marketplace.schema.json +0 -143
- package/schemas/claude-code/plugin.schema.json +0 -105
- package/schemas/claude-code/spec.md +0 -108
- package/schemas/codex/marketplace.schema.json +0 -78
- package/schemas/codex/plugin.schema.json +0 -77
- package/schemas/codex/spec.md +0 -91
- package/schemas/com.deepseek.harness/namespace.schema.json +0 -42
- package/schemas/com.deepseek.harness/spec.md +0 -52
- package/schemas/cursor/marketplace.schema.json +0 -82
- package/schemas/cursor/plugin.schema.json +0 -58
- package/schemas/cursor/spec.md +0 -92
- package/schemas/github-copilot/marketplace.schema.json +0 -107
- package/schemas/github-copilot/plugin.schema.json +0 -89
- package/schemas/github-copilot/spec.md +0 -105
- package/schemas/kimi/marketplace.schema.json +0 -38
- package/schemas/kimi/plugin.schema.json +0 -84
- package/schemas/kimi/spec.md +0 -94
- package/schemas/qoder/marketplace.schema.json +0 -115
- package/schemas/qoder/plugin.schema.json +0 -127
- package/schemas/qoder/spec.md +0 -111
- package/schemas/skill-collection/spec.md +0 -37
- package/schemas/universal/marketplace.schema.json +0 -98
- package/schemas/universal/plugin.schema.json +0 -49
- package/schemas/universal/spec.md +0 -53
- package/schemas/zcode/marketplace.schema.json +0 -161
- package/schemas/zcode/plugin.schema.json +0 -89
- package/schemas/zcode/spec.md +0 -102
|
@@ -388,6 +388,14 @@ export class Catalog {
|
|
|
388
388
|
async addMcpServer(name, server, policy) {
|
|
389
389
|
await this.mcp.addServer(name, server, policy);
|
|
390
390
|
}
|
|
391
|
+
/**
|
|
392
|
+
* Remove one user-owned MCP service, its override record, and its OAuth
|
|
393
|
+
* grant. A server a suite declares is refused: that declaration is
|
|
394
|
+
* source-owned, so disabling it is the supported operation.
|
|
395
|
+
*/
|
|
396
|
+
async removeMcpServer(suiteKey, serverKey) {
|
|
397
|
+
await this.mcp.removeServer(suiteKey, serverKey);
|
|
398
|
+
}
|
|
391
399
|
/**
|
|
392
400
|
* Read a service without credential literals, or an unsaved template when
|
|
393
401
|
* `create` is true. The two transports have separate owners, so this facade
|
|
@@ -194,10 +194,7 @@ export async function apply(ctx, config = {
|
|
|
194
194
|
syncTranslationEnabled: () => {
|
|
195
195
|
catalog.syncTranslationEnabled();
|
|
196
196
|
}
|
|
197
|
-
}
|
|
198
|
-
// The cached preference above, not a fresh host read: the translation default
|
|
199
|
-
// follows the language, and this reader is called on every settings read.
|
|
200
|
-
() => localePreference);
|
|
197
|
+
});
|
|
201
198
|
// Legacy surface and entry routes share one committed workspace policy.
|
|
202
199
|
const resourceFilters = new ResourceFilterService(dataRoot, process.cwd(), { onFiltersChanged: onChanged });
|
|
203
200
|
const surfaceToggles = new SurfaceToggleService(resourceFilters);
|
|
@@ -223,6 +220,9 @@ export async function apply(ctx, config = {
|
|
|
223
220
|
mcpRemount: (suiteId, serverKey) => runtime.forceMcpRemount(suiteId, serverKey),
|
|
224
221
|
mcpRemountAll: () => runtime.forceMcpRemountAll(),
|
|
225
222
|
mcpServerOwner: serverName => runtime.mcpServerOwner(serverName),
|
|
223
|
+
// Read per call: the set is rebuilt by every mount pass, so a snapshot here
|
|
224
|
+
// would report the demand of whichever pass happened to run at apply time.
|
|
225
|
+
mcpDemandedMounts: () => runtime.demandedMcpMounts(),
|
|
226
226
|
lspStatusSource: runtime.lsp,
|
|
227
227
|
mcpBackend: () => settings.backend(),
|
|
228
228
|
setMcpBackend: backend => settings.setBackend(backend),
|
|
@@ -5,7 +5,6 @@ export class MarketSettingsNamespace {
|
|
|
5
5
|
dataRoot;
|
|
6
6
|
locale;
|
|
7
7
|
host;
|
|
8
|
-
localePreference;
|
|
9
8
|
refs;
|
|
10
9
|
watchers = [];
|
|
11
10
|
feedbackDisposer;
|
|
@@ -16,18 +15,11 @@ export class MarketSettingsNamespace {
|
|
|
16
15
|
* switch, or skipped because the host has no tools registry to register on.
|
|
17
16
|
*/
|
|
18
17
|
feedbackMountState;
|
|
19
|
-
constructor(ctx, refs, dataRoot, locale, host
|
|
20
|
-
/**
|
|
21
|
-
* The host interface language, read per call. It is a reader rather than a
|
|
22
|
-
* captured string because a language switch must move the translation
|
|
23
|
-
* default without rebuilding this namespace.
|
|
24
|
-
*/
|
|
25
|
-
localePreference) {
|
|
18
|
+
constructor(ctx, refs, dataRoot, locale, host) {
|
|
26
19
|
this.ctx = ctx;
|
|
27
20
|
this.dataRoot = dataRoot;
|
|
28
21
|
this.locale = locale;
|
|
29
22
|
this.host = host;
|
|
30
|
-
this.localePreference = localePreference;
|
|
31
23
|
// A caller may hand in a partial ref set (a test, or a config written
|
|
32
24
|
// before this field existed); an absent switch reads as its default.
|
|
33
25
|
this.refs = {
|
|
@@ -55,10 +47,6 @@ export class MarketSettingsNamespace {
|
|
|
55
47
|
this.syncFeedbackTool();
|
|
56
48
|
});
|
|
57
49
|
this.watchers.push(watcher);
|
|
58
|
-
// A locale edit can change the default without changing this plugin's refs.
|
|
59
|
-
this.watchers.push(this.ctx.on('settings/document-updated', () => {
|
|
60
|
-
previousTranslation = this.syncTranslationProviders(previousTranslation);
|
|
61
|
-
}));
|
|
62
50
|
}
|
|
63
51
|
/** Release every watcher and unmount the feedback tool. */
|
|
64
52
|
dispose() {
|
|
@@ -69,7 +57,7 @@ export class MarketSettingsNamespace {
|
|
|
69
57
|
}
|
|
70
58
|
/**
|
|
71
59
|
* The resolved settings, from the volatile references the host updates in
|
|
72
|
-
* place
|
|
60
|
+
* place. A field whose reference has no value yet takes the contract default.
|
|
73
61
|
*/
|
|
74
62
|
settings() {
|
|
75
63
|
return resolveMarketSettings({
|
|
@@ -80,7 +68,7 @@ export class MarketSettingsNamespace {
|
|
|
80
68
|
autoUpdateSources: this.refs.autoUpdateSources.get(),
|
|
81
69
|
translationEnabled: this.refs.translationEnabled.get(),
|
|
82
70
|
agentPresetsEnabled: this.refs.agentPresetsEnabled.get()
|
|
83
|
-
}
|
|
71
|
+
});
|
|
84
72
|
}
|
|
85
73
|
/** The persisted MCP backend choice; the built-in client until the host applies a value. */
|
|
86
74
|
async backend() {
|
|
@@ -100,7 +88,7 @@ export class MarketSettingsNamespace {
|
|
|
100
88
|
}
|
|
101
89
|
/**
|
|
102
90
|
* Read the live translation switch: the stored preference while the user has
|
|
103
|
-
* one, the
|
|
91
|
+
* one, the plugin default (on) otherwise.
|
|
104
92
|
*/
|
|
105
93
|
translationEnabled() {
|
|
106
94
|
return this.settings().translationEnabled;
|
|
@@ -6,12 +6,12 @@ import { MARKET_SETTINGS_DEFAULTS } from '../../../market-contracts/src/contract
|
|
|
6
6
|
* belong to {@link MARKET_SETTINGS_DEFAULTS}; this schema states them, it does
|
|
7
7
|
* not own them.
|
|
8
8
|
*
|
|
9
|
-
* `translationEnabled` deliberately declares no default.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* the
|
|
14
|
-
*
|
|
9
|
+
* `translationEnabled` deliberately declares no default. A declared default
|
|
10
|
+
* would be materialized into the live config reference, where "the user never
|
|
11
|
+
* set this" and "the user turned it off" would both read the same boolean, and
|
|
12
|
+
* the card could no longer tell the two states apart. With the field absent,
|
|
13
|
+
* the reference answers `undefined` until the user writes it, and
|
|
14
|
+
* {@link resolveMarketSettings} answers the plugin default for that case.
|
|
15
15
|
*/
|
|
16
16
|
export const MarketSettingsFields = {
|
|
17
17
|
mcpEnhanced: z.boolean().default(MARKET_SETTINGS_DEFAULTS.mcpEnhanced),
|
|
@@ -192,6 +192,20 @@ export function mountSuiteRoutes(hostCtx, manager, panels, surfaceToggles, sessi
|
|
|
192
192
|
await manager.addMcpServer(body.name, body.config, body.policy);
|
|
193
193
|
return {};
|
|
194
194
|
});
|
|
195
|
+
// Remove one user-owned MCP server. Addressed by the same source-qualified
|
|
196
|
+
// suite id the status rows carry, so the card names the server it shows. A
|
|
197
|
+
// suite-declared server is refused by the service, not by this parser: the
|
|
198
|
+
// rule lives with the code that owns the declaration file.
|
|
199
|
+
post(MARKET_ROUTES.removeMcpServer, async (body) => {
|
|
200
|
+
const suiteKey = textField(body['suiteId'] ?? '', 'MCP suite id');
|
|
201
|
+
const serverKey = textField(body['serverKey'] ?? '', 'MCP server key');
|
|
202
|
+
if (suiteKey === '')
|
|
203
|
+
throw new Error('missing MCP suite id');
|
|
204
|
+
if (serverKey === '')
|
|
205
|
+
throw new Error('missing MCP server key');
|
|
206
|
+
await manager.removeMcpServer(suiteKey, serverKey);
|
|
207
|
+
return {};
|
|
208
|
+
});
|
|
195
209
|
get(MARKET_ROUTES.lspStatus, async (request, response) => {
|
|
196
210
|
const source = readSource(request);
|
|
197
211
|
if ('error' in source) {
|
|
@@ -141,8 +141,39 @@ export async function archiveInstall(url, dest, options = {}) {
|
|
|
141
141
|
* within fflate's synchronous push recursion overflows the stack); the
|
|
142
142
|
* chunked feed halts at the next slice.
|
|
143
143
|
*/
|
|
144
|
+
/**
|
|
145
|
+
* Entry count declared by a zip's end-of-central-directory record.
|
|
146
|
+
*
|
|
147
|
+
* The stream cannot always reach its per-entry guard: fflate parses a
|
|
148
|
+
* whole-archive central directory recursively, and a zip with tens of thousands
|
|
149
|
+
* of tiny members overflows the stack inside one `push()` before `onfile` can
|
|
150
|
+
* count them. Reading the declared count first refuses such an archive before
|
|
151
|
+
* any decompression, and the streaming guard still covers what the record
|
|
152
|
+
* understates.
|
|
153
|
+
* @param bytes - the complete archive.
|
|
154
|
+
* @returns the declared total entry count, or undefined when unreadable or zip64.
|
|
155
|
+
*/
|
|
156
|
+
function zipDeclaredEntryCount(bytes) {
|
|
157
|
+
const EOCD_SIGNATURE = 0x06_05_4b_50;
|
|
158
|
+
const EOCD_MIN_BYTES = 22;
|
|
159
|
+
const MAX_COMMENT_BYTES = 0xffff;
|
|
160
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
161
|
+
const lowest = Math.max(0, bytes.byteLength - EOCD_MIN_BYTES - MAX_COMMENT_BYTES);
|
|
162
|
+
for (let offset = bytes.byteLength - EOCD_MIN_BYTES; offset >= lowest; offset -= 1) {
|
|
163
|
+
if (view.getUint32(offset, true) !== EOCD_SIGNATURE)
|
|
164
|
+
continue;
|
|
165
|
+
const declared = view.getUint16(offset + 10, true);
|
|
166
|
+
// 0xffff defers to zip64, whose real count lives in a different record.
|
|
167
|
+
return declared === 0xffff ? undefined : declared;
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
144
171
|
async function extractZip(archiveFile, dest) {
|
|
145
172
|
const compressed = await readFile(archiveFile);
|
|
173
|
+
const declared = zipDeclaredEntryCount(compressed);
|
|
174
|
+
if (declared !== undefined && declared > ARCHIVE_MAX_ENTRIES) {
|
|
175
|
+
throw new Error(`archive exceeds the ${ARCHIVE_MAX_ENTRIES} entry limit`);
|
|
176
|
+
}
|
|
146
177
|
let totalBytes = 0;
|
|
147
178
|
let entryCount = 0;
|
|
148
179
|
let failure;
|
|
@@ -33,8 +33,25 @@ function schemasDirectory() {
|
|
|
33
33
|
let directory = dirname(fileURLToPath(import.meta.url));
|
|
34
34
|
while (true) {
|
|
35
35
|
const manifest = join(directory, 'package.json');
|
|
36
|
-
if (existsSync(manifest)
|
|
36
|
+
if (!existsSync(manifest)) {
|
|
37
|
+
const parent = dirname(directory);
|
|
38
|
+
if (parent === directory)
|
|
39
|
+
throw new Error('dsh-agent-plugins-market package root is unavailable');
|
|
40
|
+
directory = parent;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const name = JSON.parse(readFileSync(manifest, 'utf8')).name;
|
|
44
|
+
// The workspace package and the packed artifact expose the library at the
|
|
45
|
+
// same two relative spots: beside the catalog package, or under the
|
|
46
|
+
// packages/ path preserved by the npm files entry.
|
|
47
|
+
if (name === '@dsh-market/catalog')
|
|
37
48
|
return join(directory, 'schemas');
|
|
49
|
+
if (name === 'dsh-agent-plugins-market')
|
|
50
|
+
return join(directory, 'packages/market-catalog/schemas');
|
|
51
|
+
const parentDir = dirname(directory);
|
|
52
|
+
if (parentDir === directory)
|
|
53
|
+
throw new Error('dsh-agent-plugins-market package root is unavailable');
|
|
54
|
+
directory = parentDir;
|
|
38
55
|
const parent = dirname(directory);
|
|
39
56
|
if (parent === directory)
|
|
40
57
|
throw new Error('dsh-agent-plugins-market package root is unavailable');
|
|
@@ -8,6 +8,7 @@ export const MARKET_ROUTES = {
|
|
|
8
8
|
setSurfaceToggle: `${MARKET_API_PREFIX}surface-toggles/set`,
|
|
9
9
|
mcpStatus: `${MARKET_API_PREFIX}mcp-status`,
|
|
10
10
|
addMcpServer: `${MARKET_API_PREFIX}mcp-servers/add`,
|
|
11
|
+
removeMcpServer: `${MARKET_API_PREFIX}mcp-servers/remove`,
|
|
11
12
|
lspStatus: `${MARKET_API_PREFIX}lsp-status`,
|
|
12
13
|
lspServers: `${MARKET_API_PREFIX}lsp-servers`,
|
|
13
14
|
addLspServer: `${MARKET_API_PREFIX}lsp-servers/add`,
|
|
@@ -9,11 +9,10 @@
|
|
|
9
9
|
* resolved values off the settings snapshot it is handed. Changing a default is
|
|
10
10
|
* therefore a one-line edit in this module.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* selects the language of displayed translations, including English.
|
|
12
|
+
* Every default is a plain boolean or word, so this module alone answers a
|
|
13
|
+
* section that says nothing. {@link resolveTranslationTarget} still selects the
|
|
14
|
+
* language translations are written in; that choice is independent of whether
|
|
15
|
+
* translations are displayed.
|
|
17
16
|
*
|
|
18
17
|
* @module contracts/settings
|
|
19
18
|
*/
|
|
@@ -25,14 +24,11 @@ export const MARKET_SETTINGS_NAMESPACE = 'dsh-agent-plugins-market';
|
|
|
25
24
|
* These are user-visible behavior: a flip here is a behavior change that ships
|
|
26
25
|
* with its documentation, not a private implementation detail.
|
|
27
26
|
*
|
|
28
|
-
* `translationEnabled` is the one
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* answers
|
|
32
|
-
*
|
|
33
|
-
* the field at all, an absence that is load-bearing: a schema default would
|
|
34
|
-
* reach the live config reference and erase the difference between "the user
|
|
35
|
-
* never set this" and "the user turned it off".
|
|
27
|
+
* `translationEnabled` is the one field the namespace schema declares no
|
|
28
|
+
* default for. The field stays absent until the user writes it, which is what
|
|
29
|
+
* lets the card tell "never set" from an explicit value, and this constant
|
|
30
|
+
* answers the absent case. A schema default would reach the live config
|
|
31
|
+
* reference and erase that difference.
|
|
36
32
|
*/
|
|
37
33
|
export const MARKET_SETTINGS_DEFAULTS = {
|
|
38
34
|
mcpEnhanced: true,
|
|
@@ -40,13 +36,12 @@ export const MARKET_SETTINGS_DEFAULTS = {
|
|
|
40
36
|
downloadRegion: 'auto',
|
|
41
37
|
feedbackEnabled: true,
|
|
42
38
|
autoUpdateSources: false,
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
agentPresetsEnabled: false
|
|
39
|
+
// On for every interface language: a fresh install translates without
|
|
40
|
+
// visiting the settings page, and a stored false turns it off.
|
|
41
|
+
translationEnabled: true,
|
|
42
|
+
// On by default: the preset manager entry shows without an opt-in step, and a
|
|
43
|
+
// stored false hides it.
|
|
44
|
+
agentPresetsEnabled: true
|
|
50
45
|
};
|
|
51
46
|
/**
|
|
52
47
|
* Narrow an untrusted stored region to a setting; anything else reads as the
|
|
@@ -61,22 +56,6 @@ export function narrowDownloadRegion(value, fallback = MARKET_SETTINGS_DEFAULTS.
|
|
|
61
56
|
function narrowBoolean(value, fallback) {
|
|
62
57
|
return typeof value === 'boolean' ? value : fallback;
|
|
63
58
|
}
|
|
64
|
-
/**
|
|
65
|
-
* Whether one interface language renders the market's Chinese dictionary.
|
|
66
|
-
*
|
|
67
|
-
* This is the host's own rule rather than a second reading of it:
|
|
68
|
-
* `bindHostLocale` (`runtime/host/host-locale.ts`) answers every preference
|
|
69
|
-
* that is not English with the Chinese dictionary, so as far as the interface is
|
|
70
|
-
* concerned everything that is not English is Chinese — an absent preference
|
|
71
|
-
* included, which the host reads as zh.
|
|
72
|
-
*
|
|
73
|
-
* This determines only the default display preference: Chinese starts on and
|
|
74
|
-
* English starts off. It must not gate an explicitly enabled translation.
|
|
75
|
-
* @param localePreference - the host `locale.preference`, or undefined while nothing supplies one.
|
|
76
|
-
*/
|
|
77
|
-
export function interfaceLanguageTranslates(localePreference) {
|
|
78
|
-
return localePreference === undefined || !localePreference.toLowerCase().startsWith('en');
|
|
79
|
-
}
|
|
80
59
|
/**
|
|
81
60
|
* The language the interface renders, as the translation chain names it.
|
|
82
61
|
*
|
|
@@ -109,14 +88,12 @@ export function resolveTranslationTarget(localePreference) {
|
|
|
109
88
|
* function is what keeps a second reading of "what does missing mean" from
|
|
110
89
|
* growing somewhere else.
|
|
111
90
|
*
|
|
112
|
-
* A stored
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* a user who never set the field and leave every other user alone.
|
|
91
|
+
* A stored value always wins: the user's own answer is never overridden by a
|
|
92
|
+
* default. Only a section that says nothing about the field takes the value in
|
|
93
|
+
* {@link MARKET_SETTINGS_DEFAULTS}.
|
|
116
94
|
* @param section - the stored (or absent) namespace section, untrusted.
|
|
117
|
-
* @param localePreference - the host `locale.preference` the language-derived default follows.
|
|
118
95
|
*/
|
|
119
|
-
export function resolveMarketSettings(section
|
|
96
|
+
export function resolveMarketSettings(section) {
|
|
120
97
|
const stored = (typeof section === 'object' && section !== null ? section : {});
|
|
121
98
|
return {
|
|
122
99
|
mcpEnhanced: narrowBoolean(stored['mcpEnhanced'], MARKET_SETTINGS_DEFAULTS.mcpEnhanced),
|
|
@@ -124,7 +101,7 @@ export function resolveMarketSettings(section, localePreference) {
|
|
|
124
101
|
downloadRegion: narrowDownloadRegion(stored['downloadRegion']),
|
|
125
102
|
feedbackEnabled: narrowBoolean(stored['feedbackEnabled'], MARKET_SETTINGS_DEFAULTS.feedbackEnabled),
|
|
126
103
|
autoUpdateSources: narrowBoolean(stored['autoUpdateSources'], MARKET_SETTINGS_DEFAULTS.autoUpdateSources),
|
|
127
|
-
translationEnabled: narrowBoolean(stored['translationEnabled'],
|
|
104
|
+
translationEnabled: narrowBoolean(stored['translationEnabled'], MARKET_SETTINGS_DEFAULTS.translationEnabled),
|
|
128
105
|
agentPresetsEnabled: narrowBoolean(stored['agentPresetsEnabled'], MARKET_SETTINGS_DEFAULTS.agentPresetsEnabled)
|
|
129
106
|
};
|
|
130
107
|
}
|
|
@@ -8,6 +8,7 @@ export const defaultCatalogPorts = {
|
|
|
8
8
|
mcpRemount: () => { },
|
|
9
9
|
mcpRemountAll: () => { },
|
|
10
10
|
mcpServerOwner: () => undefined,
|
|
11
|
+
mcpDemandedMounts: () => [],
|
|
11
12
|
lspStatusSource: { diagnosticsSnapshot: () => new Map(), hasLiveMounts: () => false },
|
|
12
13
|
mcpBackend: async () => 'builtin',
|
|
13
14
|
localePreference: () => 'zh',
|
|
@@ -97,6 +97,34 @@ export async function prepareUserMcpServer(agentsRoot, name, server) {
|
|
|
97
97
|
await validateUserMcp(agentsRoot, document);
|
|
98
98
|
return () => writeJsonDocument(userMcpPath(agentsRoot), document);
|
|
99
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Validate a removal without publishing it. Call the returned atomic write once,
|
|
102
|
+
* within the same serialized mutation, after the plan is settled.
|
|
103
|
+
*
|
|
104
|
+
* The file is re-read rather than rebuilt from the parsed suite: rebuilding
|
|
105
|
+
* would drop top-level keys this client does not model and rewrite the other
|
|
106
|
+
* declarations into the parsed shape, while a removal must change exactly one
|
|
107
|
+
* key. A file the loader rejected is refused here for the same reason creation
|
|
108
|
+
* refuses it — the write would otherwise discard a declaration nothing could
|
|
109
|
+
* read.
|
|
110
|
+
*/
|
|
111
|
+
export async function prepareUserMcpServerRemoval(agentsRoot, name) {
|
|
112
|
+
const suite = await loadUserMcpSuite(agentsRoot);
|
|
113
|
+
if (suite.errors.length > 0)
|
|
114
|
+
throw new Error(suite.errors.join('; '));
|
|
115
|
+
if (!Object.hasOwn(suite.mcp.servers, name))
|
|
116
|
+
throw new Error(`MCP server "${name}" is not declared`);
|
|
117
|
+
const raw = await readJsonFile(userMcpPath(agentsRoot));
|
|
118
|
+
if (!isRecord(raw) || !isRecord(raw['mcpServers']))
|
|
119
|
+
throw new Error('mcp.json is not a JSON object');
|
|
120
|
+
const servers = { ...raw['mcpServers'] };
|
|
121
|
+
delete servers[name];
|
|
122
|
+
const document = { ...raw, mcpServers: servers };
|
|
123
|
+
// The remaining document is validated before the write: a removal must not
|
|
124
|
+
// publish a file the next scan would reject and disable wholesale.
|
|
125
|
+
await validateUserMcp(agentsRoot, document);
|
|
126
|
+
return () => writeJsonDocument(userMcpPath(agentsRoot), document);
|
|
127
|
+
}
|
|
100
128
|
/** The user's hand-written MCP declaration file: `<agentsRoot>/mcp.json`. */
|
|
101
129
|
export function userMcpPath(agentsRoot) {
|
|
102
130
|
return join(agentsRoot, 'mcp.json');
|
|
@@ -29,9 +29,15 @@ export function declaresAuthHeader(config) {
|
|
|
29
29
|
* resolver bound to it, when the deployment translates at all. Resolved once by
|
|
30
30
|
* the caller: a server and each of its tools is localized from this one value,
|
|
31
31
|
* never from a fresh read of the host preference.
|
|
32
|
+
* @param demanded - the suite/server pairs a live session's selection keeps
|
|
33
|
+
* mounted. The mount pass merged them in as `enabled: true`, so a stored
|
|
34
|
+
* `enabled: false` beside live tools has two possible causes: this demand, or
|
|
35
|
+
* a teardown that failed. Only the caller knows the demand, so it travels
|
|
36
|
+
* here; an omitted list means nothing is demanded and every such row keeps
|
|
37
|
+
* reading as an orphan.
|
|
32
38
|
* @returns the status payload with every translatable field carrying its translation.
|
|
33
39
|
*/
|
|
34
|
-
export function buildMcpStatus(suites, diagnostics, observed, overrides = new Map(), localization) {
|
|
40
|
+
export function buildMcpStatus(suites, diagnostics, observed, overrides = new Map(), localization, demanded = []) {
|
|
35
41
|
const entries = [];
|
|
36
42
|
// Counted so a caller can re-read until the translated text lands, exactly as
|
|
37
43
|
// the market overview reports its own pending count.
|
|
@@ -59,6 +65,9 @@ export function buildMcpStatus(suites, diagnostics, observed, overrides = new Ma
|
|
|
59
65
|
}
|
|
60
66
|
}
|
|
61
67
|
const diagnosticsByKey = new Map(diagnostics.map(diagnostic => [`${diagnostic.suiteId}\u0000${diagnostic.serverKey}`, diagnostic]));
|
|
68
|
+
// One demand identity per suite/server, joined the same way diagnostics are:
|
|
69
|
+
// suite ids carry a slash, so the separator cannot be one.
|
|
70
|
+
const demandedKeys = new Set(demanded.map(mount => `${mount.suiteId}\u0000${mount.serverKey}`));
|
|
62
71
|
const observedByServer = groupObservedTools(observed, knownServerNames);
|
|
63
72
|
for (const suite of suites) {
|
|
64
73
|
// This is an operational inventory, not a configuration audit: suite MCP
|
|
@@ -75,7 +84,13 @@ export function buildMcpStatus(suites, diagnostics, observed, overrides = new Ma
|
|
|
75
84
|
// The declaration stays on the inventory when an override disables it:
|
|
76
85
|
// the panel shows what the suite ships and how the user changed it.
|
|
77
86
|
const disabled = !enabled || suite.activeSurfaces.mcp === false;
|
|
78
|
-
|
|
87
|
+
// Live tools under a stored switch-off are the observed truth either way.
|
|
88
|
+
// What differs is the cause: session demand keeps the mount, which is the
|
|
89
|
+
// user's own selection speaking, not a teardown that left registrations
|
|
90
|
+
// behind. A real diagnostic still wins, so a failed mount underneath the
|
|
91
|
+
// demand is never explained away as demand.
|
|
92
|
+
const presetDemand = disabled && tools.length > 0 && diagnostic === undefined && demandedKeys.has(`${suiteKey}\u0000${serverKey}`);
|
|
93
|
+
const orphaned = disabled && tools.length > 0 && !presetDemand;
|
|
79
94
|
// A duplicate copy shares the live serverName, so observed tools land
|
|
80
95
|
// on it too — that does not make it connected: its own diagnostic says
|
|
81
96
|
// another source's mount is the one serving.
|
|
@@ -95,13 +110,24 @@ export function buildMcpStatus(suites, diagnostics, observed, overrides = new Ma
|
|
|
95
110
|
: 'degraded';
|
|
96
111
|
// A diagnostic's reason keeps the code the mount pipeline gave it. The
|
|
97
112
|
// notes this builder adds describe the declaration's own state — an
|
|
98
|
-
// override switched it off, an override rewrote it,
|
|
99
|
-
//
|
|
100
|
-
// for the panel to
|
|
113
|
+
// override switched it off, an override rewrote it, its surface is gone
|
|
114
|
+
// while its tools remain, or the session's own selection keeps it
|
|
115
|
+
// mounted — so each carries the matching state code for the panel to
|
|
116
|
+
// localize.
|
|
101
117
|
const orphanNote = orphaned ? 'MCP tools remain after this plugin surface was disabled' : undefined;
|
|
118
|
+
// The switch was honoured in the configuration and overruled at mount
|
|
119
|
+
// time, so the row says which of the two won. The tools stay listed: they
|
|
120
|
+
// are live, and hiding them would hide the demand too.
|
|
121
|
+
const presetDemandNote = presetDemand
|
|
122
|
+
? 'this service is selected by the active session preset, which keeps it mounted; the panel switch cannot unmount it while that preset is active'
|
|
123
|
+
: undefined;
|
|
102
124
|
const overrideNote = override === undefined || (state !== 'disabled' && tools.length > 0) ? undefined : disabled ? 'disabled by override' : 'modified by override';
|
|
103
|
-
const reason = orphanNote ?? diagnostic?.reason ?? overrideNote;
|
|
104
|
-
const code = orphanNote !== undefined
|
|
125
|
+
const reason = orphanNote ?? presetDemandNote ?? diagnostic?.reason ?? overrideNote;
|
|
126
|
+
const code = orphanNote !== undefined
|
|
127
|
+
? 'orphaned-tools'
|
|
128
|
+
: presetDemandNote !== undefined
|
|
129
|
+
? 'preset-demand'
|
|
130
|
+
: (diagnostic?.code ?? (overrideNote === undefined ? undefined : disabled ? 'disabled-override' : 'modified-override'));
|
|
105
131
|
entries.push({
|
|
106
132
|
id: `plugin:${suiteKey}/${serverKey}`,
|
|
107
133
|
name: serverName,
|
|
@@ -113,7 +139,9 @@ export function buildMcpStatus(suites, diagnostics, observed, overrides = new Ma
|
|
|
113
139
|
transport: effective.type,
|
|
114
140
|
endpoint: endpointOf(effective),
|
|
115
141
|
config: redactMcpConfig(effective),
|
|
116
|
-
|
|
142
|
+
// A demanded server keeps its tools on the row: they are live, and the
|
|
143
|
+
// row says why. Only a plain override-off server has none to show.
|
|
144
|
+
tools: disabled && !orphaned && !presetDemand ? [] : observedTools(tools, serverName, localization === undefined ? undefined : localize),
|
|
117
145
|
...(localization === undefined ? {} : localize(serverName, { name: serverName })),
|
|
118
146
|
advertisedTools: tools.length > 0,
|
|
119
147
|
retryable: diagnostic?.code === 'mount-failed' || diagnostic?.code === 'unmount-failed',
|
|
@@ -10,8 +10,8 @@ import { qualifiedSuiteId } from '../../../market-catalog/src/index.js';
|
|
|
10
10
|
import { isDirectory } from '../../../market-catalog/src/index.js';
|
|
11
11
|
import { discoverSuitesInSource } from '../../../market-catalog/src/index.js';
|
|
12
12
|
import { probeHostMcpClient } from './mcp/mcp-backend.js';
|
|
13
|
-
import { declaredMcpPolicy, namespaceMcpPolicy, resolveMcpPolicy } from './mcp/mcp-config.js';
|
|
14
|
-
import { prepareUserMcpServer, loadUserMcpSuite, USER_MCP_SOURCE, USER_MCP_SUITE } from './mcp/mcp-direct-config.js';
|
|
13
|
+
import { declaredMcpPolicy, deriveServerName, namespaceMcpPolicy, resolveMcpPolicy } from './mcp/mcp-config.js';
|
|
14
|
+
import { prepareUserMcpServer, prepareUserMcpServerRemoval, loadUserMcpSuite, USER_MCP_SOURCE, USER_MCP_SUITE } from './mcp/mcp-direct-config.js';
|
|
15
15
|
import { applyOverride, loadSuiteOverrides, mergeOverridePatch, parseMcpPolicyPatch, saveSuiteOverrides, withoutPolicyFields } from './mcp/mcp-overrides.js';
|
|
16
16
|
import { redactMcpConfig, redactMcpOverrides } from '../../../market-contracts/src/redaction.js';
|
|
17
17
|
import { buildMcpStatus, declaresAuthHeader } from './mcp/mcp-status.js';
|
|
@@ -42,7 +42,11 @@ export class McpService {
|
|
|
42
42
|
// configuration, so resolving it per entity made this status — one MCP server
|
|
43
43
|
// with many tools especially — scale with the tool count.
|
|
44
44
|
const localization = { locale: this.ports.localePreference(), localizeFields: this.localizeFields };
|
|
45
|
-
|
|
45
|
+
// A server this switch turned off can still be mounted by the session's own
|
|
46
|
+
// preset selection. The builder needs the demand to name that cause instead
|
|
47
|
+
// of reporting tools the switch never managed to stop.
|
|
48
|
+
const demanded = this.ports.mcpDemandedMounts?.() ?? [];
|
|
49
|
+
const payload = buildMcpStatus(suites, this.diagnostics, this.ports.mcpToolSnapshot(), await this.allOverrides(suites), localization, demanded);
|
|
46
50
|
for (const entry of payload.entries)
|
|
47
51
|
if (entry.suiteId === `${USER_MCP_SOURCE}/${USER_MCP_SUITE}`) {
|
|
48
52
|
entry.kind = 'direct';
|
|
@@ -105,6 +109,43 @@ export class McpService {
|
|
|
105
109
|
await this.context.notifyChanged(true);
|
|
106
110
|
});
|
|
107
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Remove one user-owned server from the declaration file and drop everything
|
|
114
|
+
* the plugin stored about it: the override record (edited config, enablement,
|
|
115
|
+
* policy) and the OAuth grant. Only the user's own declaration may be removed
|
|
116
|
+
* — a suite's `mcp.json` is source-owned, so a refresh would restore a server
|
|
117
|
+
* deleted from it, and disabling is the supported operation there.
|
|
118
|
+
*
|
|
119
|
+
* The declaration is written first. An interruption can then leave an inert
|
|
120
|
+
* override or grant record behind, never a live server whose stored policy was
|
|
121
|
+
* silently dropped.
|
|
122
|
+
*/
|
|
123
|
+
async removeServer(suiteKey, serverKey) {
|
|
124
|
+
return this.context.enqueue(async () => {
|
|
125
|
+
const { sourceId, suiteId } = splitSuiteKey(suiteKey);
|
|
126
|
+
if (!isUserOwnedMcp(suiteKey))
|
|
127
|
+
throw new Error(`server "${serverKey}" belongs to suite "${suiteKey}"; disable it instead of removing it`);
|
|
128
|
+
const publish = await prepareUserMcpServerRemoval(this.context.agentsRoot, serverKey);
|
|
129
|
+
await publish();
|
|
130
|
+
// The record would otherwise outlive its declaration: it carries the
|
|
131
|
+
// user's edited config and policy, and a later server of the same name
|
|
132
|
+
// would inherit both.
|
|
133
|
+
const overrides = await loadSuiteOverrides(this.context.dataRoot, suiteKey);
|
|
134
|
+
if (Object.hasOwn(overrides, serverKey)) {
|
|
135
|
+
delete overrides[serverKey];
|
|
136
|
+
await saveSuiteOverrides(this.context.dataRoot, suiteKey, overrides);
|
|
137
|
+
}
|
|
138
|
+
// A removed server must not keep its OAuth grant: the bridge folds the
|
|
139
|
+
// record by the same derived mount name a future server could reuse.
|
|
140
|
+
await this.ports.credentialsStore?.deleteGrantRecord(deriveServerName({ sourceId, id: suiteId }, serverKey));
|
|
141
|
+
await this.context.notifyChanged(true);
|
|
142
|
+
// The status surface reads the live tool registry, so an immediate read
|
|
143
|
+
// races the reconciler's teardown and would still show the removed server.
|
|
144
|
+
// Joining the pass here is bounded — one stuck mount costs the request its
|
|
145
|
+
// deadline, never the plugin.
|
|
146
|
+
await this.context.refreshSettled();
|
|
147
|
+
});
|
|
148
|
+
}
|
|
108
149
|
/** Read one MCP service without credential literals, or an unsaved creation template. */
|
|
109
150
|
async serverConfig(id, create = false) {
|
|
110
151
|
if (create) {
|
|
@@ -83,9 +83,11 @@ export function resolveReconnectPolicy(config, path) {
|
|
|
83
83
|
* @param policy - Resolved reconnect policy from {@link resolveReconnectPolicy}.
|
|
84
84
|
* @param credentials - The composition's credential store, when mounted; OAuth
|
|
85
85
|
* state persists only when it is.
|
|
86
|
+
* @param opener - Browser opener for the OAuth human leg; the platform browser
|
|
87
|
+
* by default.
|
|
86
88
|
* @returns Handle with a `ready` promise for startup-await and a `dispose` for teardown.
|
|
87
89
|
*/
|
|
88
|
-
export function startConnection(host, config, policy, credentials) {
|
|
90
|
+
export function startConnection(host, config, policy, credentials, opener) {
|
|
89
91
|
const label = `mcp-client(${config.serverName})`;
|
|
90
92
|
const opts = {
|
|
91
93
|
registrationFailure: 'contain',
|
|
@@ -258,9 +260,33 @@ export function startConnection(host, config, policy, credentials) {
|
|
|
258
260
|
clientClosed = undefined;
|
|
259
261
|
host.logger.error(`${label}: failed generation did not close within ${GENERATION_CLOSE_TIMEOUT_MS}ms — reconnect stopped to avoid overlapping server processes; reload the plugin or restart the Host to retry`);
|
|
260
262
|
}
|
|
263
|
+
/**
|
|
264
|
+
* Retire the generation that raised the `401` and re-establish in place.
|
|
265
|
+
*
|
|
266
|
+
* The SDK aborts that generation's handshake the moment the human leg
|
|
267
|
+
* returns: the initialize that triggered the authorization is gone, so the
|
|
268
|
+
* saved tokens only ever reach the server on a fresh call. Running that
|
|
269
|
+
* call here — instead of scheduling a backoff retry — lets `ready` report
|
|
270
|
+
* the mount's real outcome rather than a failure the caller has to count on
|
|
271
|
+
* a retry to repair after the user already approved.
|
|
272
|
+
*/
|
|
273
|
+
async function reestablishAfterAuthorization() {
|
|
274
|
+
attemptSettled = true;
|
|
275
|
+
client = undefined;
|
|
276
|
+
clientClosed = undefined;
|
|
277
|
+
try {
|
|
278
|
+
await generation.close();
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
/* transport already gone */
|
|
282
|
+
}
|
|
283
|
+
if (disposed)
|
|
284
|
+
return;
|
|
285
|
+
await connectGeneration(false);
|
|
286
|
+
}
|
|
261
287
|
const { transport, oauthProvider } = createTransport(config, credentials, (message) => {
|
|
262
288
|
host.logger.info(message);
|
|
263
|
-
});
|
|
289
|
+
}, opener);
|
|
264
290
|
try {
|
|
265
291
|
await generation.connect(transport, { timeout: opts.startupTimeoutMs });
|
|
266
292
|
if (hasClosed()) {
|
|
@@ -288,32 +314,25 @@ export function startConnection(host, config, policy, credentials) {
|
|
|
288
314
|
attemptSettled = true;
|
|
289
315
|
if (!isCurrent(generation))
|
|
290
316
|
return;
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
297
|
-
// The exchange succeeded inside this generation: finish establishing
|
|
298
|
-
// it (tool sync + connected bookkeeping) instead of respawning.
|
|
299
|
-
try {
|
|
300
|
-
await enqueueSync(generation, startup ? startupOpts : opts);
|
|
301
|
-
}
|
|
302
|
-
catch (syncError) {
|
|
303
|
-
if (isCurrent(generation))
|
|
304
|
-
host.logger.warn(`${label}: tool sync after authorization failed: ${String(syncError)}`);
|
|
305
|
-
await teardownFailedAttempt();
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
if (hasClosed()) {
|
|
317
|
+
// A failed leg leaves nothing to establish. A successful one always
|
|
318
|
+
// needs a fresh generation: the SDK closes this one as soon as the
|
|
319
|
+
// initialize that raised the `401` fails, whether that was the request
|
|
320
|
+
// timeout the human approval outlived or the redirect the leg returns.
|
|
321
|
+
if (!legSettled) {
|
|
309
322
|
generationDown(generation);
|
|
310
323
|
return;
|
|
311
324
|
}
|
|
325
|
+
await reestablishAfterAuthorization();
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
// A leg that finished inside this attempt leaves no pending promise: the
|
|
329
|
+
// SDK aborts the connect that raised the `401` as soon as the browser leg
|
|
330
|
+
// returns. The tokens are saved either way, so no handshake on this
|
|
331
|
+
// generation can carry them.
|
|
332
|
+
if (oauthProvider?.lastBrowserLegOutcome === true) {
|
|
312
333
|
if (!isCurrent(generation))
|
|
313
334
|
return;
|
|
314
|
-
|
|
315
|
-
if (failedAttempts > 0)
|
|
316
|
-
host.logger.info(`${label}: authorized and re-synced tools (attempt ${failedAttempts}/${policy.maxAttempts})`);
|
|
335
|
+
await reestablishAfterAuthorization();
|
|
317
336
|
return;
|
|
318
337
|
}
|
|
319
338
|
await teardownFailedAttempt();
|