add bmad-mcp package

This commit is contained in:
Fabian Ehrentraud 2025-07-02 20:54:10 +02:00
parent ffae072143
commit 216f80af32
21 changed files with 6435 additions and 0 deletions

33
bmad-mcp/.gitignore vendored Normal file
View File

@ -0,0 +1,33 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
coverage

23
bmad-mcp/.prettierrc Normal file
View File

@ -0,0 +1,23 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false,
"quoteProps": "as-needed",
"trailingComma": "es5",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"proseWrap": "preserve",
"endOfLine": "lf",
"overrides": [
{
"files": "*.md",
"options": {
"proseWrap": "preserve",
"printWidth": 120
}
}
]
}

42
bmad-mcp/README.md Normal file
View File

@ -0,0 +1,42 @@
# BMAD-MCP
A Model Context Protocol (MCP) server that provides prompts and resources to support AI agents in development using the BMAD (Breakthrough Method of Agile AI-Driven Development) methodology.
## Installation
Installation depends on your AI agent. For example in VSCode, you can install the npm package directly via action `MCP: Add Server...``NPM Package``@bmad/mcp-server`. Alternatively, you can install it as `stdio` MCP server that runs the command `npx @bmad/mcp-server`.
## Usage
How to execute MCP prompts of this MCP server depends on your AI agent. For example in VSCode Copilot, you can show all available actions by typing `/` into the chat message field.
## Development
### Setup
```bash
# Clone the repository
git clone <repository-url>
cd BMAD-METHOD/bmad-mcp
# Install dependencies
npm install
# Build the project
npm run build
# Run tests
npm run test
# Start the MCP Inspector
npm run inspector
```
### Install local build as MCP server
To install the local build during development, you can add `node bmad-mcp/dist/index.js` as MCP server. For example in VSCode, this can be done with action `MCP: Add Server...``Command (stdio)``node bmad-mcp/dist/index.js` (might need absolute path when not installing in workspace).
## Requirements
- Node.js 18+
- Compatible with MCP specification version `2025-03-26`

4232
bmad-mcp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
bmad-mcp/package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "@bmad/mcp-server",
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"bmad-mcp": "./dist/index.js"
},
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"dev": "vite build --watch",
"build": "vite build",
"start": "node dist/index.js",
"test": "vitest --run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"type-check": "tsc --noEmit",
"inspector": "npx @modelcontextprotocol/inspector node dist/index.js"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.10.1",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"typescript": "^5.8.3",
"vite": "^7.0.0",
"vite-plugin-dts": "^4.5.4"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.13.1",
"js-yaml": "^4.1.0",
"vitest": "^3.2.4"
}
}

6
bmad-mcp/src/index.ts Normal file
View File

@ -0,0 +1,6 @@
#!/usr/bin/env node
import { BmadMcpServer } from "./server.js";
const server = new BmadMcpServer();
server.start().catch(console.error);

View File

@ -0,0 +1,227 @@
import { promises as fs } from "fs";
import { join, resolve, relative, extname, basename, dirname } from "path";
import { fileURLToPath } from "url";
import type {
BaseResource,
ResourceProvider as IResourceProvider,
ResourceConfig,
} from "../types/index.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export class ResourceProvider<T extends BaseResource> implements IResourceProvider<T> {
protected cache = new Map<string, T>();
protected metadataCache: T[] | null = null;
protected readonly resourceDir: string;
protected readonly directoryName: string;
protected supportedExtensions: string[];
protected config: ResourceConfig;
constructor(config: ResourceConfig, baseDir?: string) {
this.config = config;
this.directoryName = config.directory;
this.supportedExtensions = config.supportedExtensions;
if (baseDir) {
this.resourceDir = resolve(baseDir, `bmad-core/${this.directoryName}`);
} else {
this.resourceDir = resolve(__dirname, `bmad-core/${this.directoryName}`);
}
}
/**
* Discover all resources by scanning the directory recursively
*/
async discover(): Promise<T[]> {
if (this.metadataCache) {
return this.metadataCache;
}
try {
const resources = await this.scanResourceDirectory(this.resourceDir);
this.metadataCache = resources;
return resources;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
console.warn(`${this.directoryName} directory not found: ${this.resourceDir}`);
return [];
}
throw new Error(`Failed to discover ${this.directoryName}: ${(error as Error).message}`);
}
}
/**
* Get a specific resource by ID, loading content if not cached
*/
async get(id: string): Promise<T> {
// Check cache first
if (this.cache.has(id)) {
return this.cache.get(id)!;
}
// Discover resources if not already cached
const resources = await this.discover();
const resource = resources.find((r) => r.id === id);
if (!resource) {
throw new Error(
`${
this.directoryName.slice(0, -1).charAt(0).toUpperCase() + this.directoryName.slice(1, -1)
} with ID '${id}' not found`
);
}
// Load content
const resourceWithContent = await this.loadResourceContent(resource);
// Cache it
this.cache.set(id, resourceWithContent);
return resourceWithContent;
}
/**
* Discover all resources by scanning the directory recursively
*/
async discoverResources(): Promise<T[]> {
return await this.discover();
}
/**
* Get a specific resource by ID, loading content if not cached
*/
async getResource(id: string): Promise<T> {
return await this.get(id);
}
/**
* Get all resources with their content loaded
*/
async getAllResources(): Promise<T[]> {
const resources = await this.discoverResources();
const resourcesWithContent: T[] = [];
for (const resource of resources) {
try {
const resourceWithContent = await this.getResource(resource.id);
resourcesWithContent.push(resourceWithContent);
} catch (error) {
console.error(`Failed to load resource ${resource.id}:`, error);
// Include resource without content rather than failing completely
resourcesWithContent.push(resource);
}
}
return resourcesWithContent;
}
/**
* Scan directory recursively for resources
*/
private async scanResourceDirectory(dir: string): Promise<T[]> {
const resources: T[] = [];
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
// Recursively scan subdirectories
const subResources = await this.scanResourceDirectory(fullPath);
resources.push(...subResources);
} else if (entry.isFile()) {
// Process files
const resource = await this.createResourceFromFile(fullPath);
if (resource) {
resources.push(resource);
}
}
}
} catch (error) {
console.error(`Error scanning directory ${dir}:`, error);
throw error;
}
return resources;
}
/**
* Create a resource metadata object from a file path
*/
private async createResourceFromFile(filePath: string): Promise<T | null> {
try {
const extension = extname(filePath).slice(1); // Remove the dot
const name = basename(filePath, `.${extension}`);
// Check if this file type should be processed
if (!this.isValidFile(filePath)) {
return null;
}
// Create relative path from the base directory
const relativePath = relative(this.resourceDir, filePath);
const id = relativePath.replace(/\\/g, "/").replace(`.${extension}`, "").replace(/\//g, "_");
const baseResource: BaseResource = {
id,
name,
path: filePath,
extension,
};
return this.enhanceResource(baseResource);
} catch (error) {
console.error(`Error creating resource from file ${filePath}:`, error);
return null;
}
}
/**
* Load content for a resource
*/
private async loadResourceContent(resource: T): Promise<T> {
try {
const content = await fs.readFile(resource.path, "utf-8");
return {
...resource,
content,
};
} catch (error) {
console.error(`Error loading content for resource ${resource.id}:`, error);
throw new Error(
`Failed to load ${this.directoryName.slice(0, -1)} '${resource.id}': ${
(error as Error).message
}`
);
}
}
/**
* Clear caches
*/
clearCache(): void {
this.cache.clear();
this.metadataCache = null;
}
/**
* Enhance the base resource using configuration
*/
protected enhanceResource(baseResource: BaseResource): T {
if (this.config.enhanceResource) {
return this.config.enhanceResource(baseResource) as T;
}
return baseResource as T;
}
/**
* Check if file is a supported format
*/
protected isValidFile(filePath: string): boolean {
const ext = extname(filePath).toLowerCase();
return this.supportedExtensions.includes(ext);
}
}

