feat: add MCP profiles library with builtin templates
Pre-configured profile templates for filesystem, GitHub, PostgreSQL, Slack, memory, and fetch MCP servers. Includes registry, validation, instantiation utilities, and .mcp.json generation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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';
|
||||
|
||||
5
src/shared/src/profiles/index.ts
Normal file
5
src/shared/src/profiles/index.ts
Normal file
@@ -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';
|
||||
67
src/shared/src/profiles/registry.ts
Normal file
67
src/shared/src/profiles/registry.ts
Normal file
@@ -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<string, ProfileTemplate>();
|
||||
|
||||
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<ProfileCategory>();
|
||||
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();
|
||||
15
src/shared/src/profiles/templates/fetch.ts
Normal file
15
src/shared/src/profiles/templates/fetch.ts
Normal file
@@ -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',
|
||||
};
|
||||
16
src/shared/src/profiles/templates/filesystem.ts
Normal file
16
src/shared/src/profiles/templates/filesystem.ts
Normal file
@@ -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',
|
||||
};
|
||||
22
src/shared/src/profiles/templates/github.ts
Normal file
22
src/shared/src/profiles/templates/github.ts
Normal file
@@ -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',
|
||||
};
|
||||
6
src/shared/src/profiles/templates/index.ts
Normal file
6
src/shared/src/profiles/templates/index.ts
Normal file
@@ -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';
|
||||
15
src/shared/src/profiles/templates/memory.ts
Normal file
15
src/shared/src/profiles/templates/memory.ts
Normal file
@@ -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',
|
||||
};
|
||||
21
src/shared/src/profiles/templates/postgres.ts
Normal file
21
src/shared/src/profiles/templates/postgres.ts
Normal file
@@ -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',
|
||||
};
|
||||
28
src/shared/src/profiles/templates/slack.ts
Normal file
28
src/shared/src/profiles/templates/slack.ts
Normal file
@@ -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',
|
||||
};
|
||||
35
src/shared/src/profiles/types.ts
Normal file
35
src/shared/src/profiles/types.ts
Normal file
@@ -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<typeof profileTemplateSchema>;
|
||||
|
||||
export type ProfileCategory = ProfileTemplate['category'];
|
||||
|
||||
export interface InstantiatedProfile {
|
||||
name: string;
|
||||
templateId: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
env: Record<string, string>;
|
||||
}
|
||||
61
src/shared/src/profiles/utils.ts
Normal file
61
src/shared/src/profiles/utils.ts
Normal file
@@ -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, string>): string[] {
|
||||
return template.requiredEnvVars
|
||||
.filter((e) => !envValues[e.name] && e.defaultValue === undefined)
|
||||
.map((e) => e.name);
|
||||
}
|
||||
|
||||
export function instantiateProfile(
|
||||
template: ProfileTemplate,
|
||||
envValues: Record<string, string>,
|
||||
): InstantiatedProfile {
|
||||
const missing = getMissingEnvVars(template, envValues);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
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<string, unknown> {
|
||||
return {
|
||||
[profile.name]: {
|
||||
command: profile.command,
|
||||
args: profile.args,
|
||||
env: profile.env,
|
||||
},
|
||||
};
|
||||
}
|
||||
207
src/shared/tests/profiles.test.ts
Normal file
207
src/shared/tests/profiles.test.ts
Normal file
@@ -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: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user