BMAD-METHOD/bmad-mcp/src/server.ts

424 lines
13 KiB
TypeScript

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