423
bmad-mcp/src/server.ts Normal file
View File

@ -0,0 +1,423 @@
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { ResourceProvider } from "./providers/resourceProvider.js";
import type {
ReadResourceResult,
ListResourcesResult,
Resource,
} from "@modelcontextprotocol/sdk/types.js";
import type { Variables } from "@modelcontextprotocol/sdk/shared/uriTemplate.js";
import type { BaseResource, PromptConfig, ResourceConfig } from "./types/index.js";
import { load } from "js-yaml";
export class BmadMcpServer {
private server: McpServer;
private transport: StdioServerTransport;
private resourceProviders: Map<string, ResourceProvider<BaseResource>>;
private readonly agentsResourceConfig: ResourceConfig = {
resourceType: "Agent",
uriScheme: "bmad://agents",
directory: "agents",
nameSuffix: "",
description: "Individual BMAD agent content",
supportedExtensions: [".md"],
};
// TODO some bmad files contain instructions that are only relevant when using bmad-core as files and not as MCP resources, e.g. `IDE-FILE-RESOLUTION` in agent files - either change this in BMAD, or do some file content replacement here when serving resources
private readonly resourceConfigs: ResourceConfig[] = [
this.agentsResourceConfig,
{
resourceType: "Checklist",
uriScheme: "bmad://checklists",
directory: "checklists",
nameSuffix: "-checklist",
description: "Individual BMAD checklist content",
supportedExtensions: [".md"],
},
{
resourceType: "Data",
uriScheme: "bmad://data",
directory: "data",
nameSuffix: "",
description: "Individual BMAD data content",
supportedExtensions: [".md"],
},
{
resourceType: "Task",
uriScheme: "bmad://tasks",
directory: "tasks",
nameSuffix: "",
description: "Individual BMAD task content",
supportedExtensions: [".md"],
},
{
resourceType: "Template",
uriScheme: "bmad://templates",
directory: "templates",
nameSuffix: "-tmpl",
description: "Individual BMAD template content",
supportedExtensions: [".md"],
},
{
resourceType: "Util",
uriScheme: "bmad://utils",
directory: "utils",
nameSuffix: "",
description: "Individual BMAD util content",
supportedExtensions: [".md"],
},
{
resourceType: "Workflow",
uriScheme: "bmad://workflows",
directory: "workflows",
nameSuffix: "",
description: "Individual BMAD workflow content",
supportedExtensions: [".yml"],
},
];
private readonly promptConfigs: PromptConfig[] = [
{
referencedResourceConfig: this.agentsResourceConfig,
parseDependencies: true,
},
];
constructor() {
this.server = new McpServer(
{
name: "bmad-mcp",
version: "0.1.0",
description: "BMAD Method MCP Server",
},
{
capabilities: {
logging: {},
prompts: {},
resources: {},
},
instructions: `This MCP server provides prompts and resources to provide an Agile Development Framework. Call the bmad-orchestrator prompt to get started, or directly use the other prompts for PRD, Architecture, and Story creation and development.`,
}
);
this.transport = new StdioServerTransport(process.stdin, process.stdout);
this.resourceProviders = new Map();
for (const config of this.resourceConfigs) {
this.resourceProviders.set(config.resourceType, new ResourceProvider(config));
}
}
private setupResources() {
for (const config of this.resourceConfigs) {
this.setupResourceType(config);
}
}
private setupResourceType(config: ResourceConfig) {
const provider = this.resourceProviders.get(config.resourceType);
if (!provider) {
throw new Error(`Provider for ${config.resourceType} not found`);
}
this.server.resource(
`BMAD ${config.resourceType}`,
new ResourceTemplate(`${config.uriScheme}/{${config.directory.slice(0, -1)}Id}`, {
list: async () => {
return await this.listIndividualResources(config, provider);
},
complete: {
[`${config.directory.slice(0, -1)}Id`]: async (value: string) => {
return await this.completeResourceId(value, config, provider);
},
},
}),
{
description: config.description,
},
async (uri: URL, variables: Variables) => {
return await this.readIndividualResource(uri, variables, config, provider);
}
);
}
private async listIndividualResources(
config: ResourceConfig,
provider: ResourceProvider<BaseResource>
): Promise<ListResourcesResult> {
try {
const resources = await provider.discover();
const resourceList = resources.map((resource) => ({
uri: `${config.uriScheme}/${resource.id}`,
name: resource.name,
title: `${config.resourceType} ${resource.name.replace(config.nameSuffix, "")}`,
description:
resource.description || `BMAD ${config.resourceType.toLowerCase()}: ${resource.name}`,
mimeType: this.getMimeTypeForExtension(resource.extension),
}));
return { resources: resourceList };
} catch (error) {
console.error(
`Error listing individual ${config.resourceType.toLowerCase()} resources:`,
error
);
throw new Error(
`Failed to list ${config.resourceType.toLowerCase()} resources: ${(error as Error).message}`
);
}
}
private async readIndividualResource(
uri: URL,
variables: Variables,
config: ResourceConfig,
provider: ResourceProvider<BaseResource>
): Promise<ReadResourceResult> {
try {
const resourceIdKey = `${config.directory.slice(0, -1)}Id`;
const resourceIdRaw = variables[resourceIdKey];
if (!resourceIdRaw) {
throw new Error(`${config.resourceType} ID is required in URI`);
}
// Handle the case where resourceId might be an array
const resourceId = Array.isArray(resourceIdRaw) ? resourceIdRaw[0] : resourceIdRaw;
if (!resourceId) {
throw new Error(`${config.resourceType} ID is required in URI`);
}
// Get resource content
const resource = await provider.get(resourceId);
const mimeType = this.getMimeTypeForExtension(resource.extension);
return {
contents: [
{
uri: uri.toString(),
mimeType,
text: resource.content || "",
},
],
};
} catch (error) {
console.error(
`Error reading individual ${config.resourceType.toLowerCase()} resource:`,
error
);
throw new Error(
`Failed to read ${config.resourceType.toLowerCase()} resource: ${(error as Error).message}`
);
}
}
private async completeResourceId(
value: string,
config: ResourceConfig,
provider: ResourceProvider<BaseResource>
): Promise<string[]> {
try {
const resources = await provider.discover();
// Filter resources that start with the provided value
const matchingIds = resources
.map((resource) => resource.id)
.filter((id) => id.toLowerCase().startsWith(value.toLowerCase()))
.sort();
return matchingIds;
} catch (error) {
console.error(`Error completing ${config.resourceType.toLowerCase()} resource IDs:`, error);
// Return empty array on error rather than throwing
return [];
}
}
getMimeTypeForExtension(extension: string): string {
const mimeTypes: { [key: string]: string } = {
md: "text/markdown",
txt: "text/plain",
json: "application/json",
yml: "application/x-yaml",
yaml: "application/x-yaml",
};
return mimeTypes[extension] || "text/plain";
}
private async setupPrompts() {
for (const config of this.promptConfigs) {
await this.setupPrompt(config);
}
}
private async setupPrompt(config: PromptConfig) {
const provider = this.resourceProviders.get(config.referencedResourceConfig.resourceType);
if (!provider) {
throw new Error(`Provider for ${config.referencedResourceConfig.resourceType} not found`);
}
const resourceList = await this.listIndividualResources(
config.referencedResourceConfig,
provider
);
for (const resource of resourceList.resources) {
const resourceContent = await provider.get(resource.name);
let dependencyResources: Resource[] = [];
if (config.parseDependencies && resourceContent.content) {
const parsedDependencies = this.getDependenciesFromYamlEmbeddedInMarkdown(
resourceContent.content,
resource.name
);
dependencyResources = await this.getDependencyResources(parsedDependencies);
}
this.server.prompt(`BMAD ${resource.name}`, async () => {
try {
return {
description: `BMAD ${resource.name}`,
messages: [
// TODO also add separate prompts for each task of the agent, so they can be triggered directly - in that case it would be great to directly embed resources instead of resource_links to save requests (relevant for ai agents like copilot which deduct premium requests and not token count)
{
role: "user",
content: {
type: "text",
text: `Please use the ${resource.name} resource to assist with your task.`,
},
} as const,
{
role: "user",
content: {
type: "resource",
resource: {
uri: resource.uri,
mimeType: resource.mimeType,
text: resourceContent.content ?? "",
},
},
} as const,
...dependencyResources.map(
(depResource) =>
({
role: "user",
content: {
type: "resource_link",
name: depResource.name,
uri: depResource.uri,
mimeType: depResource.mimeType,
},
} as const)
),
],
};
} catch (error) {
console.error(`Error loading prompt resource ${resource.id}:`, error);
throw new Error(
`Failed to load ${config.referencedResourceConfig.resourceType.toLowerCase()} prompt: ${
(error as Error).message
}`
);
}
});
}
}
private getDependenciesFromYamlEmbeddedInMarkdown(
content: string,
fileName: string
): Record<string, string[]> {
try {
// Look for YAML block in markdown - it should be between ```yaml and ```
const yamlMatch = content.match(/```y(a)?ml([\s\S]*)```/);
if (!yamlMatch) {
console.warn(`No YAML block found in file ${fileName}`);
return {};
}
const yamlContent = yamlMatch[2];
const yamlData = load(yamlContent);
const dependencies = (yamlData as any)?.dependencies || null;
return dependencies;
} catch (error) {
console.error(`Error parsing YAML front matter for file ${fileName}:`, error);
return {};
}
}
private async getDependencyResources(
dependencies: Record<string, string[]>
): Promise<Resource[]> {
const dependencyResources: Resource[] = [];
for (const [directory, dependencyList] of Object.entries(dependencies)) {
// Find the matching resource config for this dependency type
const config = this.resourceConfigs.find(
(c) => c.directory.toLowerCase() === directory.toLowerCase()
);
if (!config) {
console.warn(`No resource config found for dependency type: ${directory}`);
continue;
}
const provider = this.resourceProviders.get(config.resourceType);
if (!provider) {
console.warn(`No provider found for resource type: ${config.resourceType}`);
continue;
}
// Get all resources for this type
const resourceList = await this.listIndividualResources(config, provider);
// Filter resources that match the dependency names
const matchingResources = resourceList.resources.filter((resource) =>
dependencyList.some(
(dep) =>
resource.name.toLowerCase().includes(dep.toLowerCase()) ||
resource.title?.toLowerCase().includes(dep.toLowerCase())
)
);
dependencyResources.push(...matchingResources);
}
return dependencyResources;
}
async start() {
console.error("Starting BMAD-MCP Server...");
this.setupResources();
await this.setupPrompts();
try {
await this.server.connect(this.transport);
console.error("BMAD-MCP Server started successfully on stdio transport");
// Handle graceful shutdown
process.on("SIGINT", () => this.shutdown());
process.on("SIGTERM", () => this.shutdown());
} catch (error) {
console.error("Failed to start BMAD-MCP Server:", error);
process.exit(1);
}
}
private async shutdown() {
console.error("Shutting down BMAD-MCP Server...");
try {
await this.server.close();
console.error("BMAD-MCP Server shut down gracefully");
process.exit(0);
} catch (error) {
console.error("Error during shutdown:", error);
process.exit(1);
}
}
}

