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.
Files changed (78) hide show
  1. package/README.md +6 -6
  2. package/README.zh.md +6 -6
  3. package/client/client.js +339 -223
  4. package/client/client.js.map +1 -1
  5. package/lib/packages/market-bundle/src/application/catalog.js +8 -0
  6. package/lib/packages/market-bundle/src/index.js +4 -4
  7. package/lib/packages/market-bundle/src/platform/settings-namespace.js +4 -16
  8. package/lib/packages/market-bundle/src/platform/settings-schema.js +6 -6
  9. package/lib/packages/market-bundle/src/routes.js +14 -0
  10. package/lib/packages/market-catalog/src/scanning/archive.js +31 -0
  11. package/lib/packages/market-catalog/src/scanning/validate.js +18 -1
  12. package/lib/packages/market-contracts/src/contracts/market.js +1 -0
  13. package/lib/packages/market-contracts/src/contracts/settings.js +20 -43
  14. package/lib/packages/market-contracts/src/ports/ports.js +1 -0
  15. package/lib/packages/market-mcp/src/application/mcp/mcp-direct-config.js +28 -0
  16. package/lib/packages/market-mcp/src/application/mcp/mcp-status.js +36 -8
  17. package/lib/packages/market-mcp/src/application/mcp-service.js +44 -3
  18. package/lib/packages/market-mcp/src/runtime/mcp/bridge/connection.js +42 -23
  19. package/lib/packages/market-mcp/src/runtime/mcp/bridge/oauth.js +29 -2
  20. package/lib/packages/market-mcp/src/runtime/mcp/bridge/transport.js +8 -5
  21. package/lib/packages/market-runtime/src/runtime/agents/agent-role-router.js +2 -1
  22. package/lib/packages/market-runtime/src/runtime/agents/subagent-seam.js +29 -0
  23. package/lib/packages/market-runtime/src/runtime/agents/teammate-role-runtime.js +8 -2
  24. package/lib/packages/market-runtime/src/runtime/core/reconciler.js +12 -0
  25. package/lib/packages/market-runtime/src/runtime/host/scoped-contributors.js +4 -0
  26. package/lib/packages/market-runtime/src/runtime/surfaces/project-runtime.js +4 -1
  27. package/lib/types/packages/market-bundle/src/application/catalog.d.ts +6 -0
  28. package/lib/types/packages/market-bundle/src/index.d.ts +1 -1
  29. package/lib/types/packages/market-bundle/src/platform/settings-namespace.d.ts +4 -18
  30. package/lib/types/packages/market-bundle/src/platform/settings-schema.d.ts +6 -6
  31. package/lib/types/packages/market-contracts/src/contracts/market.d.ts +1 -0
  32. package/lib/types/packages/market-contracts/src/contracts/mcp-status.d.ts +16 -0
  33. package/lib/types/packages/market-contracts/src/contracts/settings.d.ts +15 -39
  34. package/lib/types/packages/market-contracts/src/ports/ports.d.ts +11 -0
  35. package/lib/types/packages/market-contracts/src/ports/queries.d.ts +2 -0
  36. package/lib/types/packages/market-mcp/src/application/mcp/mcp-direct-config.d.ts +12 -0
  37. package/lib/types/packages/market-mcp/src/application/mcp/mcp-status.d.ts +8 -2
  38. package/lib/types/packages/market-mcp/src/application/mcp-service.d.ts +12 -0
  39. package/lib/types/packages/market-mcp/src/runtime/mcp/bridge/connection.d.ts +4 -1
  40. package/lib/types/packages/market-mcp/src/runtime/mcp/bridge/oauth.d.ts +14 -0
  41. package/lib/types/packages/market-mcp/src/runtime/mcp/bridge/transport.d.ts +5 -1
  42. package/lib/types/packages/market-runtime/src/runtime/agents/agent-role-router.d.ts +2 -10
  43. package/lib/types/packages/market-runtime/src/runtime/agents/subagent-seam.d.ts +63 -0
  44. package/lib/types/packages/market-runtime/src/runtime/core/reconciler.d.ts +13 -0
  45. package/package.json +67 -62
  46. package/schemas/1.0.0/mcp.schema.json +0 -120
  47. package/schemas/1.0.0/plugin.schema.json +0 -65
  48. package/schemas/1.1.0/mcp.schema.json +0 -120
  49. package/schemas/1.1.0/plugin.schema.json +0 -65
  50. package/schemas/README.md +0 -79
  51. package/schemas/agent-plugins/spec.md +0 -62
  52. package/schemas/claude-code/marketplace.schema.json +0 -143
  53. package/schemas/claude-code/plugin.schema.json +0 -105
  54. package/schemas/claude-code/spec.md +0 -108
  55. package/schemas/codex/marketplace.schema.json +0 -78
  56. package/schemas/codex/plugin.schema.json +0 -77
  57. package/schemas/codex/spec.md +0 -91
  58. package/schemas/com.deepseek.harness/namespace.schema.json +0 -42
  59. package/schemas/com.deepseek.harness/spec.md +0 -52
  60. package/schemas/cursor/marketplace.schema.json +0 -82
  61. package/schemas/cursor/plugin.schema.json +0 -58
  62. package/schemas/cursor/spec.md +0 -92
  63. package/schemas/github-copilot/marketplace.schema.json +0 -107
  64. package/schemas/github-copilot/plugin.schema.json +0 -89
  65. package/schemas/github-copilot/spec.md +0 -105
  66. package/schemas/kimi/marketplace.schema.json +0 -38
  67. package/schemas/kimi/plugin.schema.json +0 -84
  68. package/schemas/kimi/spec.md +0 -94
  69. package/schemas/qoder/marketplace.schema.json +0 -115
  70. package/schemas/qoder/plugin.schema.json +0 -127
  71. package/schemas/qoder/spec.md +0 -111
  72. package/schemas/skill-collection/spec.md +0 -37
  73. package/schemas/universal/marketplace.schema.json +0 -98
  74. package/schemas/universal/plugin.schema.json +0 -49
  75. package/schemas/universal/spec.md +0 -53
  76. package/schemas/zcode/marketplace.schema.json +0 -161
  77. package/schemas/zcode/plugin.schema.json +0 -89
  78. 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 and the interface language they resolve over.
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
- }, this.localePreference());
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 interface language's default otherwise.
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. Its default follows
10
- * the interface language, and a declared default would be materialized into the
11
- * live config reference, where "the user never set this" and "the user turned it
12
- * off" would both read `false` — the distinction the derivation rests on. With
13
- * the field absent, the reference answers `undefined` until the user writes it,
14
- * and {@link resolveMarketSettings} fills the language's own default in.
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) && JSON.parse(readFileSync(manifest, 'utf8')).name === 'dsh-agent-plugins-market')
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
- * One default is not a constant: `translationEnabled` follows the interface
13
- * language, so its reader takes the language as an argument and
14
- * {@link interfaceLanguageTranslates} answers that default only. An explicit
15
- * preference is independent of language. {@link resolveTranslationTarget}
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 exception: the interface language decides it
29
- * (see {@link interfaceLanguageTranslates}), so this constant is not where its
30
- * effective default lives. It is what the browser store holds before the host
31
- * answers, and that pre-answer state deliberately withholds the translation
32
- * control rather than flashing it. The namespace schema declares no default for
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
- // The browser-side value before the effective one is known — not the
44
- // effective default, which interfaceLanguageTranslates derives from the
45
- // interface language. Off here keeps a fresh install from spending a provider
46
- // request before the host has answered.
47
- translationEnabled: false,
48
- // Experimental capability: the manager stays hidden until the user opts in.
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 boolean always wins: the user's own answer is never overridden by
113
- * the language. Only a section that says nothing about the field takes the
114
- * language's answer, which is what makes a language switch move the default for
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, localePreference) {
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'], interfaceLanguageTranslates(localePreference)),
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
- const orphaned = disabled && tools.length > 0;
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, or its surface is
99
- // gone while its tools remain — so each carries the matching state code
100
- // for the panel to localize.
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 ? 'orphaned-tools' : (diagnostic?.code ?? (overrideNote === undefined ? undefined : disabled ? 'disabled-override' : 'modified-override'));
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
- tools: disabled && !orphaned ? [] : observedTools(tools, serverName, localization === undefined ? undefined : localize),
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
- const payload = buildMcpStatus(suites, this.diagnostics, this.ports.mcpToolSnapshot(), await this.allOverrides(suites), localization);
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
- // The leg failed, or the transport closed while it ran: no generation
292
- // is left to finish establishing.
293
- if (hasClosed() || !legSettled) {
294
- generationDown(generation);
295
- return;
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
- connectedAt = Date.now();
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();