This commit is contained in:
Oleg Maslov
2026-09-02 10:14:22 +02:00
parent 0c3e2ead3b
commit b20b138fe4
771 changed files with 161561 additions and 9027 deletions

View File

@@ -24,11 +24,11 @@
"clean": "rm -rf dist/"
},
"dependencies": {
"better-sqlite3": "^11.0.0"
"better-sqlite3": "^12.6.2"
},
"optionalDependencies": {
"mcp-guardian": "^2.4.0",
"adm-zip": "^0.5.16"
"adm-zip": "^0.6.0",
"mcp-guardian": "^2.4.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.0",

View File

@@ -42,6 +42,7 @@ export { ENTERPRISE_PACKS } from './enterprise-packs.js';
export type { EnterprisePack } from './enterprise-packs.js';
export { MCP_SERVERS, seedMcpServers } from './mcp-registry.js';
export type { McpServerEntry } from './mcp-registry.js';
export { createMarketplaceMcpProvenance } from './install-security.js';
export { PACKAGE_CATEGORIES, categorizePackage, recategorizeAll } from './categories.js';
export type { PackageCategoryId } from './categories.js';
@@ -53,9 +54,11 @@ export type {
InstallManifest,
PluginManifest,
McpServerConfig,
MarketplaceMcpProvenance,
SettingField,
PostInstallHook,
InstallationType,
MarketplaceApprovalIdentity,
InstallRequest,
InstallResult,
PackInstallResult,

View File

@@ -0,0 +1,564 @@
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, realpathSync } from 'node:fs';
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { MCP_SERVERS } from './mcp-registry.js';
import type {
InstallationType,
InstallManifest,
MarketplaceMcpProvenance,
MarketplaceSource,
McpServerConfig,
} from './types.js';
export interface InstallCommandOptions {
cwd?: string;
timeout: number;
}
export interface CommandInvocation {
executable: string;
args: string[];
}
function isStrictlyInside(root: string, target: string): boolean {
const rel = relative(root, target);
return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
}
function deepestExistingAncestor(target: string): string {
let current = target;
while (!existsSync(current)) {
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
return current;
}
/** Resolve an install target while preventing traversal and symlink/junction escapes. */
export function resolveManagedInstallPath(root: string, candidate: string): string {
if (!candidate || candidate.includes('\0')) {
throw new Error('Install path must be a non-empty filesystem path.');
}
const resolvedRoot = resolve(root);
const resolvedTarget = isAbsolute(candidate)
? resolve(candidate)
: resolve(resolvedRoot, candidate);
if (!isStrictlyInside(resolvedRoot, resolvedTarget)) {
throw new Error(`Install path escapes managed directory: ${candidate}`);
}
const relativeTarget = relative(resolvedRoot, resolvedTarget);
if (process.platform === 'win32' && relativeTarget.split(/[\\/]/).some(part => part.includes(':'))) {
throw new Error(`Install path contains a Windows alternate data stream: ${candidate}`);
}
const canonicalRoot = realpathSync.native(resolvedRoot);
const canonicalAncestor = realpathSync.native(deepestExistingAncestor(resolvedTarget));
if (canonicalAncestor !== canonicalRoot && !isStrictlyInside(canonicalRoot, canonicalAncestor)) {
throw new Error(`Install path escapes managed directory through a symlink: ${candidate}`);
}
return resolvedTarget;
}
const REGISTRY_PACKAGE_SPEC = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*(?:@(?:[~^]?[a-z0-9*][a-z0-9._~+*^-]*))?$/i;
const GITHUB_SHORTHAND_SPEC = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*(?:#[a-z0-9][a-z0-9._/-]*)?$/i;
const FORBIDDEN_GIT_URL_CHARS = new Set([';', '&', '|', '`', '$', '<', '>', '(', ')', '{', '}', '[', ']', '"', "'", '\\', '^', '!']);
const PROCESS_CONTROL_ENV_KEYS = new Set([
'PATH',
'PATHEXT',
'COMSPEC',
'SHELL',
'SYSTEMROOT',
'WINDIR',
'HOME',
'USERPROFILE',
'HOMEDRIVE',
'HOMEPATH',
'APPDATA',
'LOCALAPPDATA',
'PROGRAMDATA',
'TEMP',
'TMP',
'TMPDIR',
'NODE_OPTIONS',
'NODE_PATH',
'NODE_EXTRA_CA_CERTS',
'NPM_EXECPATH',
'NPM_NODE_EXECPATH',
'BASH_ENV',
'ENV',
'PYTHONPATH',
'PYTHONHOME',
'PYTHONSTARTUP',
'PYTHONUSERBASE',
'PYTHONPYCACHEPREFIX',
'VIRTUAL_ENV',
'RUBYOPT',
'PERL5OPT',
'PSMODULEPATH',
'JAVA_TOOL_OPTIONS',
'HTTP_PROXY',
'HTTPS_PROXY',
'ALL_PROXY',
'NO_PROXY',
'SSL_CERT_FILE',
'SSL_CERT_DIR',
]);
const PROCESS_CONTROL_ENV_PREFIXES = [
'NPM_CONFIG_',
'UV_',
'PIP_',
'XDG_',
'GIT_',
'LD_',
'DYLD_',
'BASH_FUNC_',
];
interface ApprovedMarketplaceMcpProfile {
packageName: string;
packageVersion: string;
name: string;
command: string;
args: string[];
envKeys: string[];
env: Record<string, string>;
npmPackage: string;
}
/**
* Marketplace MCP execution is a curated capability, not a generic package
* runner. Build the allowlist from the same registry entries seeded into the
* marketplace so a database row cannot substitute an interpreter or
* meta-launcher while retaining a syntactically valid package name.
*/
const APPROVED_MARKETPLACE_MCP_PROFILES: ApprovedMarketplaceMcpProfile[] = MCP_SERVERS.flatMap((server) => {
const manifest = server.install_manifest;
const config = manifest?.mcp_config;
return config && manifest?.npm_package && server.version
? [{
packageName: server.name,
packageVersion: server.version,
name: config.name,
command: config.command,
args: [...config.args],
envKeys: Object.keys(config.env ?? {}).sort(),
env: { ...config.env },
npmPackage: manifest.npm_package,
}]
: [];
});
/** Allow registry packages and the owner/repo shorthand produced by GitHub sync. */
export function assertSafeNpmPackageSpec(spec: string): string {
if (!REGISTRY_PACKAGE_SPEC.test(spec) && !GITHUB_SHORTHAND_SPEC.test(spec)) {
throw new Error(`Unsupported npm package spec: ${spec}`);
}
return spec;
}
export function assertNoNpmArgs(args: string[] | undefined): void {
if (args && args.length > 0) {
throw new Error('Marketplace npm_args are not permitted.');
}
}
function assertStringArguments(args: unknown): asserts args is string[] {
if (!Array.isArray(args) || args.some(arg => typeof arg !== 'string')) {
throw new Error('Marketplace MCP launcher arguments must be an array of strings.');
}
}
function assertSafeMarketplaceEnvironment(env: unknown): void {
if (env === undefined) return;
if (!env || typeof env !== 'object' || Array.isArray(env)) {
throw new Error('Marketplace MCP environment must be a string map.');
}
for (const [key, value] of Object.entries(env)) {
if (typeof value !== 'string') {
throw new Error(`Marketplace MCP environment value for ${key} must be a string.`);
}
const normalized = key.toUpperCase();
if (
PROCESS_CONTROL_ENV_KEYS.has(normalized)
|| PROCESS_CONTROL_ENV_PREFIXES.some(prefix => normalized.startsWith(prefix))
) {
throw new Error(`Marketplace MCP environment key is not permitted: ${key}`);
}
}
}
/**
* Accept only an exact curated launcher profile. Matching the complete argv
* supplies a package-specific argument policy and prevents approved runners
* (`npx`/`uvx`) from being repurposed with interpreter or meta-launcher
* packages such as `npm`, `node`, or `python`.
*/
function approvedMarketplaceMcpProfile(config: McpServerConfig): ApprovedMarketplaceMcpProfile {
if (!config || typeof config !== 'object' || typeof config.name !== 'string' || !config.name.trim()) {
throw new Error('Marketplace MCP configuration must have a non-empty name.');
}
if (typeof config.command !== 'string') {
throw new Error('Marketplace MCP launcher command must be a string.');
}
assertStringArguments(config.args);
if (config.command !== 'npx' && config.command !== 'uvx') {
throw new Error(`Unsupported marketplace MCP launcher command: ${config.command}`);
}
assertSafeMarketplaceEnvironment(config.env);
const profile = APPROVED_MARKETPLACE_MCP_PROFILES.find(candidate => (
candidate.name === config.name
&& candidate.command === config.command
&& candidate.args.length === config.args.length
&& candidate.args.every((arg, index) => arg === config.args[index])
));
if (!profile) {
throw new Error('Marketplace MCP launcher is not an exact approved catalog profile.');
}
const envKeys = Object.keys(config.env ?? {}).sort();
if (
envKeys.length !== profile.envKeys.length
|| envKeys.some((key, index) => key !== profile.envKeys[index])
) {
throw new Error(`Marketplace MCP environment keys do not match the approved catalog profile: ${config.name}`);
}
return profile;
}
/** Preflight requires the exact catalog template, including env values. */
export function assertSafeMarketplaceMcpConfig(config: McpServerConfig): string {
const profile = approvedMarketplaceMcpProfile(config);
for (const envKey of profile.envKeys) {
if (config.env?.[envKey] !== profile.env[envKey]) {
throw new Error(`Marketplace MCP environment template does not match the approved catalog profile: ${config.name}`);
}
}
return profile.npmPackage;
}
/**
* Bind an approved launcher profile to the built-in catalog row that supplied
* it. Database-local IDs and environment values are intentionally excluded so
* the receipt is portable and never becomes another secret store.
*/
export function createMarketplaceMcpProvenance(
source: Pick<MarketplaceSource, 'name' | 'source_type' | 'is_custom'> | null,
pkg: { name: string; version: string },
config: McpServerConfig,
): MarketplaceMcpProvenance {
if (
!source
|| source.name !== 'mcp_registry'
|| source.source_type !== 'registry'
|| Boolean(source.is_custom)
) {
throw new Error('Marketplace MCP package must come from the canonical mcp_registry source.');
}
const npmPackage = assertSafeMarketplaceMcpConfig(config);
const profile = approvedMarketplaceMcpProfile(config);
if (pkg.name !== profile.packageName) {
throw new Error('Marketplace MCP package name does not match its approved catalog profile.');
}
if (pkg.version !== profile.packageVersion) {
throw new Error('Marketplace MCP package version does not match its approved catalog profile.');
}
const digestInput = {
schemaVersion: 1,
sourceName: source.name,
packageName: pkg.name,
packageVersion: pkg.version,
npmPackage,
serverName: profile.name,
command: profile.command,
args: [...profile.args],
envKeys: [...profile.envKeys],
};
const profileDigest = `sha256:${createHash('sha256')
.update(JSON.stringify(digestInput), 'utf8')
.digest('hex')}` as const;
return {
kind: 'marketplace',
schemaVersion: 1,
sourceName: 'mcp_registry',
packageName: pkg.name,
packageVersion: pkg.version,
npmPackage,
profileDigest,
};
}
function resolvedMarketplaceMcpEnvironment(
source: Record<string, string> | undefined,
settings: Record<string, string> | undefined,
): Record<string, string> | undefined {
if (!source) return undefined;
const resolved = { ...source };
for (const envKey of Object.keys(source)) {
const template = source[envKey];
for (const [settingKey, value] of Object.entries(settings ?? {})) {
if (template === `\${${settingKey}}` || (template === '' && envKey === settingKey)) {
resolved[envKey] = value;
}
}
}
return resolved;
}
/**
* Sink check for a configured profile. Values may differ from the catalog
* template only where the exact request setting key (or named placeholder)
* authorizes that replacement.
*/
export function assertSafeConfiguredMarketplaceMcpConfig(
configured: McpServerConfig,
source: McpServerConfig,
settings: Record<string, string> | undefined,
): void {
assertSafeMarketplaceMcpConfig(source);
const profile = approvedMarketplaceMcpProfile(configured);
const expectedEnv = resolvedMarketplaceMcpEnvironment(source.env, settings) ?? {};
for (const envKey of profile.envKeys) {
if (configured.env?.[envKey] !== expectedEnv[envKey]) {
throw new Error(`Configured marketplace MCP environment was not derived from approved settings: ${configured.name}`);
}
}
}
/** Clone and configure an approved profile without mutating catalog metadata. */
export function configureMarketplaceMcpServer(
source: McpServerConfig,
settings: Record<string, string> | undefined,
): McpServerConfig {
assertSafeMarketplaceMcpConfig(source);
if (
settings !== undefined
&& (!settings || typeof settings !== 'object' || Array.isArray(settings)
|| Object.values(settings).some(value => typeof value !== 'string'))
) {
throw new Error('Marketplace MCP settings must be a string map.');
}
const configured: McpServerConfig = {
...source,
args: [...source.args],
...(source.env && { env: resolvedMarketplaceMcpEnvironment(source.env, settings) }),
};
assertSafeConfiguredMarketplaceMcpConfig(configured, source, settings);
return configured;
}
function assertNoMarketplacePostInstallHooks(hooks: unknown): void {
if (hooks === undefined) return;
if (!Array.isArray(hooks)) {
throw new Error('Marketplace post_install must be an array.');
}
if (hooks.length > 0) {
throw new Error('Marketplace post-install hooks are not permitted.');
}
}
const APPROVED_PLUGIN_MANIFEST_FIELDS = new Set([
'name',
'version',
'description',
'skills',
'mcpServers',
'settingsSchema',
]);
function assertSafePluginManifest(pluginManifest: unknown): void {
if (pluginManifest === undefined) return;
if (!pluginManifest || typeof pluginManifest !== 'object' || Array.isArray(pluginManifest)) {
throw new Error('Marketplace plugin manifest must be an object.');
}
for (const key of Object.keys(pluginManifest)) {
if (!APPROVED_PLUGIN_MANIFEST_FIELDS.has(key)) {
throw new Error(`Marketplace plugin manifest field is not permitted: ${key}`);
}
}
const manifest = pluginManifest as Record<string, unknown>;
for (const key of ['name', 'version']) {
if (typeof manifest[key] !== 'string' || !(manifest[key] as string).trim()) {
throw new Error(`Marketplace plugin manifest ${key} must be a non-empty string.`);
}
}
if (typeof manifest.description !== 'string') {
throw new Error('Marketplace plugin manifest description must be a string.');
}
if (
manifest.skills !== undefined
&& (!Array.isArray(manifest.skills) || manifest.skills.some(skill => typeof skill !== 'string'))
) {
throw new Error('Marketplace plugin bundled skills must be an array of names.');
}
}
/** Validate every manifest field that can later influence process execution. */
export function assertSafeMarketplaceInstallManifest(
installType: InstallationType,
manifest: InstallManifest | null,
): void {
if (manifest === null) {
if (installType === 'mcp') {
throw new Error('Marketplace MCP package is missing its install manifest.');
}
return;
}
if (typeof manifest !== 'object' || Array.isArray(manifest)) {
throw new Error('Marketplace install manifest must be an object.');
}
if (manifest.npm_package !== undefined) {
if (typeof manifest.npm_package !== 'string') {
throw new Error('Marketplace npm package spec must be a string.');
}
assertSafeNpmPackageSpec(manifest.npm_package);
}
assertNoNpmArgs(manifest.npm_args);
assertNoMarketplacePostInstallHooks(manifest.post_install);
if (installType === 'plugin') {
if (manifest.git_url !== undefined) {
throw new Error('Uncurated marketplace plugin git provenance is not permitted.');
}
if (manifest.npm_package !== undefined) {
throw new Error('Uncurated marketplace plugin npm provenance is not permitted.');
}
assertSafePluginManifest(manifest.plugin_manifest);
const bundledSkills = manifest.plugin_manifest?.skills;
if (bundledSkills && bundledSkills.length > 0) {
throw new Error('Marketplace plugin bundled-skill provenance is not verified; automatic fetch is disabled.');
}
}
if (installType === 'mcp' && !manifest.mcp_config) {
throw new Error('Marketplace MCP package is missing mcp_config.');
}
let approvedMcpPackage: string | undefined;
if (manifest.mcp_config) {
approvedMcpPackage = assertSafeMarketplaceMcpConfig(manifest.mcp_config);
}
if (installType === 'mcp' && manifest.npm_package !== approvedMcpPackage) {
throw new Error('Marketplace MCP npm package does not match its exact approved catalog profile.');
}
const pluginServers = manifest.plugin_manifest?.mcpServers;
if (pluginServers !== undefined) {
if (!Array.isArray(pluginServers)) {
throw new Error('Marketplace plugin MCP servers must be an array.');
}
for (const config of pluginServers) {
assertSafeMarketplaceMcpConfig(config);
}
}
}
/** Git installs accept only credential-free HTTPS URLs, never local/ext transports. */
export function assertSafeGitUrl(value: string): string {
const hasForbiddenCharacter = [...value].some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint <= 0x20 || codePoint === 0x7f || FORBIDDEN_GIT_URL_CHARS.has(character);
});
if (hasForbiddenCharacter) {
throw new Error(`Unsupported plugin git URL: ${value}`);
}
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error(`Unsupported plugin git URL: ${value}`);
}
if (parsed.protocol !== 'https:' || !parsed.hostname || parsed.username || parsed.password) {
throw new Error(`Unsupported plugin git URL: ${value}`);
}
return value;
}
function envValue(env: NodeJS.ProcessEnv, name: string): string | undefined {
return Object.entries(env).find(([key]) => key.toUpperCase() === name)?.[1];
}
function npmShimTarget(content: string): string | null {
const npmCli = content.match(/SET\s+"NPM_CLI_JS=%~dp0[\\/]+([^"]+)"/i);
if (npmCli?.[1]) return npmCli[1];
const packageShim = content.match(/"%_prog%"\s+"%dp0%[\\/]+([^"]+)"/i);
return packageShim?.[1] ?? null;
}
/** Resolve npm without routing untrusted argv through cmd.exe on Windows. */
export function resolveNpmInvocation(
args: readonly string[],
platform: NodeJS.Platform = process.platform,
env: NodeJS.ProcessEnv = process.env,
): CommandInvocation {
if (platform !== 'win32') {
return { executable: 'npm', args: [...args] };
}
const npmExecPath = envValue(env, 'NPM_EXECPATH');
if (npmExecPath && /(?:^|[\\/])npm-cli\.js$/i.test(npmExecPath) && existsSync(npmExecPath)) {
return { executable: process.execPath, args: [npmExecPath, ...args] };
}
const pathValue = envValue(env, 'PATH') ?? '';
for (const rawDir of pathValue.split(';')) {
const directory = rawDir.trim().replace(/^"(.*)"$/, '$1');
if (!directory) continue;
for (const filename of ['npm.exe', 'npm.com']) {
const executable = join(directory, filename);
if (existsSync(executable)) return { executable, args: [...args] };
}
const shimPath = join(directory, 'npm.cmd');
if (!existsSync(shimPath)) continue;
const target = npmShimTarget(readFileSync(shimPath, 'utf8'));
if (!target) {
throw new Error(`Cannot safely resolve npm command shim: ${shimPath}`);
}
const npmCliPath = resolve(directory, target);
if (!existsSync(npmCliPath)) {
throw new Error(`npm CLI entrypoint does not exist: ${npmCliPath}`);
}
const siblingNode = join(directory, 'node.exe');
return {
executable: existsSync(siblingNode) ? siblingNode : process.execPath,
args: [npmCliPath, ...args],
};
}
throw new Error('npm is unavailable or cannot be resolved without a command shell.');
}
export function runMarketplaceInstallCommand(
command: 'git' | 'npm',
args: readonly string[],
options: InstallCommandOptions,
): void {
const invocation = command === 'npm'
? resolveNpmInvocation(args)
: { executable: command, args: [...args] };
execFileSync(invocation.executable, invocation.args, {
cwd: options.cwd,
stdio: 'pipe',
timeout: options.timeout,
windowsHide: true,
});
}

