feat(plugins): advertise plugin tools on the MCP server

The advertise path, shaped like trip-warnings.mcp.ts: gate on the kill
switch, fan out over providersOf, let a failing provider contribute
nothing rather than fail the surface.

A tool is advertised only if the signed manifest declares it AND the
running build reported implementing it, the same two-sided check
callPlugin makes for exports. The manifest half is what makes the text
auditable: the child composes its loaded payload after onLoad, so without
it a sha256-pinned artifact could change what it publishes on every
restart with no re-consent.

The result envelope is built here, always. The registry returns a
handler's value verbatim and skips output validation without an
outputSchema, so a plugin returning {ok: true, data} would emit a result
with no content key and every conforming client would error. A failed or
timed-out invoke becomes isError rather than a JSON-RPC fault, so the
model can see it and try something else.

Gates in order: kill switch, then demo mode. The plugins domain had no
demo call site before, and the child has none of its own.

The service owns the process-level source rather than PluginRuntimeService
beside the other sinks, because it needs PluginHooks and PluginHooks
injects PluginRuntimeService. It clears the source on destroy, which the
existing setPluginChannelSource forgets to do: a torn-down buildApp()
would otherwise leave a live source closing over a dead runtime and bleed
into the next suite.

mcpToolCapabilities is a parallel reader rather than a capabilityList
generalisation, since that one returns string[]. It re-runs the manifest
parser instead of trusting the stored blob, which was written by whatever
parser was current at install time and whose caps are the security
control.

Two caps not in the brief: MCP_TOOLS_TOTAL_MAX across all plugins, since
8 each times twenty installed plugins would put 160 tools in front of a
model already choosing from ~200. Truncation is logged, because a
truncated surface reads exactly like a plugin that published nothing.

MCPTOOLS-001 to MCPTOOLS-019.
This commit is contained in:
Renzo Beux
2026-08-28 13:47:22 -03:00
committed by Maurice
parent c6027f9e68
commit 21fd04647f
5 changed files with 466 additions and 9 deletions
@@ -20,7 +20,7 @@ import { ForbiddenResource } from './host/rpc-host';
import { removePluginData } from './host/plugin-data.service';
import { isKnownPermission } from './protocol/envelope';
import { discoverPlugins } from './install/discovery';
import { parseJsonText, parseManifest } from './install/manifest';
import { parseJsonText, parseManifest, parseMcpToolCapabilities } from './install/manifest';
import { scanForNativeBinaries } from './install/native-scan';
import { devLinkEnabled, DEV_LINK_SOURCE } from './dev-link';
import { pluginCodeDir, pluginDataDir } from './paths';
@@ -29,7 +29,7 @@ import { hostSatisfies, hostVersion } from './install/host-compat';
import { keyFingerprint } from './signature-status';
import { AuditService } from '../audit/audit.service';
import { AddonsService } from '../addons/addons.service';
import type { PluginDependency } from './install/manifest';
import type { McpToolCapability, PluginDependency } from './install/manifest';
import type { VersionMismatch, PluginDepRow } from './dependencies';
import { parseDependencies, disabledRequiredAddons, resolveDependencyState, enableOrder, findDependentsTransitive, DependencyCycleError } from './dependencies';
@@ -1147,6 +1147,30 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
}
/** A plugin's declared `capabilities.provides`/`capabilities.emits` (from the DB). */
/**
* A plugin's declared `capabilities.mcpTools`, re-validated.
*
* capabilityList below cannot carry these: it returns string[]. And the blob
* is re-parsed rather than JSON.parse'd straight through, because it was
* written by whatever version of parseCapabilities was current at install
* time and the caps in it are the security control.
*
* Public, unlike capabilityList, because PluginMcpToolsService reads it.
*/
mcpToolCapabilities(id: string): McpToolCapability[] {
const row = this.db.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(id) as { capabilities: string } | undefined;
if (!row) return [];
try {
const c = JSON.parse(row.capabilities || '{}') as Record<string, unknown>;
if (c.mcpTools === undefined) return [];
return parseMcpToolCapabilities(c.mcpTools);
} catch {
// A malformed or now-invalid blob means "advertises nothing", never a throw
// on the session-creation path.
return [];
}
}
private capabilityList(id: string, field: 'provides' | 'emits'): string[] {
const row = this.db.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(id) as { capabilities: string } | undefined;
if (!row) return [];