moving
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
564
packages/marketplace/src/install-security.ts
Normal file
564
packages/marketplace/src/install-security.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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}`, {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
1132
packages/marketplace/tests/installer-security.test.ts
Normal file
1132
packages/marketplace/tests/installer-security.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)', () => {
|
||||
|
||||
@@ -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) }),
|
||||
|
||||
Reference in New Issue
Block a user