Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"codingbuddy-rules": "workspace:*",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"yaml": "^2.8.2",
"zod": "^4.2.1"
},
"devDependencies": {
Expand Down
120 changes: 119 additions & 1 deletion apps/mcp-server/src/mcp/mcp-serverless.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as path from 'path';
import * as fs from 'fs/promises';
import { McpServerlessService } from './mcp-serverless';

// ============================================================================
Expand Down Expand Up @@ -505,6 +506,121 @@ describe('McpServerlessService', () => {
}
});
});

// ==========================================================================
// Skills Tests
// ==========================================================================

describe('listSkills', () => {
const testSkillsDir = path.join(TEST_RULES_DIR, 'skills', 'test-skill');

afterEach(async () => {
// Cleanup test skill directory
try {
await fs.rm(testSkillsDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});

it('should return all skills with name and description', async () => {
// Create test skill
await fs.mkdir(testSkillsDir, { recursive: true });
await fs.writeFile(
path.join(testSkillsDir, 'SKILL.md'),
`---
name: test-skill
description: A test skill for testing
---

# Test Skill Content

This is test content.
`,
);

const result = await invokeToolHandler(service, 'listSkills');
const data = JSON.parse(result.content[0].text);

expect(Array.isArray(data)).toBe(true);
const testSkill = data.find(
(s: { name: string }) => s.name === 'test-skill',
);
expect(testSkill).toBeDefined();
expect(testSkill.description).toBe('A test skill for testing');
});

it('should return empty array when no skills exist', async () => {
// Use a service with empty skills directory
const emptyService = new McpServerlessService(
'/nonexistent/rules',
TEST_PROJECT_ROOT,
);

const result = await invokeToolHandler(emptyService, 'listSkills');
const data = JSON.parse(result.content[0].text);

expect(Array.isArray(data)).toBe(true);
expect(data.length).toBe(0);
});
});

describe('getSkill', () => {
const testSkillsDir = path.join(TEST_RULES_DIR, 'skills', 'my-skill');

afterEach(async () => {
// Cleanup test skill directory
try {
await fs.rm(testSkillsDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});

it('should return skill content by name', async () => {
// Create test skill
await fs.mkdir(testSkillsDir, { recursive: true });
await fs.writeFile(
path.join(testSkillsDir, 'SKILL.md'),
`---
name: my-skill
description: My skill description
---

# My Skill

Detailed content here.
`,
);

const result = await invokeToolHandler(service, 'getSkill', 'my-skill');
expect(result.isError).toBeUndefined();

const skill = JSON.parse(result.content[0].text);
expect(skill.name).toBe('my-skill');
expect(skill.description).toBe('My skill description');
expect(skill.content).toContain('# My Skill');
});

it('should throw for non-existent skill', async () => {
const result = await invokeToolHandler(
service,
'getSkill',
'non-existent-skill',
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('not found');
});

it('should validate skill name format', async () => {
const result = await invokeToolHandler(
service,
'getSkill',
'Invalid Name!',
);
expect(result.isError).toBe(true);
});
});
});

// ============================================================================
Expand Down Expand Up @@ -534,6 +650,8 @@ async function invokeToolHandler(
parseMode: 'handleParseMode',
getProjectConfig: 'handleGetProjectConfig',
suggestConfigUpdates: 'handleSuggestConfigUpdates',
listSkills: 'handleListSkills',
getSkill: 'handleGetSkill',
};

const methodName = methodMap[handlerName];
Expand Down
119 changes: 119 additions & 0 deletions apps/mcp-server/src/mcp/mcp-serverless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { loadConfig } from '../config/config.loader';
import type { CodingBuddyConfig } from '../config/config.schema';
import { isPathSafe } from '../shared/security.utils';
import { parseAgentProfile, AgentSchemaError } from '../rules/agent.schema';
import { parseSkill, SkillSchemaError } from '../rules/skill.schema';
import type { Skill } from '../rules/skill.schema';
import {
validateQuery,
validatePrompt,
Expand All @@ -36,6 +38,11 @@ interface ParseModeResponse extends ParseModeResult {
language?: string;
}

interface SkillSummary {
name: string;
description: string;
}

// ============================================================================
// Default Configuration
// ============================================================================
Expand Down Expand Up @@ -226,6 +233,34 @@ export class McpServerlessService {
return this.handleSuggestConfigUpdates(projectRoot);
},
);

// list_skills tool
this.server.registerTool(
'list_skills',
{
title: 'List Skills',
description: 'List all available skills with descriptions',
inputSchema: {},
},
async (): Promise<ToolResponse> => {
return this.handleListSkills();
},
);

// get_skill tool
this.server.registerTool(
'get_skill',
{
title: 'Get Skill',
description: 'Get skill content by name',
inputSchema: {
skillName: z.string().describe('Name of the skill'),
},
},
async ({ skillName }): Promise<ToolResponse> => {
return this.handleGetSkill(skillName);
},
);
}

private registerResources(): void {
Expand Down Expand Up @@ -304,6 +339,31 @@ export class McpServerlessService {
}
}

private async handleListSkills(): Promise<ToolResponse> {
try {
const skills = await this.listSkills();
return this.jsonResponse(skills);
} catch (error) {
return this.errorResponse(
`Failed to list skills: ${sanitizeError(error)}`,
);
}
}

private async handleGetSkill(skillName: string): Promise<ToolResponse> {
// Validate skill name
if (!skillName || !/^[a-z0-9-]+$/.test(skillName)) {
return this.errorResponse('Invalid skill name format');
}

try {
const skill = await this.getSkill(skillName);
return this.jsonResponse(skill);
} catch {
return this.errorResponse(`Skill '${skillName}' not found.`);
}
}

private async handleSuggestConfigUpdates(
projectRoot?: string,
): Promise<ToolResponse> {
Expand Down Expand Up @@ -467,6 +527,65 @@ export class McpServerlessService {
return results.sort((a, b) => b.score - a.score);
}

// ============================================================================
// Skills Operations
// ============================================================================

async listSkills(): Promise<SkillSummary[]> {
const skillsDir = path.join(this.rulesDir, 'skills');
const summaries: SkillSummary[] = [];

try {
const entries = await fs.readdir(skillsDir, { withFileTypes: true });

for (const entry of entries) {
if (entry.isDirectory()) {
const skillPath = path.join(skillsDir, entry.name, 'SKILL.md');
try {
const content = await fs.readFile(skillPath, 'utf-8');
const skill = parseSkill(content, `skills/${entry.name}/SKILL.md`);
summaries.push({
name: skill.name,
description: skill.description,
});
} catch {
// Skip invalid skills
}
}
}
} catch {
// Skills directory doesn't exist
}

return summaries;
}

async getSkill(name: string): Promise<Skill> {
// Validate name format
if (!/^[a-z0-9-]+$/.test(name)) {
throw new Error(`Invalid skill name format: ${name}`);
}

const skillPath = `skills/${name}/SKILL.md`;

// Security check
if (!isPathSafe(this.rulesDir, skillPath)) {
throw new Error('Access denied: Invalid path');
}

const fullPath = path.join(this.rulesDir, skillPath);

try {
const content = await fs.readFile(fullPath, 'utf-8');
return parseSkill(content, skillPath);
} catch (error) {
if (error instanceof SkillSchemaError) {
throw new Error(`Invalid skill: ${name}`);
}
throw new Error(`Skill not found: ${name}`);
}
}

// ============================================================================
// Keyword/Mode Operations (extracted from KeywordService)
// ============================================================================
Expand Down
48 changes: 1 addition & 47 deletions apps/mcp-server/src/rules/agent.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,53 +8,7 @@
*/

import * as z from 'zod';

// ============================================================================
// Dangerous Keys (Prototype Pollution Prevention)
// ============================================================================

const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'] as const;

/**
* Recursively check for dangerous keys in an object
* Uses Object.getOwnPropertyNames to also check non-enumerable properties
*/
function containsDangerousKeys(obj: unknown, path = ''): string | null {
if (obj === null || typeof obj !== 'object') {
return null;
}

if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
const result = containsDangerousKeys(obj[i], `${path}[${i}]`);
if (result) return result;
}
return null;
}

// Use Object.getOwnPropertyNames to catch all properties including non-enumerable
// Also check with hasOwnProperty for keys like __proto__ that might be special
const keys = Object.getOwnPropertyNames(obj);

for (const key of keys) {
if (DANGEROUS_KEYS.includes(key as (typeof DANGEROUS_KEYS)[number])) {
return path ? `${path}.${key}` : key;
}
}

// Recursively check nested objects
for (const key of keys) {
if (!DANGEROUS_KEYS.includes(key as (typeof DANGEROUS_KEYS)[number])) {
const result = containsDangerousKeys(
(obj as Record<string, unknown>)[key],
path ? `${path}.${key}` : key,
);
if (result) return result;
}
}

return null;
}
import { containsDangerousKeys } from '../shared/security.utils';

// ============================================================================
// Custom Error
Expand Down
Loading