first commit

This commit is contained in:
Michal
2026-02-21 03:10:39 +00:00
commit d0aa0c5d63
174 changed files with 21169 additions and 0 deletions

26
src/cli/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "@mcpctl/cli",
"version": "0.1.0",
"private": true,
"type": "module",
"bin": {
"mcpctl": "./dist/index.js"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc --build",
"clean": "rimraf dist",
"dev": "tsx src/index.ts",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"commander": "^13.0.0",
"chalk": "^5.4.0",
"inquirer": "^12.0.0",
"js-yaml": "^4.1.0",
"@mcpctl/shared": "workspace:*",
"@mcpctl/db": "workspace:*"
}
}

2
src/cli/src/index.ts Normal file
View File

@@ -0,0 +1,2 @@
// mcpctl CLI entry point
// Will be implemented in Task 7

View File

@@ -0,0 +1,9 @@
import type { RegistryServer } from './types.js';
export abstract class RegistrySource {
abstract readonly name: string;
abstract search(query: string, limit: number): Promise<RegistryServer[]>;
protected abstract normalizeResult(raw: unknown): RegistryServer;
}

View File

@@ -0,0 +1,57 @@
import { createHash } from 'crypto';
import type { RegistryServer, SearchOptions } from './types.js';
export class RegistryCache {
private cache = new Map<string, { data: RegistryServer[]; expires: number }>();
private defaultTTL: number;
private hits = 0;
private misses = 0;
constructor(ttlMs = 3_600_000) {
this.defaultTTL = ttlMs;
}
private getKey(query: string, options: SearchOptions): string {
return createHash('sha256')
.update(JSON.stringify({ query, options }))
.digest('hex');
}
get(query: string, options: SearchOptions): RegistryServer[] | null {
const key = this.getKey(query, options);
const entry = this.cache.get(key);
if (entry !== undefined && entry.expires > Date.now()) {
this.hits++;
return entry.data;
}
if (entry !== undefined) {
this.cache.delete(key);
}
this.misses++;
return null;
}
set(query: string, options: SearchOptions, data: RegistryServer[]): void {
const key = this.getKey(query, options);
this.cache.set(key, { data, expires: Date.now() + this.defaultTTL });
}
getHitRatio(): { hits: number; misses: number; ratio: number } {
const total = this.hits + this.misses;
return {
hits: this.hits,
misses: this.misses,
ratio: total === 0 ? 0 : this.hits / total,
};
}
clear(): void {
this.cache.clear();
this.hits = 0;
this.misses = 0;
}
get size(): number {
return this.cache.size;
}
}

View File

@@ -0,0 +1,76 @@
import type { RegistryServer, EnvVar } from './types.js';
function normalizeGitHubUrl(url: string): string {
return url
.replace(/^git@github\.com:/, 'https://github.com/')
.replace(/\.git$/, '')
.replace(/\/$/, '')
.toLowerCase();
}
function mergeEnvTemplates(a: EnvVar[], b: EnvVar[]): EnvVar[] {
const seen = new Map<string, EnvVar>();
for (const v of a) {
seen.set(v.name, v);
}
for (const v of b) {
if (!seen.has(v.name)) {
seen.set(v.name, v);
}
}
return [...seen.values()];
}
export function deduplicateResults(results: RegistryServer[]): RegistryServer[] {
const byNpm = new Map<string, RegistryServer>();
const byRepo = new Map<string, RegistryServer>();
const deduped: RegistryServer[] = [];
for (const server of results) {
const npmKey = server.packages.npm;
const repoKey =
server.repositoryUrl !== undefined ? normalizeGitHubUrl(server.repositoryUrl) : undefined;
let existing: RegistryServer | undefined;
if (npmKey !== undefined) {
existing = byNpm.get(npmKey);
}
if (existing === undefined && repoKey !== undefined) {
existing = byRepo.get(repoKey);
}
if (existing !== undefined) {
// Merge: keep higher popularity, combine envTemplates
if (server.popularityScore > existing.popularityScore) {
const merged: RegistryServer = {
...server,
envTemplate: mergeEnvTemplates(server.envTemplate, existing.envTemplate),
verified: server.verified || existing.verified,
};
// Replace existing in deduped array
const idx = deduped.indexOf(existing);
if (idx !== -1) {
deduped[idx] = merged;
}
// Update maps to point to merged
if (npmKey !== undefined) byNpm.set(npmKey, merged);
if (repoKey !== undefined) byRepo.set(repoKey, merged);
if (existing.packages.npm !== undefined) byNpm.set(existing.packages.npm, merged);
if (existing.repositoryUrl !== undefined) {
byRepo.set(normalizeGitHubUrl(existing.repositoryUrl), merged);
}
} else {
// Keep existing but merge envTemplates
existing.envTemplate = mergeEnvTemplates(existing.envTemplate, server.envTemplate);
existing.verified = existing.verified || server.verified;
}
} else {
deduped.push(server);
if (npmKey !== undefined) byNpm.set(npmKey, server);
if (repoKey !== undefined) byRepo.set(repoKey, server);
}
}
return deduped;
}

