bc-code-intelligence-mcp
Version:
BC Code Intelligence MCP Server - Complete Specialist Bundle with AI-driven expert consultation, seamless handoffs, and context-preserving workflows
372 lines (369 loc) ⢠18.9 kB
JavaScript
/**
* MCP Tools for BC Specialist Interactions
*
* Provides a focused set of tools for AI-assisted Business Central development
* through personality-driven specialist consultations.
*/
import { BCSpecialistRoleplayEngine } from '../services/roleplay-engine.js';
import { z } from 'zod';
// Input validation schemas
const SuggestSpecialistSchema = z.object({
question: z.string().min(10, 'Question must be at least 10 characters'),
context: z.string().optional().describe('Additional context about the problem')
});
const GetSpecialistAdviceSchema = z.object({
specialist_id: z.string().min(1, 'Specialist ID is required'),
message: z.string().min(1, 'Message is required'),
session_id: z.string().optional().describe('Session ID for multi-turn conversations'),
problem_context: z.string().optional().describe('Overall problem being solved')
});
const ListSpecialistsSchema = z.object({
domain: z.string().optional().describe('Filter by domain (e.g., performance, security)'),
expertise: z.string().optional().describe('Filter by expertise area')
});
export class SpecialistTools {
layerService;
sessionManager;
knowledgeService;
roleplayEngine;
constructor(layerService, sessionManager, knowledgeService) {
this.layerService = layerService;
this.sessionManager = sessionManager;
this.knowledgeService = knowledgeService;
this.roleplayEngine = new BCSpecialistRoleplayEngine(layerService, knowledgeService);
}
/**
* Get all specialist tool definitions for MCP registration
*/
getToolDefinitions() {
return [
{
name: 'suggest_specialist',
description: 'Find the most appropriate BC specialist for your question. IMPORTANT: When user wants to "talk to" or "chat with" a specific specialist, use get_specialist_advice directly instead. This tool is for discovering which specialist to use when unsure.',
inputSchema: {
type: 'object',
properties: {
question: {
type: 'string',
description: 'Your question or problem description',
minLength: 10
},
context: {
type: 'string',
description: 'Additional context about your project or situation (optional)'
}
},
required: ['question']
}
},
{
name: 'get_specialist_advice',
description: `Connect directly with a BC specialist who responds authentically as themselves. CRITICAL FOR AGENTS: Respond AS the specialist, not as an AI describing roleplay. No menus, no explanations - dive straight into the specialist persona and expertise. Perfect for "Talk to Sam", "Chat with Dean", etc.
ā ļø **MANDATORY KNOWLEDGE LOOKUP**: Before generating ANY BC development advice or code, ALWAYS search the knowledge base first using find_bc_knowledge. The knowledge base contains authoritative BC standards, patterns, and conventions that override general AI training. Never rely solely on training data for BC development guidance.
Example: find_bc_knowledge({ query: "AL file naming conventions" }) before writing AL code.
š§ **AL/BC Platform Constraints**: All specialist advice MUST respect Business Central and AL language limitations:
⢠Security specialists: Focus on AL permission objects, user groups, BC security framework - NOT external auth systems
⢠UX specialists: Work within AL page/report constraints - BC controls rendering, NOT custom CSS/HTML
⢠Performance specialists: AL optimization patterns, table design, BC server constraints - NOT generic frameworks
⢠API specialists: BC API pages, web services, AL integration - NOT generic REST frameworks
⢠All specialists: Prioritize AL language capabilities and BC platform limitations over generic programming`,
inputSchema: {
type: 'object',
properties: {
specialist_id: {
type: 'string',
description: 'ID of the specialist (e.g., dean-debug, alex-architect)',
minLength: 1
},
message: {
type: 'string',
description: 'Your question or message to the specialist',
minLength: 1
},
session_id: {
type: 'string',
description: 'Session ID for ongoing conversations (optional - will create new session if not provided)'
},
problem_context: {
type: 'string',
description: 'Overall problem context to help the specialist understand the bigger picture (optional)'
}
},
required: ['specialist_id', 'message']
}
},
{
name: 'list_specialists',
description: 'Discover available BC specialists and their expertise areas. Useful for understanding the team capabilities.',
inputSchema: {
type: 'object',
properties: {
domain: {
type: 'string',
description: 'Filter by domain (e.g., performance, security, api-design) - optional'
},
expertise: {
type: 'string',
description: 'Filter by expertise area (e.g., caching, authentication) - optional'
}
},
required: []
}
}
];
}
/**
* Handle specialist tool calls
*/
async handleToolCall(request) {
try {
switch (request.params.name) {
case 'suggest_specialist':
return await this.handleSuggestSpecialist(request);
case 'get_specialist_advice':
return await this.handleGetSpecialistAdvice(request);
case 'list_specialists':
return await this.handleListSpecialists(request);
default:
return {
content: [{
type: 'text',
text: `Unknown specialist tool: ${request.params.name}`
}],
isError: true
};
}
}
catch (error) {
return {
content: [{
type: 'text',
text: `Error in ${request.params.name}: ${error instanceof Error ? error.message : 'Unknown error'}`
}],
isError: true
};
}
}
/**
* Find the best specialist for a question
*/
async handleSuggestSpecialist(request) {
const validated = SuggestSpecialistSchema.parse(request.params.arguments);
// Create basic session context for suggestion
const sessionContext = validated.context ? {
problem: validated.context,
solutions: [],
recommendations: [],
nextSteps: [],
userPreferences: {}
} : undefined;
const suggestions = await this.roleplayEngine.suggestSpecialist(validated.question, sessionContext);
if (suggestions.length === 0) {
return {
content: [{
type: 'text',
text: 'š¤ No specialists found for this question. Try rephrasing or providing more context about your BC development challenge.'
}]
};
}
// Format suggestions with personality
let response = `šÆ **Specialist Recommendations for your question:**\n\n`;
for (let i = 0; i < suggestions.length; i++) {
const suggestion = suggestions[i];
const specialist = await this.layerService.getSpecialist(suggestion.specialist_id);
if (specialist) {
const confidence = Math.round(suggestion.confidence * 100);
response += `**${i + 1}. ${specialist.title}** (${suggestion.specialist_id}) - ${confidence}% match\n`;
response += `${specialist.persona.greeting}\n`;
response += `š” **Why ${specialist.title}:** ${suggestion.reasoning}\n`;
response += `š§ **Expertise:** ${specialist.expertise.primary.join(', ')}\n\n`;
}
}
response += `š¬ **Next step:** Use \`get_specialist_advice\` with your chosen specialist_id to start the conversation!`;
return {
content: [{ type: 'text', text: response }]
};
}
/**
* Get advice from a specialist with automatic session management
*/
async handleGetSpecialistAdvice(request) {
const validated = GetSpecialistAdviceSchema.parse(request.params.arguments);
// Try exact ID match first
let specialist = await this.layerService.getSpecialist(validated.specialist_id);
// If not found, try fuzzy matching
if (!specialist) {
specialist = await this.findSpecialistByFuzzyName(validated.specialist_id);
}
if (!specialist) {
return {
content: [{
type: 'text',
text: `ā Specialist '${validated.specialist_id}' not found. Tried exact ID match and fuzzy name matching. Use 'list_specialists' to see available experts.`
}],
isError: true
};
}
// Handle session management
let sessionId = validated.session_id;
let session;
if (sessionId) {
// Get existing session with auto-recovery
session = await this.sessionManager.getSession(sessionId);
if (!session) {
// Auto-recover: create new session instead of failing
console.warn(`Session '${sessionId}' not found. Creating recovery session.`);
session = await this.sessionManager.startSession(validated.specialist_id, 'default-user', `Recovery session. Original session ${sessionId} was lost. Context: ${validated.problem_context || validated.message}`);
sessionId = session.sessionId;
}
}
else {
// Create new session
session = await this.sessionManager.startSession(validated.specialist_id, 'default-user', // TODO: Get actual user ID from context
validated.problem_context || validated.message);
sessionId = session.sessionId;
}
// Determine if this is a new session or handoff requiring introduction
const isNewSession = !validated.session_id || session.messages.length === 0;
const isHandoff = validated.session_id && session.context.current_specialist !== validated.specialist_id;
// Generate specialist response using conversation history
const roleplayContext = {
specialist,
userMessage: validated.message,
session: session.context,
conversationHistory: session.messages.map(m => ({
role: m.type === 'user' ? 'user' : 'assistant',
content: m.content,
timestamp: m.timestamp
})),
requiresIntroduction: isNewSession || isHandoff
};
const response = await this.roleplayEngine.generateResponse(roleplayContext);
// Add user and specialist messages to session
await this.sessionManager.continueSession(sessionId, validated.message);
// Update session with specialist response - we'll add this via continueSession flow
const updatedSession = await this.sessionManager.getSession(sessionId);
// Update context if provided
if (response.context_updates && updatedSession) {
await this.sessionManager.updateContext(sessionId, response.context_updates);
}
// Return agent roleplay instructions (NOT formatted user response)
let agentInstructions = '';
// **CRITICAL**: Include full specialist markdown content as instructions
agentInstructions += `SPECIALIST DEFINITION AND INSTRUCTIONS:\n\n`;
agentInstructions += specialist.content; // Full markdown content including Phase 0, workflows, etc.
agentInstructions += `\n\n${'='.repeat(80)}\n\n`;
agentInstructions += `CURRENT CONTEXT:\n\n`;
agentInstructions += response.content;
// Add knowledge context for the agent
if (response.topics_referenced.length > 0) {
agentInstructions += `\n\nKNOWLEDGE CONTEXT FOR RESPONSE:\n`;
agentInstructions += `- Referenced Topics: ${response.topics_referenced.join(', ')}\n`;
agentInstructions += `- Use this knowledge to provide specific, accurate guidance\n`;
}
// Add handoff guidance for the agent
if (response.suggested_handoffs && response.suggested_handoffs.length > 0) {
agentInstructions += `\n\nHANDOFF GUIDANCE:\n`;
for (const handoff of response.suggested_handoffs) {
const handoffSpecialist = await this.layerService.getSpecialist(handoff.specialist_id);
if (handoffSpecialist) {
agentInstructions += `- If user needs ${handoff.reason}, suggest consulting ${handoffSpecialist.title}\n`;
}
}
}
agentInstructions += `\n\nRemember: You ARE ${specialist.title}. Respond directly as this character, not as an AI assistant.`;
// Add recommendations as agent guidance
if (response.recommendations_added && response.recommendations_added.length > 0) {
agentInstructions += `\n\nRECOMMENDATIONS TO INCLUDE:\n`;
response.recommendations_added.forEach((rec, i) => {
agentInstructions += `${i + 1}. ${rec}\n`;
});
}
return {
content: [{ type: 'text', text: agentInstructions }]
};
}
/**
* List available specialists with filtering
*/
async handleListSpecialists(request) {
const validated = ListSpecialistsSchema.parse(request.params.arguments || {});
const specialists = await this.layerService.getAllSpecialists();
// Apply filters
let filteredSpecialists = specialists;
if (validated.domain) {
filteredSpecialists = specialists.filter(s => s.domains.some(d => d.toLowerCase().includes(validated.domain.toLowerCase())));
}
if (validated.expertise) {
filteredSpecialists = filteredSpecialists.filter(s => [...s.expertise.primary, ...s.expertise.secondary].some(e => e.toLowerCase().includes(validated.expertise.toLowerCase())));
}
if (filteredSpecialists.length === 0) {
return {
content: [{
type: 'text',
text: 'ā No specialists found matching your criteria. Try different filters or remove them to see all specialists.'
}]
};
}
// Group by domain for better organization
const specialistsByDomain = new Map();
filteredSpecialists.forEach(specialist => {
specialist.domains.forEach(domain => {
if (!specialistsByDomain.has(domain)) {
specialistsByDomain.set(domain, []);
}
if (!specialistsByDomain.get(domain).includes(specialist)) {
specialistsByDomain.get(domain).push(specialist);
}
});
});
let response = `š„ **BC Code Intelligence Specialists** ${validated.domain || validated.expertise ? '(filtered)' : ''}\n\n`;
// Show specialists organized by domain
for (const [domain, domainSpecialists] of specialistsByDomain.entries()) {
response += `## š·ļø ${domain.charAt(0).toUpperCase() + domain.slice(1)}\n\n`;
for (const specialist of domainSpecialists) {
response += `**${specialist.title}** (\`${specialist.specialist_id}\`)\n`;
response += `š¬ ${specialist.persona.greeting}\n`;
response += `šÆ **Primary Expertise:** ${specialist.expertise.primary.join(', ')}\n`;
if (specialist.expertise.secondary.length > 0) {
response += `š§ **Also helps with:** ${specialist.expertise.secondary.slice(0, 3).join(', ')}\n`;
}
response += `\n`;
}
}
response += `\nš” **Getting Started:**\n`;
response += `⢠Use \`suggest_specialist\` with your question to get personalized recommendations\n`;
response += `⢠Use \`get_specialist_advice\` with a specialist_id to start a conversation\n`;
response += `⢠Sessions are automatically managed for multi-turn conversations`;
return {
content: [{ type: 'text', text: response }]
};
}
/**
* Find specialist by partial/fuzzy name matching
* Handles cases like "Sam" -> "sam-coder", "Dean" -> "dean-debug", etc.
*/
async findSpecialistByFuzzyName(partialName) {
const allSpecialists = await this.layerService.getAllSpecialists();
const searchTerm = partialName.toLowerCase().trim();
// First try exact specialist_id match (case insensitive)
let match = allSpecialists.find(specialist => specialist.specialist_id.toLowerCase() === searchTerm);
if (match)
return match;
// Try partial match in specialist_id
match = allSpecialists.find(specialist => specialist.specialist_id.toLowerCase().includes(searchTerm));
if (match)
return match;
// Try matching first part of specialist_id (before the dash)
match = allSpecialists.find(specialist => {
const firstName = specialist.specialist_id.split('-')[0].toLowerCase();
return firstName === searchTerm;
});
if (match)
return match;
// Try matching in title
match = allSpecialists.find(specialist => specialist.title?.toLowerCase().includes(searchTerm));
return match || null;
}
}
//# sourceMappingURL=specialist-tools.js.map