View File

@ -0,0 +1,49 @@
// Shared types for the project
export interface ServerInfo {
name: string;
version: string;
description: string;
}
export interface ServerConfiguration {
name: string;
version: string;
description?: string;
}
export interface ServerCapabilities {
// Empty for minimal implementation
}
// Generic resource types
export interface BaseResource {
id: string;
name: string;
path: string;
extension: string;
content?: string;
description?: string;
}
export interface ResourceProvider<T extends BaseResource> {
discover(): Promise<T[]>;
get(id: string): Promise<T>;
}
export interface ResourceConfig {
resourceType: string;
uriScheme: string;
directory: string;
nameSuffix: string;
description: string;
supportedExtensions: string[];
enhanceResource?: (baseResource: BaseResource) => BaseResource;
}
export interface PromptConfig {
referencedResourceConfig: ResourceConfig;
parseDependencies?: boolean;
}
export * from "@modelcontextprotocol/sdk/types.js";

1
bmad-mcp/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,185 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { promises as fs } from "fs";
import { join } from "path";
import { ResourceProvider } from "../../src/providers/resourceProvider.js";
import type { BaseResource, ResourceConfig } from "../../src/types/index.js";
// Mock fs module
vi.mock("fs", () => ({
promises: {
readdir: vi.fn(),
readFile: vi.fn(),
},
}));
const mockFs = vi.mocked(fs);
interface TestResource extends BaseResource {
testProperty?: string;
}
class TestResourceProvider extends ResourceProvider<TestResource> {
constructor(config: ResourceConfig, baseDir?: string) {
super(config, baseDir);
}
protected enhanceResource(baseResource: BaseResource): TestResource {
return {
...baseResource,
testProperty: "enhanced",
};
}
}
describe("ResourceProvider", () => {
let provider: TestResourceProvider;
const mockBaseDir = "/test/project";
const resourcesDir = join(mockBaseDir, "bmad-core/test-resources");
const testConfig: ResourceConfig = {
resourceType: "TestResource",
uriScheme: "test://resources",
directory: "test-resources",
nameSuffix: "-test",
description: "Test resources",
supportedExtensions: [".md", ".txt"],
};
beforeEach(() => {
provider = new TestResourceProvider(testConfig, mockBaseDir);
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
describe("discoverResources", () => {
it("should return empty array when resources directory does not exist", async () => {
mockFs.readdir.mockRejectedValue({ code: "ENOENT" });
const resources = await provider.discoverResources();
expect(resources).toEqual([]);
expect(mockFs.readdir).toHaveBeenCalledWith(resourcesDir, { withFileTypes: true });
});
it("should discover resource files with supported extensions", async () => {
const mockDirents = [
{ name: "resource1.md", isDirectory: () => false, isFile: () => true },
{ name: "resource2.txt", isDirectory: () => false, isFile: () => true },
{ name: "ignored.json", isDirectory: () => false, isFile: () => true }, // Should be ignored
{ name: "resource3.md", isDirectory: () => false, isFile: () => true },
];
mockFs.readdir.mockResolvedValue(mockDirents as any);
const resources = await provider.discoverResources();
expect(resources).toHaveLength(3);
expect(resources[0]).toEqual({
id: "resource1",
name: "resource1",
path: join(resourcesDir, "resource1.md"),
extension: "md",
testProperty: "enhanced",
});
expect(resources[1]).toEqual({
id: "resource2",
name: "resource2",
path: join(resourcesDir, "resource2.txt"),
extension: "txt",
testProperty: "enhanced",
});
});
});
describe("getResource", () => {
beforeEach(() => {
const mockDirents = [
{ name: "test-resource.md", isDirectory: () => false, isFile: () => true },
];
mockFs.readdir.mockResolvedValue(mockDirents as any);
});
it("should load resource content", async () => {
const mockContent = "# Test Resource\n\nThis is test content.";
mockFs.readFile.mockResolvedValue(mockContent);
const resource = await provider.getResource("test-resource");
expect(resource.name).toBe("test-resource");
expect(resource.content).toBe(mockContent);
expect(resource.testProperty).toBe("enhanced");
});
it("should cache loaded resources", async () => {
mockFs.readFile.mockResolvedValue("Content");
// First call
await provider.getResource("test-resource");
// Second call
await provider.getResource("test-resource");
expect(mockFs.readFile).toHaveBeenCalledTimes(1);
});
});
describe("getAllResources", () => {
beforeEach(() => {
const mockDirents = [
{ name: "resource1.md", isDirectory: () => false, isFile: () => true },
{ name: "resource2.md", isDirectory: () => false, isFile: () => true },
];
mockFs.readdir.mockResolvedValue(mockDirents as any);
});
it("should return all resources with content loaded", async () => {
mockFs.readFile.mockResolvedValueOnce("Content 1").mockResolvedValueOnce("Content 2");
const resources = await provider.getAllResources();
expect(resources).toHaveLength(2);
expect(resources[0].content).toBe("Content 1");
expect(resources[1].content).toBe("Content 2");
});
it("should include resources without content when file read fails", async () => {
mockFs.readFile
.mockResolvedValueOnce("Content 1")
.mockRejectedValueOnce(new Error("File read error"));
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const resources = await provider.getAllResources();
expect(resources).toHaveLength(2);
expect(resources[0].content).toBe("Content 1");
expect(resources[1].content).toBeUndefined();
expect(consoleSpy).toHaveBeenCalledWith(
"Failed to load resource resource2:",
expect.any(Error)
);
consoleSpy.mockRestore();
});
});
describe("supported extensions", () => {
it("should only discover files with configured extensions", async () => {
const mockDirents = [
{ name: "file.md", isDirectory: () => false, isFile: () => true },
{ name: "file.txt", isDirectory: () => false, isFile: () => true },
{ name: "file.json", isDirectory: () => false, isFile: () => true }, // Not in configured extensions
{ name: "file.doc", isDirectory: () => false, isFile: () => true }, // Not supported
];
mockFs.readdir.mockResolvedValue(mockDirents as any);
const resources = await provider.discoverResources();
expect(resources).toHaveLength(2);
expect(resources.map((r) => r.extension)).toEqual(["md", "txt"]);
});
});
});

View File

@ -0,0 +1,217 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { promises as fs } from "fs";
import { join } from "path";
import { BmadMcpServer } from "../src/server.js";
import { ResourceProvider } from "../src/providers/resourceProvider.js";
import type { BaseResource } from "../src/types/index.js";
describe("BmadMcpServer - Generic Resources Integration", () => {
let server: BmadMcpServer;
let tempDir: string;
let checklistsDir: string;
let templatesDir: string;
beforeEach(async () => {
// Create temporary directory structure
tempDir = await fs.mkdtemp("/tmp/bmad-test-");
checklistsDir = join(tempDir, "bmad-core", "checklists");
templatesDir = join(tempDir, "bmad-core", "templates");
await fs.mkdir(checklistsDir, { recursive: true });
await fs.mkdir(templatesDir, { recursive: true });
// Create test checklist files
await fs.writeFile(
join(checklistsDir, "pm-checklist.md"),
`# PM Checklist
Product Manager requirements checklist for ensuring comprehensive project definition.
- [ ] Define user stories
- [ ] Create acceptance criteria
- [ ] Validate business requirements`
);
await fs.writeFile(
join(checklistsDir, "story-dod-checklist.md"),
`# Story Definition of Done
Checklist to ensure stories meet quality standards before marking as complete.
- [ ] All acceptance criteria met
- [ ] Code reviewed
- [ ] Tests written and passing`
);
// Create test template files
await fs.writeFile(
join(templatesDir, "prd-tmpl.md"),
`# Product Requirements Document Template
## Overview
[Brief description of the product]
## Requirements
[Detailed requirements here]`
);
// Create category subdirectory for checklists
const devDir = join(checklistsDir, "dev");
await fs.mkdir(devDir, { recursive: true });
await fs.writeFile(
join(devDir, "code-review.md"),
`# Code Review Checklist
Developer checklist for thorough code reviews.
- [ ] Code follows standards
- [ ] Security considerations reviewed
- [ ] Performance implications considered`
);
// Initialize server with temp directory
server = new BmadMcpServer();
const { ResourceProvider } = await import("../src/providers/resourceProvider.js");
// Create configurations for testing
const checklistConfig = {
resourceType: "Checklist",
uriScheme: "bmad://checklists",
directory: "checklists",
nameSuffix: "-checklist",
description: "Individual BMAD checklist content",
supportedExtensions: [".md"],
};
const templateConfig = {
resourceType: "Template",
uriScheme: "bmad://templates",
directory: "templates",
nameSuffix: "-tmpl",
description: "Individual BMAD template content",
supportedExtensions: [".md"],
};
// Override the resource providers to use our test directory
(server as any).resourceProviders.set(
"Checklist",
new ResourceProvider(checklistConfig, tempDir)
);
(server as any).resourceProviders.set(
"Template",
new ResourceProvider(templateConfig, tempDir)
);
});
afterEach(async () => {
// Clean up temp directory
await fs.rm(tempDir, { recursive: true, force: true });
});
describe("Checklist Resources", () => {
it("should discover and list all checklist resources", async () => {
const checklistConfig = (server as any).resourceConfigs.find(
(config: any) => config.resourceType === "Checklist"
);
const provider = (server as any).resourceProviders.get("Checklist");
const result = await (server as any).listIndividualResources(checklistConfig, provider);
expect(result.resources).toHaveLength(3);
const checklistUris = result.resources.map((r: any) => r.uri);
expect(checklistUris).toContain("bmad://checklists/pm-checklist");
expect(checklistUris).toContain("bmad://checklists/story-dod-checklist");
expect(checklistUris).toContain("bmad://checklists/dev_code-review");
// Verify metadata
const pmChecklist = result.resources.find(
(r: any) => r.uri === "bmad://checklists/pm-checklist"
);
expect(pmChecklist).toMatchObject({
name: "pm-checklist",
title: "Checklist pm",
description: "BMAD checklist: pm-checklist",
mimeType: "text/markdown",
});
});
it("should retrieve individual checklist content", async () => {
const uri = new URL("bmad://checklists/pm-checklist");
const variables = { checklistId: "pm-checklist" };
const checklistConfig = (server as any).resourceConfigs.find(
(config: any) => config.resourceType === "Checklist"
);
const provider = (server as any).resourceProviders.get("Checklist");
const result = await (server as any).readIndividualResource(
uri,
variables,
checklistConfig,
provider
);
expect(result.contents).toHaveLength(1);
expect(result.contents[0]).toMatchObject({
uri: "bmad://checklists/pm-checklist",
mimeType: "text/markdown",
});
const content = result.contents[0].text;
expect(content).toContain("# PM Checklist");
expect(content).toContain("Product Manager requirements checklist");
expect(content).toContain("- [ ] Define user stories");
});
});
describe("Template Resources", () => {
it("should discover and list all template resources", async () => {
const templateConfig = (server as any).resourceConfigs.find(
(config: any) => config.resourceType === "Template"
);
const provider = (server as any).resourceProviders.get("Template");
const result = await (server as any).listIndividualResources(templateConfig, provider);
expect(result.resources).toHaveLength(1);
const templateUris = result.resources.map((r: any) => r.uri);
expect(templateUris).toContain("bmad://templates/prd-tmpl");
// Verify metadata
const prdTemplate = result.resources.find((r: any) => r.uri === "bmad://templates/prd-tmpl");
expect(prdTemplate).toMatchObject({
name: "prd-tmpl",
title: "Template prd",
description: "BMAD template: prd-tmpl",
mimeType: "text/markdown",
});
});
it("should retrieve individual template content", async () => {
const uri = new URL("bmad://templates/prd-tmpl");
const variables = { templateId: "prd-tmpl" };
const templateConfig = (server as any).resourceConfigs.find(
(config: any) => config.resourceType === "Template"
);
const provider = (server as any).resourceProviders.get("Template");
const result = await (server as any).readIndividualResource(
uri,
variables,
templateConfig,
provider
);
expect(result.contents).toHaveLength(1);
expect(result.contents[0]).toMatchObject({
uri: "bmad://templates/prd-tmpl",
mimeType: "text/markdown",
});
const content = result.contents[0].text;
expect(content).toContain("# Product Requirements Document Template");
expect(content).toContain("## Overview");
expect(content).toContain("## Requirements");
});
});
});

View File

@ -0,0 +1,350 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { BmadMcpServer } from "../src/server.js";
import { ResourceProvider } from "../src/providers/resourceProvider.js";
import type { BaseResource } from "../src/types/index.js";
// Mock the stdio transport to avoid actual process interaction during tests
vi.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({
StdioServerTransport: vi.fn().mockImplementation(() => ({
start: vi.fn(),
on: vi.fn(),
close: vi.fn(),
})),
}));
// Mock the MCP server with prompt functionality
const mockPromptFn = vi.fn();
const mockServerInstance = {
connect: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
resource: vi.fn(),
prompt: mockPromptFn,
};
vi.mock("@modelcontextprotocol/sdk/server/mcp.js", () => ({
McpServer: vi.fn().mockImplementation(() => mockServerInstance),
ResourceTemplate: vi.fn().mockImplementation((pattern, callbacks) => ({
uriTemplate: pattern,
listCallback: callbacks.list,
completeCallback: callbacks.complete,
})),
}));
// Mock the resource provider
vi.mock("../src/providers/resourceProvider.js", () => ({
ResourceProvider: vi.fn().mockImplementation(() => ({
discover: vi.fn(),
get: vi.fn(),
})),
}));
const mockResourceProvider = vi.mocked(ResourceProvider);
describe("BmadMcpServer - MCP Prompts", () => {
let server: BmadMcpServer;
let mockProviderInstance: any;
let consoleErrorSpy: any;
const mockAgents: BaseResource[] = [
{
id: "bmad-orchestrator",
name: "BMAD Orchestrator",
path: "/test/.bmad-core/agents/bmad-orchestrator.md",
extension: "md",
content: "# BMAD Orchestrator\nThis is the main orchestrator agent.",
},
{
id: "prd-agent",
name: "PRD Agent",
path: "/test/.bmad-core/agents/prd-agent.md",
extension: "md",
content: "# PRD Agent\nThis agent helps create product requirements documents.",
},
{
id: "story-agent",
name: "Story Agent",
path: "/test/.bmad-core/agents/story-agent.md",
extension: "md",
content: "# Story Agent\nThis agent helps create user stories.",
},
];
beforeEach(() => {
// Create mock provider instance
mockProviderInstance = {
discover: vi.fn(),
get: vi.fn(),
};
mockResourceProvider.mockImplementation(() => mockProviderInstance);
server = new BmadMcpServer();
// Override the server instance with our mock after construction
(server as any).server = mockServerInstance;
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
vi.clearAllMocks();
});
describe("Prompt Setup", () => {
beforeEach(() => {
mockProviderInstance.discover.mockResolvedValue(mockAgents);
mockAgents.forEach((agent) => {
mockProviderInstance.get.mockImplementation((name: string) => {
const found = mockAgents.find((a) => a.name === name);
return Promise.resolve(found || null);
});
});
});
it("should setup prompts during server start", async () => {
// Mock the server methods to avoid actual connection
vi.spyOn(server as any, "setupResources").mockImplementation(() => {});
mockServerInstance.connect = vi.fn().mockResolvedValue(undefined);
await server.start();
// Verify that prompts were registered for each agent
expect(mockPromptFn).toHaveBeenCalledTimes(mockAgents.length);
// Verify prompt names
mockAgents.forEach((agent) => {
expect(mockPromptFn).toHaveBeenCalledWith(`BMAD ${agent.name}`, expect.any(Function));
});
});
it("should create proper prompt structure with resource content", async () => {
// Access the setupPrompt method directly for testing
const setupPrompt = (server as any).setupPrompt.bind(server);
const agentConfig = (server as any).agentsResourceConfig;
await setupPrompt({ referencedResourceConfig: agentConfig });
// Verify prompt was called with correct structure
expect(mockPromptFn).toHaveBeenCalledTimes(mockAgents.length);
// Get the prompt handler function for the first agent
const promptCalls = mockPromptFn.mock.calls;
const firstPromptHandler = promptCalls[0][1];
// Execute the prompt handler to test its structure
const promptResult = await firstPromptHandler();
expect(promptResult).toEqual({
description: `BMAD ${mockAgents[0].name}`,
messages: [
{
role: "user",
content: {
type: "text",
text: `Please use the ${mockAgents[0].name} resource to assist with your task.`,
},
},
{
role: "user",
content: {
type: "resource",
resource: {
uri: `bmad://agents/${mockAgents[0].id}`,
mimeType: "text/markdown",
text: mockAgents[0].content,
},
},
},
],
});
});
it("should handle empty content in resources", async () => {
const agentWithNoContent = {
...mockAgents[0],
content: undefined,
};
mockProviderInstance.discover.mockResolvedValue([agentWithNoContent]);
mockProviderInstance.get.mockResolvedValue(agentWithNoContent);
const setupPrompt = (server as any).setupPrompt.bind(server);
const agentConfig = (server as any).agentsResourceConfig;
await setupPrompt({ referencedResourceConfig: agentConfig });
const promptCalls = mockPromptFn.mock.calls;
const promptHandler = promptCalls[0][1];
const promptResult = await promptHandler();
expect(promptResult.messages[1].content.resource.text).toBe("");
});
it("should use correct MIME types for different extensions", async () => {
const yamlAgent = {
...mockAgents[0],
extension: "yml",
};
mockProviderInstance.discover.mockResolvedValue([yamlAgent]);
mockProviderInstance.get.mockResolvedValue(yamlAgent);
const setupPrompt = (server as any).setupPrompt.bind(server);
const agentConfig = (server as any).agentsResourceConfig;
await setupPrompt({ referencedResourceConfig: agentConfig });
const promptCalls = mockPromptFn.mock.calls;
const promptHandler = promptCalls[0][1];
const promptResult = await promptHandler();
expect(promptResult.messages[1].content.resource.mimeType).toBe("application/x-yaml");
});
});
describe("Error Handling", () => {
it("should throw error when provider is not found", async () => {
const invalidConfig = {
referencedResourceConfig: {
resourceType: "NonExistentType",
uriScheme: "bmad://invalid",
directory: "invalid",
nameSuffix: "",
description: "Invalid type",
supportedExtensions: [".md"],
},
};
const setupPrompt = (server as any).setupPrompt.bind(server);
await expect(setupPrompt(invalidConfig)).rejects.toThrow(
"Provider for NonExistentType not found"
);
});
it("should handle resource discovery errors", async () => {
mockProviderInstance.discover.mockRejectedValue(new Error("Discovery failed"));
const setupPrompt = (server as any).setupPrompt.bind(server);
const agentConfig = (server as any).agentsResourceConfig;
await expect(setupPrompt({ referencedResourceConfig: agentConfig })).rejects.toThrow(
"Failed to list agent resources: Discovery failed"
);
});
it("should handle resource loading errors in prompt execution", async () => {
// This test should actually test setup errors, since resource loading happens during setup
mockProviderInstance.discover.mockResolvedValue(mockAgents);
mockProviderInstance.get.mockRejectedValue(new Error("Resource not found"));
const setupPrompt = (server as any).setupPrompt.bind(server);
const agentConfig = (server as any).agentsResourceConfig;
// The error should happen during setup, not prompt execution
await expect(setupPrompt({ referencedResourceConfig: agentConfig })).rejects.toThrow(
"Resource not found"
);
});
it("should handle errors in prompt execution gracefully", async () => {
const agentWithBadContent = {
...mockAgents[0],
id: "bad-agent",
content: "# Bad Agent\nThis agent has content.",
};
mockProviderInstance.discover.mockResolvedValue([agentWithBadContent]);
mockProviderInstance.get.mockResolvedValue(agentWithBadContent);
const setupPrompt = (server as any).setupPrompt.bind(server);
const agentConfig = (server as any).agentsResourceConfig;
await setupPrompt({ referencedResourceConfig: agentConfig });
expect(mockPromptFn).toHaveBeenCalledWith(
`BMAD ${agentWithBadContent.name}`,
expect.any(Function)
);
// Get the registered prompt handler and verify it works
const promptCalls = mockPromptFn.mock.calls;
const promptHandler = promptCalls[0][1];
const result = await promptHandler();
expect(result.description).toBe(`BMAD ${agentWithBadContent.name}`);
expect(result.messages[1].content.resource.text).toBe(agentWithBadContent.content);
});
});
describe("Prompt Configuration", () => {
it("should have correct prompt configurations defined", () => {
const promptConfigs = (server as any).promptConfigs;
expect(promptConfigs).toHaveLength(1);
expect(promptConfigs[0].referencedResourceConfig.resourceType).toBe("Agent");
expect(promptConfigs[0].referencedResourceConfig.uriScheme).toBe("bmad://agents");
});
it("should setup prompts for all configured prompt types", async () => {
mockProviderInstance.discover.mockResolvedValue(mockAgents);
mockAgents.forEach((agent) => {
mockProviderInstance.get.mockImplementation((name: string) => {
const found = mockAgents.find((a) => a.name === name);
return Promise.resolve(found || null);
});
});
const setupPrompts = (server as any).setupPrompts.bind(server);
await setupPrompts();
// Should call setupPrompt once for each prompt config
expect(mockPromptFn).toHaveBeenCalledTimes(mockAgents.length);
});
});
describe("Resource Integration", () => {
it("should correctly map resource properties to prompt content", async () => {
const testAgent = mockAgents[0];
mockProviderInstance.discover.mockResolvedValue([testAgent]);
mockProviderInstance.get.mockResolvedValue(testAgent);
const setupPrompt = (server as any).setupPrompt.bind(server);
const agentConfig = (server as any).agentsResourceConfig;
await setupPrompt({ referencedResourceConfig: agentConfig });
const promptCalls = mockPromptFn.mock.calls;
const promptHandler = promptCalls[0][1];
const promptResult = await promptHandler();
expect(promptResult.messages[1].content.resource).toEqual({
uri: "bmad://agents/bmad-orchestrator",
mimeType: "text/markdown",
text: testAgent.content,
});
});
it("should handle multiple agents correctly", async () => {
mockProviderInstance.discover.mockResolvedValue(mockAgents);
mockAgents.forEach((agent) => {
mockProviderInstance.get.mockImplementation((name: string) => {
const found = mockAgents.find((a) => a.name === name);
return Promise.resolve(found || null);
});
});
const setupPrompt = (server as any).setupPrompt.bind(server);
const agentConfig = (server as any).agentsResourceConfig;
await setupPrompt({ referencedResourceConfig: agentConfig });
expect(mockPromptFn).toHaveBeenCalledTimes(mockAgents.length);
// Verify each agent got its own prompt
const promptNames = mockPromptFn.mock.calls.map((call) => call[0]);
expect(promptNames).toEqual(["BMAD BMAD Orchestrator", "BMAD PRD Agent", "BMAD Story Agent"]);
});
});
});

View File

@ -0,0 +1,81 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { BmadMcpServer } from "../src/server.js";
import { ResourceProvider } from "../src/providers/resourceProvider.js";
import type { BaseResource } from "../src/types/index.js";
// Mock the stdio transport to avoid actual process interaction during tests
vi.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({
StdioServerTransport: vi.fn().mockImplementation(() => ({
start: vi.fn(),
on: vi.fn(),
close: vi.fn(),
})),
}));
// Mock the unified resource provider
vi.mock("../src/providers/resourceProvider.js", () => ({
ResourceProvider: vi.fn(),
}));
const mockResourceProvider = vi.mocked(ResourceProvider);
describe("BmadMcpServer - Unified Resources", () => {
let server: BmadMcpServer;
let consoleErrorSpy: any;
const mockResources: BaseResource[] = [
{
id: "prd-tmpl",
name: "PRD Template",
path: "/test/bmad-core/templates/prd-tmpl.md",
extension: "md",
},
{
id: "architecture-tmpl",
name: "Architecture Template",
path: "/test/bmad-core/templates/architecture-tmpl.md",
extension: "md",
},
];
beforeEach(() => {
vi.clearAllMocks();
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
// Mock the constructor to return a basic mock
mockResourceProvider.mockImplementation(
() =>
({
discover: vi.fn().mockResolvedValue(mockResources),
get: vi.fn().mockResolvedValue(mockResources[0]),
} as any)
);
server = new BmadMcpServer();
});
afterEach(() => {
consoleErrorSpy.mockRestore();
vi.clearAllMocks();
});
describe("Server Construction", () => {
it("should initialize server with unified resource providers", () => {
expect(server).toBeDefined();
expect(server).toBeInstanceOf(BmadMcpServer);
// Verify ResourceProvider was called for each resource type (Agent, Checklist, Data, Task, Template, Util, Workflow)
expect(mockResourceProvider).toHaveBeenCalledTimes(7);
});
});
describe("MIME Type Detection", () => {
it("should return correct MIME types for different extensions", () => {
const mimeTypeMethod = (server as any).getMimeTypeForExtension.bind(server);
expect(mimeTypeMethod("md")).toBe("text/markdown");
expect(mimeTypeMethod("txt")).toBe("text/plain");
expect(mimeTypeMethod("json")).toBe("application/json");
expect(mimeTypeMethod("unknown")).toBe("text/plain");
});
});
});