View File

@@ -0,0 +1,180 @@
import { z } from 'zod';
// ── Normalized types used throughout mcpctl ──
export interface EnvVar {
name: string;
description: string;
isSecret: boolean;
setupUrl?: string;
defaultValue?: string;
}
export interface RegistryServer {
name: string;
description: string;
packages: {
npm?: string;
pypi?: string;
docker?: string;
};
envTemplate: EnvVar[];
transport: 'stdio' | 'sse' | 'streamable-http';
repositoryUrl?: string;
popularityScore: number;
verified: boolean;
sourceRegistry: 'official' | 'glama' | 'smithery';
lastUpdated?: Date;
}
export interface SearchOptions {
query: string;
limit?: number;
registries?: RegistryName[];
verified?: boolean;
transport?: 'stdio' | 'sse';
category?: string;
}
export type RegistryName = 'official' | 'glama' | 'smithery';
export interface RegistryClientConfig {
registries?: RegistryName[];
cacheTTLMs?: number;
smitheryApiKey?: string;
httpProxy?: string;
httpsProxy?: string;
}
// ── Zod schemas for API response validation ──
// Official MCP Registry
const OfficialEnvVarSchema = z.object({
name: z.string(),
description: z.string().optional().default(''),
format: z.string().optional(),
isSecret: z.boolean().optional().default(false),
});
const OfficialPackageSchema = z.object({
registryType: z.string(),
identifier: z.string(),
version: z.string().optional(),
runtimeHint: z.string().optional(),
transport: z.object({
type: z.string(),
}).optional(),
environmentVariables: z.array(OfficialEnvVarSchema).optional().default([]),
});
const OfficialRemoteSchema = z.object({
type: z.string(),
url: z.string(),
headers: z.array(z.object({
name: z.string(),
description: z.string().optional().default(''),
value: z.string().optional(),
isRequired: z.boolean().optional(),
isSecret: z.boolean().optional(),
})).optional().default([]),
});
const OfficialServerSchema = z.object({
server: z.object({
name: z.string(),
title: z.string().optional(),
description: z.string().optional().default(''),
version: z.string().optional(),
repository: z.object({
url: z.string(),
source: z.string().optional(),
subfolder: z.string().optional(),
}).optional(),
packages: z.array(OfficialPackageSchema).optional().default([]),
remotes: z.array(OfficialRemoteSchema).optional().default([]),
}),
_meta: z.record(z.unknown()).optional(),
});
export const OfficialRegistryResponseSchema = z.object({
servers: z.array(OfficialServerSchema),
metadata: z.object({
nextCursor: z.string().nullable().optional(),
count: z.number().optional(),
}).optional(),
});
// Glama.ai
const GlamaServerSchema = z.object({
id: z.string(),
name: z.string(),
namespace: z.string().optional().default(''),
slug: z.string().optional().default(''),
description: z.string().optional().default(''),
url: z.string().optional(),
attributes: z.array(z.string()).optional().default([]),
repository: z.object({
url: z.string(),
}).optional(),
environmentVariablesJsonSchema: z.object({
type: z.string().optional(),
properties: z.record(z.object({
type: z.string().optional(),
description: z.string().optional(),
default: z.string().optional(),
})).optional().default({}),
required: z.array(z.string()).optional().default([]),
}).optional(),
});
export const GlamaRegistryResponseSchema = z.object({
servers: z.array(GlamaServerSchema),
pageInfo: z.object({
startCursor: z.string().nullable().optional(),
endCursor: z.string().nullable().optional(),
hasNextPage: z.boolean(),
hasPreviousPage: z.boolean(),
}),
});
// Smithery.ai
const SmitheryServerSchema = z.object({
qualifiedName: z.string(),
displayName: z.string().optional().default(''),
description: z.string().optional().default(''),
iconUrl: z.string().optional(),
verified: z.boolean().optional().default(false),
useCount: z.number().optional().default(0),
remote: z.boolean().optional().default(false),
isDeployed: z.boolean().optional().default(false),
createdAt: z.string().optional(),
homepage: z.string().optional(),
score: z.number().optional().default(0),
});
export const SmitheryRegistryResponseSchema = z.object({
servers: z.array(SmitheryServerSchema),
pagination: z.object({
currentPage: z.number(),
pageSize: z.number(),
totalPages: z.number(),
totalCount: z.number(),
}),
});
// ── Inferred types from Zod schemas ──
export type OfficialRegistryResponse = z.infer<typeof OfficialRegistryResponseSchema>;
export type OfficialServerEntry = z.infer<typeof OfficialServerSchema>;
export type GlamaRegistryResponse = z.infer<typeof GlamaRegistryResponseSchema>;
export type GlamaServerEntry = z.infer<typeof GlamaServerSchema>;
export type SmitheryRegistryResponse = z.infer<typeof SmitheryRegistryResponseSchema>;
export type SmitheryServerEntry = z.infer<typeof SmitheryServerSchema>;
// ── Security utilities ──
const ANSI_ESCAPE_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F]|\x1b\[[0-9;]*[a-zA-Z]|\033\[[0-9;]*[a-zA-Z]/g;
export function sanitizeString(text: string): string {
return text.replace(ANSI_ESCAPE_RE, '');
}

View File

@@ -0,0 +1,190 @@
import { describe, it, expect } from 'vitest';
import {
OfficialRegistryResponseSchema,
GlamaRegistryResponseSchema,
SmitheryRegistryResponseSchema,
sanitizeString,
} from '../../src/registry/types.js';
describe('sanitizeString', () => {
it('removes ANSI escape codes (\\x1b[)', () => {
expect(sanitizeString('\x1b[31mRED\x1b[0m text')).toBe('RED text');
});
it('removes \\033[ style escape codes', () => {
expect(sanitizeString('\x1b[1mBOLD\x1b[0m')).toBe('BOLD');
});
it('removes cursor movement codes', () => {
expect(sanitizeString('\x1b[2J\x1b[Hscreen cleared')).toBe('screen cleared');
});
it('removes control characters', () => {
expect(sanitizeString('hello\x07world')).toBe('helloworld');
});
it('preserves normal text', () => {
expect(sanitizeString('A normal MCP server description.')).toBe('A normal MCP server description.');
});
it('preserves unicode characters', () => {
expect(sanitizeString('Serveur MCP pour Slack 🚀')).toBe('Serveur MCP pour Slack 🚀');
});
it('handles empty string', () => {
expect(sanitizeString('')).toBe('');
});
});
describe('OfficialRegistryResponseSchema', () => {
it('validates a correct response', () => {
const valid = {
servers: [{
server: {
name: 'io.github.test/slack-mcp',
description: 'Slack integration',
packages: [{
registryType: 'npm',
identifier: '@test/slack-mcp',
transport: { type: 'stdio' },
environmentVariables: [
{ name: 'SLACK_TOKEN', description: 'Bot token', isSecret: true },
],
}],
},
}],
metadata: { nextCursor: 'abc:1.0.0', count: 1 },
};
const result = OfficialRegistryResponseSchema.safeParse(valid);
expect(result.success).toBe(true);
});
it('validates response with remotes', () => {
const valid = {
servers: [{
server: {
name: 'io.github.test/remote-mcp',
remotes: [{
type: 'sse',
url: 'https://example.com/sse',
headers: [{ name: 'Authorization', isSecret: true }],
}],
},
}],
};
const result = OfficialRegistryResponseSchema.safeParse(valid);
expect(result.success).toBe(true);
});
it('rejects response without servers array', () => {
const result = OfficialRegistryResponseSchema.safeParse({ metadata: {} });
expect(result.success).toBe(false);
});
it('rejects server without name', () => {
const result = OfficialRegistryResponseSchema.safeParse({
servers: [{ server: { description: 'no name' } }],
});
expect(result.success).toBe(false);
});
it('defaults missing optional fields', () => {
const minimal = {
servers: [{ server: { name: 'test/minimal' } }],
};
const result = OfficialRegistryResponseSchema.parse(minimal);
expect(result.servers[0]?.server.packages).toEqual([]);
expect(result.servers[0]?.server.remotes).toEqual([]);
expect(result.servers[0]?.server.description).toBe('');
});
});
describe('GlamaRegistryResponseSchema', () => {
it('validates a correct response', () => {
const valid = {
servers: [{
id: 'abc123',
name: 'Slack MCP Server',
description: 'Slack integration',
attributes: ['hosting:local-only'],
repository: { url: 'https://github.com/test/slack' },
environmentVariablesJsonSchema: {
type: 'object',
properties: {
SLACK_TOKEN: { type: 'string', description: 'Bot token' },
},
required: ['SLACK_TOKEN'],
},
}],
pageInfo: {
endCursor: 'xyz',
hasNextPage: true,
hasPreviousPage: false,
},
};
const result = GlamaRegistryResponseSchema.safeParse(valid);
expect(result.success).toBe(true);
});
it('rejects response without pageInfo', () => {
const result = GlamaRegistryResponseSchema.safeParse({
servers: [{ id: 'a', name: 'test' }],
});
expect(result.success).toBe(false);
});
it('defaults missing env schema properties', () => {
const minimal = {
servers: [{
id: 'a',
name: 'test',
environmentVariablesJsonSchema: {},
}],
pageInfo: { hasNextPage: false, hasPreviousPage: false },
};
const result = GlamaRegistryResponseSchema.parse(minimal);
const envSchema = result.servers[0]?.environmentVariablesJsonSchema;
expect(envSchema?.properties).toEqual({});
expect(envSchema?.required).toEqual([]);
});
});
describe('SmitheryRegistryResponseSchema', () => {
it('validates a correct response', () => {
const valid = {
servers: [{
qualifiedName: 'slack',
displayName: 'Slack',
description: 'Slack integration',
verified: true,
useCount: 14062,
remote: true,
}],
pagination: {
currentPage: 1,
pageSize: 10,
totalPages: 5,
totalCount: 50,
},
};
const result = SmitheryRegistryResponseSchema.safeParse(valid);
expect(result.success).toBe(true);
});
it('rejects response without pagination', () => {
const result = SmitheryRegistryResponseSchema.safeParse({
servers: [{ qualifiedName: 'test' }],
});
expect(result.success).toBe(false);
});
it('defaults useCount and verified', () => {
const minimal = {
servers: [{ qualifiedName: 'test' }],
pagination: { currentPage: 1, pageSize: 10, totalPages: 1, totalCount: 1 },
};
const result = SmitheryRegistryResponseSchema.parse(minimal);
expect(result.servers[0]?.useCount).toBe(0);
expect(result.servers[0]?.verified).toBe(false);
});
});

12
src/cli/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../shared" },
{ "path": "../db" }
]
}

