feat: add MCP profiles library with builtin templates
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions

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:
Michal
2026-02-21 05:25:56 +00:00
parent 6161686441
commit d0a224e839
13 changed files with 499 additions and 0 deletions

View File

@@ -2,3 +2,4 @@ export * from './types/index.js';
export * from './validation/index.js'; export * from './validation/index.js';
export * from './constants/index.js'; export * from './constants/index.js';
export * from './utils/index.js'; export * from './utils/index.js';
export * from './profiles/index.js';

View 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';

View 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();

View 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',
};

View 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',
};

View 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',
};

View 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';

View 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',
};

View 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',
};

View 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',
};

View 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>;
}

View 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,
},
};
}

View 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: {},
},
});
});
});