View File

@ -0,0 +1,168 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { BmadMcpServer } from "../src/server.js";
import { ResourceProvider } from "../src/providers/resourceProvider.js";
import type { BaseResource } from "../src/types/index.js";
// Mock the stdio transport to avoid actual process interaction during tests
vi.mock("@modelcontextprotocol/sdk/server/stdio.js", () => ({
StdioServerTransport: vi.fn().mockImplementation(() => ({
start: vi.fn(),
on: vi.fn(),
close: vi.fn(),
})),
}));
// Mock the unified resource provider
vi.mock("../src/providers/resourceProvider.js", () => ({
ResourceProvider: vi.fn().mockImplementation(() => ({
discover: vi.fn(),
get: vi.fn(),
discoverResources: vi.fn(),
getResource: vi.fn(),
getAllResources: vi.fn(),
clearCache: vi.fn(),
})),
}));
const mockResourceProvider = vi.mocked(ResourceProvider);
describe("BmadMcpServer - MCP Resources", () => {
let server: BmadMcpServer;
let mockProviderInstance: any;
let consoleErrorSpy: any;
const mockTemplates: BaseResource[] = [
{
id: "prd-tmpl",
name: "PRD Template",
path: "/test/bmad-core/templates/prd-tmpl.md",
extension: "md",
},
{
id: "architecture-tmpl",
name: "Architecture Template",
path: "/test/bmad-core/templates/architecture-tmpl.md",
extension: "md",
},
{
id: "story-tmpl",
name: "Story Template",
path: "/test/bmad-core/templates/story-tmpl.txt",
extension: "txt",
},
];
beforeEach(() => {
// Create mock provider instance
mockProviderInstance = {
discover: vi.fn(),
get: vi.fn(),
discoverResources: vi.fn(),
getResource: vi.fn(),
getAllResources: vi.fn(),
clearCache: vi.fn(),
};
mockResourceProvider.mockImplementation(() => mockProviderInstance);
server = new BmadMcpServer();
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
vi.clearAllMocks();
});
describe("Template Resources Registration", () => {
it("should register resources during server construction", () => {
// Verify server was created (constructor ran successfully)
expect(server).toBeDefined();
expect(server).toBeInstanceOf(BmadMcpServer);
});
it("should have template provider initialized", () => {
expect(mockResourceProvider).toHaveBeenCalledTimes(7); // Agent, Checklist, Data, Task, Template, Util, Workflow
});
});
describe("Individual Template Resources", () => {
beforeEach(() => {
mockProviderInstance.discover.mockResolvedValue(mockTemplates);
});
it("should list individual template resources", async () => {
// Test the provider directly since the server methods are now private
const templates = await (server as any).resourceProviders.get("Template").discover();
expect(templates).toEqual(mockTemplates);
expect(mockProviderInstance.discover).toHaveBeenCalledTimes(1);
});
it("should read individual template content", async () => {
const mockTemplateWithContent = {
...mockTemplates[0],
content: "# PRD Template\nThis is a product requirements document template.",
};
mockProviderInstance.get.mockResolvedValue(mockTemplateWithContent);
// Test the provider directly
const result = await (server as any).resourceProviders.get("Template").get("prd-tmpl");
expect(result).toEqual(mockTemplateWithContent);
expect(mockProviderInstance.get).toHaveBeenCalledWith("prd-tmpl");
});
it("should handle array templateId variables", async () => {
const mockTemplateWithContent = {
...mockTemplates[0],
content: "# PRD Template\nThis is a product requirements document template.",
};
mockProviderInstance.get.mockResolvedValue(mockTemplateWithContent);
// Test that provider handles single ID correctly
const result = await (server as any).resourceProviders.get("Template").get("prd-tmpl");
expect(result).toEqual(mockTemplateWithContent);
expect(mockProviderInstance.get).toHaveBeenCalledWith("prd-tmpl");
});
it("should handle missing templateId", async () => {
// Test provider error handling for missing ID
mockProviderInstance.get.mockRejectedValue(new Error("Template with ID '' not found"));
await expect((server as any).resourceProviders.get("Template").get("")).rejects.toThrow();
});
it("should handle template loading errors", async () => {
mockProviderInstance.get.mockRejectedValue(new Error("Template not found"));
await expect(
(server as any).resourceProviders.get("Template").get("missing")
).rejects.toThrow("Template not found");
});
});
describe("MIME Type Detection", () => {
it("should return correct MIME types for different extensions", () => {
const getMimeType = server.getMimeTypeForExtension.bind(server);
expect(getMimeType("md")).toBe("text/markdown");
expect(getMimeType("txt")).toBe("text/plain");
expect(getMimeType("json")).toBe("application/json");
expect(getMimeType("unknown")).toBe("text/plain");
});
});
describe("Error Handling", () => {
it("should handle template discovery errors in listing", async () => {
mockProviderInstance.discover.mockRejectedValue(new Error("Discovery failed"));
await expect((server as any).resourceProviders.get("Template").discover()).rejects.toThrow(
"Discovery failed"
);
});
});
});