8
src/cli/vitest.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineProject } from 'vitest/config';
export default defineProject({
test: {
name: 'cli',
include: ['tests/**/*.test.ts'],
},
});

31
src/db/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "@mcpctl/db",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc --build",
"clean": "rimraf dist",
"test": "vitest",
"test:run": "vitest run",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:seed": "tsx src/seed/index.ts"
},
"dependencies": {
"@prisma/client": "^6.0.0",
"@mcpctl/shared": "workspace:*"
},
"devDependencies": {
"prisma": "^6.0.0"
}
}

2
src/db/src/index.ts Normal file
View File

@@ -0,0 +1,2 @@
// Database package - Prisma client and utilities
// Will be implemented in Task 2

11
src/db/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../shared" }
]
}

8
src/db/vitest.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineProject } from 'vitest/config';
export default defineProject({
test: {
name: 'db',
include: ['tests/**/*.test.ts'],
},
});

View File

@@ -0,0 +1,20 @@
{
"name": "@mcpctl/local-proxy",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc --build",
"clean": "rimraf dist",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"@mcpctl/shared": "workspace:*"
}
}

View File

@@ -0,0 +1,2 @@
// Local LLM proxy entry point
// Will be implemented in Task 11

View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../shared" }
]
}

View File

@@ -0,0 +1,8 @@
import { defineProject } from 'vitest/config';
export default defineProject({
test: {
name: 'local-proxy',
include: ['tests/**/*.test.ts'],
},
});

