diff --git a/src/shared/src/index.ts b/src/shared/src/index.ts index 9f512e7..9a3a1da 100644 --- a/src/shared/src/index.ts +++ b/src/shared/src/index.ts @@ -2,3 +2,4 @@ export * from './types/index.js'; export * from './validation/index.js'; export * from './constants/index.js'; export * from './utils/index.js'; +export * from './profiles/index.js'; diff --git a/src/shared/src/profiles/index.ts b/src/shared/src/profiles/index.ts new file mode 100644 index 0000000..76717f1 --- /dev/null +++ b/src/shared/src/profiles/index.ts @@ -0,0 +1,5 @@ +export type { ProfileTemplate, ProfileCategory, InstantiatedProfile } from './types.js'; +export { profileTemplateSchema, envTemplateEntrySchema } from './types.js'; +export { ProfileRegistry, defaultRegistry } from './registry.js'; +export { validateTemplate, getMissingEnvVars, instantiateProfile, generateMcpJsonEntry } from './utils.js'; +export * from './templates/index.js'; diff --git a/src/shared/src/profiles/registry.ts b/src/shared/src/profiles/registry.ts new file mode 100644 index 0000000..22d7df7 --- /dev/null +++ b/src/shared/src/profiles/registry.ts @@ -0,0 +1,67 @@ +import type { ProfileTemplate, ProfileCategory } from './types.js'; +import { filesystemTemplate } from './templates/filesystem.js'; +import { githubTemplate } from './templates/github.js'; +import { postgresTemplate } from './templates/postgres.js'; +import { slackTemplate } from './templates/slack.js'; +import { memoryTemplate } from './templates/memory.js'; +import { fetchTemplate } from './templates/fetch.js'; + +const builtinTemplates: ProfileTemplate[] = [ + filesystemTemplate, + githubTemplate, + postgresTemplate, + slackTemplate, + memoryTemplate, + fetchTemplate, +]; + +export class ProfileRegistry { + private templates = new Map(); + + constructor(templates: ProfileTemplate[] = builtinTemplates) { + for (const t of templates) { + this.templates.set(t.id, t); + } + } + + getAll(): ProfileTemplate[] { + return [...this.templates.values()]; + } + + getById(id: string): ProfileTemplate | undefined { + return this.templates.get(id); + } + + getByCategory(category: ProfileCategory): ProfileTemplate[] { + return this.getAll().filter((t) => t.category === category); + } + + getCategories(): ProfileCategory[] { + const cats = new Set(); + for (const t of this.templates.values()) { + cats.add(t.category); + } + return [...cats]; + } + + search(query: string): ProfileTemplate[] { + const q = query.toLowerCase(); + return this.getAll().filter( + (t) => + t.id.includes(q) || + t.name.includes(q) || + t.displayName.toLowerCase().includes(q) || + t.description.toLowerCase().includes(q), + ); + } + + register(template: ProfileTemplate): void { + this.templates.set(template.id, template); + } + + has(id: string): boolean { + return this.templates.has(id); + } +} + +export const defaultRegistry = new ProfileRegistry(); diff --git a/src/shared/src/profiles/templates/fetch.ts b/src/shared/src/profiles/templates/fetch.ts new file mode 100644 index 0000000..06dd672 --- /dev/null +++ b/src/shared/src/profiles/templates/fetch.ts @@ -0,0 +1,15 @@ +import type { ProfileTemplate } from '../types.js'; + +export const fetchTemplate: ProfileTemplate = { + id: 'fetch', + name: 'fetch', + displayName: 'Fetch', + description: 'Fetch and convert web pages to markdown for reading and analysis', + category: 'utility', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-fetch'], + requiredEnvVars: [], + optionalEnvVars: [], + setupInstructions: 'No configuration required. Fetches web content and converts HTML to markdown.', + documentationUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/fetch', +}; diff --git a/src/shared/src/profiles/templates/filesystem.ts b/src/shared/src/profiles/templates/filesystem.ts new file mode 100644 index 0000000..1239ca7 --- /dev/null +++ b/src/shared/src/profiles/templates/filesystem.ts @@ -0,0 +1,16 @@ +import type { ProfileTemplate } from '../types.js'; + +export const filesystemTemplate: ProfileTemplate = { + id: 'filesystem', + name: 'filesystem', + displayName: 'Filesystem', + description: 'Provides read/write access to local filesystem directories', + category: 'filesystem', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem'], + requiredEnvVars: [], + optionalEnvVars: [], + setupInstructions: + 'Append allowed directory paths as additional args. Example: npx -y @modelcontextprotocol/server-filesystem /home/user/docs', + documentationUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem', +}; diff --git a/src/shared/src/profiles/templates/github.ts b/src/shared/src/profiles/templates/github.ts new file mode 100644 index 0000000..e591379 --- /dev/null +++ b/src/shared/src/profiles/templates/github.ts @@ -0,0 +1,22 @@ +import type { ProfileTemplate } from '../types.js'; + +export const githubTemplate: ProfileTemplate = { + id: 'github', + name: 'github', + displayName: 'GitHub', + description: 'Interact with GitHub repositories, issues, pull requests, and more', + category: 'integration', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-github'], + requiredEnvVars: [ + { + name: 'GITHUB_PERSONAL_ACCESS_TOKEN', + description: 'GitHub personal access token with repo scope', + isSecret: true, + setupUrl: 'https://github.com/settings/tokens', + }, + ], + optionalEnvVars: [], + setupInstructions: 'Create a personal access token at GitHub Settings > Developer settings > Personal access tokens.', + documentationUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/github', +}; diff --git a/src/shared/src/profiles/templates/index.ts b/src/shared/src/profiles/templates/index.ts new file mode 100644 index 0000000..e952df6 --- /dev/null +++ b/src/shared/src/profiles/templates/index.ts @@ -0,0 +1,6 @@ +export { filesystemTemplate } from './filesystem.js'; +export { githubTemplate } from './github.js'; +export { postgresTemplate } from './postgres.js'; +export { slackTemplate } from './slack.js'; +export { memoryTemplate } from './memory.js'; +export { fetchTemplate } from './fetch.js'; diff --git a/src/shared/src/profiles/templates/memory.ts b/src/shared/src/profiles/templates/memory.ts new file mode 100644 index 0000000..0952ff5 --- /dev/null +++ b/src/shared/src/profiles/templates/memory.ts @@ -0,0 +1,15 @@ +import type { ProfileTemplate } from '../types.js'; + +export const memoryTemplate: ProfileTemplate = { + id: 'memory', + name: 'memory', + displayName: 'Memory', + description: 'Persistent knowledge graph memory for storing and retrieving entities and relations', + category: 'utility', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-memory'], + requiredEnvVars: [], + optionalEnvVars: [], + setupInstructions: 'No configuration required. Memory is stored locally in a JSON knowledge graph file.', + documentationUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/memory', +}; diff --git a/src/shared/src/profiles/templates/postgres.ts b/src/shared/src/profiles/templates/postgres.ts new file mode 100644 index 0000000..09ed2e2 --- /dev/null +++ b/src/shared/src/profiles/templates/postgres.ts @@ -0,0 +1,21 @@ +import type { ProfileTemplate } from '../types.js'; + +export const postgresTemplate: ProfileTemplate = { + id: 'postgres', + name: 'postgres', + displayName: 'PostgreSQL', + description: 'Query and inspect PostgreSQL databases with read-only access', + category: 'database', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-postgres'], + requiredEnvVars: [ + { + name: 'DATABASE_URL', + description: 'PostgreSQL connection string (e.g., postgresql://user:pass@localhost:5432/dbname)', + isSecret: true, + }, + ], + optionalEnvVars: [], + setupInstructions: 'Provide a PostgreSQL connection string. The server provides read-only query access by default.', + documentationUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/postgres', +}; diff --git a/src/shared/src/profiles/templates/slack.ts b/src/shared/src/profiles/templates/slack.ts new file mode 100644 index 0000000..6864ea5 --- /dev/null +++ b/src/shared/src/profiles/templates/slack.ts @@ -0,0 +1,28 @@ +import type { ProfileTemplate } from '../types.js'; + +export const slackTemplate: ProfileTemplate = { + id: 'slack', + name: 'slack', + displayName: 'Slack', + description: 'Read and send Slack messages, manage channels, and search workspace content', + category: 'integration', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-slack'], + requiredEnvVars: [ + { + name: 'SLACK_BOT_TOKEN', + description: 'Slack Bot User OAuth Token (starts with xoxb-)', + isSecret: true, + setupUrl: 'https://api.slack.com/apps', + }, + { + name: 'SLACK_TEAM_ID', + description: 'Slack workspace/team ID', + isSecret: false, + }, + ], + optionalEnvVars: [], + setupInstructions: + 'Create a Slack App at api.slack.com/apps, install it to your workspace, and copy the Bot User OAuth Token.', + documentationUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/slack', +}; diff --git a/src/shared/src/profiles/types.ts b/src/shared/src/profiles/types.ts new file mode 100644 index 0000000..5baa69e --- /dev/null +++ b/src/shared/src/profiles/types.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +export const envTemplateEntrySchema = z.object({ + name: z.string().min(1), + description: z.string(), + isSecret: z.boolean(), + setupUrl: z.string().url().optional(), + defaultValue: z.string().optional(), +}); + +export const profileTemplateSchema = z.object({ + id: z.string().min(1).regex(/^[a-z0-9-]+$/, 'ID must be lowercase alphanumeric with hyphens'), + name: z.string().min(1), + displayName: z.string().min(1), + description: z.string().min(1), + category: z.enum(['filesystem', 'database', 'integration', 'ai', 'utility', 'development']), + command: z.string().min(1), + args: z.array(z.string()), + requiredEnvVars: z.array(envTemplateEntrySchema).default([]), + optionalEnvVars: z.array(envTemplateEntrySchema).default([]), + setupInstructions: z.string().optional(), + documentationUrl: z.string().url().optional(), +}); + +export type ProfileTemplate = z.infer; + +export type ProfileCategory = ProfileTemplate['category']; + +export interface InstantiatedProfile { + name: string; + templateId: string; + command: string; + args: string[]; + env: Record; +} diff --git a/src/shared/src/profiles/utils.ts b/src/shared/src/profiles/utils.ts new file mode 100644 index 0000000..f6f39e1 --- /dev/null +++ b/src/shared/src/profiles/utils.ts @@ -0,0 +1,61 @@ +import { profileTemplateSchema } from './types.js'; +import type { ProfileTemplate, InstantiatedProfile } from './types.js'; + +export function validateTemplate(template: unknown): { success: true; data: ProfileTemplate } | { success: false; errors: string[] } { + const result = profileTemplateSchema.safeParse(template); + if (result.success) { + return { success: true, data: result.data }; + } + return { + success: false, + errors: result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`), + }; +} + +export function getMissingEnvVars(template: ProfileTemplate, envValues: Record): string[] { + return template.requiredEnvVars + .filter((e) => !envValues[e.name] && e.defaultValue === undefined) + .map((e) => e.name); +} + +export function instantiateProfile( + template: ProfileTemplate, + envValues: Record, +): InstantiatedProfile { + const missing = getMissingEnvVars(template, envValues); + if (missing.length > 0) { + throw new Error(`Missing required environment variables: ${missing.join(', ')}`); + } + + const env: Record = {}; + for (const entry of template.requiredEnvVars) { + const value = envValues[entry.name] ?? entry.defaultValue; + if (value !== undefined) { + env[entry.name] = value; + } + } + for (const entry of template.optionalEnvVars) { + const value = envValues[entry.name] ?? entry.defaultValue; + if (value !== undefined) { + env[entry.name] = value; + } + } + + return { + name: template.name, + templateId: template.id, + command: template.command, + args: [...template.args], + env, + }; +} + +export function generateMcpJsonEntry(profile: InstantiatedProfile): Record { + return { + [profile.name]: { + command: profile.command, + args: profile.args, + env: profile.env, + }, + }; +} diff --git a/src/shared/tests/profiles.test.ts b/src/shared/tests/profiles.test.ts new file mode 100644 index 0000000..aeb3a16 --- /dev/null +++ b/src/shared/tests/profiles.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect } from 'vitest'; +import { + ProfileRegistry, + defaultRegistry, + profileTemplateSchema, + validateTemplate, + getMissingEnvVars, + instantiateProfile, + generateMcpJsonEntry, + filesystemTemplate, + githubTemplate, + postgresTemplate, + slackTemplate, + memoryTemplate, + fetchTemplate, +} from '../src/profiles/index.js'; + +const allTemplates = [ + filesystemTemplate, + githubTemplate, + postgresTemplate, + slackTemplate, + memoryTemplate, + fetchTemplate, +]; + +describe('ProfileTemplate schema', () => { + it.each(allTemplates)('validates $id template', (template) => { + const result = profileTemplateSchema.safeParse(template); + expect(result.success).toBe(true); + }); + + it('rejects template with missing required fields', () => { + const result = profileTemplateSchema.safeParse({ id: 'x' }); + expect(result.success).toBe(false); + }); + + it('rejects template with invalid id format', () => { + const result = profileTemplateSchema.safeParse({ + ...filesystemTemplate, + id: 'Invalid ID!', + }); + expect(result.success).toBe(false); + }); + + it('rejects template with invalid category', () => { + const result = profileTemplateSchema.safeParse({ + ...filesystemTemplate, + category: 'nonexistent', + }); + expect(result.success).toBe(false); + }); +}); + +describe('ProfileRegistry', () => { + it('default registry contains all builtin templates', () => { + const ids = defaultRegistry.getAll().map((t) => t.id); + expect(ids).toContain('filesystem'); + expect(ids).toContain('github'); + expect(ids).toContain('postgres'); + expect(ids).toContain('slack'); + expect(ids).toContain('memory'); + expect(ids).toContain('fetch'); + }); + + it('has no duplicate IDs', () => { + const ids = defaultRegistry.getAll().map((t) => t.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('getById returns correct template', () => { + expect(defaultRegistry.getById('github')).toBe(githubTemplate); + expect(defaultRegistry.getById('nonexistent')).toBeUndefined(); + }); + + it('getByCategory filters correctly', () => { + const integrations = defaultRegistry.getByCategory('integration'); + expect(integrations.map((t) => t.id)).toEqual( + expect.arrayContaining(['github', 'slack']), + ); + for (const t of integrations) { + expect(t.category).toBe('integration'); + } + }); + + it('getCategories returns unique categories', () => { + const cats = defaultRegistry.getCategories(); + expect(cats.length).toBeGreaterThan(0); + expect(new Set(cats).size).toBe(cats.length); + }); + + it('search finds by name', () => { + const results = defaultRegistry.search('git'); + expect(results.some((t) => t.id === 'github')).toBe(true); + }); + + it('search finds by description', () => { + const results = defaultRegistry.search('knowledge graph'); + expect(results.some((t) => t.id === 'memory')).toBe(true); + }); + + it('search returns empty for no match', () => { + expect(defaultRegistry.search('zzzznotfound')).toEqual([]); + }); + + it('register adds a custom template', () => { + const registry = new ProfileRegistry([]); + registry.register(filesystemTemplate); + expect(registry.has('filesystem')).toBe(true); + expect(registry.getAll()).toHaveLength(1); + }); +}); + +describe('validateTemplate', () => { + it('returns success for valid template', () => { + const result = validateTemplate(filesystemTemplate); + expect(result.success).toBe(true); + }); + + it('returns errors for invalid template', () => { + const result = validateTemplate({ id: '' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.errors.length).toBeGreaterThan(0); + } + }); +}); + +describe('getMissingEnvVars', () => { + it('returns empty for template with no required env vars', () => { + expect(getMissingEnvVars(filesystemTemplate, {})).toEqual([]); + }); + + it('returns missing vars for github template', () => { + const missing = getMissingEnvVars(githubTemplate, {}); + expect(missing).toContain('GITHUB_PERSONAL_ACCESS_TOKEN'); + }); + + it('returns empty when all vars provided', () => { + const missing = getMissingEnvVars(githubTemplate, { + GITHUB_PERSONAL_ACCESS_TOKEN: 'ghp_xxx', + }); + expect(missing).toEqual([]); + }); +}); + +describe('instantiateProfile', () => { + it('creates profile from template without env vars', () => { + const profile = instantiateProfile(filesystemTemplate, {}); + expect(profile.name).toBe('filesystem'); + expect(profile.templateId).toBe('filesystem'); + expect(profile.command).toBe('npx'); + expect(profile.args).toEqual(['-y', '@modelcontextprotocol/server-filesystem']); + expect(profile.env).toEqual({}); + }); + + it('creates profile with env vars', () => { + const profile = instantiateProfile(githubTemplate, { + GITHUB_PERSONAL_ACCESS_TOKEN: 'ghp_test123', + }); + expect(profile.env.GITHUB_PERSONAL_ACCESS_TOKEN).toBe('ghp_test123'); + }); + + it('throws on missing required env vars', () => { + expect(() => instantiateProfile(githubTemplate, {})).toThrow( + 'Missing required environment variables', + ); + }); + + it('includes optional env vars when provided', () => { + const profile = instantiateProfile(filesystemTemplate, { + SOME_OPTIONAL: 'value', + }); + // Optional vars not in template are not included + expect(profile.env).toEqual({}); + }); +}); + +describe('generateMcpJsonEntry', () => { + it('generates valid .mcp.json entry', () => { + const profile = instantiateProfile(githubTemplate, { + GITHUB_PERSONAL_ACCESS_TOKEN: 'ghp_abc', + }); + const entry = generateMcpJsonEntry(profile); + expect(entry).toEqual({ + github: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-github'], + env: { + GITHUB_PERSONAL_ACCESS_TOKEN: 'ghp_abc', + }, + }, + }); + }); + + it('generates entry with empty env for no-env template', () => { + const profile = instantiateProfile(memoryTemplate, {}); + const entry = generateMcpJsonEntry(profile); + expect(entry).toEqual({ + memory: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-memory'], + env: {}, + }, + }); + }); +});