View File

@ -0,0 +1,109 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { promises as fs } from "fs";
import { join } from "path";
import { BmadMcpServer } from "../src/server.js";
import { ResourceProvider } from "../src/providers/resourceProvider.js";
describe("BmadMcpServer - Unified Resources Integration", () => {
let server: BmadMcpServer;
let tempDir: string;
let checklistsDir: string;
let templatesDir: string;
beforeEach(async () => {
// Create temporary directory structure
tempDir = await fs.mkdtemp("/tmp/bmad-test-");
checklistsDir = join(tempDir, "bmad-core", "checklists");
templatesDir = join(tempDir, "bmad-core", "templates");
await fs.mkdir(checklistsDir, { recursive: true });
await fs.mkdir(templatesDir, { recursive: true });
// Create test checklist files
await fs.writeFile(
join(checklistsDir, "pm-checklist.md"),
`# PM Checklist
Product Manager requirements checklist for ensuring comprehensive project definition.
- [ ] Define user stories
- [ ] Create acceptance criteria
- [ ] Validate business requirements`
);
// Create test template files
await fs.writeFile(
join(templatesDir, "prd-tmpl.md"),
`# Product Requirements Document Template
## Overview
[Brief description of the product]
## Requirements
[Detailed requirements here]`
);
// Initialize server
server = new BmadMcpServer();
});
afterEach(async () => {
// Clean up temp directory
await fs.rm(tempDir, { recursive: true, force: true });
});
describe("Resource Provider Integration", () => {
it("should create ResourceProvider instances for each resource type", () => {
expect(server).toBeDefined();
expect(server).toBeInstanceOf(BmadMcpServer);
// Verify server was constructed successfully
const resourceProviders = (server as any).resourceProviders;
expect(resourceProviders).toBeDefined();
expect(resourceProviders.has("Template")).toBe(true);
expect(resourceProviders.has("Checklist")).toBe(true);
expect(resourceProviders.has("Agent")).toBe(true);
expect(resourceProviders.has("Data")).toBe(true);
expect(resourceProviders.has("Task")).toBe(true);
expect(resourceProviders.has("Util")).toBe(true);
expect(resourceProviders.has("Workflow")).toBe(true);
});
it("should have resource configurations with proper settings", () => {
const resourceConfigs = (server as any).resourceConfigs;
expect(resourceConfigs).toHaveLength(7); // Agent, Checklist, Data, Task, Template, Util, Workflow
const templateConfig = resourceConfigs.find((c: any) => c.resourceType === "Template");
expect(templateConfig).toBeDefined();
expect(templateConfig.supportedExtensions).toEqual([".md"]);
expect(templateConfig.directory).toBe("templates");
const checklistConfig = resourceConfigs.find((c: any) => c.resourceType === "Checklist");
expect(checklistConfig).toBeDefined();
expect(checklistConfig.supportedExtensions).toEqual([".md"]);
expect(checklistConfig.directory).toBe("checklists");
// Verify some of the other resource types are present
const agentConfig = resourceConfigs.find((c: any) => c.resourceType === "Agent");
expect(agentConfig).toBeDefined();
expect(agentConfig.supportedExtensions).toEqual([".md"]);
expect(agentConfig.directory).toBe("agents");
const workflowConfig = resourceConfigs.find((c: any) => c.resourceType === "Workflow");
expect(workflowConfig).toBeDefined();
expect(workflowConfig.supportedExtensions).toEqual([".yml"]);
expect(workflowConfig.directory).toBe("workflows");
});
});
describe("MIME Type Handling", () => {
it("should correctly determine MIME types", () => {
const getMimeType = (server as any).getMimeTypeForExtension.bind(server);
expect(getMimeType("md")).toBe("text/markdown");
expect(getMimeType("txt")).toBe("text/plain");
expect(getMimeType("json")).toBe("application/json");
expect(getMimeType("unknown")).toBe("text/plain");
});
});
});