25
src/mcpd/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "@mcpctl/mcpd",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc --build",
"clean": "rimraf dist",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"fastify": "^5.0.0",
"@fastify/cors": "^10.0.0",
"@fastify/helmet": "^12.0.0",
"@fastify/rate-limit": "^10.0.0",
"zod": "^3.24.0",
"@mcpctl/shared": "workspace:*",
"@mcpctl/db": "workspace:*"
}
}

2
src/mcpd/src/index.ts Normal file
View File

@@ -0,0 +1,2 @@
// mcpd daemon server entry point
// Will be implemented in Task 3

12
src/mcpd/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../shared" },
{ "path": "../db" }
]
}

View File

@@ -0,0 +1,8 @@
import { defineProject } from 'vitest/config';
export default defineProject({
test: {
name: 'mcpd',
include: ['tests/**/*.test.ts'],
},
});

23
src/shared/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "@mcpctl/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc --build",
"clean": "rimraf dist",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"zod": "^3.24.0"
}
}

View File

@@ -0,0 +1,5 @@
// Shared constants
export const APP_NAME = 'mcpctl';
export const APP_VERSION = '0.1.0';
export const DEFAULT_MCPD_URL = 'http://localhost:3000';
export const DEFAULT_DB_PORT = 5432;

4
src/shared/src/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from './types/index.js';
export * from './validation/index.js';
export * from './constants/index.js';
export * from './utils/index.js';