View File

@@ -13,17 +13,24 @@
* Write plugin.json manifest, copy skill files, register in registry.json.
* Then call POST /api/plugins/install if server is running.
*
* MCP: Add server config to .mcp.json (or bundle inside a plugin).
* Optionally install npm package via npx.
* MCP: Add an exact curated npx/uvx server config to .mcp.json.
*/
import { existsSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, rmSync } from 'fs';
import { join, dirname } from 'path';
import { join, dirname, resolve } from 'path';
import { homedir } from 'os';
import { execSync } from 'child_process';
import { createHash } from 'node:crypto';
import { MarketplaceDB } from './db.js';
import { SecurityGate, type ScanResult, type SecurityGateConfig } from './security.js';
import { type FetchFn, defaultFetch } from './fetcher.js';
import {
assertSafeConfiguredMarketplaceMcpConfig,
assertSafeMarketplaceInstallManifest,
assertSafeMarketplaceMcpConfig,
configureMarketplaceMcpServer,
createMarketplaceMcpProvenance,
resolveManagedInstallPath,
} from './install-security.js';
import type {
MarketplacePackage,
InstallManifest,
@@ -31,15 +38,49 @@ import type {
InstallResult,
PackInstallResult,
InstallationType,
MarketplaceMcpProvenance,
MarketplaceApprovalIdentity,
McpServerConfig,
PluginManifest,
PostInstallHook,
} from './types.js';
const WAGGLE_DIR = join(homedir(), '.waggle');
const SKILLS_DIR = join(WAGGLE_DIR, 'skills');
const PLUGINS_DIR = join(WAGGLE_DIR, 'plugins');
const REGISTRY_PATH = join(PLUGINS_DIR, 'registry.json');
function stableJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
if (value && typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>)
.sort(([a], [b]) => a.localeCompare(b));
return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`).join(',')}}`;
}
return JSON.stringify(value) ?? 'null';
}
function identityChangedResult(
pkg: MarketplacePackage,
installType: InstallationType,
): InstallResult {
const message = 'Marketplace package changed after approval; review a fresh proposal';
return {
success: false,
packageId: pkg.id,
packageName: pkg.name,
installType,
installPath: pkg.waggle_install_path,
message,
errors: [message],
errorCode: 'PACKAGE_IDENTITY_CHANGED',
};
}
function isPluginRegistryPath(candidate: string): boolean {
// Reserve the same namespace on every platform; Windows aliases path casing.
return resolve(candidate).toLowerCase() === resolve(REGISTRY_PATH).toLowerCase();
}
// UX-Refactor Phase 4 (C4): the sidecar boot loader reads <dataDir>/.mcp.json
// (dataDir = WAGGLE_DATA_DIR or ~/.waggle — see server local/mcp-config.ts).
// This previously wrote to process.cwd(), a file nothing ever read.
@@ -57,6 +98,7 @@ interface McpConfigEntry {
command: string;
args: string[];
env?: Record<string, string>;
provenance?: MarketplaceMcpProvenance;
}
/** Shape of the `.mcp.json` config file we read/write. */
@@ -78,6 +120,96 @@ export class MarketplaceInstaller {
this.ensureDirectories();
}
static configureMcpServer(
source: McpServerConfig,
settings?: Record<string, string>,
): McpServerConfig {
return configureMarketplaceMcpServer(source, settings);
}
static mcpProvenanceMatches(
actual: MarketplaceMcpProvenance,
expected: MarketplaceMcpProvenance,
): boolean {
return actual.kind === expected.kind
&& actual.schemaVersion === expected.schemaVersion
&& actual.sourceName === expected.sourceName
&& actual.packageName === expected.packageName
&& actual.packageVersion === expected.packageVersion
&& actual.npmPackage === expected.npmPackage
&& actual.profileDigest === expected.profileDigest;
}
static createApprovalIdentity(
pkg: MarketplacePackage,
scan: ScanResult,
): MarketplaceApprovalIdentity {
const digest = (value: unknown): `sha256:${string}` => `sha256:${createHash('sha256')
.update(stableJson(value))
.digest('hex')}`;
const riskSnapshot = {
status: scan.overall_severity,
score: scan.security_score,
blocked: scan.blocked,
contentHash: scan.content_hash,
engines: scan.engines_used,
findings: scan.findings,
};
return {
schemaVersion: 1,
packageId: pkg.id,
sourceId: pkg.source_id,
name: pkg.name,
publisher: pkg.author,
version: pkg.version,
installType: pkg.waggle_install_type,
manifestDigest: digest(pkg.install_manifest ?? null),
riskStatus: scan.overall_severity,
riskScore: scan.security_score,
riskContentHash: scan.content_hash,
riskBlocked: scan.blocked,
riskDigest: digest(riskSnapshot),
};
}
private static approvalPackageMatches(
pkg: MarketplacePackage,
requestedPackageId: number,
expected: MarketplaceApprovalIdentity,
): boolean {
const digest = `sha256:${createHash('sha256')
.update(stableJson(pkg.install_manifest ?? null))
.digest('hex')}`;
return expected.schemaVersion === 1
&& requestedPackageId === expected.packageId
&& pkg.id === expected.packageId
&& pkg.source_id === expected.sourceId
&& pkg.name === expected.name
&& pkg.author === expected.publisher
&& pkg.version === expected.version
&& pkg.waggle_install_type === expected.installType
&& digest === expected.manifestDigest;
}
private static approvalIdentityMatches(
actual: MarketplaceApprovalIdentity,
expected: MarketplaceApprovalIdentity,
): boolean {
return actual.schemaVersion === expected.schemaVersion
&& actual.packageId === expected.packageId
&& actual.sourceId === expected.sourceId
&& actual.name === expected.name
&& actual.publisher === expected.publisher
&& actual.version === expected.version
&& actual.installType === expected.installType
&& actual.manifestDigest === expected.manifestDigest
&& actual.riskStatus === expected.riskStatus
&& actual.riskScore === expected.riskScore
&& actual.riskContentHash === expected.riskContentHash
&& actual.riskBlocked === expected.riskBlocked
&& actual.riskDigest === expected.riskDigest;
}
// ─── Public API ───────────────────────────────────────────────────
/**
@@ -97,8 +229,108 @@ export class MarketplaceInstaller {
};
}
const installType = pkg.waggle_install_type as InstallationType;
const approvalIdentity = request.expectedApprovalIdentity;
if (approvalIdentity && !MarketplaceInstaller.approvalPackageMatches(
pkg,
request.packageId,
approvalIdentity,
)) {
return identityChangedResult(pkg, installType);
}
if (request.expectedInstallType && installType !== request.expectedInstallType) {
const error = `Expected ${request.expectedInstallType} package but installer loaded ${installType}`;
return {
success: false,
packageId: pkg.id,
packageName: pkg.name,
installType,
installPath: pkg.waggle_install_path,
message: error,
errors: [error],
};
}
if (request.installPath !== undefined) {
return {
success: false,
packageId: pkg.id,
packageName: pkg.name,
installType,
installPath: request.installPath,
message: 'Custom install paths are not supported. Marketplace packages install only to Waggle-managed destinations.',
errors: ['Custom install paths are not supported'],
};
}
try {
assertSafeMarketplaceInstallManifest(
installType,
pkg.install_manifest as InstallManifest | null,
);
} catch (err) {
const error = (err as Error).message;
return {
success: false,
packageId: pkg.id,
packageName: pkg.name,
installType,
installPath: pkg.waggle_install_path,
message: `Rejected marketplace manifest: ${error}`,
errors: [error],
};
}
let mcpProvenance: MarketplaceMcpProvenance | undefined;
if (installType === 'mcp') {
const manifest = pkg.install_manifest as InstallManifest;
try {
mcpProvenance = createMarketplaceMcpProvenance(
this.db.getSource(pkg.source_id),
{ name: pkg.name, version: pkg.version },
manifest.mcp_config!,
);
} catch (err) {
const provenanceError = (err as Error).message;
const identityChanged = request.expectedMcpProvenance !== undefined;
const error = identityChanged
? 'Marketplace MCP package changed during installation; retry from the refreshed catalog'
: provenanceError;
return {
success: false,
packageId: pkg.id,
packageName: pkg.name,
installType,
installPath: pkg.waggle_install_path,
message: identityChanged ? error : `Rejected marketplace MCP provenance: ${error}`,
errors: [identityChanged ? `${error}: ${provenanceError}` : error],
...(identityChanged && { errorCode: 'PACKAGE_IDENTITY_CHANGED' as const }),
};
}
}
if (
request.expectedMcpProvenance
&& (!mcpProvenance || !MarketplaceInstaller.mcpProvenanceMatches(
mcpProvenance,
request.expectedMcpProvenance,
))
) {
const error = 'Marketplace MCP package changed during installation; retry from the refreshed catalog';
return {
success: false,
packageId: pkg.id,
packageName: pkg.name,
installType,
installPath: pkg.waggle_install_path,
message: error,
errors: [error],
errorCode: 'PACKAGE_IDENTITY_CHANGED',
};
}
// Check if already installed
if (!request.force && this.db.isInstalled(pkg.id)) {
const wasInstalled = this.db.isInstalled(pkg.id);
if (installType !== 'mcp' && !request.force && wasInstalled && !approvalIdentity) {
return {
success: true,
packageId: pkg.id,
@@ -119,8 +351,26 @@ export class MarketplaceInstaller {
}
const scanResult = await this.security.scan(pkg, contentToScan);
if (approvalIdentity) {
const currentIdentity = MarketplaceInstaller.createApprovalIdentity(pkg, scanResult);
if (!MarketplaceInstaller.approvalIdentityMatches(currentIdentity, approvalIdentity)) {
return identityChangedResult(pkg, installType);
}
}
this.recordScanResult(pkg.id, scanResult);
if (installType !== 'mcp' && !request.force && wasInstalled) {
return {
success: true,
packageId: pkg.id,
packageName: pkg.name,
installType,
installPath: pkg.waggle_install_path,
message: `${pkg.display_name} is already installed. Use force=true to reinstall.`,
scanResult,
};
}
if (scanResult.blocked && !request.forceInsecure) {
return {
success: false,
@@ -136,18 +386,17 @@ export class MarketplaceInstaller {
// ─── END SECURITY GATE ─────────────────────────────────────
// Dispatch to type-specific installer
const installType = pkg.waggle_install_type as InstallationType;
let result: InstallResult;
switch (installType) {
case 'skill':
result = await this.installSkill(pkg, request);
result = await this.installSkill(pkg, request, contentToScan);
break;
case 'plugin':
result = await this.installPlugin(pkg, request);
break;
case 'mcp':
result = await this.installMcp(pkg, request);
result = await this.installMcp(pkg, request, mcpProvenance!);
break;
default:
result = {
@@ -170,12 +419,14 @@ export class MarketplaceInstaller {
const settingKeys = Object.fromEntries(
Object.keys(request.settings ?? {}).map((k) => [k, '[redacted]']),
);
this.db.recordInstallation(
pkg.id,
pkg.version,
result.installPath,
settingKeys,
);
if (!(installType === 'mcp' && wasInstalled)) {
this.db.recordInstallation(
pkg.id,
pkg.version,
result.installPath,
settingKeys,
);
}
// Attach scan result to install result
result.scanResult = scanResult;
}
@@ -311,28 +562,23 @@ export class MarketplaceInstaller {
// ─── Skill Installation ───────────────────────────────────────────
private async installSkill(pkg: MarketplacePackage, request: InstallRequest): Promise<InstallResult> {
private async installSkill(
pkg: MarketplacePackage,
request: InstallRequest,
scannedContent: string | undefined,
): Promise<InstallResult> {
const skillName = pkg.name;
const installPath = request.installPath || join(SKILLS_DIR, `${skillName}.md`);
const manifest = pkg.install_manifest as InstallManifest | null;
let installPath = request.installPath || '';
try {
let content: string;
if (manifest?.skill_content) {
// Inline content from database
content = manifest.skill_content;
} else if (manifest?.skill_url) {
// Fetch from URL (GitHub raw, ClawHub API, etc.)
content = await this.fetchContent(manifest.skill_url);
} else if (pkg.repository_url) {
// Try to fetch SKILL.md from repository
const rawUrl = this.githubRawUrl(pkg.repository_url, 'SKILL.md');
content = await this.fetchContent(rawUrl);
} else {
// Generate a stub skill file from package metadata
content = this.generateSkillStub(pkg);
installPath = resolveManagedInstallPath(SKILLS_DIR, request.installPath || `${skillName}.md`);
if (existsSync(installPath) && !request.force) {
throw new Error(`Skill destination already exists: ${installPath}`);
}
if (scannedContent === undefined) {
throw new Error('Unable to resolve the exact skill content for security scanning.');
}
const content = scannedContent;
// Ensure skills directory exists
mkdirSync(dirname(installPath), { recursive: true });
@@ -371,56 +617,42 @@ export class MarketplaceInstaller {
private async installPlugin(pkg: MarketplacePackage, request: InstallRequest): Promise<InstallResult> {
const pluginName = pkg.name;
const pluginDir = request.installPath || join(PLUGINS_DIR, pluginName);
let pluginDir = '';
let createdPluginDir = false;
const manifest = pkg.install_manifest as InstallManifest | null;
try {
mkdirSync(pluginDir, { recursive: true });
// Step 1: Clone repo, install npm package, or create from metadata
if (manifest?.git_url) {
execSync(`git clone --depth 1 ${manifest.git_url} ${pluginDir}`, {
stdio: 'pipe',
timeout: 60_000,
});
} else if (manifest?.npm_package) {
// Install npm package into plugin directory
try {
writeFileSync(join(pluginDir, 'package.json'), JSON.stringify({ name: pluginName, private: true }), 'utf-8');
execSync(`npm install ${manifest.npm_package} --save`, {
cwd: pluginDir,
stdio: 'pipe',
timeout: 120_000,
});
} catch {
// npm install failed — continue with metadata-only plugin
pluginDir = resolveManagedInstallPath(PLUGINS_DIR, request.installPath || pluginName);
if (isPluginRegistryPath(pluginDir)) {
throw new Error('Plugin destination conflicts with the marketplace registry');
}
if (existsSync(pluginDir)) {
if (!request.force) {
throw new Error(`Plugin destination already exists: ${pluginDir}`);
}
} else {
mkdirSync(pluginDir, { recursive: true });
createdPluginDir = true;
}
// Step 2: Write plugin.json
const pluginManifest: PluginManifest = manifest?.plugin_manifest || {
// Step 1: Write plugin.json from preflight-validated metadata.
const sourcePluginManifest: PluginManifest = manifest?.plugin_manifest || {
name: pluginName,
version: pkg.version,
description: pkg.description,
skills: [],
mcpServers: [],
};
// Apply user settings to the manifest
if (request.settings && pluginManifest.settingsSchema) {
for (const [key, value] of Object.entries(request.settings)) {
// Inject settings into MCP server env vars
pluginManifest.mcpServers?.forEach(server => {
if (server.env) {
for (const envKey of Object.keys(server.env)) {
if (server.env[envKey] === `\${${key}}`) {
server.env[envKey] = value;
}
}
}
});
}
}
const pluginSettings = sourcePluginManifest.settingsSchema ? request.settings : undefined;
const pluginManifest: PluginManifest = {
...sourcePluginManifest,
...(sourcePluginManifest.skills && { skills: [...sourcePluginManifest.skills] }),
...(sourcePluginManifest.mcpServers && {
mcpServers: sourcePluginManifest.mcpServers.map(server => (
configureMarketplaceMcpServer(server, pluginSettings)
)),
}),
};
writeFileSync(
join(pluginDir, 'plugin.json'),
@@ -428,38 +660,10 @@ export class MarketplaceInstaller {
'utf-8',
);
// Step 3: Install bundled skills
if (pluginManifest.skills && pluginManifest.skills.length > 0) {
const skillsDir = join(pluginDir, 'skills');
mkdirSync(skillsDir, { recursive: true });
for (const skillName of pluginManifest.skills) {
const skillPath = join(skillsDir, `${skillName}.md`);
if (!existsSync(skillPath)) {
// Try to find the skill in marketplace and install it into the plugin
const skillPkg = this.db.getPackageByName(skillName);
if (skillPkg?.install_manifest) {
const skillManifest = skillPkg.install_manifest as InstallManifest;
if (skillManifest.skill_url) {
const content = await this.fetchContent(skillManifest.skill_url);
writeFileSync(skillPath, content, 'utf-8');
}
}
}
}
}
// Step 4: Update registry.json
// Step 2: Update registry.json
this.updatePluginRegistry(pluginName, pluginManifest);
// Step 5: Run post-install hooks
if (manifest?.post_install) {
for (const hook of manifest.post_install) {
await this.runPostInstallHook(hook, pluginDir);
}
}
// Step 6: Notify server
// Step 3: Notify server
await this.notifyServer('POST', '/api/plugins/install', {
path: pluginDir,
});
@@ -474,7 +678,7 @@ export class MarketplaceInstaller {
};
} catch (err) {
// Clean up on failure
if (existsSync(pluginDir)) {
if (createdPluginDir && pluginDir && existsSync(pluginDir)) {
rmSync(pluginDir, { recursive: true, force: true });
}
return {
@@ -491,7 +695,11 @@ export class MarketplaceInstaller {
// ─── MCP Server Installation ──────────────────────────────────────
private async installMcp(pkg: MarketplacePackage, request: InstallRequest): Promise<InstallResult> {
private async installMcp(
pkg: MarketplacePackage,
request: InstallRequest,
provenance: MarketplaceMcpProvenance,
): Promise<InstallResult> {
const manifest = pkg.install_manifest as InstallManifest | null;
const mcpConfig = manifest?.mcp_config;
@@ -508,29 +716,15 @@ export class MarketplaceInstaller {
}
try {
// Step 1: Install npm package if needed
if (manifest?.npm_package) {
const args = manifest.npm_args?.join(' ') || '';
execSync(`npm install -g ${manifest.npm_package} ${args}`, {
stdio: 'pipe',
timeout: 120_000,
});
}
assertSafeMarketplaceMcpConfig(mcpConfig);
// Step 2: Apply user settings to env vars
const serverConfig = { ...mcpConfig };
if (request.settings && serverConfig.env) {
for (const [key, value] of Object.entries(request.settings)) {
for (const envKey of Object.keys(serverConfig.env)) {
if (serverConfig.env[envKey] === `\${${key}}` || serverConfig.env[envKey] === '') {
serverConfig.env[envKey] = value;
}
}
}
}
// Step 1: Apply user settings to the exact matching env vars. npx/uvx
// resolves the curated package when the MCP process starts; installation
// must not execute package lifecycle scripts.
const serverConfig = configureMarketplaceMcpServer(mcpConfig, request.settings);
// Step 3: Update .mcp.json
this.updateMcpConfig(serverConfig);
// Step 2: Update .mcp.json
this.updateMcpConfig(serverConfig, mcpConfig, request.settings, provenance);
return {
success: true,
@@ -539,6 +733,13 @@ export class MarketplaceInstaller {
installType: 'mcp',
installPath: mcpConfigPath(),
message: `MCP server "${pkg.display_name}" added to ${mcpConfigPath()}`,
mcpSourceConfig: {
name: mcpConfig.name,
command: mcpConfig.command,
args: [...mcpConfig.args],
...(mcpConfig.env && { env: { ...mcpConfig.env } }),
},
mcpProvenance: provenance,
};
} catch (err) {
return {
@@ -556,7 +757,7 @@ export class MarketplaceInstaller {
// ─── Uninstallation ───────────────────────────────────────────────
private async uninstallSkill(pkg: MarketplacePackage): Promise<void> {
const skillPath = join(SKILLS_DIR, `${pkg.name}.md`);
const skillPath = resolveManagedInstallPath(SKILLS_DIR, `${pkg.name}.md`);
if (existsSync(skillPath)) {
rmSync(skillPath);
}
@@ -564,7 +765,7 @@ export class MarketplaceInstaller {
}
private async uninstallPlugin(pkg: MarketplacePackage): Promise<void> {
const pluginDir = join(PLUGINS_DIR, pkg.name);
const pluginDir = resolveManagedInstallPath(PLUGINS_DIR, pkg.name);
if (existsSync(pluginDir)) {
rmSync(pluginDir, { recursive: true, force: true });
}
@@ -597,20 +798,21 @@ export class MarketplaceInstaller {
}
if (pkg.waggle_install_type === 'mcp') {
// For MCPs, "content" is the config + description for scanning
return JSON.stringify({
name: manifest?.mcp_config?.name || pkg.name,
name: pkg.name,
description: pkg.description,
args: manifest?.mcp_config?.args || [],
env: manifest?.mcp_config?.env || {},
install_type: pkg.waggle_install_type,
install_manifest: manifest,
});
}
if (pkg.waggle_install_type === 'plugin') {
// For plugins, return the manifest as content
if (manifest?.plugin_manifest) {
return JSON.stringify(manifest.plugin_manifest);
}
return JSON.stringify({
name: pkg.name,
description: pkg.description,
install_type: pkg.waggle_install_type,
install_manifest: manifest,
});
}
return undefined;
@@ -728,7 +930,13 @@ This skill was installed from the marketplace. Configure or extend it as needed
writeFileSync(REGISTRY_PATH, JSON.stringify(registry, null, 2), 'utf-8');
}
private updateMcpConfig(serverConfig: McpServerConfig): void {
private updateMcpConfig(
serverConfig: McpServerConfig,
sourceConfig: McpServerConfig,
settings: Record<string, string> | undefined,
provenance: MarketplaceMcpProvenance,
): void {
assertSafeConfiguredMarketplaceMcpConfig(serverConfig, sourceConfig, settings);
let mcpJson: McpConfigFile = { mcpServers: {} };
if (existsSync(mcpConfigPath())) {
mcpJson = JSON.parse(readFileSync(mcpConfigPath(), 'utf-8')) as McpConfigFile;
@@ -737,6 +945,7 @@ This skill was installed from the marketplace. Configure or extend it as needed
command: serverConfig.command,
args: serverConfig.args,
...(serverConfig.env && { env: serverConfig.env }),
provenance,
};
writeFileSync(mcpConfigPath(), JSON.stringify(mcpJson, null, 2), 'utf-8');
}
@@ -748,26 +957,6 @@ This skill was installed from the marketplace. Configure or extend it as needed
writeFileSync(mcpConfigPath(), JSON.stringify(mcpJson, null, 2), 'utf-8');
}
private async runPostInstallHook(hook: PostInstallHook, cwd: string): Promise<void> {
switch (hook.type) {
case 'run_command':
if (hook.command) {
execSync(hook.command, { cwd, stdio: 'pipe', timeout: 30_000 });
}
break;
case 'create_file':
if (hook.path && hook.content) {
const fullPath = join(cwd, hook.path);
mkdirSync(dirname(fullPath), { recursive: true });
writeFileSync(fullPath, hook.content, 'utf-8');
}
break;
case 'append_config':
// Append to workspace config
break;
}
}
private async notifyServer(method: string, path: string, body?: unknown): Promise<void> {
try {
await fetch(`${API_BASE}${path}`, {

View File

@@ -1,19 +1,16 @@
/**
* Waggle Marketplace — MCP Server Registry
* Waggle Marketplace — curated local-stdio MCP registry.
*
* Seed data for well-known MCP servers from the official ecosystem.
* These entries are inserted into the marketplace DB on initialization,
* making popular MCP servers immediately discoverable and installable.
*
* All npm package names and configurations reference real, published
* packages from the MCP ecosystem.
* This file is an executable allowlist, not the broad discovery catalog. Every
* entry must name a maintained upstream package, pin the exact package version
* in both metadata and argv, and disable npm lifecycle scripts. Remote/OAuth,
* archived, path-sensitive, and community-only servers stay discovery-only
* until Waggle has the matching transport or configuration boundary.
*/
import type { MarketplacePackage } from './types.js';
import type { MarketplaceDB } from './db.js';
// ─── Source ID Management ────────────────────────────────────────────
const MCP_REGISTRY_SOURCE = {
name: 'mcp_registry',
display_name: 'MCP Server Registry',
@@ -23,17 +20,12 @@ const MCP_REGISTRY_SOURCE = {
total_packages: 0,
install_method: 'npm' as const,
api_endpoint: null,
description: 'Official and community MCP servers curated for Waggle',
description: 'Verified local-stdio MCP servers curated for Waggle',
};
/**
* Ensure the MCP Registry source exists in the DB.
* Returns the source_id to use for package inserts.
*/
/** Ensure the canonical MCP Registry source exists and return its source id. */
function ensureMcpSource(db: MarketplaceDB): number {
// Access the underlying better-sqlite3 instance
const rawDb = db.getRawDb();
const existing = rawDb
.prepare('SELECT id FROM sources WHERE name = ?')
.get(MCP_REGISTRY_SOURCE.name) as { id: number } | undefined;
@@ -60,8 +52,6 @@ function ensureMcpSource(db: MarketplaceDB): number {
return result.lastInsertRowid as number;
}
// ─── MCP Server Definitions ─────────────────────────────────────────
export type McpServerEntry = Omit<
Partial<MarketplacePackage>,
'id' | 'source_id' | 'created_at' | 'updated_at'
@@ -72,690 +62,250 @@ export type McpServerEntry = Omit<
};
/**
* Well-known MCP servers from the official ecosystem.
*
* Organized by category:
* - developer-tools: filesystem, git, github, sqlite, postgres
* - web: brave-search, fetch, puppeteer
* - productivity: google-drive, slack, notion, gmail
* - knowledge: memory, everything, sequential-thinking
* - data: google-sheets, airtable
* Maintained local-stdio profiles verified against official registries on
* 2026-07-19. Exact argv is part of the security boundary consumed by
* install-security.ts.
*/
export const MCP_SERVERS: McpServerEntry[] = [
// ── Developer Tools ─────────────────────────────────────────────
{
name: 'filesystem',
display_name: 'File System',
description:
'Read, write, search, and manage files and directories on the local filesystem with configurable access controls',
author: 'Anthropic',
package_type: 'mcp_server',
waggle_install_type: 'mcp',
waggle_install_path: '.mcp.json',
version: '0.6.2',
license: 'MIT',
repository_url: 'https://github.com/modelcontextprotocol/servers',
homepage_url:
'https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem',
downloads: 85000,
stars: 15000,
rating: 4.8,
rating_count: 420,
category: 'developer-tools',
subcategory: 'file-management',
platforms: ['claude_code', 'waggle', 'cursor', 'windsurf'],
dependencies: [],
packs: ['developer'],
install_manifest: {
npm_package: '@modelcontextprotocol/server-filesystem',
mcp_config: {
name: 'filesystem',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', '/home/user/projects'],
},
},
},
{
name: 'git',
display_name: 'Git',
description:
'Read, search, and analyze Git repositories including diffs, logs, branches, and file history',
author: 'Anthropic',
package_type: 'mcp_server',
waggle_install_type: 'mcp',
waggle_install_path: '.mcp.json',
version: '0.6.2',
license: 'MIT',
repository_url: 'https://github.com/modelcontextprotocol/servers',
homepage_url:
'https://github.com/modelcontextprotocol/servers/tree/main/src/git',
downloads: 62000,
stars: 15000,
rating: 4.7,
rating_count: 310,
category: 'developer-tools',
subcategory: 'version-control',
platforms: ['claude_code', 'waggle', 'cursor', 'windsurf'],
dependencies: [],
packs: ['developer'],
install_manifest: {
npm_package: 'mcp-server-git',
mcp_config: {
name: 'git',
command: 'uvx',
args: ['mcp-server-git'],
},
},
},
{
name: 'github',
display_name: 'GitHub',
description:
'Interact with GitHub repositories, issues, pull requests, branches, and files via the GitHub API',
author: 'GitHub',
package_type: 'mcp_server',
waggle_install_type: 'mcp',
waggle_install_path: '.mcp.json',
version: '0.6.2',
license: 'MIT',
repository_url: 'https://github.com/modelcontextprotocol/servers',
homepage_url:
'https://github.com/modelcontextprotocol/servers/tree/main/src/github',
downloads: 78000,
stars: 15000,
rating: 4.8,
rating_count: 385,
category: 'developer-tools',
subcategory: 'version-control',
platforms: ['claude_code', 'waggle', 'cursor', 'windsurf'],
dependencies: [],
packs: ['developer'],
install_manifest: {
npm_package: '@modelcontextprotocol/server-github',
mcp_config: {
name: 'github',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-github'],
env: { GITHUB_PERSONAL_ACCESS_TOKEN: '' },
},
},
},
{
name: 'sqlite',
display_name: 'SQLite',
description:
'Query and manage SQLite databases with read/write access, schema inspection, and business intelligence capabilities',
author: 'Anthropic',
package_type: 'mcp_server',
waggle_install_type: 'mcp',
waggle_install_path: '.mcp.json',
version: '0.6.2',
license: 'MIT',
repository_url: 'https://github.com/modelcontextprotocol/servers',
homepage_url:
'https://github.com/modelcontextprotocol/servers/tree/main/src/sqlite',
downloads: 41000,
stars: 15000,
rating: 4.6,
rating_count: 198,
category: 'developer-tools',
subcategory: 'database',
platforms: ['claude_code', 'waggle', 'cursor', 'windsurf'],
dependencies: [],
packs: ['developer', 'data_scientist'],
install_manifest: {
npm_package: 'mcp-server-sqlite',
mcp_config: {
name: 'sqlite',
command: 'uvx',
args: ['mcp-server-sqlite', '--db-path', '/path/to/database.db'],
},
},
},
{
name: 'postgres',
display_name: 'PostgreSQL',
description:
'Connect to PostgreSQL databases for schema inspection, read-only queries, and data analysis',
author: 'Anthropic',
package_type: 'mcp_server',
waggle_install_type: 'mcp',
waggle_install_path: '.mcp.json',
version: '0.6.2',
license: 'MIT',
repository_url: 'https://github.com/modelcontextprotocol/servers',
homepage_url:
'https://github.com/modelcontextprotocol/servers/tree/main/src/postgres',
downloads: 38000,
stars: 15000,
rating: 4.5,
rating_count: 176,
category: 'developer-tools',
subcategory: 'database',
platforms: ['claude_code', 'waggle', 'cursor', 'windsurf'],
dependencies: [],
packs: ['developer', 'data_scientist'],
install_manifest: {
npm_package: 'mcp-server-postgres',
mcp_config: {
name: 'postgres',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-postgres', 'postgresql://localhost/mydb'],
},
},
},
// ── Web ─────────────────────────────────────────────────────────
{
name: 'brave-search',
display_name: 'Brave Search',
description:
'Search the web and get local results using the Brave Search API with web and local search capabilities',
author: 'Anthropic',
package_type: 'mcp_server',
waggle_install_type: 'mcp',
waggle_install_path: '.mcp.json',
version: '0.6.2',
license: 'MIT',
repository_url: 'https://github.com/modelcontextprotocol/servers',
homepage_url:
'https://github.com/modelcontextprotocol/servers/tree/main/src/brave-search',
downloads: 54000,
stars: 15000,
rating: 4.7,
rating_count: 265,
category: 'web',
subcategory: 'search',
platforms: ['claude_code', 'waggle', 'cursor', 'windsurf'],
dependencies: [],
packs: ['research_analyst', 'content_operator'],
install_manifest: {
npm_package: '@modelcontextprotocol/server-brave-search',
mcp_config: {
name: 'brave-search',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-brave-search'],
env: { BRAVE_API_KEY: '' },
},
},
},
{
name: 'fetch',
display_name: 'Fetch',
description:
'Fetch and extract content from web URLs, converting HTML to markdown for easy consumption by AI agents',
author: 'Anthropic',
package_type: 'mcp_server',
waggle_install_type: 'mcp',
waggle_install_path: '.mcp.json',
version: '0.6.2',
license: 'MIT',
repository_url: 'https://github.com/modelcontextprotocol/servers',
homepage_url:
'https://github.com/modelcontextprotocol/servers/tree/main/src/fetch',
downloads: 47000,
stars: 15000,
rating: 4.6,
rating_count: 230,
category: 'web',
subcategory: 'http',
platforms: ['claude_code', 'waggle', 'cursor', 'windsurf'],
dependencies: [],
packs: ['research_analyst', 'developer'],
install_manifest: {
npm_package: 'mcp-server-fetch',
mcp_config: {
name: 'fetch',
command: 'uvx',
args: ['mcp-server-fetch'],
},
},
},
{
name: 'puppeteer',
display_name: 'Puppeteer',
description:
'Browser automation and web scraping using Puppeteer — navigate pages, take screenshots, click elements, fill forms',
author: 'Anthropic',
package_type: 'mcp_server',
waggle_install_type: 'mcp',
waggle_install_path: '.mcp.json',
version: '0.6.2',
license: 'MIT',
repository_url: 'https://github.com/modelcontextprotocol/servers',
homepage_url:
'https://github.com/modelcontextprotocol/servers/tree/main/src/puppeteer',
downloads: 35000,
stars: 15000,
rating: 4.5,
rating_count: 185,
category: 'web',
subcategory: 'automation',
platforms: ['claude_code', 'waggle', 'cursor', 'windsurf'],
dependencies: [],
packs: ['developer', 'research_analyst'],
install_manifest: {
npm_package: '@modelcontextprotocol/server-puppeteer',
mcp_config: {
name: 'puppeteer',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-puppeteer'],
},
},
},
// ── Productivity ────────────────────────────────────────────────
{
name: 'google-drive',
display_name: 'Google Drive',
description:
'Search and read files from Google Drive with support for native Google Docs/Sheets/Slides export',
author: 'Anthropic',
package_type: 'mcp_server',
waggle_install_type: 'mcp',
waggle_install_path: '.mcp.json',
version: '0.6.2',
license: 'MIT',
repository_url: 'https://github.com/modelcontextprotocol/servers',
homepage_url:
'https://github.com/modelcontextprotocol/servers/tree/main/src/gdrive',
downloads: 29000,
stars: 15000,
rating: 4.4,
rating_count: 145,
category: 'productivity',
subcategory: 'cloud-storage',
platforms: ['claude_code', 'waggle', 'cursor'],
dependencies: [],
packs: ['content_operator', 'business_ops'],
install_manifest: {
npm_package: '@modelcontextprotocol/server-gdrive',
mcp_config: {
name: 'google-drive',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-gdrive'],
},
},
},
{
name: 'slack',
display_name: 'Slack',
description:
'Interact with Slack workspaces — read channels, post messages, reply to threads, and manage reactions',
author: 'Anthropic',
package_type: 'mcp_server',
waggle_install_type: 'mcp',
waggle_install_path: '.mcp.json',
version: '0.6.2',
license: 'MIT',
repository_url: 'https://github.com/modelcontextprotocol/servers',
homepage_url:
'https://github.com/modelcontextprotocol/servers/tree/main/src/slack',
downloads: 32000,
stars: 15000,
rating: 4.5,
rating_count: 168,
category: 'productivity',
subcategory: 'communication',
platforms: ['claude_code', 'waggle', 'cursor'],
dependencies: [],
packs: ['business_ops', 'customer_success'],
install_manifest: {
npm_package: '@modelcontextprotocol/server-slack',
mcp_config: {
name: 'slack',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-slack'],
env: { SLACK_BOT_TOKEN: '', SLACK_TEAM_ID: '' },
},
},
},
{
name: 'notion',
display_name: 'Notion',
description:
'Search, read, create, and update Notion pages and databases with full API integration',
author: 'suekou',
package_type: 'mcp_server',
waggle_install_type: 'mcp',
waggle_install_path: '.mcp.json',
version: '0.6.0',
license: 'MIT',
repository_url: 'https://github.com/suekou/mcp-notion-server',
homepage_url: 'https://github.com/suekou/mcp-notion-server',
downloads: 25000,
stars: 600,
rating: 4.4,
rating_count: 132,
category: 'productivity',
subcategory: 'note-taking',
platforms: ['claude_code', 'waggle', 'cursor'],
dependencies: [],
packs: ['content_operator', 'pm_pack'],
install_manifest: {
npm_package: '@suekou/mcp-notion-server',
mcp_config: {
name: 'notion',
command: 'npx',
args: ['-y', '@suekou/mcp-notion-server'],
env: { NOTION_API_TOKEN: '' },
},
},
},
{
name: 'gmail',
display_name: 'Gmail (Google)',
description:
'Read, search, draft, and send emails through Gmail via the Google API with OAuth2 authentication',
author: 'Anthropic',
package_type: 'mcp_server',
waggle_install_type: 'mcp',
waggle_install_path: '.mcp.json',
version: '0.6.2',
license: 'MIT',
repository_url: 'https://github.com/modelcontextprotocol/servers',
homepage_url:
'https://github.com/modelcontextprotocol/servers/tree/main/src/gmail',
downloads: 22000,
stars: 15000,
rating: 4.3,
rating_count: 118,
category: 'productivity',
subcategory: 'email',
platforms: ['claude_code', 'waggle'],
dependencies: [],
packs: ['business_ops', 'executive'],
install_manifest: {
npm_package: '@anthropic-ai/mcp-server-gmail',
mcp_config: {
name: 'gmail',
command: 'npx',
args: ['-y', '@anthropic-ai/mcp-server-gmail'],
},
},
},
// ── Knowledge ───────────────────────────────────────────────────
{
name: 'memory',
display_name: 'Memory (Knowledge Graph)',
description:
'Persistent memory using a local knowledge graph — store entities, relations, and observations across conversations',
author: 'Anthropic',
'Persistent local knowledge graph for entities, relations, and observations across conversations',
author: 'Model Context Protocol',
package_type: 'mcp_server',
waggle_install_type: 'mcp',
waggle_install_path: '.mcp.json',
version: '0.6.2',
version: '2026.7.4',
license: 'MIT',
repository_url: 'https://github.com/modelcontextprotocol/servers',
homepage_url:
'https://github.com/modelcontextprotocol/servers/tree/main/src/memory',
downloads: 48000,
stars: 15000,
rating: 4.6,
rating_count: 240,
downloads: 0,
stars: 0,
rating: 0,
rating_count: 0,
category: 'knowledge',
subcategory: 'memory',
platforms: ['claude_code', 'waggle', 'cursor', 'windsurf'],
dependencies: [],
packs: ['research_analyst'],
install_manifest: {
npm_package: '@modelcontextprotocol/server-memory',
npm_package: '@modelcontextprotocol/server-memory@2026.7.4',
mcp_config: {
name: 'memory',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-memory'],
args: [
'--yes',
'--ignore-scripts',
'@modelcontextprotocol/server-memory@2026.7.4',
],
},
},
},
{
name: 'everything',
display_name: 'Everything (Voidtools Search)',
description:
'Lightning-fast file and folder search on Windows using the Everything SDK — instant results across all drives',
author: 'Anthropic',
package_type: 'mcp_server',
waggle_install_type: 'mcp',
waggle_install_path: '.mcp.json',
version: '0.6.2',
license: 'MIT',
repository_url: 'https://github.com/modelcontextprotocol/servers',
homepage_url:
'https://github.com/modelcontextprotocol/servers/tree/main/src/everything',
downloads: 18000,
stars: 15000,
rating: 4.3,
rating_count: 95,
category: 'knowledge',
subcategory: 'search',
platforms: ['claude_code', 'waggle', 'cursor'],
dependencies: [],
packs: [],
install_manifest: {
npm_package: '@modelcontextprotocol/server-everything',
mcp_config: {
name: 'everything',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-everything'],
},
},
},
{
name: 'sequential-thinking',
display_name: 'Sequential Thinking',
description:
'Dynamic problem-solving through a structured thinking process with branching, revision, and hypothesis tracking',
author: 'Anthropic',
'Structured problem solving with branching, revision, and hypothesis tracking',
author: 'Model Context Protocol',
package_type: 'mcp_server',
waggle_install_type: 'mcp',
waggle_install_path: '.mcp.json',
version: '0.6.2',
version: '2026.7.4',
license: 'MIT',
repository_url: 'https://github.com/modelcontextprotocol/servers',
homepage_url:
'https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking',
downloads: 31000,
stars: 15000,
rating: 4.5,
rating_count: 155,
downloads: 0,
stars: 0,
rating: 0,
rating_count: 0,
category: 'knowledge',
subcategory: 'reasoning',
platforms: ['claude_code', 'waggle', 'cursor', 'windsurf'],
dependencies: [],
packs: ['research_analyst', 'consultant'],
install_manifest: {
npm_package: '@modelcontextprotocol/server-sequential-thinking',
npm_package: '@modelcontextprotocol/server-sequential-thinking@2026.7.4',
mcp_config: {
name: 'sequential-thinking',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-sequential-thinking'],
args: [
'--yes',
'--ignore-scripts',
'@modelcontextprotocol/server-sequential-thinking@2026.7.4',
],
},
},
},
// ── Data ────────────────────────────────────────────────────────
{
name: 'google-sheets',
display_name: 'Google Sheets',
name: 'brave-search',
display_name: 'Brave Search',
description:
'Read, write, and manage Google Sheets spreadsheets — create sheets, update cells, and read data ranges',
author: 'nicholasoxford',
'Official Brave web and local search server using the Brave Search API',
author: 'Brave Software',
package_type: 'mcp_server',
waggle_install_type: 'mcp',
waggle_install_path: '.mcp.json',
version: '1.0.0',
license: 'MIT',
repository_url: 'https://github.com/nicholasoxford/google-sheets-mcp',
homepage_url: 'https://github.com/nicholasoxford/google-sheets-mcp',
downloads: 12000,
stars: 200,
rating: 4.2,
rating_count: 68,
category: 'data',
subcategory: 'spreadsheets',
platforms: ['claude_code', 'waggle', 'cursor'],
version: '2.1.0',
license: 'MPL-2.0',
repository_url: 'https://github.com/brave/brave-search-mcp-server',
homepage_url: 'https://github.com/brave/brave-search-mcp-server',
downloads: 0,
stars: 0,
rating: 0,
rating_count: 0,
category: 'web',
subcategory: 'search',
platforms: ['claude_code', 'waggle', 'cursor', 'windsurf'],
dependencies: [],
packs: ['data_scientist', 'business_ops', 'finance_accounting'],
packs: ['research_analyst', 'content_operator'],
install_manifest: {
npm_package: '@nicholasoxford/google-sheets-mcp',
npm_package: '@brave/brave-search-mcp-server@2.1.0',
mcp_config: {
name: 'google-sheets',
name: 'brave-search',
command: 'npx',
args: ['-y', '@nicholasoxford/google-sheets-mcp'],
env: { GOOGLE_SHEETS_CREDENTIALS: '' },
args: [
'--yes',
'--ignore-scripts',
'@brave/brave-search-mcp-server@2.1.0',
'--transport',
'stdio',
],
env: { BRAVE_API_KEY: '' },
},
},
},
{
name: 'airtable',
display_name: 'Airtable',
description:
'Read, create, update, and delete records in Airtable bases with full schema and field type support',
author: 'felores',
package_type: 'mcp_server',
waggle_install_type: 'mcp',
waggle_install_path: '.mcp.json',
version: '1.0.0',
license: 'MIT',
repository_url: 'https://github.com/felores/airtable-mcp',
homepage_url: 'https://github.com/felores/airtable-mcp',
downloads: 8500,
stars: 150,
rating: 4.1,
rating_count: 52,
category: 'data',
subcategory: 'database',
platforms: ['claude_code', 'waggle', 'cursor'],
dependencies: [],
packs: ['business_ops', 'pm_pack'],
install_manifest: {
npm_package: 'airtable-mcp-server',
mcp_config: {
name: 'airtable',
command: 'npx',
args: ['-y', 'airtable-mcp-server'],
env: { AIRTABLE_API_KEY: '' },
},
},
},
// ── Additional Popular Servers ──────────────────────────────────
{
name: 'playwright',
display_name: 'Playwright',
description:
'Browser automation using Playwright — navigate, interact with elements, take screenshots, and execute JavaScript in real browsers',
'Official Microsoft browser automation server running headless in an isolated profile',
author: 'Microsoft',
package_type: 'mcp_server',
waggle_install_type: 'mcp',
waggle_install_path: '.mcp.json',
version: '0.0.14',
version: '0.0.78',
license: 'Apache-2.0',
repository_url: 'https://github.com/microsoft/playwright-mcp',
homepage_url: 'https://github.com/microsoft/playwright-mcp',
downloads: 42000,
stars: 4500,
rating: 4.7,
rating_count: 210,
downloads: 0,
stars: 0,
rating: 0,
rating_count: 0,
category: 'web',
subcategory: 'automation',
platforms: ['claude_code', 'waggle', 'cursor', 'windsurf'],
dependencies: [],
packs: ['developer'],
install_manifest: {
npm_package: '@anthropic-ai/mcp-server-playwright',
npm_package: '@playwright/mcp@0.0.78',
mcp_config: {
name: 'playwright',
command: 'npx',
args: ['-y', '@anthropic-ai/mcp-server-playwright'],
args: [
'--yes',
'--ignore-scripts',
'@playwright/mcp@0.0.78',
'--headless',
'--isolated',
],
},
},
},
{
name: 'linear',
display_name: 'Linear',
name: 'chrome-devtools',
display_name: 'Chrome DevTools',
description:
'Manage Linear issues, projects, and teams — create, update, search issues and track project progress',
author: 'jerhadf',
'Official Chrome DevTools browser automation with a slim, private, headless tool profile',
author: 'Chrome DevTools',
package_type: 'mcp_server',
waggle_install_type: 'mcp',
waggle_install_path: '.mcp.json',
version: '1.0.0',
license: 'MIT',
repository_url: 'https://github.com/jerhadf/linear-mcp-server',
homepage_url: 'https://github.com/jerhadf/linear-mcp-server',
downloads: 15000,
stars: 300,
rating: 4.4,
rating_count: 88,
category: 'productivity',
subcategory: 'project-management',
platforms: ['claude_code', 'waggle', 'cursor'],
version: '1.6.0',
license: 'Apache-2.0',
repository_url: 'https://github.com/ChromeDevTools/chrome-devtools-mcp',
homepage_url: 'https://github.com/ChromeDevTools/chrome-devtools-mcp',
downloads: 0,
stars: 0,
rating: 0,
rating_count: 0,
category: 'developer-tools',
subcategory: 'browser-debugging',
platforms: ['claude_code', 'waggle', 'cursor', 'windsurf'],
dependencies: [],
packs: ['pm_pack', 'developer'],
packs: ['developer'],
install_manifest: {
npm_package: 'linear-mcp-server',
npm_package: 'chrome-devtools-mcp@1.6.0',
mcp_config: {
name: 'linear',
name: 'chrome-devtools',
command: 'npx',
args: ['-y', 'linear-mcp-server'],
env: { LINEAR_API_KEY: '' },
args: [
'--yes',
'--ignore-scripts',
'chrome-devtools-mcp@1.6.0',
'--slim',
'--headless',
'--isolated',
'--no-usage-statistics',
'--no-performance-crux',
],
},
},
},
];
// ─── Seed Function ──────────────────────────────────────────────────
/**
* Seed MCP server entries into the marketplace database.
* Seed or refresh the canonical MCP rows.
*
* Inserts each server from MCP_SERVERS if it doesn't already exist
* (matched by name). Skips duplicates safely.
*
* @returns Count of newly added MCP server entries
* Matching is source-qualified, so a same-name third-party package cannot
* shadow the trusted row. Existing canonical rows are fully refreshed in
* place, preserving their id and installation foreign keys while replacing
* every stale manifest field.
*/
export function seedMcpServers(db: MarketplaceDB): number {
const sourceId = ensureMcpSource(db);
const rawDb = db.getRawDb();
const findCuratedPackage = rawDb.prepare(
`SELECT id, version, description, waggle_install_type, install_manifest,
repository_url, homepage_url
FROM packages WHERE source_id = ? AND name = ?`,
);
const invalidateChangedScan = rawDb.prepare(
`UPDATE packages SET
security_status = 'unscanned',
security_score = -1,
last_scanned_at = NULL,
content_hash = NULL,
scan_engines = NULL,
scan_findings = NULL,
scan_blocked = 0
WHERE id = ?`,
);
let added = 0;
for (const server of MCP_SERVERS) {
// Check if already present by name
const existing = db.getPackageByName(server.name);
if (existing) {
// Patch existing records that are missing npm_package in install_manifest
const manifest = typeof existing.install_manifest === 'string'
? JSON.parse(existing.install_manifest)
: existing.install_manifest;
const seedManifest = server.install_manifest;
if (seedManifest?.npm_package && (!manifest || !manifest.npm_package)) {
const patched = { ...manifest, ...seedManifest };
const rawDb = db.getRawDb();
rawDb
.prepare('UPDATE packages SET install_manifest = ? WHERE id = ?')
.run(JSON.stringify(patched), existing.id);
}
continue;
}
const installType = server.waggle_install_type || 'mcp';
const version = server.version || '1.0.0';
const installManifest = JSON.stringify(server.install_manifest);
const existing = findCuratedPackage.get(sourceId, server.name) as {
id: number;
version: string;
description: string;
waggle_install_type: string;
install_manifest: string;
repository_url: string | null;
homepage_url: string | null;
} | undefined;
const scanInputChanged = existing !== undefined && (
existing.version !== version
|| existing.description !== server.description
|| existing.waggle_install_type !== installType
|| existing.install_manifest !== installManifest
|| existing.repository_url !== (server.repository_url || null)
|| existing.homepage_url !== (server.homepage_url || null)
);
db.upsertPackage({
source_id: sourceId,
@@ -764,9 +314,9 @@ export function seedMcpServers(db: MarketplaceDB): number {
description: server.description,
author: server.author || 'community',
package_type: server.package_type || 'mcp_server',
waggle_install_type: server.waggle_install_type || 'mcp',
waggle_install_type: installType,
waggle_install_path: server.waggle_install_path || '.mcp.json',
version: server.version || '1.0.0',
version,
license: server.license || 'MIT',
repository_url: server.repository_url || null,
homepage_url: server.homepage_url || null,
@@ -779,14 +329,13 @@ export function seedMcpServers(db: MarketplaceDB): number {
platforms: JSON.stringify(server.platforms || ['waggle']),
dependencies: JSON.stringify(server.dependencies || []),
packs: JSON.stringify(server.packs || []),
install_manifest: JSON.stringify(server.install_manifest),
install_manifest: installManifest,
});
added++;
if (scanInputChanged) invalidateChangedScan.run(existing.id);
if (!existing) added++;
}
// Update source package count
const rawDb = db.getRawDb();
rawDb
.prepare('UPDATE sources SET total_packages = ? WHERE id = ?')
.run(MCP_SERVERS.length, sourceId);

View File

@@ -629,7 +629,9 @@ export class SecurityGate {
const descToScan = [
mcpConfig.name,
pkg.description,
mcpConfig.command,
...(mcpConfig.args || []),
...Object.entries(mcpConfig.env || {}).flatMap(([key, value]) => [key, value]),
].join(' ');
const result = guardian.scanToolDescription(mcpConfig.name, descToScan);
@@ -693,7 +695,13 @@ export class SecurityGate {
*/
private mcpPatternScan(config: McpServerConfig, description: string): SecurityFinding[] {
const findings: SecurityFinding[] = [];
const allText = [config.name, description, ...(config.args || [])].join(' ').toLowerCase();
const allText = [
config.name,
description,
config.command,
...(config.args || []),
...Object.entries(config.env || {}).flatMap(([key, value]) => [key, value]),
].join(' ').toLowerCase();
// ── Critical: Cross-tool instructions ──
const crossToolPatterns = [

View File

@@ -166,6 +166,17 @@ export interface McpServerConfig {
env?: Record<string, string>;
}
/** Portable, secret-free identity for one audited marketplace MCP profile. */
export interface MarketplaceMcpProvenance {
kind: 'marketplace';
schemaVersion: 1;
sourceName: 'mcp_registry';
packageName: string;
packageVersion: string;
npmPackage: string;
profileDigest: `sha256:${string}`;
}
export interface SettingField {
type: 'string' | 'number' | 'boolean';
description: string;
@@ -184,8 +195,34 @@ export interface PostInstallHook {
export type InstallationType = 'skill' | 'plugin' | 'mcp';
/** Immutable marketplace snapshot approved by a capability proposal. */
export interface MarketplaceApprovalIdentity {
schemaVersion: 1;
packageId: number;
sourceId: number;
name: string;
publisher: string;
version: string;
installType: InstallationType;
manifestDigest: `sha256:${string}`;
riskStatus: import('./security.js').Severity;
riskScore: number;
riskContentHash: string;
riskBlocked: boolean;
riskDigest: `sha256:${string}`;
}
export interface InstallRequest {
packageId: number;
/** Exact package and scan snapshot the user approved. */
expectedApprovalIdentity?: MarketplaceApprovalIdentity;
/** Require the freshly loaded package snapshot to keep this install type. */
expectedInstallType?: InstallationType;
/**
* Bind a delegated MCP install to the exact secret-free catalog receipt the
* caller selected. Direct marketplace and CLI installs leave this unset.
*/
expectedMcpProvenance?: MarketplaceMcpProvenance;
/** Override install path (default: auto-detected from package) */
installPath?: string;
/** User-provided settings (API keys, etc.) */
@@ -204,8 +241,18 @@ export interface InstallResult {
installPath: string;
message: string;
errors?: string[];
/** Stable conflict marker for callers that preserve retryable HTTP 409s. */
errorCode?: 'PACKAGE_IDENTITY_CHANGED';
/** Security scan result (attached when scan was performed) */
scanResult?: import('./security.js').ScanResult;
/**
* Exact validated MCP source template used for installation. Environment
* values remain unresolved catalog templates, so this receipt never carries
* user secrets and can be safely normalized again at the server boundary.
*/
mcpSourceConfig?: McpServerConfig;
/** Source-qualified identity persisted beside the configured MCP entry. */
mcpProvenance?: MarketplaceMcpProvenance;
}
export interface PackInstallResult {

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,8 @@
* MCP Server Registry — Tests
*
* Validates:
* - MCP_SERVERS has at least 15 entries
* - MCP_SERVERS contains only the exact approved local-stdio profiles
* - Every executable package selector is version-pinned in metadata and argv
* - Each entry has required fields (name, display_name, description, install_manifest)
* - Each install_manifest has mcp_config with command and args
* - seedMcpServers inserts into a temp DB correctly
@@ -17,6 +18,15 @@ import os from 'node:os';
import { MCP_SERVERS, seedMcpServers, type McpServerEntry } from '../src/mcp-registry';
import { MarketplaceDB } from '../src/db';
const EXACT_PACKAGE_VERSION = /(?:@|==)(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)$/;
const APPROVED_LOCAL_STDIO_SERVERS = [
'memory',
'sequential-thinking',
'brave-search',
'playwright',
'chrome-devtools',
];
// ── Schema: Create a temp marketplace DB with the real schema ────────
const SCHEMA_SQL = `
@@ -151,8 +161,8 @@ function createTempDb(): string {
// ── Static Data Validation ──────────────────────────────────────────
describe('MCP_SERVERS definitions', () => {
it('has at least 15 MCP server entries', () => {
expect(MCP_SERVERS.length).toBeGreaterThanOrEqual(15);
it('contains only the approved local stdio profiles', () => {
expect(MCP_SERVERS.map(server => server.name)).toEqual(APPROVED_LOCAL_STDIO_SERVERS);
});
it('has at most 25 entries (reasonable catalog size)', () => {
@@ -196,6 +206,28 @@ describe('MCP_SERVERS definitions', () => {
}
});
it('pins every executable package in both metadata and runtime argv', () => {
for (const server of MCP_SERVERS) {
const manifest = server.install_manifest!;
const packageSpec = manifest.npm_package!;
const versionMatch = EXACT_PACKAGE_VERSION.exec(packageSpec);
expect(versionMatch, `${server.name} package spec must use an exact version`).not.toBeNull();
expect(manifest.mcp_config!.args).toContain(packageSpec);
expect(server.version).toBe(versionMatch![1]);
}
});
it('disables lifecycle scripts for every npx-backed catalog profile', () => {
for (const server of MCP_SERVERS) {
const config = server.install_manifest!.mcp_config!;
if (config.command !== 'npx') continue;
expect(config.args).toContain('--yes');
expect(config.args).toContain('--ignore-scripts');
}
});
it('all names are unique', () => {
const names = MCP_SERVERS.map(s => s.name);
expect(new Set(names).size).toBe(names.length);
@@ -228,22 +260,7 @@ describe('MCP_SERVERS definitions', () => {
it('covers expected categories', () => {
const categories = new Set(MCP_SERVERS.map(s => s.category));
expect(categories.has('developer-tools')).toBe(true);
expect(categories.has('web')).toBe(true);
expect(categories.has('productivity')).toBe(true);
expect(categories.has('knowledge')).toBe(true);
expect(categories.has('data')).toBe(true);
});
it('includes key well-known servers', () => {
const names = MCP_SERVERS.map(s => s.name);
expect(names).toContain('filesystem');
expect(names).toContain('github');
expect(names).toContain('brave-search');
expect(names).toContain('memory');
expect(names).toContain('sequential-thinking');
expect(names).toContain('puppeteer');
expect(names).toContain('slack');
expect(categories).toEqual(new Set(['knowledge', 'web', 'developer-tools']));
});
it('mcp_config command is npx or uvx', () => {
@@ -339,14 +356,193 @@ describe('seedMcpServers', () => {
expect(results.total).toBe(MCP_SERVERS.length);
});
it('refreshes a stale curated manifest and version in place', () => {
seedMcpServers(db);
const expected = MCP_SERVERS.find(server => server.name === 'memory')!;
const before = db.getPackageByName('memory')!;
const rawDb = db.getRawDb();
rawDb.prepare('UPDATE packages SET version = ?, install_manifest = ? WHERE id = ?').run(
'0.0.0',
JSON.stringify({
npm_package: '@modelcontextprotocol/server-memory',
mcp_config: {
name: 'memory',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-memory'],
},
}),
before.id,
);
expect(seedMcpServers(db)).toBe(0);
const refreshed = db.getPackage(before.id)!;
expect(refreshed.id).toBe(before.id);
expect(refreshed.version).toBe(expected.version);
expect(refreshed.install_manifest).toEqual(expected.install_manifest);
});
it('invalidates stale scan evidence when a curated executable profile changes', () => {
seedMcpServers(db);
const memory = db.getPackageByName('memory')!;
const rawDb = db.getRawDb();
rawDb.prepare(
`UPDATE packages SET
version = '0.0.0',
install_manifest = '{}',
security_status = 'clean',
security_score = 100,
last_scanned_at = '2026-07-18T12:00:00Z',
content_hash = 'stale-hash',
scan_engines = '["content_hash"]',
scan_findings = '[]',
scan_blocked = 1
WHERE id = ?`,
).run(memory.id);
seedMcpServers(db);
const refreshed = rawDb.prepare(
`SELECT security_status, security_score, last_scanned_at, content_hash,
scan_engines, scan_findings, scan_blocked
FROM packages WHERE id = ?`,
).get(memory.id) as Record<string, unknown>;
expect(refreshed).toEqual({
security_status: 'unscanned',
security_score: -1,
last_scanned_at: null,
content_hash: null,
scan_engines: null,
scan_findings: null,
scan_blocked: 0,
});
});
it('invalidates stale scan evidence when curated provenance changes', () => {
seedMcpServers(db);
const memory = db.getPackageByName('memory')!;
const rawDb = db.getRawDb();
rawDb.prepare(
`UPDATE packages SET
repository_url = 'https://example.invalid/stale-source',
security_status = 'clean',
security_score = 100,
last_scanned_at = '2026-07-18T12:00:00Z',
content_hash = 'stale-hash',
scan_engines = '["gen_trust_hub"]',
scan_findings = '[]',
scan_blocked = 0
WHERE id = ?`,
).run(memory.id);
seedMcpServers(db);
const refreshed = rawDb.prepare(
`SELECT security_status, security_score, last_scanned_at, content_hash,
scan_engines, scan_findings, scan_blocked
FROM packages WHERE id = ?`,
).get(memory.id) as Record<string, unknown>;
expect(refreshed).toEqual({
security_status: 'unscanned',
security_score: -1,
last_scanned_at: null,
content_hash: null,
scan_engines: null,
scan_findings: null,
scan_blocked: 0,
});
});
it('preserves current scan evidence during an identical startup reseed', () => {
seedMcpServers(db);
const memory = db.getPackageByName('memory')!;
const rawDb = db.getRawDb();
rawDb.prepare(
`UPDATE packages SET
security_status = 'clean',
security_score = 100,
last_scanned_at = '2026-07-18T12:00:00Z',
content_hash = 'current-hash',
scan_engines = '["content_hash"]',
scan_findings = '[]',
scan_blocked = 0
WHERE id = ?`,
).run(memory.id);
seedMcpServers(db);
const preserved = rawDb.prepare(
`SELECT security_status, security_score, last_scanned_at, content_hash,
scan_engines, scan_findings, scan_blocked
FROM packages WHERE id = ?`,
).get(memory.id) as Record<string, unknown>;
expect(preserved).toEqual({
security_status: 'clean',
security_score: 100,
last_scanned_at: '2026-07-18T12:00:00Z',
content_hash: 'current-hash',
scan_engines: '["content_hash"]',
scan_findings: '[]',
scan_blocked: 0,
});
});
it('preserves an active installation while refreshing its curated package row', () => {
seedMcpServers(db);
const memory = db.getPackageByName('memory')!;
const rawDb = db.getRawDb();
rawDb.prepare(
`INSERT INTO installations (package_id, installed_version, install_path, status, config)
VALUES (?, ?, ?, 'installed', '{}')`,
).run(memory.id, 'legacy', '.mcp.json');
rawDb.prepare('UPDATE packages SET version = ? WHERE id = ?').run('legacy', memory.id);
seedMcpServers(db);
expect(db.getPackage(memory.id)!.version).toBe(
MCP_SERVERS.find(server => server.name === 'memory')!.version,
);
expect(db.isInstalled(memory.id)).toBe(true);
});
it('seeds a trusted source row when an external source already uses the same name', () => {
const rawDb = db.getRawDb();
const externalSource = rawDb.prepare(
`INSERT INTO sources (name, display_name, source_type, platform)
VALUES ('external', 'External', 'registry', 'npm')`,
).run().lastInsertRowid;
rawDb.prepare(
`INSERT INTO packages (
source_id, name, display_name, description, package_type,
waggle_install_type, install_manifest
) VALUES (?, 'memory', 'External Memory', 'Untrusted shadow row',
'mcp_server', 'mcp', ?)`,
).run(
externalSource,
JSON.stringify({
npm_package: 'external-memory@1.0.0',
mcp_config: { name: 'memory', command: 'npx', args: ['external-memory@1.0.0'] },
}),
);
expect(seedMcpServers(db)).toBe(MCP_SERVERS.length);
const trustedRows = rawDb.prepare(
`SELECT p.* FROM packages p
INNER JOIN sources s ON s.id = p.source_id
WHERE s.name = 'mcp_registry' AND p.name = 'memory'`,
).all();
expect(trustedRows).toHaveLength(1);
});
it('partial seeding skips existing entries', () => {
// First seed
seedMcpServers(db);
// Manually delete a few entries and re-seed
const rawDb = db.getRawDb();
rawDb.prepare("DELETE FROM packages WHERE name = 'filesystem'").run();
rawDb.prepare("DELETE FROM packages WHERE name = 'github'").run();
rawDb.prepare("DELETE FROM packages WHERE name = 'memory'").run();
rawDb.prepare("DELETE FROM packages WHERE name = 'playwright'").run();
// Re-seed should only add the 2 deleted ones back
const added = seedMcpServers(db);
@@ -369,13 +565,13 @@ describe('seedMcpServers', () => {
seedMcpServers(db);
const devTools = db.search({ type: 'mcp', category: 'developer-tools', limit: 50 });
expect(devTools.total).toBeGreaterThanOrEqual(3); // filesystem, git, github, sqlite, postgres
expect(devTools.total).toBe(1); // chrome-devtools
const web = db.search({ type: 'mcp', category: 'web', limit: 50 });
expect(web.total).toBeGreaterThanOrEqual(2); // brave-search, fetch, puppeteer
expect(web.total).toBe(2); // brave-search, playwright
const productivity = db.search({ type: 'mcp', category: 'productivity', limit: 50 });
expect(productivity.total).toBeGreaterThanOrEqual(3); // google-drive, slack, notion, gmail
const knowledge = db.search({ type: 'mcp', category: 'knowledge', limit: 50 });
expect(knowledge.total).toBe(2); // memory, sequential-thinking
});
it('facets include mcp type', () => {
@@ -404,7 +600,7 @@ describe('db.search — FTS5 query relaxation', () => {
beforeEach(() => {
ftsDbPath = createTempDb();
ftsDb = new MarketplaceDB(ftsDbPath);
seedMcpServers(ftsDb); // seeds the 'filesystem' MCP server
seedMcpServers(ftsDb); // seeds the 'memory' MCP server
// The bare test schema declares packages_fts as external-content FTS5
// with no sync triggers (production ships them in the seed DB). Rebuild
// the index from the content table so search() exercises real FTS —
@@ -422,31 +618,31 @@ describe('db.search — FTS5 query relaxation', () => {
try { fs.unlinkSync(ftsDbPath + '-shm'); } catch { /* ignore */ }
});
const hasFilesystem = (r: { packages: Array<{ name: string; description: string }> }) =>
r.packages.some(p => p.name === 'filesystem' || /filesystem/i.test(p.description));
const hasMemory = (r: { packages: Array<{ name: string; description: string }> }) =>
r.packages.some(p => p.name === 'memory' || /memory/i.test(p.description));
it('baseline: a single tight keyword finds the filesystem MCP server', () => {
const r = ftsDb.search({ query: 'filesystem', limit: 10 });
it('baseline: a single tight keyword finds the memory MCP server', () => {
const r = ftsDb.search({ query: 'memory', limit: 10 });
expect(r.total).toBeGreaterThan(0);
expect(hasFilesystem(r)).toBe(true);
expect(hasMemory(r)).toBe(true);
});
it('REGRESSION: a verbose natural-language need still surfaces the filesystem server', () => {
it('REGRESSION: a verbose natural-language need still surfaces the memory server', () => {
// Exact shape acquire_capability feeds into searchMarketplace(need).
const need =
'Access and read files from an external local filesystem path outside my managed workspace directory looking for an MCP filesystem connector or similar capability';
'Keep durable entities and relationships across many conversations using a persistent MCP memory knowledge graph';
const r = ftsDb.search({ query: need, limit: 10 });
expect(r.total).toBeGreaterThan(0);
expect(hasFilesystem(r)).toBe(true);
expect(hasMemory(r)).toBe(true);
});
it('ROBUSTNESS: a need with FTS-special chars (path with : and \\ and quotes) does not throw and still matches', () => {
const need =
'read files at D:\\Projects\\PM-Waggle-OS — need a "filesystem" connector, not workspace-only access';
'remember D:\\Projects\\PM-Waggle-OS — need a "memory" knowledge graph: durable * context';
expect(() => ftsDb.search({ query: need, limit: 10 })).not.toThrow();
const r = ftsDb.search({ query: need, limit: 10 });
expect(r.total).toBeGreaterThan(0);
expect(hasFilesystem(r)).toBe(true);
expect(hasMemory(r)).toBe(true);
});
it('EMPTY/garbage query degrades gracefully (no throw, no crash)', () => {

View File

@@ -10,6 +10,7 @@
import { describe, it, expect } from 'vitest';
import { createHash } from 'node:crypto';
import { createRequire } from 'node:module';
import {
resolveSkillSource,
classifySource,
@@ -53,6 +54,7 @@ description: A demo skill for tests.
Do the thing.
`;
const runtimeRequire = createRequire(import.meta.url);
// ── Grammar classification ───────────────────────────────────────────
@@ -212,6 +214,25 @@ describe('resolveSkillSource — zip', () => {
const url = 'https://example.com/pkg.zip';
const zipBytes = Buffer.from('PK-fake-zip');
it('uses the patched bundled zip parser for a real archive', async () => {
const packageMeta = runtimeRequire('adm-zip/package.json') as { version: string };
const [major, minor] = packageMeta.version.split('.').map(Number);
expect(major > 0 || minor >= 6, `adm-zip ${packageMeta.version} includes CVE-2026-39244`).toBe(true);
const AdmZip = runtimeRequire('adm-zip') as new () => {
addFile(name: string, content: Buffer): void;
toBuffer(): Buffer;
};
const archive = new AdmZip();
archive.addFile('SKILL.md', Buffer.from(SKILL, 'utf-8'));
const realZip = archive.toBuffer();
const res = await resolveSkillSource(url, {
fetchImpl: fetcherFor({ [url]: binResponse(realZip) }),
});
expect(res.content).toContain('name: demo-skill');
});
it('extracts the shallowest SKILL.md from a zip', async () => {
const res = await resolveSkillSource(url, {
fetchImpl: fetcherFor({ [url]: binResponse(zipBytes) }),