View File

@ -0,0 +1,57 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { BmadMcpServer } from '../src/server.js';
// Mock the stdio transport to avoid actual process interaction during tests
vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
StdioServerTransport: vi.fn().mockImplementation(() => ({
start: vi.fn(),
on: vi.fn(),
close: vi.fn(),
}))
}));
// Mock the MCP server
vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({
McpServer: vi.fn().mockImplementation((config) => ({
connect: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
resource: vi.fn().mockReturnValue({ enable: vi.fn(), disable: vi.fn() }),
config
})),
ResourceTemplate: vi.fn().mockImplementation((pattern, callbacks) => ({
uriTemplate: pattern,
listCallback: callbacks.list,
completeCallback: vi.fn()
}))
}));
// Mock the template provider
vi.mock('../src/providers/templateProvider.js', () => ({
TemplateProvider: vi.fn().mockImplementation(() => ({
discoverTemplates: vi.fn().mockResolvedValue([]),
getTemplate: vi.fn(),
getAllTemplates: vi.fn(),
clearCache: vi.fn()
}))
}));
describe('BmadMcpServer', () => {
let consoleErrorSpy: any;
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
vi.clearAllMocks();
});
describe('constructor', () => {
it('should create server instance with correct configuration', () => {
const server = new BmadMcpServer();
expect(server).toBeDefined();
expect(server).toBeInstanceOf(BmadMcpServer);
});
});
});

