feat(plugins): capabilities.mcpTools in the manifest

Tools are declared in the signed manifest, not only reported at load.
plugin-host-entry composes its loaded payload AFTER onLoad runs, so a
signed, sha256-pinned artifact could otherwise emit different tool text on
every restart with no version bump and no re-consent. The host will
advertise the intersection of the two.

It also gives the admin something to read. What mcp:tools will put in
front of every user is a manifest field now, so it can be shown on the
consent screen before the grant is given.

Rejects rather than drops, unlike most capability parsing: a malformed
tool should fail the install visibly, not become a tool that silently
never appears once the plugin is running. Caps and sanitising come from
mcp-tool-schema.ts, so the install path and the advertise path cannot
disagree about them.

Declaring tools without mcp:tools is an error on both sides. Otherwise
the consent screen lists tools that can never run.

No migration: install/discovery.ts already persists capabilities as JSON.

The SDK validator mirrors the checks it can make without the server's
normaliser, so `trek-plugin validate` catches a bad declaration before
publish rather than at install.
This commit is contained in:
Renzo Beux
2026-08-28 13:40:31 -03:00
committed by Maurice
parent 41de91668b
commit 29de8dd9c1
3 changed files with 167 additions and 1 deletions
+41
View File
@@ -207,6 +207,7 @@ export function validateManifest(raw: unknown): ValidationResult {
tripPage?: { replaces?: unknown; position?: unknown };
notificationChannel?: { title?: unknown; events?: unknown };
routeProfiles?: unknown;
mcpTools?: unknown;
provides?: unknown;
emits?: unknown;
settingsUi?: unknown;
@@ -284,6 +285,46 @@ export function validateManifest(raw: unknown): ValidationResult {
}
}
}
// MCP tools go into every user's assistant context, so the declaration is
// checked here too rather than only at install: an author should hear about a
// malformed one from `trek-plugin validate`, not from a tool that never shows up.
const mcpTools = capabilities?.mcpTools;
if (mcpTools !== undefined) {
if (!permissions.includes('mcp:tools')) {
errors.push('capabilities.mcpTools requires the "mcp:tools" permission');
}
if (!Array.isArray(mcpTools)) errors.push('capabilities.mcpTools must be an array');
else {
if (mcpTools.length > 8) errors.push('capabilities.mcpTools: at most 8 tools');
const seenTools = new Set<string>();
for (const v of mcpTools) {
if (!v || typeof v !== 'object' || Array.isArray(v)) {
errors.push('capabilities.mcpTools entries must be objects');
continue;
}
const t = v as Record<string, unknown>;
const name = typeof t.name === 'string' ? t.name : '';
if (!/^[a-z0-9_]{1,48}$/.test(name)) {
errors.push('capabilities.mcpTools: name must be lowercase [a-z0-9_], max 48 chars');
continue;
}
if (seenTools.has(name)) errors.push(`capabilities.mcpTools: duplicate name "${name}"`);
seenTools.add(name);
if (typeof t.description !== 'string' || !t.description.trim()) {
errors.push(`capabilities.mcpTools["${name}"]: description is required`);
}
if (t.inputSchema !== undefined) {
const schema = t.inputSchema as Record<string, unknown> | null;
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
errors.push(`capabilities.mcpTools["${name}"]: inputSchema must be an object`);
} else if (schema.type !== undefined && schema.type !== 'object') {
errors.push(`capabilities.mcpTools["${name}"]: inputSchema root type must be "object"`);
}
}
}
}
}
// Settings keys become JSON object keys in the plugin's stored config, so they are
// constrained (mirrors the server's SETTING_KEY_RE). `__proto__`/`constructor` would
// resolve off Object.prototype on read and make a required field look configured for