View File

@@ -0,0 +1,46 @@
// Core domain types for mcpctl
// These will be expanded as tasks are implemented
export interface McpServerConfig {
name: string;
type: string;
command: string;
args: string[];
envTemplate: EnvTemplateEntry[];
setupGuide?: string;
}
export interface EnvTemplateEntry {
name: string;
description: string;
isSecret: boolean;
setupUrl?: string;
defaultValue?: string;
}
export interface McpProfile {
name: string;
serverId: string;
config: Record<string, unknown>;
filterRules?: Record<string, unknown>;
}
export interface McpProject {
name: string;
description?: string;
profileIds: string[];
}
// Service interfaces for dependency injection
export interface BackupService {
exportConfig(): Promise<string>;
importConfig(data: string): Promise<void>;
}
export interface ConfigExporter {
serialize(): Promise<Record<string, unknown>>;
}
export interface ConfigImporter {
deserialize(data: Record<string, unknown>): Promise<void>;
}

View File

@@ -0,0 +1,2 @@
// Shared utility functions
// Will be expanded as tasks are implemented

View File

@@ -0,0 +1,4 @@
// Shared Zod validation schemas
// Will be expanded as tasks are implemented
export { z } from 'zod';

View File

@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import { APP_NAME, APP_VERSION, DEFAULT_MCPD_URL } from '../src/constants/index.js';
describe('shared package', () => {
it('exports APP_NAME constant', () => {
expect(APP_NAME).toBe('mcpctl');
});
it('exports APP_VERSION constant', () => {
expect(APP_VERSION).toBe('0.1.0');
});
it('exports DEFAULT_MCPD_URL constant', () => {
expect(DEFAULT_MCPD_URL).toBe('http://localhost:3000');
});
});

8
src/shared/tsconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,8 @@
import { defineProject } from 'vitest/config';
export default defineProject({
test: {
name: 'shared',
include: ['tests/**/*.test.ts'],
},
});