64
bmad-mcp/tests/setup.ts Normal file
View File

@ -0,0 +1,64 @@
import { vi, beforeEach, afterEach } from 'vitest';
// Global test setup and configuration
beforeEach(() => {
// Clear all mocks before each test
vi.clearAllMocks();
});
afterEach(() => {
// Clean up after each test
vi.restoreAllMocks();
});
// Configure test timeout globally
export const TEST_TIMEOUT = 10000;
// Mock utilities for MCP transport
export const createMockTransport = () => ({
start: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
send: vi.fn().mockResolvedValue(undefined),
onMessage: vi.fn(),
onClose: vi.fn(),
onError: vi.fn()
});
// Mock utilities for MCP server
export const createMockMcpServer = (config?: any) => ({
connect: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
setRequestHandler: vi.fn(),
setNotificationHandler: vi.fn(),
config: config || {
name: 'test-server',
version: '0.1.0'
}
});
// Suppress console outputs during tests unless needed
const originalConsole = global.console;
global.console = {
...originalConsole,
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
info: vi.fn(),
debug: vi.fn()
};
// Restore console for specific tests that need it
export const restoreConsole = () => {
global.console = originalConsole;
};
export const mockConsole = () => {
global.console = {
...originalConsole,
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
info: vi.fn(),
debug: vi.fn()
};
};

27
bmad-mcp/tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022"],
"skipLibCheck": true,
"outDir": "dist",
/* Bundler mode */
"moduleResolution": "node",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "tests"]
}

57
bmad-mcp/vite.config.ts Normal file
View File

@ -0,0 +1,57 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
import dts from 'vite-plugin-dts';
import { cpSync } from 'fs';
export default defineConfig({
plugins: [
dts({
insertTypesEntry: true,
rollupTypes: true
}),
{
name: 'copy-bmad-core',
writeBundle() {
cpSync('../bmad-core', 'dist/bmad-core', { recursive: true });
}
}
],
build: {
target: 'node18',
outDir: 'dist',
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'BmadMcp',
fileName: 'index',
formats: ['es']
},
minify: false,
sourcemap: true,
rollupOptions: {
external: [
'@modelcontextprotocol/sdk',
'node:fs',
'node:path',
'node:url',
'fs',
'path',
'url'
],
output: {
preserveModules: false,
globals: {
'@modelcontextprotocol/sdk': 'McpSdk'
}
}
},
emptyOutDir: true
},
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
define: {
global: 'globalThis'
}
});

39
bmad-mcp/vitest.config.ts Normal file
View File

@ -0,0 +1,39 @@
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
export default defineConfig({
test: {
environment: 'node',
globals: true,
include: ['tests/**/*.test.ts', '**/*.spec.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'dist/',
'coverage/',
'*.config.ts',
'*.config.js',
'src/vite-env.d.ts',
'src/providers/',
'src/types/index.ts',
'src/index.ts'
],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80
}
},
setupFiles: ['./tests/setup.ts'],
testTimeout: 10000,
hookTimeout: 10000
},
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
}
});